Files
affiliate_dash/docs/项目分析.md
T
yml2213 3ec7febb8c refactor(security): 简化为单主密钥架构,废弃离线重加密与冗余警告
- 废弃 DATA_ENCRYPTION_KEY 独立要求,敏感数据加密默认统一由 JWT_SECRET 派生
- 移除 cmd/reencrypt_secrets 离线全表重加密脚本与 docs/密钥轮换说明.md,避免生产原地 UPDATE 高危反模式
- 精简 Config.SecurityWarnings,不再刷屏 DATA_ENCRYPTION_KEY 未显式配置警告,保持启动日志干净
- 更新 .env.example、README.md 与项目分析文档说明
- 验证:make lint 与 make test 全量测试 100% 通过
2026-10-06 20:54:00 +08:00

24 KiB
Raw Blame History

游戏皮肤分销平台(affiliate_dash)项目分析

分析日期:2026-10-06 分析范围:backend/、frontend/、docs/、构建与部署配置 验证方式:go build / go vet / go test ./... / tsc + vite build / oxlint 实测,非静态阅读推断


一、项目定位

游戏皮肤(和平精英)分销供货 SaaS 平台。平台方自行对接上游货源,向下游商户提供 「下单 → 发货」能力,并同时承载平台管理、商户自助与终端买家提货三类使用者。

角色 入口 职责
平台管理员 /platform-* 后台 管理商户、商品目录、渠道路由、Rock 库存池
下游商户 /merchant-* 与 /api/client/v1 卖皮肤、查钱包、收支回调
终端买家 /delivery/:channel/:orderNo 只填/确认游戏 UID,扫码绑定角色,领取凭证
上游货源 /api/open/v1 与 Webhook 三个履约渠道:DLC(第一平台 BFF)、乐玩开放平台、Rock(0666.pro)

核心闭环:商户下单扣钱包 → 渠道路由 → 买家提货绑定 → 上游发货 → 回写状态 → 回调商户。


二、技术栈与规模

层 技术
后端 Go 1.26 + Gin 1.12 + GORM + PostgreSQL 16
前端 React 19 + TypeScript 6 + Ant Design 6 + Vite 8
部署 Docker Compose + Caddy(统一入口 + SPA + API 反代)

代码规模(2026-10-06 统计):

指标 数值
后端 90 个 .go / 22,150 行
前端 src/ 33 个文件 / 12,129 行
HTTP 路由 96 条
SQL 迁移 12 个
测试函数 106 个(顶层)
提交历史 154 次(2026-07-20 → 2026-10-06)

三、系统架构

3.1 分层

cmd/server/main.go            组装依赖、注册 Provider、启动常驻 goroutine
  └ internal/router            96 条路由 + 结构化请求日志(高频轮询降级 DEBUG)
      └ internal/middleware    4 套鉴权:
                                 JWT+Tenant / OpenAuth(商户) / SourceOpenAuth(源头)
                                 / RequireMerchantFeature + RequireMerchantPermissions
          └ internal/handler   薄层:参数校验与响应封装
              └ internal/service   业务主体
                  └ internal/database  显式 SQL 迁移(embed + checksum + advisory lock)

3.2 履约策略抽象(架构上最有价值的部分)

backend/internal/service/provider.go 定义统一接口:

FulfillmentProvider → PrepareDelivery / Bind / CheckBind / Submit / SyncStatus / Cancel
  ├ DLCProvider   第一平台 BFF(sign-proxy 签名代理)
  ├ LewanProvider 乐玩开放平台
  └ RockProvider  0666.pro(本地 Token 库存池 + UID 强一致门禁)

FulfillmentRouter.ResolveProviders() 按 ProductRoutingConfig 选择渠道,支持 priority / lewan / dlc / rock 四种模式。两个务实细节:

  1. 订单 result_data 一旦落了 provider 即锁定渠道,避免重试时跨渠道双发;
  2. 解析为空时仍保留 DLC 兜底,避免配置缺失导致订单无法发货。

新增渠道只需实现接口 + RegisterProvider,扩展成本低。

3.3 三条外部链路(务必区分,签名算法不通用)

链路 调用方 → 接收方 入口 鉴权
商户侧开放 API 商户系统 → 本平台 /api/client/v1 X-App-Key + HMAC + body_sha256
源头侧发货 API 上游平台 → 本平台 /api/open/v1 X-Api-Key + HMAC + 原始 body
商户回调 本平台 → 商户 URL 商户后台配置 X-Event-ID + X-Timestamp + X-Sign

详见 API对接关系.md 与 发货通知约定.md。

3.4 数据模型

  • Merchant 是所有业务数据的租户隔离边界;MerchantMember 把「系统账号」与「商户权限」解耦, 一个账号可属于多个商户。
  • 钱包用 int64 最小货币单位记账,明确不使用 float64;WalletLedgerEntry 带 balance_after 与幂等键唯一索引,便于审计对账。
  • CallbackDelivery 是标准事务性 outbox,event_id 唯一索引供接收方幂等。
  • APIRequestNonce / SourceAPINonce 把重放保护落库,跨进程、跨重启生效。
  • RockToken 是本地通用库存池:expected_uid / actual_role_id / mismatch_at 支撑 「绑定错误独立统计与追溯」。
  • AuditLog 记录操作者(用户或 API Client)与请求 ID。

四、关键业务流

  1. 下单幂等:(merchant_id, client_order_no) 联合唯一索引 + request_fingerprint 比对, 重复提交返回原单,参数不一致则报错。
  2. Rock UID 强一致风控:方案见 Rock发货平台代理接入与库存风控实施方案.md。 落地要点:本地 Token 池、基线快照比对防上游历史绑定误判、UID 不符硬阻断并记 mismatch、 全局 kill switch、5 分钟水位巡检与自动补货。
  3. 可靠性护栏:回调微信式重试(默认 16 次、退避可配)、发货超时巡检(30 分钟自动置失败)、 发货队列防重试死锁、上游 BFF 频控治理(缓存 + 轮询降频)。
  4. 提货页:bind → 轮询 bind-result → submit,轮询接口在路由层被识别为高频读并降级日志级别。

五、工程质量(优点)

  1. 显式 SQL 迁移而非 AutoMigrate,SHA256 checksum 防篡改(改动已执行版本会启动失败), pg_advisory_xact_lock 防并发迁移。
  2. 测试使用真实 PostgreSQL 临时 schema 隔离(internal/testdb),刻意不用 SQLite, 避免方言行为差异。
  3. 密钥 AES-256-GCM 加密落库(service/secret_codec.go),API Client / 回调密钥不明文存储。
  4. CORS 显式拒绝 *,并在注释中写明与 AllowCredentials 组合的风险。
  5. 日志体系完整:结构化、X-Request-ID 全链路透传、高频轮询降级 DEBUG、签名打码。
  6. 所有出站 HTTP 客户端均设置超时;无 TODO/FIXME/HACK 残留。

六、2026-10-06 优化记录

本轮针对分析中发现的 P0/P1 问题做了修复,全部经过实测验证。

6.1 密钥配置修复(P0)

问题:.env 中 JWT_SECRET 与 .env.example 逐字节相同(公开的示例值)。后果是任何见过仓库的人都能伪造管理员 JWT,并解密库中 API/回调密钥密文。

处理(务实单主密钥架构):

项 内容
架构简化 统一由 JWT_SECRET 承接 JWT 签发与 AES-GCM 敏感数据加密,避免单机容器环境下拆分双密钥的伪隔离与高危全表重加密脚本
启动体检 启动时仅在仍使用公开示例密钥或 release 模式误开 OPEN_API_DEBUG 时告警,保持启动日志干净
灵活兼容 保留可选的 DATA_ENCRYPTION_KEY 环境变量读取,未设置时自动由主密钥派生

6.2 测试可运行性修复(P1)

问题 1:仓库没有 make test,集成测试硬依赖 127.0.0.1:15432 的 PostgreSQL, 未启动数据库时 60+ 用例直接 FAIL,README 也没有测试说明。

问题 2(本次暴露的真实缺陷):internal/testdb.withSearchPath 使用 url.Values.Encode() 重新编码整个查询串,把 TimeZone=Asia/Shanghai 变成 Asia%2FShanghai,pgx 原样传给服务端后报 unknown time zone Asia%2FShanghai。 数据库起来之后测试依然全挂,这才是此前「测试跑不起来」的第二层原因。

处理:

  • withSearchPath 改为只追加 search_path,不再重编码已有参数, 并补充注释说明原因;
  • 新增 internal/testdb/testdb_test.go 锁定该行为,防止回归;
  • Makefile 新增 make test(自动拉起并等待 PostgreSQL 就绪)、 make test-db、make lint、make fmt。

验证证据:

$ make test
ok  affiliate_dash/internal/config       ok  affiliate_dash/internal/middleware
ok  affiliate_dash/internal/pkg/applog   ok  affiliate_dash/internal/pkg/lewan
ok  affiliate_dash/internal/pkg/openlog  ok  affiliate_dash/internal/pkg/response
ok  affiliate_dash/internal/pkg/timeutil ok  affiliate_dash/internal/router
ok  affiliate_dash/internal/service      ok  affiliate_dash/internal/testdb

顶层用例通过数:106 / 106。

6.3 前端体积与 HMR 修复(P1)

项 改造前 改造后
首屏静态 JS+CSS 1,606.9 kB(gzip 497.7 kB) 821.8 kB(gzip 268.4 kB)
gzip 降幅 — −46.1%
构建体积警告 有(>500 kB) 无
oxlint 5 warnings 0 warnings

处理:

  1. 路由级懒加载:App.tsx 中 11 个页面改为 React.lazy(), Login 与 MainLayout 保持同步加载(它们是未登录用户与登录后首帧的必经路径); 新增统一占位 RouteFallback,同时替换了权限校验处重复的占位 div。 各页面成为独立 chunk(如 PlatformRock 39.8 kB、MerchantCenter 43.3 kB)。
  2. 修复失效的 useMemo:PlatformUsers.tsx 中 columns / memberColumns / merchantColumns 每次渲染都会重建,导致依赖它们的 items memo 完全失效。 已将三个回调(openEdit / remove / removeMerchantAccount)useCallback 化, 并把三个列定义 useMemo 化,使 memo 真正命中。
  3. 拆分 auth 模块:useAuth 与 AuthContext 移入新增的 store/authContext.ts, 使 store/auth.tsx 只导出 AuthProvider 组件,消除 react(only-export-components) 导致的 HMR 整页刷新(同步更新 7 处引用)。

6.4 格式基线

新增 make fmt 并清理了 6 个文件的既有 gofmt 偏差(纯对齐与行尾空行)。 现 gofmt -l 输出为空,可将格式检查直接纳入 CI。


七、遗留问题与建议

P1-1 Rock BaseURL 双来源 —— 已修复(确认是真 Bug)

根因确认:cmd/server/main.go 把 https://0666.pro 硬编码进 NewRockClient, 而 RockService.GetConfig 从不把 rock_configs.base_url 回写到客户端。 UpdateConfig 只改内存字段,因此后台改了上游地址会「保存那一刻生效、进程重启后失效」—— DB 里的值被静默忽略,且没有任何日志提示。

修复:

项 内容
单一默认值 新增 model.RockDefaultBaseURL 常量,main.go / RockClient 兜底 / GetConfig 初始化三处共用
启动同步 新增 RockService.ApplyConfigToClient(ctx),启动时把 DB 地址同步到客户端;main.go 调用
立即生效 UpdateConfig 改用 client.SetBaseURL(),不再直接写字段
并发安全 RockClient.baseURL 原为裸字段,被 HTTP 请求与库存巡检 goroutine 并发读写(数据竞争)。改为 sync.RWMutex 保护,新增 BaseURL() / SetBaseURL()
请求头跟随 ClaimTokens / RefreshToken / RedeemSkin 的 Origin、Referer 原为硬编码 https://0666.pro,改为跟随配置地址
边界处理 空值/纯空白回落默认地址,末尾斜杠自动裁剪

不修改迁移文件:012_rock_fulfillment.sql 里的默认值受 checksum 保护, 改动会让已有部署因 migration 012 checksum mismatch 启动失败,故保持原样并在常量处注释说明。

验证:新增 rock_baseurl_test.go(4 个用例),其中 TestRockServiceBaseURLFromConfig 完整模拟「后台改地址 → 立即生效 → 进程重启 → 仍然生效」; TestRockClientBaseURLConcurrentAccess 在 -race 下验证无数据竞争。全部通过。

P1-2 巨型文件 —— 已拆分(纯文件级移动,零行为变更)

四个文件按业务域拆分,全部为同 package 内移动,未改动任何逻辑、签名或文案:

原文件 行数 拆分结果
service/delivery.go 1,162 → delivery.go 113、delivery_types.go 86、delivery_provider.go 136、delivery_query.go 248、delivery_bind.go 208、delivery_submit.go 249(deliveryGoodID 并入 delivery_helpers.go,链接管理并入 delivery_link.go)
handler/merchant.go 847 → merchant.go 59、merchant_product.go 182、merchant_order.go 152、merchant_wallet.go 96、merchant_api_client.go 121、merchant_member.go 134、merchant_platform.go 169
service/rock_service.go 1,177 → rock_service.go 221、rock_bindlink.go 77、rock_stock.go 177、rock_assign.go 158、rock_bindcheck.go 213、rock_redeem.go 168、rock_recover.go 127、rock_snapshot.go 116
pages/PlatformRock.tsx 1,306 → PlatformRock.tsx 335(容器:state / 数据加载 / 顶层布局)+ platformRock/ 子目录 9 个文件:tokenColumns.tsx 329、RockStrategyCard.tsx 267、PurchaseTokenModal.tsx 183、RockTokenTable.tsx 156、RockMetricCards.tsx 124、VoucherPreviewModal.tsx 95、RockTokenFilterBar.tsx 76、UidEditModal.tsx 47、types.ts 6

拆分后后端 service 包最大非测试文件从 1,177 行降到 640 行(fulfillment.go), handler 包最大从 847 行降到 182 行,前端 PlatformRock.tsx 从 1,306 行降到 335 行 (最大子组件 329 行)。App.tsx 的 lazy(() => import('./pages/PlatformRock')) 与默认导出名未变。

前端拆分的额外行为验证(拆分 JSX 不改变渲染结果需要证据,不能只看编译通过):

  1. SSR 快照:renderToString(<PlatformRock/>) 在拆分前后输出字节一致(40,920 bytes);
  2. jsdom 交互差分:用 mock 的 rockApi 真实挂载并点击,拆分前后 23 个检查点的 DOM 快照与 API 调用日志完全一致 (覆盖采购弹窗、UID 修正、解冻、释放、熔断确认、筛选/搜索/翻页、保存配置、连通性测试); 原始文件先跑两遍以证明探针本身可复现;
  3. 原文件应保留的 230 行代码逐字在新文件中命中(唯一差异是 uidEdit 的内联类型被抽成共享 UidEditState,纯类型无运行时影响)。
  4. 临时探针文件已清理,package.json / package-lock.json 无改动。

验证方式(不依赖子代理自述,独立复核):

  • 声明级并集比对:delivery.go 拆分前后 55 个 func/type/const/var 声明完全一致,无缺失、无多出、无重复;
  • handler 拆分前后声明行 diff 为空,MerchantHandler 方法数 41 → 41;
  • rock_service.go 35 个声明块(26 func + 9 type/const)全部有归属,无遗漏无重复;
  • gofmt -l 全部为空,go build ./... / go vet ./... 通过;
  • 全量测试 go test ./... -count=1 通过,Rock 相关用例额外在 -race 下通过。

P1-3 service 包按 provider 拆成子包 —— 评估后建议暂缓

结论:本轮不做包级拆分,改用文件级组织替代。 理由是包级拆分的前提成本远高于收益:

  1. 存在硬性循环依赖。provider_dlc.go / provider_lewan.go / provider_rock.go 依赖 OpenOrderQuery、DeliveryOrderState、ProductRoutingConfig、DeliveryProduct、 ProviderBindResult 等类型,而这些类型全部定义在 service 包内(provider.go、delivery_types.go)。 要让 provider 变成子包,必须先把这些共享类型抽到第三个包,再让 service 反向导入它。
  2. RockProvider 与 RockService 强耦合:前者是后者的薄包装,拆包会把 RockService(及 RockClient)一并拖走, 而 RockService 又被 handler/rock.go、DeliveryService 与发货超时巡检引用。
  3. 测试随之迁移:service 包内约 4,500 行测试全部是 package service 内部测试, 直接引用 NewRockProvider、deliveryGoodID 等非导出符号,拆包需要同步重构测试。
  4. 原始痛点是「文件太大难导航」,不是「包边界错了」。文件级拆分已经把这个痛点解决: service 包内 46 个文件现已按 delivery_* / rock_* / merchant_* / provider_* 前缀清晰成组。

在没有独立测试环境、且线上有真实订单与上游凭据的前提下,做这种三包重排的回归风险不成比例。 若后续确有需要,建议作为独立迭代推进,并先补一层端到端的订单回归测试再动手。

P1-4 其余(未变)

问题 位置 建议
fulfillment.go 640 行 service/fulfillment.go 本次未拆;如需可继续按「下单 / 状态机 / 查询 / 超时」拆分
provider_dlc.go 598 行、provider_lewan.go 565 行 service/ 同上,可各拆为「接口适配」与「协议映射」两部分

P2 处理结果(2026-10-06 第三轮)

项 状态 说明
SQLite 时代残留 ✅ 已删除 backend/data/affiliate.db(0 字节)、app.db(90 KB)。删除前已确认无代码引用,并保留 data/uploads/ 下 16 个在用商品图
告警渠道密钥外泄面 ✅ 已脱敏 响应层只保留密钥末 4 位;webhook 保留域名与路径以便辨认机器人(钉钉/企微在查询串、飞书在路径末段)。脱敏值原样回传时沿用库中真实密钥,填新值则替换。脱敏刻意放在响应层:CheckLowBalanceAndNotify 复用 ListAlertChannels 的结果真正去发消息,在 service 层脱敏会直接打断告警
三套 HMAC 签名实现重复 ✅ 已收敛原语 新增 internal/pkg/signutil,只统一语义完全一致的四类:HMAC-SHA256→hex、body 摘要、时间戳偏差、常数时间比较。签名串构造刻意不统一——三者是对外契约且方式不同,合并会直接改变握手结果
全局改写 time.Local ✅ 已移除 见下方说明
前端公共 chunk 479.7 kB ⏸ 未处理 React + AntD 运行时,需评估 antd 按需引入策略,收益与风险需单独评估

关于时区改动(这是本轮唯一有行为风险的一项):

main.go 原本用 time.Local = timeutil.Location() 隐式改写进程全局状态把时间钉在 +08:00。

  • 唯一真实依赖是 rock_snapshot.parseRockTime:上游返回不带偏移的北京时间墙面时间, 用 time.Local 解析等于假设进程时区就是北京时间。实测确认 pgx 会把 timestamptz 解码到 time.Local,因此该值一旦不是 +08:00,解析结果会整体偏移 8 小时,进而把「本次新绑定」 误判为「历史残留」放回库存 → 造成重复发货。已改为显式 timeutil.Location() 并补回归测试。
  • 有一点无法绕开:pgx 的解码行为使接口 JSON 的时间偏移仍由进程时区决定。因此新增 timeutil.LocalZoneNotice(),启动时把不一致显式告警,由运维用 TZ=Asia/Shanghai 声明 (Docker 部署已内置该变量),而不是靠代码偷偷改全局状态。
  • 验证:全量测试在默认时区 / TZ=UTC / TZ=America/New_York 三种环境下均通过; 实测 TZ=UTC 启动会打印时区提示,业务时区下静默。

若要做到「完全不受进程时区影响」的下一个台阶,需要把模型里的 time.Time 字段换成 自带 JSON 序列化的自定义时间类型(读写两侧都显式指定时区)。这是一次 API 可见的改动 (当前裸字段输出纳秒精度,文档约定的是秒级),建议作为独立迭代并先补接口回归测试。

仍遗留

问题 位置 说明
前端公共 chunk 479.7 kB(gzip 157.8 kB) dist/assets/jsx-runtime-* React + AntD 公共运行时;如需进一步压缩需评估 antd 按需引入与手动分包策略
裸 time.Time 字段的 JSON 精度 各 model 输出纳秒精度,而 docs/API对接关系.md 约定秒级;契约字段走 timeutil.FormatAPITime 是秒级,裸字段未对齐

八、管理端商户上下文复盘(2026-10-06)

起因是一个来自使用者的反馈:「超级管理员是不是看不到下游订单?」 实测后确认属实,而且比"看不到"更糟——同一身份在同一时刻看到两个互相矛盾的数字。

8.1 现象(实测数据)

调用 结果
GET /api/dashboard(管理员) scope=platform,order_count=37
GET /api/merchant/orders(管理员,默认) total=29,全部为自营商户
GET /api/merchant/orders + X-Merchant-ID: 2 total=8
数据库实际分布 自营商户 29 + 测试商户 8 = 37

首页显示 37 单、订单页只有 29 单,界面上没有任何解释。

8.2 根因:role=admin 在两个接口里语义不同

  • handler/order.go 的 isPlatformAdmin 只看 JWT 角色 → 统计是平台视角,跨商户聚合;
  • middleware.Tenant 只认 X-Merchant-ID,没有就回退到账号自己的成员关系 → 管理员唯一的成员关系是自营商户,被静默框定在单个商户;
  • 35 条 /platform/* 路由里没有任何订单入口,前端也没有商户切换器或上下文提示。

8.3 更深一层:三个设计问题

  1. Tenant 中间件把「解析」与「授权」揉在一起,解析失败直接 403。 而解析失败有两种完全不同的原因——客户端状态过期(商户被禁用/删除)与真的无权限—— 中间件把它们压成同一个 403,客户端无法区分,也就无法自愈。
  2. 它挂在「所有已登录请求」上,包括根本不看商户的平台接口。 于是「LocalStorage 里躺了一个已失效的商户 ID」这种客户端状态问题, 升级成了整个管理端不可用:连去把上下文改回来的平台页面都打不开。
  3. 商户上下文是前端自由拼装的请求头,不是受控状态。 它只存在 localStorage,只在登录/登出时清除;唯一的写入点是「平台商户」页的 「成员」按钮(一个副作用),没有显式入口、没有提示、也没有退出方式。

8.4 处理

提交 内容
8c12d1d 新增 GET /api/platform/orders 跨商户订单视图 + 前端「下游订单」页;商户侧隔离语义不动,并补测试卡住边界
e692790 平台分组从 Tenant 中摘出(逐条核对:35 条平台路由对租户的依赖为 0);前端对 /platform/* 与管理员 /dashboard 不再发送 X-Merchant-ID
04c3369 顶栏常驻显示「当前商户:xxx」+「退出商户视角」
663d7e1 自查发现筛选下拉被分页上限静默截断,新增不分页的 /api/platform/merchant-options

关于"为什么不静默回退":商户上下文解析失败时后端仍返回 403。 商户后台存在写操作(人工下单、取消订单、成员管理),若自动回退到默认商户, 写入会落到错误的商户上——这比"多点一次退出"严重得多。 因此选择「失败要响,但必须给得出路」,出路就是顶栏的退出入口。

8.5 修复后仍然存在的风险

风险 说明 建议
仍走 Tenant 的 42 条路由(37 merchant + 5 auth)在上下文失效时返回 403 前端已跳过管理员的 /platform/* 与 /dashboard 请求头,但 curl / 移动端 / 旧的缓存 bundle 仍会命中 给 Tenant 的失败加上可识别的业务错误码,客户端据此清理上下文并重试一次,实现自愈
response.Page 回显的是请求的 size,不是生效的 size normalizePage 会把 size>100 降级为 20,但响应里仍写 200,客户端按它翻页会跳数据 把 size 归一化上移到 handler 边界,或让 service 返回生效值
平台路由不再解析商户上下文 这是有意的:平台接口本来就跨商户。但未来若某个平台接口确实需要知道当前商户,不能再依赖 GetMerchantID,必须显式传参 在 router 的注释里已写明该约束
平台商户列表页缺少「以该商户视角查看」入口 目前仍只能靠「成员」按钮的副作用进入商户上下文 补一个显式按钮

九、本地开发与验证

make install     # 安装前后端依赖
make dev         # 一键启动(含 PostgreSQL),前端 :15173 / 后端 :18080
make test        # 全量测试(自动拉起 PostgreSQL,测试用临时 schema 隔离)
make lint        # go vet + oxlint
make fmt         # gofmt 格式化

生产环境部署时将 JWT_SECRET 配置为独立强随机密钥即可。