16 KiB
Context
项目于 2024 年初启动,经过一年多的功能迭代,业务模块逐步完善(支付、轮询、分佣、多租户等),但代码库中积累了分散的废弃代码、硬编码常量、规范缺陷和设计遗留问题。这些问题目前不影响功能,但随着新功能增加,维护成本快速上升:
- API 文档覆盖率仅 18.75%:docs.go/gendocs 只注册了 9 个 Handler,实际路由 48 个,导致 39 个接口无法在 OpenAPI 文档中查阅
- 支付配置硬编码:order/service 和 recharge/service 中 3 处 TODO,直接使用全局
s.wechatPayment,多商户场景下无法正确验签 - 硬编码魔法字符串:轮询系统中 10+ 处状态字符串未提取常量,无法被 IDE 重构工具识别,变更风险高
- 废弃代码散布各处:15 个常量别名、3 个 DTO、2 个方法标注废弃但仍保留,新开发者易误用
- Model 数据冗余:IotCard 和 Device 模型中 4 个字段已被替代但仍保留在数据库,造成数据不一致风险
- DTO 规范缺口:122 个 Response DTO 缺少
_name文字字段,违反项目规范 - 迁移文件堆积,无法支撑生产部署:开发阶段逐步积累 114 个迁移(000000~000113),包含大量中间过渡状态(重命名表、删除旧表、修复字段类型等),问题具体为:
000104_polling_config_data仅有.up.sql,缺少.down.sqlbackfill_order_purchase_role.sql游离在外,不符合 golang-migrate 命名规范,不会被自动执行- 新环境部署需顺序执行 114 个迁移,任何一步失败即卡住
Goals / Non-Goals
Goals:
- 文档完整性:补全 API 文档生成器,达到 100% 覆盖率,确保前端/对接方能通过 OpenAPI 文档查阅所有接口
- 系统可靠性:实现支付配置动态加载,支持多商户场景,消除验签失败隐患
- 代码规范化:清理所有硬编码常量、废弃代码、重复 DTO,统一提取到
pkg/constants/,降低维护成本和新手上手难度 - 数据库一致性:删除废弃 Model 字段和对应数据库列,防止新/旧逻辑产生的数据不一致
- 规范落实:补全 DTO
_name字段和 Service 层赋值逻辑,确保规范 100% 贯彻 - 生产就绪的迁移基线:将 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 动态获取对应的支付配置实例
// 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 中的硬编码字符串
// 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"。本次清理仅提取常量,不修改字段类型,原因:
- 修改字段类型需要数据库迁移,会产生 API 破坏性变更(现有 JSON 字段值改变)
- 当前没有并行业务需求推动这个改动 本次提案将此作为已知技术债务记录,待后续版本统一规划。
决策 4:废弃代码清理
选择:
- 同步模板代码(
internal/task/sync.go):删除整个文件,轮询系统已实现真正的 SIM 卡状态/实名状态/流量同步逻辑 - 废弃常量别名(
pkg/constants/wallet.go):全局搜索并替换 15 个Deprecated别名为新常量,然后删除别名定义 - 废弃 DTO 类型(3 个):确认无引用后删除
DeviceBundle、DeviceBundleCard、AllocatedDevice - 废弃方法(2 个):确认无引用后删除
CreateLegacy()和CheckAndStopCard()
理由:
- 废弃代码继续存在会增加代码认知负担,新开发者易误用
- 同步任务虽然是模板,但从未被入队使用,保留无意义
- 这些清理是一次性工作,越早做越好(后续修改越多,冲突风险越大)
替代方案考虑:
- ❌ 标注但保留:会持续占用代码审查注意力,推迟问题不是解决方案
- ❌ 分批清理:逐个清理会产生多个 PR,合并前后的重构冲突难以管理
决策 5:Model 废弃字段清理
选择:
- 从
iot_card.go和device.go中删除FirstCommissionPaid和AccumulatedRecharge字段定义 - 创建数据库迁移脚本(
000XXX_remove_legacy_commission_fields.up.sql),删除对应列
理由:
- 这两个字段已被
AccumulatedRechargeBySeriesJSON和FirstRechargeTriggeredBySeriesJSON替代,新逻辑不再维护它们 - 保留在数据库中造成数据冗余和不一致风险(新逻辑更新 BySeriesJSON,旧字段不更新)
- GORM 对比迁移后,Model 与数据库结构保持一致
替代方案考虑:
- ❌ 仅删除 Model 定义不删除数据库列:GORM 迁移时会警告字段缺失,容易引发混淆
- ❌ 只删除数据库列不删除 Model 定义:运行时可能触发 scanning 错误
决策 6:DTO _name 字段补全
选择:
- 遍历
internal/model/dto/下所有 DTO 文件,找出所有 int 类型状态字段 - 为每个状态字段补充
_name或_text文字字段(命名规则:字段名+_name) - 在 Service 层使用中间件或钩子函数,自动赋值这些文字字段
示例:
// 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:未使用常量清理
选择:
- 删除
pkg/errors/codes.go中的CodeExceedLimit错误码及其消息映射 - 为
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)
生产部署流程(首次):
migrate -path migrations -database "$DB_DSN" up
# 执行 000114 → 000115 → 000116,完成建库 + 数据初始化
测试环境重置:
./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
第一阶段:准备(半天)
- 通知团队成员,清理方案确认
- 备份测试环境数据库(快照或 pg_dump)
- 确认所有功能分支代码已 merge 或暂存
第二阶段:实现(4-5 天)
- 第 1 天:迁移文件合并(000114 + 000115 + 000116)+ 测试环境重置验证
- 第 2 天:API 文档补全 + 支付配置动态加载
- 第 3 天:轮询状态常量提取 + 废弃代码清理(别名、DTO、方法、空文件)
- 第 4 天:Model 废弃字段清理 + DTO
_name字段批量补全 - 第 5 天:未使用常量清理 + 全量编译测试 + 代码审查
第三阶段:上线准备(1 天)
- 在纯净环境验证
migrate up从 0 跑到底(000114 → 000115 → 000116) - 验证所有接口正常(特别是支付、轮询、账号管理)
- 部署到生产环境
回滚策略
- 代码:git revert(无 API 破坏性变更)
- 数据库 000114:执行
.down.sql删除所有表(仅在新环境适用,旧数据无法恢复) - 数据库 000116:执行
.down.sql恢复废弃列(列结构恢复,历史数据值已备份)
Open Questions
- 支付配置 Redis 缓存 TTL:现拟设置为 1 小时,是否有其他考虑?
- DTO
_name字段赋值时机:是否需要在 Service 层统一处理,还是允许各模块自行处理? - 轮询日志状态是否需要 API 端点查询:当前只在日志中记录,是否需要开放查询接口?
- 实名认证检查的业务意图(
client_order/service.go:141):注释掉的原因是什么,是暂时关闭还是永久删除?