445 lines
19 KiB
Markdown
445 lines
19 KiB
Markdown
---
|
||
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"
|
||
---
|
||
|
||
<objective>
|
||
原子性重构实名激活架构(REALNAME-01~05),将 enable_realname_activation 布尔字段替换为 expiry_base 枚举,重构激活决策为三维判断(卡类型+购买路径+expiry_base),修复 C 端实名检查按卡类型判断。
|
||
|
||
Purpose: 当前 enable_realname_activation 字段混淆了"是否等实名"和"计时起点"两个独立语义,架构重构后逻辑清晰分离,生产套餐全部默认 from_activation(运营后续按需手动调整)。
|
||
Output: DB 迁移 + Model + Store + Service + DTO 全链路变更,原子完成,不可拆分。
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.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 行)
|
||
|
||
<interfaces>
|
||
<!-- 本 plan 执行时所需的关键接口,直接使用,无需探索 -->
|
||
|
||
**当前 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`)
|
||
</interfaces>
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto">
|
||
<name>Task 1: DB 迁移 + Model + Store 清理(REALNAME-01)</name>
|
||
<files>
|
||
migrations/000089_realname_expiry_base.up.sql,
|
||
migrations/000089_realname_expiry_base.down.sql,
|
||
internal/model/package.go,
|
||
internal/store/postgres/package_store.go
|
||
</files>
|
||
<action>
|
||
**步骤 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`
|
||
</action>
|
||
<verify>
|
||
<automated>make migrate-version && go build ./... 2>&1 | head -30</automated>
|
||
</verify>
|
||
<done>
|
||
- 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 修复)
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>Task 2: Service + DTO 重构(REALNAME-02/03/04/05)</name>
|
||
<files>
|
||
internal/model/dto/package_dto.go,
|
||
internal/service/order/service.go,
|
||
internal/service/client_order/service.go,
|
||
internal/service/package/activation_service.go
|
||
</files>
|
||
<action>
|
||
**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 全部清理完成)
|
||
```
|
||
</action>
|
||
<verify>
|
||
<automated>go build ./... 2>&1 | head -30</automated>
|
||
</verify>
|
||
<done>
|
||
- 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)
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<verification>
|
||
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 标签的字段定义
|
||
```
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
- 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)
|
||
</success_criteria>
|
||
|
||
<output>
|
||
完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-02-SUMMARY.md`
|
||
</output>
|