---
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 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(&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-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(&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 个独立 commit（PERM、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-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`
  </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 个独立 commit（POLL-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 个 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 检查
</success_criteria>

<output>
完成后创建：`.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md`
</output>
