大家好!今天是宠物服务平台实战的第 16 天,我们聚焦订单提交核心功能 ------ 这是连接 "购物车结算" 与 "支付流程" 的关键环节。不同于单纯的单表插入,订单提交需处理多表联动 (订单主表 + 明细表 + 购物车 + 商品库存)、事务一致性 (确保步骤要么全成、要么全败)和唯一订单号生成,同时前端需实现地址动态联动,让用户操作更流畅。
即使是新手,也能通过本文的 "分步代码 + 实战细节" 掌握:MyBatis-Plus 事务管理、Hutool 订单号生成、Uni-App 地址选择器联动,这些都是电商项目的必备技能,学会后可直接复用到其他商城类项目中!
一、前置准备
开始编码前,需确保环境与数据满足以下条件,避免开发中卡壳:
准备项 | 具体要求 | 注意事项 |
---|---|---|
数据库表结构 | 需创建 3 张核心表,字段如下:1. orders(订单主表):id(主键)、order_no(订单号,唯一)、user_id(用户 ID)、address_id(地址 ID)、total_amount(总金额)、order_status(状态:0 - 待付款 / 1 - 待发货等)、create_time(创建时间)2. order_item(订单明细表):id(主键)、order_no(关联订单号)、goods_id(商品 ID)、goods_name(商品名)、goods_price(下单单价)、quantity(数量)3. address(用户地址表):id(主键)、user_id(用户 ID)、receiver(收货人)、phone(手机号)、full_address(详细地址)、is_default(是否默认:0 - 否 / 1 - 是) | 若表缺失,执行下方建表 SQL:sql-- 订单主表CREATE TABLE orders (id BIGINT PRIMARY KEY AUTO_INCREMENT,order_no VARCHAR(50) NOT NULL COMMENT '唯一订单号',user_id BIGINT NOT NULL COMMENT '用户ID',address_id BIGINT NOT NULL COMMENT '收货地址ID',total_amount DECIMAL(10,2) NOT NULL COMMENT '总金额',order_status TINYINT DEFAULT 0 COMMENT '0-待付款/1-待发货/2-已完成',create_time DATETIME DEFAULT CURRENT_TIMESTAMP,UNIQUE KEY uk_order_no (order_no));-- 订单明细表CREATE TABLE order_item (id BIGINT PRIMARY KEY AUTO_INCREMENT,order_no VARCHAR(50) NOT NULL COMMENT '关联订单号',goods_id BIGINT NOT NULL COMMENT '商品ID',goods_name VARCHAR(100) NOT NULL COMMENT '商品名称',goods_price DECIMAL(10,2) NOT NULL COMMENT '下单单价',quantity INT NOT NULL COMMENT '购买数量',FOREIGN KEY (order_no) REFERENCES orders(order_no));-- 用户地址表CREATE TABLE address (id BIGINT PRIMARY KEY AUTO_INCREMENT,user_id BIGINT NOT NULL COMMENT '用户ID',receiver VARCHAR(20) NOT NULL COMMENT '收货人',phone VARCHAR(20) NOT NULL COMMENT '手机号',full_address VARCHAR(255) NOT NULL COMMENT '详细地址',is_default TINYINT DEFAULT 0 COMMENT '0-非默认/1-默认'); |
后端依赖与配置 | 1. pom.xml需引入:- MyBatis-Plus(3.5.x,事务 + CRUD)- Hutool(4.0.12+,生成订单号)- JWT(Day10 登录用,验证用户身份)2. Spring 事务配置:在启动类添加@EnableTransactionManagement开启事务支持 | 若 Hutool 依赖缺失,补充:xmlcn.hutoolhutool-all4.0.12 |
前端组件与数据 | 1. Uni-App 项目已导入uni-picker(地址选择)、uni-icons(图标)组件2. 登录后uni.getStorageSync('mypet_token')可获取有效 Token,mypet_userId存储用户 ID3. 从购物车跳转结算页时,携带选中的购物车 ID 列表(如cartIds=1,2,3) | uni-picker安装:HBuilder X→插件市场→搜索 "uni-ui"→导入 "uni-picker";购物车 ID 通过路由参数传递,确保能查询选中的商品 |
测试数据 | 1. cart表有选中的购物车数据(关联user_id=1、goods_id=1/2)2. address表有user_id=1的地址数据(至少 1 条,含默认地址)3. goods表有商品库存(如goods_id=1库存≥购买数量) | 可手动插入测试地址:sqlINSERT INTO address (user_id, receiver, phone, full_address, is_default) VALUES (1, '张三', '13812345678', '北京市朝阳区XX小区1号楼101', 1); |
二、核心逻辑流程图
订单提交涉及多步操作,需用事务确保一致性,先通过流程图理清步骤:
flowchart TD
A[用户在结算页点击提交订单] --> B[前端校验
1选地址 2有商品 3库存大于等于数量] B -- 校验失败 --> C[提示错误 如请选择地址] B -- 校验成功 --> D[调用后端/orders/submit接口
携带user_id address_id cartIds] D --> E[后端拦截器验证Token
解析user_id,防越权] E -- Token无效 --> F[返回登录过期,请重新登录] E -- Token有效 --> G[开启事务] G --> H[1查询选中的购物车数据
含goods_id quantity goods_info] H --> I[2校验商品库存
若库存不足,事务回滚] I --> J[3生成唯一订单号
时间戳+随机数,避免重复] J --> K[4插入订单主表orders
order_no user_id total_amount等] K --> L[5批量插入订单明细表order_item
每条商品对应1条明细] L --> M[6扣减商品库存
goods.stock = stock - quantity] M --> N[7删除购物车中已下单的商品
cart表删除选中的cartIds] N --> O[事务提交] O --> P[返回订单号+总金额给前端] P --> Q[前端跳转至支付页
携带order_no total_amount]
1选地址 2有商品 3库存大于等于数量] B -- 校验失败 --> C[提示错误 如请选择地址] B -- 校验成功 --> D[调用后端/orders/submit接口
携带user_id address_id cartIds] D --> E[后端拦截器验证Token
解析user_id,防越权] E -- Token无效 --> F[返回登录过期,请重新登录] E -- Token有效 --> G[开启事务] G --> H[1查询选中的购物车数据
含goods_id quantity goods_info] H --> I[2校验商品库存
若库存不足,事务回滚] I --> J[3生成唯一订单号
时间戳+随机数,避免重复] J --> K[4插入订单主表orders
order_no user_id total_amount等] K --> L[5批量插入订单明细表order_item
每条商品对应1条明细] L --> M[6扣减商品库存
goods.stock = stock - quantity] M --> N[7删除购物车中已下单的商品
cart表删除选中的cartIds] N --> O[事务提交] O --> P[返回订单号+总金额给前端] P --> Q[前端跳转至支付页
携带order_no total_amount]
三、代码实现
3.1 后端:订单提交接口(含事务、订单号生成)
3.1.1 1. 实体类(Entity)
需创建OrdersEntity(订单主表)、OrderItemEntity(订单明细表),映射数据库字段:
kotlin
// src/main/java/com/entity/OrdersEntity.java(订单主表)
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
@TableName("orders")
public class OrdersEntity {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("order_no")
private String orderNo; // 唯一订单号
@TableField("user_id")
private Long userId; // 关联用户ID
@TableField("address_id")
private Long addressId; // 关联地址ID
@TableField("total_amount")
private BigDecimal totalAmount; // 总金额
@TableField("order_status")
private Integer orderStatus; // 订单状态:0-待付款
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime; // 创建时间,自动填充
}
// src/main/java/com/entity/OrderItemEntity.java(订单明细表)
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
@TableName("order_item")
public class OrderItemEntity {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("order_no")
private String orderNo; // 关联订单号
@TableField("goods_id")
private Long goodsId; // 商品ID
@TableField("goods_name")
private String goodsName; // 商品名称
@TableField("goods_price")
private BigDecimal goodsPrice; // 下单单价
@TableField("quantity")
private Integer quantity; // 购买数量
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime; // 创建时间,自动填充
}
3.1.2 2. Mapper 层(数据访问)
继承 MP 的BaseMapper,无需自定义 SQL,直接使用 CRUD 方法:
java
// src/main/java/com/mapper/OrdersMapper.java
import com.entity.OrdersEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OrdersMapper extends BaseMapper<OrdersEntity> {}
// src/main/java/com/mapper/OrderItemMapper.java
import com.entity.OrderItemEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OrderItemMapper extends BaseMapper<OrderItemEntity> {}
3.1.3 3. Service 层(业务逻辑 + 事务)
核心层,处理订单生成、库存扣减、购物车删除,并用@Transactional确保事务一致性:
java
// src/main/java/com/service/OrdersService.java(接口)
import com.entity.OrdersEntity;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.List;
import java.util.Map;
public interface OrdersService extends IService<OrdersEntity> {
// 提交订单:参数为用户ID、地址ID、选中的购物车ID列表
Map<String, Object> submitOrder(Long userId, Long addressId, List<Long> cartIds);
}
// src/main/java/com/service/impl/OrdersServiceImpl.java(实现)
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.entity.*;
import com.mapper.*;
import com.service.OrdersService;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.IdUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class OrdersServiceImpl extends ServiceImpl<OrdersMapper, OrdersEntity> implements OrdersService {
@Autowired
private CartMapper cartMapper;
@Autowired
private GoodsMapper goodsMapper;
@Autowired
private OrderItemMapper orderItemMapper;
/**
* 提交订单(核心方法,事务保证一致性)
* @param userId 用户ID(从Token解析,防篡改)
* @param addressId 收货地址ID
* @param cartIds 选中的购物车ID列表
* @return 订单号+总金额
*/
@Override
@Transactional(rollbackFor = Exception.class) // 关键:异常时回滚所有操作
public Map<String, Object> submitOrder(Long userId, Long addressId, List<Long> cartIds) {
// 1. 查询选中的购物车数据(关联商品信息)
QueryWrapper<CartEntity> cartWrapper = new QueryWrapper<>();
cartWrapper.in("id", cartIds).eq("user_id", userId).eq("is_deleted", 0);
List<CartEntity> cartList = cartMapper.selectList(cartWrapper);
if (cartList.isEmpty()) {
throw new RuntimeException("购物车商品已失效,请重新选择!");
}
// 2. 校验库存+计算总金额
BigDecimal totalAmount = BigDecimal.ZERO;
List<OrderItemEntity> orderItemList = new ArrayList<>(); // 订单明细列表
for (CartEntity cart : cartList) {
Long goodsId = cart.getGoodsId();
Integer buyQuantity = cart.getQuantity();
// 查商品信息
GoodsEntity goods = goodsMapper.selectById(goodsId);
if (goods == null || goods.getStatus() != 1) { // 1-上架状态
throw new RuntimeException("商品【" + goods.getGoodsName() + "】已下架或不存在!");
}
// 校验库存
if (goods.getStock() < buyQuantity) {
throw new RuntimeException("商品【" + goods.getGoodsName() + "】库存不足,当前库存:" + goods.getStock());
}
// 计算总金额(单价*数量)
BigDecimal itemAmount = goods.getPrice().multiply(new BigDecimal(buyQuantity));
totalAmount = totalAmount.add(itemAmount);
// 构建订单明细
OrderItemEntity item = new OrderItemEntity();
item.setGoodsId(goodsId);
item.setGoodsName(goods.getGoodsName());
item.setGoodsPrice(goods.getPrice());
item.setQuantity(buyQuantity);
orderItemList.add(item);
}
// 3. 生成唯一订单号(格式:YYYYMMDD + 6位随机数,如20240520123456)
String orderNo = DateUtil.format(DateUtil.date(), "yyyyMMdd") + IdUtil.randomNumbers(6);
// 双重校验:确保订单号不重复(极端情况)
if (baseMapper.selectCount(new QueryWrapper<OrdersEntity>().eq("order_no", orderNo)) > 0) {
orderNo = DateUtil.format(DateUtil.date(), "yyyyMMdd") + IdUtil.randomNumbers(6); // 重新生成
}
// 4. 插入订单主表
OrdersEntity order = new OrdersEntity();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setAddressId(addressId);
order.setTotalAmount(totalAmount);
order.setOrderStatus(0); // 0-待付款
baseMapper.insert(order);
// 5. 批量插入订单明细表(给每条明细设置订单号)
for (OrderItemEntity item : orderItemList) {
item.setOrderNo(orderNo);
}
orderItemMapper.insertBatchSomeColumn(orderItemList); // MP批量插入方法
// 6. 扣减商品库存(库存=原库存-购买数量)
for (CartEntity cart : cartList) {
GoodsEntity goods = goodsMapper.selectById(cart.getGoodsId());
goods.setStock(goods.getStock() - cart.getQuantity());
goodsMapper.updateById(goods);
}
// 7. 删除购物车中已下单的商品(逻辑删除,is_deleted=1)
CartEntity deleteCart = new CartEntity();
deleteCart.setIsDeleted(1);
cartMapper.update(deleteCart, cartWrapper);
// 8. 返回订单号和总金额
Map<String, Object> result = new HashMap<>();
result.put("orderNo", orderNo);
result.put("totalAmount", totalAmount);
return result;
}
}
3.1.4 4. Controller 层(接口入口)
接收前端请求,验证用户身份,调用 Service 处理:
kotlin
// src/main/java/com/controller/OrdersController.java
import com.service.OrdersService;
import com.utils.R;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/orders")
public class OrdersController {
@Autowired
private OrdersService ordersService;
// JWT密钥(与Day10登录一致,建议配置在application.properties)
private static final String JWT_SECRET = "mypet-secret-2024";
/**
* 订单提交接口(需登录,Token验证)
* @param param 前端传递参数:addressId(地址ID)、cartIds(购物车ID列表)
* @param request 获取Token
*/
@PostMapping("/submit")
public R submitOrder(@RequestBody Map<String, Object> param, HttpServletRequest request) {
try {
// 1. 从Token解析用户ID(防篡改,比前端传userId更安全)
String token = request.getHeader("token");
Claims claims = Jwts.parser().setSigningKey(JWT_SECRET).parseClaimsJws(token).getBody();
Long userId = Long.parseLong(claims.getSubject());
// 2. 解析前端参数
Long addressId = Long.parseLong(param.get("addressId").toString());
List<Long> cartIds = (List<Long>) param.get("cartIds");
// 3. 调用Service提交订单
Map<String, Object> orderResult = ordersService.submitOrder(userId, addressId, cartIds);
// 4. 返回成功结果(订单号+总金额,供前端跳转支付)
return R.ok("订单提交成功").put("data", orderResult);
} catch (RuntimeException e) {
// 业务异常(如库存不足),返回具体错误信息
return R.error(e.getMessage());
} catch (Exception e) {
// 系统异常(如Token解析失败)
e.printStackTrace();
return R.error("订单提交失败,请重试!");
}
}
/**
* 辅助接口:获取用户地址列表(供前端联动)
*/
@PostMapping("/getAddressList")
public R getAddressList(HttpServletRequest request) {
// 从Token解析用户ID
String token = request.getHeader("token");
Claims claims = Jwts.parser().setSigningKey(JWT_SECRET).parseClaimsJws(token).getBody();
Long userId = Long.parseLong(claims.getSubject());
// 查询该用户的所有地址(按默认地址排序)
QueryWrapper<AddressEntity> addressWrapper = new QueryWrapper<>();
addressWrapper.eq("user_id", userId).orderByDesc("is_default");
List<AddressEntity> addressList = addressMapper.selectList(addressWrapper);
return R.ok().put("data", addressList);
}
}
3.2 前端:结算页(settle.vue)+ 地址联动
前端需实现 "地址选择→商品预览→总价计算→提交订单" 全流程,核心是uni-picker地址联动和表单校验:
xml
<template>
<view class="settle-page">
<!-- 1. 地址选择器 -->
<view class="address-container">
<view class="address-title">收货地址</view>
<uni-picker
mode="selector"
:range="addressList"
:range-key="'addressDesc'"
@change="onAddressChange"
class="address-picker"
>
<view class="address-content">
<!-- 显示默认地址或选中地址 -->
<view v-if="selectedAddress" class="address-info">
<view class="receiver">{{ selectedAddress.receiver }} {{ selectedAddress.phone }}</view>
<view class="full-address">{{ selectedAddress.fullAddress }}</view>
</view>
<view v-else class="address-placeholder">请选择收货地址</view>
</view>
</uni-picker>
<!-- 新增地址按钮 -->
<button class="add-address-btn" @click="navToAddAddress">+ 新增地址</button>
</view>
<!-- 2. 订单商品列表 -->
<view class="goods-list">
<view class="list-title">订单商品({{ cartList.length }}件)</view>
<view class="goods-item" v-for="(item, index) in cartList" :key="index">
<image :src="item.goodsImage" mode="widthFix" class="goods-img"></image>
<view class="goods-info">
<view class="goods-name">{{ item.goodsName }}</view>
<view class="goods-price">¥{{ item.goodsPrice.toFixed(2) }}</view>
</view>
<view class="goods-quantity">x{{ item.quantity }}</view>
</view>
</view>
<!-- 3. 订单金额 -->
<view class="order-amount">
<view class="amount-item">
<view class="label">商品总价</view>
<view class="value">¥{{ totalAmount.toFixed(2) }}</view>
</view>
<view class="amount-item total">
<view class="label">实付金额</view>
<view class="value">¥{{ totalAmount.toFixed(2) }}</view>
</view>
</view>
<!-- 4. 提交订单按钮 -->
<button
class="submit-btn"
@click="submitOrder"
:disabled="!selectedAddress || isLoading"
>
{{ isLoading ? '提交中...' : `提交订单(¥${totalAmount.toFixed(2)})` }}
</button>
</view>
</template>
<script>
import request from '@/api/request.js';
import uniPicker from '@dcloudio/uni-ui/lib/uni-picker/uni-picker';
export default {
components: { uniPicker },
data() {
return {
cartIds: [], // 选中的购物车ID列表(从路由参数获取)
cartList: [], // 购物车商品列表(含商品信息)
addressList: [], // 用户地址列表(供picker联动)
selectedAddress: null,// 选中的地址对象
totalAmount: 0, // 订单总金额
isLoading: false // 加载状态(防重复提交)
};
},
onLoad(options) {
// 1. 从路由参数获取选中的购物车ID(如cartIds=1,2,3)
this.cartIds = options.cartIds.split(',').map(id => parseInt(id));
// 2. 加载购物车商品和地址列表
this.getCartGoods();
this.getAddressList();
},
methods: {
// 1. 加载选中的购物车商品
getCartGoods() {
this.isLoading = true;
request.post('/cart/getSelectedGoods', { cartIds: this.cartIds })
.then(res => {
if (res.data.code === 0) {
this.cartList = res.data.data;
// 计算总金额
this.calculateTotal();
} else {
uni.showToast({ title: res.data.msg, icon: 'none' });
}
})
.catch(err => {
uni.showToast({ title: '加载商品失败', icon: 'none' });
console.error(err);
})
.finally(() => {
this.isLoading = false;
});
},
// 2. 加载用户地址列表(供picker联动)
getAddressList() {
request.post('/orders/getAddressList', {}, {
headers: { token: uni.getStorageSync('mypet_token') }
})
.then(res => {
if (res.data.code === 0) {
this.addressList = res.data.data;
// 自动选中默认地址(is_default=1)
const defaultAddr = this.addressList.find(addr => addr.isDefault === 1);
if (defaultAddr) {
this.selectedAddress = defaultAddr;
}
// 格式化地址显示文本(供picker选项)
this.addressList.forEach(addr => {
addr.addressDesc = `${addr.receiver} ${addr.phone} | ${addr.fullAddress}`;
});
}
})
.catch(err => {
uni.showToast({ title: '加载地址失败', icon: 'none' });
console.error(err);
});
},
// 3. 地址选择变化(picker回调)
onAddressChange(e) {
const index = e.detail.value;
this.selectedAddress = this.addressList[index];
},
// 4. 计算订单总金额
calculateTotal() {
this.totalAmount = this.cartList.reduce((sum, item) => {
return sum + (item.goodsPrice * item.quantity);
}, 0);
},
// 5. 提交订单
submitOrder() {
// 前端兜底校验
if (!this.selectedAddress) {
return uni.showToast({ title: '请选择收货地址', icon: 'none' });
}
if (this.cartList.length === 0) {
return uni.showToast({ title: '订单无商品,请重新选择', icon: 'none' });
}
this.isLoading = true;
// 调用后端提交接口
request.post('/orders/submit', {
addressId: this.selectedAddress.id,
cartIds: this.cartIds
}, {
headers: { token: uni.getStorageSync('mypet_token') }
})
.then(res => {
if (res.data.code === 0) {
// 提交成功:跳转至支付页(携带订单号和金额)
const { orderNo, totalAmount } = res.data.data;
uni.showToast({ title: '订单提交成功', icon: 'success' });
setTimeout(() => {
uni.navigateTo({
url: `/pages/pay/index?orderNo=${orderNo}&totalAmount=${totalAmount}`
});
}, 1500);
} else {
uni.showToast({ title: res.data.msg, icon: 'none' });
}
})
.catch(err => {
uni.showToast({ title: '提交失败,请重试', icon: 'none' });
console.error(err);
})
.finally(() => {
this.isLoading = false;
});
},
// 6. 跳转到新增地址页
navToAddAddress() {
uni.navigateTo({ url: '/pages/address/add' });
}
}
};
</script>
<style scoped>
/* 页面整体样式 */
.settle-page {
padding: 20rpx;
background-color: #f5f5f5;
min-height: 100vh;
}
/* 地址容器 */
.address-container {
background-color: #fff;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.address-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.address-picker {
width: 100%;
}
.address-content {
padding: 20rpx;
border: 1px solid #eee;
border-radius: 10rpx;
min-height: 120rpx;
display: flex;
align-items: center;
}
.address-placeholder {
color: #999;
font-size: 26rpx;
}
.address-info {
font-size: 26rpx;
color: #333;
}
.receiver {
margin-bottom: 10rpx;
}
.full-address {
color: #666;
}
.add-address-btn {
margin-top: 20rpx;
padding: 15rpx 0;
background-color: #f5f5f5;
color: #007aff;
font-size: 26rpx;
border-radius: 10rpx;
}
/* 商品列表 */
.goods-list {
background-color: #fff;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.list-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.goods-item {
display: flex;
align-items: center;
padding: 15rpx 0;
border-bottom: 1px solid #eee;
}
.goods-img {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
margin-right: 20rpx;
}
.goods-info {
flex: 1;
}
.goods-name {
font-size: 26rpx;
color: #333;
margin-bottom: 10rpx;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.goods-price {
font-size: 26rpx;
color: #f53f3f;
}
.goods-quantity {
font-size: 26rpx;
color: #666;
}
/* 订单金额 */
.order-amount {
background-color: #fff;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 30rpx;
}
.amount-item {
display: flex;
justify-content: space-between;
font-size: 26rpx;
margin-bottom: 15rpx;
}
.amount-item.total {
font-weight: bold;
color: #f53f3f;
font-size: 28rpx;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1px solid #eee;
}
.label {
color: #666;
}
.value {
color: #333;
}
/* 提交按钮 */
.submit-btn {
width: 100%;
padding: 24rpx 0;
background-color: #007aff;
color: #fff;
font-size: 30rpx;
border-radius: 16rpx;
position: fixed;
bottom: 30rpx;
left: 20rpx;
right: 20rpx;
}
.submit-btn:disabled {
background-color: #8cc5ff;
}
</style>
四、效果验证
需分后端接口测试 和前端全流程测试,确保数据一致性和功能正常:
✅ 1. 后端接口测试(Postman)
测试 1:提交订单接口
- 请求方式:POST
- 请求头:token: 有效用户Token
- 请求体(JSON) :
json
{
"addressId": 1,
"cartIds": [1, 2]
}
- 成功返回:
css
{
"code": 0,
"msg": "订单提交成功",
"data": {
"orderNo": "20240520123456",
"totalAmount": 269.80
}
}
- 数据库验证:
-
- orders表:新增 1 条记录,order_no=20240520123456,order_status=0;
-
- order_item表:新增 2 条明细,关联该订单号;
-
- cart表:选中的cartIds=1,2记录is_deleted=1(逻辑删除);
-
- goods表:对应商品库存已扣减(如原库存 100→98)。
测试 2:库存不足场景
- 手动将goods表中某商品库存改为 1,购买数量设为 2;
- 重新发送请求,返回:{"code":1,"msg":"商品【宠物狗粮10kg】库存不足,当前库存:1"};
- 数据库验证:所有表无新增 / 修改,事务回滚成功。
✅ 2. 前端全流程测试(Uni-App 模拟器)
- 跳转结算页:从购物车选中 2 件商品,点击 "结算"→携带cartIds=1,2跳转至settle.vue;
- 地址联动:页面自动加载地址列表,默认选中is_default=1的地址,点击地址可下拉切换其他地址;
- 商品预览:显示选中的商品图片、名称、单价、数量,总金额自动计算(如 2 件商品共 269.8 元);
- 提交订单:点击 "提交订单"→按钮显示 "提交中..."→提示 "订单提交成功"→1.5 秒后跳转至支付页;
- 支付页验证:支付页携带orderNo=20240520123456和totalAmount=269.8,数据正确。
五、常见问题与排查
问题现象 | 可能原因 | 解决方式 |
---|---|---|
1. 订单提交后部分表更新,部分未更新 | 1. 未加@Transactional注解;2. 异常未抛到 Service 层;3. 事务隔离级别配置错误 | 1. 确保submitOrder方法加@Transactional(rollbackFor = Exception.class);2. 所有业务异常(如库存不足)需抛RuntimeException;3. 检查 Spring 事务配置,默认隔离级别即可 |
2. 前端 picker 不显示地址选项 | 1. addressList为空;2. range-key配置错误;3. 地址列表未格式化addressDesc | 1. 用 Postman 测试/orders/getAddressList,确认返回地址数据;2. 确保range-key="addressDesc",且地址对象有该字段;3. 检查getAddressList中是否执行addr.addressDesc = ... |
3. 订单号重复 | 1. 生成策略重复(如仅用随机数);2. 高并发下未双重校验 | 1. 改用 "时间戳 + 随机数" 策略(如本文的yyyyMMdd+6位随机数);2. 生成后加baseMapper.selectCount(...)双重校验,重复则重新生成 |
4. 前端提交时提示 "购物车商品已失效" | 1. 购物车数据被删除;2. cartIds传递错误;3. 购物车is_deleted=1 | 1. 检查cart表,确保cartIds对应的记录is_deleted=0;2. 打印前端this.cartIds,确认与后端接收一致;3. 结算页加载时重新查询购物车,避免数据过时 |
5. 扣减库存后订单提交失败,库存未回滚 | 1. 库存扣减代码在事务外;2. 异常类型未被rollbackFor捕获 | 1. 确保库存扣减逻辑在@Transactional注解的方法内;2. rollbackFor = Exception.class捕获所有异常,避免只回滚 RuntimeException |
六、工具类 / 框架特性拓展
6.1 Hutool 订单号生成策略对比
本文用 "时间戳 + 6 位随机数",适合中小项目,其他常用策略:
策略 | 实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
时间戳 + 随机数 | DateUtil.format(...) + IdUtil.randomNumbers(6) | 可读性高(含日期),重复率低 | 高并发下需双重校验 | 中小项目、非秒杀场景 |
UUID(无横线) | IdUtil.simpleUUID() | 代码简单,全球唯一 | 无意义(难追溯日期),字符串长 | 无需可读性的场景 |
雪花算法(Snowflake) | IdUtil.getSnowflake().nextIdStr() | 分布式环境唯一,有序递增 | 依赖机器 ID,配置复杂 | 分布式项目、高并发场景 |
6.2 Uni-App picker 高级用法:省市区三级联动
若需更精细的地址选择(省→市→区),可改用uni-picker-cascader组件:
ini
<uni-picker-cascader
:options="areaOptions"
@change="onAreaChange"
placeholder="选择省市区"
></uni-picker-cascader>
- areaOptions:省市区数据源(可从后端接口获取,格式为[{value: '110000', label: '北京市', children: [...]}, ...]);
- onAreaChange:选择后获取省市区 ID,拼接成完整地址。
七、下节预告
👉 明天 Day17:订单列表与状态筛选!我们将学习:
- 实现订单列表分页加载(按状态筛选:待付款 / 待发货 / 已完成);
- 前端用uni-segmented-control实现状态切换标签;
- 订单详情页开发(展示主表 + 明细表数据);
- 待付款订单的 "取消订单" 功能(事务回滚库存)。
提前复习 MP 的条件查询(QueryWrapper)和 Uni-App 的分段控制器组件!