2131 lines
86 KiB
Markdown
2131 lines
86 KiB
Markdown
# 修正业务 · 完整修复方案 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 级),有佣金钱包、成本价体系
|
||
- **个人客户(PersonalCustomer)**:C 端用户,有资产钱包(跟着卡/设备走,转手后钱包一起转)
|
||
- **企业账号**:B 端大客户,被分配卡/设备,可管控但不能购买套餐
|
||
|
||
**关键路径**:
|
||
```
|
||
代理/平台 → 采购 IoT 卡 + 设备 → 分配给下级代理/企业 → C端客户购买套餐 → 充值/激活/使用
|
||
↓
|
||
佣金链:逐级向上分差额(成本价之差)
|
||
```
|
||
|
||
## 全文统一验收约定(适用于所有方案)
|
||
|
||
> 本文档**不要求新增自动化测试代码**,默认采用:`rg/grep`、`go build ./...`、DBHub、Worker/API 日志四类手段做人工验收。
|
||
|
||
**统一工具**:
|
||
- **代码结构核对**:`rg` / `grep`
|
||
- **编译验收**:`go build ./...`
|
||
- **表结构 / 数据核对**:DBHub(`search_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)
|
||
|
||
```go
|
||
// 修改前
|
||
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:170`**(isFirstRealname 判断,改用常量)
|
||
|
||
```go
|
||
// 修改前
|
||
isFirstRealname := (card.RealNameStatus == 0 || card.RealNameStatus == 1) && newRealnameStatus == 2
|
||
|
||
// 修改后
|
||
isFirstRealname := card.RealNameStatus != constants.RealNameStatusVerified &&
|
||
newRealnameStatus == constants.RealNameStatusVerified
|
||
```
|
||
|
||
**③ `internal/model/iot_card.go:30`**(Model 注释)
|
||
|
||
```go
|
||
// 修改前
|
||
RealNameStatus int `gorm:"...; comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)"`
|
||
|
||
// 修改后(注释统一)
|
||
RealNameStatus int `gorm:"...; comment:实名状态 0-未实名 1-已实名"`
|
||
```
|
||
|
||
**④ 全局搜索并修正** DTO 注释(不影响运行,但影响文档):
|
||
|
||
```bash
|
||
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 修完后自动正确):
|
||
|
||
```go
|
||
// 当前错误(硬编码 1,但 DB 里实际是 2,A-1 修完前暂时改这里)
|
||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 {
|
||
return errors.New(errors.CodeForbidden, "请先完成实名认证")
|
||
}
|
||
```
|
||
|
||
**修改方式**
|
||
|
||
```go
|
||
// 修改后(使用常量,A-1 先修好后此行自动正确)
|
||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||
return errors.New(errors.CodeForbidden, "请先完成实名认证")
|
||
}
|
||
```
|
||
|
||
同时检查 `internal/task/polling_handler.go:982`:
|
||
```go
|
||
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`(当前状态):
|
||
|
||
```go
|
||
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.go`:PollingHandler struct 新增字段**
|
||
|
||
```go
|
||
// 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 行)**
|
||
|
||
```go
|
||
// 修改前
|
||
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 行)**
|
||
|
||
```go
|
||
// 修改前
|
||
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 行附近)**
|
||
|
||
```go
|
||
// 修改前(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-3**:`recharge/service.go:HandlePaymentCallback` 充值回调成功后,完全没有触发自动购包的代码。`task.NewAutoPurchaseTask` 函数全项目只有定义,**零调用点**。
|
||
- **P0-4**:`AutoPurchaseHandler.ProcessTask()` 创建订单并激活套餐后,没有调用 `enqueueCommissionCalculation()`,自动购包产生的订单不计佣金。
|
||
|
||
**涉及的关键事实**
|
||
|
||
- `AssetRechargeRecord.LinkedPackageIDs` 字段:`internal/model/asset_wallet.go:91`(JSONB,存放 package IDs 数组)
|
||
- `recharge/service.go` 当前没有 `queueClient` 依赖,需要新增
|
||
- `pkg/queue/Client` 有 `EnqueueTask(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:68`),`task.NewAutoPurchaseTask()` 也已使用该常量,无需新增第二个自动购包任务类型
|
||
- `AutoPurchaseHandler` 目前没有在 `pkg/queue/handler.go` 里注册,需要新增
|
||
|
||
**调用链路(修正版)**
|
||
|
||
```text
|
||
充值支付回调成功
|
||
│
|
||
▼
|
||
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.go`:Service struct 新增 queueClient**
|
||
|
||
```go
|
||
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("充值支付回调处理成功"...)` 之前,添加:
|
||
|
||
```go
|
||
// 充值成功后,如有关联套餐则触发自动购包(强充绑套餐功能)
|
||
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`:复用现有任务类型常量,不新增第二套名字**
|
||
|
||
```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 struct` 和 `NewAutoPurchaseHandler(...)` 需要新增 `asynqClient *asynq.Client` 字段 / 参数,供自动购包完成后继续提交佣金任务:
|
||
|
||
```go
|
||
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.go` 或 `worker_services.go` 中找到 `recharge.New(...)` 的调用位置,补充 `queueClient` 参数传入。
|
||
|
||
**修改清单(P0-4)**
|
||
|
||
**P0-4 目标**:AutoPurchaseHandler 在订单创建 + 套餐激活成功后,把 `order_id` 投递到佣金计算队列,确保自动购包订单与普通订单一样进入佣金链路。
|
||
|
||
**`internal/task/auto_purchase.go`:ProcessTask 事务提交成功后触发佣金**
|
||
|
||
这里要明确分层:
|
||
- `recharge.Service` 属于 Service 层,用 `*queue.Client`
|
||
- `AutoPurchaseHandler` 属于 Worker 消费者,直接持有并使用 `*asynq.Client`
|
||
|
||
不要在同一段代码里写成 `if h.asynqClient != nil { h.queueClient.EnqueueTask(...) }`,这会把两套依赖混在一起。
|
||
|
||
建议写法:在 `ProcessTask()` 里先声明 `var createdOrderID uint`,事务内创建订单后赋值,事务成功返回后再入队佣金任务:
|
||
|
||
```go
|
||
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 查询顺序图**
|
||
|
||
```text
|
||
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 分支,代理代购下级):
|
||
|
||
```go
|
||
// 当前代码
|
||
buyerCostPrice, _ := s.getCostPrice(ctx, *resourceShopID, firstPackageID) // 下级成本价
|
||
operatorCostPrice, _ := s.getCostPrice(ctx, operatorShopID, firstPackageID) // 自己成本价
|
||
|
||
totalAmount = buyerCostPrice // 下级成本价作为面值(正确)
|
||
actualPaidAmount = &operatorCostPrice // 实际扣自己钱包(正确)
|
||
sellerCostPrice = buyerCostPrice // ← ❌ 应是 operatorCostPrice
|
||
```
|
||
|
||
`sellerCostPrice` 用于佣金差额计算基准。错误地使用下级成本价,导致整个佣金链的差额计算为 0,更上级代理无法获得应有的佣金。
|
||
|
||
**修改方式**
|
||
|
||
```go
|
||
sellerCostPrice = operatorCostPrice // ✅ 改为自己的成本价
|
||
```
|
||
|
||
同时检查同文件里是否有相同模式的第二处(搜索 `sellerCostPrice = buyerCostPrice`,共修复所有出现位置)。
|
||
|
||
---
|
||
|
||
### A-6 · 设备导入补充 IMEI 字段(P0-6)
|
||
|
||
**业务背景**
|
||
|
||
设备(Device)Model 有 `imei` 字段(`internal/model/device.go:19`),用于调用 Gateway API。但设备批量导入(Excel)的解析结构 `DeviceRow` 没有 IMEI,导入后 IMEI 始终为空,无法通过 IMEI 调 Gateway。
|
||
|
||
**修改清单**
|
||
|
||
**① `pkg/utils/excel.go:35-43`(DeviceRow struct)**
|
||
|
||
```go
|
||
// 修改前
|
||
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.go`(Excel 列名映射处)**
|
||
|
||
找到 `ParseDeviceExcel` 中处理表头映射的代码(大约在 290-320 行),在已有的列名 map 中新增:
|
||
|
||
```go
|
||
case "imei", "IMEI", "设备IMEI", "IMEI号":
|
||
row.IMEI = cellValue
|
||
```
|
||
|
||
**③ `internal/task/device_import.go`(processBatch 函数,约 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`
|
||
|
||
```go
|
||
// 修改前(只检查 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 2`、`newRealnameStatus == 2`、`2=已实名` 是否仍残留在实名链路相关文件
|
||
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_amount`、`actual_paid_amount` 与上级佣金金额是否不再全为 0
|
||
- 预期结果:代购下级场景里,订单面值仍是下级成本价,但佣金链能继续向上计算,不再整链归零
|
||
|
||
- **A-6 设备导入补充 IMEI 字段**
|
||
- 工具:`rg` + DBHub + 导入日志
|
||
- 步骤:
|
||
1. 搜索 `DeviceRow`、`ParseDeviceExcel`、`device_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 行)**
|
||
|
||
```go
|
||
// 移除错误的企业拦截(企业应该能查询被授权的资产)
|
||
// 删除以下代码:
|
||
if userType == constants.UserTypeEnterprise {
|
||
return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
|
||
}
|
||
```
|
||
|
||
**② `internal/handler/admin/asset.go:Refresh`(第 78 行附近)**
|
||
|
||
在 `Refresh` 方法开头新增企业拦截:
|
||
|
||
```go
|
||
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.go`(`Tag` / `ResourceTag` 模型定义)
|
||
- 当前上层调用点:`internal/service/exchange/migration.go:copyResourceTagsWithTx()`
|
||
|
||
如果企业资产详情 / 查询接口后续需要返回标签,优先修改 `resource_tag_store.go:ListByResource()` 这一层,确保企业账号读取资源标签时不会越权。
|
||
|
||
建议搜索:
|
||
|
||
```bash
|
||
grep -rn "ResourceTagStore\|ListByResource\|enterprise_id\|shop_id" internal/store/postgres internal/service --include="*.go"
|
||
```
|
||
|
||
建议补法:
|
||
|
||
```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` 定义了 `ApprovedBy` 和 `ApprovedAt`,但 `commission_withdrawal/service.go:Approve()` 只写了 `processor_id` 和 `processed_at`,`approved_by`/`approved_at` 永远为空。
|
||
|
||
**修改位置**:`internal/service/commission_withdrawal/service.go:Approve()` 中 updates map,补充:
|
||
|
||
```go
|
||
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 后补充:
|
||
|
||
```go
|
||
if err := validator.Validate(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam)
|
||
}
|
||
```
|
||
|
||
(参考同文件或其他 Handler 的 Validate 调用方式)
|
||
|
||
---
|
||
|
||
### C-3 · 激活配置并发保护(P2-21 + P2-22)
|
||
|
||
**问题**:`commission_withdrawal_setting/service.go` 和 `wechat_config_store.go` 激活新配置时,是两步 UPDATE(先全部置 false,再置目标为 true),无锁保护,并发可产生两条 active=true 的记录。
|
||
|
||
**修改方式**:在事务内,第一步 UPDATE 前加 `FOR UPDATE` 锁:
|
||
|
||
```go
|
||
// 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(¤t) // 不管 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`
|
||
- URL:`POST /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 同步时间 |
|
||
|
||
只实时返回(不存 DB):`rssi`, `battery_level`, `ssid`, `wifi_enabled`, `rsrp`, `sinr`, `run_time`, `connect_time` 等
|
||
|
||
**修改清单**
|
||
|
||
**① DB 迁移(`migrations/` 目录新建迁移文件)**
|
||
|
||
```sql
|
||
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:102` 有 `CurrentIccid` 字段但注释写着"当前 Gateway 同步接口尚未对接,预留结构待后续填充"。
|
||
|
||
**修改清单**
|
||
|
||
**① `internal/gateway/device.go`**(当前只有 `GetDeviceInfo()` 和 `GetSlotInfo()`):
|
||
|
||
新增方法:
|
||
|
||
```go
|
||
// 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_current(P2-28)
|
||
|
||
**业务背景**:设备绑定多张卡,需要知道"当前设备用哪张卡在传输数据"。现在用 `slot_position == 1` 猜测,不准。
|
||
|
||
**修改清单**
|
||
|
||
**① DB 迁移**:
|
||
```sql
|
||
ALTER TABLE tb_device_sim_binding ADD COLUMN is_current BOOLEAN NOT NULL DEFAULT FALSE;
|
||
```
|
||
|
||
**② `internal/model/device_sim_binding.go`**:新增字段:
|
||
```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.go` 的 `BoundCardInfo` 中新增 `IsCurrent bool`。
|
||
|
||
---
|
||
|
||
### D-3 · 设备 Refresh + 详情接入 sync-info(P2-29)
|
||
|
||
**当前问题**:`POST /assets/device/:id/refresh` 只遍历绑定卡调卡网关,`gateway/device.go` 的 `SyncDeviceInfo` 方法(D-1 新增的)没有被使用。
|
||
|
||
**修改位置**:`internal/service/asset/service.go:RefreshDevice()` 分支(约 307-339 行)
|
||
|
||
在刷新卡数据之后,追加设备自身的 sync-info 调用:
|
||
|
||
```go
|
||
// 刷新设备自身信息(在线状态、当前卡、固件版本等)
|
||
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_current`(D-2 逻辑)
|
||
|
||
同样,查看设备详情接口(`GetDeviceDetail` 或 `RealtimeStatus`)中,如果调用了 `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.go` 和 `internal/model/dto/device_dto.go`,确认模型 / DTO 已同步
|
||
- 预期结果:库表、模型、DTO 三处字段一致
|
||
|
||
- **D-1 sync-info 接口对接**
|
||
- 工具:`rg` + `go build ./...`
|
||
- 步骤:搜索 `SyncDeviceInfoReq`、`SyncDeviceInfoResp`、`SyncDeviceInfo(` 是否已在 `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_device` 与 `tb_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 迁移**:
|
||
|
||
```sql
|
||
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` 新增):
|
||
|
||
```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`):
|
||
|
||
```go
|
||
// 修改前:无条件写 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 迁移**:
|
||
|
||
```sql
|
||
-- 运营商表新增重置日
|
||
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`):
|
||
|
||
```go
|
||
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()` 查旧表,需适配。
|
||
|
||
**新建 `TrafficQueryService`**(`internal/service/traffic/query_service.go`):
|
||
|
||
```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. 搜索 `RedisCardDailyTrafficKey` 与 `TaskTypeDailyTrafficFlush`
|
||
3. 观察日落盘任务日志,确认执行了 flush / upsert
|
||
- 预期结果:今日增量先缓存在 Redis,历史日流量落到 `tb_card_daily_usage`;不再无差别写 0 增量详单
|
||
|
||
- **E-2 流量增量累加**
|
||
- 工具:DBHub + `rg` + polling 日志
|
||
- 步骤:
|
||
1. DBHub 确认新增 `tb_carrier.data_reset_day`、`tb_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. 搜索 `TrafficQueryService`、`GetDailyUsage`
|
||
2. 搜索 `GetRealtimeStatus` / C 端流量详情是否改为依赖新查询服务
|
||
3. DBHub 抽查历史日数据是否可查
|
||
- 预期结果:查询层能同时兼容“今日 Redis + 历史 DB”两段数据
|
||
|
||
- **方案 E 收尾检查**
|
||
- 工具:`go build ./...`
|
||
- 预期结果:编译通过
|
||
|
||
---
|
||
|
||
## 方案 F:代码质量清理
|
||
|
||
---
|
||
|
||
### F-1 · 佣金链断裂改为零额待审记录(P2-4)
|
||
|
||
**业务规则**:一个代理卖出资产后,套餐系列未分配给该代理(无佣金配置),不应静默跳过,而是创建金额=0、状态=待人工修正的佣金记录,让平台管理员能发现并手动处理。
|
||
|
||
**修改清单**
|
||
|
||
**① `pkg/constants/` 中新增佣金状态常量**:
|
||
|
||
```go
|
||
CommissionStatusPendingReview = 99 // 待人工修正(链路断裂,需平台处理)
|
||
```
|
||
|
||
**② `internal/service/commission_calculation/service.go`** 断链处(`break` 之前):
|
||
|
||
```go
|
||
// 原来:静默 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`:删除注册行
|
||
```go
|
||
// 删除这两行
|
||
h.mux.HandleFunc(constants.TaskTypeSIMStatusSync, simHandler.HandleSIMStatusSync)
|
||
h.logger.Info("注册 SIM 状态同步任务处理器", ...)
|
||
```
|
||
|
||
2. `pkg/queue/handler.go` 的 `import` 中,检查 `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`:
|
||
|
||
```go
|
||
// 修改前(错误被 _ 忽略)
|
||
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)中补充:
|
||
|
||
```go
|
||
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`,找到 `StopResumeService` 和 `ActivationService` 初始化处)
|
||
|
||
搜索:
|
||
```bash
|
||
grep -rn "NewStopResumeService\|NewActivationService\|ActivationService" internal/bootstrap/ --include="*.go"
|
||
```
|
||
|
||
在两者初始化完成后,追加:
|
||
|
||
```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=99`、`amount=0` 的待审佣金记录,日志有“佣金链断裂”告警
|
||
|
||
- **F-2 废弃代码删除**
|
||
- 工具:`rg` + `go build ./...`
|
||
- 步骤:搜索 `TaskTypeSIMStatusSync`、`HandleSIMStatusSync`、`internal/task/sim.go`、`internal/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` + 日志
|
||
- 步骤:搜索 `SetStopResumeCallback`、`SetResumeCallback` 在 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 迁移**:
|
||
|
||
```sql
|
||
-- 移除旧字段
|
||
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`**:
|
||
|
||
```go
|
||
// 删除
|
||
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 行)**
|
||
|
||
改写套餐激活决策:
|
||
|
||
```go
|
||
// 修改前(只看套餐字段)
|
||
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.go`**(C端购买实名检查)
|
||
|
||
```go
|
||
// 修改前(依赖套餐字段)
|
||
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 行)**
|
||
|
||
```go
|
||
// 修改前(到期时间总是从当前时刻算)
|
||
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.go`、`client_order/service.go`、`package/activation_service.go` 被使用
|
||
3. 抽查普通卡 / 行业卡 / 囤货套餐的 `tb_package_usage`,核对 `status`、`pending_realname_activation`、`activated_at`、`expires_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 行)
|
||
|
||
```go
|
||
// 当前(缺少 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, 211`,`gatewayClient==nil` 时 `return nil`(成功),调用方继续更新本地 DB,导致 DB 状态与运营商不一致。
|
||
|
||
```go
|
||
// 修改前
|
||
if s.gatewayClient == nil {
|
||
s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口", ...)
|
||
return nil // ← 假装成功
|
||
|
||
// 修改后
|
||
if s.gatewayClient == nil {
|
||
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||
```
|
||
|
||
两处都改:`stopCardWithRetry` 和 `resumeCardWithRetry`。
|
||
|
||
---
|
||
|
||
### H-3 · packages 接口加分页(P2-10)
|
||
|
||
`internal/service/asset/service.go:GetPackages()`:新增 `page, pageSize` 参数,默认 page=1,pageSize=50,最大 100。对应 DTO 新增 `Page int`, `PageSize int`, `Total int64` 字段。
|
||
|
||
---
|
||
|
||
### H-4 · Series 删除前检查关联套餐(P2-23)
|
||
|
||
`internal/service/package_series/service.go:Delete()`:
|
||
|
||
```go
|
||
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/` 新迁移文件):
|
||
|
||
```sql
|
||
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)`:
|
||
|
||
```go
|
||
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 行,替换留桩)**
|
||
|
||
```go
|
||
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 = 公众号 AppID(config.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"`(小程序支付)
|
||
- `subAppid` 传 `config.MiniappAppID`(小程序 AppID)
|
||
|
||
**③ 新增 DTO `FuiouPayJSAPIResponse`**(`internal/model/dto/`):
|
||
|
||
```go
|
||
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-pay` → `FuiouPayCallback()` → 验签 → 解析订单号前缀 → 路由到对应 `HandlePaymentCallback()` → 更新支付状态 → 触发套餐激活/佣金
|
||
|
||
---
|
||
|
||
### J-2 · 平台钱包代购(P1-7)
|
||
|
||
**业务背景**:平台操作人员可以用某代理的钱包,以该代理的成本价,给该代理名下的资产购买套餐。当前 Service 层拒绝了平台使用钱包支付(要求 `buyerType == agent`)。
|
||
|
||
**修改位置**:`internal/service/order/service.go:CreateLegacy`(约 195 行)
|
||
|
||
```go
|
||
// 修改前(直接拒绝非代理的钱包支付)
|
||
} 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_card` 和 `device` 表上)跟踪客户使用状态:
|
||
- 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/4(pending/paid/cancelled/refunded),C 端通过 `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/3`(4=退款,后续退款功能实现后前端处理)
|
||
|
||
### 方案 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 架构)
|
||
```
|