All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m13s
341 lines
16 KiB
Markdown
341 lines
16 KiB
Markdown
## 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`):注释掉的原因是什么,是暂时关闭还是永久删除?
|