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