阅读对象:零基础学习者(无后端/前端经验也可阅读)
覆盖范围:项目架构、数据库设计、后端微服务、前端SPA、支付宝支付、全部29条Bug修复
学习路径建议:先看第1章了解基础概念 → 第2章看整体架构 → 第5-8章深入后端 → 第10章看前端 → 第14章看Bug修正是最实战的部分
目录
第一部分:入门篇
第二部分:架构篇
第三部分:数据库篇
第四部分:后端篇
- 6. common公共模块:所有微服务共享的”工具箱”
- 7. admin_service:管理员服务和后台统计
- 8. user_service:用户、收藏、收货地址
- 9. goods_service:商品管理和库存扣减
- 10. order_service:订单结算+支付宝支付+定时取消
第五部分:前端篇
第六部分:运维篇
第七部分:实战篇
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服务之间互相调用的框架。它的工作方式:
- 每个服务启动时,向ZooKeeper(”注册中心”)报到:”我是goods_service,我的地址是192.168.1.5:38090″
- order_service需要调goods_service时,先问ZooKeeper:”goods_service在哪儿?”
- ZooKeeper告诉它地址,order_service直接连过去调用
- 对程序员来说,调用远程服务和调用本地方法差不多(都是
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)时:
- MyBatis找到对应的SQL:
SELECT ... FROM user WHERE id = 3 - 把SQL发给MySQL执行
- MySQL返回一行数据:
{id:3, username:"张三", phone:"13322332233", reg_time:"2024-09-01"} - MyBatis根据
map-underscore-to-camel-case=true配置,自动把reg_time(数据库下划线)转成regTime(Java驼峰) - 创建一个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)
这是支付宝提供的一种支付方式,流程是:
- 商家后台(我们)调用支付宝API:”我要创建一笔订单,金额5999元,商品是小米14″
- 支付宝返回一段HTML表单(包含签名、订单号、金额等加密字段)
- 前端把这段HTML提交到支付宝网站
- 用户在支付宝自己的页面上登录、输支付密码、付款
- 付款完成后,支付宝通过两个渠道通知我们:
- 同步通知(浏览器跳转):用户浏览器从支付宝页面跳回我们的”支付结果页”
- 异步通知(服务器推送):支付宝服务器直接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"/>
问题:GoodsPicMapper 在 goods_service 模块里,而 CollectMapper 在 user_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` < #{before}
<!-- < 是 < 的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别名错误 | collection→collect |
| 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章术语表