--- 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`