Files
zt-closet-backend/docs/代码审查报告-三次check.md
T
2026-09-11 01:41:52 +08:00

179 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# zt-closet-backend 全量代码三次审查报告
- **审查日期**:2026-09-09
- **审查范围**:全量 Go 代码(`internal/`、`pkg/`、`global/`、`main.go`、`cmd/`,不含 `thirdparty/` Python 服务)
- **审查方式**:只读审查,未修改任何业务代码
- **分级标准**:
- 严重:资损、安全漏洞、并发导致数据错乱/重复发放、可被直接利用的越权
- 中等:边界条件缺失、一致性降级风险、错误处理不当导致的业务卡死
- 轻微:代码风格、冗余、文档偏差、低概率窗口、死代码
> 说明:本报告为「三次 check」,在复核上一轮(二次 check)各项修复状态的基础上,继续深挖新增问题。上一轮已修复项统一在「第四部分」复核确认;上轮遗留未修复项在「第五部分」列明现状。
---
## 修复状态更新(2026-09-09)
> 本报告原为只读审查。经用户确认后,已对以下「中等及以上」问题实施修复(按约定排除:M11 公开接口限流、M15 虚拟订单垃圾堆积):
| 编号 | 问题 | 修复状态 |
| --- | --- | --- |
| S2-new | 广告激励刷额度 | ✅ 已修复:新增 `AdReward` 配置段(`Enable` 开关 + 各类型固定金额),DTO 移除 `Amount`,前端不再传金额,服务端定价 |
| S1-new | XPay 退款推送无反查 | ✅ 已修复:新增 `verifyXPayRefund` 反查微信侧退款真实性(状态+金额),fail-closed 触发重试 |
| M1-new | 自动退款卡死无重试 | ✅ 已修复:`autoRefundCancelledOrder` 改为生成待审核退款单(不再自动调微信退款,退款全部由管理员操作) |
| M2-new | 会员类型覆盖降级 | ✅ 已修复:`VIPGrantIdempotent` 按最终到期时长推导 `vip_type`(体验会员除外) |
| M3 | iOS 退款问询无条件建议退款 | ✅ 已修复:按权益消耗情况细化,已消耗则返回 `result_code=1` 拒绝 |
| M4 | platform 可伪造 | ✅ 已处理:明确 platform 仅作体验优化、真正资损防线为发货反查;dto 已含 `oneof` 白名单校验 |
| M9 | 并发 Paid 分支 pay_time 无兜底 | ✅ 已修复:抽 `resolvePaidTime` 统一兜底,`finalizeVirtualPaidOrder` 与并发 Paid 分支复用 |
| M12 | 上传无请求体大小限制 | ✅ 已修复:handler 入口加 `http.MaxBytesReader` 限制请求体 |
> 备注:S1-new 反查依赖的微信 XPay `query_order` 退款字段(`refund_status`/`refund_fee`)与退款状态枚举为「待确认」,联调时需以官方文档校正。验证:`go build ./...` 与 `go vet ./...` 均通过。
---
## 一、结论摘要
- **上轮 2 个严重问题(S1 发货伪造、S2 道具价格同步)均已妥善处置**,发货链路的 `verifyXPayOrderPaid` 反查已落地,但**其对称缺口——XPay 退款推送无反查——本次新发现,构成新的资损/DoS 面(S1-new)**。
- **本次新发现 1 个严重资损漏洞**:广告激励额度接口信任前端上报的金额与凭证 ID,可被任意用户无限刷额度(S2-new)。
- 支付/退款/权益发放的核心事务、幂等、金额校验整体设计正确,上一轮确认良好的项继续成立。
- 上轮遗留的中等问题(M3/M4/M9/M11/M12/M15)与大部分轻微问题(L 系列)**仍未处理**。
---
## 二、严重问题(新增)
### S1-new. XPay 退款推送无微信侧真实性反查,可伪造退款回收权益(资损/DoS)
- **位置**:`internal/service/virtualpay.go:295-326`(`handleRefundNotify`)
- **问题**:上一轮 S1 修复时,发货推送 `handleGoodsDeliver` 已新增 `verifyXPayOrderPaid`(`virtualpay.go:168-199`)——在本地校验后、条件流转前调用 `wechat.QueryOrder` 反查微信侧订单「已支付 + 金额一致 + 道具一致」,fail-closed。但**退款推送 `handleRefundNotify` 只做了 `RefundStatus == "SUCCESS"` 的白名单判断,没有对应的微信侧退款真实性反查**。
- **攻击路径**:XPay 消息推送 POST 报文不携带签名(`ParseNotify` 只解析 JSON),攻击者可向公开路由 `POST /api/v1/xpay/notify` 构造 `Event=xpay_refund_notify`、`RefundStatus="SUCCESS"`、`OutTradeNo=<目标订单号>` 的报文:
1. `handleRefundNotify` 白名单判断通过;
2. 查订单(`OutTradeNo`)命中且非 Refunded;
3. `settleRefundSuccess` 条件流转 `Paid -> Refunded` 成功 → **回收该订单已发放的会员天数与额度凭证** + 发「退款成功」通知。
- **影响**:
- 对他人订单:攻击者若获取到他人订单号(`OD` + 时间戳 + 16 位 hex,存在展示/日志泄露渠道),可恶意回收他人权益、把他人订单标记为已退款,造成「用户付了钱、权益被回收、钱并未退回」的资损与客诉。
- 对自己订单:结合 M3(iOS 退款问询无条件建议退款)存在权益白嫖放大。
- **对比**:普通微信支付退款回调 `RefundNotify` 走官方 SDK 验签 + AES 解密(`pkg/wechat/pay.go:306-335`),不存在此问题;XPay 退款链路是发货反查落地后唯一仍「裸奔」的入口。
- **修复建议**:`handleRefundNotify` 增加微信侧退款状态反查(`wechat` 层补一个退款状态查询接口,或复用 `QueryOrder` 的退款字段),确认「微信侧确实已退款 + 退款金额一致」后才 `settleRefundSuccess`;未通过返回失败应答(`ErrCode:1`)触发微信重试,并落库/告警。至少在反查落地前,对该路由加网关级来源防护。
### S2-new. 广告激励额度接口信任前端金额与凭证 ID,可无限刷额度(资损)
- **位置**:`internal/service/adreward.go:24-54`(`Grant`)
- **问题**:`/ad-reward/grant`(需鉴权)接收前端上报的 `RewardType`、`Amount`、`RefID`,其中 **`Amount`(奖励额度数量)与 `RefID`(幂等凭证 ID)完全由客户端指定**,服务端:
1. 不校验 `Amount` 上限(可传任意大值,如 999999);
2. 不校验 `RefID` 的真实性(未与广告平台服务端回调或观看记录对账);
3. 直接以 `Amount` 作为凭证数量 `QuotaGrantCreate` 落库(`ExpireTime=当天23:59:59`)。
- **影响**:任意登录用户可反复调用,给自己发放任意数量的 Cos/AI/分享额度凭证(当天有效),彻底击穿额度体系,构成直接资损。
- **修复建议**:
1. 奖励额度改为**服务端按 RewardType 配置固定值**,不从客户端接收 `Amount`;
2. `RefID` 必须由广告平台**服务端回调**下发并校验(或引入一次性凭证/签名),杜绝前端自造;
3. 增加每日领取次数上限。
---
## 三、中等问题(新增)
### M1-new. 自动退款失败后状态卡死,无重试机制(用户付了钱收不到退款)
- **位置**:`internal/service/order.go:402-437`(`autoRefundCancelledOrder`)
- **问题**:订单「已取消但用户已付款」时自动发起退款:
1. 先建退款流水 `Status=RefundStatusProcessing`(`order.go:411-420`);
2. 调 `wechat.Refund`(`order.go:429`),**若失败仅记录日志后直接 return,未把流水置为 `RefundStatusFailed`**(`order.go:430-433`)。
3. 下次再次进入该函数时,幂等检查 `existing.Status != RefundStatusFailed` 命中(`order.go:404-408`),直接 return,跳过退款。
- **影响**:`PayNotify` 对 Cancelled 分支返回 `nil`(成功应答,`order.go:197-198`),微信不会重推;后台无退款重试任务;用户也无法通过 `RefundApply` 再次申请(订单已 Cancelled,`RefundApply` 仅接受 `Paid`,`order.go:279-281`)。最终该订单退款**永久卡在 Processing**,用户付了钱拿不到退款。
- **修复建议**:`wechat.Refund` 失败时把流水置为 `RefundStatusFailed`(复用已有失败重试语义);或增加独立的退款重试后台任务。
### M2-new. 会员续费时 `vip_type` 直接覆盖,年度会员续月包被降级为月度
- **位置**:`internal/service/order.go:618-655`(`grantVIP`)+ `internal/mysqldao/user.go:51-73`(`VIPGrantIdempotent`)
- **问题**:`grantVIP` 的 `newVIPType` 仅由本次购买 `memberDays >= 365` 决定(`order.go:626-629`),`VIPGrantIdempotent` 用 `vip_type = ?`(本次类型)直接 `SET`(`user.go:58`),**未根据最终 `vip_expire` 剩余时长推导**。场景:
- 年度会员(剩余 350 天)续费 30 天月包 → `vip_expire` 变为 380 天(> 365,应为年度),但 `vip_type` 被覆盖为「月度(2)」,字段自相矛盾,前端展示「月度会员」造成「被降级」客诉。
- 反向:月会员续大额包也有类型跳变。
- **修复建议**:`vip_type` 依据「续费后的 `vip_expire - now` 是否 ≥ 365 天」推导写入(或改为查询时统一按到期时长归一,DB 中 `vip_type` 仅作冗余),与 `buildUserInfoResponse` 的归一逻辑对齐。
---
## 四、上一轮修复项复核结论(已确认落地,无需再改)
| 编号 | 上一轮问题 | 复核结论 |
| --- | --- | --- |
| S1 | XPay 发货推送伪造资损 | ✅ 已修复:`verifyXPayOrderPaid` 反查微信侧订单(金额/道具/支付态),fail-closed |
| S2 | 道具价格同步仅启动一次 | ✅ 已处置:服务端道具自动同步逻辑已删除,改由管理后台负责 |
| M1 | 退款推送 `RefundStatus` 空串误判 | ✅ 已修复:白名单 `XPayRefundStatusSuccess` 判断 |
| M2 | 发货校验失败静默吞掉 | ✅ 已修复:失败分支返回 `70009`,应答 `ErrCode:1` 触发重试 |
| M5 | 补偿重试上限被回调重置绕过 | ✅ 已修复:`BenefitGrantTaskReset` 保留 `retry_count` + 5 分钟冷却期 |
| M10 | `RefreshToken` 与文档「可选」矛盾 | ✅ 已修复:代码与文档统一为必填 |
| M13 | `Outfit.Public` 扣额失败回退竞态 | ✅ 已修复:`KeyLockOutfitPublic` per-outfit 锁串行化 |
| M14 | 衣物失败/删除并发漏退额度 | ✅ 已修复:`markProcessFailed` 重写为行锁单事务,与删除事务行锁互斥 |
此外,上轮确认良好的核心链路本次继续成立:支付/退款回调金额校验、条件状态流转 + Redis 幂等锁双层防护、过期/取消竞态兜底、权益发放幂等(`(order_no,benefit_type)` 唯一索引 + claim 状态机 + 补偿先查流水)、退款回收「已用不回退」(`GREATEST` 保护)、SQL 全部参数绑定(无 SQLi)、响应 JSON 无 XSS、出站请求仅微信固定域名与配置的 AI 地址(无 SSRF)、密钥仅存配置文件(无硬编码)。
---
## 五、上一轮遗留未修复项(现状复核)
### 中等(仍未处理)
| 编号 | 问题 | 位置(现状) |
| --- | --- | --- |
| M3 | iOS 退款问询无条件建议退款,白嫖权益窗口 | `internal/service/virtualpay.go:332-342`(仍恒返回 `result_code=0`,注释声明为权衡) |
| M4 | 前端上报 `platform` 可伪造,绕过 iOS 最低 1 元限制 | `internal/service/virtualpay.go:47-50` + `internal/dto/order.go`(`Platform` 由前端上报) |
| M9 | 并发 Paid 分支 `pay_time` 无兜底 | `internal/service/virtualpay.go:267-274`(`finalizeVirtualPaidOrder` 已兜底,但并发 Paid 分支仍直接写 `deliver.WeChatPayInfo.PaidTime`) |
| M11 | 公开接口(wxlogin/refresh/pay/notify/xpay/notify)无限流 | `internal/routers/router.go:47-61`(无 rate limit 中间件) |
| M12 | 上传接口大小校验发生在 multipart 解析之后 | `internal/service/clothes.go:51-57`(`c.FormFile` 解析后才 `FileCheckMaxSize`) |
| M15 | 虚拟支付订单禁取消带来的垃圾订单堆积 | `internal/service/order.go:213-217`(设计权衡,建议加监控) |
> M6/M7/M8(道具上传/发布轮询、分组重复发布、道具 ID 字符集)已随 S2 处置(道具同步逻辑删除)自然消除。
### 轻微(L 系列,仍未处理)
| 编号 | 问题 | 位置 |
| --- | --- | --- |
| L1 | 通知 `read_time` 永不写入 | `internal/service/notification.go:69-104` |
| L2 | 并发点赞基于前置读 `wasLiked` 判断,可能重复发通知 | `internal/service/like.go:61-64` |
| L3 | 收藏列表已删穿搭被跳过导致 `total` 与 `list` 不一致 + 每页 N+1 查询 | `internal/service/collect.go:91-110` |
| L4 | CORS 未配置 Domain 时回退 `Allow-Origin: *` | `internal/middleware/cors.go` |
| L5 | `http.Server` 未设 `IdleTimeout`/`ReadHeaderTimeout`(slowloris 风险) | `main.go:70-78` |
| L6 | 临时封禁无自动解封逻辑 | `internal/service/user.go:95`(`Status != Normal` 直接拒绝,不按 `ban_expire` 判定) |
| L7 | 封禁状态受用户缓存延迟影响 | `internal/middleware/lzcwyjwt.go:78-99`(缓存命中直接用缓存 status) |
| L8 | `RefreshToken` 先删旧会话再写新会话,Del 成功 Set 失败时新旧 token 全失效 | `internal/service/user.go:250-253` |
| L9 | `OrderExpireByTime` 硬编码状态字面量 1/4 | `internal/mysqldao/order.go` |
| L10 | 迁移 `down` 全量回滚无二次确认 | `pkg/migration/migration.go` |
| L12 | XPay `getAccessToken` 无重试 | `pkg/wechat/virtualpay.go:180-229` |
| L13 | `Outfit.Create` 主表成功后明细失败留下孤儿穿搭 | `internal/service/outfit.go:62-68`(与 AI 推荐不一致,AI 推荐会删主表) |
| L14 | `Outfit.Update` 基础字段与明细重建分两个事务 | `internal/service/outfit.go:121-140` |
| L15 | `ClosetShare.Create` 明细失败仅回退额度,主记录残留空分享 | `internal/service/closet.go:92-95` |
| L16 | 信号量全局共享、阻塞式获取不可取消 | `internal/service/clothes.go:26,106` |
| L17 | 扣额建记录成功、goroutine 启动前崩溃,衣物停留「排队中」无恢复 | `internal/service/clothes.go:88-95` |
| L18 | COS 签名 URL 无过期参数 | `pkg/upload/upload.go` |
| L19 | AI 推荐 Redis 锁 180s 无续期 | `internal/service/airecommend.go:50` |
| L20 | 运势生成单次失败无重试 | `pkg/tokenhub/scheduler.go` |
| L21 | `time.Sleep` 在系统时间回拨时可能忙循环 | `pkg/tokenhub/scheduler.go` |
| L22 | 锁 key 手写字符串与 redismodel 常量两处维护 | `internal/service/benefit_compensation.go:40` |
| L23 | 已废弃 DAO(`QuotaIncreaseUsed`/`QuotaDecreaseUsed`/`QuotaConsumeWithFlow`/`QuotaRefundWithFlow`/`QuotaGrantIdempotent`/`QuotaRevokeIdempotent`)无调用点,死代码 | `internal/mysqldao/quota.go`(本次已确认全工程无调用) |
| L24 | 普通支付 `finalizePaidOrder` 的 `pay_time` 用本地时间而非微信 `SuccessTime` | `internal/service/order.go:457-462` |
| L25 | 道具同步全量上架商品无通道开关 | 已随 S2 消除,可忽略 |
### 新增轻微
| 编号 | 问题 | 位置 |
| --- | --- | --- |
| L26 | `recordDailyUsage` 首次并发创建当日计数行无 OnConflict 幂等,第二个请求 1062 冲突(仅日志噪音,不阻断) | `internal/service/service.go:85-117` |
| L27 | 登录 `AuthTokenSet` fail-open 与刷新 `AuthTokenSet` fail-closed 策略不一致 | `internal/service/user.go:107-110` vs `user.go:250-253` |
| L28 | 退款回收 `VIPRevokeIdempotent` 仅扣 `vip_expire`,不重算 `vip_type`,DB 字段可能残留过期类型 | `internal/mysqldao/user.go:77-101` |
---
## 六、修复优先级建议
| 优先级 | 编号 | 一句话结论 |
| --- | --- | --- |
| P0(上线前) | S1-new | XPay 退款推送无微信侧反查,可伪造退款回收权益,必须补齐反查 |
| P0(上线前) | S2-new | 广告激励额度信任前端金额/凭证 ID,可无限刷额度,必须服务端定价 + 校验 |
| P1 | M1-new | 自动退款失败卡死无重试,用户付钱收不到退款 |
| P1 | M2-new | 会员续费 `vip_type` 覆盖导致年度会员降级 |
| P2 | M3/M4/M9/M11/M12 | iOS 白嫖、platform 伪造、pay_time 兜底、公开接口限流、上传防护 |
| P3 | L1-L28 | 按影响面择机处理;其中 L13/L14/L15 与一致性相关,建议优先 |
> 备注:本报告仅列出问题与建议,未修改任何代码。S1-new 的修复需先联调确认 XPay `query_order` / 退款查询接口在官方文档中的字段与状态枚举(`wechat/virtualpay.go` 中多处已标注「以官方枚举为准,联调时校正」)。