在线商城 — 每个功能的代码作用详解
本文以”功能”为线索,逐个解释项目中每一段代码是干什么的、输入什么、输出什么、和谁配合。
适合边看代码边对着本文理解。
一、用户端功能
1. 用户注册
涉及文件:
UserController.reg()— 接收注册请求UserServiceImpl.reg()— 注册业务逻辑UserMapper.xmlinsert— 写入数据库
1.1 前端 RegView.vue 的 reg() 函数
function reg() {
// 前端表单校验:用户名和密码非空
if (!form.value.username || !form.value.password) {
ElMessage.warning('用户名和密码不能为空'); return
}
// 两次密码一致性校验
if (form.value.password !== form.value.password1) {
ElMessage.warning('两次密码不一致'); return
}
// 调用后端API,传4个参数
userApi.reg({
username: form.value.username,
password: form.value.password,
key: captchaData.value.key, // 验证码的雪花key
captchaInput: form.value.captchaInput // 用户输入的验证码文本
}).then(resp => {
if (resp.code === 200) {
ElMessage.success('注册成功,请登录')
router.push('/user/login') // 跳转到登录页
} else {
ElMessage.error(resp.msg)
loadCaptcha() // 注册失败→刷新验证码
}
})
}
作用:收集用户填写的表单数据→校验前端规则→发给后端→根据返回结果决定跳转还是提示错误。
1.2 后端 UserController.reg()
@PostMapping("/reg")
public RespBean reg(String username, String password,
String key, String captchaInput) throws StException {
// ① 从Redis取验证码,校验是否匹配
String captcha = (String) redisUtil.get(key);
if (captcha == null || !captcha.equals(captchaInput)) {
throw new StException("验证码错误。请确认后重新登录");
}
// ② 创建User对象,填前端传来的数据
User user = new User();
user.setUsername(username);
user.setPassword(password);
// ③ 调用Service完成真正的注册
userService.reg(user);
return RespBean.success("注册成功");
}
作用:接收HTTP请求→验证码校验(防机器人)→包装数据→委托Service→返回结果。
1.3 UserServiceImpl.reg() — 真正的业务逻辑
public boolean reg(User user) throws StException {
// ① 用户名唯一性检查
if (userMapper.selectByUsername(user.getUsername()) != null) {
throw new StException("该用户已经存在,无法注册~~~");
}
// ② 生成随机盐(8位字符串)
String salt = RandomUtil.randomString(8);
user.setSalt(salt);
// ③ 密码加密:MD5(MD5(明文 + 盐))
String md5Pwd = SecureUtil.md5(SecureUtil.md5(user.getPassword() + salt));
user.setPassword(md5Pwd);
// ④ 插入数据库
return userMapper.insert(user) == 1;
}
参数:User user(只有username和password有值) 返回:boolean(true=成功) 核心算法:双重MD5加盐加密。MD5(MD5("123456" + "981a735b")) = "c34c4c74..."。盐是随机生成的,两个用户密码一样但盐不同→密文不同→防彩虹表攻击。 数据库操作:INSERT INTO user(username, password, salt, ...) VALUES (...)
1.4 UserMapper.insert() SQL
<insert id="insert" parameterType="user">
INSERT INTO user(username, password, salt, realname, gender, phone, email, money, status)
VALUES (#{username}, #{password}, #{salt}, #{realname}, #{gender}, #{phone}, #{email}, #{money}, #{status})
</insert>
作用:把Java对象的值填入SQL占位符→MySQL执行插入→返回受影响行数。
2. 用户登录
涉及文件:UserController.login()、UserServiceImpl.login()、UserMapper.selectByUsername()
2.1 后端 UserController.login()
@PostMapping("/login")
public RespBean login(String username, String password,
String key, String captchaInput) throws StException {
// 验证码校验(同注册)
String captcha = (String) redisUtil.get(key);
if (captcha == null || !captcha.equals(captchaInput)) {
throw new StException("验证码错误");
}
// 调用Service验证用户名密码
User user = userService.login(username, password);
// 登录成功后生成JWT
HashMap<String, Object> map = new HashMap<>();
map.put("id", user.getId());
map.put("username", user.getUsername());
map.put("role", "admin"); // 所有登录用户role=admin(JWT通用可读)
String jwtStr = JwtUtil.generateJwt(map);
return RespBean.success("登录成功", jwtStr);
}
作用:验验证码→验密码→如果正确→把用户ID和用户名”封印”进JWT→返回给前端。前端拿到JWT后存sessionStorage,之后所有请求自动携带。
2.2 UserServiceImpl.login()
public User login(String username, String password) throws StException {
// ① 根据用户名查用户
User user = userMapper.selectByUsername(username);
if (user == null) {
throw new StException("该用户不存在");
}
// ② 检查是否被禁用/注销
if (user.getStatus().equals(2) || user.getStatus().equals(3)) {
throw new StException("该用户被禁用或注销");
}
// ③ 对输入的密码进行同样的加密(MD5(MD5(明文+盐)))
String md5Pwd = SecureUtil.md5(SecureUtil.md5(password + user.getSalt()));
// ④ 比较加密后的密码和数据库中的密码
if (!md5Pwd.equals(user.getPassword())) {
throw new StException("密码输入错误");
}
return user;
}
关键点:数据库里存的不是明文密码,是MD5(MD5("123456"+"981a735b"))。验证时用同样的算法加密用户输入→和数据库比对→一样就通过。
3. 加入购物车
涉及文件:CartController.insert()、CartServiceImpl.insert()、CartMapper.xml
3.1 CartServiceImpl.insert()
public boolean insert(Cart cart) {
// ① 查这个用户是否已经加过这个商品
Cart c = cartMapper.selectByGoodsIdAndUserId(cart.getGoodsId(), cart.getUserId());
if (c == null) {
// ② 第一次加→数量设为1→插入新记录
cart.setCount(1);
return cartMapper.insert(cart) == 1;
} else {
// ③ 已经加过→数量+1→更新已有记录
c.setCount(c.getCount() + 1);
return cartMapper.update(c) == 1;
}
}
设计原因:同一个商品在购物车里只应该有一条记录,数量累加。如果用户两次点击”加入购物车”应该看到数量变成2而不是两条重复记录。 输入:Cart{userId:3, goodsId:45}(goodsId来自URL路径,userId来自JWT解析) 输出:boolean
4. 收藏商品
涉及文件:CollectController.insert()、CollectServiceImpl.insert()、CollectMapper.xml
4.1 CollectController.insert()
@PostMapping("/{goodsId}")
public RespBean insert(@PathVariable("goodsId") Integer goodsId,
@RequestHeader("token") String token) throws StException {
// 从JWT解析出userId
Map<String, Object> map = JwtUtil.parseJwtToMap(token);
Integer userId = (Integer) map.get("id");
// 构造收藏对象
Collect collection = new Collect();
collection.setGoodsId(goodsId);
collection.setUserId(userId);
if (!collectService.insert(collection)) {
throw new StException("收藏失败,请重试");
}
return RespBean.success("收藏成功");
}
作用:从URL拿goodsId→从JWT拿userId→组装成Collect对象→交给Service。
4.2 CollectServiceImpl.insert()
public boolean insert(Collect collection) throws StException {
// ① 检查是否已经收藏过
Collect con = collectMapper.selectByGoodsIdAndUserId(
collection.getGoodsId(), collection.getUserId()
);
if (con != null) {
throw new StException("商品已添加"); // 不能重复收藏
}
// ② 插入收藏记录
return collectMapper.insert(collection) == 1;
}
防止重复收藏:先查collect表中是否已有一条(goods_id=45, user_id=3)的记录→有就拒绝→没有才插入。
4.3 前端 GoodsView.vue collect() 和 cancelcollect()
// 收藏
function collect() {
collectApi.insert(goods.value.id).then(resp => {
if (resp.code == 200) {
ElMessage.success(resp.msg)
getCollectInfo() // 刷新收藏状态→按钮文字从"收藏"变成"取消收藏"
}
})
}
// 取消收藏
function cancelcollect() {
collectApi.delect(collectInfo.value.id).then(resp => {
if (resp.code == 200) {
ElMessage.success(resp.msg)
getCollectInfo() // 刷新→按钮文字从"取消收藏"变回"收藏"
}
})
}
状态切换原理:collectInfo ref初始为null。页面加载时调用getCollectInfo()查询是否已收藏→如果是,返回收藏记录(含id)→v-if="!collectInfo"显示”收藏”按钮→v-else显示”取消收藏”按钮。
5. 地址管理
涉及文件:AddrController、AddrServiceImpl、AddrMapper.xml、UserCenterView.vue
5.1 AddrServiceImpl.insert() — 新增地址
public boolean insert(Addr addr) throws StException {
// 查询用户是否已有地址
List<Addr> existing = addrMapper.selectByUserId(addr.getUserId());
if (existing == null || existing.isEmpty()) {
// 第一地址→自动设为默认
addr.setStatus(1);
} else {
// 已有地址→保持前端传来的状态(没传就默认为0非默认)
addr.setStatus(addr.getStatus() != null ? addr.getStatus() : 0);
}
return addrMapper.insert(addr) == 1;
}
设计原理:每个用户至少需要一个默认地址。第一地址自动默认→用户不需要手动操作。后续新增地址默认不是默认。
5.2 AddrServiceImpl.setDefault() — 设为默认地址
@Transactional // 两步在一个事务中
public boolean setDefault(Integer id, Integer userId) throws StException {
// 归属校验
Addr existing = addrMapper.selectById(id);
if (!existing.getUserId().equals(userId)) throw new StException("无权操作");
// ① 把这个用户的所有地址status清0
addrMapper.clearDefault(userId);
// ② 把目标地址status置1
return addrMapper.setDefault(id) == 1;
}
为什么需要@Transactional:如果第一步成功了但第二步失败了→用户的所有地址都变成”非默认”→页面可能显示不了默认地址。事务保证两条UPDATE要么全成功要么全撤销。
5.3 前端地址管理面板的数据流
进入地址管理Tab
→ onTabChange('addr')
→ loadAddrs()
→ addrApi.selectByUserId() // GET /addr
→ addrList.value = resp.data
用户点击"新增地址"
→ openAddrAdd()
→ addrForm重置为空
→ addrDialogShow = true (弹窗显示)
用户填写表单后点"保存"
→ saveAddr()
→ 校验非空 + 手机号格式
→ addrIsEdit ? addrApi.update(form) : addrApi.insert(form)
→ 成功后 loadAddrs() 刷新列表
用户点击"设为默认"
→ setDefaultAddr(id)
→ addrApi.setDefault(id) // PUT /addr/default/{id}
→ loadAddrs() 刷新列表
→ 界面上"默认"绿标移到新地址
6. 我的收藏列表
涉及文件:CollectController.list()、CollectServiceImpl.selectByPage()、CollectMapper.xml selectByUserId、UserCenterView.vue
6.1 CollectController.list() — 分页查询
@GetMapping("/list")
public RespBean list(@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestHeader("token") String token) {
Map<String, Object> map = JwtUtil.parseJwtToMap(token);
Integer userId = (Integer) map.get("id");
return RespBean.success("", collectService.selectByPage(pageNum, pageSize, userId));
}
作用:从JWT解析当前用户→调用分页查询→返回PageInfo对象(含list+total+pages+pageNum)。
6.2 CollectServiceImpl.selectByPage()
public PageInfo<Collect> selectByPage(Integer pageNum, Integer pageSize, Integer userId) {
PageHelper.startPage(pageNum, pageSize); // ① 开启MyBatis分页
List<Collect> collection = collectMapper.selectByUserId(userId); // ② 查数据
return new PageInfo<>(collection); // ③ 包装分页信息
}
PageHelper原理:startPage在当前线程设置分页参数→MyBatis执行SQL前自动在SQL末尾追加LIMIT offset, pageSize→同时执行一条SELECT COUNT(*)获取总条数→PageInfo封装。
6.3 CollectMapper.xml selectByUserId — 一次JOIN三表
<select id="selectByUserId" resultMap="collectWithGoods">
SELECT c.id, c.goods_id, c.user_id, c.create_time,
g.id AS g_id, g.name AS g_name, g.price AS g_price,
g.market_price AS g_market_price, g.status AS g_status,
gp.id AS gp_id, gp.url AS gp_url
FROM collect c
LEFT JOIN goods g ON c.goods_id = g.id
LEFT JOIN goods_pic gp ON g.id = gp.goods_id
WHERE c.user_id = #{userId}
ORDER BY c.create_time DESC
</select>
为什么一次JOIN而不是循环单查:如果先SELECT FROM collect WHERE user_id=3(得10条),再逐条SELECT FROM goods WHERE id IN (...)(再一次查询),再逐条SELECT * FROM goods_pic WHERE goods_id IN (...)(第三次查询)→三次网络往返+3次SQL解析。JOIN只一次MySQL查询→resultMap用<association>和<collection>自动把扁平的行映射成嵌套的Java对象。
6.4 前端收藏面板渲染
收藏数据来自:collectApi.selectByPage(page, 10)
→ resp.data = {
list: [{id, goodsId, goods: {name, price, picList: [{url}]}}, ...],
total: 10
}
模板遍历 collectList:
<div v-for="item in collectList">
<img :src="SERVER_ADDR + '/goods/pic/' + item.goods.picList[0].url" />
<p>{{ item.goods.name }}</p>
<p>¥{{ item.goods.price }}</p>
<el-button @click="cancelCollect(item)">取消收藏</el-button>
</div>
取消收藏后:filter掉被删除的那条→如果当前页变空→pageNum--回退一页→重新loadCollects()。
7. 下单(购物车结算 + 直接购买)
这是全项目最复杂的业务逻辑,涉及6个步骤。
涉及文件:OrderController.create()/createDirect()、OrderServiceImpl.create()/createDirect()/persistOrder()、OrderMapper.xml insert、CartMapper.xml deleteByIds、CreateOrderView.vue submitOrder()
7.1 前端 CreateOrderView.submitOrder()
function submitOrder() {
// 前置检查
if (!selectedAddrId.value) { ElMessage.warning("请选择收货地址"); return }
if (detailList.value.length === 0) { ElMessage.warning("没有可结算的商品"); return }
submitting.value = true
// 步骤①:下单(根据模式选不同的API)
const apiCall = mode.value === "cart"
? orderApi.create({ addrId, payType, cartIds: cartIdArr.value })
: orderApi.createDirect({ goodsId, count, addrId, payType })
apiCall.then(resp => {
if (resp.code !== 200) { ElMessage.error(resp.msg); return null }
return resp.data // 返回Order对象(不弹提示)
})
// 步骤②:获取支付表单
.then(order => {
if (!order) return
orderApi.pay(order.id).then(payResp => {
if (payResp.code === 200 && payResp.data) {
submitAlipayForm(payResp.data) // 跳转支付宝
} else {
ElMessage.error("订单已创建,支付发起失败,请到「我的订单」重新支付")
}
})
})
.catch(() => ElMessage.error("下单失败,请重试"))
.finally(() => submitting.value = false)
}
为什么分两步串成链而不是分开:
- 先调用create(下单)→如果成功拿到orderId →再调pay(获取支付表单)
- create不弹成功提示(因为支付还没完成)
- 只有在最后一步确定成功或失败时才弹一条消息→不会同时弹出”下单成功”+”支付失败”两条消息
7.2 后端 OrderServiceImpl.create() — 购物车结算
@Transactional(rollbackFor = StException.class)
public Order create(Order order, List<Integer> cartIds, Integer userId) {
List<OrderDetail> detailList = new ArrayList<>();
// 遍历每个购物车项
for (Integer cartId : cartIds) {
// ① 查购物车
Cart cart = cartMapper.selectById(cartId);
// ② 归属校验(不能结算别人购物车)
if (!cart.getUserId().equals(userId)) throw new StException("无权");
// ③ Dubbo跨服务查商品信息
Goods goods = goodsService.selectById(cart.getGoodsId());
// ④ 库存检查(只查不扣,真正扣在支付成功后)
if (cart.getCount() > goods.getCount()) throw new StException("库存不足");
// ⑤ 构造快照:盯住下单时的价格
BigDecimal subtotal = goods.getPrice().multiply(new BigDecimal(cart.getCount()));
detailList.add(new OrderDetail(
goods.getId(), goods.getName(), firstPic(goods),
goods.getPrice(), // ← 下单时售价
cart.getCount(),
subtotal
));
}
return persistOrder(order, userId, detailList, cartIds);
}
每个步骤的作用:
- ①查购物车→确认这个购物车项确实存在
- ②归属校验→用户A不能结算用户B的购物车(安全)
- ③Dubbo跨服务调用→goods_service提供商品信息
- ④库存检查→库存不够直接拒绝下单
- ⑤快照→存下单时的价格和商品名,以后商品涨价了历史订单还是旧价格
7.3 persistOrder() — 统一落库
private Order persistOrder(Order order, Integer userId,
List<OrderDetail> detailList, List<Integer> cartIds) {
// ① 雪花算法生19位订单号
order.setId(IdUtil.getSnowflakeNextId());
// ② 设置用户+状态
order.setUserId(userId);
order.setStatus(0); // 待付款
// ③ 拼接收货地址快照文本
order.setAddrDetail(buildAddrDetail(order.getAddrId()));
// ④ 商品列表→JSON字符串
order.setItems(JSONUtil.toJsonStr(detailList));
// ⑤ 插入order表
orderMapper.insert(order);
// ⑥ 删除购物车项(和⑤在同一事务中)
if (cartIds != null && !cartIds.isEmpty()) {
cartMapper.deleteByIds(cartIds);
}
// ⑦ 回填数据给前端展示
order.setDetailList(detailList);
fillDetailAndPrice(order); // 计算totalPrice
return order;
}
关键顺序:先insert order→再delete cart。@Transactional保证insert和delete是原子的→要么全成功要么全撤销→不会出现”订单存在但购物车没清”的脏状态。
7.4 buildAddrDetail() — 地址快照
private String buildAddrDetail(Integer addrId) {
Addr addr = orderMapper.selectAddrById(addrId); // 直接查addr表
if (addr == null) throw new StException("收货地址不存在");
StringBuilder sb = new StringBuilder();
if (addr.getProvince() != null) sb.append(addr.getProvince());
if (addr.getCity() != null) sb.append(addr.getCity());
if (addr.getDistrict() != null) sb.append(addr.getDistrict());
if (addr.getStreet() != null) sb.append(addr.getStreet());
sb.append(" ").append(addr.getAddress());
sb.append(" - ").append(addr.getContact());
sb.append(" - ").append(addr.getPhone());
return sb.toString();
}
拼接结果示例:”山东省青岛市城阳区城阳街道 农大西苑A座 – 汤姆 – 13322332233″ 为什么order_service能查addr表:所有服务共用同一个MySQL数据库。不需要经过user_service的Dubbo接口。
7.5 OrderMapper.insert() SQL
<insert id="insert" parameterType="org.example.mall.common.bean.Order">
INSERT INTO `order`(id, user_id, pay_type, express,
addr_id, addr_detail, status, items, create_time)
VALUES (#{id}, #{userId}, #{payType}, #{express},
#{addrId}, #{addrDetail}, #{status}, #{items}, NOW())
</insert>
NOW():MySQL函数,取当前时间。create_time用数据库时间而不是Java时间→避免服务器时间不一致。
8. 支付宝支付
这是连接外部系统的代码,涉及加密、签名、回调处理。
涉及文件:OrderServiceImpl.pay()、OrderServiceImpl.notifyPay()、OrderServiceImpl.queryAndUpdate()、OrderServiceImpl.markPaid()、AlipayConfig
8.1 OrderServiceImpl.pay() — 生成支付表单
public String pay(Long id, Integer userId) throws StException {
// ① 三重校验
Order order = orderMapper.selectById(id);
if (order == null) throw new StException("订单不存在");
if (!order.getUserId().equals(userId)) throw new StException("无权操作");
if (order.getStatus() != 0) throw new StException("订单状态不允许支付");
// ② 解析items JSON→计算总金额
fillDetailAndPrice(order);
// ③ 构造支付宝支付请求
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
request.setNotifyUrl(alipayConfig.getNotifyUrl()); // 异步通知地址
request.setReturnUrl(alipayConfig.getReturnUrl()); // 同步跳转地址
// ④ 构造业务参数(用JSONObject避免手拼JSON字符串)
JSONObject biz = new JSONObject();
biz.set("out_trade_no", String.valueOf(order.getId())); // 商户订单号
biz.set("total_amount", order.getTotalPrice().toPlainString()); // 金额
biz.set("subject", "在线商城订单" + order.getId()); // 商品标题
biz.set("product_code", "FAST_INSTANT_TRADE_PAY"); // PC网站支付
biz.set("timeout_express", "30m"); // 30分钟后自动关闭
request.setBizContent(biz.toString());
// ⑤ 调用支付宝SDK→获取HTML表单字符串
return alipayClient.pageExecute(request).getBody();
}
返回的HTML是什么:
<form action="https://openapi-sandbox.dl.alipaydev.com/gateway.do" method="post">
<input type="hidden" name="biz_content" value='{"out_trade_no":"1876...",...}'/>
<input type="hidden" name="sign" value="加密签名"/>
<input type="submit" value="submit" style="display:none"/>
</form>
<script>document.forms[0].submit();</script>
前端拿到这段HTML→submitAlipayForm(html)→innerHTML→form.submit()→浏览器跳转到支付宝。
8.2 submitAlipayForm() — 前端提交支付表单
function submitAlipayForm(html) {
// 创建隐藏div
const container = document.createElement('div')
container.style.display = 'none'
container.innerHTML = html // 把支付宝返回的表单HTML放进去
document.body.appendChild(container)
const form = container.querySelector('form')
if (form) {
form.submit() // 提交→浏览器跳转到支付宝
} else {
ElMessage.error('支付表单异常')
document.body.removeChild(container) // 清理
}
}
为什么不用document.write:document.write会把整个Vue应用清空→如果表单有问题(支付宝返回空HTML)→白屏。隐藏form方式出错了还能弹提示。
8.3 OrderServiceImpl.notifyPay() — 支付宝异步回调
public boolean notifyPay(Map<String, String> params) {
// ① 验签:确认这个请求真的是支付宝发的,不是伪造的
boolean signOk = AlipaySignature.rsaCheckV1(
params, alipayConfig.getPublicKey(), "UTF-8", alipayConfig.getSignType()
);
if (!signOk) return false; // 签名不对→拒绝
// ② 检查交易状态
String tradeStatus = params.get("trade_status");
if (!"TRADE_SUCCESS".equals(tradeStatus) && !"TRADE_FINISHED".equals(tradeStatus)) {
return true; // 不是支付成功的状态→返回success但不处理
}
// ③ 提取订单号→更新订单
Long orderId = Long.valueOf(params.get("out_trade_no"));
return markPaid(orderId);
}
为什么需要验签:
- 任何人都可以POST
http://114.55.106.49/order/notify这个地址 - 如果不验签,攻击者可以伪造”支付成功”的通知→不付钱就能把订单变成”已付款”
- 验签用的支付宝公钥只有支付宝能匹配→签名对=确实是支付宝发的
8.4 markPaid() — 标记已付款(幂等)
private boolean markPaid(Long orderId) {
// ① 幂等检查:如果已经处理过,直接返回
Order order = orderMapper.selectById(orderId);
if (order == null || order.getStatus() != 0) {
return true; // status已经是1(已付款),说明之前处理过了
}
fillDetailAndPrice(order);
// ② 改订单状态(先做,保证钱不白付)
Order upd = new Order();
upd.setId(orderId);
upd.setStatus(1); // 待付款→已付款
orderMapper.updateById(upd);
// ③ 扣库存(尽力而为,失败记日志不阻塞)
for (OrderDetail d : order.getDetailList()) {
try {
if (!goodsService.reduceStock(d.getGoodsId(), d.getCount())) {
log.warning("库存扣减失败: orderId=" + orderId + " goodsName=" + d.getGoodsName());
}
} catch (Exception e) {
log.warning("库存扣减异常: " + e.getMessage());
}
}
return true;
}
设计决策:
- 先改状态:钱已付→必须改状态为”已付款”。如果因为库存不够而回滚状态→用户付了钱但订单显示”未付款”→这是不可接受的
- 后扣库存:尽力而为→失败记日志→人工介入补货/退款。这是权衡——业务完美(预扣库存)vs代码简单(付款后扣)的选择
- 无@Transactional:改状态是单条UPDATE原子操作,扣库存是Dubbo远程调用→不需要同一事务
8.5 queryAndUpdate() — return页主动查询
public boolean queryAndUpdate(Long id) {
Order order = orderMapper.selectById(id);
if (order.getStatus() != 0) return false; // 已处理过
// 调支付宝查询接口:这个订单付了没?
AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
request.setBizContent("{\"out_trade_no\":\"" + id + "\"}");
AlipayTradeQueryResponse response = alipayClient.execute(request);
if ("TRADE_SUCCESS".equals(response.getTradeStatus())) {
return markPaid(id); // 支付宝说付了→更新本地
}
return false;
}
为什么需要这个:支付宝的notifyUrl(异步通知)如果在本地网络环境下不可达→付款永远检测不到→订单永远”待付款”。returnUrl的页面(PayResultView)调用这个接口主动去问支付宝→确认付款了没。
9. 库存扣减
涉及文件:GoodsServiceImpl.reduceStock()、GoodsMapper.xml reduceStock
9.1 GoodsMapper.reduceStock SQL
UPDATE goods
SET count = count - #{count}
WHERE id = #{id} AND count >= #{count}
并发安全原理:
- MySQL的UPDATE语句有行级锁:两个UPDATE同时操作id=45这一行→第一个执行→锁住这行→第二个等待→第一个提交后释放锁→第二个执行时count已经变了
count >= #{count}:执行时实时检查库存是否够→不够(<)→WHERE条件不满足→受影响行数为0→Java代码返回false
public boolean reduceStock(Integer goodsId, Integer count) {
return goodsMapper.reduceStock(goodsId, count) == 1;
// 返回1=成功扣减,0=库存不足被WHERE拦截
}
10. 仪表盘统计
涉及文件:DashboardController、DashboardMapper.xml
10.1 今日销售额计算
BigDecimal todaySales = BigDecimal.ZERO;
List<String> itemsList = dashboardMapper.selectTodayOrderItems();
for (String itemsJson : itemsList) {
if (itemsJson == null || itemsJson.isEmpty()) continue;
List<OrderDetail> details = JSONUtil.toList(itemsJson, OrderDetail.class);
for (OrderDetail d : details) {
if (d.getSubtotal() != null) {
todaySales = todaySales.add(d.getSubtotal());
}
}
}
data.put("todaySales", todaySales);
为什么不在SQL里SUM:因为订单商品存的是JSON文本(items列),不是结构化列。MySQL没有SUM(JSON字段)的功能→必须在Java里解析JSON然后手动累加。这个设计取舍的代价——JSON存多商品方便,但统计要额外处理。
10.2 安全合并加载(前端)
function loadDashboard() {
Service.get("/admin/dashboard").then(resp => {
if (resp.code === 200 && resp.data) {
const d = resp.data
// 逐字段覆盖:只覆盖后端有值的字段,没值的保留默认值
['totalOrders','totalUsers','totalGoods','todayOrders',
'todaySales','recentOrders','recentUsers']
.forEach(k => { if (d[k] != null) dash.value[k] = d[k] })
// statusCnt分层覆盖:防止后端缺某个status时冲成undefined
if (d.statusCnt) Object.assign(dash.value.statusCnt, d.statusCnt)
}
})
}
为什么不能dash.value = resp.data:后端可能不返回某个字段→直接覆盖把默认值0冲成undefined→Number(undefined).toFixed(2) = “NaN” →页面崩。安全合并:只覆盖有值的字段。
11. 定时取消超时订单
涉及文件:OrderTimeoutTask、OrderMapper.cancelExpiredOrders
11.1 OrderTimeoutTask
@Component
public class OrderTimeoutTask {
@Scheduled(fixedRate = 5 * 60 * 1000) // 每5分钟执行一次
public void cancelExpiredOrders() {
Date before = new Date(System.currentTimeMillis() - 30 * 60 * 1000);
int n = orderMapper.cancelExpiredOrders(before);
if (n > 0) log.info("已自动取消超时未支付订单 " + n + " 笔");
}
}
为什么是5分钟而不是实时:定时任务是轻量级方案。30分钟超时→每5分钟扫描→最坏情况35分钟后才取消(多出5分钟)。对于电商来说这个精度够了。
<update id="cancelExpiredOrders">
UPDATE `order` SET status = 4
WHERE status = 0 AND create_time < #{before}
</update>
<是XML里<的转义写法—XML不允许在属性值里写<符号。
配合支付宝侧的timeout_express=30m→支付宝自己也30分钟后关闭交易→双保险。
12. 取消订单(归属校验)
@PutMapping("/cancel/{id}")
public RespBean cancel(@PathVariable Long id, @RequestHeader String token) {
Integer userId = JwtUtil.parseJwtToMap(token).get("id");
Order order = orderService.selectById(id);
if (order == null) throw new StException("订单不存在"); // ①
if (!order.getUserId().equals(userId)) throw new StException("无权操作"); // ②
if (order.getStatus() != 0) throw new StException("状态无法取消"); // ③
Order upd = new Order();
upd.setId(id);
upd.setStatus(4); // 已取消
orderService.updateStatus(upd);
return RespBean.success("订单已取消");
}
三层防护:①存在性→②归属→③状态。只有自己的待付款订单才能取消。管理员后台的PUT /order/status不做这层校验(管理员可以改任何订单状态)。
13. JacksonConfig — Long序列化
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() {
return builder -> {
builder.serializerByType(Long.class, ToStringSerializer.instance);
builder.serializerByType(long.class, ToStringSerializer.instance);
};
}
}
作用原理:
- Spring Boot启动时加载这个Bean
- 之后所有Controller返回的JSON里,只要字段是
Long类型→自动调用ToString()→变成字符串 Order.id = 1876543210987654321L→ JSON输出"1876543210987654321"而不是数字- 前端JS拿到字符串→不会丢精度
为什么Long.class和long.class都要设置:
Long是包装类型(可null,如Long id = null)long是基本类型(不可null,如long timestamp)- 两种类型在JVM中是不同的→都要注册序列化器
14. 前端Axios拦截器
// 请求拦截器:每次发请求前自动加token
service.interceptors.request.use(req => {
const tokenStore = userTokenStore()
if (tokenStore.tokenStr) {
req.headers.token = tokenStore.tokenStr // 放到请求头
}
return req
})
// 响应拦截器:收到响应后自动刷新token+403处理
service.interceptors.response.use(resp => {
const token = resp.headers.token // 后端每次返回新JWT
if (token) tokenStore.updateToken(token) // 自动更新
return resp.data // 直接返回body,调用的地方写 resp.code 即可
}, error => {
if (error.status === 403) {
ElMessage.error("令牌错误,重新登录")
tokenStore.$reset()
router.push("/user/login")
}
})
请求拦截器的作用:每个API函数不需要手动传token→自动从store取出加到请求头。 响应拦截器第一个函数的作用:后端JwtInterceptor每次返回新JWT→这里自动替换本地旧的→用户只要持续操作就不会过期。 响应拦截器第二个函数的作用:Token过期/无效→清除本地数据→跳登录页→后面的请求不会再发。
15. 路由导航守卫
router.beforeEach((to, from) => {
// 公开页面直接放行
if (to.path === '/admin/login' || to.path === '/' ||
to.path === '/user/login' || to.path === '/user/index' ||
to.path.startsWith('/user/search') || to.path === '/user/reg' ||
to.path === '/user/goods' || to.path === '/user/center') {
return true // 不检查token
}
// 需要登录的页面:检查有没有token
const tokenStore = new userTokenStore()
if (tokenStore.tokenStr) return true // 已登录→放行
// 未登录→跳到对应登录页
if (to.path.startsWith("/admin")) return "/admin/login"
else return "/user/login"
})
return值含义:
true:正常进入目标页面false/string:跳转到指定路径(不进入目标页面)
本文配合 PROJECT_DOC.md(原理+架构)、DOC_APPENDIX.md(搭建+部署)、DOC_PART3.md(完整代码补充)形成完整的四件套。
阅读顺序建议:先看本文的功能代码详解→遇到不懂的概念查PROJECT_DOC.md的术语表和原理章节→想动手复现查DOC_APPENDIX.md和DOC_PART3.md。