- 项目分析补充第三轮(P2)处理结果与验证方式,含时区改动的风险说明 - README 环境变量表补充 TZ:业务时间按北京时间处理,且 pgx 会把 timestamptz 解码到进程时区,要求显式设为 Asia/Shanghai - 遗留项收敛为:前端公共 chunk 体积、裸 time.Time 字段的 JSON 精度未对齐秒级约定
21 KiB
游戏皮肤分销平台(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 四种模式。两个务实细节:
- 订单
result_data一旦落了provider即锁定渠道,避免重试时跨渠道双发; - 解析为空时仍保留 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。
四、关键业务流
- 下单幂等:
(merchant_id, client_order_no)联合唯一索引 +request_fingerprint比对, 重复提交返回原单,参数不一致则报错。 - Rock UID 强一致风控:方案见
Rock发货平台代理接入与库存风控实施方案.md。 落地要点:本地 Token 池、基线快照比对防上游历史绑定误判、UID 不符硬阻断并记mismatch、 全局 kill switch、5 分钟水位巡检与自动补货。 - 可靠性护栏:回调微信式重试(默认 16 次、退避可配)、发货超时巡检(30 分钟自动置失败)、 发货队列防重试死锁、上游 BFF 频控治理(缓存 + 轮询降频)。
- 提货页:
bind → 轮询 bind-result → submit,轮询接口在路由层被识别为高频读并降级日志级别。
五、工程质量(优点)
- 显式 SQL 迁移而非 AutoMigrate,SHA256 checksum 防篡改(改动已执行版本会启动失败),
pg_advisory_xact_lock防并发迁移。 - 测试使用真实 PostgreSQL 临时 schema 隔离(
internal/testdb),刻意不用 SQLite, 避免方言行为差异。 - 密钥 AES-256-GCM 加密落库(
service/secret_codec.go),API Client / 回调密钥不明文存储。 - CORS 显式拒绝
*,并在注释中写明与AllowCredentials组合的风险。 - 日志体系完整:结构化、
X-Request-ID全链路透传、高频轮询降级 DEBUG、签名打码。 - 所有出站 HTTP 客户端均设置超时;无
TODO/FIXME/HACK残留。
六、2026-10-06 优化记录
本轮针对分析中发现的 P0/P1 问题做了修复,全部经过实测验证。
6.1 密钥配置修复(P0)
问题:.env 中 JWT_SECRET 与 .env.example 逐字节相同(公开的示例值),
且 DATA_ENCRYPTION_KEY 未设置,按 config.go 的回退逻辑实际等于 JWT_SECRET。
后果是任何见过仓库的人都能伪造管理员 JWT,并解密库中全部 API/回调密钥密文。
处理:
| 项 | 内容 |
|---|---|
| 生成新密钥 | JWT_SECRET 96 位十六进制(384-bit)、DATA_ENCRYPTION_KEY 64 位十六进制(256-bit),两者独立 |
| 历史密文迁移 | 开发库 11 条 api_clients + 10 条 callback_subscriptions 已用 cmd/reencrypt_secrets 重加密,0 失败 |
| 新增防护 | Config.SecurityWarnings() 启动体检,示例密钥 / 未分离 / release 下开调试日志均打印 [CONFIG] 安全告警 |
| 新增工具 | backend/cmd/reencrypt_secrets(默认预演,-apply 才写库;幂等,坏数据不中断整轮) |
| 新增文档 | 密钥轮换说明.md |
| 其他 | .env 中 OPEN_API_DEBUG 由 true 改为 false;.gitignore 忽略 .env.bak* |
验证证据:
# 负例:故意使用示例密钥 → 告警如期触发,/health 仍 200
[WARN] [CONFIG] 安全告警: JWT_SECRET 仍是 .env.example 里的示例值,任何见过仓库的人都能伪造管理员 Token
[WARN] [CONFIG] 安全告警: DATA_ENCRYPTION_KEY 未显式设置,已回退到 JWT_SECRET;…
[WARN] [CONFIG] 安全告警: DATA_ENCRYPTION_KEY 与 JWT_SECRET 相同,建议分离以便独立轮换
# 正例:使用新密钥 → 0 条告警
游戏皮肤供货平台 API 启动: http://localhost:18080
# 重加密:预演 → 写入 → 复跑幂等
api_clients 扫描=11 重加密=11 已是新密钥=0 失败=0
callback_subscriptions 扫描=10 重加密=10 已是新密钥=0 失败=0
# 复跑(预演)
api_clients 扫描=11 重加密=0 已是新密钥=11 失败=0
callback_subscriptions 扫描=10 重加密=0 已是新密钥=10 失败=0
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 |
处理:
- 路由级懒加载:
App.tsx中 11 个页面改为React.lazy(),Login与MainLayout保持同步加载(它们是未登录用户与登录后首帧的必经路径); 新增统一占位RouteFallback,同时替换了权限校验处重复的占位 div。 各页面成为独立 chunk(如PlatformRock39.8 kB、MerchantCenter43.3 kB)。 - 修复失效的
useMemo:PlatformUsers.tsx中columns/memberColumns/merchantColumns每次渲染都会重建,导致依赖它们的itemsmemo 完全失效。 已将三个回调(openEdit/remove/removeMerchantAccount)useCallback化, 并把三个列定义useMemo化,使 memo 真正命中。 - 拆分 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 不改变渲染结果需要证据,不能只看编译通过):
- SSR 快照:
renderToString(<PlatformRock/>)在拆分前后输出字节一致(40,920 bytes); - jsdom 交互差分:用 mock 的
rockApi真实挂载并点击,拆分前后 23 个检查点的 DOM 快照与 API 调用日志完全一致 (覆盖采购弹窗、UID 修正、解冻、释放、熔断确认、筛选/搜索/翻页、保存配置、连通性测试); 原始文件先跑两遍以证明探针本身可复现; - 原文件应保留的 230 行代码逐字在新文件中命中(唯一差异是
uidEdit的内联类型被抽成共享UidEditState,纯类型无运行时影响)。 - 临时探针文件已清理,
package.json/package-lock.json无改动。
验证方式(不依赖子代理自述,独立复核):
- 声明级并集比对:
delivery.go拆分前后 55 个func/type/const/var声明完全一致,无缺失、无多出、无重复; handler拆分前后声明行diff为空,MerchantHandler方法数 41 → 41;rock_service.go35 个声明块(26 func + 9 type/const)全部有归属,无遗漏无重复;gofmt -l全部为空,go build ./.../go vet ./...通过;- 全量测试
go test ./... -count=1通过,Rock 相关用例额外在-race下通过。
P1-3 service 包按 provider 拆成子包 —— 评估后建议暂缓
结论:本轮不做包级拆分,改用文件级组织替代。 理由是包级拆分的前提成本远高于收益:
- 存在硬性循环依赖。
provider_dlc.go/provider_lewan.go/provider_rock.go依赖OpenOrderQuery、DeliveryOrderState、ProductRoutingConfig、DeliveryProduct、ProviderBindResult等类型,而这些类型全部定义在service包内(provider.go、delivery_types.go)。 要让 provider 变成子包,必须先把这些共享类型抽到第三个包,再让service反向导入它。 - RockProvider 与 RockService 强耦合:前者是后者的薄包装,拆包会把
RockService(及RockClient)一并拖走, 而RockService又被handler/rock.go、DeliveryService与发货超时巡检引用。 - 测试随之迁移:
service包内约 4,500 行测试全部是package service内部测试, 直接引用NewRockProvider、deliveryGoodID等非导出符号,拆包需要同步重构测试。 - 原始痛点是「文件太大难导航」,不是「包边界错了」。文件级拆分已经把这个痛点解决:
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 是秒级,裸字段未对齐 |
八、本地开发与验证
make install # 安装前后端依赖
make dev # 一键启动(含 PostgreSQL),前端 :15173 / 后端 :18080
make test # 全量测试(自动拉起 PostgreSQL,测试用临时 schema 隔离)
make lint # go vet + oxlint
make fmt # gofmt 格式化
首次配置必须为 JWT_SECRET 与 DATA_ENCRYPTION_KEY 各自生成随机值,
详见 密钥轮换说明.md。