diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 60df2ad..a0d6a6c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -53,7 +53,11 @@ Plans: 4. 数据库中 tb_package 表不再有 enable_realname_activation 字段,改为 expiry_base 字段(值为 from_activation 或 from_purchase) 5. C 端购买普通号卡套餐时,实名检查按卡类型(card_category == "normal")判断,逻辑简洁正确 6. polling:protect 任务正确进入调度器,停复机时若 gatewayClient 为 nil 返回业务错误而非静默成功 -**Plans**: TBD +**Plans**: 2 plans + +Plans: +- [ ] 02-01-PLAN.md — PERM-01/02 + FIN-01/02/03 + POLL-01/02/03/04:权限/财务/轮询小修合并(Wave 1) +- [ ] 02-02-PLAN.md — REALNAME-01~05:实名激活架构重构,原子执行(Wave 1,parallel) --- @@ -119,7 +123,7 @@ Plans: | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. P0 紧急修复 | 5/5 | Complete | 2026-03-27 | -| 2. 权限/财务/实名/轮询修复 | 0/1 | Not started | - | +| 2. 权限/财务/实名/轮询修复 | 0/2 | Not started | - | | 3. 设备体系完善 | 0/1 | Not started | - | | 4. 退款 + 支付 + 运营修复 | 0/1 | Not started | - | | 5. 代码质量清理 | 0/1 | Not started | - | diff --git a/.planning/phases/02-perm-fin-realname-poll/02-01-PLAN.md b/.planning/phases/02-perm-fin-realname-poll/02-01-PLAN.md new file mode 100644 index 0000000..ae39780 --- /dev/null +++ b/.planning/phases/02-perm-fin-realname-poll/02-01-PLAN.md @@ -0,0 +1,434 @@ +--- +phase: 02-perm-fin-realname-poll +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - internal/handler/admin/asset.go + - internal/handler/admin/commission_withdrawal.go + - internal/service/commission_withdrawal/service.go + - internal/service/commission_withdrawal_setting/service.go + - internal/store/postgres/wechat_config_store.go + - internal/polling/scheduler.go + - internal/task/polling_handler.go + - internal/service/iot_card/stop_resume_service.go + - internal/service/asset/service.go + - internal/store/postgres/package_store.go + - internal/service/package_series/service.go +autonomous: true +requirements: + - PERM-01 + - PERM-02 + - FIN-01 + - FIN-02 + - FIN-03 + - POLL-01 + - POLL-02 + - POLL-03 + - POLL-04 +must_haves: + truths: + - "企业账号可调用 Resolve 接口查询被授权资产,不再被 403 拦截" + - "企业账号调用 Refresh 接口时返回 403 业务错误" + - "提现审批通过后,approved_by 和 approved_at 字段正确填充" + - "拒绝提现时 remark 为空返回参数校验错误,申请状态不变" + - "同时激活两个提现配置时,行锁生效,任何时刻最多一条 is_active=true" + - "polling:protect 任务进入调度器,日志可见 protect 队列被处理" + - "stopCardWithRetry/resumeCardWithRetry 在 gatewayClient==nil 时返回业务错误而非 nil" + - "GetPackages 接口响应中包含 page/page_size/total 分页字段,默认 page=1 pageSize=50" + - "删除有关联套餐的 PackageSeries 时返回错误提示而非成功" + artifacts: + - path: "internal/handler/admin/asset.go" + provides: "PERM-01 删除 Resolve 企业拦截;PERM-02 新增 Refresh 企业拦截" + - path: "internal/service/commission_withdrawal/service.go" + provides: "FIN-01 Approve 补 approved_by/approved_at" + - path: "internal/handler/admin/commission_withdrawal.go" + provides: "FIN-02 Reject 补 validator.Validate" + - path: "internal/service/commission_withdrawal_setting/service.go" + provides: "FIN-03 激活配置前加 FOR UPDATE 行锁" + - path: "internal/store/postgres/wechat_config_store.go" + provides: "FIN-03 微信配置激活前加 FOR UPDATE 行锁" + - path: "internal/polling/scheduler.go" + provides: "POLL-01 补 polling:protect 调度" + - path: "internal/service/iot_card/stop_resume_service.go" + provides: "POLL-02 gatewayClient==nil 返回业务错误" + - path: "internal/service/asset/service.go" + provides: "POLL-03 GetPackages 支持分页" + - path: "internal/service/package_series/service.go" + provides: "POLL-04 Delete 前检查关联套餐" + - path: "internal/store/postgres/package_store.go" + provides: "POLL-04 新增 CountBySeriesID 方法" + key_links: + - from: "internal/handler/admin/asset.go:Resolve" + to: "constants.UserTypeEnterprise" + via: "middleware.GetUserTypeFromContext" + pattern: "UserTypeEnterprise" + - from: "internal/service/commission_withdrawal/service.go:Approve" + to: "tb_commission_withdrawal_request.approved_by/approved_at" + via: "updates map" + pattern: "approved_by.*approved_at" + - from: "internal/polling/scheduler.go" + to: "constants.TaskTypePollingProtect" + via: "processManualQueue+processTimedQueue" + pattern: "TaskTypePollingProtect" +--- + + +修复 PERM(企业权限)+ FIN(财务流程)+ POLL(轮询小修)三个域共 9 个需求。 + +Purpose: 补全企业账号权限边界(可查不可刷新)、提现流程数据完整性(审批人/remark 必填/激活并发锁)、轮询调度和安全修复,全部为独立小修,无跨域依赖。 +Output: 9 个文件修改,每个独立 Bug 一个 commit。 + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02-perm-fin-realname-poll/02-CONTEXT.md + +# 修复规格(核心参考) +@.sisyphus/plans/修正业务-完整方案.md(方案 B 766-863 行,方案 C 864-944 行,方案 H 1612-1711 行) + + + + +**asset.go 当前 Resolve 企业拦截(第 40-44 行,PERM-01 要删除)**: +```go +func (h *AssetHandler) Resolve(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType == constants.UserTypeEnterprise { + return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口") + } + // ... +``` + +**asset.go Refresh(第 78 行附近,PERM-02 在此处新增企业拦截)**: +```go +func (h *AssetHandler) Refresh(c *fiber.Ctx) error { + assetType := c.Params("asset_type") + // 需在此处开头插入企业拦截 +``` + +**commission_withdrawal/service.go:Approve updates map(FIN-01)**: +```go +updates := map[string]interface{}{ + "status": constants.WithdrawalStatusApproved, + "processor_id": currentUserID, + "processed_at": now, + "payment_type": req.PaymentType, + "remark": req.Remark, + // 需补充: + // "approved_by": currentUserID, + // "approved_at": now, +``` + +**commission_withdrawal.go:Reject(FIN-02)**: +```go +// 当前仅有 BodyParser,缺少 Validate: +if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) +} +// 需补充 validator.Validate(&req) +``` + +**scheduler.go 当前调度(第 243-250 行,POLL-01 新增 protect)**: +```go +s.processManualQueue(ctx, constants.TaskTypePollingRealname) +s.processManualQueue(ctx, constants.TaskTypePollingCarddata) +s.processManualQueue(ctx, constants.TaskTypePollingPackage) + +s.processTimedQueue(ctx, constants.RedisPollingQueueRealnameKey(), constants.TaskTypePollingRealname, now) +s.processTimedQueue(ctx, constants.RedisPollingQueueCarddataKey(), constants.TaskTypePollingCarddata, now) +s.processTimedQueue(ctx, constants.RedisPollingQueuePackageKey(), constants.TaskTypePollingPackage, now) +// 需新增 TaskTypePollingProtect 两行 +``` + +**stop_resume_service.go:stopCardWithRetry(第 178 行,POLL-02)**: +```go +if s.gatewayClient == nil { + s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口", zap.Uint("card_id", card.ID)) + return nil // ← 改为返回业务错误 +} +``` + +**asset/service.go:GetPackages 当前签名(POLL-03 需改)**: +```go +func (s *Service) GetPackages(ctx context.Context, assetType string, id uint) ([]*dto.AssetPackageResponse, error) +// 需新增 page, pageSize 参数,并在响应中返回分页信息 +``` + +**package_series/service.go:Delete(POLL-04 需在首行前添加检查)**: +```go +func (s *Service) Delete(ctx context.Context, id uint) error { + // 需新增 CountBySeriesID 检查 + return s.packageSeriesStore.Delete(ctx, id) +} +``` + +**FIN-03 GORM v2 行锁写法(参考项目 clause 用法)**: +```go +import "gorm.io/gorm/clause" + +tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("is_active = ?", true). + First(¤t) +``` + + + + + + + Task 1: PERM + FIN 修复(权限/财务流程) + + internal/handler/admin/asset.go, + internal/service/commission_withdrawal/service.go, + internal/handler/admin/commission_withdrawal.go, + internal/service/commission_withdrawal_setting/service.go, + internal/store/postgres/wechat_config_store.go + + +**PERM-01(per D-04)**:`internal/handler/admin/asset.go:Resolve` + +删除第 40-44 行的企业拦截代码块(共 3 行): +```go +// 删除以下代码: +userType := middleware.GetUserTypeFromContext(c.UserContext()) +if userType == constants.UserTypeEnterprise { + return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口") +} +``` +注意:若 `userType` 变量在删除后此函数不再使用,同步删除导入的 `constants` 包引用(如其他方法仍在用则保留)。 + +**PERM-02(per D-05)**:`internal/handler/admin/asset.go:Refresh` + +在 `Refresh` 方法开头(`assetType := c.Params("asset_type")` 之前)插入企业拦截: +```go +// 企业账号只读,不允许主动触发运营商刷新 +userType := middleware.GetUserTypeFromContext(c.UserContext()) +if userType == constants.UserTypeEnterprise { + return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态") +} +``` + +完成后 commit:`fix(perm): 修复企业账号资产权限边界 PERM-01/02` + +**FIN-01(per D-07)**:`internal/service/commission_withdrawal/service.go:Approve` + +在 `updates` map 中补充审批人字段(紧跟 `"processed_at": now` 之后): +```go +"approved_by": currentUserID, +"approved_at": now, +``` + +完成后 commit:`fix(fin): 补全提现审批人字段 FIN-01` + +**FIN-02(per D-08)**:`internal/handler/admin/commission_withdrawal.go:Reject` + +在 `BodyParser` 后补充参数验证: +```go +if err := validator.Validate(&req); err != nil { + return errors.New(errors.CodeInvalidParam) +} +``` +`validator` 包引用参考同文件或同目录其他 Handler 的 import 写法。 + +完成后 commit:`fix(fin): 提现拒绝补 remark 必填校验 FIN-02` + +**FIN-03(per D-09)**:`internal/service/commission_withdrawal_setting/service.go` + +找到激活配置的事务逻辑(先全部置 false,再置目标为 true),在事务内第一步 UPDATE 前加行锁: +```go +// 先锁定当前活跃记录,防止并发激活 +var current model.CommissionWithdrawalSetting +tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("is_active = ?", true). + First(¤t) // 不管 err,只是获取锁 + +// 继续原有的 deactivate → activate 逻辑 +``` +同理修改 `internal/store/postgres/wechat_config_store.go` 微信配置激活逻辑(同一模式)。 + +需要导入:`"gorm.io/gorm/clause"`(检查项目是否已有此导入,有则直接用)。 + +完成后 commit:`fix(fin): 激活配置并发行锁保护 FIN-03` + + + go build ./... 2>&1 | head -30 + + + - Resolve 方法中企业拦截代码块已删除,Refresh 方法开头已新增企业拦截 + - commission_withdrawal/service.go Approve updates map 包含 approved_by/approved_at + - commission_withdrawal.go Reject 有 validator.Validate 调用 + - commission_withdrawal_setting/service.go 和 wechat_config_store.go 激活事务内有 clause.Locking + - go build ./... 无编译错误 + - 已产出 3 个独立 commit(PERM、FIN-01/02、FIN-03) + + + + + Task 2: POLL 修复(轮询调度/安全/分页/系列删除保护) + + internal/polling/scheduler.go, + internal/service/iot_card/stop_resume_service.go, + internal/service/asset/service.go, + internal/store/postgres/package_store.go, + internal/service/package_series/service.go + + +**POLL-01(per D-14)**:`internal/polling/scheduler.go` + +在主调度循环第 243-250 行,紧跟现有 3 个 polling 类型之后新增 protect 调度: +```go +// 新增:保护期一致性检查调度 +s.processManualQueue(ctx, constants.TaskTypePollingProtect) +s.processTimedQueue(ctx, constants.RedisPollingQueueProtectKey(), constants.TaskTypePollingProtect, now) +``` +确认 `constants.RedisPollingQueueProtectKey()` 已在 `pkg/constants/redis.go:374` 定义(已存在)。 +如有 `initCardPolling()` 或类似初始化 protect 队列的函数不存在,参考 realname/carddata/package 的初始化方式补充。 + +完成后 commit:`fix(poll): polling:protect 接入调度器 POLL-01` + +**POLL-02(per D-15)**:`internal/service/iot_card/stop_resume_service.go` + +**两处都修改**:`stopCardWithRetry`(第 178 行)和 `resumeCardWithRetry`(第 211 行) + +将 `return nil` 改为返回业务错误: +```go +// 修改前: +if s.gatewayClient == nil { + s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口", ...) + return nil +} + +// 修改后: +if s.gatewayClient == nil { + return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用") +} +``` +删除原有 `s.logger.Warn` 日志行(不再是"跳过",而是直接拒绝)。 + +完成后 commit:`fix(poll): gatewayClient=nil 时停复机返回错误而非成功 POLL-02` + +**POLL-03(per D-16)**:`internal/service/asset/service.go:GetPackages` + +当前签名为 `GetPackages(ctx, assetType, id)` 返回 `([]*dto.AssetPackageResponse, error)`。 + +改写为支持分页: +1. 函数签名改为 `GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int) (*dto.AssetPackagesResult, error)` +2. 在函数开头处理参数边界:page 默认 1,pageSize 默认 50,pageSize 最大 100 +3. 新建 `dto.AssetPackagesResult` 结构体(在 `internal/model/dto/asset_dto.go`): + ```go + // AssetPackagesResult 套餐列表分页结果 + type AssetPackagesResult struct { + Total int64 `json:"total" description:"总条数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页条数"` + Items []*AssetPackageResponse `json:"items" description:"套餐列表"` + } + ``` +4. 在 DB 查询时添加 `.Count(&total).Offset((page-1)*pageSize).Limit(pageSize)` +5. 更新 Handler 层 `asset.go:Packages` 从 Query 参数读取 page/pageSize(若 Query 参数为空使用默认值),Handler 调用改为传入分页参数 + +检查 `handler/app/client_asset.go` 中是否也调用了 `GetPackages`,若有则同步更新调用签名,传入默认分页参数(page=1, pageSize=50)。 + +完成后 commit:`fix(poll): GetPackages 接口新增分页 POLL-03` + +**POLL-04(per D-17)**:`internal/service/package_series/service.go:Delete` + `internal/store/postgres/package_store.go` + +第一步:在 `package_store.go` 中新增 `CountBySeriesID` 方法(参考同文件其他 Count 方法的写法): +```go +// CountBySeriesID 统计指定系列下的套餐数量(含软删除前的活跃记录) +func (s *PackageStore) CountBySeriesID(ctx context.Context, seriesID uint) (int64, error) { + var count int64 + err := s.db.WithContext(ctx).Model(&model.Package{}). + Where("series_id = ? AND deleted_at IS NULL", seriesID). + Count(&count).Error + if err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计关联套餐失败") + } + return count, nil +} +``` + +第二步:在 `package_series/service.go:Delete` 中注入并调用 `packageStore.CountBySeriesID`: +```go +func (s *Service) Delete(ctx context.Context, id uint) error { + // 前置检查:有关联套餐则拒绝删除 + count, err := s.packageStore.CountBySeriesID(ctx, id) + if err != nil { + return err + } + if count > 0 { + return errors.New(errors.CodeInvalidParam, fmt.Sprintf("该系列下有 %d 个关联套餐,请先处理后再删除", count)) + } + return s.packageSeriesStore.Delete(ctx, id) +} +``` + +需确认 `Service` 结构体中有 `packageStore` 字段(若无则注入)。 + +完成后 commit:`fix(poll): Series 删除前检查关联套餐 POLL-04` + + + go build ./... 2>&1 | head -30 + + + - scheduler.go 在第 245-250 行区域新增了 protect 的 processManualQueue 和 processTimedQueue 调用 + - stop_resume_service.go 两处 gatewayClient==nil 均返回 errors.New 而非 nil + - GetPackages 函数签名包含 page/pageSize 参数,响应结构体含 total/page/page_size/items + - package_store.go 新增 CountBySeriesID 方法;package_series/service.go:Delete 首行检查关联套餐 + - go build ./... 无编译错误 + - 已产出 4 个独立 commit(POLL-01/02/03/04) + + + + + + +所有 PERM + FIN + POLL 修复完成后执行最终验收: + +```bash +# 编译验证 +go build ./... + +# 搜索验证:PERM +grep -n "UserTypeEnterprise" internal/handler/admin/asset.go +# 预期:Resolve 中无企业拦截;Refresh 中有企业拦截 + +# 搜索验证:FIN-01 +grep -n "approved_by\|approved_at" internal/service/commission_withdrawal/service.go +# 预期:updates map 中包含两个字段 + +# 搜索验证:POLL-01 +grep -n "TaskTypePollingProtect\|RedisPollingQueueProtectKey" internal/polling/scheduler.go +# 预期:出现两行新增调用 + +# 搜索验证:POLL-02 +grep -n "return nil" internal/service/iot_card/stop_resume_service.go +# 预期:stopCardWithRetry 和 resumeCardWithRetry 中 gatewayClient==nil 分支不再有 return nil +``` + + + +- 所有 9 个需求(PERM-01/02, FIN-01/02/03, POLL-01/02/03/04)均有对应代码变更 +- go build ./... 零错误 +- 每个独立 Bug 均有独立 commit(共 7 个 commit:PERM, FIN-01, FIN-02, FIN-03, POLL-01, POLL-02, POLL-03, POLL-04 → 实际按修复内容分组为 7 次提交) +- Resolve 方法企业拦截已删除;Refresh 方法企业拦截已添加 +- commission_withdrawal:Approve 包含 approved_by/approved_at;Reject 包含 validator.Validate +- 激活配置事务中包含 clause.Locking{Strength: "UPDATE"} +- scheduler.go 包含 protect 的 processManualQueue+processTimedQueue 调用 +- 停复机函数 gatewayClient==nil 返回错误 +- GetPackages 签名含分页参数,响应含分页字段 +- 系列删除前有 CountBySeriesID 检查 + + + +完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-perm-fin-realname-poll/02-02-PLAN.md b/.planning/phases/02-perm-fin-realname-poll/02-02-PLAN.md new file mode 100644 index 0000000..25d345b --- /dev/null +++ b/.planning/phases/02-perm-fin-realname-poll/02-02-PLAN.md @@ -0,0 +1,444 @@ +--- +phase: 02-perm-fin-realname-poll +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - migrations/000089_realname_expiry_base.up.sql + - migrations/000089_realname_expiry_base.down.sql + - internal/model/package.go + - internal/model/dto/package_dto.go + - internal/store/postgres/package_store.go + - internal/service/order/service.go + - internal/service/client_order/service.go + - internal/service/package/activation_service.go +autonomous: true +requirements: + - REALNAME-01 + - REALNAME-02 + - REALNAME-03 + - REALNAME-04 + - REALNAME-05 +must_haves: + truths: + - "数据库 tb_package 表不再有 enable_realname_activation 字段,已有 expiry_base 字段默认 from_activation" + - "行业卡(card_category=industry)套餐永远直接激活,无视 expiry_base" + - "C 端购买普通卡套餐前检查实名状态(card_category=normal),删除 packagesNeedRealname 函数" + - "后台囤货 expiry_base=from_purchase 时直接激活,from_activation 时等实名" + - "ActivateByRealname 按 ExpiryBase 选择计时基准(from_purchase 用 CreatedAt,from_activation 用当前时刻)" + - "套餐管理 API 的 DTO 不再有 enable_realname_activation,改为 expiry_base 枚举字段" + - "全仓库搜索 EnableRealnameActivation 零引用" + artifacts: + - path: "migrations/000089_realname_expiry_base.up.sql" + provides: "REALNAME-01 DB 迁移:DROP enable_realname_activation + ADD expiry_base" + - path: "migrations/000089_realname_expiry_base.down.sql" + provides: "REALNAME-01 回滚迁移" + - path: "internal/model/package.go" + provides: "REALNAME-01 Model 同步:删旧字段,加 ExpiryBase" + - path: "internal/model/dto/package_dto.go" + provides: "REALNAME-05 DTO 更新:移除 EnableRealnameActivation,新增 ExpiryBase" + - path: "internal/store/postgres/package_store.go" + provides: "REALNAME-01 删除两步写入特殊处理" + - path: "internal/service/order/service.go" + provides: "REALNAME-02 activateMainPackage 三维决策重写" + - path: "internal/service/client_order/service.go" + provides: "REALNAME-03 实名检查改按卡类型,删 packagesNeedRealname" + - path: "internal/service/package/activation_service.go" + provides: "REALNAME-04 ActivateByRealname 按 ExpiryBase 选择激活基准" + key_links: + - from: "internal/service/order/service.go:activateMainPackage" + to: "model.Package.ExpiryBase" + via: "pkg.ExpiryBase == from_activation" + pattern: "ExpiryBase.*from_activation" + - from: "internal/service/client_order/service.go" + to: "assetInfo.CardCategory" + via: "card_category == normal" + pattern: "CardCategory.*normal" + - from: "internal/service/package/activation_service.go:ActivateByRealname" + to: "packageUsage.CreatedAt" + via: "pkg.ExpiryBase == from_purchase" + pattern: "ExpiryBase.*from_purchase.*CreatedAt" +--- + + +原子性重构实名激活架构(REALNAME-01~05),将 enable_realname_activation 布尔字段替换为 expiry_base 枚举,重构激活决策为三维判断(卡类型+购买路径+expiry_base),修复 C 端实名检查按卡类型判断。 + +Purpose: 当前 enable_realname_activation 字段混淆了"是否等实名"和"计时起点"两个独立语义,架构重构后逻辑清晰分离,生产套餐全部默认 from_activation(运营后续按需手动调整)。 +Output: DB 迁移 + Model + Store + Service + DTO 全链路变更,原子完成,不可拆分。 + + + +@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md +@$HOME/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02-perm-fin-realname-poll/02-CONTEXT.md + +# 修复规格(核心参考) +@.sisyphus/plans/修正业务-完整方案.md(方案 G 第 1492-1611 行) + + + + +**当前 Package Model(内含 EnableRealnameActivation,需替换)**: +```go +// internal/model/package.go +EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;default:true;comment:是否启用实名激活"` +// 改为: +ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算"` +``` + +**当前 package_store.go 两步写入(REALNAME-01 后需删除整个特殊处理块)**: +```go +// internal/store/postgres/package_store.go:23-28 +// GORM 对零值字段有特殊处理,先创建然后立即更新 enable_realname_activation 字段确保正确设置 +if err := s.db.WithContext(ctx).Omit("enable_realname_activation").Create(pkg).Error; err != nil { + return err +} +// 明确更新 enable_realname_activation 字段(包括零值 false) +return s.db.WithContext(ctx).Model(pkg).Update("enable_realname_activation", pkg.EnableRealnameActivation).Error +``` +删除后改为直接 `s.db.WithContext(ctx).Create(pkg).Error`(ExpiryBase 是 string,无零值布尔问题)。 + +**当前 package_dto.go EnableRealnameActivation 三处(REALNAME-05)**: +```go +// 第 18 行(CreatePackageRequest) +EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活"` +// 第 35 行(UpdatePackageRequest) +EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活"` +// 第 98 行(PackageResponse) +EnableRealnameActivation bool `json:"enable_realname_activation" description:"是否启用实名激活"` +// 全部改为 ExpiryBase: +ExpiryBase string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"` +``` + +**当前 activateMainPackage 激活决策(第 1873 行,REALNAME-02)**: +```go +// 任务 8.9: 后台囤货场景 +if pkg.EnableRealnameActivation { + // 需要实名后才能激活 + status = constants.PackageUsageStatusPending + pendingRealnameActivation = true +} +``` +改写为三维决策(见 action 中详细说明)。 + +**当前 client_order/service.go 实名检查(第 115 行,REALNAME-03)**: +```go +if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified { + return nil, errors.New(errors.CodeNeedRealname) +} +``` +改为: +```go +if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified { + return nil, errors.New(errors.CodeNeedRealname) +} +``` +然后删除 `packagesNeedRealname` 函数(第 619-626 行)。 + +**当前 ActivateByRealname 激活时间固定用 now(第 113 行附近,REALNAME-04)**: +```go +activatedAt := now +expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) +``` +改为: +```go +var activatedAt time.Time +if pkg.ExpiryBase == "from_purchase" { + activatedAt = usage.CreatedAt // 用记录创建时间(购买时刻) +} else { + activatedAt = now // 实名被触发的当前时刻 +} +``` + +**迁移命名参考(现有最新为 000088)**: +新建 `migrations/000089_realname_expiry_base.up.sql` 和 `000089_realname_expiry_base.down.sql` + +**card_category 访问路径(已确认)**: +- `assetInfo.CardCategory` 来自 `dto.AssetResolveResponse.CardCategory`(第 49 行),值由 `asset/service.go:175` 填充 +- `activateMainPackage` 中通过 order 可以获取 `carrierType`/`carrierID`,card_category 需要从 `IotCard` 查询 +- 建议在 `activateMainPackage` 函数中通过 `tx.First(&card, carrierID)` 获取卡信息,再读 `card.CardCategory` + +**BuyerTypePersonal 常量**: +`model.BuyerTypePersonal` 已存在(参考 service.go 第 450 行 `orderBuyerType = model.BuyerTypePersonal`) + + + + + + + Task 1: DB 迁移 + Model + Store 清理(REALNAME-01) + + migrations/000089_realname_expiry_base.up.sql, + migrations/000089_realname_expiry_base.down.sql, + internal/model/package.go, + internal/store/postgres/package_store.go + + +**步骤 1:创建迁移文件(per D-10)** + +`migrations/000089_realname_expiry_base.up.sql`: +```sql +-- REALNAME-01: 实名激活架构重构 +-- 移除旧的布尔字段 enable_realname_activation, +-- 新增 expiry_base 枚举字段控制套餐计时起点 +-- 注意:不做旧字段到新字段的数据迁移,现有记录全部默认 from_activation + +-- 移除旧字段 +ALTER TABLE tb_package DROP COLUMN IF EXISTS enable_realname_activation; + +-- 新增到期时间基准字段 +ALTER TABLE tb_package + ADD COLUMN IF NOT EXISTS expiry_base VARCHAR(30) NOT NULL DEFAULT 'from_activation'; + +COMMENT ON COLUMN tb_package.expiry_base IS '到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算'; +``` + +`migrations/000089_realname_expiry_base.down.sql`: +```sql +-- 回滚:恢复旧字段,移除新字段 +ALTER TABLE tb_package DROP COLUMN IF EXISTS expiry_base; + +ALTER TABLE tb_package + ADD COLUMN IF NOT EXISTS enable_realname_activation BOOLEAN NOT NULL DEFAULT TRUE; + +COMMENT ON COLUMN tb_package.enable_realname_activation IS '是否启用实名激活 true-需实名后激活 false-立即激活'; +``` + +执行迁移并验证: +```bash +make migrate-up +make migrate-version +``` + +**步骤 2:更新 Model(`internal/model/package.go`)** + +删除 `EnableRealnameActivation` 字段: +```go +EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;..."` +``` + +新增 `ExpiryBase` 字段(放在原来的位置附近): +```go +// ExpiryBase 到期时间基准:from_activation-实名激活时起算,from_purchase-购买时起算 +ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算" json:"expiry_base"` +``` + +**步骤 3:清理 package_store.go 两步写入** + +找到 `internal/store/postgres/package_store.go:23-28` 的特殊处理块: +```go +// GORM 对零值字段有特殊处理,先创建然后立即更新 enable_realname_activation 字段确保正确设置 +if err := s.db.WithContext(ctx).Omit("enable_realname_activation").Create(pkg).Error; err != nil { + return err +} +// 明确更新 enable_realname_activation 字段(包括零值 false) +return s.db.WithContext(ctx).Model(pkg).Update("enable_realname_activation", pkg.EnableRealnameActivation).Error +``` + +替换为直接创建(ExpiryBase 是 string,无零值问题): +```go +return s.db.WithContext(ctx).Create(pkg).Error +``` +同时删除其上方的注释说明(第 22-23 行注释块)。 + +完成后 commit:`fix(realname): DB 迁移 + Model + Store 清理 REALNAME-01` + + + make migrate-version && go build ./... 2>&1 | head -30 + + + - make migrate-version 显示版本 89,dirty=false + - tb_package 不再有 enable_realname_activation,已有 expiry_base 字段 + - package.go Package 结构体有 ExpiryBase 字段,无 EnableRealnameActivation 字段 + - package_store.go Create 函数已是直接一步 Create + - go build ./... 无编译错误(此时 service 层还有旧引用,编译会报错,先记录,Task 2 修复) + + + + + Task 2: Service + DTO 重构(REALNAME-02/03/04/05) + + internal/model/dto/package_dto.go, + internal/service/order/service.go, + internal/service/client_order/service.go, + internal/service/package/activation_service.go + + +**REALNAME-05(per D-10)**:`internal/model/dto/package_dto.go` + +替换三处 `EnableRealnameActivation` 为 `ExpiryBase`: + +1. 第 18 行(CreatePackageRequest): +```go +// 删除:EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活 (true:需实名后激活, false:立即激活)"` +// 新增: +ExpiryBase string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)" example:"from_activation"` +``` + +2. 第 35 行(UpdatePackageRequest):同上写法 + +3. 第 98 行(PackageResponse): +```go +// 删除:EnableRealnameActivation bool `json:"enable_realname_activation" description:"是否启用实名激活 (true:需实名后激活, false:立即激活)"` +// 新增: +ExpiryBase string `json:"expiry_base" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"` +``` + +完成后 commit:`fix(realname): DTO 更新 expiry_base 替换 enable_realname_activation REALNAME-05` + +**REALNAME-02(per D-11)**:`internal/service/order/service.go:activateMainPackage` + +找到第 1873 行 `if pkg.EnableRealnameActivation` 块,替换为三维决策逻辑: + +```go +// REALNAME-02: 按卡类型 + 购买路径 + expiry_base 三维决策 +// 需要在 else 分支(无生效中主套餐,立即激活)之后插入以下逻辑: + +// 获取卡类型(行业卡永远直接激活,不等实名) +var cardCategory string +if carrierType == "iot_card" { + var card model.IotCard + if err := tx.Select("card_category").First(&card, carrierID).Error; err == nil { + cardCategory = card.CardCategory + } +} + +// 判断是否 C 端购买(C 端购买前已做实名检查,直接激活) +isCEndPurchase := order.BuyerType == model.BuyerTypePersonal + +// 三维决策:覆盖 activateMainPackage 已有的 status 赋值 +if cardCategory == "industry" { + // 行业卡:永远直接激活,不等实名 + // status 已在 else 分支设为 Active,保持不变 +} else if isCEndPurchase { + // C 端购买:前置实名检查已完成,直接激活 + // status 已在 else 分支设为 Active,保持不变 +} else { + // 后台囤货:按 expiry_base 决定 + if pkg.ExpiryBase == "from_purchase" { + // 购买即激活,立即开始计时,status 保持 Active(已设置) + } else { + // from_activation(默认):等实名后激活 + status = constants.PackageUsageStatusPending + pendingRealnameActivation = true + // 清空激活时间(排队状态不设置) + activatedAt = time.Time{} + expiresAt = time.Time{} + nextResetAt = nil + } +} +``` + +注意:上述三维决策逻辑用于替换原来第 1873-1876 行的 `if pkg.EnableRealnameActivation` 块。放置位置:在 `hasActiveMain` 分支结束之后、`usage := &model.PackageUsage` 创建之前(即原 8.9 任务注释区域)。 + +完成后 commit:`fix(realname): activateMainPackage 三维决策重构 REALNAME-02` + +**REALNAME-03(per D-12)**:`internal/service/client_order/service.go` + +第 115 行,替换实名检查逻辑: +```go +// 删除: +if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified { + +// 替换为: +// REALNAME-03: 按卡类型判断实名需求,普通卡需要实名,行业卡不需要 +if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified { +``` + +然后删除 `packagesNeedRealname` 函数(第 619-626 行)。 + +检查 `validationResult.Packages` 是否仍被其他逻辑使用;若不再使用,保留 `validationResult` 结构但不必删除(其他字段如 `Packages` 本身可能仍用于后续订单创建)。 + +完成后 commit:`fix(realname): C 端实名检查改为按卡类型判断 REALNAME-03` + +**REALNAME-04(per D-13)**:`internal/service/package/activation_service.go:ActivateByRealname` + +找到循环内 `activatedAt := now` 的赋值(约第 113 行),替换为: +```go +// REALNAME-04: 按 ExpiryBase 选择激活计时基准 +var activatedAt time.Time +if pkg.ExpiryBase == "from_purchase" { + // 购买时起算:用 PackageUsage 记录的创建时间 + activatedAt = usage.CreatedAt +} else { + // 实名激活时起算(from_activation 或默认):用当前时刻 + activatedAt = now +} +``` + +注意:`usage` 变量在循环中已有(为 `*model.PackageUsage`),直接访问 `.CreatedAt` 即可。 + +完成后 commit:`fix(realname): ActivateByRealname 按 ExpiryBase 选择计时基准 REALNAME-04` + +最终检查所有旧引用清零: +```bash +grep -rn "EnableRealnameActivation\|enable_realname_activation\|packagesNeedRealname" \ + internal/ --include="*.go" +# 预期:零结果(DTO、Model、Service 全部清理完成) +``` + + + go build ./... 2>&1 | head -30 + + + - package_dto.go 三处 EnableRealnameActivation 已替换为 ExpiryBase,description 使用中文枚举说明 + - order/service.go:activateMainPackage 已有三维决策逻辑(industry/C端/后台囤货+expiry_base) + - client_order/service.go 实名检查用 CardCategory==normal;packagesNeedRealname 函数已删除 + - activation_service.go:ActivateByRealname 用 ExpiryBase 分支选择激活时间 + - grep 搜索 EnableRealnameActivation/enable_realname_activation/packagesNeedRealname 零结果 + - go build ./... 无编译错误 + - 已产出 4 个独立 commit(REALNAME-05、02、03、04) + + + + + + +REALNAME 重构完成后执行最终验收: + +```bash +# 编译验证 +go build ./... + +# DB 迁移验证 +make migrate-version +# 预期:version=89, dirty=false + +# 旧字段引用清零 +grep -rn "EnableRealnameActivation\|enable_realname_activation\|packagesNeedRealname" \ + internal/ --include="*.go" +# 预期:零结果 + +# 新字段引用确认 +grep -rn "ExpiryBase\|expiry_base" internal/ --include="*.go" | grep -v "_test.go" +# 预期:至少在 package.go, package_dto.go, order/service.go, +# client_order/service.go, activation_service.go 中出现 + +# Model 验证 +grep -n "ExpiryBase" internal/model/package.go +# 预期:有 gorm column:expiry_base 标签的字段定义 +``` + + + +- go build ./... 零错误 +- make migrate-version 显示 version=89, dirty=false +- 全仓库 EnableRealnameActivation/enable_realname_activation/packagesNeedRealname 零引用 +- package.go 有 ExpiryBase 字段(gorm tag: expiry_base, varchar(30), default:from_activation) +- package_store.go Create 函数为直接一步创建(无两步写入特殊处理) +- activateMainPackage 包含三维决策(行业卡/C端/后台囤货+expiry_base) +- client_order 实名检查用 CardCategory=="normal" +- ActivateByRealname 按 ExpiryBase 分支选择 activatedAt 基准 +- 已产出 5 个独立 commit(REALNAME-01、05、02、03、04) + + + +完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-02-SUMMARY.md` +