16 KiB
16 KiB
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=<目标订单号>的报文:handleRefundNotify白名单判断通过;- 查订单(
OutTradeNo)命中且非 Refunded; 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)完全由客户端指定,服务端:- 不校验
Amount上限(可传任意大值,如 999999); - 不校验
RefID的真实性(未与广告平台服务端回调或观看记录对账); - 直接以
Amount作为凭证数量QuotaGrantCreate落库(ExpireTime=当天23:59:59)。
- 不校验
- 影响:任意登录用户可反复调用,给自己发放任意数量的 Cos/AI/分享额度凭证(当天有效),彻底击穿额度体系,构成直接资损。
- 修复建议:
- 奖励额度改为服务端按 RewardType 配置固定值,不从客户端接收
Amount; RefID必须由广告平台服务端回调下发并校验(或引入一次性凭证/签名),杜绝前端自造;- 增加每日领取次数上限。
- 奖励额度改为服务端按 RewardType 配置固定值,不从客户端接收
三、中等问题(新增)
M1-new. 自动退款失败后状态卡死,无重试机制(用户付了钱收不到退款)
- 位置:
internal/service/order.go:402-437(autoRefundCancelledOrder) - 问题:订单「已取消但用户已付款」时自动发起退款:
- 先建退款流水
Status=RefundStatusProcessing(order.go:411-420); - 调
wechat.Refund(order.go:429),若失败仅记录日志后直接 return,未把流水置为RefundStatusFailed(order.go:430-433)。 - 下次再次进入该函数时,幂等检查
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)」,字段自相矛盾,前端展示「月度会员」造成「被降级」客诉。 - 反向:月会员续大额包也有类型跳变。
- 年度会员(剩余 350 天)续费 30 天月包 →
- 修复建议:
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中多处已标注「以官方枚举为准,联调时校正」)。