【Uni-App+SSM 宠物项目实战】Day16:订单提交

大家好!今天是宠物服务平台实战的第 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]

三、代码实现

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:提交订单接口

  1. 请求地址http://localhost:8080/orders/submit
  1. 请求方式:POST
  1. 请求头:token: 有效用户Token
  1. 请求体(JSON)
json 复制代码
{
  "addressId": 1,
  "cartIds": [1, 2]
}
  1. 成功返回
css 复制代码
{
  "code": 0,
  "msg": "订单提交成功",
  "data": {
    "orderNo": "20240520123456",
    "totalAmount": 269.80
  }
}
  1. 数据库验证
    • orders表:新增 1 条记录,order_no=20240520123456,order_status=0;
    • order_item表:新增 2 条明细,关联该订单号;
    • cart表:选中的cartIds=1,2记录is_deleted=1(逻辑删除);
    • goods表:对应商品库存已扣减(如原库存 100→98)。

测试 2:库存不足场景

  1. 手动将goods表中某商品库存改为 1,购买数量设为 2;
  1. 重新发送请求,返回:{"code":1,"msg":"商品【宠物狗粮10kg】库存不足,当前库存:1"};
  1. 数据库验证:所有表无新增 / 修改,事务回滚成功。

✅ 2. 前端全流程测试(Uni-App 模拟器)

  1. 跳转结算页:从购物车选中 2 件商品,点击 "结算"→携带cartIds=1,2跳转至settle.vue;
  1. 地址联动:页面自动加载地址列表,默认选中is_default=1的地址,点击地址可下拉切换其他地址;
  1. 商品预览:显示选中的商品图片、名称、单价、数量,总金额自动计算(如 2 件商品共 269.8 元);
  1. 提交订单:点击 "提交订单"→按钮显示 "提交中..."→提示 "订单提交成功"→1.5 秒后跳转至支付页;
  1. 支付页验证:支付页携带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:订单列表与状态筛选!我们将学习:

  1. 实现订单列表分页加载(按状态筛选:待付款 / 待发货 / 已完成);
  1. 前端用uni-segmented-control实现状态切换标签;
  1. 订单详情页开发(展示主表 + 明细表数据);
  1. 待付款订单的 "取消订单" 功能(事务回滚库存)。

提前复习 MP 的条件查询(QueryWrapper)和 Uni-App 的分段控制器组件!

相关推荐
高松燈2 小时前
浮点数类型导致金额计算错误复盘总结
后端
华仔啊2 小时前
主线程存了用户信息,子线程居然拿不到?ThreadLocal 背锅
java·后端
知了一笑2 小时前
「AI」网站模版,效果如何?
前端·后端·产品
小王子4802 小时前
性能优化实践分享
后端
RoyLin2 小时前
TypeScript设计模式:状态模式
前端·后端·typescript
RoyLin2 小时前
TypeScript设计模式:观察者模式
前端·后端·typescript
RoyLin2 小时前
TypeScript设计模式:备忘录模式
前端·后端·typescript
白衣鸽子2 小时前
PageHelper:分页陷阱避免与最佳实践
后端
BingoGo2 小时前
PHP 和 Elasticsearch:给你的应用加个强力搜索引擎
后端·php