first commit

This commit is contained in:
2026-09-11 01:41:52 +08:00
commit 9f22e6ccae
263 changed files with 28935 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
# 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` 中多处已标注「以官方枚举为准,联调时校正」)。
+207
View File
@@ -0,0 +1,207 @@
# 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` 等接口在官方文档中的字段与状态枚举。