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

435 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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>