在线商城 — 每个功能的代码作用详解

在线商城 — 每个功能的代码作用详解

本文以”功能”为线索,逐个解释项目中每一段代码是干什么的、输入什么、输出什么、和谁配合。
适合边看代码边对着本文理解。


一、用户端功能

1. 用户注册

涉及文件:

  • UserController.reg() — 接收注册请求
  • UserServiceImpl.reg() — 注册业务逻辑
  • UserMapper.xml insert — 写入数据库

1.1 前端 RegView.vue 的 reg() 函数

function reg() {
  // 前端表单校验:用户名和密码非空
  if (!form.value.username || !form.value.password) {
    ElMessage.warning('用户名和密码不能为空'); return
  }
  // 两次密码一致性校验
  if (form.value.password !== form.value.password1) {
    ElMessage.warning('两次密码不一致'); return
  }
  // 调用后端API,传4个参数
  userApi.reg({
    username: form.value.username,
    password: form.value.password,
    key: captchaData.value.key,           // 验证码的雪花key
    captchaInput: form.value.captchaInput  // 用户输入的验证码文本
  }).then(resp => {
    if (resp.code === 200) {
      ElMessage.success('注册成功,请登录')
      router.push('/user/login')  // 跳转到登录页
    } else {
      ElMessage.error(resp.msg)
      loadCaptcha()  // 注册失败→刷新验证码
    }
  })
}

作用:收集用户填写的表单数据→校验前端规则→发给后端→根据返回结果决定跳转还是提示错误。

1.2 后端 UserController.reg()

@PostMapping("/reg")
public RespBean reg(String username, String password,
                    String key, String captchaInput) throws StException {

    // ① 从Redis取验证码,校验是否匹配
    String captcha = (String) redisUtil.get(key);
    if (captcha == null || !captcha.equals(captchaInput)) {
        throw new StException("验证码错误。请确认后重新登录");
    }

    // ② 创建User对象,填前端传来的数据
    User user = new User();
    user.setUsername(username);
    user.setPassword(password);

    // ③ 调用Service完成真正的注册
    userService.reg(user);

    return RespBean.success("注册成功");
}

作用:接收HTTP请求→验证码校验(防机器人)→包装数据→委托Service→返回结果。

1.3 UserServiceImpl.reg() — 真正的业务逻辑

public boolean reg(User user) throws StException {
    // ① 用户名唯一性检查
    if (userMapper.selectByUsername(user.getUsername()) != null) {
        throw new StException("该用户已经存在,无法注册~~~");
    }

    // ② 生成随机盐(8位字符串)
    String salt = RandomUtil.randomString(8);
    user.setSalt(salt);

    // ③ 密码加密:MD5(MD5(明文 + 盐))
    String md5Pwd = SecureUtil.md5(SecureUtil.md5(user.getPassword() + salt));
    user.setPassword(md5Pwd);

    // ④ 插入数据库
    return userMapper.insert(user) == 1;
}

参数:User user(只有username和password有值) 返回:boolean(true=成功) 核心算法:双重MD5加盐加密。MD5(MD5("123456" + "981a735b")) = "c34c4c74..."。盐是随机生成的,两个用户密码一样但盐不同→密文不同→防彩虹表攻击。 数据库操作:INSERT INTO user(username, password, salt, ...) VALUES (...)

1.4 UserMapper.insert() SQL

<insert id="insert" parameterType="user">
    INSERT INTO user(username, password, salt, realname, gender, phone, email, money, status)
    VALUES (#{username}, #{password}, #{salt}, #{realname}, #{gender}, #{phone}, #{email}, #{money}, #{status})
</insert>

作用:把Java对象的值填入SQL占位符→MySQL执行插入→返回受影响行数。


2. 用户登录

涉及文件:UserController.login()、UserServiceImpl.login()、UserMapper.selectByUsername()

2.1 后端 UserController.login()

@PostMapping("/login")
public RespBean login(String username, String password,
                      String key, String captchaInput) throws StException {

    // 验证码校验(同注册)
    String captcha = (String) redisUtil.get(key);
    if (captcha == null || !captcha.equals(captchaInput)) {
        throw new StException("验证码错误");
    }

    // 调用Service验证用户名密码
    User user = userService.login(username, password);

    // 登录成功后生成JWT
    HashMap<String, Object> map = new HashMap<>();
    map.put("id", user.getId());
    map.put("username", user.getUsername());
    map.put("role", "admin");  // 所有登录用户role=admin(JWT通用可读)
    String jwtStr = JwtUtil.generateJwt(map);

    return RespBean.success("登录成功", jwtStr);
}

作用:验验证码→验密码→如果正确→把用户ID和用户名”封印”进JWT→返回给前端。前端拿到JWT后存sessionStorage,之后所有请求自动携带。

2.2 UserServiceImpl.login()

public User login(String username, String password) throws StException {
    // ① 根据用户名查用户
    User user = userMapper.selectByUsername(username);
    if (user == null) {
        throw new StException("该用户不存在");
    }

    // ② 检查是否被禁用/注销
    if (user.getStatus().equals(2) || user.getStatus().equals(3)) {
        throw new StException("该用户被禁用或注销");
    }

    // ③ 对输入的密码进行同样的加密(MD5(MD5(明文+盐)))
    String md5Pwd = SecureUtil.md5(SecureUtil.md5(password + user.getSalt()));

    // ④ 比较加密后的密码和数据库中的密码
    if (!md5Pwd.equals(user.getPassword())) {
        throw new StException("密码输入错误");
    }

    return user;
}

关键点:数据库里存的不是明文密码,是MD5(MD5("123456"+"981a735b"))。验证时用同样的算法加密用户输入→和数据库比对→一样就通过。


3. 加入购物车

涉及文件:CartController.insert()、CartServiceImpl.insert()、CartMapper.xml

3.1 CartServiceImpl.insert()

public boolean insert(Cart cart) {
    // ① 查这个用户是否已经加过这个商品
    Cart c = cartMapper.selectByGoodsIdAndUserId(cart.getGoodsId(), cart.getUserId());

    if (c == null) {
        // ② 第一次加→数量设为1→插入新记录
        cart.setCount(1);
        return cartMapper.insert(cart) == 1;
    } else {
        // ③ 已经加过→数量+1→更新已有记录
        c.setCount(c.getCount() + 1);
        return cartMapper.update(c) == 1;
    }
}

设计原因:同一个商品在购物车里只应该有一条记录,数量累加。如果用户两次点击”加入购物车”应该看到数量变成2而不是两条重复记录。 输入:Cart{userId:3, goodsId:45}(goodsId来自URL路径,userId来自JWT解析) 输出:boolean


4. 收藏商品

涉及文件:CollectController.insert()、CollectServiceImpl.insert()、CollectMapper.xml

4.1 CollectController.insert()

@PostMapping("/{goodsId}")
public RespBean insert(@PathVariable("goodsId") Integer goodsId,
                       @RequestHeader("token") String token) throws StException {

    // 从JWT解析出userId
    Map<String, Object> map = JwtUtil.parseJwtToMap(token);
    Integer userId = (Integer) map.get("id");

    // 构造收藏对象
    Collect collection = new Collect();
    collection.setGoodsId(goodsId);
    collection.setUserId(userId);

    if (!collectService.insert(collection)) {
        throw new StException("收藏失败,请重试");
    }
    return RespBean.success("收藏成功");
}

作用:从URL拿goodsId→从JWT拿userId→组装成Collect对象→交给Service。

4.2 CollectServiceImpl.insert()

public boolean insert(Collect collection) throws StException {
    // ① 检查是否已经收藏过
    Collect con = collectMapper.selectByGoodsIdAndUserId(
        collection.getGoodsId(), collection.getUserId()
    );
    if (con != null) {
        throw new StException("商品已添加"); // 不能重复收藏
    }
    // ② 插入收藏记录
    return collectMapper.insert(collection) == 1;
}

防止重复收藏:先查collect表中是否已有一条(goods_id=45, user_id=3)的记录→有就拒绝→没有才插入。

4.3 前端 GoodsView.vue collect() 和 cancelcollect()

// 收藏
function collect() {
  collectApi.insert(goods.value.id).then(resp => {
    if (resp.code == 200) {
      ElMessage.success(resp.msg)
      getCollectInfo()  // 刷新收藏状态→按钮文字从"收藏"变成"取消收藏"
    }
  })
}

// 取消收藏
function cancelcollect() {
  collectApi.delect(collectInfo.value.id).then(resp => {
    if (resp.code == 200) {
      ElMessage.success(resp.msg)
      getCollectInfo()  // 刷新→按钮文字从"取消收藏"变回"收藏"
    }
  })
}

状态切换原理:collectInfo ref初始为null。页面加载时调用getCollectInfo()查询是否已收藏→如果是,返回收藏记录(含id)→v-if="!collectInfo"显示”收藏”按钮→v-else显示”取消收藏”按钮。


5. 地址管理

涉及文件:AddrController、AddrServiceImpl、AddrMapper.xml、UserCenterView.vue

5.1 AddrServiceImpl.insert() — 新增地址

public boolean insert(Addr addr) throws StException {
    // 查询用户是否已有地址
    List<Addr> existing = addrMapper.selectByUserId(addr.getUserId());

    if (existing == null || existing.isEmpty()) {
        // 第一地址→自动设为默认
        addr.setStatus(1);
    } else {
        // 已有地址→保持前端传来的状态(没传就默认为0非默认)
        addr.setStatus(addr.getStatus() != null ? addr.getStatus() : 0);
    }
    return addrMapper.insert(addr) == 1;
}

设计原理:每个用户至少需要一个默认地址。第一地址自动默认→用户不需要手动操作。后续新增地址默认不是默认。

5.2 AddrServiceImpl.setDefault() — 设为默认地址

@Transactional  // 两步在一个事务中
public boolean setDefault(Integer id, Integer userId) throws StException {
    // 归属校验
    Addr existing = addrMapper.selectById(id);
    if (!existing.getUserId().equals(userId)) throw new StException("无权操作");

    // ① 把这个用户的所有地址status清0
    addrMapper.clearDefault(userId);

    // ② 把目标地址status置1
    return addrMapper.setDefault(id) == 1;
}

为什么需要@Transactional:如果第一步成功了但第二步失败了→用户的所有地址都变成”非默认”→页面可能显示不了默认地址。事务保证两条UPDATE要么全成功要么全撤销。

5.3 前端地址管理面板的数据流

进入地址管理Tab
  → onTabChange('addr')
  → loadAddrs()
    → addrApi.selectByUserId()  // GET /addr
    → addrList.value = resp.data

用户点击"新增地址"
  → openAddrAdd()
    → addrForm重置为空
    → addrDialogShow = true (弹窗显示)

用户填写表单后点"保存"
  → saveAddr()
    → 校验非空 + 手机号格式
    → addrIsEdit ? addrApi.update(form) : addrApi.insert(form)
    → 成功后 loadAddrs() 刷新列表

用户点击"设为默认"
  → setDefaultAddr(id)
    → addrApi.setDefault(id)  // PUT /addr/default/{id}
    → loadAddrs() 刷新列表
    → 界面上"默认"绿标移到新地址

6. 我的收藏列表

涉及文件:CollectController.list()、CollectServiceImpl.selectByPage()、CollectMapper.xml selectByUserId、UserCenterView.vue

6.1 CollectController.list() — 分页查询

@GetMapping("/list")
public RespBean list(@RequestParam(defaultValue = "1") Integer pageNum,
                     @RequestParam(defaultValue = "10") Integer pageSize,
                     @RequestHeader("token") String token) {
    Map<String, Object> map = JwtUtil.parseJwtToMap(token);
    Integer userId = (Integer) map.get("id");
    return RespBean.success("", collectService.selectByPage(pageNum, pageSize, userId));
}

作用:从JWT解析当前用户→调用分页查询→返回PageInfo对象(含list+total+pages+pageNum)。

6.2 CollectServiceImpl.selectByPage()

public PageInfo<Collect> selectByPage(Integer pageNum, Integer pageSize, Integer userId) {
    PageHelper.startPage(pageNum, pageSize);  // ① 开启MyBatis分页
    List<Collect> collection = collectMapper.selectByUserId(userId);  // ② 查数据
    return new PageInfo<>(collection);  // ③ 包装分页信息
}

PageHelper原理:startPage在当前线程设置分页参数→MyBatis执行SQL前自动在SQL末尾追加LIMIT offset, pageSize→同时执行一条SELECT COUNT(*)获取总条数→PageInfo封装。

6.3 CollectMapper.xml selectByUserId — 一次JOIN三表

<select id="selectByUserId" resultMap="collectWithGoods">
    SELECT c.id, c.goods_id, c.user_id, c.create_time,
           g.id AS g_id, g.name AS g_name, g.price AS g_price,
           g.market_price AS g_market_price, g.status AS g_status,
           gp.id AS gp_id, gp.url AS gp_url
    FROM collect c
    LEFT JOIN goods g ON c.goods_id = g.id
    LEFT JOIN goods_pic gp ON g.id = gp.goods_id
    WHERE c.user_id = #{userId}
    ORDER BY c.create_time DESC
</select>

为什么一次JOIN而不是循环单查:如果先SELECT FROM collect WHERE user_id=3(得10条),再逐条SELECT FROM goods WHERE id IN (...)(再一次查询),再逐条SELECT * FROM goods_pic WHERE goods_id IN (...)(第三次查询)→三次网络往返+3次SQL解析。JOIN只一次MySQL查询→resultMap用<association>和<collection>自动把扁平的行映射成嵌套的Java对象。

6.4 前端收藏面板渲染

收藏数据来自:collectApi.selectByPage(page, 10)
  → resp.data = {
      list: [{id, goodsId, goods: {name, price, picList: [{url}]}}, ...],
      total: 10
    }

模板遍历 collectList:
  <div v-for="item in collectList">
    <img :src="SERVER_ADDR + '/goods/pic/' + item.goods.picList[0].url" />
    <p>{{ item.goods.name }}</p>
    <p>¥{{ item.goods.price }}</p>
    <el-button @click="cancelCollect(item)">取消收藏</el-button>
  </div>

取消收藏后:filter掉被删除的那条→如果当前页变空→pageNum--回退一页→重新loadCollects()。


7. 下单(购物车结算 + 直接购买)

这是全项目最复杂的业务逻辑,涉及6个步骤。

涉及文件:OrderController.create()/createDirect()、OrderServiceImpl.create()/createDirect()/persistOrder()、OrderMapper.xml insert、CartMapper.xml deleteByIds、CreateOrderView.vue submitOrder()

7.1 前端 CreateOrderView.submitOrder()

function submitOrder() {
  // 前置检查
  if (!selectedAddrId.value) { ElMessage.warning("请选择收货地址"); return }
  if (detailList.value.length === 0) { ElMessage.warning("没有可结算的商品"); return }

  submitting.value = true

  // 步骤①:下单(根据模式选不同的API)
  const apiCall = mode.value === "cart"
    ? orderApi.create({ addrId, payType, cartIds: cartIdArr.value })
    : orderApi.createDirect({ goodsId, count, addrId, payType })

  apiCall.then(resp => {
    if (resp.code !== 200) { ElMessage.error(resp.msg); return null }
    return resp.data  // 返回Order对象(不弹提示)
  })
  // 步骤②:获取支付表单
  .then(order => {
    if (!order) return
    orderApi.pay(order.id).then(payResp => {
      if (payResp.code === 200 && payResp.data) {
        submitAlipayForm(payResp.data)  // 跳转支付宝
      } else {
        ElMessage.error("订单已创建,支付发起失败,请到「我的订单」重新支付")
      }
    })
  })
  .catch(() => ElMessage.error("下单失败,请重试"))
  .finally(() => submitting.value = false)
}

为什么分两步串成链而不是分开:

  • 先调用create(下单)→如果成功拿到orderId →再调pay(获取支付表单)
  • create不弹成功提示(因为支付还没完成)
  • 只有在最后一步确定成功或失败时才弹一条消息→不会同时弹出”下单成功”+”支付失败”两条消息

7.2 后端 OrderServiceImpl.create() — 购物车结算

@Transactional(rollbackFor = StException.class)
public Order create(Order order, List<Integer> cartIds, Integer userId) {

    List<OrderDetail> detailList = new ArrayList<>();

    // 遍历每个购物车项
    for (Integer cartId : cartIds) {
        // ① 查购物车
        Cart cart = cartMapper.selectById(cartId);
        // ② 归属校验(不能结算别人购物车)
        if (!cart.getUserId().equals(userId)) throw new StException("无权");

        // ③ Dubbo跨服务查商品信息
        Goods goods = goodsService.selectById(cart.getGoodsId());
        // ④ 库存检查(只查不扣,真正扣在支付成功后)
        if (cart.getCount() > goods.getCount()) throw new StException("库存不足");

        // ⑤ 构造快照:盯住下单时的价格
        BigDecimal subtotal = goods.getPrice().multiply(new BigDecimal(cart.getCount()));
        detailList.add(new OrderDetail(
            goods.getId(), goods.getName(), firstPic(goods),
            goods.getPrice(),   // ← 下单时售价
            cart.getCount(),
            subtotal
        ));
    }

    return persistOrder(order, userId, detailList, cartIds);
}

每个步骤的作用:

  • ①查购物车→确认这个购物车项确实存在
  • ②归属校验→用户A不能结算用户B的购物车(安全)
  • ③Dubbo跨服务调用→goods_service提供商品信息
  • ④库存检查→库存不够直接拒绝下单
  • ⑤快照→存下单时的价格和商品名,以后商品涨价了历史订单还是旧价格

7.3 persistOrder() — 统一落库

private Order persistOrder(Order order, Integer userId,
        List<OrderDetail> detailList, List<Integer> cartIds) {

    // ① 雪花算法生19位订单号
    order.setId(IdUtil.getSnowflakeNextId());
    // ② 设置用户+状态
    order.setUserId(userId);
    order.setStatus(0);  // 待付款
    // ③ 拼接收货地址快照文本
    order.setAddrDetail(buildAddrDetail(order.getAddrId()));
    // ④ 商品列表→JSON字符串
    order.setItems(JSONUtil.toJsonStr(detailList));
    // ⑤ 插入order表
    orderMapper.insert(order);

    // ⑥ 删除购物车项(和⑤在同一事务中)
    if (cartIds != null && !cartIds.isEmpty()) {
        cartMapper.deleteByIds(cartIds);
    }

    // ⑦ 回填数据给前端展示
    order.setDetailList(detailList);
    fillDetailAndPrice(order);  // 计算totalPrice
    return order;
}

关键顺序:先insert order→再delete cart。@Transactional保证insert和delete是原子的→要么全成功要么全撤销→不会出现”订单存在但购物车没清”的脏状态。

7.4 buildAddrDetail() — 地址快照

private String buildAddrDetail(Integer addrId) {
    Addr addr = orderMapper.selectAddrById(addrId);  // 直接查addr表
    if (addr == null) throw new StException("收货地址不存在");

    StringBuilder sb = new StringBuilder();
    if (addr.getProvince() != null) sb.append(addr.getProvince());
    if (addr.getCity() != null)     sb.append(addr.getCity());
    if (addr.getDistrict() != null) sb.append(addr.getDistrict());
    if (addr.getStreet() != null)   sb.append(addr.getStreet());
    sb.append(" ").append(addr.getAddress());
    sb.append(" - ").append(addr.getContact());
    sb.append(" - ").append(addr.getPhone());
    return sb.toString();
}

拼接结果示例:”山东省青岛市城阳区城阳街道 农大西苑A座 – 汤姆 – 13322332233″ 为什么order_service能查addr表:所有服务共用同一个MySQL数据库。不需要经过user_service的Dubbo接口。

7.5 OrderMapper.insert() SQL

<insert id="insert" parameterType="org.example.mall.common.bean.Order">
    INSERT INTO `order`(id, user_id, pay_type, express,
                        addr_id, addr_detail, status, items, create_time)
    VALUES (#{id}, #{userId}, #{payType}, #{express},
            #{addrId}, #{addrDetail}, #{status}, #{items}, NOW())
</insert>

NOW():MySQL函数,取当前时间。create_time用数据库时间而不是Java时间→避免服务器时间不一致。


8. 支付宝支付

这是连接外部系统的代码,涉及加密、签名、回调处理。

涉及文件:OrderServiceImpl.pay()、OrderServiceImpl.notifyPay()、OrderServiceImpl.queryAndUpdate()、OrderServiceImpl.markPaid()、AlipayConfig

8.1 OrderServiceImpl.pay() — 生成支付表单

public String pay(Long id, Integer userId) throws StException {
    // ① 三重校验
    Order order = orderMapper.selectById(id);
    if (order == null)                  throw new StException("订单不存在");
    if (!order.getUserId().equals(userId)) throw new StException("无权操作");
    if (order.getStatus() != 0)        throw new StException("订单状态不允许支付");

    // ② 解析items JSON→计算总金额
    fillDetailAndPrice(order);

    // ③ 构造支付宝支付请求
    AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
    request.setNotifyUrl(alipayConfig.getNotifyUrl());  // 异步通知地址
    request.setReturnUrl(alipayConfig.getReturnUrl());  // 同步跳转地址

    // ④ 构造业务参数(用JSONObject避免手拼JSON字符串)
    JSONObject biz = new JSONObject();
    biz.set("out_trade_no", String.valueOf(order.getId()));     // 商户订单号
    biz.set("total_amount", order.getTotalPrice().toPlainString()); // 金额
    biz.set("subject", "在线商城订单" + order.getId());         // 商品标题
    biz.set("product_code", "FAST_INSTANT_TRADE_PAY");         // PC网站支付
    biz.set("timeout_express", "30m");  // 30分钟后自动关闭
    request.setBizContent(biz.toString());

    // ⑤ 调用支付宝SDK→获取HTML表单字符串
    return alipayClient.pageExecute(request).getBody();
}

返回的HTML是什么:

<form action="https://openapi-sandbox.dl.alipaydev.com/gateway.do" method="post">
  <input type="hidden" name="biz_content" value='{"out_trade_no":"1876...",...}'/>
  <input type="hidden" name="sign" value="加密签名"/>
  <input type="submit" value="submit" style="display:none"/>
</form>
<script>document.forms[0].submit();</script>

前端拿到这段HTML→submitAlipayForm(html)→innerHTML→form.submit()→浏览器跳转到支付宝。

8.2 submitAlipayForm() — 前端提交支付表单

function submitAlipayForm(html) {
  // 创建隐藏div
  const container = document.createElement('div')
  container.style.display = 'none'
  container.innerHTML = html   // 把支付宝返回的表单HTML放进去
  document.body.appendChild(container)

  const form = container.querySelector('form')
  if (form) {
    form.submit()  // 提交→浏览器跳转到支付宝
  } else {
    ElMessage.error('支付表单异常')
    document.body.removeChild(container)  // 清理
  }
}

为什么不用document.write:document.write会把整个Vue应用清空→如果表单有问题(支付宝返回空HTML)→白屏。隐藏form方式出错了还能弹提示。

8.3 OrderServiceImpl.notifyPay() — 支付宝异步回调

public boolean notifyPay(Map<String, String> params) {
    // ① 验签:确认这个请求真的是支付宝发的,不是伪造的
    boolean signOk = AlipaySignature.rsaCheckV1(
        params, alipayConfig.getPublicKey(), "UTF-8", alipayConfig.getSignType()
    );
    if (!signOk) return false;  // 签名不对→拒绝

    // ② 检查交易状态
    String tradeStatus = params.get("trade_status");
    if (!"TRADE_SUCCESS".equals(tradeStatus) && !"TRADE_FINISHED".equals(tradeStatus)) {
        return true;  // 不是支付成功的状态→返回success但不处理
    }

    // ③ 提取订单号→更新订单
    Long orderId = Long.valueOf(params.get("out_trade_no"));
    return markPaid(orderId);
}

为什么需要验签:

  • 任何人都可以POST http://114.55.106.49/order/notify 这个地址
  • 如果不验签,攻击者可以伪造”支付成功”的通知→不付钱就能把订单变成”已付款”
  • 验签用的支付宝公钥只有支付宝能匹配→签名对=确实是支付宝发的

8.4 markPaid() — 标记已付款(幂等)

private boolean markPaid(Long orderId) {
    // ① 幂等检查:如果已经处理过,直接返回
    Order order = orderMapper.selectById(orderId);
    if (order == null || order.getStatus() != 0) {
        return true;  // status已经是1(已付款),说明之前处理过了
    }

    fillDetailAndPrice(order);

    // ② 改订单状态(先做,保证钱不白付)
    Order upd = new Order();
    upd.setId(orderId);
    upd.setStatus(1);       // 待付款→已付款
    orderMapper.updateById(upd);

    // ③ 扣库存(尽力而为,失败记日志不阻塞)
    for (OrderDetail d : order.getDetailList()) {
        try {
            if (!goodsService.reduceStock(d.getGoodsId(), d.getCount())) {
                log.warning("库存扣减失败: orderId=" + orderId + " goodsName=" + d.getGoodsName());
            }
        } catch (Exception e) {
            log.warning("库存扣减异常: " + e.getMessage());
        }
    }
    return true;
}

设计决策:

  • 先改状态:钱已付→必须改状态为”已付款”。如果因为库存不够而回滚状态→用户付了钱但订单显示”未付款”→这是不可接受的
  • 后扣库存:尽力而为→失败记日志→人工介入补货/退款。这是权衡——业务完美(预扣库存)vs代码简单(付款后扣)的选择
  • 无@Transactional:改状态是单条UPDATE原子操作,扣库存是Dubbo远程调用→不需要同一事务

8.5 queryAndUpdate() — return页主动查询

public boolean queryAndUpdate(Long id) {
    Order order = orderMapper.selectById(id);
    if (order.getStatus() != 0) return false;  // 已处理过

    // 调支付宝查询接口:这个订单付了没?
    AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
    request.setBizContent("{\"out_trade_no\":\"" + id + "\"}");
    AlipayTradeQueryResponse response = alipayClient.execute(request);

    if ("TRADE_SUCCESS".equals(response.getTradeStatus())) {
        return markPaid(id);  // 支付宝说付了→更新本地
    }
    return false;
}

为什么需要这个:支付宝的notifyUrl(异步通知)如果在本地网络环境下不可达→付款永远检测不到→订单永远”待付款”。returnUrl的页面(PayResultView)调用这个接口主动去问支付宝→确认付款了没。


9. 库存扣减

涉及文件:GoodsServiceImpl.reduceStock()、GoodsMapper.xml reduceStock

9.1 GoodsMapper.reduceStock SQL

UPDATE goods
SET count = count - #{count}
WHERE id = #{id} AND count >= #{count}

并发安全原理:

  • MySQL的UPDATE语句有行级锁:两个UPDATE同时操作id=45这一行→第一个执行→锁住这行→第二个等待→第一个提交后释放锁→第二个执行时count已经变了
  • count >= #{count}:执行时实时检查库存是否够→不够(<)→WHERE条件不满足→受影响行数为0→Java代码返回false
public boolean reduceStock(Integer goodsId, Integer count) {
    return goodsMapper.reduceStock(goodsId, count) == 1;
    // 返回1=成功扣减,0=库存不足被WHERE拦截
}

10. 仪表盘统计

涉及文件:DashboardController、DashboardMapper.xml

10.1 今日销售额计算

BigDecimal todaySales = BigDecimal.ZERO;
List<String> itemsList = dashboardMapper.selectTodayOrderItems();
for (String itemsJson : itemsList) {
    if (itemsJson == null || itemsJson.isEmpty()) continue;
    List<OrderDetail> details = JSONUtil.toList(itemsJson, OrderDetail.class);
    for (OrderDetail d : details) {
        if (d.getSubtotal() != null) {
            todaySales = todaySales.add(d.getSubtotal());
        }
    }
}
data.put("todaySales", todaySales);

为什么不在SQL里SUM:因为订单商品存的是JSON文本(items列),不是结构化列。MySQL没有SUM(JSON字段)的功能→必须在Java里解析JSON然后手动累加。这个设计取舍的代价——JSON存多商品方便,但统计要额外处理。

10.2 安全合并加载(前端)

function loadDashboard() {
  Service.get("/admin/dashboard").then(resp => {
    if (resp.code === 200 && resp.data) {
      const d = resp.data
      // 逐字段覆盖:只覆盖后端有值的字段,没值的保留默认值
      ['totalOrders','totalUsers','totalGoods','todayOrders',
       'todaySales','recentOrders','recentUsers']
        .forEach(k => { if (d[k] != null) dash.value[k] = d[k] })

      // statusCnt分层覆盖:防止后端缺某个status时冲成undefined
      if (d.statusCnt) Object.assign(dash.value.statusCnt, d.statusCnt)
    }
  })
}

为什么不能dash.value = resp.data:后端可能不返回某个字段→直接覆盖把默认值0冲成undefined→Number(undefined).toFixed(2) = “NaN” →页面崩。安全合并:只覆盖有值的字段。


11. 定时取消超时订单

涉及文件:OrderTimeoutTask、OrderMapper.cancelExpiredOrders

11.1 OrderTimeoutTask

@Component
public class OrderTimeoutTask {

    @Scheduled(fixedRate = 5 * 60 * 1000)  // 每5分钟执行一次
    public void cancelExpiredOrders() {
        Date before = new Date(System.currentTimeMillis() - 30 * 60 * 1000);
        int n = orderMapper.cancelExpiredOrders(before);
        if (n > 0) log.info("已自动取消超时未支付订单 " + n + " 笔");
    }
}

为什么是5分钟而不是实时:定时任务是轻量级方案。30分钟超时→每5分钟扫描→最坏情况35分钟后才取消(多出5分钟)。对于电商来说这个精度够了。

<update id="cancelExpiredOrders">
    UPDATE `order` SET status = 4
    WHERE status = 0 AND create_time &lt; #{before}
</update>

&lt;是XML里<的转义写法—XML不允许在属性值里写<符号。

配合支付宝侧的timeout_express=30m→支付宝自己也30分钟后关闭交易→双保险。


12. 取消订单(归属校验)

@PutMapping("/cancel/{id}")
public RespBean cancel(@PathVariable Long id, @RequestHeader String token) {
    Integer userId = JwtUtil.parseJwtToMap(token).get("id");

    Order order = orderService.selectById(id);
    if (order == null)               throw new StException("订单不存在");     // ①
    if (!order.getUserId().equals(userId)) throw new StException("无权操作"); // ②
    if (order.getStatus() != 0)     throw new StException("状态无法取消");    // ③

    Order upd = new Order();
    upd.setId(id);
    upd.setStatus(4);  // 已取消
    orderService.updateStatus(upd);
    return RespBean.success("订单已取消");
}

三层防护:①存在性→②归属→③状态。只有自己的待付款订单才能取消。管理员后台的PUT /order/status不做这层校验(管理员可以改任何订单状态)。


13. JacksonConfig — Long序列化

@Configuration
public class JacksonConfig {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
        return builder -> {
            builder.serializerByType(Long.class, ToStringSerializer.instance);
            builder.serializerByType(long.class, ToStringSerializer.instance);
        };
    }
}

作用原理:

  • Spring Boot启动时加载这个Bean
  • 之后所有Controller返回的JSON里,只要字段是Long类型→自动调用ToString()→变成字符串
  • Order.id = 1876543210987654321L → JSON输出 "1876543210987654321" 而不是数字
  • 前端JS拿到字符串→不会丢精度

为什么Long.class和long.class都要设置:

  • Long是包装类型(可null,如Long id = null)
  • long是基本类型(不可null,如long timestamp)
  • 两种类型在JVM中是不同的→都要注册序列化器

14. 前端Axios拦截器

// 请求拦截器:每次发请求前自动加token
service.interceptors.request.use(req => {
    const tokenStore = userTokenStore()
    if (tokenStore.tokenStr) {
        req.headers.token = tokenStore.tokenStr  // 放到请求头
    }
    return req
})

// 响应拦截器:收到响应后自动刷新token+403处理
service.interceptors.response.use(resp => {
    const token = resp.headers.token   // 后端每次返回新JWT
    if (token) tokenStore.updateToken(token)  // 自动更新
    return resp.data  // 直接返回body,调用的地方写 resp.code 即可
}, error => {
    if (error.status === 403) {
        ElMessage.error("令牌错误,重新登录")
        tokenStore.$reset()
        router.push("/user/login")
    }
})

请求拦截器的作用:每个API函数不需要手动传token→自动从store取出加到请求头。 响应拦截器第一个函数的作用:后端JwtInterceptor每次返回新JWT→这里自动替换本地旧的→用户只要持续操作就不会过期。 响应拦截器第二个函数的作用:Token过期/无效→清除本地数据→跳登录页→后面的请求不会再发。


15. 路由导航守卫

router.beforeEach((to, from) => {
    // 公开页面直接放行
    if (to.path === '/admin/login' || to.path === '/' ||
        to.path === '/user/login' || to.path === '/user/index' ||
        to.path.startsWith('/user/search') || to.path === '/user/reg' ||
        to.path === '/user/goods' || to.path === '/user/center') {
        return true  // 不检查token
    }

    // 需要登录的页面:检查有没有token
    const tokenStore = new userTokenStore()
    if (tokenStore.tokenStr) return true  // 已登录→放行

    // 未登录→跳到对应登录页
    if (to.path.startsWith("/admin")) return "/admin/login"
    else return "/user/login"
})

return值含义:

  • true:正常进入目标页面
  • false / string:跳转到指定路径(不进入目标页面)

本文配合 PROJECT_DOC.md(原理+架构)、DOC_APPENDIX.md(搭建+部署)、DOC_PART3.md(完整代码补充)形成完整的四件套。

阅读顺序建议:先看本文的功能代码详解→遇到不懂的概念查PROJECT_DOC.md的术语表和原理章节→想动手复现查DOC_APPENDIX.md和DOC_PART3.md。

上一篇
下一篇