Files
junhong_cmp_fiber/.sisyphus/plans/修正业务-完整方案.md

86 KiB
Raw Blame History

修正业务 · 完整修复方案 v5完整规格书

写给另一个 AI 看的规格书。本文档包含每一个修复的完整上下文:

  • 业务背景:为什么存在这个功能、为什么会出这个问题
  • 代码现状:问题在哪个文件哪一行,现在的代码是什么
  • 修复方式:精确到文件+行号的改法,含完整代码片段

技术栈Go + Fiber v2 + GORM + Asynq + PostgreSQL + Redis 架构Handler → Service → Store → Model 严格分层 仓库根目录:/Users/break/csxjProject/junhong_cmp_fiber


系统概览

这是一个物联网卡IoT SIM Card+ 设备全生命周期管理平台,支持多级代理商体系和分佣结算。

核心实体

  • IoT 卡:物联网 SIM 卡,绑定运营商,有实名状态、网络状态、套餐使用情况
  • 设备物联网设备GPS、传感器等可绑定 1-4 张 IoT 卡
  • 套餐:用于 IoT 卡的流量套餐,有时长、流量额度、计费模式
  • 代理商Shop:多级分销体系(最多 7 级),有佣金钱包、成本价体系
  • 个人客户PersonalCustomerC 端用户,有资产钱包(跟着卡/设备走,转手后钱包一起转)
  • 企业账号B 端大客户,被分配卡/设备,可管控但不能购买套餐

关键路径

代理/平台 → 采购 IoT 卡 + 设备 → 分配给下级代理/企业 → C端客户购买套餐 → 充值/激活/使用
                                                               ↓
                                          佣金链:逐级向上分差额(成本价之差)

全文统一验收约定(适用于所有方案)

本文档不要求新增自动化测试代码,默认采用:rg/grepgo build ./...、DBHub、Worker/API 日志四类手段做人工验收。

统一工具

  • 代码结构核对rg / grep
  • 编译验收go build ./...
  • 表结构 / 数据核对DBHubsearch_objects + execute_sql
  • 异步链路核对Worker / API 日志关键字

统一通过标准

  1. 相关代码路径存在且引用关系正确
  2. 涉及 schema 的任务DBHub 能查到新增字段 / 新表 / 新索引
  3. 涉及业务数据流转的任务DBHub 能查到状态变化或新增记录
  4. 涉及异步任务的任务,日志能看到"入队 / 消费 / 成功 / 失败"关键节点
  5. 最后执行一次 go build ./...,必须能通过

方案 A紧急 Bug 修复P0 全部 + P2-2 前置)

这 7 个 P0 是生产直接跑不通的 Bug,优先级最高。 A-1 是其他所有 A 系修复的前置条件,必须第一个做。


A-1 · 实名状态常量统一P2-2前置

业务背景

IoT 卡需要实名认证普通卡。系统通过网关Gateway轮询获取卡的实名状态结果写入 iot_card.real_name_status 字段。购买需实名套餐时检查此字段。

问题

三处代码对同一个字段的含义定义不一致,造成整条链路断裂:

位置 文件 定义
常量定义 pkg/constants/iot.go:47-48 RealNameStatusVerified = 1
轮询实际写入 internal/task/polling_handler.go:696-700 true → return 2
Model 注释 internal/model/iot_card.go:30 0-未实名 1-已实名

结果已实名的卡DB 里值=2用常量=1校验逻辑全错。

正确定义(以常量为准):0 = 未实名1 = 已实名

修改清单

internal/task/polling_handler.go:696-700(轮询写入值,改为 1

// 修改前
func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
    if realStatus {
        return 2 // 已实名
    }
    return 0 // 未实名
}

// 修改后
func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
    if realStatus {
        return constants.RealNameStatusVerified // = 1
    }
    return constants.RealNameStatusNotVerified // = 0
}

internal/task/polling_handler.go:170isFirstRealname 判断,改用常量)

// 修改前
isFirstRealname := (card.RealNameStatus == 0 || card.RealNameStatus == 1) && newRealnameStatus == 2

// 修改后
isFirstRealname := card.RealNameStatus != constants.RealNameStatusVerified &&
    newRealnameStatus == constants.RealNameStatusVerified

internal/model/iot_card.go:30Model 注释)

// 修改前
RealNameStatus int `gorm:"...; comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)"`

// 修改后(注释统一)
RealNameStatus int `gorm:"...; comment:实名状态 0-未实名 1-已实名"`

④ 全局搜索并修正 DTO 注释(不影响运行,但影响文档):

grep -rn "real_name_status\|RealNameStatus" internal/model/dto/ --include="*.go"

凡是注释写 2=已实名1实名中 的,统一改为 0=未实名, 1=已实名


A-2 · C端实名校验修复P0-1

业务背景

C 端客户购买"需实名套餐"前,系统检查卡的实名状态。普通卡(card_category = "normal")必须已实名才能购买。

问题

internal/service/client_order/service.go:115(依赖 A-1 修完后自动正确):

// 当前错误(硬编码 1但 DB 里实际是 2A-1 修完前暂时改这里)
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 {
    return errors.New(errors.CodeForbidden, "请先完成实名认证")
}

修改方式

// 修改后使用常量A-1 先修好后此行自动正确)
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
    return errors.New(errors.CodeForbidden, "请先完成实名认证")
}

同时检查 internal/task/polling_handler.go:982

if card.RealNameStatus != constants.RealNameStatusVerified {

A-1 修完后此行也自动正确,无需单独改。


A-3 · 实名激活任务断链修复P0-2

业务背景

后台代理/平台可以"囤货"——预购套餐时套餐不立即激活(PackageUsage.status = pending, pending_realname_activation = true),等客户完成实名认证后自动激活。这是"先备货,客户实名后开始计费"的业务模式。

完整链路:

1. 后台囤货购买 → PackageUsage(status=0=pending, pending_realname_activation=true)
2. 轮询 polling:realname 定期调 Gateway 检测卡实名状态
3. 检测到首次实名card.RealNameStatus 从 0 变为 1
4. triggerFirstRealnameActivation(cardID) 查找该卡所有 pending 套餐
5. 对每个套餐 Enqueue Asynq 任务 → 消费者激活套餐(设定激活时间、到期时间)

问题

步骤 5 没有接入 Asynq。PollingHandler 没有注入 asynq.Client,开发者临时用 RPush 到 Redis List 降级,然后用 _ = task 把正确的 Asynq task 对象扔掉。

关键代码 internal/task/polling_handler.go:1100-1115(当前状态):

task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes, ...)

// 这里需要访问 Asynq Client暂时使用 Redis 队列
activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
h.redis.RPush(ctx, activationKey, string(payloadBytes))

_ = task  // ← task 被扔掉!

pkg/queue/handler.go:Handler 结构体里已经有 asynqClient *asynq.Client,只需传进去。

修改清单

internal/task/polling_handler.goPollingHandler struct 新增字段

// PollingHandler struct第 31-42 行附近)
type PollingHandler struct {
    db                    *gorm.DB
    redis                 *redis.Client
    asynqClient           *asynq.Client    // 新增此行
    gatewayClient         *gateway.Client
    // ... 其余字段不变
}

internal/task/polling_handler.go:NewPollingHandler(第 44 行)

// 修改前
func NewPollingHandler(
    db *gorm.DB,
    redis *redis.Client,
    gatewayClient *gateway.Client,
    usageService *packagepkg.UsageService,
    logger *zap.Logger,
) *PollingHandler {
    return &PollingHandler{
        db:            db,
        redis:         redis,
        gatewayClient: gatewayClient,
        // ...

// 修改后(新增 asynqClient 参数)
func NewPollingHandler(
    db *gorm.DB,
    redis *redis.Client,
    asynqClient *asynq.Client,   // 新增
    gatewayClient *gateway.Client,
    usageService *packagepkg.UsageService,
    logger *zap.Logger,
) *PollingHandler {
    return &PollingHandler{
        db:            db,
        redis:         redis,
        asynqClient:   asynqClient, // 新增
        gatewayClient: gatewayClient,
        // ...

pkg/queue/handler.go:registerPollingHandlers(第 151 行)

// 修改前
pollingHandler := task.NewPollingHandler(
    h.db,
    h.redis,
    h.gatewayClient,
    h.workerResult.Services.UsageService,
    h.logger,
)

// 修改后(传入 asynqClient
pollingHandler := task.NewPollingHandler(
    h.db,
    h.redis,
    h.asynqClient, // 新增Handler 已有此字段
    h.gatewayClient,
    h.workerResult.Services.UsageService,
    h.logger,
)

internal/task/polling_handler.go:triggerFirstRealnameActivation(第 1100 行附近)

// 修改前RPush 降级 + _ = task
task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes,
    asynq.MaxRetry(3),
    asynq.Timeout(30*time.Second),
    asynq.Queue(constants.QueueDefault),
)
activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
if err := h.redis.RPush(ctx, activationKey, string(payloadBytes)).Err(); err != nil {
    // ...
}
_ = task  // 删掉

// 修改后(直接 Enqueue
task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes,
    asynq.MaxRetry(3),
    asynq.Timeout(30*time.Second),
    asynq.Queue(constants.QueueDefault),
)
if _, err := h.asynqClient.EnqueueContext(ctx, task); err != nil {
    h.logger.Warn("提交首次实名激活任务失败",
        zap.Uint("package_usage_id", pkg.ID),
        zap.Error(err))
    continue
}

A-4 · 自动购包触发 + 佣金补全P0-3 + P0-4

业务背景

C 端客户"强充"ForceRecharge可以同时指定想自动购买的套餐 IDs存在 AssetRechargeRecord.LinkedPackageIDs JSONB 字段里)。充值成功到账后,系统应自动从资产钱包扣款购买这些套餐。这是"充值即购包"一步操作,省去客户手动购买。

两个断点

  • P0-3recharge/service.go:HandlePaymentCallback 充值回调成功后,完全没有触发自动购包的代码。task.NewAutoPurchaseTask 函数全项目只有定义,零调用点
  • P0-4AutoPurchaseHandler.ProcessTask() 创建订单并激活套餐后,没有调用 enqueueCommissionCalculation(),自动购包产生的订单不计佣金。

涉及的关键事实

  • AssetRechargeRecord.LinkedPackageIDs 字段:internal/model/asset_wallet.go:91JSONB存放 package IDs 数组)
  • recharge/service.go 当前没有 queueClient 依赖,需要新增
  • pkg/queue/ClientEnqueueTask(ctx, taskType, payload, opts...) 方法(pkg/queue/client.go:38),适合 Service 层 调用
  • pkg/queue/handler.go:Handler 当前持有的是 asynqClient *asynq.Client,适合 Worker 任务处理器 在消费完成后继续提交下游任务
  • TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" 常量已存在(pkg/constants/constants.go:68task.NewAutoPurchaseTask() 也已使用该常量,无需新增第二个自动购包任务类型
  • AutoPurchaseHandler 目前没有在 pkg/queue/handler.go 里注册,需要新增

调用链路(修正版)

充值支付回调成功
    │
    ▼
recharge.Service.HandlePaymentCallback()
    │  Service 层:使用 *queue.Client 提交任务
    ▼
TaskTypeAutoPurchaseAfterRecharge
    │
    ▼
Asynq Worker → registerAutoPurchaseHandler()
    │
    ▼
AutoPurchaseHandler.ProcessTask()
    │  事务内:扣钱包 → 创建订单 → 激活套餐 → 更新 auto_purchase_status=success
    │  事务提交成功后:使用 *asynq.Client 提交下游任务
    ▼
TaskTypeCommission
    │
    ▼
CommissionCalculationHandler

一句话结论

  • Service 层(充值回调):用 *queue.Client,复用已有 TaskTypeAutoPurchaseAfterRecharge
  • Worker 层AutoPurchaseHandler:用 *asynq.Client,在事务提交成功后再投递 TaskTypeCommission
  • 禁止做法:新增第二套自动购包常量、在同一段代码里混用 queueClient / asynqClient、在事务未提交前先投递佣金任务

修改清单P0-3

P0-3 目标:充值支付回调成功后,自动把 RechargeRecordID 投递到自动购包任务队列。

internal/service/recharge/service.goService struct 新增 queueClient

type Service struct {
    db                          *gorm.DB
    // ... 其余字段 ...
    queueClient                 *queue.Client  // 新增
}

func New(
    db *gorm.DB,
    // ... 其余参数 ...
    queueClient *queue.Client,  // 新增
) *Service {
    return &Service{
        // ... 其余字段 ...
        queueClient: queueClient,
    }
}

internal/service/recharge/service.go:HandlePaymentCallback(第 272 行附近,事务成功后)

if err != nil { return err } 之后,s.logger.Info("充值支付回调处理成功"...) 之前,添加:

// 充值成功后,如有关联套餐则触发自动购包(强充绑套餐功能)
linkedIDs := recharge.LinkedPackageIDs
if len(linkedIDs) > 0 && string(linkedIDs) != "[]" && string(linkedIDs) != "null" {
    if s.queueClient != nil {
        taskPayload := task.AutoPurchasePayload{RechargeRecordID: recharge.ID}
        if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeAutoPurchaseAfterRecharge, taskPayload,
            asynq.Queue(constants.QueueDefault),
            asynq.MaxRetry(3),
        ); err != nil {
            s.logger.Error("自动购包任务入队失败",
                zap.Uint("recharge_id", recharge.ID),
                zap.Error(err))
            // 不影响充值成功,仅记录日志
        }
    }
}

pkg/constants/constants.go:复用现有任务类型常量,不新增第二套名字

// 已存在,直接复用
TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包

不要新增 TaskTypeAutoPurchase = "auto_purchase" 这类新常量,否则会出现:

  • producer 用新常量入队
  • task.NewAutoPurchaseTask() 和 worker handler 仍使用旧常量

最终导致生产者 / 消费者任务类型对不上。

pkg/queue/handler.go:新增 registerAutoPurchaseHandler()

RegisterHandlers() 中添加 h.registerAutoPurchaseHandler() 调用,并新增方法。

同时,internal/task/auto_purchase.go 中的 AutoPurchaseHandler structNewAutoPurchaseHandler(...) 需要新增 asynqClient *asynq.Client 字段 / 参数,供自动购包完成后继续提交佣金任务:

func (h *Handler) registerAutoPurchaseHandler() {
    autoPurchaseHandler := task.NewAutoPurchaseHandler(
        h.db,
        nil, // orderStore让 handler 内部初始化
        nil, // rechargeRecordStore
        nil, // walletStore
        nil, // walletTransactionStore
        nil, // packageUsageStore
        h.redis,
        h.asynqClient, // 新增:传入裸 asynq client供 Worker 内继续投递下游任务
        h.logger,
    )
    h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask)
    h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge))
}

internal/bootstrap/ 中找到 recharge service 的初始化,传入 queueClient

internal/bootstrap/services.goworker_services.go 中找到 recharge.New(...) 的调用位置,补充 queueClient 参数传入。

修改清单P0-4

P0-4 目标AutoPurchaseHandler 在订单创建 + 套餐激活成功后,把 order_id 投递到佣金计算队列,确保自动购包订单与普通订单一样进入佣金链路。

internal/task/auto_purchase.goProcessTask 事务提交成功后触发佣金

这里要明确分层:

  • recharge.Service 属于 Service 层,用 *queue.Client
  • AutoPurchaseHandler 属于 Worker 消费者,直接持有并使用 *asynq.Client

不要在同一段代码里写成 if h.asynqClient != nil { h.queueClient.EnqueueTask(...) },这会把两套依赖混在一起。

建议写法:在 ProcessTask() 里先声明 var createdOrderID uint,事务内创建订单后赋值,事务成功返回后再入队佣金任务:

var createdOrderID uint

err = h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
    // ... 扣钱包、创建订单、创建流水 ...
    if err = tx.Create(order).Error; err != nil {
        return err
    }
    createdOrderID = order.ID

    if err = h.activatePackages(ctx, tx, order, packages, now); err != nil {
        return err
    }

    if err = tx.Model(&model.AssetRechargeRecord{}).
        Where("id = ?", rechargeRecord.ID).
        Update("auto_purchase_status", constants.AutoPurchaseStatusSuccess).Error; err != nil {
        return err
    }

    return nil
})
if err != nil {
    return err
}

// 事务提交成功后,触发佣金计算
if h.asynqClient != nil && createdOrderID > 0 {
    payloadBytes, err := sonic.Marshal(map[string]any{"order_id": createdOrderID})
    if err != nil {
        h.logger.Warn("佣金任务载荷序列化失败",
            zap.Uint("order_id", createdOrderID),
            zap.Error(err))
    } else {
        commissionTask := asynq.NewTask(constants.TaskTypeCommission, payloadBytes,
            asynq.Queue(constants.QueueLow),
            asynq.MaxRetry(3),
        )
        if _, err := h.asynqClient.EnqueueContext(ctx, commissionTask); err != nil {
            h.logger.Warn("自动购包后提交佣金任务失败",
                zap.Uint("order_id", createdOrderID),
                zap.Error(err))
        }
    }
}

注意:AutoPurchaseHandler 这里新增的是 asynqClient *asynq.Client,不是 queueClient *queue.Client

常见误区(必须避免)

  1. 不要新增第二套自动购包常量

    • 错误:TaskTypeAutoPurchase = "auto_purchase"
    • 正确:复用 TaskTypeAutoPurchaseAfterRecharge
  2. 不要在 Worker 里再注入 *queue.Client

    • 当前 pkg/queue/handler.go:Handler 已经持有 asynqClient *asynq.Client
    • 对 Worker 来说,直接 EnqueueContext() 更直接,也与现有 PackageActivationHandler 注入方式一致
  3. 不要在事务内先投递佣金任务

    • 如果事务后续回滚,就会出现:佣金任务已提交,但订单 / 套餐激活其实未落库成功
    • 所以必须先 Transaction(...) 成功返回,再提交 TaskTypeCommission
  4. 不要漏掉 handler 注册

    • internal/task/auto_purchase.go 里虽然已有 AutoPurchaseHandler
    • 但如果 pkg/queue/handler.go:RegisterHandlers() 不调用 h.registerAutoPurchaseHandler(),任务仍然不会被消费
  5. Service 层调用 queueClient.EnqueueTask(...) 时,传的是对象,不是预先 Marshal 后的 []byte

    • queue.Client 内部会自行 Marshal
    • 这里应直接传 task.AutoPurchasePayload{RechargeRecordID: recharge.ID}

验收要点(不打接口,只看日志 + DBHub

不要求额外写测试代码。按下面方式人工校验即可。

日志关键点

  • 支付回调成功后,应看到自动购包任务入队成功相关日志(至少能看到 Asynq 客户端的任务提交日志,task_type=task:auto_purchase_after_recharge
  • Worker 消费成功后,应看到:自动购包任务执行成功
  • 如佣金任务入队失败,应看到:自动购包后提交佣金任务失败

DBHub 核对顺序

  1. tb_asset_recharge_record

    • 目标记录的 auto_purchase_status 应从 pending 变成 success
  2. tb_order / tb_order_item

    • 应新增一笔由自动购包生成的订单
    • payment_method=wallet
    • payment_status=paid
    • buyer_type=personal
  3. tb_package_usage

    • 应存在该订单对应的套餐使用记录
    • 正式套餐 / 加油包状态应与现有激活规则一致
  4. tb_commission_record

    • 在 worker 正常消费佣金任务后,应出现该 order_id 的佣金记录
    • 如果没有记录,再回看 worker 日志是否出现佣金任务提交失败或佣金计算失败

建议的 DBHub 查询顺序图

tb_asset_recharge_record
        │ auto_purchase_status = success ?
        ├─ 否 → 先查自动购包 worker 日志
        ▼
tb_order / tb_order_item
        │ 自动购包订单是否已生成?
        ├─ 否 → 看 auto_purchase handler 事务内失败点
        ▼
tb_package_usage
        │ 套餐是否已激活 / 排队?
        ├─ 否 → 看 activatePackages() 相关日志
        ▼
tb_commission_record
          佣金记录是否生成?
          ├─ 否 → 看佣金任务入队 / 佣金计算日志
          └─ 是 → A-4 验收通过

A-5 · 代购订单金额修正P0-5

业务背景:五种购买场景

系统支持以下五种套餐购买场景,每种有不同的金额和佣金逻辑:

# 操作方 资产所属 付款来源 订单面值totalAmount 实际扣款actualPaidAmount 佣金产生
1 平台 平台自己客户 线下(无钱包) 建议售价 N/A 不产生(应有标记)
2 平台 某代理客户 该代理钱包 该代理成本价 该代理成本价 如有上级代理产生差额
3 平台 某代理客户 线下 该代理成本价 N/A 如有上级代理产生差额
4 代理 自己客户 自己钱包 自己成本价 自己成本价 如有上级代理产生差额
5 代理 下级代理客户 自己钱包 下级代理成本价(面值) 自己(上级)成本价 如有更上级代理产生差额

梯度式佣金:所有场景都计入梯度条件计数。一次性固定佣金只在走充值场景才产生,不走充值不产生。

Bug 位置

internal/service/order/service.go:525-548(场景 5 分支,代理代购下级):

// 当前代码
buyerCostPrice, _ := s.getCostPrice(ctx, *resourceShopID, firstPackageID)   // 下级成本价
operatorCostPrice, _ := s.getCostPrice(ctx, operatorShopID, firstPackageID) // 自己成本价

totalAmount     = buyerCostPrice       // 下级成本价作为面值(正确)
actualPaidAmount = &operatorCostPrice  // 实际扣自己钱包(正确)
sellerCostPrice  = buyerCostPrice      // ← ❌ 应是 operatorCostPrice

sellerCostPrice 用于佣金差额计算基准。错误地使用下级成本价,导致整个佣金链的差额计算为 0更上级代理无法获得应有的佣金。

修改方式

sellerCostPrice = operatorCostPrice  // ✅ 改为自己的成本价

同时检查同文件里是否有相同模式的第二处(搜索 sellerCostPrice = buyerCostPrice,共修复所有出现位置)。


A-6 · 设备导入补充 IMEI 字段P0-6

业务背景

设备DeviceModel 有 imei 字段(internal/model/device.go:19),用于调用 Gateway API。但设备批量导入Excel的解析结构 DeviceRow 没有 IMEI导入后 IMEI 始终为空,无法通过 IMEI 调 Gateway。

修改清单

pkg/utils/excel.go:35-43DeviceRow struct

// 修改前
type DeviceRow struct {
    Line         int
    VirtualNo    string
    DeviceName   string
    DeviceModel  string
    DeviceType   string
    MaxSimSlots  int
    Manufacturer string
    ICCIDs       []string
}

// 修改后(新增 IMEI 字段)
type DeviceRow struct {
    Line         int
    VirtualNo    string
    IMEI         string   // 新增
    DeviceName   string
    DeviceModel  string
    DeviceType   string
    MaxSimSlots  int
    Manufacturer string
    ICCIDs       []string
}

pkg/utils/excel.goExcel 列名映射处)

找到 ParseDeviceExcel 中处理表头映射的代码(大约在 290-320 行),在已有的列名 map 中新增:

case "imei", "IMEI", "设备IMEI", "IMEI号":
    row.IMEI = cellValue

internal/task/device_import.goprocessBatch 函数,约 178 行)

在创建 model.Device{} 时新增 IMEI: row.IMEI


A-7 · 提现冻结并发校验P0-7

业务背景

代理申请提现时,先冻结佣金钱包余额(balance -= amount, frozen_balance += amount),再创建提现申请单。问题:两个并发请求可以同时通过 balance >= amount 的 WHERE 检查,第二个 UPDATE 实际 RowsAffected=0(余额已不够),但代码只检查 .Error 不检查 RowsAffected,导致冻结失败却继续创建了提现单。

修改位置internal/service/my_commission/service.go:162-170

// 修改前(只检查 Error
if err := tx.WithContext(ctx).Model(&model.AgentWallet{}).
    Where("id = ? AND balance >= ?", wallet.ID, req.Amount).
    Updates(map[string]interface{}{
        "balance":        gorm.Expr("balance - ?", req.Amount),
        "frozen_balance": gorm.Expr("frozen_balance + ?", req.Amount),
    }).Error; err != nil {
    return errors.Wrap(errors.CodeInternalError, err, "冻结余额失败")
}

// 修改后(新增 RowsAffected 校验)
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
    Where("id = ? AND balance >= ?", wallet.ID, req.Amount).
    Updates(map[string]interface{}{
        "balance":        gorm.Expr("balance - ?", req.Amount),
        "frozen_balance": gorm.Expr("frozen_balance + ?", req.Amount),
    })
if result.Error != nil {
    return errors.Wrap(errors.CodeInternalError, result.Error, "冻结余额失败")
}
if result.RowsAffected == 0 {
    return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突,请稍后重试")
}

方案 A · 人工验收清单

  • A-1 实名状态常量统一

    • 工具:rg + DBHub + polling 日志
    • 步骤:
      1. 搜索 return 2newRealnameStatus == 22=已实名 是否仍残留在实名链路相关文件
      2. DBHub 执行:SELECT DISTINCT real_name_status FROM tb_iot_card ORDER BY 1;
      3. 观察一轮实名轮询日志,确认不再写入值 2
    • 预期结果:代码 / 注释统一为 0=未实名, 1=已实名DB 中实名状态最终只有 0/1
  • A-2 C端实名校验修复

    • 工具:rg + DBHub
    • 步骤:
      1. 搜索 internal/service/client_order/service.go 中实名判断是否改为 constants.RealNameStatusVerified
      2. DBHub 抽查普通卡记录:SELECT id, card_category, real_name_status FROM tb_iot_card WHERE card_category='normal' LIMIT 20;
    • 预期结果:普通卡实名判断不再依赖硬编码;未实名普通卡会被拦截,已实名普通卡可继续下单
  • A-3 实名激活任务断链修复

    • 工具:rg + Worker 日志 + DBHub
    • 步骤:
      1. 搜索 internal/task/polling_handler.go,确认不再走 RPush 降级人工队列
      2. 观察首次实名触发日志,应出现首次实名激活任务入队 / 消费日志
      3. DBHub 抽查:SELECT id, status, pending_realname_activation, activated_at FROM tb_package_usage WHERE pending_realname_activation=true ORDER BY id DESC LIMIT 20;
    • 预期结果:首次实名后相关套餐进入 Asynq 正常链路;待实名套餐能被激活,activated_at 被写入
  • A-4 自动购包触发 + 佣金补全

    • 详见本节上方 A-4 详细验收要点
    • 最低预期:tb_asset_recharge_record.auto_purchase_status=success、自动购包订单生成、套餐激活成功、对应 order_id 出现佣金记录
  • A-5 代购订单金额修正

    • 工具:rg + DBHub
    • 步骤:
      1. 搜索 sellerCostPrice = buyerCostPrice,确认场景 5 的错误赋值已消失
      2. DBHub 选一笔“代理代购下级代理客户”的订单及其佣金记录,核对 total_amountactual_paid_amount 与上级佣金金额是否不再全为 0
    • 预期结果:代购下级场景里,订单面值仍是下级成本价,但佣金链能继续向上计算,不再整链归零
  • A-6 设备导入补充 IMEI 字段

    • 工具:rg + DBHub + 导入日志
    • 步骤:
      1. 搜索 DeviceRowParseDeviceExceldevice_import.go,确认 IMEI 已贯通到导入流程
      2. 完成一次设备导入后DBHub 执行:SELECT id, virtual_no, imei FROM tb_device ORDER BY id DESC LIMIT 20;
    • 预期结果:导入后的设备 imei 不再长期为空;日志中无 IMEI 丢失相关告警
  • A-7 提现冻结并发校验

    • 工具:日志 + DBHub
    • 步骤:
      1. 人工制造一次重复提交提现的场景(后台重复点击或相邻两次提交)
      2. DBHub 查询对应钱包和提现单:
        • SELECT id, balance, frozen_balance, version FROM tb_agent_wallet WHERE id = ?;
        • SELECT id, amount, status, created_at FROM tb_commission_withdrawal_request WHERE shop_id = ? ORDER BY id DESC LIMIT 10;
      3. 观察日志是否出现“余额不足或并发冲突”
    • 预期结果:余额冻结成功次数与提现单创建次数一致;失败的并发请求不会继续生成提现单
  • 方案 A 收尾检查

    • 工具:go build ./...
    • 预期结果:编译通过,无新增类型错误或引用错误

方案 B企业端权限完善P1-1 + P2-11

业务背景

企业账号(user_type = 4)通过 AdminAuth JWT 登录,复用 /api/admin/* 路由(无需新建中间件)。企业被分配了代理的卡/设备后,可以查看资产信息,可以对被授权的资产执行停复机,但不能购买套餐没有钱包

现状

internal/handler/admin/asset.go 有 10 个方法,企业拦截现状:

方法 功能 当前状态 应有状态
Resolve 通过 ICCID/虚拟号查询资产 有企业拦截(应移除,企业应能查) 可访问
RealtimeStatus 查看实时状态 无拦截 可访问
Packages 查看套餐列表 无拦截 可访问
CurrentPackage 查看当前套餐 无拦截 可访问
Refresh 主动触发运营商刷新 无拦截 需拦截
StopDevice 设备停机 无拦截 企业有权管控
StartDevice 设备复机 无拦截 企业有权管控
StopCard 卡停机 无拦截 企业有权管控
StartCard 卡复机 无拦截 企业有权管控
Wallet 相关 (2个) 钱包查看 已拦截(企业无钱包) 已正确拦截

修改清单

internal/handler/admin/asset.go:Resolve(第 40-44 行)

// 移除错误的企业拦截(企业应该能查询被授权的资产)
// 删除以下代码:
if userType == constants.UserTypeEnterprise {
    return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
}

internal/handler/admin/asset.go:Refresh(第 78 行附近)

Refresh 方法开头新增企业拦截:

func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
    // 企业账号不允许主动触发刷新(只读访问)
    userType := middleware.GetUserTypeFromContext(c.UserContext())
    if userType == constants.UserTypeEnterprise {
        return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态")
    }
    // ... 其余代码不变

③ 资源标签覆盖度(修正后的真实落点)

当前仓库不存在 internal/service/tag/ 目录,不要再按这个路径搜索。标签相关的真实代码入口是:

  • internal/store/postgres/resource_tag_store.go:ListByResource()
  • internal/model/tag.goTag / ResourceTag 模型定义)
  • 当前上层调用点:internal/service/exchange/migration.go:copyResourceTagsWithTx()

如果企业资产详情 / 查询接口后续需要返回标签,优先修改 resource_tag_store.go:ListByResource() 这一层,确保企业账号读取资源标签时不会越权。

建议搜索:

grep -rn "ResourceTagStore\|ListByResource\|enterprise_id\|shop_id" internal/store/postgres internal/service --include="*.go"

建议补法:

query := s.db.WithContext(ctx).
    Where("resource_type = ? AND resource_id = ?", resourceType, resourceID)

query = middleware.ApplyShopFilter(ctx, query)

// 注意pkg/middleware/data_scope.go:ApplyEnterpriseFilter() 只会加 enterprise_id = ?
// 如果企业账号还应看到全局标签enterprise_id IS NULL这里需要显式补 OR IS NULL 条件
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
    enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
    query = query.Where("enterprise_id = ? OR enterprise_id IS NULL", enterpriseID)
} else {
    query = middleware.ApplyEnterpriseFilter(ctx, query)
}

如果当前企业资产接口根本还没有返回标签,这一项就作为“防未来越权回归”的检查项保留,不要求为了这次修复额外新增标签接口。

方案 B · 人工验收清单

  • 工具:rg + DBHub + 管理端 / API 日志 + go build ./...
  • 步骤:
    1. 搜索 internal/handler/admin/asset.go,确认 Resolve 中已删除企业账号禁用逻辑,Refresh 中新增企业账号拦截
    2. 搜索 internal/store/postgres/resource_tag_store.go 与其上层调用点,确认资源标签读取路径已定位到真实文件,而不是不存在的 internal/service/tag/
    3. 如当前资产接口会返回标签,则检查标签读取逻辑是否包含 enterprise_id = ? OR enterprise_id IS NULL 一类条件;如当前不返回标签,则仅记录“当前无标签读取链路,无需额外改接口”
    4. DBHub 抽查一条企业资产数据 / 资源标签数据,确认企业侧仅能命中本企业可见资产 / 标签
    5. 查看日志关键字:企业调用 Refresh 时应被拒绝;调用 Resolve / RealtimeStatus / StopCard / StartCard / StopDevice / StartDevice 不应被通用禁止逻辑拦住
  • 预期结果:企业账号“可查看、可停复机、不可主动刷新、无钱包”,且标签查询不越权

方案 C提现/佣金流程完善P2-19 + P2-20 + P2-21 + P2-22

P1-3已到账确认接口已确认不需要实现系统无法感知到账。


C-1 · 审批人字段补全P2-20

问题internal/model/financial.go:27-30 定义了 ApprovedByApprovedAt,但 commission_withdrawal/service.go:Approve() 只写了 processor_idprocessed_atapproved_by/approved_at 永远为空。

修改位置internal/service/commission_withdrawal/service.go:Approve() 中 updates map补充

updates["approved_by"] = currentUserID
updates["approved_at"] = now

C-2 · 提现拒绝 remark 必填校验P2-19

问题internal/model/dto/commission_withdrawal_dto.go:61-65 的 DTO 标注了 validate:"required",但 internal/handler/admin/commission_withdrawal.go:Reject()BodyParser 没有 Validate

修改位置commission_withdrawal.go:Reject() 中 BodyParser 后补充:

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

(参考同文件或其他 Handler 的 Validate 调用方式)


C-3 · 激活配置并发保护P2-21 + P2-22

问题commission_withdrawal_setting/service.gowechat_config_store.go 激活新配置时,是两步 UPDATE先全部置 false再置目标为 true无锁保护并发可产生两条 active=true 的记录。

修改方式:在事务内,第一步 UPDATE 前加 FOR UPDATE 锁:

// commission_withdrawal_setting/service.go两处都改
err := s.db.Transaction(func(tx *gorm.DB) error {
    // 先锁定当前 active 记录
    var current model.CommissionWithdrawalSetting
    tx.Set("gorm:query_option", "FOR UPDATE").
        Where("is_active = ?", true).
        First(&current)  // 不管 err只是锁住

    // 再执行原有的 deactivate → activate 逻辑
    ...
})

// wechat_config_store.go 同理

方案 C · 人工验收清单

  • C-1 审批人字段补全

    • 工具DBHub + 日志
    • 步骤:审批一笔提现后,执行:SELECT id, processor_id, processed_at, approved_by, approved_at FROM tb_commission_withdrawal_request ORDER BY id DESC LIMIT 10;
    • 预期结果:approved_by / approved_at 不再为空,且与 processor_id / processed_at 对应
  • C-2 拒绝提现 remark 必填

    • 工具:rg + 管理端 / API 日志
    • 步骤:
      1. 搜索 commission_withdrawal.go:Reject(),确认存在 validator.Validate(&req)
      2. 人工提交一次空 remark 的拒绝操作
    • 预期结果:请求被拦截,返回参数错误;提现记录状态不发生变化
  • C-3 激活配置并发保护

    • 工具DBHub + 日志
    • 步骤:
      1. 连续切换一次佣金提现配置、一次微信支付配置
      2. DBHub 分别执行:
        • SELECT COUNT(*) FROM tb_commission_withdrawal_setting WHERE is_active=true;
        • SELECT COUNT(*) FROM tb_wechat_config WHERE is_active=true;
    • 预期结果:两个表任意时刻都最多只有一条 is_active=true
  • 方案 C 收尾检查

    • 工具:go build ./...
    • 预期结果:编译通过

方案 D设备体系完善P1-10 + P2-28 + P2-29 + 字段扩展)

设备定期轮询P1-9已确认为伪需求取消。改为查详情时按需实时拉取。 P2-30轮询配置通用化也随之取消。


D-0 · 设备模型字段扩展

业务背景

Gateway sync-info 接口返回大量实时设备信息,当前 Device Model 几乎无对应字段。需要扩展存储"有离线意义"的字段,"只在线有意义"的字段只实时返回不存库。

Gateway 接口文档docs/第三方文档/gateway设备详情同步接口.md

  • URLPOST /v1/iot/openApi/device/sync-info
  • 请求:{"params": {"cardNo": "设备标识"}} cardNo > 15 位用 ICCID= 15 位用 IMEI= 11 位用 SN
  • 认证:appId + sign 签名(与现有 Gateway Client 一致)

字段策略(不用 JSONB用独立字段

需存 DB有离线意义

字段名 类型 Gateway 对应字段 说明
online_status INT DEFAULT 0 online_status 1=在线2=离线
last_online_time TIMESTAMP NULL last_online_time 最后在线时间
software_version VARCHAR(100) DEFAULT '' software_version 固件版本
switch_mode VARCHAR(10) DEFAULT '0' switch_mode 切卡模式0=自动1=手动
last_gateway_sync_at TIMESTAMP NULL 最后一次 sync-info 同步时间

只实时返回(不存 DBrssi, battery_level, ssid, wifi_enabled, rsrp, sinr, run_time, connect_time

修改清单

① DB 迁移(migrations/ 目录新建迁移文件)

ALTER TABLE tb_device
    ADD COLUMN online_status INT NOT NULL DEFAULT 0,
    ADD COLUMN last_online_time TIMESTAMP NULL,
    ADD COLUMN software_version VARCHAR(100) NOT NULL DEFAULT '',
    ADD COLUMN switch_mode VARCHAR(10) NOT NULL DEFAULT '0',
    ADD COLUMN last_gateway_sync_at TIMESTAMP NULL;

internal/model/device.go:新增以上 5 个 GORM 字段(参考现有字段的标签格式)。

internal/model/dto/device_dto.go

  • DeviceResponse(列表/详情基础信息):新增 5 个 DB 字段
  • DeviceRealtimeInfo(实时详情,从 sync-info 直接返回新增非存储字段rssi, battery_level, ssid, wifi_enabled 等),标注 description:"实时字段,设备在线时有效"

D-1 · Gateway sync-info 接口对接P1-10前置

为什么现在没有:早期只对接了旧的异步接口 /device/info(调用后通过 callbackUrl 异步返回)。同步接口 sync-info 是 Gateway 后来提供的,直接返回全量数据,未同步接入。internal/model/dto/client_asset_dto.go:102CurrentIccid 字段但注释写着"当前 Gateway 同步接口尚未对接,预留结构待后续填充"。

修改清单

internal/gateway/device.go(当前只有 GetDeviceInfo()GetSlotInfo()

新增方法:

// SyncDeviceInfoReq sync-info 请求
type SyncDeviceInfoReq struct {
    CardNo string `json:"card_no"` // ICCID / IMEI / SN
}

// SyncDeviceInfoResp sync-info 响应(对应文档 data 字段)
type SyncDeviceInfoResp struct {
    DeviceID        string  `json:"device_id"`
    DeviceName      string  `json:"device_name"`
    IMEI            string  `json:"imei"`
    CurrentIccid    string  `json:"current_iccid"`
    DeviceType      string  `json:"device_type"`
    SoftwareVersion string  `json:"software_version"`
    MacAddress      string  `json:"mac_address"`
    SSID            string  `json:"ssid"`
    WifiEnabled     bool    `json:"wifi_enabled"`
    SwitchMode      string  `json:"switch_mode"`
    RSSI            string  `json:"rssi"`
    BatteryLevel    *int    `json:"battery_level"`
    OnlineStatus    int     `json:"online_status"` // 1=在线, 2=离线
    LastUpdateTime  string  `json:"last_update_time"`
    LastOnlineTime  string  `json:"last_online_time"`
    RunTime         string  `json:"run_time"`
    DailyUsage      string  `json:"daily_usage"`
}

// SyncDeviceInfo 同步查询设备信息(直接返回,无需回调)
func (c *Client) SyncDeviceInfo(ctx context.Context, req *SyncDeviceInfoReq) (*SyncDeviceInfoResp, error) {
    // 参考现有 doRequestWithResponse 的调用方式
    return doRequestWithResponse[SyncDeviceInfoResp](c, ctx, "/device/sync-info", req)
}

D-2 · device_sim_binding 加 is_currentP2-28

业务背景:设备绑定多张卡,需要知道"当前设备用哪张卡在传输数据"。现在用 slot_position == 1 猜测,不准。

修改清单

① DB 迁移

ALTER TABLE tb_device_sim_binding ADD COLUMN is_current BOOLEAN NOT NULL DEFAULT FALSE;

internal/model/device_sim_binding.go:新增字段:

IsCurrent bool `gorm:"column:is_current;type:boolean;not null;default:false;comment:是否为当前使用的卡" json:"is_current"`

③ 更新逻辑(在 D-3 的 updateDeviceFromSyncInfo 函数中):

  • sync-info 返回 current_iccid → 在 tb_device_sim_binding 中找到对应行,设为 is_current=true,其余行设为 is_current=false
  • 使用事务,两步原子更新

④ DTO 补充internal/model/dto/device_dto.goBoundCardInfo 中新增 IsCurrent bool


D-3 · 设备 Refresh + 详情接入 sync-infoP2-29

当前问题POST /assets/device/:id/refresh 只遍历绑定卡调卡网关,gateway/device.goSyncDeviceInfo 方法D-1 新增的)没有被使用。

修改位置internal/service/asset/service.go:RefreshDevice() 分支(约 307-339 行)

在刷新卡数据之后,追加设备自身的 sync-info 调用:

// 刷新设备自身信息(在线状态、当前卡、固件版本等)
if s.gatewayClient != nil {
    cardNo := device.IMEI  // 优先用 IMEI无 IMEI 则用 SN
    if cardNo == "" {
        cardNo = device.SN
    }
    if cardNo != "" {
        if syncResp, err := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
            CardNo: cardNo,
        }); err == nil {
            s.updateDeviceFromSyncInfo(ctx, device.ID, syncResp)
        } else {
            s.logger.Warn("sync-info 调用失败", zap.Uint("device_id", device.ID), zap.Error(err))
        }
    }
}

新增私有函数 updateDeviceFromSyncInfo(ctx, deviceID, syncResp)

  1. 更新 device 表的 5 个 DB 字段online_status, last_online_time, software_version, switch_mode, last_gateway_sync_at
  2. 根据 syncResp.CurrentIccid 更新 device_sim_binding.is_currentD-2 逻辑)

同样,查看设备详情接口(GetDeviceDetailRealtimeStatus)中,如果调用了 SyncDeviceInfo,将 syncResp 中的实时字段追加到响应中(不存 DB 的字段)。

方案 D · 人工验收清单

  • D-0 设备模型字段扩展

    • 工具DBHub + rg
    • 步骤:
      1. DBHub 搜索 tb_device 列,确认新增 online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at
      2. 搜索 internal/model/device.gointernal/model/dto/device_dto.go,确认模型 / DTO 已同步
    • 预期结果库表、模型、DTO 三处字段一致
  • D-1 sync-info 接口对接

    • 工具:rg + go build ./...
    • 步骤:搜索 SyncDeviceInfoReqSyncDeviceInfoRespSyncDeviceInfo( 是否已在 internal/gateway/device.go 定义并编译通过
    • 预期结果Gateway Client 已具备同步查询设备详情的能力
  • D-2 is_current 字段

    • 工具DBHub
    • 步骤:
      1. DBHub 搜索 tb_device_sim_binding 列,确认存在 is_current
      2. 抽查一台绑定多卡设备:SELECT device_id, iot_card_id, is_current FROM tb_device_sim_binding WHERE device_id = ? ORDER BY id;
    • 预期结果:同一设备最多一条 is_current=true
  • D-3 Refresh / 详情接入 sync-info

    • 工具Worker / API 日志 + DBHub
    • 步骤:
      1. 人工触发一次设备刷新
      2. 日志应出现 sync-info 调用成功或失败关键字
      3. DBHub 查询 tb_devicetb_device_sim_binding,确认设备状态字段和当前卡标识已更新
    • 预期结果:设备刷新不再只刷新卡,还会刷新设备自身在线状态 / 固件版本 / 当前使用卡
  • 方案 D 收尾检查

    • 工具:go build ./...
    • 预期结果:编译通过

方案 E流量体系改革P2-26 + P2-27 + P2-31

注意:这是架构改造,涉及 DB 迁移,建议低峰期发布。


E-1 · 流量详单改为日粒度缓冲P2-26

问题internal/task/polling_handler.go:268 每次轮询无条件调用 insertDataUsageRecord(),即便 data_increase_mb=0 也写记录(函数内部计算增量=0 后仍然 Create。大量卡每日产生无意义记录。

新设计

  1. 新建 tb_card_daily_usage 表(每张卡每天一条)
  2. 轮询时流量变化写 Redis 增量缓存INCRBYFLOAT
  3. 每日落盘:定时任务 SCAN 昨日 key → UPSERT 到 tb_card_daily_usage → 删 key

DB 迁移

CREATE TABLE tb_card_daily_usage (
    id BIGSERIAL PRIMARY KEY,
    iot_card_id BIGINT NOT NULL,
    date DATE NOT NULL,
    usage_mb BIGINT NOT NULL DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    CONSTRAINT uk_card_daily UNIQUE (iot_card_id, date)
);
CREATE INDEX idx_card_daily_date ON tb_card_daily_usage (date);

Redis Key 格式(在 pkg/constants/redis.go 新增):

// RedisCardDailyTrafficKey 卡日流量缓存 key
// 格式: traffic:daily:{card_id}:{YYYY-MM-DD}
func RedisCardDailyTrafficKey(cardID uint, date string) string {
    return fmt.Sprintf("traffic:daily:%d:%s", cardID, date)
}

insertDataUsageRecord 改造polling_handler.go:812

// 修改前:无条件写 DB
// 修改后:只在有增量时更新 Redis
func (h *PollingHandler) insertDataUsageRecord(ctx context.Context, cardID uint, currentUsageMB, previousUsageMB float64, ...) {
    increment := currentUsageMB - previousUsageMB
    if increment <= 0 {
        return // 无增量,不写
    }

    today := time.Now().Format("2006-01-02")
    key := constants.RedisCardDailyTrafficKey(cardID, today)
    h.redis.IncrByFloat(ctx, key, increment)
    h.redis.Expire(ctx, key, 48*time.Hour) // 保留 48h落盘任务有时间消费
}

新增每日落盘任务pkg/constants/constants.go 新增 TaskTypeDailyTrafficFlush

  • Asynq Scheduler 每天凌晨 2 点触发
  • SCAN traffic:daily:*:{昨日} → 批量 UPSERT tb_card_daily_usage → DELETE key

E-2 · current_month_usage_mb 改增量累加P2-27

问题calculateFlowUpdates() 直接 current_month_usage_mb = gatewayValue 覆盖。上游运营商按自然月重置(联通 27 号,移动/电信/广电 1 号),重置后 gateway 返回 0我们的值也归零但我们的套餐周期未必与自然月一致。

运营商重置日规则:存在 tb_carrier.data_reset_day 字段(方案 E-2 新增,运营建档时手动填写):

  • 中国联通27
  • 中国移动 / 中国电信 / 广电1

DB 迁移

-- 运营商表新增重置日
ALTER TABLE tb_carrier ADD COLUMN data_reset_day INT NOT NULL DEFAULT 1;

-- IoT 卡表新增上次网关读数(用于算增量)
ALTER TABLE tb_iot_card ADD COLUMN last_gateway_reading_mb FLOAT NOT NULL DEFAULT 0;

运营商管理接口:创建/编辑运营商时新增 data_reset_day 字段1-28前端显示为"每月 N 日重置流量"。IoT 卡创建/导入时 carrier_id 改为必填(原来可为空)。

calculateFlowUpdates() 改写polling_handler.go:388

func (h *PollingHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time) map[string]any {
    updates := make(map[string]any)

    increment := gatewayFlowMB - card.LastGatewayReadingMB

    // 检测上游重置:当前值比上次读数小
    if increment < 0 {
        resetDay := h.getCarrierResetDay(card.CarrierID) // 从缓存/DB 读运营商重置日
        today := now.Day()
        // 在重置日窗口内(重置日 ±1 天容错)才认为是正常重置
        if isResetWindow(today, resetDay) {
            increment = gatewayFlowMB  // 上游重置,本次原始值就是增量
        } else {
            h.logger.Warn("流量异常:非重置日出现值下降", ...)
            increment = 0
        }
    }

    if increment > 0 {
        updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment)
        updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment))
    }
    updates["last_gateway_reading_mb"] = gatewayFlowMB

    // 跨自然月重置(我们系统的重置跟套餐 data_reset_cycle 走)
    // ... 保留现有的 isCrossMonth 逻辑,但改为 +=,不再覆盖

    return updates
}

E-3 · 流量查询层适配P2-31

问题:改造后数据在 Redis今日+ tb_card_daily_usage(历史),现有 DataUsageRecordStore.ListByCardID() 查旧表,需适配。

新建 TrafficQueryServiceinternal/service/traffic/query_service.go

func (s *TrafficQueryService) GetDailyUsage(ctx context.Context, cardID uint, startDate, endDate time.Time) ([]*DailyUsageEntry, error) {
    var result []*DailyUsageEntry

    // 今天的数据 → Redis
    today := time.Now().Format("2006-01-02")
    if !endDate.Before(time.Now().Truncate(24*time.Hour)) {
        key := constants.RedisCardDailyTrafficKey(cardID, today)
        val, _ := s.redis.Get(ctx, key).Float64()
        result = append(result, &DailyUsageEntry{Date: today, UsageMB: val})
    }

    // 历史数据 → DB
    dbRecords, err := s.dailyUsageStore.ListByCardIDAndDateRange(ctx, cardID, startDate, endDate.AddDate(0, 0, -1))
    if err == nil {
        result = append(result, dbRecords...)
    }

    return sortAndMerge(result), nil
}

更新 GetRealtimeStatus、C 端流量详情接口等,改为调用 TrafficQueryService

方案 E · 人工验收清单

  • E-1 日粒度缓冲

    • 工具DBHub + rg + Worker 日志
    • 步骤:
      1. DBHub 确认存在 tb_card_daily_usage
      2. 搜索 RedisCardDailyTrafficKeyTaskTypeDailyTrafficFlush
      3. 观察日落盘任务日志,确认执行了 flush / upsert
    • 预期结果:今日增量先缓存在 Redis历史日流量落到 tb_card_daily_usage;不再无差别写 0 增量详单
  • E-2 流量增量累加

    • 工具DBHub + rg + polling 日志
    • 步骤:
      1. DBHub 确认新增 tb_carrier.data_reset_daytb_iot_card.last_gateway_reading_mb
      2. 搜索 calculateFlowUpdates(),确认 current_month_usage_mb 改成 gorm.Expr("current_month_usage_mb + ?", increment) 一类累加写法
      3. 观察异常下降日志是否带“非重置日出现值下降”告警
    • 预期结果:系统不再被上游自然月清零直接覆盖;异常下降只告警不误扣
  • E-3 查询层适配

    • 工具:rg + DBHub
    • 步骤:
      1. 搜索 TrafficQueryServiceGetDailyUsage
      2. 搜索 GetRealtimeStatus / C 端流量详情是否改为依赖新查询服务
      3. DBHub 抽查历史日数据是否可查
    • 预期结果:查询层能同时兼容“今日 Redis + 历史 DB”两段数据
  • 方案 E 收尾检查

    • 工具:go build ./...
    • 预期结果:编译通过

方案 F代码质量清理


F-1 · 佣金链断裂改为零额待审记录P2-4

业务规则:一个代理卖出资产后,套餐系列未分配给该代理(无佣金配置),不应静默跳过,而是创建金额=0、状态=待人工修正的佣金记录,让平台管理员能发现并手动处理。

修改清单

pkg/constants/ 中新增佣金状态常量

CommissionStatusPendingReview = 99 // 待人工修正(链路断裂,需平台处理)

internal/service/commission_calculation/service.go 断链处(break 之前):

// 原来:静默 break
// 修改后:创建零额待审记录 + 记日志
s.logger.Warn("佣金链断裂:代理缺少套餐系列授权,创建待审记录",
    zap.Uint("shop_id", shopID),
    zap.Uint("series_id", seriesID),
    zap.Uint("order_id", orderID))

pendingRecord := &model.CommissionRecord{
    OrderID:         orderID,
    ShopID:          shopID,
    SeriesID:        &seriesID,
    Amount:          0,
    Status:          constants.CommissionStatusPendingReview,
    Remark:          fmt.Sprintf("套餐系列[%d]未分配给该代理,请人工核查", seriesID),
}
_ = s.commissionRecordStore.Create(ctx, pendingRecord) // 失败不阻断主流程
break

③ 管理端新增筛选:佣金记录列表接口支持按 status=99 筛选,让平台管理员能看到所有待人工修正的记录。


F-2 · 废弃代码删除P2-8

背景sim:status:sync 任务功能已被 Refresh 接口 + 轮询系统完整覆盖,但留了一套完全无效的代码(消费者核心是 time.Sleep,生产者全局无调用点)。

删除清单(顺序执行,防止编译错误):

  1. pkg/queue/handler.go:62-63:删除注册行

    // 删除这两行
    h.mux.HandleFunc(constants.TaskTypeSIMStatusSync, simHandler.HandleSIMStatusSync)
    h.logger.Info("注册 SIM 状态同步任务处理器", ...)
    
  2. pkg/queue/handler.goimport 中,检查 simHandler 引用,如果只有这里用,也删掉 import

  3. pkg/constants/constants.go:删除 TaskTypeSIMStatusSync 常量

  4. internal/task/sim.go:整个文件删除

  5. internal/service/sync/service.go:整个文件删除(检查是否有外部引用,无则直接删)

  6. go build ./... 确认无编译错误


F-3 · 错误不应被吞P2-14

internal/service/asset/service.go:124:273

// 修改前(错误被 _ 忽略)
cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs)

// 修改后(记录日志,不中断主流程)
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
    s.logger.Warn("查询绑定卡信息失败,结果可能不完整",
        zap.Uints("card_ids", cardIDs),
        zap.Error(err))
}

F-4 · C端 Handler 迁移到 Service 层P2-15

问题internal/handler/app/client_order.go:103 直接 h.db.WithContext(resolved.SkipPermissionCtx) 查数据库,绕过了 Service 层。

修改方式

  1. internal/service/client_order/service.go 新增 ListOrders(ctx, req)GetOrderDetail(ctx, orderID) 方法(迁移 Handler 里的 DB 查询逻辑)
  2. client_order.go Handler 改为调用 Service 方法,删除直接 DB 访问和 SkipPermissionCtx

F-5 · Handler/Service 权限不一致P2-16

internal/handler/admin/order.go(创建订单 Handler中补充

if req.PaymentMethod == model.PaymentMethodWallet {
    userType := middleware.GetUserTypeFromContext(c.UserContext())
    if userType != constants.UserTypeAgent {
        return errors.New(errors.CodeInvalidParam, "仅代理账号可使用钱包支付")
    }
}

F-6 · StopResumeCallback / ResumeCallback 注入P2-7

问题SetStopResumeCallback / SetResumeCallback 方法定义了但没有被 bootstrap 调用,停复机后套餐相关回调不触发。

修改位置internal/bootstrap/services.go(或 worker_services.go,找到 StopResumeServiceActivationService 初始化处)

搜索:

grep -rn "NewStopResumeService\|NewActivationService\|ActivationService" internal/bootstrap/ --include="*.go"

在两者初始化完成后,追加:

usageService.SetStopResumeCallback(stopResumeService)
activationService.SetResumeCallback(stopResumeService)

方案 F · 人工验收清单

  • F-1 零额待审佣金记录

    • 工具DBHub + 日志
    • 步骤:人工制造一笔“套餐系列未分配给代理”的订单后,执行:SELECT order_id, shop_id, amount, status, remark FROM tb_commission_record WHERE status=99 ORDER BY id DESC LIMIT 20;
    • 预期结果:出现 status=99amount=0 的待审佣金记录,日志有“佣金链断裂”告警
  • F-2 废弃代码删除

    • 工具:rg + go build ./...
    • 步骤:搜索 TaskTypeSIMStatusSyncHandleSIMStatusSyncinternal/task/sim.gointernal/service/sync/service.go
    • 预期结果:无残留引用,相关文件已删,编译通过
  • F-3 错误不应被吞

    • 工具:rg + 日志
    • 步骤:搜索 cards, _ := 是否已替换;观察查询失败时日志是否出现“查询绑定卡信息失败,结果可能不完整”
    • 预期结果:错误被记录但不误中断主流程
  • F-4 C端 Handler 迁移到 Service

    • 工具:rg + go build ./...
    • 步骤:
      1. 搜索 internal/handler/app/client_order.go,确认不再直接 h.db.WithContext(...)
      2. 搜索 internal/service/client_order/service.go,确认存在 ListOrders / GetOrderDetail
    • 预期结果C 端订单查询重新回到 Handler → Service 分层
  • F-5 Handler/Service 权限一致

    • 工具:rg
    • 步骤:搜索 internal/handler/admin/order.go,确认钱包支付前置校验存在“仅代理账号可使用钱包支付”
    • 预期结果Handler 层和 Service 层权限前置一致,不再出现一个放行一个拒绝
  • F-6 StopResume / Resume 回调注入

    • 工具:rg + 日志
    • 步骤:搜索 SetStopResumeCallbackSetResumeCallback 在 bootstrap 中的调用;观察停复机后相关联动日志
    • 预期结果:停机 / 复机后的套餐联动回调真正被触发
  • 方案 F 收尾检查

    • 工具:go build ./...
    • 预期结果:编译通过

方案 G实名激活架构重构P2-25 + P2-1 + P1-8

业务背景

现有 Package.enable_realname_activation 字段同时混淆了两个独立的业务概念:

  1. "是否需要等实名才激活" → 这应该由卡的类型决定,不是套餐决定

    • industry 卡(行业卡):永远不需要实名,直接激活
    • normal普通卡C 端客户自购前就要求实名;后台囤货则等实名后激活
  2. "到期时间从哪里开始算" → 这才是套餐级配置,需要新增字段

原字段的本来意图:平台/代理"囤货"给资产预购套餐时,套餐激活时间有两种模式:

  • from_activation:等客户实名后激活,激活那一刻开始计时(当前行为)
  • from_purchase:购买即开始计时,实名只是"解锁使用权",不影响计时

修改清单

① DB 迁移

-- 移除旧字段
ALTER TABLE tb_package DROP COLUMN enable_realname_activation;

-- 新增到期时间基准字段
ALTER TABLE tb_package ADD COLUMN expiry_base VARCHAR(30) NOT NULL DEFAULT 'from_activation';
-- 枚举值from_activation激活时起算| from_purchase购买时起算

internal/model/package.go:Package

// 删除
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;..."`

// 新增
ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算"`

internal/service/order/service.go:activateMainPackage(约 1869 行)

改写套餐激活决策:

// 修改前(只看套餐字段)
if pkg.EnableRealnameActivation {
    status = pending
    pendingRealnameActivation = true
} else {
    status = active; activatedAt = now
}

// 修改后(按卡类型 + 购买路径决定)
isCEndPurchase := 购买路径判断可从 order.OrderType  buyerType 推断C端 = "personal"
if card.CardCategory == "industry" {
    // 行业卡永远直接激活
    status = constants.PackageUsageStatusActive
    activatedAt = now
} else if isCEndPurchase {
    // C端客户购买前已检查实名直接激活
    status = constants.PackageUsageStatusActive
    activatedAt = now
} else {
    // 后台囤货(平台/代理)
    if pkg.ExpiryBase == "from_purchase" {
        // 购买即激活,立即开始计时
        status = constants.PackageUsageStatusActive
        activatedAt = now
    } else {
        // from_activation等实名后激活
        status = constants.PackageUsageStatusPending
        pendingRealnameActivation = true
    }
}

internal/service/client_order/service.goC端购买实名检查

// 修改前(依赖套餐字段)
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {

// 修改后(改为按卡类型判断)
if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
    return errors.New(errors.CodeForbidden, "请先完成实名认证")
}
// 删除 packagesNeedRealname() 函数(不再需要)

internal/service/package/activation_service.go:ActivateByRealname(约 117 行)

// 修改前(到期时间总是从当前时刻算)
activatedAt := now
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, ...)

// 修改后(根据 ExpiryBase 选择基准时间)
var activatedAt time.Time
if pkg.ExpiryBase == "from_purchase" {
    activatedAt = packageUsage.CreatedAt  // 用订单/记录创建时间
} else {
    activatedAt = now  // 实名检测到的时刻
}
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, ...)

⑥ 套餐管理 API:创建/更新套餐时,移除 enable_realname_activation 字段,新增 expiry_base 字段(枚举:from_activation | from_purchase,默认 from_activation)。更新 DTO 和接口文档。

方案 G · 人工验收清单

  • 工具DBHub + rg + polling / activation 日志 + go build ./...
  • 步骤:
    1. DBHub 确认 tb_package 已删除 enable_realname_activation、新增 expiry_base
    2. 搜索全仓 EnableRealnameActivation,确认旧字段引用清空;搜索 ExpiryBase,确认至少在 order/service.goclient_order/service.gopackage/activation_service.go 被使用
    3. 抽查普通卡 / 行业卡 / 囤货套餐的 tb_package_usage,核对 statuspending_realname_activationactivated_atexpires_at
    4. 观察首次实名激活日志,确认 from_purchase / from_activation 的到期时间基准已分流
  • 预期结果:实名需求回归卡类型;套餐只负责到期时间基准;旧字段完全退出主流程

方案 H轮询系统小修P1-6 + P2-10 + P2-12 + P2-23


H-1 · polling:protect 接入调度P1-6

问题pkg/constants/constants.go:59 定义了 TaskTypePollingProtect = "polling:protect",处理器 HandleProtectConsistencyCheck 也已注册(pkg/queue/handler.go:registerPollingHandlers),但 Scheduler 从未调度此任务。

修改位置internal/polling/scheduler.go(找到主调度循环,约 243-250 行)

// 当前(缺少 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)

// 修改后(新增 protect 的调度)
s.processManualQueue(ctx, constants.TaskTypePollingProtect)  // 新增
s.processTimedQueue(ctx, constants.RedisPollingQueueProtectKey(), constants.TaskTypePollingProtect, now)  // 新增

同时确认 constants.RedisPollingQueueProtectKey() 函数存在,以及 initCardPolling() 中是否已初始化 protect 队列(如没有则补充)。


H-2 · gatewayClient=nil 停复机拒绝P2-12

问题internal/service/iot_card/stop_resume_service.go:178, 211gatewayClient==nilreturn nil(成功),调用方继续更新本地 DB导致 DB 状态与运营商不一致。

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

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

两处都改:stopCardWithRetryresumeCardWithRetry


H-3 · packages 接口加分页P2-10

internal/service/asset/service.go:GetPackages():新增 page, pageSize 参数,默认 page=1pageSize=50最大 100。对应 DTO 新增 Page int, PageSize int, Total int64 字段。


H-4 · Series 删除前检查关联套餐P2-23

internal/service/package_series/service.go:Delete()

func (s *Service) Delete(ctx context.Context, id uint) error {
    // 新增:检查是否有关联套餐
    count, err := s.packageStore.CountBySeriesID(ctx, id)
    if err != nil {
        return errors.Wrap(errors.CodeDatabaseError, err, "查询关联套餐失败")
    }
    if count > 0 {
        return errors.New(errors.CodeInvalidParam, fmt.Sprintf("该系列下有 %d 个关联套餐,请先处理", count))
    }
    return s.packageSeriesStore.Delete(ctx, id)
}

PackageStore 需要新增 CountBySeriesID(ctx, seriesID uint) (int64, error) 方法。

方案 H · 人工验收清单

  • H-1 protect 调度接入

    • 工具:rg + Worker 日志
    • 步骤:搜索 scheduler.go 是否已调度 TaskTypePollingProtect;观察调度日志是否出现 protect 队列处理
    • 预期结果protect 任务不再只注册不调度
  • H-2 gatewayClient=nil 拒绝停复机

    • 工具:rg + 日志
    • 步骤:搜索 stop_resume_service.go,确认 gatewayClient==nil 时返回业务错误而非 nil
    • 预期结果:未配置 Gateway 时操作失败并报错,本地 DB 不会误更新为成功
  • H-3 packages 接口分页

    • 工具:rg + go build ./...
    • 步骤:搜索 GetPackages() 参数与 DTO确认有 page/pageSize/total
    • 预期结果:套餐列表接口默认分页,单页最大 100
  • H-4 Series 删除前检查

    • 工具DBHub + 日志
    • 步骤:
      1. DBHub 查询某个有套餐关联的系列:SELECT COUNT(*) FROM tb_package WHERE series_id = ?;
      2. 人工触发删除
    • 预期结果:若 count > 0则删除被拒绝并提示“请先处理关联套餐”
  • 方案 H 收尾检查

    • 工具:go build ./...
    • 预期结果:编译通过

方案 I退款完整功能P2-18 升级为完整功能)

业务背景

需要对资产套餐订单发起退款申请。退款不走自动化,由人工填写实收金额和申请退款金额,经审批后线下打款。审批通过后,系统自动从获得过佣金的代理佣金钱包中按比例扣减(允许为负数)。


I-1 · 数据模型

新建 tb_refund_request 表(migrations/ 新迁移文件):

CREATE TABLE tb_refund_request (
    id BIGSERIAL PRIMARY KEY,
    refund_no VARCHAR(50) NOT NULL UNIQUE,
    order_id BIGINT NOT NULL,
    package_usage_id BIGINT,                   -- 关联的套餐使用记录
    applicant_id BIGINT NOT NULL,
    shop_id BIGINT,
    actual_received_amount BIGINT NOT NULL,    -- 实收金额(分,人工填写)
    requested_refund_amount BIGINT NOT NULL,   -- 申请退款金额(分)
    approved_refund_amount BIGINT,             -- 审批后实际退款金额(分)
    refund_reason TEXT NOT NULL,
    status INT NOT NULL DEFAULT 1,             -- 1-待审批 2-已通过 3-已拒绝 4-已退回(可重提)
    processor_id BIGINT,                       -- 审批人
    processed_at TIMESTAMP WITH TIME ZONE,
    commission_deducted BOOLEAN NOT NULL DEFAULT FALSE,
    remark TEXT,
    creator BIGINT NOT NULL,
    updater BIGINT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    deleted_at TIMESTAMP WITH TIME ZONE
);

新建 internal/model/refund.go 定义 GORM Model参考 financial.go 的风格)。


I-2 · 接口设计

新建 Handler + Service + Store参考 commission_withdrawal 的完整实现):

接口 方法 Handler 方法
POST /api/admin/refunds Create 发起退款申请
GET /api/admin/refunds List 列表(分页+按状态筛选)
GET /api/admin/refunds/:id Get 详情
POST /api/admin/refunds/:id/approve Approve 审批通过(可修改金额)
POST /api/admin/refunds/:id/reject Reject 审批拒绝(必填拒绝原因)
POST /api/admin/refunds/:id/return Return 退回申请(申请人可重提)
POST /api/admin/refunds/:id/resubmit Resubmit 重新提交(被退回后)

状态流转:

1(待审批) → 2(已通过)  [Approve]  → 触发佣金回扣
1(待审批) → 3(已拒绝)  [Reject]
1(待审批) → 4(已退回)  [Return]   → 可重提
4(已退回) → 1(待审批)  [Resubmit]

I-3 · 佣金回扣逻辑

refund/service.go:deductCommissionAfterApprove(ctx, refundID uint)

func (s *Service) deductCommissionAfterApprove(ctx context.Context, refundID uint) error {
    refund, _ := s.refundStore.GetByID(ctx, refundID)

    // 查找该订单的所有已结算佣金记录
    commissions, _ := s.commissionRecordStore.ListByOrderID(ctx, refund.OrderID)
    if len(commissions) == 0 {
        return nil // 没有佣金,直接标记
    }

    // 退款比例
    ratio := float64(refund.ApprovedRefundAmount) / float64(refund.ActualReceivedAmount)

    return s.db.Transaction(func(tx *gorm.DB) error {
        for _, commission := range commissions {
            deductAmount := int64(float64(commission.Amount) * ratio)
            if deductAmount == 0 {
                continue
            }

            // 从代理佣金钱包扣减(允许负数,直接 UPDATE不检查余额
            tx.Model(&model.AgentWallet{}).
                Where("shop_id = ? AND wallet_type = 'commission'", commission.ShopID).
                Updates(map[string]any{
                    "balance": gorm.Expr("balance - ?", deductAmount),
                })

            // 写交易流水
            refType := "refund"
            refID := refundID
            tx.Create(&model.AgentWalletTransaction{
                ShopID:          commission.ShopID,
                TransactionType: "commission_deduct",
                Amount:          -deductAmount,
                ReferenceType:   &refType,
                ReferenceID:     &refID,
                Remark:          "退款佣金回扣",
            })
        }

        // 标记退款佣金已回扣
        tx.Model(&model.RefundRequest{}).
            Where("id = ?", refundID).
            Update("commission_deducted", true)

        return nil
    })
}

Approve() 方法在更新 status=2 后异步调用此函数Goroutine失败记日志但不影响审批结果

方案 I · 人工验收清单

  • I-1 数据模型

    • 工具DBHub
    • 步骤:搜索 tb_refund_request 表及其列
    • 预期结果:退款表存在,核心字段(refund_no / order_id / actual_received_amount / requested_refund_amount / approved_refund_amount / commission_deducted)齐全
  • I-2 接口设计

    • 工具:rg + go build ./...
    • 步骤:搜索 refund 的 handler / service / store / route 注册,确认 7 个接口都已落地
    • 预期结果:退款功能具备完整 CRUD + 审批流转能力
  • I-3 佣金回扣逻辑

    • 工具DBHub + 日志
    • 步骤:审批通过一笔退款后,执行:
      • SELECT id, status, approved_refund_amount, commission_deducted FROM tb_refund_request ORDER BY id DESC LIMIT 10;
      • SELECT shop_id, amount, transaction_type, reference_id FROM tb_agent_wallet_transaction WHERE transaction_type='commission_deduct' ORDER BY id DESC LIMIT 20;
    • 预期结果:退款单变为已通过、commission_deducted=true,并产生负向佣金钱包流水
  • 方案 I 收尾检查

    • 工具:go build ./...
    • 预期结果:编译通过

方案 J其他修复项


J-1 · 富友支付实现P1-4 + P1-5

背景pkg/fuiou/ SDK 已完整实现,tb_wechat_config 模型已有富友字段,回调 FuiouPayCallback 已实现。唯一缺失:FuiouPayJSAPI()FuiouPayMiniApp() 是留桩,返回"暂未实现"。

凭证数据(插入 tb_wechat_config 表,provider_type='fuiou'is_active=true

字段
fy_ins_cd 08K0079562
fy_mchnt_cd 0005210F8785904
fy_term_id 88888888
fy_api_url https://fundwx.fuiou.com
fy_notify_url {你们服务器地址}/api/callback/fuiou-pay
fy_private_key MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC5CM5aXbwDQkP8Stvq85lIeOy/kEogjlT1K8nS+ytQ5ipaT2tnj45rCw/+1mCvh+EmnfLNPIcW7lJxQai0IPtKfLR17kBd01nmDaelLZAFHSAjqQc4ksyDHGj8A8Fe96kaPcK0j+he6ei2j6LGrDNkEqATgZKSohdgV5ntG57F6lOEjY+3g8qpLmCSX1dv7jngLk3qT65zf5RWjqbQ/KU7VXo/GapVvEig2dzgI9RmIQ8yOzECBK9kJC3e5hklx98gz5sRkxASa9IH49jotwCXpKX5DiLKohnlP5xFmYJIt4htsiRjRvMVPZJRnlmAYW7bv4NAn6FFNH8L+lw7SG5lAgMBAAECggEBAKTRtEYIYrYga8CqydQyYuKMXI6Sr4TqY8Dz3VYix0XLkARb5BceZ8Tv2LKuMPeKOMMWRLYOaWLCrQsXanfxPQXvqSu3Kvyoi9aBaUiYGkaD2CILqVP6Z1OOlfGOQsweHTIzu2DtIxaQkuszbNI9h5VnhdF6RJ565gm6XnE3filZ+jFk4nvjLbJV7hF/nNpal+QUDfJ1oliIZjiETl0iL6aCP/6Lh/h/YYuMeHMDdE/9VfDSE4GFVyaWiVg8SdVBEW2EAMNOTmO7BD2mCbNsWjkwHcud0LwmPpAFj1muE1n5o/bzoqXpYPVq0eXvtAfF/440/01fqrcI1ip5OG7uSS0CgYEA4u+X8s7MeJ7AnJLHVjXETC0/WlOYhwHrNYz21i7JqNtpAp8WCZNpMpyN+525BPyaZS0dlHJgxsCbq5fNeDjZ9k3bCXFvLiV90kOO2ImwTkDF3+1Suk8EHek47Lyy2VpZDjgXHkCgfSi0glJXeNa8Q05N9Ih7lXxjSA3SvS13FX8CgYEA0Ltp5w1X8BjyGmQfn2i6rVMJQR5OV5bKNISeb/HfgQkQkbSMVStzmA3WtrqR9wtkmXpddJ8kl5O5w8cDJjAKz1aso98SuWag4Si5JZHP3DLtSNwznbqte7Ix4i8NqHquR/gL8Cmj6LJW1h3nzN/JLSFcgGHVhc1RimdtIya/1hsCgYEAreeJe6p6Cp0tYU8hrrD5Qp8SA3g4VI1l392scqncI6gwKrAaxS/P19cc/wr49BdXgd0248Fa5DRJlw93h3+ZmCRFjFD/ME/Owci/uLSbBPyiJl3JnbhboUhONSzNqb6QrFLTdH11/zOoUI4lNhboonNpTdEhU4bE1jyxmAM1VKUCgYEAjGE+/DGxLrzYNn+X9PHOersZwj3LmoTDQUbf95HIK1QZXKT8rFsoxt6nxQT9HhT/d2kgaUqOpZKooM67g3dUDdXRDfT89sva7xMgUfAax5FInHPcEvx1qHdTrTbQDLtVcvmTrdWTcvBeDmrWdqca+csyFvW1UOOhL2AXukhZRHkCgYBsRh01f7TE1c77RJ7QxBEZbyA1A3gcvCSQITJDmpM6NaREg3+Uh7vfJh1yY6SNvyCLvabFQMKw6BluWXs1f7LUVNE79r/0w/acjbCpOzHxEJQKS5RYmY/mzpU2AA/55vJmtxaB8dAFCl68qnXv9r144d0WSOtyNbFZbYJDbKf2QQ==
fy_public_key MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuQjOWl28A0JD/Erb6vOZSHjsv5BKII5U9SvJ0vsrUOYqWk9rZ4+OawsP/tZgr4fhJp3yzTyHFu5ScUGotCD7Sny0de5AXdNZ5g2npS2QBR0gI6kHOJLMgxxo/APBXvepGj3CtI/oXunoto+ixqwzZBKgE4GSkqIXYFeZ7RuexepThI2Pt4PKqS5gkl9Xb+454C5N6k+uc3+UVo6m0PylO1V6PxmqVbxIoNnc4CPUZiEPMjsxAgSvZCQt3uYZJcffIM+bEZMQEmvSB+PY6LcAl6Sl+Q4iyqIZ5T+cRZmCSLeIbbIkY0bzFT2SUZ5ZgGFu27+DQJ+hRTR/C/pcO0huZQIDAQAB

修改清单

internal/service/order/service.go:FuiouPayJSAPI(第 2355 行,替换留桩)

func (s *Service) FuiouPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.FuiouPayJSAPIResponse, error) {
    // 1. 查询并校验订单
    order, err := s.orderStore.GetByID(ctx, orderID)
    if err != nil {
        return nil, errors.New(errors.CodeNotFound, "订单不存在")
    }
    if order.BuyerType != buyerType || order.BuyerID != buyerID {
        return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
    }
    if order.PaymentStatus != model.PaymentStatusPending {
        return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
    }

    // 2. 加载激活的富友配置
    config, err := s.wechatConfigStore.GetActive(ctx)  // GetActive 已存在,只取 is_active=true 的那条
    if err != nil || config.ProviderType != model.ProviderTypeFuiou {
        return nil, errors.New(errors.CodeFuiouPayFailed, "富友支付未配置或未激活")
    }

    // 3. 构造 fuiou.Client
    client, err := fuiou.NewClient(
        config.FyInsCd, config.FyMchntCd, config.FyTermID,
        config.FyAPIURL, config.FyNotifyURL,
        config.FyPrivateKey, config.FyPublicKey,
        s.logger,
    )
    if err != nil {
        return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "初始化富友客户端失败")
    }

    // 4. 获取商品描述
    items, _ := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
    goodsDesc := "套餐购买"
    if len(items) > 0 && items[0] != nil {
        goodsDesc = items[0].PackageName
    }

    // 5. 调用富友预下单
    // subAppid = 公众号 AppIDconfig.OaAppID
    // subOpenid = 用户在公众号下的 openID入参 openID
    termIP := middleware.GetIPFromContext(ctx)
    if termIP == "" {
        termIP = "127.0.0.1"
    }

    resp, err := client.WxPreCreate(
        order.OrderNo,
        strconv.FormatInt(order.TotalAmount, 10), // 单位:分
        goodsDesc,
        termIP,
        "JSAPI",         // 公众号支付
        config.OaAppID,  // 公众号 AppID
        openID,
    )
    if err != nil {
        s.logger.Error("富友 JSAPI 预下单失败",
            zap.Uint("order_id", orderID),
            zap.String("order_no", order.OrderNo),
            zap.Error(err))
        return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
    }

    // 6. 返回前端调起支付所需参数
    return &dto.FuiouPayJSAPIResponse{
        AppID:     resp.SdkAppid,
        TimeStamp: resp.SdkTimestamp,
        NonceStr:  resp.SdkNoncestr,
        Package:   "prepay_id=" + resp.SdkPrepayid,
        SignType:  resp.SdkSigntype,
        PaySign:   resp.SdkPaysign,
    }, nil
}

internal/service/order/service.go:FuiouPayMiniApp(第 2361 行,替换留桩)

与 FuiouPayJSAPI 完全相同,两处不同:

  • tradeType"LETPAY"(小程序支付)
  • subAppidconfig.MiniappAppID(小程序 AppID

③ 新增 DTO FuiouPayJSAPIResponseinternal/model/dto/

type FuiouPayJSAPIResponse struct {
    AppID     string `json:"appId"     description:"应用ID"`
    TimeStamp string `json:"timeStamp" description:"时间戳"`
    NonceStr  string `json:"nonceStr"  description:"随机字符串"`
    Package   string `json:"package"   description:"prepay_id=xxx"`
    SignType  string `json:"signType"  description:"签名类型"`
    PaySign   string `json:"paySign"   description:"支付签名"`
}
// 注:字段名与微信官方 wx.requestPayment() 参数一致,前端无需区分富友/微信

④ 回调链路(已实现,无需修改)

POST /api/callback/fuiou-payFuiouPayCallback() → 验签 → 解析订单号前缀 → 路由到对应 HandlePaymentCallback() → 更新支付状态 → 触发套餐激活/佣金


J-2 · 平台钱包代购P1-7

业务背景:平台操作人员可以用某代理的钱包,以该代理的成本价,给该代理名下的资产购买套餐。当前 Service 层拒绝了平台使用钱包支付(要求 buyerType == agent)。

修改位置internal/service/order/service.go:CreateLegacy(约 195 行)

// 修改前(直接拒绝非代理的钱包支付)
} else if req.PaymentMethod == model.PaymentMethodWallet {
    if buyerType != model.BuyerTypeAgent {
        return nil, errors.New(errors.CodeInvalidParam, "只有代理账号可以使用钱包支付")
    }
    ...

// 修改后平台用代理钱包代购buyerType="" 但指定了 resourceShopID
} else if req.PaymentMethod == model.PaymentMethodWallet {
    if buyerType == model.BuyerTypeAgent {
        // 代理用自己钱包,正常逻辑
        ...
    } else if buyerType == "" && resourceShopID != nil {
        // 平台用代理钱包代购:钱包来自资产所属代理
        orderBuyerType = model.BuyerTypeAgent
        // 成本价、钱包都使用 *resourceShopID 对应的代理
        costPrice, _ := s.getCostPrice(ctx, *resourceShopID, firstPackageID)
        totalAmount = costPrice
        actualPaidAmount = &costPrice
        // 钱包扣款指向 *resourceShopID 的代理钱包
    } else {
        return nil, errors.New(errors.CodeInvalidParam, "不支持的钱包支付场景")
    }

J-3 · status 字段 3/4 业务触发P2-3

业务含义澄清status 字段(在 iot_carddevice 表上)跟踪客户使用状态:

  • 1 = 在库(未分配)
  • 2 = 已分销(分配给代理,等待客户)
  • 3 = 已激活(有客户在使用,有 active 套餐)
  • 4 = 已停用(套餐全部到期,无续套餐)

asset_status 是另一个维度(业务生命周期:在库/已销售/已换货/已停用),两者不合并。

需要补充的触发逻辑

  • → 3已激活:在 PackageActivationHandler 激活套餐成功后,如果 card.status < 3,更新为 3
  • → 4已停用:在套餐到期处理逻辑中(OrderExpireHandler 或类似),当某资产最后一个 active 套餐过期时,更新为 4

换货流程不影响:换货代码 exchange/service.go 只操作 asset_status,不碰 status 字段。


J-4 · payment_status 枚举统一P2-17

现状:管理端 payment_status 是 1/2/3/4pending/paid/cancelled/refundedC 端通过 orderStatusToClientStatus() 映射成 0/1/2。

修改清单

  1. internal/service/client_order/service.go:652-659:删除 orderStatusToClientStatus() 函数
  2. C 端 DTO ClientOrderResponse.PaymentStatus:直接用 int值与管理端一致1/2/3/4
  3. 通知前端C 端解析状态枚举从 0/1/2 改为 1/2/34=退款,后续退款功能实现后前端处理)

方案 J · 人工验收清单

  • J-1 富友支付实现

    • 工具DBHub + 日志 + rg + go build ./...
    • 步骤:
      1. DBHub 查询 tb_wechat_config,确认存在 provider_type='fuiou' AND is_active=true 配置
      2. 搜索 FuiouPayJSAPI / FuiouPayMiniApp,确认不再返回“暂未实现”
      3. 联调一次预下单 / 回调,观察日志关键字“富友 JSAPI 预下单失败 / 成功”“FuiouPayCallback”
    • 预期结果:富友支付可正常走预下单 + 回调链路
  • J-2 平台钱包代购

    • 工具:rg + DBHub
    • 步骤:
      1. 搜索 CreateLegacy 钱包支付分支,确认支持 buyerType=="" && resourceShopID != nil
      2. DBHub 抽查一笔平台代代理钱包订单,核对 buyer_type / total_amount / actual_paid_amount / seller_shop_id
    • 预期结果:平台可用代理钱包代购,金额口径与代理成本价体系一致
  • J-3 status 3/4 业务触发

    • 工具DBHub + 日志
    • 步骤:
      1. 激活一笔套餐后,查询 tb_iot_card.status / tb_device.status
      2. 最后一个 active 套餐到期后,再次查询状态
    • 预期结果:激活后状态进入 3最后套餐过期后进入 4换货流程不误改该字段
  • J-4 payment_status 枚举统一

    • 工具:rg + go build ./...
    • 步骤:
      1. 搜索 orderStatusToClientStatus(),确认已删除
      2. 搜索 ClientOrderResponse.PaymentStatus,确认直接输出管理端同款整型枚举
    • 预期结果C 端 / 管理端支付状态枚举语义统一
  • 方案 J 收尾检查

    • 工具:go build ./...
    • 预期结果:编译通过

附:取消 / 不做的项目

条目 原因
P1-2 企业认证中间件 复用 AdminAuth不需要新中间件
P1-3 提现"已到账"接口 无法感知,不需要实现
P1-9 设备定期轮询 伪需求改为按需查询D-3
P2-6 告警通知渠道 无关紧要,暂不做
P2-9 asset_type 校验 误判Service 层已有校验兜底
P2-24 Carrier→Series 层级 系列可用于任意运营商的卡,不需要强绑定
P2-30 轮询配置通用化 设备轮询取消后,此问题不复存在
P2-5 废弃字段写入 有意的向后兼容,不清理

附:优先级排序(最终)

立即1-2天
  A-1 常量统一      ← 所有 A 系修复的前置
  A-2 实名校验      ← 依赖 A-1
  A-3 实名激活断链
  A-4 自动购包触发+佣金
  A-5 代购金额
  A-6 设备导入IMEI
  A-7 提现并发校验

近期3-5天
  B   企业端权限
  C   提现/佣金流程P2-19/20/21/22
  G   实名激活重构(破坏性变更,单独提案)
  H   轮询小修P1-6 / P2-10 / P2-12 / P2-23
  J-4 payment_status 枚举统一

中期1-2周
  D   设备体系D0→D1→D2→D3
  I   退款完整功能
  J-1 富友支付凭证已有SDK 已有,写两个方法即可)
  J-2 平台钱包代购
  J-3 status 3/4 触发逻辑
  F   代码清理(废弃代码删除、错误处理补全等)

长期/谨慎(需低峰期发布)
  E   流量体系改革DB 迁移 + Redis 架构)