docs(02-perm-fin-realname-poll): 创建 Phase 2 执行计划(2 个 Plan)

This commit is contained in:
2026-03-28 10:29:50 +08:00
parent 84aca2fd63
commit 62eb43a1cf
3 changed files with 884 additions and 2 deletions

View File

@@ -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 1parallel
---
@@ -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 | - |

View File

@@ -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"
---
<objective>
修复 PERM企业权限+ FIN财务流程+ POLL轮询小修三个域共 9 个需求。
Purpose: 补全企业账号权限边界(可查不可刷新)、提现流程数据完整性(审批人/remark 必填/激活并发锁)、轮询调度和安全修复,全部为独立小修,无跨域依赖。
Output: 9 个文件修改,每个独立 Bug 一个 commit。
</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方案 B 766-863 行,方案 C 864-944 行,方案 H 1612-1711 行)
<interfaces>
<!-- 本 plan 执行时所需的关键接口,直接使用,无需探索 -->
**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 mapFIN-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:RejectFIN-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:DeletePOLL-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(&current)
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: PERM + FIN 修复(权限/财务流程)</name>
<files>
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
</files>
<action>
**PERM-01per 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-02per 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-01per 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-02per 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-03per 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(&current) // 不管 err只是获取锁
// 继续原有的 deactivate → activate 逻辑
```
同理修改 `internal/store/postgres/wechat_config_store.go` 微信配置激活逻辑(同一模式)。
需要导入:`"gorm.io/gorm/clause"`(检查项目是否已有此导入,有则直接用)。
完成后 commit`fix(fin): 激活配置并发行锁保护 FIN-03`
</action>
<verify>
<automated>go build ./... 2>&1 | head -30</automated>
</verify>
<done>
- 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 个独立 commitPERM、FIN-01/02、FIN-03
</done>
</task>
<task type="auto">
<name>Task 2: POLL 修复(轮询调度/安全/分页/系列删除保护)</name>
<files>
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
</files>
<action>
**POLL-01per 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-02per 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-03per 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 默认 1pageSize 默认 50pageSize 最大 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-04per 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`
</action>
<verify>
<automated>go build ./... 2>&1 | head -30</automated>
</verify>
<done>
- 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 个独立 commitPOLL-01/02/03/04
</done>
</task>
</tasks>
<verification>
所有 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
```
</verification>
<success_criteria>
- 所有 9 个需求PERM-01/02, FIN-01/02/03, POLL-01/02/03/04均有对应代码变更
- go build ./... 零错误
- 每个独立 Bug 均有独立 commit共 7 个 commitPERM, FIN-01, FIN-02, FIN-03, POLL-01, POLL-02, POLL-03, POLL-04 → 实际按修复内容分组为 7 次提交)
- Resolve 方法企业拦截已删除Refresh 方法企业拦截已添加
- commission_withdrawal:Approve 包含 approved_by/approved_atReject 包含 validator.Validate
- 激活配置事务中包含 clause.Locking{Strength: "UPDATE"}
- scheduler.go 包含 protect 的 processManualQueue+processTimedQueue 调用
- 停复机函数 gatewayClient==nil 返回错误
- GetPackages 签名含分页参数,响应含分页字段
- 系列删除前有 CountBySeriesID 检查
</success_criteria>
<output>
完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md`
</output>

View File

@@ -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 用 CreatedAtfrom_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 显示版本 89dirty=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-05per 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-02per 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-03per 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-04per 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 已替换为 ExpiryBasedescription 使用中文枚举说明
- order/service.go:activateMainPackage 已有三维决策逻辑industry/C端/后台囤货+expiry_base
- client_order/service.go 实名检查用 CardCategory==normalpackagesNeedRealname 函数已删除
- activation_service.go:ActivateByRealname 用 ExpiryBase 分支选择激活时间
- grep 搜索 EnableRealnameActivation/enable_realname_activation/packagesNeedRealname 零结果
- go build ./... 无编译错误
- 已产出 4 个独立 commitREALNAME-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 个独立 commitREALNAME-01、05、02、03、04
</success_criteria>
<output>
完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-02-SUMMARY.md`
</output>