--- 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" --- 修复 PERM(企业权限)+ FIN(财务流程)+ POLL(轮询小修)三个域共 9 个需求。 Purpose: 补全企业账号权限边界(可查不可刷新)、提现流程数据完整性(审批人/remark 必填/激活并发锁)、轮询调度和安全修复,全部为独立小修,无跨域依赖。 Output: 9 个文件修改,每个独立 Bug 一个 commit。 @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md @.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 要删除)**: ```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(¤t) ``` 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-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(¤t) // 不管 err,只是获取锁 // 继续原有的 deactivate → activate 逻辑 ``` 同理修改 `internal/store/postgres/wechat_config_store.go` 微信配置激活逻辑(同一模式)。 需要导入:`"gorm.io/gorm/clause"`(检查项目是否已有此导入,有则直接用)。 完成后 commit:`fix(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 个独立 commit(PERM、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-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` 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 个独立 commit(POLL-01/02/03/04) 所有 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 ``` - 所有 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 检查 完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md`