## Context 项目于 2024 年初启动,经过一年多的功能迭代,业务模块逐步完善(支付、轮询、分佣、多租户等),但代码库中积累了分散的废弃代码、硬编码常量、规范缺陷和设计遗留问题。这些问题目前不影响功能,但随着新功能增加,维护成本快速上升: 1. **API 文档覆盖率仅 18.75%**:docs.go/gendocs 只注册了 9 个 Handler,实际路由 48 个,导致 39 个接口无法在 OpenAPI 文档中查阅 2. **支付配置硬编码**:order/service 和 recharge/service 中 3 处 TODO,直接使用全局 `s.wechatPayment`,多商户场景下无法正确验签 3. **硬编码魔法字符串**:轮询系统中 10+ 处状态字符串未提取常量,无法被 IDE 重构工具识别,变更风险高 4. **废弃代码散布各处**:15 个常量别名、3 个 DTO、2 个方法标注废弃但仍保留,新开发者易误用 5. **Model 数据冗余**:IotCard 和 Device 模型中 4 个字段已被替代但仍保留在数据库,造成数据不一致风险 6. **DTO 规范缺口**:122 个 Response DTO 缺少 `_name` 文字字段,违反项目规范 7. **迁移文件堆积,无法支撑生产部署**:开发阶段逐步积累 114 个迁移(000000~000113),包含大量中间过渡状态(重命名表、删除旧表、修复字段类型等),问题具体为: - `000104_polling_config_data` 仅有 `.up.sql`,缺少 `.down.sql` - `backfill_order_purchase_role.sql` 游离在外,不符合 golang-migrate 命名规范,不会被自动执行 - 新环境部署需顺序执行 114 个迁移,任何一步失败即卡住 ## Goals / Non-Goals **Goals:** 1. **文档完整性**:补全 API 文档生成器,达到 100% 覆盖率,确保前端/对接方能通过 OpenAPI 文档查阅所有接口 2. **系统可靠性**:实现支付配置动态加载,支持多商户场景,消除验签失败隐患 3. **代码规范化**:清理所有硬编码常量、废弃代码、重复 DTO,统一提取到 `pkg/constants/`,降低维护成本和新手上手难度 4. **数据库一致性**:删除废弃 Model 字段和对应数据库列,防止新/旧逻辑产生的数据不一致 5. **规范落实**:补全 DTO `_name` 字段和 Service 层赋值逻辑,确保规范 100% 贯彻 6. **生产就绪的迁移基线**:将 114 个开发期迁移合并为 2 个生产基线文件,新环境一条命令完成全量建库,彻底消除中间过渡状态 **Non-Goals:** - 重构现有业务逻辑(支付、轮询、分佣等),仅清理代码结构 - 修改 API 契约或响应格式(除了补充 `_name` 字段) - 优化性能(无性能变更) - 新增功能 ## Decisions ### 决策 1:API 文档生成器补全 **选择**:修改 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`,在 `bootstrap.Handlers` 结构体中注册缺失的 39 个 Handler(Account、AdminOrder、Asset、Authorization 等) **理由**: - 这两个文件是唯一的 API 文档入口,所有接口都需在此注册才能出现在 OpenAPI 文档中 - 注册逻辑简单且低风险(仅字段赋值,无业务逻辑变更) - 满足项目规范:"新增 Handler 时必须同步更新文档生成器" **替代方案考虑**: - ❌ 自动扫描所有 Handler:增加框架复杂性,不符合 Go 惯用模式(显式优于隐式) - ❌ 分阶段补全:低优先级接口仍可能被遗漏,最终还是需要一次性全部补全 --- ### 决策 2:支付配置动态加载架构 **选择**:在 Service 层新增 `PaymentConfigLoader` 接口和实现,`order/service.go` 和 `recharge/service.go` 中调用此接口从 `payment_config_id` 动态获取对应的支付配置实例 ```go // pkg/payment/loader.go(新增) type PaymentConfigLoader interface { LoadConfig(ctx context.Context, configID uint) (Payment, error) } // internal/service/order/service.go(修改) func (s *OrderService) PayOrder(ctx context.Context, orderID uint) error { order := s.store.GetOrder(orderID) cfg, err := s.paymentLoader.LoadConfig(ctx, order.PaymentConfigID) // 动态加载 return cfg.Verify(order.PaymentData) } ``` **理由**: - 解耦支付配置与订单逻辑,支持多商户多配置场景 - 缓存在 Redis 中(key: `payment:config:{configID}`),减少数据库查询 - 若配置不存在或无权限,返回 `errors.New(errors.CodePaymentConfigNotFound)` **替代方案考虑**: - ❌ 在 Store 层加载:支付验签是业务逻辑,应在 Service 层处理 - ❌ 继续使用全局单例:无法支持多商户,前期配置可行但长期不可扩展 --- ### 决策 3:轮询状态常量提取 **选择**:在 `pkg/constants/polling.go` 中新增轮询日志状态常量,替换 `polling_manual_trigger.go`、`manual_trigger_service.go`、`polling_manual_trigger_store.go`、`alert_service.go` 中的硬编码字符串 ```go // pkg/constants/polling.go(新增) const ( // 轮询手动触发日志状态 PollingManualTriggerStatusPending = "pending" // 待处理 PollingManualTriggerStatusProcessing = "processing" // 处理中 PollingManualTriggerStatusCompleted = "completed" // 已完成 PollingManualTriggerStatusCancelled = "cancelled" // 已取消 ) ``` **理由**: - 集中管理状态值,便于全局修改和 IDE 重构 - 保持与轮询系统其他常量(`PollingStatusEnabled` 等)的命名一致性 - 消除硬编码减少代码行数 ~10 行 **替代方案考虑**: - ❌ 定义 enum 类型:Go 中无原生 enum,定义 type+const 更符合惯用模式 - ❌ 分散定义在各模块:违反"集中在 pkg/constants/"规范 **已知遗留问题(不在本次范围内)**: 轮询手动触发日志的状态字段(`status`)在数据库和代码中均为 `string` 类型,而项目规范要求"状态类(生命周期)用 `int`"。本次清理**仅提取常量,不修改字段类型**,原因: 1. 修改字段类型需要数据库迁移,会产生 API 破坏性变更(现有 JSON 字段值改变) 2. 当前没有并行业务需求推动这个改动 本次提案将此作为已知技术债务记录,待后续版本统一规划。 --- ### 决策 4:废弃代码清理 **选择**: 1. **同步模板代码**(`internal/task/sync.go`):删除整个文件,轮询系统已实现真正的 SIM 卡状态/实名状态/流量同步逻辑 2. **废弃常量别名**(`pkg/constants/wallet.go`):全局搜索并替换 15 个 `Deprecated` 别名为新常量,然后删除别名定义 3. **废弃 DTO 类型**(3 个):确认无引用后删除 `DeviceBundle`、`DeviceBundleCard`、`AllocatedDevice` 4. **废弃方法**(2 个):确认无引用后删除 `CreateLegacy()` 和 `CheckAndStopCard()` **理由**: - 废弃代码继续存在会增加代码认知负担,新开发者易误用 - 同步任务虽然是模板,但从未被入队使用,保留无意义 - 这些清理是一次性工作,越早做越好(后续修改越多,冲突风险越大) **替代方案考虑**: - ❌ 标注但保留:会持续占用代码审查注意力,推迟问题不是解决方案 - ❌ 分批清理:逐个清理会产生多个 PR,合并前后的重构冲突难以管理 --- ### 决策 5:Model 废弃字段清理 **选择**: 1. 从 `iot_card.go` 和 `device.go` 中删除 `FirstCommissionPaid` 和 `AccumulatedRecharge` 字段定义 2. 创建数据库迁移脚本(`000XXX_remove_legacy_commission_fields.up.sql`),删除对应列 **理由**: - 这两个字段已被 `AccumulatedRechargeBySeriesJSON` 和 `FirstRechargeTriggeredBySeriesJSON` 替代,新逻辑不再维护它们 - 保留在数据库中造成数据冗余和不一致风险(新逻辑更新 BySeriesJSON,旧字段不更新) - GORM 对比迁移后,Model 与数据库结构保持一致 **替代方案考虑**: - ❌ 仅删除 Model 定义不删除数据库列:GORM 迁移时会警告字段缺失,容易引发混淆 - ❌ 只删除数据库列不删除 Model 定义:运行时可能触发 scanning 错误 --- ### 决策 6:DTO `_name` 字段补全 **选择**: 1. 遍历 `internal/model/dto/` 下所有 DTO 文件,找出所有 int 类型状态字段 2. 为每个状态字段补充 `_name` 或 `_text` 文字字段(命名规则:字段名+`_name`) 3. 在 Service 层使用中间件或钩子函数,自动赋值这些文字字段 示例: ```go // internal/model/dto/account_dto.go(修改) type AccountResponse struct { ID uint `json:"id"` Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` StatusName string `json:"status_name" description:"状态名称"` // 新增 // ... } // internal/service/account/service.go(修改) func (s *Service) GetAccount(ctx context.Context, id uint) (*dto.AccountResponse, error) { account, err := s.store.Get(ctx, id) resp := s.toAccountResponse(account) resp.StatusName = constants.GetAccountStatusName(resp.Status) // 赋值 return resp, nil } ``` **理由**: - 规范要求:项目 AGENTS.md 明确规定 "Response DTO 的 int 状态字段必须有 `_name` 字段" - 无需前端维护枚举映射表,直接使用后端返回的中文文本显示 - 规范 100% 贯彻,新接口强制遵守,存量接口逐步补全 **替代方案考虑**: - ❌ 统一使用 string 类型状态字段:破坏现有 API 契约,多个客户端需升级 - ❌ 前端维护枚举映射表:维护成本高,易不同步 --- ### 决策 7:未使用常量清理 **选择**: 1. 删除 `pkg/errors/codes.go` 中的 `CodeExceedLimit` 错误码及其消息映射 2. 为 `pkg/constants/iot.go` 中 ~22 个未引用的预留常量(Replacement/Merchant/Approval 系列)添加注释说明预留用途,暂不删除 **理由**: - `CodeExceedLimit` 完全未使用,删除无损失 - iot.go 中的常量可能是为未来功能预留(如设备更换流程、商家管理等),贸然删除可能影响后续规划 - 保留预留常量并标注其用途,便于未来实现相应功能时快速找到 **替代方案考虑**: - ❌ 全部删除:若未来需要这些常量,重新定义会遇到 Git 历史问题 - ❌ 全部保留不标注:占用代码空间,不清楚预留目的 --- ## Risks / Trade-offs ### 风险 1:支付配置动态加载的缓存策略 **风险**:支付配置改变后,Redis 缓存不会立即更新,旧订单可能使用过期配置验签 **缓解**: - 缓存 TTL 设置为 1 小时(`JUNHONG_PAYMENT_CONFIG_CACHE_TTL=3600`) - 支付配置变更时主动清除 Redis 缓存(`DELETE payment:config:{configID}`) - 监控日志记录每次加载的配置 ID 和来源(缓存/DB) --- ### 风险 2:废弃代码全量删除的合并冲突 **风险**:若有并行开发的分支引用被删除的废弃 DTO 或方法,合并时会产生编译错误 **缓解**: - 清理前通知团队成员,检查是否有进行中的 Feature 分支 - 先提交清理 PR,所有进行中的 Feature 分支在合并前需 rebase main - 提前进行代码搜索确认零引用,避免删除有隐式依赖的代码 --- ### 风险 3:Model 废弃字段删除的数据丢失 **风险**:若有代码仍在写入这些字段(虽已确认无引用),删除数据库列后无法回滚 **缓解**: - 迁移前导出这些列的数据备份(`SELECT id, first_commission_paid, accumulated_recharge FROM tb_iot_card`) - 迁移脚本提供回滚版本(.down.sql) - 在测试环境验证迁移逻辑和回滚步骤 --- ### 权衡:API 文档生成器的手动注册 vs 自动扫描 **当前选择**:手动注册(在 docs.go 中列出所有 Handler) **权衡**: - ✅ 显式、可控、符合 Go 惯用模式 - ✅ 可灵活控制哪些 Handler 出现在文档中(如隐藏内部接口) - ❌ 新增 Handler 时需同步更新,易遗漏 **为什么不自动扫描**: - Go 反射在编译期无法获知(需运行时),不符合项目"显式优于隐式"原则 - 增加框架复杂性,维护成本高 --- ### 决策 8:数据库迁移文件合并策略 **选择**:将 000000~000113 归档,生成新的生产基线迁移(000114、000115),编号接续现有序列 **执行步骤**: ``` Step 1: pg_dump --schema-only 生成当前 schema → migrations/000114_squash_baseline.up.sql(建表 DDL) → migrations/000114_squash_baseline.down.sql(DROP 所有表) Step 2: 整合数据初始化 → migrations/000115_init_data.up.sql 内容:轮询系统初始配置(原 000104 内容)+ 历史订单 purchase_role 回填(原 backfill 脚本,已幂等) → migrations/000115_init_data.down.sql 内容:DELETE 插入的轮询配置行(purchase_role 回填不需要 rollback) Step 3: Model 废弃字段删除迁移 → migrations/000116_remove_legacy_commission_fields.up.sql DROP COLUMN first_commission_paid, accumulated_recharge(tb_iot_card, tb_device) → migrations/000116_remove_legacy_commission_fields.down.sql Step 4: 归档旧迁移 → 将 000000~000113 全部移入 migrations/archive/ Step 5: 提供重置脚本 → scripts/reset_db.sh(DROP DATABASE → CREATE DATABASE → migrate up) ``` **生产部署流程**(首次): ```bash migrate -path migrations -database "$DB_DSN" up # 执行 000114 → 000115 → 000116,完成建库 + 数据初始化 ``` **测试环境重置**: ```bash ./scripts/reset_db.sh # 清空后重建,等价于全新生产部署 ``` **理由**: - 无生产历史数据,squash 没有历史包袱 - 000114 + 000115 完全等价于顺序执行 000000~000113 的最终结果 - 消除 114 步执行链的失败风险,部署过程更可控 - `backfill_order_purchase_role.sql` 已确认执行过(测试环境 5 条记录有 purchase_role),合并进 000115 后幂等安全 **替代方案考虑**: - ❌ 保留全部 114 个迁移:生产首次部署需顺序执行 114 步,中途失败难以排查 - ❌ 从编号 000001 重新开始:测试环境会产生混淆(历史 PR 记录编号冲突) --- ## Migration Plan ### 第一阶段:准备(半天) 1. 通知团队成员,清理方案确认 2. 备份测试环境数据库(快照或 pg_dump) 3. 确认所有功能分支代码已 merge 或暂存 ### 第二阶段:实现(4-5 天) 1. **第 1 天**:迁移文件合并(000114 + 000115 + 000116)+ 测试环境重置验证 2. **第 2 天**:API 文档补全 + 支付配置动态加载 3. **第 3 天**:轮询状态常量提取 + 废弃代码清理(别名、DTO、方法、空文件) 4. **第 4 天**:Model 废弃字段清理 + DTO `_name` 字段批量补全 5. **第 5 天**:未使用常量清理 + 全量编译测试 + 代码审查 ### 第三阶段:上线准备(1 天) 1. 在纯净环境验证 `migrate up` 从 0 跑到底(000114 → 000115 → 000116) 2. 验证所有接口正常(特别是支付、轮询、账号管理) 3. 部署到生产环境 ### 回滚策略 - **代码**:git revert(无 API 破坏性变更) - **数据库 000114**:执行 `.down.sql` 删除所有表(仅在新环境适用,旧数据无法恢复) - **数据库 000116**:执行 `.down.sql` 恢复废弃列(列结构恢复,历史数据值已备份) --- ## Open Questions 1. **支付配置 Redis 缓存 TTL**:现拟设置为 1 小时,是否有其他考虑? 2. **DTO `_name` 字段赋值时机**:是否需要在 Service 层统一处理,还是允许各模块自行处理? 3. **轮询日志状态是否需要 API 端点查询**:当前只在日志中记录,是否需要开放查询接口? 4. **实名认证检查的业务意图**(`client_order/service.go:141`):注释掉的原因是什么,是暂时关闭还是永久删除?