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

21 KiB

zt-closet-backend 全量代码二次审查报告

  • 审查日期:2026-09-08
  • 审查范围:全量 Go 代码(约 9700 行核心业务代码),重点深挖支付链路(虚拟支付 XPay、微信支付 V3、订单状态机、权益发放/退款/补偿)
  • 审查方式:只读审查,未修改任何业务代码
  • 分级标准:
    • 严重:资损、安全漏洞、并发导致数据错乱/重复发放、状态机死锁
    • 中等:边界条件缺失、一致性降级风险、错误处理不当
    • 轻微:代码风格、冗余、文档偏差、低概率窗口

一、严重问题(必须修复)

S1. XPay 发货推送无验签与订单真实性反查,可伪造发货通知零成本获取权益(资损) —— ✅ 已修复(2026-09-08)

修复内容:发货推送处理链路新增微信侧订单真实性反查 verifyXPayOrderPaid(internal/service/virtualpay.go),在本地校验通过后、条件流转前调用 wechat.QueryOrder 反查订单已支付、金额与道具一致才发放权益;fail-closed,未通过返回 70009 订单真实性校验失败 触发微信重试。配套 IsOrderPaidStatus 白名单判断与单测(pkg/wechat/virtualpay.go)。

  • 位置:internal/routers/router.go:59(公开路由)、internal/routers/api/v1/order.go:163-171、internal/service/virtualpay.go:290-374
  • 问题:微信虚拟支付的消息推送 POST 报文不携带签名(URL 配置时的 GET SHA1 验签仅用于验证 URL 归属,XPayVerify 只处理 GET)。XPayNotify 收到 POST 后直接信任报文内容,仅做本地校验(订单存在、pay_channel=2、product_id/amount/env 与订单快照一致)。这些校验信息对攻击者完全可控:
    1. 攻击者用合法账号创建虚拟支付订单(自己下单即可拿到 order_no、product_id、金额;env 只需在 0/1 间试一次);
    2. 向公开路由 POST /api/v1/xpay/notify 发送伪造的 xpay_goods_deliver_notify 报文;
    3. 所有本地校验通过 → 订单 Pending→Paid → 权益发放 → 未付款即到账。
  • 对比:普通微信支付回调 PayNotify 走官方 SDK 验签 + AES 解密,不存在此问题;XPay 链路是唯一裸奔的资损入口。
  • 修复建议:发货处理前调用 wechat.QueryOrder(带 HMAC-SHA256 签名的服务端 API,签名无法伪造)反查微信侧真实订单状态,确认「已支付 + 金额一致 + 道具一致」后才流转发放;推送仅作为触发信号。退款推送同样需要反查 QueryOrder 的退款状态。至少在反查落地前,建议给该路由加网关级来源防护并告警。

S2. 虚拟支付道具价格同步只在服务启动执行一次,运营改价后微信侧与本地价格不一致(资损/拒付风险) —— ✅ 已确认由管理后台负责,已删除服务端自动同步(2026-09-08)

处置:删除服务端自动道具同步逻辑(main.go 调用、StartVirtualGoodsSync/SyncVirtualGoods/syncOneGoods/pollGoodsTask、wechat 层道具上传/发布函数、DAO GoodsListVirtualSync/GoodsUpdateByID)。道具上传/发布/改价由管理后台负责并回填 goods.product_id/product_status。README 已标注运营约束:改价后必须通过管理后台同步,否则微信侧价格与本地不一致会导致发货金额校验拒绝或差价资损。

  • 位置:internal/service/virtualpay.go:32-41(StartVirtualGoodsSync 仅启动时触发一次)、main.go:68、syncOneGoods 用 goods.DiscountPrice 作为道具价格
  • 问题:运营在 DB 修改商品售价后,若服务未重启,微信侧道具价格仍是旧价。用户下单时按本地新价生成 signData(goodsPrice),但微信侧按道具旧价扣款 → 出现两种情况:
    • 微信扣款 > 本地订单金额:发货推送 ActualPrice != order.PayAmount 校验失败,拒绝发货,用户付了钱拿不到权益(客诉/监管风险);
    • 微信扣款 < 本地订单金额:少扣钱,资损。
  • 修复建议:道具同步改为周期任务(如每 10 分钟增量同步),或提供管理端改价入口同步触发;对金额不匹配的发货推送增加人工兜底策略(按实际扣款金额改单发货 + 告警),而不是直接拒绝。

二、中等问题

M1. 退款推送 RefundStatus 为空串时被误判为「退款成功」并回收权益 —— ✅ 已修复(2026-09-08)

修复:新增 XPayRefundStatusSuccess 常量,handleRefundNotify 改为白名单判断,仅 RefundStatus == "SUCCESS" 才回退权益,空串/未知状态仅告警。

  • 位置:internal/service/virtualpay.go:386-391
  • 问题:判断条件 if refund.RefundStatus != "" && refund.RefundStatus != "SUCCESS",当推送报文字段缺失或字段名与官方文档不符(解析结果为空串)时,跳过 warn 分支,直接走 settleRefundSuccess 回收权益并置订单 Refunded。若实际退款并未成功,会造成误回收。
  • 修复建议:改为白名单判断——仅当 RefundStatus == "SUCCESS" 才回退;空串/未知状态仅记录日志并告警,等待后续推送。

M2. 发货推送校验失败时返回成功应答,微信不重试且无告警,资损线索被静默吞掉 —— ✅ 已修复(2026-09-08)

修复:handleGoodsDeliver 五个校验失败分支(订单不存在/渠道不符/道具不符/金额不符/环境不符)改返回 70009,应答 ErrCode:1 触发微信重试;锁竞争分支保持成功应答防重试风暴。

  • 位置:internal/service/virtualpay.go:298-328(order not found / product mismatch / amount mismatch / env mismatch 均 return nil),XPayNotify 对 handleGoodsDeliver 返回 nil 时回 ErrCode:0
  • 问题:金额不符、道具不符是资损/攻击线索,当前仅打 error 日志,微信收到成功应答后不再推送,无任何人工介入或监控渠道。回调原始报文(raw)也未持久化(未落库)。
  • 修复建议:校验失败返回失败应答(ErrCode:1)让微信重试(可限制重试次数防风暴),并将校验失败的推送原始报文落库/告警到监控。

M3. iOS 退款问询无条件「建议退款」,存在白嫖权益窗口

  • 位置:internal/service/virtualpay.go:410-424(handleIOSRefundQuery 恒返回 result_code=0)
  • 问题:用户在 iOS 购买会员/额度包后立即走 App Store 申请退款,后端无条件建议退款;退款成功后 revokeOrderBenefits 回收权益,但「已消耗部分不回退」(GREATEST 保护)且会员天数不回退到过去。对会员类权益,用户可在会员有效期内退款白嫖。代码注释已声明该权衡,但从业务角度属于明确的风险敞口。
  • 修复建议:按权益使用情况细化拒绝策略(如已消耗额度超过阈值时 result_code=1 + evidence),或对会员类订单设置退款冷却期。

M4. 前端上报的 platform 可伪造,绕过 iOS 最低 1 元限制

  • 位置:internal/service/virtualpay.go:172、internal/dto/order.go(Platform 由前端上报)
  • 问题:iOS 用户上报 platform=android 即可绕过 100 分最低金额校验。微信侧 iOS 调起低金额虚拟支付会失败,产生无法取消(虚拟订单禁取消)的垃圾订单,堆积至过期。
  • 修复建议:该限制本身只是前端体验保护,不构成资损;可在发货时校验平台一致性,或对超低价商品在商品层面限制,订单滞留靠过期清理即可。

M5. 补偿任务 Failed 状态被支付回调重置(retry_count 清零),重试上限被绕过 —— ✅ 已修复(2026-09-08)

修复:BenefitGrantTaskReset 不再清零 retry_count;Failed 任务增加 5 分钟冷却期(benefitRetryCooldownSeconds),冷却期内回调仅告警不重置,恢复重试上限与人工介入机制。

  • 位置:internal/service/order.go:518-525(Failed 分支 BenefitGrantTaskReset)、internal/mysqldao/benefit_grant_task.go:78-89(Reset 将 retry_count 归零)
  • 问题:任务重试 3 次失败进入 Failed(等待人工介入),但微信回调重试会再次触发 grantOrderBenefits → 唯一索引冲突 → existing.Status == Failed → Reset(retry_count=0)→ 立即重新发放。若故障持续,任务在 Failed 与 Pending 之间无限循环,「重试超限告警人工介入」机制失效,且每次回调都会触发一次发放尝试。
  • 修复建议:Reset 时保留 retry_count(不归零),或对 Failed 任务在回调路径仅记录告警、不自动重置,由人工确认后重置。

M6. 道具上传/发布任务轮询仅 2.5 秒(5 次 × 500ms),大概率误判失败

  • 位置:internal/service/virtualpay.go:25-27、119-141(pollGoodsTask)
  • 问题:微信道具上传/发布任务耗时未在代码中确认(注释自述「联调时核对」),若实际耗时超过 2.5 秒,syncOneGoods 会把商品置为 product_status=3 发布失败,导致虚拟商品下单入口长期不可用,直到下次重启重新同步。
  • 修复建议:增加轮询总时长(如 30 秒、退避间隔),或超时后保持「发布中」状态待下次同步周期重查。

M7. 多商品循环中每个商品都重复发布整个分组

  • 位置:internal/service/virtualpay.go:76-116(syncOneGoods 对每个商品都执行 StartUploadGoods + StartPublishGoods,且发布参数只有 group_id)
  • 问题:若微信 API 语义为「发布分组内全部待发布道具」,循环 N 个商品会发布 N 次分组,冗余且可能触发频率限制;若语义为单道具,则参数不匹配。另外「对已发布道具重复 upload+publish 视为改价」这一假设需要官方文档确认(已发布道具是否允许改价)。
  • 修复建议:联调确认 group 语义后重构:全部 upload 完成后统一 publish 一次;改价策略按官方支持方式实现。

M8. 道具 ID 直接复用 GoodsCode,可能不符合微信 goods_id 字符集规范

  • 位置:internal/service/virtualpay.go:77(productID := goods.GoodsCode)
  • 问题:微信虚拟支付道具 ID 一般有字符集限制(字母数字、长度上限)。若商品编码含下划线等特殊字符,道具上传会失败,虚拟支付功能整体不可用。
  • 修复建议:联调核对规范,必要时做编码映射(如取 MD5 前缀或规范化 ID)并保持 goods.product_id 回填一致。

M9. 发货推送并发分支中 pay_time 无兜底(与 finalize 路径不一致)

  • 位置:internal/service/virtualpay.go:349-356(OrderStatusPaid 分支直接写 deliver.WeChatPayInfo.PaidTime)
  • 问题:finalizeVirtualPaidOrder 对 PaidTime <= 0 有兜底(取 nowUnix()),但并发回调的 Paid 分支没有;推送字段缺失时 pay_time 会被写成 0,订单列表展示异常。
  • 修复建议:抽公共函数统一兜底。

M10. RefreshToken 要求旧 access_token 必填,与接口文档「可选」矛盾 —— ✅ 已修复(2026-09-08)

修复:代码与接口文档均为必填,README 接口表中 access_token 由「否」改为「是」并说明缺失返回 10000,与实现一致。

  • 位置:internal/service/user.go:240-242、接口文档.md(access_token 标注「否」)
  • 问题:文档声明不传旧 access_token 也能刷新(向前兼容),但代码对空值直接返回参数错误,前端若按文档实现会刷新失败。
  • 修复建议:二选一并保持一致——文档改为必填,或代码放宽为可选(不传则不吊销旧 access)。

M11. 公开接口无任何限流防护

  • 位置:internal/middleware/(无 rate limit 中间件)、internal/routers/router.go:45-60
  • 问题:/user/wxlogin、/user/refresh-token、/order/pay/notify、/xpay/notify 均无频率限制。暴力调用 code2session 会消耗微信侧配额(触发 45011 后全员登录失败);回调接口可被刷量放大 S1 的伪造风险。
  • 修复建议:接入限流中间件(按 IP + 按用户),回调类接口单独设置更严格的防护策略。

M12. 上传接口无请求体大小限制(校验发生在 multipart 解析之后)

  • 位置:internal/service/clothes.go:51-57、main.go:73-81(仅 MaxHeaderBytes)
  • 问题:c.FormFile 先完整解析 multipart(超出 MaxMultipartMemory 32MB 的部分落临时文件),之后才做 FileCheckMaxSize 校验。攻击者可发送超大 body 消耗内存与磁盘。
  • 修复建议:在 handler 入口用 http.MaxBytesReader 按 Upload.MaxSizeMB 提前限制请求体。

M13. Outfit.Public 扣额失败回退与并发幂等返回存在语义冲突 —— ✅ 已修复(2026-09-08)

修复:新增 KeyLockOutfitPublic,公开/取消公开全流程加 per-outfit Redis 锁(10s)串行化,锁失败返回 10003;Redis 异常 fail-open。串行化后「A 回退 → B 条件更新」语义正确。

  • 位置:internal/service/outfit.go:210-224
  • 问题:请求 A 条件更新(私→公)成功、扣额度失败、回退公开态;期间并发请求 B 的条件更新返回 0 行(已被 A 置为公开),B 幂等返回「成功」。最终穿搭实际为未公开,但 B 用户收到成功响应,且双方均未写审核日志。
  • 修复建议:扣额失败回退后返回明确错误码(而非让并发请求误报成功);或回退前重查状态,仅在仍为公开时回退。

M14. 衣物「异步处理失败」与「用户删除」并发存在额度漏退窗口 —— ✅ 已修复(2026-09-08)

修复:markProcessFailed 重写为行锁单事务(FOR UPDATE → 置失败 → 同事务回退额度 + 写流水),与删除事务通过行锁互斥,两路径恰好回退一次;删除 refundIfExists 与 DAO ClothesMarkProcessFailedIfNotFailed。

  • 位置:internal/service/clothes.go:216-238(markProcessFailed → refundIfExists)与 306-368(删除事务)
  • 问题:时序:异步路径先提交 image_process_status=5 → 删除事务 FOR UPDATE 读到失败态跳过回退并软删 → 异步路径随后 ClothesGetByUUID 读不到(已软删)跳过回退。两个「应回退」的事件叠加,结果零回退,用户损失 1 点上传额度(概率低,但真实存在)。
  • 说明:上一轮担心的「双退」窗口经复核已被防御(GORM soft_delete 自动过滤 + 条件更新 + 删除事务行锁串行化),不会重复回退;但漏退窗口未覆盖。
  • 修复建议:删除事务中对「状态=失败但删除前未回退过」的场景仍执行回退(可查回退流水是否存在判断),或 refundIfExists 改为按流水幂等凭证条件回退。

M15. 虚拟支付订单禁取消带来的垃圾订单堆积(设计权衡)

  • 位置:internal/service/order.go:213-217
  • 问题:虚拟支付订单禁取消后,用户下单不支付只能等 15 分钟过期,过期订单仅靠每分钟扫描批量更新,量大时扫描压力与脏数据堆积。属可接受的设计权衡(避免「已付款但权益不发放」),建议加监控观察堆积量。

三、轻微问题

# 问题 位置 建议
L1 通知 read_time 永不写入,恒为 0 internal/mysqldao/notification.go:53-60 标记已读时写入 read_time
L2 并发点赞时基于前置读的 wasLiked 判断,可能重复发通知 internal/service/like.go:61-65 通知本身加幂等凭证或改为「状态变更成功才通知」(upsert 返回是否新增)
L3 收藏列表:已删除穿搭被跳过导致 total 与 list 长度不一致,且每页 N+1 查询 internal/service/collect.go:91-110 批量查询 + 过滤后重算 total,或 join 查询
L4 CORS 未配置 Domain 时回退 Allow-Origin: * internal/middleware/cors.go:21 生产环境强制配置 Domain
L5 http.Server 未设 IdleTimeout/ReadHeaderTimeout,存在 slowloris 风险 main.go:73-81 补充超时配置
L6 临时封禁无自动解封逻辑,ban_expire 到期后账号永久禁用 internal/service/user.go:95 增加解封后台任务或查询时按 ban_expire 动态判定
L7 封禁状态受 24h 用户缓存延迟影响,改库封禁最长 24h 生效 internal/middleware/lzcwyjwt.go:78-92 运营封禁入口需同步删除缓存
L8 RefreshToken 先删旧会话再写新会话,Del 成功 Set 失败时新旧 token 全部失效,用户被强制登出 internal/service/user.go:250-253 Set 失败时保留旧会话或允许旧 refresh 重试
L9 OrderExpireByTime 硬编码状态字面量 1/4,与 service 层常量重复维护 internal/mysqldao/order.go:62-74 收敛为共享常量
L10 迁移 down 为全量回滚且无二次确认,生产误执行即删全库 pkg/migration/migration.go 增加环境检查/交互确认
L11 XPay 商品同步启动失败无重试与告警渠道 internal/service/virtualpay.go:32-41 失败时上报监控
L12 XPay 客户端 HTTP 无重试,getAccessToken 网络抖动直接失败 pkg/wechat/virtualpay.go:184-233 关键调用加 1~2 次重试
L13 Outfit.Create 主表创建成功后明细失败,留下孤儿穿搭 internal/service/outfit.go:59-65 与 AI 推荐一致:失败时删除主记录
L14 Outfit.Update 基础字段与明细重建分两个事务,中途失败状态不一致 internal/service/outfit.go:118-140 合并事务或先明细后主表
L15 ClosetShare.Create 明细失败仅回退额度,主记录残留空分享 internal/service/closet.go:92-95 失败时删除主记录
L16 衣物异步流水线信号量全局共享、阻塞式获取不可取消,单用户可占满 10 槽位 internal/service/clothes.go:26,106 获取信号量支持 ctx 取消,或 per-user 限额
L17 扣额度 + 建记录成功后、goroutine 启动前崩溃,衣物永远停留「排队中」,无恢复任务 internal/service/clothes.go:88-95 增加启动/周期扫描重置超时排队记录
L18 COS 签名 URL 无过期参数,泄露后长期有效 pkg/upload/upload.go:165-178 签名加有效期
L19 AI 推荐 Redis 锁 180s 无续期,大模型响应逼近超时时可并发重复扣额 + 重复调用 internal/service/airecommend.go:50 锁续期或加大 TTL 并依赖额度流水幂等
L20 运势生成任务单次执行失败无重试,当天数据可能不完整 pkg/tokenhub/scheduler.go:52-101 失败后延迟重试
L21 time.Sleep(wait) 在系统时间回拨时可能忙循环 pkg/tokenhub/scheduler.go:35-43 wait<=0 时设最小间隔
L22 锁 key 手写字符串(ztcloset:lock:benefit:compensation 等)与 redismodel 常量两处维护,易漂移 internal/service/benefit_compensation.go:40 收敛到 redismodel
L23 已废弃 DAO(QuotaIncreaseUsed/QuotaDecreaseUsed 等)与死代码未清理 internal/mysqldao/quota.go:36-64 删除防误用
L24 普通支付回调 finalizePaidOrder 的 pay_time 用本地时间而非微信 SuccessTime internal/service/order.go:457-462 优先使用回调时间字段
L25 道具同步任务将「全部上架商品」同步为虚拟道具,无商品级开关区分普通/虚拟通道 internal/mysqldao/goods.go:62-75 视业务需要增加商品级通道开关

四、上一轮修复项复核结论(已确认良好)

以下为上一轮「修复支付业务逻辑问题」计划涉及的修复点,本轮复核确认实现正确,无需再改:

  1. 支付回调金额校验:PayNotify/RefundNotify/XPay 发货推送均校验回调金额与订单/流水一致后才流转。✓
  2. 条件状态流转防重复回调:OrderUpdateStatusByOrderNo/RefundFlowLogUpdateStatusByFrom 条件更新 + Redis 幂等锁(30s)双层防护。✓
  3. 过期/取消竞态兜底:普通支付「已过期→补发货」「已取消→自动退款」,XPay「已过期/已取消→补发货」分支均已实现且使用重新查询的最新订单对象。✓
  4. 权益发放幂等:benefit_grant_tasks 唯一索引 (order_no, benefit_type) + claim 状态机 + 补偿任务先查流水幂等,链路完整。(M5 的重置绕过是唯一遗留缺口)
  5. 退款回收「已用不回退」:GREATEST(used, total-n) 与 GREATEST(now, vip_expire-days) 实现正确。✓
  6. 虚拟支付订单禁取消、iOS 订单禁主动退款:位置与校验顺序正确(归属校验 → 渠道校验 → 状态校验)。✓
  7. 事务边界:额度扣减/回退/发放/回收、VIP 发放/回收均为「数值变更 + 回读 + 写流水」同事务,唯一索引兜底幂等。✓
  8. SQL 注入面:全部 DAO 使用参数绑定,未发现 SQLi。✓
  9. XSS:响应均为 JSON 结构化输出,Gin 默认无模板渲染场景,未发现 XSS。✓
  10. SSRF:出站请求仅微信固定域名与配置的 AI 服务地址,无用户可控 URL。✓
  11. 密钥管理:JWT Secret 启动强制校验、APIv3Key 32 字节校验、所有密钥仅存配置文件(已 gitignore),未发现硬编码。✓

五、修复优先级建议

优先级 编号 一句话结论
P0(上线前) S1 XPay 推送伪造发货资损漏洞,必须加订单反查
P0(上线前) S2 道具价格同步仅启动一次,改价即资损
P1 M1、M2 退款误回收、校验失败静默(资损线索)
P1 M5 补偿重试上限被绕过
P2 M3、M4、M6-M9 iOS 白嫖、道具同步可靠性
P2 M10-M14 文档矛盾、限流、上传防护、公开竞态、衣物漏退
P3 L1-L25 按影响面择机处理

备注:本报告仅列出问题与建议,未修改任何代码。S1/S2 的修复方案(订单反查、同步周期化)涉及与微信 XPay 官方文档的联调核对,建议修复前先确认 query_order 等接口在官方文档中的字段与状态枚举。