Files
junhong_cmp_fiber/.planning/phases/02-perm-fin-realname-poll/02-01-PLAN.md

17 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-perm-fin-realname-poll 01 execute 1
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
true
PERM-01
PERM-02
FIN-01
FIN-02
FIN-03
POLL-01
POLL-02
POLL-03
POLL-04
truths artifacts key_links
企业账号可调用 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 时返回错误提示而非成功
path provides
internal/handler/admin/asset.go PERM-01 删除 Resolve 企业拦截PERM-02 新增 Refresh 企业拦截
path provides
internal/service/commission_withdrawal/service.go FIN-01 Approve 补 approved_by/approved_at
path provides
internal/handler/admin/commission_withdrawal.go FIN-02 Reject 补 validator.Validate
path provides
internal/service/commission_withdrawal_setting/service.go FIN-03 激活配置前加 FOR UPDATE 行锁
path provides
internal/store/postgres/wechat_config_store.go FIN-03 微信配置激活前加 FOR UPDATE 行锁
path provides
internal/polling/scheduler.go POLL-01 补 polling:protect 调度
path provides
internal/service/iot_card/stop_resume_service.go POLL-02 gatewayClient==nil 返回业务错误
path provides
internal/service/asset/service.go POLL-03 GetPackages 支持分页
path provides
internal/service/package_series/service.go POLL-04 Delete 前检查关联套餐
path provides
internal/store/postgres/package_store.go POLL-04 新增 CountBySeriesID 方法
from to via pattern
internal/handler/admin/asset.go:Resolve constants.UserTypeEnterprise middleware.GetUserTypeFromContext UserTypeEnterprise
from to via pattern
internal/service/commission_withdrawal/service.go:Approve tb_commission_withdrawal_request.approved_by/approved_at updates map approved_by.*approved_at
from to via pattern
internal/polling/scheduler.go constants.TaskTypePollingProtect processManualQueue+processTimedQueue TaskTypePollingProtect
修复 PERM企业权限+ FIN财务流程+ POLL轮询小修三个域共 9 个需求。

Purpose: 补全企业账号权限边界(可查不可刷新)、提现流程数据完整性(审批人/remark 必填/激活并发锁)、轮询调度和安全修复,全部为独立小修,无跨域依赖。 Output: 9 个文件修改,每个独立 Bug 一个 commit。

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_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 行)

asset.go 当前 Resolve 企业拦截(第 40-44 行PERM-01 要删除)

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 在此处新增企业拦截)

func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
    assetType := c.Params("asset_type")
    // 需在此处开头插入企业拦截

commission_withdrawal/service.go:Approve updates mapFIN-01

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

// 当前仅有 BodyParser缺少 Validate
if err := c.BodyParser(&req); err != nil {
    return errors.New(errors.CodeInvalidParam)
}
// 需补充 validator.Validate(&req)

scheduler.go 当前调度(第 243-250 行POLL-01 新增 protect

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

if s.gatewayClient == nil {
    s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口", zap.Uint("card_id", card.ID))
    return nil  // ← 改为返回业务错误
}

asset/service.go:GetPackages 当前签名POLL-03 需改)

func (s *Service) GetPackages(ctx context.Context, assetType string, id uint) ([]*dto.AssetPackageResponse, error)
// 需新增 page, pageSize 参数,并在响应中返回分页信息

package_series/service.go:DeletePOLL-04 需在首行前添加检查)

func (s *Service) Delete(ctx context.Context, id uint) error {
    // 需新增 CountBySeriesID 检查
    return s.packageSeriesStore.Delete(ctx, id)
}

FIN-03 GORM v2 行锁写法(参考项目 clause 用法)

import "gorm.io/gorm/clause"

tx.Clauses(clause.Locking{Strength: "UPDATE"}).
    Where("is_active = ?", true).
    First(&current)
Task 1: PERM + FIN 修复(权限/财务流程) 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 **PERM-01per D-04**`internal/handler/admin/asset.go:Resolve`

删除第 40-44 行的企业拦截代码块(共 3 行):

// 删除以下代码:
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType == constants.UserTypeEnterprise {
    return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
}

注意:若 userType 变量在删除后此函数不再使用,同步删除导入的 constants 包引用(如其他方法仍在用则保留)。

PERM-02per D-05internal/handler/admin/asset.go:Refresh

Refresh 方法开头(assetType := c.Params("asset_type") 之前)插入企业拦截:

// 企业账号只读,不允许主动触发运营商刷新
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType == constants.UserTypeEnterprise {
    return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态")
}

完成后 commitfix(perm): 修复企业账号资产权限边界 PERM-01/02

FIN-01per D-07internal/service/commission_withdrawal/service.go:Approve

updates map 中补充审批人字段(紧跟 "processed_at": now 之后):

"approved_by": currentUserID,
"approved_at": now,

完成后 commitfix(fin): 补全提现审批人字段 FIN-01

FIN-02per D-08internal/handler/admin/commission_withdrawal.go:Reject

BodyParser 后补充参数验证:

if err := validator.Validate(&req); err != nil {
    return errors.New(errors.CodeInvalidParam)
}

validator 包引用参考同文件或同目录其他 Handler 的 import 写法。

完成后 commitfix(fin): 提现拒绝补 remark 必填校验 FIN-02

FIN-03per D-09internal/service/commission_withdrawal_setting/service.go

找到激活配置的事务逻辑(先全部置 false再置目标为 true在事务内第一步 UPDATE 前加行锁:

// 先锁定当前活跃记录,防止并发激活
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"(检查项目是否已有此导入,有则直接用)。

完成后 commitfix(fin): 激活配置并发行锁保护 FIN-03 go build ./... 2>&1 | head -30 - 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

Task 2: POLL 修复(轮询调度/安全/分页/系列删除保护) 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 **POLL-01per D-14**`internal/polling/scheduler.go`

在主调度循环第 243-250 行,紧跟现有 3 个 polling 类型之后新增 protect 调度:

// 新增:保护期一致性检查调度
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 的初始化方式补充。

完成后 commitfix(poll): polling:protect 接入调度器 POLL-01

POLL-02per D-15internal/service/iot_card/stop_resume_service.go

两处都修改stopCardWithRetry(第 178 行)和 resumeCardWithRetry(第 211 行)

return nil 改为返回业务错误:

// 修改前:
if s.gatewayClient == nil {
    s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口", ...)
    return nil
}

// 修改后:
if s.gatewayClient == nil {
    return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
}

删除原有 s.logger.Warn 日志行(不再是"跳过",而是直接拒绝)。

完成后 commitfix(poll): gatewayClient=nil 时停复机返回错误而非成功 POLL-02

POLL-03per D-16internal/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
    // 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

完成后 commitfix(poll): GetPackages 接口新增分页 POLL-03

POLL-04per D-17internal/service/package_series/service.go:Delete + internal/store/postgres/package_store.go

第一步:在 package_store.go 中新增 CountBySeriesID 方法(参考同文件其他 Count 方法的写法):

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

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 字段(若无则注入)。

完成后 commitfix(poll): Series 删除前检查关联套餐 POLL-04 go build ./... 2>&1 | head -30 - 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

所有 PERM + FIN + POLL 修复完成后执行最终验收:
# 编译验证
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

<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>
完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md`