🏪 在线商城项目 — 零基础完全解读文档

阅读对象:零基础学习者(无后端/前端经验也可阅读)
覆盖范围:项目架构、数据库设计、后端微服务、前端SPA、支付宝支付、全部29条Bug修复
学习路径建议:先看第1章了解基础概念 → 第2章看整体架构 → 第5-8章深入后端 → 第10章看前端 → 第14章看Bug修正是最实战的部分


目录

第一部分:入门篇

第二部分:架构篇

第三部分:数据库篇

第四部分:后端篇

第五部分:前端篇

第六部分:运维篇

第七部分:实战篇


1. 预备知识

如果你已经有编程基础,可以直接跳到第3章。这一章是为零基础读者准备的技术概念速成课。

1.1 什么是”前后端分离”

传统的网站开发,后端负责一切——处理数据、渲染HTML页面、返回给浏览器。一个Java程序员要同时写数据库查询和HTML页面。

前后端分离把这份工作拆成两部分:

用户浏览器
    │
    ├── 看到漂亮的页面、按钮、表格(前端负责)
    │
    └── 点击按钮后,浏览器偷偷发HTTP请求到服务器
             │
             └── 服务器查数据库、算数据、返回纯JSON(后端负责)
  • 前端只关心”用户看到什么”——页面长什么样、点了按钮会发生什么
  • 后端只关心”数据怎么处理”——数据库里有什么、怎么查、怎么算、怎么返回
  • 前后端通过 JSON(一种纯文本的数据格式)沟通

1.2 什么是JSON

JSON就是把数据组织成”键:值”的纯文本。比如一个订单:

{
    "orderId": 123456789,
    "username": "张三",
    "totalPrice": 5999.00,
    "items": [
        {"name": "小米14", "count": 2, "price": 2999.50},
        {"name": "华为Mate60", "count": 1, "price": 6999.00}
    ]
}

前端收到这个JSON后,把它解析成JavaScript对象,然后渲染成表格或卡片。后端收到前端发的JSON后,把里面的数据取出来存进数据库。

1.3 什么是微服务

传统做法是把所有代码写在一个大项目里,叫做”单体应用”。就像一个大超市,卖菜、卖电器、卖服装都在一起。

微服务把大项目拆成多个小项目(”服务”),每个服务只做一类事情。就像把大超市拆成专门的蔬菜店、电器店、服装店。

本项目有4个微服务:

微服务 职责 一句话
admin_service 管理员相关 管理员怎么登录的、后台数据怎么统计的
user_service 用户相关 用户怎么注册的、收货地址怎么存的
goods_service 商品相关 商品分类、商品信息、库存扣减
order_service 订单相关 购物车、下单、支付宝支付

好处

  • 一个服务挂了不影响其他(商品可以正常浏览,只是不能下单)
  • 每个服务可以用不同的技术(虽然本项目统一用Spring Boot)
  • 团队可以分工:A写商品服务,B写订单服务

坏处

  • 服务之间通信有额外开销(网络延迟)
  • 需要额外的组件(ZooKeeper)来协调服务之间如何发现彼此

1.4 什么是Dubbo(RPC框架)

微服务拆分后,一个问题出现了:order_service(订单)需要查商品信息,但商品信息在goods_service(商品)那里,怎么办?

order_service 说:"我需要知道商品#45的名字和价格"
goods_service 说:"我这里有,我给你"

它们怎么通信?

Dubbo就是一个让Java服务之间互相调用的框架。它的工作方式:

  1. 每个服务启动时,向ZooKeeper(”注册中心”)报到:”我是goods_service,我的地址是192.168.1.5:38090″
  2. order_service需要调goods_service时,先问ZooKeeper:”goods_service在哪儿?”
  3. ZooKeeper告诉它地址,order_service直接连过去调用
  4. 对程序员来说,调用远程服务和调用本地方法差不多(都是goodsService.selectById(45)
@DubboReference          // ← 这行注解告诉Dubbo:"这个字段的值来自远程服务"
private GoodsService goodsService;

// 然后在代码里直接调用,就像调用本地方法一样
Goods goods = goodsService.selectById(45);  // ← 实际是跨网络调用goods_service

1.5 什么是MyBatis(ORM框架)

问题:Java是面向对象的(有User类、Order类),但MySQL是关系型数据库(有user表、order表)。两者数据结构不同——Java里是user.getUsername(),MySQL里是SELECT username FROM user

MyBatis解决了这个”范式不匹配”问题。它让你用XML写SQL语句,并自动把数据库的行映射成Java对象。

<!-- MyBatis的XML文件: UserMapper.xml -->

<!-- 定义"映射关系":数据库列 <-> Java对象属性 -->
<select id="selectById" resultType="user">
    SELECT id, username, phone, reg_time
    FROM user
    WHERE id = #{id}
</select>

当Java代码调用userMapper.selectById(3)时:

  1. MyBatis找到对应的SQL:SELECT ... FROM user WHERE id = 3
  2. 把SQL发给MySQL执行
  3. MySQL返回一行数据:{id:3, username:"张三", phone:"13322332233", reg_time:"2024-09-01"}
  4. MyBatis根据map-underscore-to-camel-case=true配置,自动把reg_time(数据库下划线)转成regTime(Java驼峰)
  5. 创建一个User对象,把值填进去,返回给Java代码

1.6 什么是JWT(JSON Web Token)

问题:HTTP请求是无状态的——每次请求都是独立的,服务器不知道”这次请求和上次请求是同一个人吗”。

传统方案叫Session:用户登录后,服务器在内存里存一份”张三已登录”的记录,返回一个sessionId。下次请求带上sessionId,服务器查到对应的session就知道你是谁了。

JWT方案更简单——不需要服务器记录任何东西

登录时:
  服务器生成一个JWT:3段Base64字符串,用点号分隔
  xxxxxx.yyyyyy.zzzzzz
    ↑       ↑       ↑
  头部    载荷    签名
  (算法)  (用户ID  (防篡改的
          和角色)  数字指纹)

每次请求时:
  前端把JWT放在HTTP头里
  后端解析JWT,验证签名没被改过,提取用户ID
  完事!

为什么用JWT而不是Session

  • JWT不需要服务器存状态 → 微服务架构下所有服务共享一个JWT密钥就能解析
  • Session需要Redis或共享内存 → 增加运维复杂度

1.7 什么是nginx(反向代理)

问题:4个微服务各跑在不同端口上(8090, 8091, 8092, 8093),前端Ajax请求不能跨端口——http://www.dxkua.cn:80怎么分别访问4个不同端口的服务?

nginx解决了这个问题。它监听80端口(HTTP默认端口),收到请求后按URL路径”分发”给不同的后端服务:

nginx: "收到 GET /goods/45 → 这是商品请求,转发给8090端口"
nginx: "收到 GET /user/info → 这是用户请求,转发给8092端口"
nginx: "收到 POST /order → 这是订单请求,转发给8093端口"

对外只暴露80端口一个入口,对内按路径规则分发。

1.8 什么是支付宝PC网站支付(alipay.trade.page.pay)

这是支付宝提供的一种支付方式,流程是:

  1. 商家后台(我们)调用支付宝API:”我要创建一笔订单,金额5999元,商品是小米14″
  2. 支付宝返回一段HTML表单(包含签名、订单号、金额等加密字段)
  3. 前端把这段HTML提交到支付宝网站
  4. 用户在支付宝自己的页面上登录、输支付密码、付款
  5. 付款完成后,支付宝通过两个渠道通知我们:
    • 同步通知(浏览器跳转):用户浏览器从支付宝页面跳回我们的”支付结果页”
    • 异步通知(服务器推送):支付宝服务器直接POST我们的notifyUrl,告知支付结果

为什么需要两个通知

  • 同步通知可能丢失(用户付款后立刻关浏览器 → 跳转不执行)
  • 异步通知最可靠(支付宝服务器保证至少发一次),但可能有网络防火墙问题
  • 两个互补,保证万无一失

2. 项目一瞥

2.1 功能地图

在线商城
│
├── 用户端 (普通顾客用的)
│   ├── 首页(轮播图+推荐商品)
│   ├── 搜索商品(按分类/名称搜索)
│   ├── 商品详情(图片轮播+加入购物车+收藏+直接购买)
│   ├── 购物车(多选+数量修改+结算)
│   ├── 结算页(选地址+支付+跳转支付宝沙箱)
│   ├── 支付结果页(查支付状态)
│   ├── 个人中心
│   │   ├── 个人信息修改
│   │   ├── 修改密码
│   │   ├── 我的收藏(商品卡片网格+取消收藏)
│   │   ├── 地址管理(新增/编辑/删除/设为默认)
│   │   └── 我的订单(订单列表+去支付+取消订单)
│   ├── 登录/注册(图形验证码)
│   └── 在线聊天(AI客服)
│
└── 管理端 (管理员用的)
    ├── 登录
    ├── 仪表盘(总订单/总用户/今日销售额/状态分布)
    ├── 商品管理(增删改查+图片上传+富文本编辑器)
    ├── 分类管理(增删改查+图片上传)
    ├── 用户管理(搜索+封禁/解封+删除)
    ├── 订单管理(搜索+改状态+删除)
    └── 管理员管理(增删改查)

2.2 核心业务流程

完整的购物流程

浏览商品 → 加入购物车 → 去购物车勾选结算
    │                          │
    │                          ├── 选收货地址
    │                          ├── 选支付方式
    │                          └── 提交订单
    │                              │
    │                        订单创建成功 (status=0 待付款)
    │                              │
    │                        跳转支付宝沙箱付款
    │                              │
    │                        付款成功 (status=1 已付款)
    │                              │
    │                        notify/query 扣库存
    │                              │
    │                        管理员后台发货(status=2)
    │                              │
    │                        用户确认收货(status=3)
    │
    └── 直接购买(跳过购物车,一步到结算页)

3. 整体架构

3.1 一张图看懂全部模块

┌─────────────────────────────────────────────────────────────┐
│                      用户浏览器                              │
│            http://www.dxkua.cn (80端口)                    │
└─────────────┬───────────────────────────────────────────────┘
              │
              ▼
┌─────────────────────────────────────────────────────────────┐
│                       nginx (80端口)                         │
│                                                              │
│  根据URL路径分发请求:                                         │
│  /           → 前端静态文件 (index.html + JS/CSS)             │
│  /category/* → 127.0.0.1:8090  goods_service                │
│  /goods/*    → 127.0.0.1:8090  goods_service                │
│  /admin/*    → 127.0.0.1:8091  admin_service                │
│  /user/*     → 127.0.0.1:8092  user_service                 │
│  /collect/*  → 127.0.0.1:8092  user_service                 │
│  /addr/*     → 127.0.0.1:8092  user_service ★新增           │
│  /cart/*     → 127.0.0.1:8093  order_service                │
│  /order/*    → 127.0.0.1:8093  order_service ★新增           │
└─────────────┬───────────────────────────────────────────────┘
              │
    ┌─────────┼─────────┬─────────┐
    ▼         ▼         ▼         ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│8090  │ │8091  │ │8092  │ │8093  │
│goods │ │admin │ │user  │ │order │
│商品   │ │管理员│ │用户   │ │订单   │
│分类   │ │统计  │ │收藏   │ │购物车│
│上传   │ │      │ │地址   │ │支付   │
│库存   │ │      │ │      │ │      │
└──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘
   │        │        │        │
   └────────┴────┬───┴────────┘
                 │
    ┌────────────┴────────────┐
    │    MySQL (3306端口)      │
    │    数据库名:mall         │
    │    共享一个数据库         │
    └─────────────────────────┘

┌──────────┐    ┌──────────┐
│ZooKeeper │    │  Redis   │
│2181端口   │    │6379端口   │
│Dubbo注册  │    │验证码缓存 │
└──────────┘    └──────────┘

3.2 服务间通信的两种情况

情况一:直接查自己的数据库(大多数情况)

大部分时候,每个微服务用自己的Mapper直接查数据库,不经过其他服务。比如:user_service查user表、order_service查order表、goods_service查goods表。

情况二:跨服务调用(通过Dubbo RPC)

少数时候一个服务需要另一个服务的数据。比如order_service下单时需要查商品的当前价格和库存,这就得调goods_service。

// 在 order_service 的 OrderServiceImpl 中
@DubboReference                    // "这是一个远程服务的引用"
private GoodsService goodsService;  // "它实现了GoodsService接口"

// 使用方式:就像本地方法调用一样
Goods goods = goodsService.selectById(goodsId);
// ↑ 实际发生的事情:
// ① Dubbo向ZooKeeper查询"goods_service在哪里?"
// ② ZooKeeper返回地址 "192.168.x.x:38090"
// ③ Dubbo通过网络把goodsId发送给goods_service
// ④ goods_service查数据库 → 返回Goods对象
// ⑤ Dubbo把Goods对象序列化 → 传输 → 反序列化 → 返回给order_service

这也解释了为什么order_service可以直接查addr表:因为所有服务共享同一个MySQL数据库,order_service可以直接SELECT * FROM addr WHERE id = ?,不需要远程调用。

3.3 认证的完整流程(为什么每次请求都会”自动登录”)

步骤①:登录
  用户输入用户名+密码+验证码 → POST /user/login
  后端验证通过 → 生成JWT = JwtUtil.generateJwt({id:3, username:"zs", role:"user"})
  后端返回 {"code":200, "data":"eyJhbGc...完整JWT字符串..."}

步骤②:前端存储JWT
  前端收到JWT → 保存到Pinia store → pinia-plugin-persistedstate自动写入 sessionStorage
  浏览器关闭前JWT一直有效;关闭浏览器后sessionStorage清空→需重新登录

步骤③:每次请求自动带JWT
  Axios请求拦截器:每次发请求前,从store取出JWT,写入 req.headers.token
  等于每次HTTP请求自动在"请求头"里告诉后端"我是谁"

步骤④:后端验证JWT
  JwtInterceptor拦截需要认证的路径(如/admin/**、/cart/**)
  从请求头取token → JwtUtil.parseJwtToMap(token) 验证签名和有效期
  → 验证通过 → 生成新JWT放响应头(刷新过期时间)→ 放行
  → 验证失败 → 返回403 → 前端弹"令牌错误"→ 跳登录页

步骤⑤:Controller获取当前用户
  不需要拦截器的端点(如/addr/**),Controller自己取token解析:
  Integer userId = (Integer) JwtUtil.parseJwtToMap(token).get("id");

4. 请求流向

4.1 完整案例:用户点击”加入购物车”

从浏览器点击按钮到弹”添加成功”,这背后一共有7个步骤:

步骤①:用户点击 [加入购物车]
  GoodsView.vue 第166行:
  <el-button @click="insertCart">加入购物车</el-button>

步骤②:JavaScript函数执行
  GoodsView.vue insertCart()函数:
  cartApi.insert(goods.value.id)     // 调用API函数,参数是商品ID
     .then(resp => {
       if (resp.code === 200) {
         ElMessageBox.confirm('加入购物车成功,是否跳转到购物车页面')
       }
     });

步骤③:API函数发起HTTP请求
  cartApi.js:
  insert(goodsId) {
    return Service.post(`/cart/${goodsId}`);  // POST /cart/45
  }

  Service是axios实例,baseURL = "http://www.dxkua.cn"

步骤④:请求拦截器自动干两件事
  api/index.js 请求拦截器:
  req.headers.token = tokenStore.tokenStr;  // 附上JWT

  所以实际发送的HTTP请求是:
  POST http://www.dxkua.cn/cart/45
  Headers: { token: "eyJhbGci..." }

步骤⑤:nginx接收到请求,按URL路径转发
  nginx.conf:
  location /cart { proxy_pass http://localhost:8093; }

  请求被转发到 → http://localhost:8093/cart/45

步骤⑥:order_service处理请求
  CartController:
  @PostMapping("/{goodsId}")
  public RespBean insert(@PathVariable("goodsId") Integer goodsId,
                         @RequestHeader("token") String token) {
      Map<String,Object> map = JwtUtil.parseJwtToMap(token);
      Integer userId = (Integer) map.get("id");   // → 3(假设当前用户ID是3)

      Cart cart = new Cart();
      cart.setUserId(3);
      cart.setGoodsId(45);
      cartService.insert(cart);  // → 调service层

      return RespBean.success("添加购物车成功");
  }

  CartServiceImpl.insert():
  // 检查这个商品是否已经在购物车里了
  Cart existing = cartMapper.selectByGoodsIdAndUserId(45, 3);
  if (existing == null) {
      // 第一次加入 → 数量设为1 → 插入新记录
      cart.setCount(1);
      cartMapper.insert(cart);
  } else {
      // 已经在购物车 → 数量+1 → 更新
      existing.setCount(existing.getCount() + 1);
      cartMapper.update(existing);
  }

步骤⑦:响应沿着来路返回
  order_service → nginx → 浏览器
  响应拦截器检查 resp.headers.token → 有新JWT就更新本地
  返回 resp.data → 即 {"code":200, "msg":"添加购物车成功", "data":null}

步骤⑧:前端.then()执行
  if (resp.code === 200) {
    ElMessageBox.confirm('添加购物车成功,是否跳转到购物车页面');
  }

全程耗时:通常100-300ms(局域网环境)

5. 数据库设计

5.1 为什么数据库设计重要

数据库是整个项目的”地基”。所有功能本质上就是对数据库的”增删改查”:

  • 注册 = INSERT INTO user
  • 搜索商品 = SELECT FROM goods WHERE …
  • 下单 = INSERT INTO order
  • 支付成功 = UPDATE order SET status=1
  • 库存扣减 = UPDATE goods SET count=count-N

数据库字段定义错了 → 后端要写很多额外代码来补救 → 前端展示也可能有问题。所以一个好的数据库设计能让后面的开发事半功倍。

5.2 核心表详细说明

addr — 用户收货地址

CREATE TABLE `addr` (
    `id`       int UNSIGNED    NOT NULL AUTO_INCREMENT  COMMENT '自增主键',
    `contact`  varchar(20)     NOT NULL                 COMMENT '收货人姓名',
    `phone`    varchar(11)     NOT NULL                 COMMENT '收货人手机号',
    `province` varchar(20)     DEFAULT NULL             COMMENT '省份',
    `city`     varchar(20)     DEFAULT NULL             COMMENT '城市',
    `district` varchar(20)     DEFAULT NULL             COMMENT '区县',
    `street`   varchar(20)     DEFAULT NULL             COMMENT '街道',
    `address`  varchar(255)    NOT NULL                 COMMENT '详细门牌号',
    `user_id`  int UNSIGNED    NOT NULL                 COMMENT '这个地址是谁的',
    `status`   tinyint         DEFAULT 0                COMMENT '0=普通地址 1=默认收货地址',
    PRIMARY KEY (`id`),
    INDEX `user_id`(`user_id`),
    FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
) COMMENT='用户收货地址表';

为什么省市区街道分开存,而不是存在一个大字段里?

  • 分开存可以做统计分析:哪个城市的用户最多
  • 分开存可以做省市区三级联动选择器(前端UI组件)
  • 分开存比一个大字段灵活

为什么status=1是默认地址?

  • 0在数据库中通常是”否”的意思,1是”是”
  • 每个用户同一时间只能有一个默认地址→设新默认时先把所有地址的都清成0,再把目标置成1
  • 这是最简洁的表示方式,不需要额外一张”默认地址映射表”

ON DELETE CASCADE 是什么意思?

  • 如果user表里删了一个用户,他所有的收货地址自动删除
  • 避免”孤儿数据”:地址还在但用户已经不存在了

order — 订单

CREATE TABLE `order` (
    `id`          bigint UNSIGNED NOT NULL               COMMENT '订单号',
    `user_id`     int UNSIGNED    NOT NULL               COMMENT '谁下的单',
    `create_time` datetime        DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
    `express`     varchar(50)     DEFAULT NULL           COMMENT '快递单号(发货后才有)',
    `pay_type`    tinyint         DEFAULT NULL           COMMENT '1=支付宝 2=微信 3=银行卡',
    `addr_id`     int UNSIGNED    NOT NULL               COMMENT '哪个地址送',
    `addr_detail` varchar(255)    NOT NULL               COMMENT '完整的地址文本(下单时的快照)',
    `status`      tinyint         DEFAULT 0              COMMENT '0待付款 1已付款 2已发货 3已完成 4已取消',
    `items`       text            DEFAULT NULL           COMMENT '★订单里买了什么(JSON格式)',
    PRIMARY KEY (`id`),
    FOREIGN KEY (`user_id`) REFERENCES `user`(`id`),
    FOREIGN KEY (`addr_id`) REFERENCES `addr`(`id`)
) COMMENT='订单主表';

为什么订单ID用bigint(雪花算法)而不是auto_increment?

  • 雪花算法生成的ID是全局唯一的分布式ID,不依赖数据库自增
  • 多个微服务可能同时创建订单,自增ID在高并发下可能冲突或有性能瓶颈
  • 雪花ID的缺点是它是19位数字→JS的Number存不下→需要序列化成String

为什么addr_detail要存一份快照,而不是只存addr_id去查?

  • 用户下单后可能把收货地址改了/删了→如果只存addr_id,查到的地址已经不是下单时的地址了
  • 购物历史记录必须保留原始数据,这是一条商业原则

为什么items用JSON中文而不是新建一张order_detail表?

  • 这是本次开发的取舍:不建新表,减少数据库变更
  • JSON方式的优点:一个订单的所有商品信息存在一行里,查询快;结构灵活,想加字段直接改JSON
  • JSON方式的缺点:不能用SQL直接在数据库里统计”本月哪个商品卖得最好”(需要在Java代码里解析JSON然后计算)
  • 生产环境建议用order_detail表,但本项目选择”不建新表”来减少数据库变更

items列的JSON内容示例

[
    {
        "goodsId": 1,
        "goodsName": "小米14 Ultra",
        "goodsPic": "abc123.jpg",
        "price": 5999.00,
        "count": 2,
        "subtotal": 11998.00
    }
]

订单状态机

0(待付款) ──付款──→ 1(已付款) ──发货──→ 2(已发货) ──确认──→ 3(已完成)
    │                      │                      │
    └──取消──→ 4(已取消)    └──取消──→ 4(已取消)  └──取消──→ 4(已取消)

定时任务:status=0 超过30分钟 → 自动 → 4(已取消)

goods — 商品

CREATE TABLE `goods` (
    `id`             int UNSIGNED    NOT NULL AUTO_INCREMENT,
    `name`           varchar(100)    NOT NULL            COMMENT '商品名称',
    `decp`           varchar(255)    DEFAULT NULL        COMMENT '一句话描述',
    `detail`         text            DEFAULT NULL        COMMENT '详细图文描述(富文本HTML)',
    `price`          decimal(10,2)   NOT NULL            COMMENT '实际售价',
    `market_price`   decimal(10,2)   DEFAULT NULL        COMMENT '市场参考价',
    `purchase_price` decimal(10,2)   DEFAULT NULL        COMMENT '进货价',
    `color`          varchar(50)     DEFAULT NULL        COMMENT '颜色',
    `version`        varchar(50)     DEFAULT NULL        COMMENT '版本',
    `count`          int UNSIGNED    DEFAULT 0           COMMENT '当前库存数量',
    `recom`          tinyint         DEFAULT 0           COMMENT '0不推荐 1首页推荐',
    `category_id`    int UNSIGNED    NOT NULL            COMMENT '所属分类ID',
    `score`          float           DEFAULT 0           COMMENT '平均评分',
    `status`         tinyint         DEFAULT 0           COMMENT '0下架 1上架',
    PRIMARY KEY (`id`),
    FOREIGN KEY (`category_id`) REFERENCES `category`(`id`)
);

为什么价格用decimal(10,2)而不是float?

  • float是近似值,做计算会有精度误差(0.1+0.2!=0.3这种经典的浮点数问题)
  • decimal是精确值,做金额计算不会丢精度→事关金钱,必须用decimal
  • (10,2)表示总共10位数字,其中2位是小数→最多99999999.99元

同名称+同颜色+同版本=重复商品?

  • 这是GoodsServiceImpl里insert方法定义的唯一性约束
  • “小米14、黑色、16+512GB”和”小米14、白色、8+256GB”是不同的商品
  • 三者必须完全相同才算重复

collect — 收藏

CREATE TABLE `collect` (
    `id`          int UNSIGNED NOT NULL AUTO_INCREMENT,
    `goods_id`    int UNSIGNED NOT NULL,
    `user_id`     int UNSIGNED NOT NULL,
    `create_time` datetime     DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    FOREIGN KEY (`goods_id`) REFERENCES `goods`(`id`) ON DELETE CASCADE,
    FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE
);

为什么goods_id和user_id用外键约束?

  • 防止脏数据:如果商品被删了(cascade),收藏记录自动删掉,避免用户收藏里出现”幽灵商品”
  • 一对(goods_id, user_id)唯一:CoullectServiceImpl.insert里会先查是否已收藏,避免同一用户重复收藏同一商品

Other tables — 其他表(简述)

用途 关键字段
user 用户 username(唯一)、password(MD5+salt加密)、status(0未认证/1正常/2禁用/3注销)
admin 管理员 同user表结构,status(0正常/1禁用)
cart 购物车 user_id + goods_id + count(数量)
category 商品分类 parent_id(自引用:0=顶级分类,其他=父分类ID),形成树形结构
goods_pic 商品图片 url(图片文件名) + goods_id(所属商品)

6. common公共模块

common模块是所有微服务的”共享工具箱”——这里面的代码4个微服务都会用到。

6.1 JacksonConfig — 救命的配置

// 文件路径:common/.../config/JacksonConfig.java

package org.example.mall.common.config;

import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Jackson 全局序列化配置
 * 
 * 【要解决什么问题】
 * 订单号是雪花算法生成的19位Long:1876543210987654321
 * JavaScript的 Number 类型最大安全整数是 9007199254740992(16位)
 * 19位的Long经JSON序列化成数字后,前端JSON.parse会丢失末尾几位的值
 * 比如后端返回 {"id":1876543210987654321}
 *    前端解析变成 {"id":1876543210987654400}  ← 末尾79被截断!
 *    之后用这个错的id去支付/取消/查询 → 全部失败
 * 
 * 【怎么解决的】
 * 把所有的Long/long在序列化时输出为字符串
 * 后端返回 {"id":"1876543210987654321"}
 * 前端JSON.parse拿到字符串 "1876543210987654321" → 不丢精度
 * 
 * 【为什么不影响其他代码】
 * - String→Long的反序列化Jackson原生支持(JSON "123" → Java Long 123)
 * - Spring MVC的@PathVariable也是用String→Long的转换器
 * - 本项目只有Order.id是Long,其他Bean全是Integer → 只影响订单号
 */
@Configuration
public class JacksonConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
        return builder -> {
            // Long是包装类型(可能为null),long是基本类型(不能为null)
            // 两个都要设置
            builder.serializerByType(Long.class, ToStringSerializer.instance);
            builder.serializerByType(long.class, ToStringSerializer.instance);
        };
    }
}

@Configuration 告诉Spring:”这是一个配置类,启动时加载它” @Bean 告诉Spring:”这个方法返回的对象要放到Spring容器里,其他地方可以用” Jackson2ObjectMapperBuilderCustomizer 是Spring Boot提供的”自定义Jackson行为的钩子” ToStringSerializer.instance 是Jackson提供的”把任何对象转成String”的序列化器

6.2 RespBean — 统一响应格式

// 文件路径:common/.../bean/RespBean.java

package org.example.mall.common.bean;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 统一响应体:所有后端接口返回的数据都用这个格式包装
 * 
 * 【为什么需要统一格式】
 * 如果不统一:
 *   有的接口返回 {"status":"ok", "user":{"name":"张三"}}
 *   有的接口返回 {"code":200, "message":"成功", "result":{"name":"张三"}}
 * 前端要针对每个接口写不同的解析逻辑,维护成本极高
 * 
 * 统一格式后:
 *   所有接口返回 {"code":200, "msg":"成功", "data":{...}}
 *   前端只需要检查 resp.code === 200 → 取 resp.data
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RespBean {
    private Integer code;   // 200=成功, 500=失败
    private String  msg;    // 提示信息(前端直接拿来弹toast)
    private Object  data;   // 业务数据(可以是任何类型:对象、列表、字符串、null)

    // 工厂方法:省去每次手动 new RespBean(200, msg, null)
    public static RespBean success(String msg) {
        return new RespBean(200, msg, null);
    }
    public static RespBean success(String msg, Object data) {
        return new RespBean(200, msg, data);
    }
    public static RespBean error(String msg) {
        return new RespBean(500, msg, null);
    }
    public static RespBean error(Integer code, String msg) {
        return new RespBean(code, msg, null);
    }
}

@Data = Lombok自动生成 getter/setter/toString/equals/hashCode @NoArgsConstructor = 自动生成无参构造函数(new RespBean()@AllArgsConstructor = 自动生成全参构造函数(new RespBean(200, "成功", null)

6.3 JwtInterceptor — 认证拦截器

// 文件路径:common/.../intercepter/JwtInterceptor.java

package org.example.mall.common.intercepter;

@Component
public class JwtInterceptor implements HandlerInterceptor {

    @Autowired
    private WhiteListConfig whiteListConfig;  // 白名单配置(哪些路径不用登录)

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {

        String path = request.getRequestURI();     // 当前请求的URL路径
        String method = request.getMethod();        // GET / POST / PUT / DELETE

        // ====== 第一步:白名单检查 ======
        // 如果当前路径在白名单里,直接放行,不检查token
        if (whiteListConfig.getRules() != null &&
            whiteListConfig.getRules().stream()
                .anyMatch(rule ->
                    rule.getMethod().equalsIgnoreCase(method) &&  // 方法匹配(如GET)
                    path.matches(rule.getRegpath()))) {            // 路径匹配(正则)
            return true;  // ← return true = 放行
        }

        // ====== 第二步:OPTIONS预检放行 ======
        // 浏览器在跨域请求前会先发一个OPTIONS请求"探路"
        // 这个请求不涉及认证,直接放行
        if ("OPTIONS".equals(request.getMethod().toUpperCase())) {
            return true;
        }

        // ====== 第三步:从请求头取token ======
        String token = request.getHeader("token");
        RespBean respBean = null;

        try {
            // ====== 第四步:解析JWT ======
            // 这里会验证签名、检查过期时间
            // 如果出错,会抛下面4种异常之一
            Map<String, Object> map = JwtUtil.parseJwtToMap(token);

            // ====== 第五步:生成新JWT(刷新过期时间)=====
            // 每次请求都返回一个新的JWT→只要用户持续操作,就不会过期
            String jwt = JwtUtil.generateJwt(map);

            // 把新JWT放在响应头里→前端响应拦截器会自动发现并更新
            response.setHeader("token", jwt);
            // 告诉浏览器"token"这个响应头前端可以读(安全策略默认不让读自定义头)
            response.setHeader("Access-Control-Expose-Headers", "token");

            return true;  // 放行

        } catch (SignatureVerificationException e) {
            // JWT签名验证失败 → 说明JWT被篡改过
            respBean = RespBean.error("无效签名");
        } catch (TokenExpiredException e) {
            // JWT已过期
            respBean = RespBean.error("令牌超时");
        } catch (AlgorithmMismatchException e) {
            // 加密算法不匹配(比如以前用HS256现在用RS256)
            respBean = RespBean.error("算法不匹配");
        } catch (Exception e) {
            // 其他任何JWT解析异常
            respBean = RespBean.error("令牌无效");
        }

        // ====== 第六步:返回403错误 ======
        // HttpServletResponse.SC_FORBIDDEN = 403
        // 注意:这里直接用Jackson写JSON到响应流,不经过Spring的@ResponseBody
        // 因为HandlerInterceptor发生在Controller之前,必须自己写响应
        ObjectMapper objectMapper = new ObjectMapper();
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=utf-8");
        objectMapper.writeValue(response.getOutputStream(), respBean);

        return false;  // ← return false = 拦截,请求不会再往下走到Controller
    }
}

HandlerInterceptor的生命周期

请求进来
  → preHandle() 执行(此处验证JWT)
  → 如果return true → Controller方法执行
  → postHandle() 执行(Controller执行完、渲染视图前)
  → afterCompletion() 执行(视图渲染后,做清理工作)

如果preHandle return false → 直接停在这里,Controller根本不会执行

6.4 JwtUtil — JWT生成和解析

// 文件路径:common/.../utils/JwtUtil.java

public class JwtUtil {
    // ★密钥:用于签名JWT。泄露后可伪造任何人身份!
    // 生产环境必须通过环境变量注入,不能写死在代码里
    private static final String SECRET = "mall_secret_key";

    /**
     * 生成JWT
     * @param map 要存进JWT的载荷数据,如 {id:3, username:"zs", role:"user"}
     * @return JWT字符串,三段Base64,用点号分隔
     */
    public static String generateJwt(Map<String, Object> map) {
        return JWT.create()
            .withPayload(map)                       // 载荷
            .withExpiresAt(new Date(                // 过期时间
                System.currentTimeMillis() + 7*24*3600*1000L))  // 7天后
            .sign(Algorithm.HMAC256(SECRET));       // 用密钥签名
    }

    /**
     * 解析JWT
     * @param token JWT字符串
     * @return 载荷里的Map {id:3, username:"zs", role:"user"}
     * @throws TokenExpiredException      如果JWT已过期
     * @throws SignatureVerificationException 如果签名不对(被篡改)
     */
    public static Map<String, Object> parseJwtToMap(String token) {
        return JWT.require(Algorithm.HMAC256(SECRET))   // 用同样的密钥验证
            .build()
            .verify(token)                               // 验证(可能抛异常)
            .getClaims()                                 // 获取全部声明
            .stream()
            .collect(Collectors.toMap(Claim::getKey, Claim::asObject));
    }
}

JWT的结构(三段Base64,用.分隔):

eyJhbGciOiJIUzI1NiJ9          ← 头部:{"alg":"HS256"} → Base64编码
.                              ← 点号分隔
eyJpZCI6MywidXNlcm5hbWUiOiJ6    ← 载荷:{"id":3,"username":"zs","role":"user"} → Base64
cyIsInJvbGUiOiJ1c2VyIn0       (载荷只是编码不是加密→不要放密码等敏感数据!)
.                              ← 点号分隔
XbZ3y-H8kL9mN2vP7qR4sT1uW6Y    ← 签名:HMAC256(头部+载荷+密钥) → 防篡改

7. admin_service管理员服务

7.1 DashboardMapper.xml — 仪表盘统计SQL

<!-- 文件:admin_service/.../mapper/DashboardMapper.xml -->

<!-- ① 总订单数:直接 COUNT(*) -->
<select id="countOrders" resultType="int">
    SELECT COUNT(*) FROM `order`
</select>

<!-- ② 总用户数 -->
<select id="countUsers" resultType="int">
    SELECT COUNT(*) FROM `user`
</select>

<!-- ③ 在售商品数:只统计上架(status=1)的 -->
<select id="countGoods" resultType="int">
    SELECT COUNT(*) FROM `goods` WHERE `status` = 1
</select>

<!-- ④ 今日订单数 -->
<select id="countTodayOrders" resultType="int">
    SELECT COUNT(*) FROM `order`
    WHERE DATE(`create_time`) = CURDATE()
    <!-- DATE()只取日期部分(忽略时分秒),CURDATE()是今天的日期 -->
    <!-- 例:WHERE DATE('2026-07-28 14:30:00') = '2026-07-28' -->
</select>

<!-- ⑤ 今日已付款订单的items列(用于计算今日销售额) -->
<select id="selectTodayOrderItems" resultType="string">
    SELECT `items` FROM `order`
    WHERE DATE(`create_time`) = CURDATE()
      AND `items` IS NOT NULL
      AND `status` IN (1, 2, 3)
    <!-- ★关键:status IN(1,2,3) = 已付款/已发货/已完成
         不包括待付款(0)和已取消(4)
         否则销售额会虚高(把没付钱的也算进去) -->
</select>

<!-- ⑥ 最近5条订单(只查仪表盘需要的字段) -->
<select id="selectRecentOrders"
        resultType="org.example.mall.common.bean.Order">
    SELECT o.id,
           o.user_id    AS userId,
           o.status,
           o.create_time AS createTime,
           u.username    -- 来自user表的join
    FROM `order` o
    LEFT JOIN `user` u ON o.user_id = u.id
    ORDER BY o.create_time DESC
    LIMIT #{limit}
    <!-- ★注意:没有查 o.*,因为 items 列很大(JSON文本),
         仪表盘不需要看商品明细,查了反而浪费带宽 -->
</select>

<!-- ⑦ 最近5条注册用户 -->
<select id="selectRecentUsers"
        resultType="org.example.mall.common.bean.User">
    SELECT id, username, phone, email,
           reg_time AS regTime,  -- ★手动别名:reg_time → regTime
           status
    FROM `user`
    ORDER BY reg_time DESC
    LIMIT #{limit}
</select>

<!-- ⑧ 按状态分组统计订单数 -->
<select id="countOrdersByStatus" resultType="java.util.HashMap">
    SELECT `status`, COUNT(*) AS cnt
    FROM `order`
    GROUP BY `status`
</select>
<!-- 返回示例:
  [{status:0, cnt:10}, {status:1, cnt:80}, {status:3, cnt:5}]
  每个元素是一个HashMap,key=列名,value=列值 -->

7.2 DashboardController — 仪表盘JSON接口

// 文件:admin_service/.../controller/DashboardController.java

@RestController
@RequestMapping("/admin/dashboard")
public class DashboardController {

    @Autowired
    private DashboardMapper dashboardMapper;

    @GetMapping
    public RespBean index() {
        Map<String, Object> data = new HashMap<>();

        // 基础统计数字 → 直接放进去
        data.put("totalOrders", dashboardMapper.countOrders());
        data.put("totalUsers",  dashboardMapper.countUsers());
        data.put("totalGoods",  dashboardMapper.countGoods());
        data.put("todayOrders", dashboardMapper.countTodayOrders());

        // ★今日销售额:需要手动计算
        // 因为订单商品存的是JSON文本,不能直接在SQL里SUM
        // 所以在Java代码里逐条解析JSON,累加subtotal
        BigDecimal todaySales = BigDecimal.ZERO;
        List<String> itemsList = dashboardMapper.selectTodayOrderItems();
        for (String itemsJson : itemsList) {
            if (itemsJson == null || itemsJson.isEmpty()) continue;
            try {
                // Hutool的JSONUtil:把JSON字符串解析成List<OrderDetail>
                List<OrderDetail> details =
                    JSONUtil.toList(itemsJson, OrderDetail.class);
                for (OrderDetail d : details) {
                    if (d.getSubtotal() != null) {
                        todaySales = todaySales.add(d.getSubtotal());
                    }
                }
            } catch (Exception ignored) {
                // 如果某条订单的JSON格式坏了(不太可能但安全第一),跳过
            }
        }
        data.put("todaySales", todaySales);

        // 订单状态分布:把SQL查出来的原始列表转换成前端好用的Map
        // SQL返回 [{status:0,cnt:10}, {status:1,cnt:80}] 
        // → 变成 {pending:10, paid:80, shipped:0, completed:5, cancelled:0}
        List<Map<String, Object>> statusList =
            dashboardMapper.countOrdersByStatus();
        Map<String, Integer> statusMap = new HashMap<>();
        // 先给所有状态填默认值0(避免前端读到undefined)
        statusMap.put("pending", 0);   // 对应status=0
        statusMap.put("paid", 0);      // 对应status=1
        statusMap.put("shipped", 0);   // 对应status=2
        statusMap.put("completed", 0); // 对应status=3
        statusMap.put("cancelled", 0); // 对应status=4

        for (Map<String, Object> row : statusList) {
            int st  = ((Number) row.get("status")).intValue();  // 状态值
            int cnt = ((Number) row.get("cnt")).intValue();     // 该状态的数量
            // 根据状态值填到对应的key里
            switch (st) {
                case 0: statusMap.put("pending", cnt);   break;
                case 1: statusMap.put("paid", cnt);      break;
                case 2: statusMap.put("shipped", cnt);   break;
                case 3: statusMap.put("completed", cnt); break;
                case 4: statusMap.put("cancelled", cnt); break;
            }
        }
        data.put("statusCnt", statusMap);

        // 最近记录
        data.put("recentOrders", dashboardMapper.selectRecentOrders(5));
        data.put("recentUsers",  dashboardMapper.selectRecentUsers(5));

        return RespBean.success("", data);
    }
}

今日销售额为什么不能在SQL里直接SUM?

  • 因为订单的商品快照存的是JSON文本,不是结构化列
  • SQL的聚合函数SUM不能直接作用于JSON字段
  • 所以需要把每条订单的items JSON取出来,在Java代码里解析→累加

8. user_service用户服务

8.1 AddrServiceImpl — 地址管理业务逻辑

// 文件:user_service/.../service/AddrServiceImpl.java

@Service
public class AddrServiceImpl implements AddrService {

    @Autowired
    private AddrMapper addrMapper;

    /**
     * 新增地址
     * 业务规则:如果是用户的第一条地址,自动设为默认
     */
    @Override
    public boolean insert(Addr addr) throws StException {
        List<Addr> existing = addrMapper.selectByUserId(addr.getUserId());
        if (existing == null || existing.isEmpty()) {
            // 这是用户的第一条地址 → 自动设为默认
            addr.setStatus(1);
        } else {
            // 用户已有地址 → 保持前端传的状态,
            // 如果前端没传(null),默认为0(非默认)
            addr.setStatus(addr.getStatus() != null ? addr.getStatus() : 0);
        }
        return addrMapper.insert(addr) == 1;
    }

    /**
     * 修改地址
     * 安全规则:①地址必须存在 ②只能改自己的地址 ③userId字段不允许修改
     */
    @Override
    public boolean update(Addr addr, Integer userId) throws StException {
        Addr existing = addrMapper.selectById(addr.getId());
        if (existing == null) {
            throw new StException("地址不存在");
        }
        // ★归属校验:防止用户A通过猜ID来修改用户B的地址
        if (!existing.getUserId().equals(userId)) {
            throw new StException("无权操作该地址");
        }
        // ★防篡改:把userId置null,这样即使前端恶意传了userId也会被忽略
        addr.setUserId(null);
        return addrMapper.update(addr) == 1;
    }

    /**
     * 删除地址(同样要做归属校验)
     */
    @Override
    public boolean delete(Integer id, Integer userId) throws StException {
        Addr existing = addrMapper.selectById(id);
        if (existing == null) throw new StException("地址不存在");
        if (!existing.getUserId().equals(userId))
            throw new StException("无权操作该地址");
        return addrMapper.delete(id) == 1;
    }

    /**
     * 设为默认地址
     * ★事务保证原子性:如果第一步成功第二步失败,状态不一致
     * 例如用户有地址A(默认)和B,想设B为默认:
     * 第一步 UPDATE SET status=0 WHERE user_id=3  → A和B都变成非默认
     * 第二步 UPDATE SET status=1 WHERE id=B       → B变成默认
     * 如果只执行了第一步 → 用户没有默认地址了!
     */
    @Override
    @Transactional   // ★事务注解:两步操作要么全成功,要么全回滚
    public boolean setDefault(Integer id, Integer userId) throws StException {
        Addr existing = addrMapper.selectById(id);
        if (existing == null) throw new StException("地址不存在");
        if (!existing.getUserId().equals(userId))
            throw new StException("无权操作该地址");

        addrMapper.clearDefault(userId);  // ① 清空该用户所有默认状态
        return addrMapper.setDefault(id) == 1;  // ② 把目标设为默认
    }
}

@Transactional的工作原理

方法开始 → 开启数据库事务
  → clearDefault(userId)  → 执行SQL,但还没提交
  → setDefault(id)        → 执行SQL,但还没提交
  → 如果两个都成功 → return → 事务提交(COMMIT) → 两条SQL一起生效
  → 如果setDefault抛异常 → 事务回滚(ROLLBACK)→ 两条SQL都撤销

为什么需要归属校验

// 如果没有归属校验,任何人都可以:
POST /addr  body: {"id": 999, "contact": "黑客"}
// 就把别人的地址改了

// 有归属校验后:
Addr existing = addrMapper.selectById(999);  // 查出地址的原主人
if (!existing.getUserId().equals(userId))    // 原主人 != 当前登录用户
    throw new StException("无权操作");        // → 拒绝

8.2 CollectMapper.xml — 收藏列表查询

<!-- 文件:user_service/.../mapper/CollectMapper.xml -->

<!--
  resultMap 是 MyBatis 的核心功能之一。
  它定义"SQL查出来的行怎么映射成嵌套的Java对象"。

  这里的映射关系:
  collect表(收藏记录)
    └── goods(收藏的商品)
          └── picList(商品的图片列表)

  一条收藏 SQL 会 JOIN 三张表:
    collect c LEFT JOIN goods g ON c.goods_id = g.id
              LEFT JOIN goods_pic gp ON g.id = gp.goods_id
-->
<resultMap id="collectWithGoods" type="collect">
    <!-- 收藏记录本身的字段 -->
    <id column="c_id" property="id"/>
    <result column="goods_id" property="goodsId"/>
    <result column="user_id" property="userId"/>
    <result column="c_create_time" property="createTime"/>

    <!--
      嵌套的 Goods 对象(association = 1对1关联)
      查出来的 g_id, g_name 等列会自动填入 Goods 对象的对应属性
    -->
    <association property="goods"
                 javaType="org.example.mall.common.bean.Goods">
        <id column="g_id" property="id"/>
        <result column="g_name" property="name"/>
        <result column="g_price" property="price"/>
        <result column="g_market_price" property="marketPrice"/>
        <result column="g_status" property="status"/>
        <result column="g_color" property="color"/>
        <result column="g_version" property="version"/>
        <result column="g_count" property="count"/>

        <!--
          Goods 对象里的图片列表(collection = 1对多关联)
          同一 goods_id 下可能有多条 goods_pic 行,
          MyBatis 会自动合并成一个 List<GoodsPic>
        -->
        <collection property="picList"
                    ofType="org.example.mall.common.bean.GoodsPic">
            <id column="gp_id" property="id"/>
            <result column="gp_url" property="url"/>
            <result column="gp_goods_id" property="goodsId"/>
        </collection>
    </association>
</resultMap>

<select id="selectByUserId" resultMap="collectWithGoods">
    SELECT
        c.id AS c_id,
        c.goods_id,
        c.user_id,
        c.create_time AS 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,
        g.color AS g_color,
        g.version AS g_version,
        g.count AS g_count,
        gp.id AS gp_id,
        gp.url AS gp_url,
        gp.goods_id AS gp_goods_id
    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>

为什么之前用<collection select="..."/>子查询会出错?

<!-- ❌ 错误的做法(之前尝试过的) -->
<collection column="c_id" property="picList"
            ofType="goodsPic"
            select="org.example.mall.goods_service.mapper.GoodsPicMapper.selectByGoodsId"/>

问题:GoodsPicMappergoods_service 模块里,而 CollectMapperuser_service 模块里。虽然它们连同一个MySQL数据库,但 MyBatis 的 SqlSessionFactory 是各自独立的——user_service 不知道 goods_service 有什么 Mapper。跨模块引用会抛 MapperStatementNotFoundException

解决方案:不用子查询,直接在SQL里LEFT JOIN goods_pic,用 resultMap<collection> 映射。一次SQL搞定所有数据。


9. goods_service商品服务

9.1 库存扣减 — 并发安全的核心

<!-- 文件:goods_service/.../mapper/GoodsMapper.xml -->

<!--
  扣减库存 SQL
  这是整个项目中最关键的并发安全设计

  【MySQL的行级锁机制】
  UPDATE语句在执行时会自动加行级锁
  两条并发的 UPDATE goods SET count=count-1 WHERE id=45 AND count>=1
  不管谁先执行谁后执行,只有库存够的那条会成功(受影响行数=1)
  库存不够的那条因为 "count>=1" 条件不满足 → 受影响行数=0

  所以不需要在Java代码里写 synchronized 或锁(那样会让整个服务排队)
  数据库帮我们处理了并发!
-->
<update id="reduceStock">
    UPDATE `goods`
    SET `count` = `count` - #{count}
    WHERE `id` = #{id} AND `count` >= #{count}
    <!--      注意这个条件 ↑            -->
    <!--      只有当前库存 >= 要扣的数量时才执行 -->
    <!--      这就是防超卖的核心! -->
</update>
// 文件:goods_service/.../service/GoodsServiceImpl.java

@Override
public boolean reduceStock(Integer goodsId, Integer count) {
    if (goodsId == null || count == null || count <= 0) {
        return false;
    }
    // reduceStock返回受影响的行数:
    //   1 = 扣减成功(库存够+UPDATE执行了)
    //   0 = 扣减失败(库存不够被WHERE拦截了)
    return goodsMapper.reduceStock(goodsId, count) == 1;
}

并发场景演示

假设当前库存 count = 5

时刻T1:用户A下单买3件     → UPDATE SET count=count-3 WHERE id=45 AND count>=3
时刻T2:用户B下单买3件     → UPDATE SET count=count-3 WHERE id=45 AND count>=3
时刻T3:用户C下单买2件     → UPDATE SET count=count-2 WHERE id=45 AND count>=2

MySQL处理顺序:
T1先执行:count=5>=3 → 成功 → count变成2
T2接着执行:count=2<3 → 失败 → 受影响行数=0 → reduceStock返回false
   ↑ 这是MySQL的行级锁保证的:T1执行时锁住了id=45这一行,T2必须等T1提交后才能执行
T3同理等待→count=2>=2 → 成功 → count变成0

9.2 GoodsMapper.xml — resultMap关联分类和图片

<!-- 文件:goods_service/.../mapper/GoodsMapper.xml -->

<!-- 这个resultMap定义了"查goods表时自动带出分类和图片" -->
<resultMap id="goodsMap" type="goods">
    <id column="id" property="id"/>
    <!-- ... 其他字段 ... -->
    <result column="category_id" property="categoryId"/>

    <!-- association: 1对1(一个商品只属于一个分类) -->
    <association column="category_id" property="category"
                 javaType="category"
                 select="org.example...CategoryMapper.selectById"/>
    <!-- ↑ MyBatis会调用这个子查询:SELECT * FROM category WHERE id = goods.category_id -->

    <!-- collection: 1对多(一个商品有多张图片) -->
    <collection column="id" property="picList"
                ofType="goodsPic"
                select="org.example...GoodsPicMapper.selectByGoodsId"/>
    <!-- ↑ MyBatis会调用这个子查询:SELECT * FROM goods_pic WHERE goods_id = goods.id
         注意:这里是同模块内的引用(GoodsPicMapper也在goods_service里),所以没问题 -->
</resultMap>

10. order_service订单服务

这是本项目最复杂的微服务,包含下单、支付、回调、定时取消等核心业务逻辑。我们将逐段深入分析。

10.1 OrderServiceImpl — 下单核心

// 文件:order_service/.../service/OrderServiceImpl.java

@Service
public class OrderServiceImpl implements OrderService {

    private static final Logger log =
        Logger.getLogger(OrderServiceImpl.class.getName());

    @Autowired private OrderMapper orderMapper;      // 订单数据库操作
    @Autowired private CartMapper cartMapper;        // 购物车数据库操作
    @Autowired private AlipayClient alipayClient;     // 支付宝SDK客户端
    @Autowired private AlipayConfig alipayConfig;      // 支付宝配置

    @DubboReference  // ← 远程调用goods_service
    private GoodsService goodsService;

    // ═══════════════════════════════════════════════════
    // 工具方法:解析items JSON → 计算totalPrice
    // ═══════════════════════════════════════════════════

    /**
     * 把订单的items JSON字符串解析成 detailList(商品列表)
     * 并计算出 totalPrice(订单总金额)
     * 
     * 每当从数据库查出Order对象时,都要调用这个方法来回填数据
     */
    private void fillDetailAndPrice(Order order) {
        if (order == null) return;

        // 解析items JSON → List<OrderDetail>
        if (order.getItems() != null && !order.getItems().isEmpty()) {
            order.setDetailList(
                JSONUtil.toList(order.getItems(), OrderDetail.class)
            );
        } else {
            order.setDetailList(new ArrayList<>());  // 空列表,避免null
        }

        // 累加每项商品的subtotal,得到订单总金额
        BigDecimal total = BigDecimal.ZERO;
        for (OrderDetail d : order.getDetailList()) {
            if (d.getSubtotal() != null) {
                total = total.add(d.getSubtotal());
            } else if (d.getPrice() != null && d.getCount() != null) {
                // 兼容旧数据:如果subtotal没存,用price×count计算
                total = total.add(
                    d.getPrice().multiply(new BigDecimal(d.getCount()))
                );
            }
        }
        order.setTotalPrice(total);
    }

    /**
     * 取商品的第一张图片URL
     */
    private String firstPic(Goods goods) {
        if (goods != null &&
            goods.getPicList() != null &&
            !goods.getPicList().isEmpty()) {
            GoodsPic pic = goods.getPicList().get(0);
            return pic == null ? null : pic.getUrl();
        }
        return null;
    }

    /**
     * 拼接收货地址的完整文本
     * 把省/市/区/街道/门牌号/收件人/手机号拼成一行:
     * "山东省青岛市城阳区城阳街道 农大西苑A座 - 汤姆 - 13322332233"
     * 
     * ★这就是"地址快照":下单时拼好存进order,以后地址改了也不影响历史订单
     */
    private String buildAddrDetail(Integer addrId) throws StException {
        Addr addr = orderMapper.selectAddrById(addrId);
        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();
    }

    // ═══════════════════════════════════════════════════
    // 购物车结算下单
    // ═══════════════════════════════════════════════════

    /**
     * 从购物车结算:把多个购物车项变成一个订单
     * 
     * @param order    前端传来的订单参数(地址ID、支付方式)
     * @param cartIds  用户选中的购物车项ID列表
     * @param userId   当前登录用户ID(从JWT解析得来)
     * 
     * @Transactional 保证订单插入+购物车删除在同一事务里
     *   如果插入订单成功了但删除购物车失败了 → 事务回滚 → 订单也撤销
     *   → 用户下次还可以重新结算(不会出现"订单已创建但购物车没清"的脏状态)
     */
    @Override
    @Transactional(rollbackFor = StException.class)
    public Order create(Order order, List<Integer> cartIds, Integer userId)
            throws StException {

        if (cartIds == null || cartIds.isEmpty()) {
            throw new StException("请选择要结算的商品");
        }

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

        // 遍历每个选中的购物车项
        for (Integer cartId : cartIds) {
            // ① 查购物车项
            Cart cart = cartMapper.selectById(cartId);
            if (cart == null)
                throw new StException("购物车项不存在:" + cartId);

            // ② 验证购物车归属(不能结算别人的购物车)
            if (!cart.getUserId().equals(userId))
                throw new StException("无权结算该购物车项:" + cartId);

            // ③ 通过Dubbo查商品信息(跨服务调用goods_service)
            Goods goods = goodsService.selectById(cart.getGoodsId());
            if (goods == null)
                throw new StException("商品不存在或已下架");

            // ④ 检查库存(注意:只检查不扣减,真正的扣减发生在支付成功后)
            if (cart.getCount() > goods.getCount())
                throw new StException("商品【" + goods.getName() + "】库存不足");

            // ⑤ 构造订单明细快照
            //    记录下单时的价格(snapshot),以后商品调价不影响历史订单
            BigDecimal subtotal =
                goods.getPrice().multiply(new BigDecimal(cart.getCount()));

            detailList.add(new OrderDetail(
                goods.getId(),       // 商品ID
                goods.getName(),     // 商品名(快照)
                firstPic(goods),     // 商品主图(快照)
                goods.getPrice(),    // ★下单时的售价(快照)
                cart.getCount(),     // 购买数量
                subtotal             // 小计
            ));
        }

        // ⑥ 统一落库
        return persistOrder(order, userId, detailList, cartIds);
    }

    // ═══════════════════════════════════════════════════
    // 直接购买下单(从商品详情页,跳过购物车)
    // ═══════════════════════════════════════════════════

    @Override
    @Transactional(rollbackFor = StException.class)
    public Order createDirect(Integer goodsId, Integer count,
            Integer addrId, Integer payType, Integer userId)
            throws StException {

        if (goodsId == null || count == null || count <= 0)
            throw new StException("购买信息不合法");

        Goods goods = goodsService.selectById(goodsId);
        if (goods == null)
            throw new StException("商品不存在或已下架");
        if (count > goods.getCount())
            throw new StException("商品库存不足");

        BigDecimal subtotal =
            goods.getPrice().multiply(new BigDecimal(count));

        List<OrderDetail> detailList = new ArrayList<>();
        detailList.add(new OrderDetail(
            goods.getId(), goods.getName(), firstPic(goods),
            goods.getPrice(), count, subtotal));

        Order order = new Order();
        order.setAddrId(addrId);
        order.setPayType(payType);

        // cartIds传null → 不删购物车
        return persistOrder(order, userId, detailList, null);
    }

    // ═══════════════════════════════════════════════════
    // 统一落库逻辑(购物车结算和直接购买共用)
    // ═══════════════════════════════════════════════════

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

        // ① 生成雪花算法订单号
        order.setId(IdUtil.getSnowflakeNextId());
        // ② 设置用户ID
        order.setUserId(userId);
        // ③ 初始状态:待付款
        order.setStatus(0);
        // ④ 生成地址快照
        order.setAddrDetail(buildAddrDetail(order.getAddrId()));
        // ⑤ 把商品列表序列化成JSON字符串
        order.setItems(JSONUtil.toJsonStr(detailList));
        // ⑥ 插入订单表
        orderMapper.insert(order);

        // ⑦ 如果是从购物车结算的 → 删除已购买的购物车项
        //    这一步和⑥在同一事务中(@Transactional)
        if (cartIds != null && !cartIds.isEmpty()) {
            cartMapper.deleteByIds(cartIds);
        }

        // ⑧ 回填detailList和totalPrice(返回给前端展示)
        order.setDetailList(detailList);
        fillDetailAndPrice(order);
        return order;
    }

    // ═══════════════════════════════════════════════════
    // 支付宝支付:生成跳转表单
    // ═══════════════════════════════════════════════════

    @Override
    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);
        if (order.getTotalPrice().compareTo(BigDecimal.ZERO) <= 0)
            throw new StException("订单金额异常");      // 金额校验

        // ④ 构造支付宝PC网站支付请求
        AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
        request.setNotifyUrl(alipayConfig.getNotifyUrl());  // 异步通知URL
        request.setReturnUrl(alipayConfig.getReturnUrl());  // 同步跳转URL

        // ★用JSONObject构造bizContent,不用手拼字符串(避免引号/特殊字符问题)
        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");          // 产品码
        biz.set("timeout_express", "30m");  // ★30分钟未付款→支付宝自动关闭交易
        request.setBizContent(biz.toString());

        // ⑤ 调用支付宝SDK → 拿到自提交表单的HTML字符串
        try {
            return alipayClient.pageExecute(request).getBody();
        } catch (Exception e) {
            throw new StException("生成支付表单失败:" + e.getMessage());
        }
    }

    // ═══════════════════════════════════════════════════
    // 支付宝异步回调(notifyUrl)
    // ═══════════════════════════════════════════════════

    @Override
    public boolean notifyPay(Map<String, String> params) {
        try {
            // ① 签验:确保请求确实来自支付宝(不是伪造的)
            boolean signOk = AlipaySignature.rsaCheckV1(
                params,
                alipayConfig.getPublicKey(),  // 支付宝公钥
                "UTF-8",
                alipayConfig.getSignType()    // "RSA2"
            );
            if (!signOk) {
                return false;  // 签名不对 → 返回"failure"
            }

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

            // ③ 从支付宝返回的参数中提取订单号,然后更新订单状态
            Long orderId = Long.valueOf(params.get("out_trade_no"));
            return markPaid(orderId);

        } catch (Exception e) {
            log.warning("notifyPay 异常: " + e.getMessage());
            return false;
        }
    }

    // ═══════════════════════════════════════════════════
    // 主动查询支付宝(returnUrl兜底方案)
    // ═══════════════════════════════════════════════════

    @Override
    public boolean queryAndUpdate(Long id) {
        // 只处理待付款的订单(已处理过的跳过)
        Order order = orderMapper.selectById(id);
        if (order == null || order.getStatus() != 0) {
            return false;
        }

        try {
            // 构造支付宝查询请求
            AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
            JSONObject biz = new JSONObject()
                .set("out_trade_no", String.valueOf(id));
            request.setBizContent(biz.toString());

            // 调用支付宝查询接口
            AlipayTradeQueryResponse response = alipayClient.execute(request);
            if (response == null) return false;

            // 支付宝说支付成功了 → 更新订单
            if ("TRADE_SUCCESS".equals(response.getTradeStatus())) {
                return markPaid(id);
            }

            return false;
        } catch (Exception e) {
            return false;
        }
    }

    // ═══════════════════════════════════════════════════
    // 标记订单为"已付款"(幂等操作)
    // ═══════════════════════════════════════════════════

    /**
     * ★幂等设计:这个方法可以被多次调用,但只有第一次真正执行
     * 支付宝可能在1秒内连续发多次notify(网络重试)
     * 如果没有幂等保护 → 第一次改status=1成功,第二次又扣一次库存 → 重复扣
     * 
     * 幂等保护:status!=0 → return true(什么都不做)
     * 第一次调用:status=0 → 改status=1 → 扣库存 → return true
     * 第二次调用:status=1(已被第一次改了)→ return true(跳过)
     * 
     * ★没有@Transactional:改status是单条UPDATE(MySQL自己保证原子性)
     * 扣库存是Dubbo RPC(独立事务),不放在同一本地事务里
     * 先改状态(保证支付闭环:钱已付,必须显示"已付款")
     * 再扣库存(尽力而为:失败记日志,人工补货/退款)
     */
    private boolean markPaid(Long orderId) {
        // ★幂等检查
        Order order = orderMapper.selectById(orderId);
        if (order == null || order.getStatus() != 0) {
            return true;  // 已经处理过了
        }

        fillDetailAndPrice(order);

        // ① 改订单状态(先做,保证钱不白付)
        Order upd = new Order();
        upd.setId(orderId);
        upd.setStatus(1);  // 待付款 → 已付款
        orderMapper.updateById(upd);
        // 这一条UPDATE是原子操作,不需要事务包裹

        // ② 扣库存(尽力而为,不计较成败)
        for (OrderDetail d : order.getDetailList()) {
            try {
                if (!goodsService.reduceStock(d.getGoodsId(), d.getCount())) {
                    log.warning("库存扣减失败: orderId=" + orderId +
                        " goodsId=" + d.getGoodsId() +
                        " goodsName=" + d.getGoodsName());
                }
            } catch (Exception e) {
                log.warning("库存扣减异常: orderId=" + orderId +
                    " goodsId=" + d.getGoodsId() + " " + e.getMessage());
            }
        }

        return true;
    }

    // ═══════════════════════════════════════════════════
    // 查询用户订单列表
    // ═══════════════════════════════════════════════════

    @Override
    public List<Order> selectByUserId(Integer userId) {
        List<Order> list = orderMapper.selectByUserId(userId);
        // 每条订单都要解析items → 填detailList和totalPrice
        list.forEach(this::fillDetailAndPrice);
        return list;
    }

    // ... search, selectById, updateStatus, delete 等方法同样处理
}

10.2 OrderMapper.xml — 订单SQL

<!-- 文件:order_service/.../mapper/OrderMapper.xml -->

<!-- 插入订单:id由程序生成(雪花算法)、create_time用MySQL的NOW() -->
<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>

<!-- 查询用户订单列表(带用户名) -->
<select id="selectByUserId" resultMap="orderMap">
    SELECT o.*, u.username
    FROM `order` o
    LEFT JOIN `user` u ON o.user_id = u.id
    WHERE o.user_id = #{userId}
    ORDER BY o.create_time DESC
</select>

<!-- 查询地址表(供buildAddrDetail用) -->
<select id="selectAddrById" resultType="org.example.mall.common.bean.Addr">
    SELECT * FROM `addr` WHERE `id` = #{id}
</select>

<!-- 定时取消超时未付订单 -->
<update id="cancelExpiredOrders">
    UPDATE `order` SET `status` = 4
    WHERE `status` = 0
      AND `create_time` &lt; #{before}
    <!-- &lt; 是 < 的XML转义写法 -->
</update>

10.3 OrderTimeoutTask — 定时取消

// 文件:order_service/.../task/OrderTimeoutTask.java

@Component  // Spring管理的组件
public class OrderTimeoutTask {

    private static final Logger log =
        Logger.getLogger(OrderTimeoutTask.class.getName());

    /** 超时阈值:30分钟 */
    private static final long TIMEOUT_MS = 30L * 60 * 1000;

    @Autowired
    private OrderMapper orderMapper;

    /**
     * @Scheduled:Spring的定时任务注解
     * fixedRate = 5 * 60 * 1000 = 每5分钟执行一次
     * 
     * 执行逻辑:查出所有超过30分钟还没付款的订单,批量设为"已取消"
     */
    @Scheduled(fixedRate = 5 * 60 * 1000)
    public void cancelExpiredOrders() {
        // 计算"30分钟前"的时间点
        Date before = new Date(System.currentTimeMillis() - TIMEOUT_MS);

        // 批量更新
        int n = orderMapper.cancelExpiredOrders(before);
        if (n > 0) {
            log.info("已自动取消超时未支付订单 " + n + " 笔");
        }
    }
}
// 启用定时任务:在启动类上加 @EnableScheduling
// 文件:order_service/.../OrderServiceApplication.java

@SpringBootApplication(scanBasePackages = {"org.example.mall"})
@MapperScan(basePackages = "org.example.mall.order_service.mapper")
@EnableDubbo         // 启用Dubbo(作为消费者调用goods_service)
@EnableScheduling    // ★启用定时任务
public class OrderServiceApplication {
    // ...
}

10.4 OrderController — 取消订单的归属校验

// 文件:order_service/.../controller/OrderController.java

/**
 * ★关键:用户取消自己的订单(和后台管理员改状态是不同的接口)
 * 
 * 为什么要区分?
 * 后台 PUT /order/status 不应该做归属校验(管理员可以改任何订单的状态)
 * 前台用户只能取消自己的订单,必须做归属校验
 * 如果前台用户调用后台接口 PUT /order/status 也可以取消订单,
 *    而且没有归属校验 → 用户可以取消任何人的订单!
 */
@PutMapping("/cancel/{id}")
public RespBean cancel(@PathVariable Long id,
                       @RequestHeader String token) throws StException {
    Integer userId = (Integer) 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("订单已取消");
}

11. 前端基础

11.1 Vue 3单文件组件的结构

<!-- 文件:front/src/views/user/GoodsView.vue(简化版) -->

<script setup>
// ← setup=语法糖,不需要写 export default {}
//   变量、函数直接暴露给模板使用

import { ref } from "vue";
// ref() 是Vue的响应式API:值变了模板自动重新渲染

const goods = ref({});
//      ↑   ↑
//      |   初始值(当数据还没从后端加载时,模板可以用这个空对象占位)
//      包装后的响应式变量

function selectById() {
    let id = route.query.id;
    goodsApi.selectById(id).then(resp => {
        goods.value = resp.data;  // ← .value 修改ref的值
        // 这一行执行后,模板里 {{ goods.name }} 会自动更新
    });
}
</script>

<template>
    <!-- 模板中可以省略 .value,直接写 goods.name -->
    <div class="name">{{ goods.name }}</div>
    <div class="price">¥{{ goods.price }}</div>
    <el-button @click="selectById">加载商品</el-button>
</template>

<style scoped>
/* scoped = 只作用于当前组件,不影响其他组件 */
.name { font-weight: bold; font-size: 25px; }
.price { color: #FF6800; }
</style>

11.2 项目入口和全局配置

// 文件:front/src/main.js

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const app = createApp(App)    // 创建Vue应用实例

// ★全局注册所有Element Plus图标
// 这样模板里可以直接用 <HomeFilled/> <User/> 等图标,不用每个页面单独import
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component)
}

// 配置Pinia状态管理 + 持久化插件
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)  // ← 自动把store数据存到sessionStorage

app.use(pinia)         // 注册状态管理
app.use(router)         // 注册路由
app.use(ElementPlus)    // 注册UI组件库
app.mount('#app')       // 挂载到index.html的<div id="app">

11.3 店铺全局CSS变量

/* 文件:front/src/assets/base.css */

:root {
    --theme-color: #FF6800;         /* 主色调:橙色 */
    --theme-bg-color: #303133;      /* 深色背景色 */
}

整个项目中所有用到主题色的地方都写 var(--theme-color),如果要换颜色,只改这一个地方就行。


12. HTTP层

12.1 Axios实例 + 拦截器双向自动化

// 文件:front/src/api/index.js

import axios from "axios";
import { userTokenStore } from "@/stores/token.js";
import { ElMessage } from "element-plus";
import router from "@/router/index.js";

// 创建Axios实例,配置基础URL
const service = axios.create({
    baseURL: import.meta.env.VITE_SERVER_ADDR  // 打包时从.env注入
    // 开发/生产环境自动切换:本地npm run dev用开发地址,打包后用生产地址
});

// ══════════════════════════════════════════
// ★请求拦截器:发请求前自动干的事
// ══════════════════════════════════════════
service.interceptors.request.use(req => {
    // 从Pinia store取出当前token
    const tokenStore = userTokenStore();
    if (tokenStore.tokenStr) {
        req.headers.token = tokenStore.tokenStr;  // 放请求头里
    }
    return req;  // 返回修改后的请求配置
}, error => {});

// ══════════════════════════════════════════
// ★响应拦截器:收到响应后自动干的事
// ══════════════════════════════════════════
service.interceptors.response.use(resp => {
    // ① 后端每次返回新JWT → 自动更新本地
    const token = resp.headers.token;
    if (token) {
        const tokenStore = userTokenStore();
        tokenStore.updateToken(token);
    }

    // ② ★直接返回resp.data(JSON对象),不是整个Axios响应
    // 这样调用方写的是 resp.code 而不是 resp.data.code
    return resp.data;

}, error => {
    // ③ 403错误 → 弹提示 + 清token + 跳登录页
    if (error.status === 403) {
        ElMessage.error({
            message: "令牌错误,重新登录",
            duration: 1200,
            onClose: () => {
                const tokenStore = userTokenStore();
                tokenStore.$reset();
                let currentPath = router.currentRoute.value.path;
                if (currentPath.startsWith("/admin")) {
                    router.push("/admin/login");
                } else {
                    router.push("/user/login");
                }
            }
        });
    }
});

export default service;

关键设计

  • baseURL 统一配置 → 不需要在每个API函数里写完整URL
  • 请求拦截器自动带token → 每个API函数不用手动传token
  • 响应拦截器自动刷新token → 用户只要持续操作就不会过期
  • 返回resp.data → 调用方拿到的是解析好的JSON对象,简洁

13. 核心页面

13.1 CreateOrderView — 结算页完整代码分析

<!-- 文件:front/src/views/user/CreateOrderView.vue -->

<script setup>
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import cartApi from "@/api/cartApi.js";
import goodsApi from "@/api/GoodsApi.js";
import addrApi from "@/api/addrApi.js";
import orderApi from "@/api/orderApi.js";

const route = useRoute();
const router = useRouter();
const SERVER_ADDR = ref(import.meta.env.VITE_SERVER_ADDR);

// ====== 状态管理 ======

// 结算模式:cart=购物车结算,direct=直接购买
const mode = ref("cart");

// 购物车结算的cartId数字数组
const cartIdArr = ref([]);

// 商品明细:统一的显示格式(不管来自购物车还是直接购买)
const detailList = ref([]);

// 收货地址
const addrList = ref([]);
const selectedAddrId = ref(null);

// 支付方式:1支付宝 2微信 3银行卡(支付宝沙箱:只有1是真正可支付的)
const payType = ref(1);

const submitting = ref(false);

// ====== 计算属性 ======

// 订单总金额 = 所有商品小计的累加
const totalPrice = computed(() =>
    detailList.value.reduce((sum, d) => sum + Number(d.subtotal || 0), 0)
);

// ====== 数据加载 ======

function loadAddrs() {
    addrApi.selectByUserId().then(resp => {
        if (resp.code === 200) {
            addrList.value = resp.data || [];
            // 自动选中默认地址
            const def = addrList.value.find(a => a.status === 1);
            selectedAddrId.value = def ? def.id : (addrList.value[0]?.id || null);
        }
    });
}

// 购物车结算模式:根据cartIds加载对应的购物车项
function loadCartItems(cartIds) {
    cartApi.selectBycodition().then(resp => {
        const all = resp.data || [];
        detailList.value = all
            .filter(c => cartIds.includes(c.id))  // 只保留选中的
            .map(c => ({                          // 转换成统一格式
                goodsId: c.goodsId,
                goodsName: c.goods ? c.goods.name : "商品已下架",
                goodsPic: c.goods?.picList?.[0]?.url || null,
                price: c.goods ? c.goods.price : 0,
                count: c.count,
                subtotal: c.goods ? (c.goods.price * c.count).toFixed(2) : 0
            }));
    });
}

// 直接购买模式:根据goodsId+count加载单个商品
function loadDirectGoods(goodsId, count) {
    goodsApi.selectById(goodsId).then(resp => {
        const g = resp.data;
        detailList.value = [{
            goodsId: g.id,
            goodsName: g.name,
            goodsPic: g.picList?.[0]?.url || null,
            price: g.price,
            count: count,
            subtotal: (g.price * count).toFixed(2)
        }];
    });
}

// ====== 提交订单 → 支付 ======

/**
 * ★核心流程:下单 AND 支付串成一条链
 * 
 * 为什么这样做?因为之前分开"下单成功"和"支付失败"两条消息很困惑
 * 现在:
 *   - 下单成功 + 支付成功 → 直接跳转支付宝(用户看到的是跳转动作)
 *   - 下单成功 + 支付失败 → 弹一条消息:"订单已创建,请到我的订单重新支付"
 *   - 下单失败 → 弹一条消息:"下单失败,请重试"
 */
function submitOrder() {
    // 前置检查
    if (!selectedAddrId.value) { ElMessage.warning("请选择收货地址"); return; }
    if (detailList.value.length === 0) { ElMessage.warning("没有可结算的商品"); return; }

    submitting.value = true;

    // 第一步:下单
    const apiCall = mode.value === "cart"
        ? orderApi.create({ addrId: selectedAddrId.value, payType: payType.value, cartIds: cartIdArr.value })
        : orderApi.createDirect({ goodsId: Number(route.query.goodsId), count: Number(route.query.count || 1), addrId: selectedAddrId.value, payType: payType.value });

    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("订单已创建,支付发起失败"));
    })
    .catch(() => ElMessage.error("下单失败,请重试"))
    .finally(() => submitting.value = false);
}

/**
 * ★替代 document.write 的支付表单提交方法
 * 
 * 以前的做法:document.write(html) → 整个Vue应用被清空 → 表单异常时白屏
 * 现在的做法:创建隐藏div → innerHTML → 找到form → form.submit()
 *   - 如果form存在 → 提交跳转到支付宝(页面跳转前Vue还活着)
 *   - 如果form不存在 → 弹错误提示 + 清理DOM(不会白屏)
 */
function submitAlipayForm(html) {
    const container = document.createElement('div');
    container.style.display = 'none';
    container.innerHTML = html;
    document.body.appendChild(container);
    const form = container.querySelector('form');
    if (form) {
        form.submit();  // 跳转到支付宝
    } else {
        ElMessage.error('支付表单异常,请到「我的订单」重新支付');
        document.body.removeChild(container);
    }
}

// ====== 组件初始化 ======

onMounted(() => {
    loadAddrs();  // 先加载地址列表

    // 判断是购物车结算还是直接购买
    if (route.query.cartIds) {
        mode.value = "cart";
        // ★兼容处理:Vue Router的query数组可能是数组、单字符串、或cartIds[]格式
        const raw = route.query.cartIds || route.query["cartIds[]"];
        if (Array.isArray(raw)) {
            cartIdArr.value = raw.map(v => Number(v));
        } else {
            cartIdArr.value = String(raw).split(",").map(v => Number(v));
        }
        cartIdArr.value = cartIdArr.value.filter(v => !isNaN(v));
        loadCartItems(cartIdArr.value);
    } else if (route.query.goodsId) {
        mode.value = "direct";
        loadDirectGoods(Number(route.query.goodsId), Number(route.query.count || 1));
    } else {
        ElMessage.warning("未选择商品,请返回重新选择");
    }
});
</script>

13.2 UserCenterView — 个人中心数据加载策略

// 文件:front/src/views/user/UserCenterView.vue(简化摘录)

/**
 * ★按需加载策略:切换tab时才加载对应数据
 * 不是一进入个人中心就加载所有5个面板的数据
 * 而是只有用户切换到"我的收藏"时,才去加载收藏数据
 * 这样可以减少不必要的网络请求
 */
function onTabChange(key) {
    if (key === 'collect') loadCollects();  // 切到收藏 → 加载收藏
    if (key === 'addr')    loadAddrs();     // 切到地址 → 加载地址
    if (key === 'orders')  loadOrders();    // 切到订单 → 加载订单
}

13.3 Admin IndexView — 仪表盘安全合并数据

// 文件:front/src/views/admin/IndexView.vue(关键片段)

/**
 * 仪表盘数据加载
 * 
 * ★为什么不能直接 dash.value = resp.data?
 * 因为后端可能不返回某个字段(比如todaySales可能在早期的代码版本里没有)
 * 直接覆盖 → 那个字段变成undefined → 模板里 Number(undefined) = NaN → 崩溃
 * 
 * 安全合并:只覆盖后端有值的字段,没值的保持初始默认值
 */
function loadDashboard() {
    loading.value = true;
    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];
                    //                  ↑ != null 同时排除 null 和 undefined
                });

            // statusCnt:用Object.assign逐key覆盖
            // 如果后端只返回了3个状态 → 另外2个状态保持默认值0
            if (d.statusCnt) {
                Object.assign(dash.value.statusCnt, d.statusCnt);
            }
        }
    }).catch(() => ElMessage.error("加载仪表盘失败"))
      .finally(() => loading.value = false);
}

13.4 PayResultView — 支付结果查询

<script setup>
import { ref, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import orderApi from "@/api/orderApi.js";
import { CircleCheck, Clock } from "@element-plus/icons-vue";

const route = useRoute();
const router = useRouter();
const loading = ref(true);
const paid = ref(false);
const orderInfo = ref(null);

/**
 * 支付宝跳回来时,URL是:
 * http://www.dxkua.cn/#/user/payResult?out_trade_no=1876543210987654321
 * 
 * route.query.out_trade_no 就是订单号
 * 
 * 调/order/query/{id} → 后端主动查支付宝 → 如果支付成功同步状态
 */
onMounted(() => {
    const orderId = route.query.out_trade_no;
    if (!orderId) { loading.value = false; return; }

    orderApi.queryStatus(orderId).then(resp => {
        if (resp.code === 200) {
            orderInfo.value = resp.data;
            paid.value = resp.data && resp.data.status === 1;
        }
    }).catch(() => ElMessage.error("查询支付状态失败"))
      .finally(() => loading.value = false);
});
</script>

14. nginx反向代理

# 文件:nginx/conf/nginx.conf

# worker_processes = nginx的工作进程数(通常设为CPU核心数)
worker_processes 1;

events {
    worker_connections 1024;  # 每个工作进程允许的最大并发连接数
}

http {
    include mime.types;       # 包含MIME类型映射(告诉浏览器文件是什么类型)
    default_type application/octet-stream;
    sendfile on;
    keepalive_timeout 65;

    server {
        listen 80;            # 监听HTTP默认端口
        server_name localhost;

        # ═══════════════════════════════════════
        # 每个 location 块 = 一条转发规则
        # 格式:location /路径前缀 { proxy_pass 后端地址; }
        # ═══════════════════════════════════════

        location /category {
            proxy_pass http://localhost:8090;
            # proxy_pass 只转发请求,不修改路径
            # 请求 /category/search → 转发到 http://localhost:8090/category/search
        }

        location /goods    { proxy_pass http://localhost:8090; }
        location /admin    { proxy_pass http://localhost:8091; }
        location /user     { proxy_pass http://localhost:8092; }
        location /collect  { proxy_pass http://localhost:8092; }
        location /addr     { proxy_pass http://localhost:8092; }  # ★新增
        location /cart     { proxy_pass http://localhost:8093; }
        location /order    { proxy_pass http://localhost:8093; }  # ★新增

        # ★注意:如果图片上传到本地目录,还需要配置静态文件访问
        # 本项目图片通过Spring Boot的 ResourceHandler 直接提供
        # 所以 nginx 不需要额外配置图片路径

        error_page 500 502 503 504 /50x.html;
        location = /50x.html { root html; }
    }
}

测试配置并重载

nginx -t          # 测试配置文件语法是否正确
nginx -s reload   # 平滑重载(不中断现有连接)

15. 支付宝支付

15.1 完整时序图

时间线               用户浏览器                前端代码              后端代码              支付宝服务器
──────              ─────────                ────────              ────────              ──────────

T0 用户点击              │                       │                    │                    │
[提交订单并支付]        │                       │                    │                    │
                        │                       │ → submitOrder()     │                    │
                        │                       │   POST /order       │                    │
                        │                       │ ──────────────────→ │                    │
                        │                       │                    │ create():           │
                        │                       │                    │ - 遍历cartIds       │
                        │                       │                    │ - Dubbo查商品信息   │
                        │                       │                    │ - 构造OrderDetail   │
                        │                       │                    │ - 雪花ID+items JSON  │
                        │                       │                    │ - INSERT order      │
                        │                       │                    │ - DELETE cart       │
                        │                       │   {code:200,        │ ← all in @Transactional
                        │                       │    data:{id:"187.."}}│                    │
                        │                       │ ←────────────────── │                    │
                        │                       │                    │                    │
                        │                       │ → 链式调用pay       │                    │
                        │                       │   GET /order/pay/   │                    │
                        │                       │   {id}              │                    │
                        │                       │ ──────────────────→ │                    │
                        │                       │                    │ pay():              │
                        │                       │                    │ - 校验归属+status=0 │
                        │                       │                    │ - fillDetailAndPrice│
                        │                       │                    │ - 构造bizContent    │
                        │                       │                    │ - pageExecute()     │
                        │                       │   {code:200,        │   → Alipay API     │
                        │                       │    data:"<form...>"}│ ← HTML表单字符串    │
                        │                       │ ←────────────────── │                    │
                        │                       │                    │                    │
                        │                       │ submitAlipayForm()   │                    │
                        │                       │ - 创建隐藏div       │                    │
                        │                       │ - innerHTML=表单   │                    │
                        │                       │ - form.submit()     │                    │
                        │                       │ ↓                  │                    │
                        │ 浏览器跳转到支付宝     │                    │                    │
                        │ ══════════════════════════════════════════→ │                    │
                        │                       │                    │                    │ 支付宝沙箱页面
                        │                       │                    │                    │ 用户输入账号密码
T1 用户在支付宝付款     │                       │                    │                    │ 确认支付
                        │                       │                    │                    │
                        │                       │                    │                    │ 扣款成功!
                        │                       │                    │                    │
T2 双通道通知           │                       │                    │                    │
   ┌────────────────── 同步通道 ─────────────────────────────────────────────┐
   │ 浏览器被重定向到    │                       │                    │      │ GET returnUrl
   │ /#/user/payResult   │                       │                    │      │ ?out_trade_no=xxx
   │ ?out_trade_no=xxx   │                       │                    │      │
   │                      │ PayResultView挂载     │                    │      │
   │                      │ GET /order/query/{id} │                    │      │
   │                      │ ──────────────────────→                    │      │
   │                      │                      │ queryAndUpdate()    │      │
   │                      │                      │ - 调支付宝查询接口  │      │
   │                      │                      │ ───────────────────→│      │
   │                      │                      │ ← TRADE_SUCCESS ───│      │
   │                      │                      │ - markPaid(id)      │      │
   │                      │                      │   UPDATE status=1   │      │
   │                      │                      │   扣库存(尽力而为)   │      │
   │                      │ {code:200,            │                    │      │
   │                      │  data:{status:1,..}} │                    │      │
   │                      │ ←─────────────────────                      │      │
   │                      │ 显示"支付成功"        │                    │      │
   └────────────────────────────────────────────────────────────────────────┘
   ┌────────────────── 异步通道 ─────────────────────────────────────────────┐
   │                      │                       │                    │ POST notifyUrl    │
   │                      │                       │ ← /order/notify ──│                   │
   │                      │                       │ rsaCheckV1验签    │                   │
   │                      │                       │ TRADE_SUCCESS     │                   │
   │                      │                       │ markPaid(id)      │                   │
   │                      │                       │ 幂等检查:status=1  │                   │
   │                      │                       │ → 已处理过, return │                   │
   │                      │                       │ response="success" │                   │
   │                      │                       │ ──────────────────→│                   │
   └────────────────────────────────────────────────────────────────────────┘

15.2 双通道的必要性

通道 触发方 可靠性 失败处理
同步(returnUrl) 用户浏览器GET 不可靠(用户可能付款后立刻关闭页面) queryAndUpdate主动查支付宝
异步(notifyUrl) 支付宝服务器POST 较可靠(支付宝会重试) 返回”failure” → 支付宝重新发送

两者缺一不可

  • 如果只有同步没有异步:用户付款后立刻关页面 → 订单永远卡在”待付款”
  • 如果只有异步没有同步:notifyUrl可能被防火墙拦(本地开发时绝对不可达) → 没有兜底
  • 现在两者都有:异步是主力(支付宝服务器推送),同步是兜底(用户浏览器查)

15.3 AlipayConfig — 配置说明

# 文件:order_service/.../application.yml

alipay:
  # 应用ID(在支付宝开放平台/沙箱控制台获取)
  appId: ${ALIPAY_APP_ID:}
  # ↑ ${KEY:默认值} = 先读环境变量KEY,没有则使用默认值

  # 应用私钥(用于签名请求)— ★绝不能写死在这里,必须环境变量注入
  privateKey: ${ALIPAY_PRIVATE_KEY:}

  # 支付宝公钥(用于验证支付宝返回的签名)
  publicKey: ${ALIPAY_PUBLIC_KEY:}

  # 沙箱网关(正式环境换成 https://openapi.alipay.com/gateway.do)
  gateway: ${ALIPAY_GATEWAY:https://openapi-sandbox.dl.alipaydev.com/gateway.do}

  # 签名算法(RSA2=使用SHA-256的RSA签名)
  signType: RSA2

  # 支付完成后浏览器跳回的前端URL
  returnUrl: ${ALIPAY_RETURN_URL:http://www.dxkua.cn/#/user/payResult}

  # 支付完成后支付宝服务器POST的后端URL
  notifyUrl: ${ALIPAY_NOTIFY_URL:http://www.dxkua.cn/order/notify}
// 文件:order_service/.../config/AlipayConfig.java

@Data
@Configuration
@ConfigurationProperties(prefix = "alipay")
public class AlipayConfig {
    private String appId;       // ← Spring自动从yml的 alipay.app-id 读取
    private String privateKey;  // ← 自动从 alipay.private-key 读取
    private String publicKey;   // ← kebab-case ↔ camelCase 自动转换
    private String gateway;
    private String signType = "RSA2";  // ← 默认值:如果yml没配就用RSA2
    private String returnUrl;
    private String notifyUrl;

    @Bean
    public AlipayClient alipayClient() {
        // DefaultAlipayClient是支付宝SDK的核心类
        return new DefaultAlipayClient(
            gateway,       // 支付宝网关地址
            appId,         // 应用ID
            privateKey,    // 应用私钥(用于签名)
            "json",        // 请求格式
            "UTF-8",       // 字符编码
            publicKey,     // 支付宝公钥(用于验签)
            signType       // 签名算法
        );
    }
}

16. API路由全表

admin_service (8091 → nginx /admin)

方法 路径 说明 登录要求
GET /admin/captcha 获取图形验证码
POST /admin/login 管理员登录
GET /admin/info 获取当前管理员信息
PUT /admin/chgPwd 修改密码
PUT /admin/chgInfo 修改个人信息
GET /admin/search 搜索管理员列表
POST /admin 新增管理员
PUT /admin 修改管理员
DELETE /admin/{id} 删除管理员
GET /admin/{id} 查单条管理员
★GET /admin/dashboard 仪表盘统计(总数/今日/状态分布/最近记录)

user_service (8092 → nginx /user, /collect, /addr)

方法 路径 说明
GET /user/captcha 图形验证码
POST /user/login 用户登录
POST /user/reg 注册
GET /user/info 当前用户信息
PUT /user/chgPwd 修改密码
PUT /user/chgInfo 修改个人信息
GET /user/search 后台搜索用户
PUT /user/status 改状态(后台)
DELETE /user/{id} 删除用户(后台)
POST /collect/{goodsId} 收藏商品
DELETE /collect/{id} 取消收藏
GET /collect/{goodsId} 查是否已收藏
★GET /collect/list 分页收藏列表
★GET /addr 地址列表
★POST /addr 新增地址
★PUT /addr 修改地址
★DELETE /addr/{id} 删除地址
★PUT /addr/default/{id} 设为默认
★GET /addr/{id} 查单条

goods_service (8090 → nginx /goods, /category)

方法 路径 说明
GET /category/search 分页分类列表
GET /category/allParent 查所有父分类(级联选择器用)
GET /category/{id} 查单条
POST /category 新增
PUT /category 修改
DELETE /category/{id} 删除
GET /goods 分页商品列表
GET /goods/search 搜索商品(含子分类)
GET /goods/{id} 查单条(含分类+图片)
POST /goods 新增(含图片上传)
PUT /goods 修改
DELETE /goods/{id} 删除

order_service (8093 → nginx /cart, /order)

方法 路径 说明
POST /cart/{goodsId} 加入购物车
GET /cart 购物车列表(含商品详情)
PUT /cart 修改购物车数量
DELETE /cart/{id} 删单条
DELETE /cart 批量删除(body传ids)
★POST /order 购物车结算下单
★POST /order/direct 直接购买下单
★GET /order/pay/{id} 获取支付宝支付表单HTML
★POST /order/notify 支付宝异步回调(返回纯文本)
★GET /order/query/{id} 查支付状态+同步(精简返回)
★PUT /order/cancel/{id} 用户取消订单(校验归属)
★GET /order/myOrders 用户订单列表
GET /order/search 后台搜索(管理员)
GET /order/{id} 查单条(含items解析)
PUT /order/status 后台改状态(管理员)
DELETE /order/{id} 后台删除(管理员)

17. Bug修正记录

P0: 必须立即修复(导致数据错误或功能完全不可用)

# 问题 症状 根因 修复
B01 Long精度丢失 下单返回的id和支付时传的id对不上→”订单不存在” JS Number最大安全整数9e15(16位),雪花ID为19位。Jackson默认Long→Number 全局JacksonConfig:所有Long序列化为String
B02 密钥硬编码 私钥完整写进yml并推入Git 默认值写了完整私钥 清空默认值→环境变量注入
B03 超卖 多用户付了钱没货(钱已扣库存不够) 下单只检查不改库存 + 付款后扣失败只记log markPaid先改status=1再扣库存(尽力而为+日志)
B04 AdminView status错误 添加管理员后弹窗里默认显示”禁用” 代码写死status:1 改为status:0
B05 Collect.java字段类型错误 goods字段是Date类型 应该是Goods对象 改为private Goods goods
B06 CollectMapper跨模块引用 查询收藏列表报错 user_service引用了goods_service的mapper 改LEFT JOIN一次性查询
B07 支付发起同时弹出两条消息 “下单成功”+”支付发起失败”同时出现 create成功时立即弹success→goPay失败弹error 串成链:create不弹→pay成功跳转→失败弹一条

P1: 重要(影响功能可用性或安全)

# 问题 修复
B08 returnUrl/notifyUrl写死localhost 改为公网IP www.dxkua.cn
B09 notify长事务包Dubbo 去掉@Transactional(单UPDATE已原子)
B10 /query/{id}无鉴权 返回精简summary(仅id/status/totalPrice)
B11 用户可取消别人订单 新建/cancel/{id}端点(归属三连查)
B12 nginx缺/addr和/order 新增两个location块→nginx -s reload
B13 AlipayClient无超时 建议后续加(本次未修)

P2: 一般(功能可用但不完善)

# 问题 修复
B14 document.write清空SPA 改为submitAlipayForm(隐藏form提交)
B15 手拼JSON字符串 改为Hutool JSONObject构造
B16 无超时取消机制 支付宝timeout_express=30m + @Scheduled本地取消

P3: 体验(用户感受不正常)

# 问题 修复
B17 HomeView文案错误 “用户管理管理”→”用户管理”等
B18 删除确认文案”删除该员工” 改为”商品”/”分类”/”管理员”
B19 搜索框每字发请求 @input@keyup.enter+@clear
B20 cartIds query解析不健壮 兼容数组/单字符串/[]格式
B21 parameterType别名错误 collectioncollect
B22 PayResultView图标无import CircleCheck,Clock import
B23 仪表盘数据全量覆盖 逐字段安全合并+Object.assign
B24 仪表盘无loading v-loading
B25 recentOrders查o.* 改查5个字段不查items
B26 今日销售额统计含待付款 status IN(1,2,3)
B27 chgPwd误建新ref chgPwdobj.value = {...}
B28 各服务JWT拦截器未覆盖新增 不拦截但Controller手动token校验
B29 Admin/User/OrderView缺分页 后加el-pagination

18. 术语表

术语 全称 解释
SPA Single Page Application 单页应用——浏览器只加载一次HTML,后续页面切换通过JS动态渲染
API Application Programming Interface 应用程序接口——前端和后端约定好的”函数调用”规则
JSON JavaScript Object Notation 一种轻量级的数据交换格式,前端后端都读得懂
REST Representational State Transfer 一种API设计风格:用HTTP方法(GET/POST/PUT/DELETE)表达操作意图
ORM Object-Relational Mapping 对象关系映射——把数据库的行自动转成Java对象
JWT JSON Web Token 一种无状态的身份认证令牌,三段Base64组成
RPC Remote Procedure Call 远程过程调用——像调用本地方法一样调用远程服务的方法
Dubbo 阿里巴巴开源的Java RPC框架
MyBatis Java的ORM框架,XML写SQL+自动映射
Hutool Java工具库,提供雪花ID、JSON、MD5等便捷方法
nginx 高性能HTTP服务器/反向代理
ZooKeeper 分布式协调服务,本项目用于Dubbo服务注册发现
Redis 内存键值存储,本项目用于验证码临时缓存
Maven Java项目的构建和依赖管理工具
npm / Vite JavaScript的包管理 / 前端构建工具
EC 2 阿里云虚拟机实例(部署用)
雪花算法 Snowflake Twitter开源的分布式唯一ID生成算法
幂等 Idempotent 同一个操作执行多次和执行一次结果相同
事务 Transaction 数据库的一组操作要么全部成功要么全部撤销
Spring Boot 简化Spring应用的创建和配置的框架
Spring MVC Spring的Web层框架,处理HTTP请求
@Autowired Spring注解:自动注入一个Bean
@Bean Spring注解:把方法返回值注册到容器
Pinia Vue 3的状态管理库(替代Vuex)
Axios 浏览器端HTTP请求库
Element Plus Vue 3的UI组件库(按钮/表格/对话框等)

文档版本:2026-07-28
目标读者:从零基础到进阶,适合边看代码边对照此文理解每一行代码的”为什么” 阅读建议:先在IDEA里打开项目,然后按章节顺序阅读,遇到不懂的技术术语查第18章术语表

暂无评论

发送评论 编辑评论


				
上一篇
下一篇