Compare commits
2 Commits
062f5a436f
...
5c2ef97c24
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c2ef97c24 | |||
| 2885b503b3 |
126
.scratch/customer-binding-architecture/PRD.md
Normal file
126
.scratch/customer-binding-architecture/PRD.md
Normal file
@@ -0,0 +1,126 @@
|
||||
Status: ready-for-agent
|
||||
|
||||
# PRD:客户绑定体系重构与资产归属验证统一
|
||||
|
||||
## Problem Statement
|
||||
|
||||
C 端用户在使用没有虚拟号的 IoT 卡时,整个业务流程完全失效:登录后无法实名认证、无法购买套餐、无法操作设备。根本原因是系统的客户绑定机制依赖虚拟号(`virtual_no`)作为唯一标识,而虚拟号在 IoT 卡上是可选字段,线上有 12,000+ 张无虚拟号的卡且确认会流向 C 端用户。
|
||||
|
||||
与此同时,换货流程在旧卡有虚拟号、新卡无虚拟号时会被错误拦截,即使旧卡实际上没有任何客户绑定记录,换货依然报错"新资产无法承接客户绑定"。
|
||||
|
||||
从设计层面看,`tb_personal_customer_iccid` 表(按 ICCID 绑定)早已建好,Store 层也实现完整,但从未被任何 Service 或 Handler 接入。同时,"判断某张卡/设备是否属于某个客户"这个核心安全规则,散落在 6 个文件中以 4 种不同方式实现,部分实现缺少对已禁用绑定记录的过滤,存在安全缺口。
|
||||
|
||||
## Solution
|
||||
|
||||
建立统一的 **CustomerBinding 模块**,封装客户与资产之间的绑定创建和归属验证逻辑,屏蔽"有虚拟号走 `tb_personal_customer_device` / 无虚拟号走 `tb_personal_customer_iccid`"的路由细节。所有调用方只需传入资产信息,不感知底层用了哪张表。
|
||||
|
||||
在此基础上,修复换货服务中校验与执行逻辑混淆的 bug,并将资产解析和 IoT 卡状态字段整理为后续阶段任务。
|
||||
|
||||
## User Stories
|
||||
|
||||
### C 端用户(无虚拟号的卡)
|
||||
|
||||
1. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在首次登录时成功绑定我的卡,以便后续能正常使用所有 C 端功能。
|
||||
2. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在绑定卡后能正常提交实名认证,以便激活卡的完整功能。
|
||||
3. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想购买流量套餐,以便为我的卡充值续费。
|
||||
4. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在换货后仍能访问新卡并正常操作,以便换货不影响我的使用体验。
|
||||
|
||||
### C 端用户(有虚拟号的卡)
|
||||
|
||||
5. 作为一个持有有虚拟号 IoT 卡的 C 端用户,我的所有现有功能不受本次改动影响,以便升级对我透明无感知。
|
||||
6. 作为一个持有有虚拟号 IoT 卡的 C 端用户,当我的绑定关系被禁用后,我不能通过该绑定关系访问资产,以便系统安全边界得到保障。
|
||||
|
||||
### 后台运营人员(换货)
|
||||
|
||||
7. 作为后台运营人员,我想将一张有虚拟号的旧卡换货为一张无虚拟号的新卡,并且换货能正常完成,以便不再因无虚拟号而被系统错误拦截。
|
||||
8. 作为后台运营人员,当旧卡有客户绑定记录、新卡无虚拟号时,我希望系统在换货完成后自动将客户绑定迁移到新卡的 ICCID,以便客户换货后仍能访问新卡。
|
||||
9. 作为后台运营人员,当旧卡没有任何客户绑定记录时,无论旧卡是否有虚拟号,换货都应该正常完成,以便换货流程不被无意义的条件拦截。
|
||||
|
||||
### 系统(数据一致性)
|
||||
|
||||
10. 作为系统,当客户首次绑定一张无虚拟号的卡时,应正确将该卡的 `asset_status` 从在库更新为已销售,以便轮询系统能感知到该卡有真实用户在使用。
|
||||
11. 作为系统,当客户的绑定关系被禁用(`status=0`)后,归属验证应拒绝该客户访问对应资产,以便被撤销的绑定不再赋予访问权限。
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### 1. 新增 CustomerBinding 模块
|
||||
|
||||
在 `internal/service/` 下建立 `customer_binding` 包,对外暴露两个核心接口:
|
||||
|
||||
- `Bind(ctx, tx, customerID, assetType, assetID)` — 创建或更新客户与资产的绑定关系;对于卡资产,有虚拟号写 `tb_personal_customer_device`,无虚拟号写 `tb_personal_customer_iccid`
|
||||
- `OwnsAsset(ctx, customerID, assetType, assetID) bool` — 验证客户是否持有该资产的有效绑定(`status=1`);按资产类型路由到对应绑定表查询
|
||||
- `Migrate(ctx, tx, oldAsset, newAsset)` — 换货时迁移绑定关系;处理四种组合(有→有、有→无、无→有、无→无)
|
||||
|
||||
`OwnsAsset` 内部统一强制 `status=1` 过滤,解决当前部分实现缺少此过滤的安全缺口。
|
||||
|
||||
### 2. 绑定路由规则(卡资产)
|
||||
|
||||
| 旧卡 | 新卡 | Migrate 行为 |
|
||||
|------|------|-------------|
|
||||
| 有虚拟号,有 pcd 绑定 | 有虚拟号 | 更新 pcd 记录的 virtual_no |
|
||||
| 有虚拟号,有 pcd 绑定 | 无虚拟号 | 禁用旧 pcd 记录 + 创建 pci ICCID 绑定 |
|
||||
| 有虚拟号,无绑定 | 无虚拟号 | 跳过(无需迁移) |
|
||||
| 无虚拟号 | 任意 | 迁移 pci 记录(若存在)到新卡 ICCID |
|
||||
|
||||
### 3. 接入 CustomerBinding 的调用点
|
||||
|
||||
以下调用点需要替换为 CustomerBinding 模块:
|
||||
|
||||
- `client_auth/service.go: bindAsset()` — 使用 `CustomerBinding.Bind()`
|
||||
- `client_auth/service.go: resolveAssetBindingKey()` — 废弃,逻辑内聚到 CustomerBinding
|
||||
- `handler/app/client_realname.go: ExistsByCustomerAndDevice()` — 替换为 `CustomerBinding.OwnsAsset()`
|
||||
- `handler/app/client_device.go: ExistsByCustomerAndDevice()` — 替换为 `CustomerBinding.OwnsAsset()`
|
||||
- `handler/app/client_wallet.go: isCustomerOwnAsset()` — 替换为 `CustomerBinding.OwnsAsset()`
|
||||
- `handler/app/client_asset.go: isCustomerOwnAsset()` — 替换为 `CustomerBinding.OwnsAsset()`
|
||||
- `service/client_order/service.go: checkAssetOwnership()` — 替换为 `CustomerBinding.OwnsAsset()`
|
||||
- `service/exchange/service.go: switchCustomerBindingWithTx()` — 替换为 `CustomerBinding.Migrate()`
|
||||
|
||||
### 4. 修复 switchCustomerBindingWithTx 的校验混淆
|
||||
|
||||
当前 `switchCustomerBindingWithTx`(执行函数)在执行阶段重复做了 `ensureNewAssetBindingAvailableWithTx`(校验函数)的判断,且条件更严(不查实际记录数,只看 key 是否为空)。
|
||||
|
||||
修复方式:将 `switchCustomerBindingWithTx` 改为调用 `CustomerBinding.Migrate()`,移除函数内的 `newKey==""` 拦截逻辑。`ensureNewAssetBindingAvailableWithTx` 同步更新:当 `newKey==""` 时,不再拦截,因为 `Migrate()` 已能正确处理此情形。
|
||||
|
||||
### 5. asset_status 首销标记修复
|
||||
|
||||
`bindAsset` 中通过 `firstEverBind`(查 `virtual_no=""` 的记录数)来判断是否首次绑定并触发 `markAssetAsSold()`。无虚拟号的卡因为 key 为空,导致此逻辑失效,`asset_status` 永远停在在库。
|
||||
|
||||
修复方式:在 `CustomerBinding.Bind()` 内,对 IoT 卡分别按各自绑定表查询首绑状态,再触发 `markAssetAsSold()`。
|
||||
|
||||
### 6. 不引入新的数据库表或字段
|
||||
|
||||
`tb_personal_customer_iccid` 表和 `PersonalCustomerICCIDStore` 已经存在且完整,本次只是接入,不需要迁移或 schema 变更。
|
||||
|
||||
### 7. 现有 personal_customer_device 数据不迁移
|
||||
|
||||
有虚拟号的卡现有绑定数据保持不变,继续走 `tb_personal_customer_device` 路径。只有无虚拟号的卡新登录时才写 `tb_personal_customer_iccid`。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
本项目禁止自动化测试,使用 PostgreSQL MCP 和 Postman/curl 手动验证。
|
||||
|
||||
**验证重点场景:**
|
||||
|
||||
1. **无虚拟号卡首次登录**:用无虚拟号卡的 ICCID 登录后,`tb_personal_customer_iccid` 应出现绑定记录,`tb_iot_card.asset_status` 应变为 2(已销售)
|
||||
2. **无虚拟号卡归属验证**:登录后调用实名认证接口,应返回成功而非"无权限"
|
||||
3. **无虚拟号卡购买套餐**:购买套餐接口应正常通过归属验证
|
||||
4. **有虚拟号换无虚拟号(旧卡有绑定)**:换货完成后,`tb_personal_customer_device` 旧记录 status 应为 0,`tb_personal_customer_iccid` 应出现新卡 ICCID 的绑定记录
|
||||
5. **有虚拟号换无虚拟号(旧卡无绑定)**:换货应正常完成,无报错,不写入任何绑定记录
|
||||
6. **有虚拟号换有虚拟号(回归)**:现有流程不受影响,pcd 表记录的 virtual_no 正确更新到新卡
|
||||
7. **禁用绑定后归属验证**:将某条 pcd 或 pci 记录 status 设为 0,对应客户调用归属验证应返回无权限
|
||||
8. **有虚拟号卡回归(全流程)**:确认有虚拟号卡的登录、实名、购买套餐流程无任何变化
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- **IoT 卡状态字段整理**(`status` / `asset_status` / `activation_status` 语义重叠问题):影响面广,单独规划
|
||||
- **资产解析函数统一**(12 个 resolve 变体合并):不影响当前 bug,单独规划
|
||||
- **`tb_personal_customer_iccid` 换货迁移的历史数据回填**:历史上无虚拟号卡的用户无绑定记录,不做回填,用户需重新登录
|
||||
- **`tb_personal_customer_device` 中 `virtual_no=""` 的历史垃圾数据清理**:需先确认实际数量(SQL 查询),作为独立数据修复任务
|
||||
|
||||
## Further Notes
|
||||
|
||||
- 线上有 12,000+ 张无虚拟号的 IoT 卡,这是合法业务数据,不是数据质量问题
|
||||
- `tb_personal_customer_iccid` 的 Store 已有完整的 CRUD 实现(Create、ExistsByCustomerAndICCID、GetByCustomerID、CreateOrUpdateLastUsed 等),无需重写
|
||||
- 换货 bug 的直接触发路径:`completeExchangeWithTx → switchCustomerBindingWithTx`,在执行阶段因 `newKey==""` 报错,即使旧卡实际无任何客户绑定记录
|
||||
- 建议在上线前执行:`SELECT COUNT(*) FROM tb_personal_customer_device WHERE (virtual_no IS NULL OR virtual_no = '') AND deleted_at IS NULL` 确认是否有历史脏数据需要清理
|
||||
- **临时线上补丁(知悉)**:2026-06-18 已将线上 12,000+ 张无虚拟号的卡手动补充了虚拟号作为应急措施,以保障线上可用性。数据回滚由业务方自行处理,不在本次实现范围内。
|
||||
@@ -0,0 +1,50 @@
|
||||
Status: done
|
||||
|
||||
# CustomerBinding 模块骨架 + 有虚拟号路径替换(纯重构)
|
||||
|
||||
## What to build
|
||||
|
||||
建立 `internal/service/customer_binding` 包,对外暴露 `Bind()` 和 `OwnsAsset()` 两个接口,将现有有虚拟号路径的逻辑迁入。同时将代码库中 6 处归属验证调用点统一替换为新接口。
|
||||
|
||||
**本切片是纯重构,不改变任何现有行为。**
|
||||
|
||||
### Bind(ctx, tx, customerID, assetType, assetID)
|
||||
|
||||
封装创建客户与资产绑定记录的逻辑,当前只实现有虚拟号路径:
|
||||
- IoT 卡有虚拟号 → 写 `tb_personal_customer_device`(与现在 `bindAsset` 行为一致)
|
||||
- 设备 → 写 `tb_personal_customer_device`(设备必然有虚拟号)
|
||||
- 首次绑定时触发 `markAssetAsSold()`(firstEverBind 逻辑保持不变)
|
||||
|
||||
### OwnsAsset(ctx, customerID, assetType, assetID) bool
|
||||
|
||||
封装归属验证逻辑,当前只实现有虚拟号路径:
|
||||
- 查 `tb_personal_customer_device WHERE virtual_no = ? AND status = 1`
|
||||
- 内部统一强制 `status = 1` 过滤(修复现有部分实现缺少此过滤的安全缺口)
|
||||
|
||||
### 替换 6 处调用点
|
||||
|
||||
以下调用点替换为 `CustomerBinding` 的方法:
|
||||
|
||||
| 调用点 | 替换目标 |
|
||||
|--------|---------|
|
||||
| `client_auth/service.go: bindAsset()` | `CustomerBinding.Bind()` |
|
||||
| `handler/app/client_realname.go:92` | `CustomerBinding.OwnsAsset()` |
|
||||
| `handler/app/client_device.go:82` | `CustomerBinding.OwnsAsset()` |
|
||||
| `handler/app/client_wallet.go: isCustomerOwnAsset()` | `CustomerBinding.OwnsAsset()` |
|
||||
| `handler/app/client_asset.go: isCustomerOwnAsset()` | `CustomerBinding.OwnsAsset()` |
|
||||
| `service/client_order/service.go: checkAssetOwnership()` | `CustomerBinding.OwnsAsset()` |
|
||||
|
||||
exchange service 的 `customerOwnsAsset()` 在切片 3 中处理。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `internal/service/customer_binding` 包存在,对外暴露 `Bind` 和 `OwnsAsset`
|
||||
- [ ] 有虚拟号的 IoT 卡登录后,`tb_personal_customer_device` 正常写入绑定记录(与现在一致)
|
||||
- [ ] 设备登录后,`tb_personal_customer_device` 正常写入绑定记录(与现在一致)
|
||||
- [ ] 被禁用(status=0)的绑定记录不能通过 `OwnsAsset` 验证(修复安全缺口)
|
||||
- [ ] 6 处调用点全部替换完毕,原有私有方法(resolveAssetBindingKey、isCustomerOwnAsset 等)可删除
|
||||
- [ ] 有虚拟号卡的实名认证、购买套餐、设备操作全链路与现在行为一致
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - 可立即开始
|
||||
@@ -0,0 +1,52 @@
|
||||
Status: done
|
||||
|
||||
# 无虚拟号单卡 C 端完整链路
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/customer-binding-architecture/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
扩展 `CustomerBinding` 模块,使无虚拟号的独立 IoT 卡(单卡)能够完整走通 C 端业务流程:登录绑定、实名认证、购买套餐。
|
||||
|
||||
**仅影响 IoT 卡(单卡)。设备资产必然有虚拟号,不在本切片处理范围内。**
|
||||
|
||||
### 扩展 Bind()
|
||||
|
||||
当资产为 IoT 卡且 `virtual_no` 为空时:
|
||||
- 写 `tb_personal_customer_iccid`(按 ICCID 绑定),而非 `tb_personal_customer_device`
|
||||
- `PersonalCustomerICCIDStore` 已有完整实现,直接注入使用
|
||||
|
||||
当资产为 IoT 卡且 `virtual_no` 不为空时:行为与切片 1 一致,不变。
|
||||
|
||||
### 修复 firstEverBind + markAssetAsSold
|
||||
|
||||
当前 `firstEverBind` 通过查 `tb_personal_customer_device WHERE virtual_no = ""` 来判断是否首次绑定,对无虚拟号的卡完全失效(所有无虚拟号的卡共用同一个空 key)。
|
||||
|
||||
修复方式:在 `Bind()` 内,按路径分别判断首绑:
|
||||
- 有虚拟号路径:查 `tb_personal_customer_device WHERE virtual_no = ?`
|
||||
- 无虚拟号路径:查 `tb_personal_customer_iccid WHERE iccid = ?`
|
||||
|
||||
首次绑定后正常触发 `markAssetAsSold()`,将卡的 `asset_status` 从在库(1)更新为已销售(2)。
|
||||
|
||||
### 扩展 OwnsAsset()
|
||||
|
||||
当资产为 IoT 卡时:
|
||||
- 若卡有 `virtual_no`:查 `tb_personal_customer_device`(与切片 1 一致)
|
||||
- 若卡无 `virtual_no`:查 `tb_personal_customer_iccid WHERE iccid = ? AND status = 1`
|
||||
|
||||
当资产为设备时:行为不变,只查 `tb_personal_customer_device`。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 无虚拟号的单卡 C 端登录后,`tb_personal_customer_iccid` 中出现对应的绑定记录
|
||||
- [ ] 无虚拟号单卡首次登录后,`tb_iot_card.asset_status` 变为 2(已销售)
|
||||
- [ ] 无虚拟号单卡登录后,实名认证接口返回成功(不报"无权限")
|
||||
- [ ] 无虚拟号单卡登录后,购买套餐接口归属验证通过
|
||||
- [ ] 有虚拟号卡的全部行为与切片 1 完成后保持一致(无回归)
|
||||
- [ ] 设备资产的全部行为不受影响
|
||||
|
||||
## Blocked by
|
||||
|
||||
`.scratch/customer-binding-architecture/issues/01-customer-binding-module-refactor.md`
|
||||
@@ -0,0 +1,45 @@
|
||||
Status: done
|
||||
|
||||
# 换货绑定迁移修复
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/customer-binding-architecture/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
在 `CustomerBinding` 模块上实现 `Migrate(ctx, tx, oldAsset, newAsset)` 方法,替换换货服务中现有的 `switchCustomerBindingWithTx` 和 `ensureNewAssetBindingAvailableWithTx` 逻辑,修复有虚拟号旧卡换无虚拟号新卡时被误拦截的 bug。
|
||||
|
||||
### Migrate(ctx, tx, oldAsset, newAsset)
|
||||
|
||||
处理四种虚拟号组合,仅涉及 IoT 卡(设备必然有虚拟号,只有有→有一种情形):
|
||||
|
||||
| 旧卡 | 新卡 | 行为 |
|
||||
|------|------|------|
|
||||
| 有虚拟号,pcd 有绑定 | 有虚拟号 | 更新 pcd 记录的 virtual_no 为新卡虚拟号 |
|
||||
| 有虚拟号,pcd 有绑定 | 无虚拟号 | 禁用旧 pcd 记录(status=0)+ 创建新 pci ICCID 绑定 |
|
||||
| 有虚拟号,pcd 无绑定 | 无虚拟号 | 跳过,无需迁移 |
|
||||
| 无虚拟号,pci 有绑定 | 任意 | 迁移 pci 记录到新卡 ICCID(或新卡虚拟号路径) |
|
||||
| 任意 | 任意(无绑定) | 跳过 |
|
||||
|
||||
### 修复换货服务
|
||||
|
||||
- `switchCustomerBindingWithTx` 替换为调用 `CustomerBinding.Migrate()`,移除函数内 `newKey == ""` 的误拦截逻辑
|
||||
- `ensureNewAssetBindingAvailableWithTx` 更新:当新卡无虚拟号时,不再拦截换货,因为 `Migrate()` 已能正确处理此情形(无绑定时跳过,有绑定时迁移到 pci)
|
||||
|
||||
### 根本 bug 说明
|
||||
|
||||
原 `switchCustomerBindingWithTx` 在执行阶段做了 `if newKey == "" { return error }` 的检查,但没有先确认旧资产是否实际存在绑定记录。旧卡有虚拟号但无任何客户绑定时,也会被误拦截报错"新资产无法承接客户绑定"。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 旧卡有虚拟号 + 有客户绑定,换货为有虚拟号新卡:pcd 记录的 virtual_no 正确更新为新卡虚拟号
|
||||
- [ ] 旧卡有虚拟号 + 有客户绑定,换货为无虚拟号新卡:旧 pcd 记录 status 变为 0,pci 表出现新卡 ICCID 的绑定记录
|
||||
- [ ] 旧卡有虚拟号 + 无客户绑定,换货为无虚拟号新卡:换货正常完成,不报错,不写入任何绑定记录
|
||||
- [ ] 旧卡无虚拟号,换货为任意新卡:换货正常完成
|
||||
- [ ] 有虚拟号换有虚拟号(原有场景):行为与修复前一致,无回归
|
||||
- [ ] 换货完成后,客户通过新卡(无论有无虚拟号)能正常通过归属验证
|
||||
|
||||
## Blocked by
|
||||
|
||||
`.scratch/customer-binding-architecture/issues/02-no-virtual-no-card-cend-flow.md`
|
||||
@@ -139,8 +139,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
}(),
|
||||
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
|
||||
Refund: admin.NewRefundHandler(svc.Refund),
|
||||
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
|
||||
Refund: admin.NewRefundHandler(svc.Refund),
|
||||
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate),
|
||||
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
|
||||
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
|
||||
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import (
|
||||
|
||||
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
|
||||
operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password"
|
||||
orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
|
||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
|
||||
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
|
||||
@@ -111,6 +112,7 @@ type services struct {
|
||||
OperationPassword *operationPasswordSvc.Service
|
||||
AgentOpenAPI *agentOpenAPISvc.Service
|
||||
CustomerBinding *customerBindingSvc.Service
|
||||
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
|
||||
}
|
||||
|
||||
func initServices(s *stores, deps *Dependencies) *services {
|
||||
@@ -313,6 +315,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.AssetWallet,
|
||||
deps.Logger,
|
||||
),
|
||||
CustomerBinding: customerBinding,
|
||||
CustomerBinding: customerBinding,
|
||||
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ type stores struct {
|
||||
WechatConfig *postgres.WechatConfigStore
|
||||
// 退款系统
|
||||
RefundRequest *postgres.RefundStore
|
||||
// 订单套餐失效任务
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
// 流量系统
|
||||
CardDailyUsage *postgres.CardDailyUsageStore
|
||||
// 资产标识符注册表
|
||||
@@ -131,8 +133,9 @@ func initStores(deps *Dependencies) *stores {
|
||||
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
|
||||
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
|
||||
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||
RefundRequest: postgres.NewRefundStore(deps.DB),
|
||||
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
RefundRequest: postgres.NewRefundStore(deps.DB),
|
||||
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ type Handlers struct {
|
||||
WechatConfig *admin.WechatConfigHandler
|
||||
AgentRecharge *admin.AgentRechargeHandler
|
||||
Refund *admin.RefundHandler
|
||||
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
|
||||
ClientWechat *app.ClientWechatHandler
|
||||
SuperAdmin *admin.SuperAdminHandler
|
||||
AgentOpenAPI *openapiHandler.Handler
|
||||
|
||||
@@ -33,8 +33,9 @@ type workerStores struct {
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
}
|
||||
|
||||
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
@@ -66,8 +67,9 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
}
|
||||
|
||||
return &queue.WorkerStores{
|
||||
@@ -98,7 +100,8 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
AgentWalletTransaction: stores.AgentWalletTransaction,
|
||||
AssetWallet: stores.AssetWallet,
|
||||
AssetIdentifier: stores.AssetIdentifier,
|
||||
PersonalCustomer: stores.PersonalCustomer,
|
||||
PersonalCustomerPhone: stores.PersonalCustomerPhone,
|
||||
PersonalCustomer: stores.PersonalCustomer,
|
||||
PersonalCustomerPhone: stores.PersonalCustomerPhone,
|
||||
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
|
||||
}
|
||||
}
|
||||
|
||||
92
internal/handler/admin/order_package_invalidate.go
Normal file
92
internal/handler/admin/order_package_invalidate.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
invalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// OrderPackageInvalidateHandler 订单套餐批量失效任务 Handler
|
||||
type OrderPackageInvalidateHandler struct {
|
||||
service *invalidateSvc.Service
|
||||
}
|
||||
|
||||
// NewOrderPackageInvalidateHandler 创建 Handler 实例
|
||||
func NewOrderPackageInvalidateHandler(service *invalidateSvc.Service) *OrderPackageInvalidateHandler {
|
||||
return &OrderPackageInvalidateHandler{service: service}
|
||||
}
|
||||
|
||||
// Create 创建批量失效订单套餐任务
|
||||
// POST /api/admin/order-package-invalidate-tasks
|
||||
func (h *OrderPackageInvalidateHandler) Create(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可创建套餐失效任务")
|
||||
}
|
||||
|
||||
var req dto.CreateOrderPackageInvalidateTaskRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
if req.FileKey == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "文件路径不能为空")
|
||||
}
|
||||
|
||||
result, err := h.service.Create(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// List 查询批量失效任务列表
|
||||
// GET /api/admin/order-package-invalidate-tasks
|
||||
func (h *OrderPackageInvalidateHandler) List(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可查看套餐失效任务")
|
||||
}
|
||||
|
||||
var req dto.ListOrderPackageInvalidateTaskRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
result, err := h.service.List(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
// GetByID 查询批量失效任务详情
|
||||
// GET /api/admin/order-package-invalidate-tasks/:id
|
||||
func (h *OrderPackageInvalidateHandler) GetByID(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可查看套餐失效任务详情")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的任务ID")
|
||||
}
|
||||
|
||||
result, err := h.service.GetByID(c.UserContext(), uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
@@ -82,7 +82,8 @@ type AgentRechargeRecord struct {
|
||||
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
|
||||
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
|
||||
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500);comment:支付凭证对象存储Key(线下支付时必填,微信支付时为空)" json:"payment_voucher_key,omitempty"`
|
||||
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表(线下支付时必填,最多5个,微信支付时为空)" json:"payment_voucher_key"`
|
||||
Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"`
|
||||
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
|
||||
ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"`
|
||||
|
||||
@@ -29,6 +29,7 @@ type DeviceImportTask struct {
|
||||
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
||||
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:导入批次实名策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
|
||||
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -5,7 +5,8 @@ type CreateAgentRechargeRequest struct {
|
||||
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID,代理只能填自己店铺"`
|
||||
Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"`
|
||||
PaymentVoucherKey string `json:"payment_voucher_key" validate:"omitempty,max=500" description:"支付凭证对象存储Key(payment_method=offline 时必填,微信支付时忽略)"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,微信支付时忽略)"`
|
||||
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
|
||||
}
|
||||
|
||||
// AgentOfflinePayRequest 代理线下充值确认请求
|
||||
@@ -31,7 +32,8 @@ type AgentRechargeResponse struct {
|
||||
PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"`
|
||||
PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID,线下充值为null"`
|
||||
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"`
|
||||
PaymentVoucherKey string `json:"payment_voucher_key,omitempty" description:"支付凭证对象存储Key(线下支付时存在)"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表(线下支付时存在,最多5个)"`
|
||||
Remark string `json:"remark,omitempty" description:"运营备注"`
|
||||
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
PaidAt *string `json:"paid_at" description:"支付时间"`
|
||||
|
||||
@@ -39,6 +39,7 @@ type DeviceImportTaskResponse struct {
|
||||
StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
|
||||
ErrorMessage string `json:"error_message,omitempty" description:"错误信息"`
|
||||
CreatorName string `json:"creator_name" description:"操作人姓名"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ type ImportTaskResponse struct {
|
||||
StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
|
||||
ErrorMessage string `json:"error_message,omitempty" description:"错误信息"`
|
||||
CreatorName string `json:"creator_name" description:"操作人姓名"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ type CreateAdminOrderRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(ICCID 或 VirtualNo)"`
|
||||
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
|
||||
PaymentVoucherKey string `json:"payment_voucher_key" validate:"omitempty,max=500" maxLength:"500" description:"线下支付凭证对象存储file_key(payment_method=offline时必填,通过/storage/upload-url上传图片后获得)"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表(payment_method=offline时至少1个,最多5个,通过/storage/upload-url上传图片后获得)"`
|
||||
}
|
||||
|
||||
type OrderListRequest struct {
|
||||
@@ -63,7 +63,7 @@ type OrderResponse struct {
|
||||
PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
|
||||
PaymentStatusText string `json:"payment_status_text" description:"支付状态文本"`
|
||||
PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"`
|
||||
PaymentVoucherKey string `json:"payment_voucher_key,omitempty" description:"线下支付凭证对象存储file_key(仅线下支付订单有值)"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" description:"线下支付凭证对象存储file_key列表(线下支付订单有值,最多5个)"`
|
||||
IsPurchaseOnBehalf bool `json:"is_purchase_on_behalf" description:"是否为代购订单"`
|
||||
CommissionStatus int `json:"commission_status" description:"佣金流程状态 (1:待计算, 2:已完成, 3:待人工处理)"`
|
||||
CommissionStatusName string `json:"commission_status_name" description:"佣金流程状态名称(中文)"`
|
||||
|
||||
56
internal/model/dto/order_package_invalidate_dto.go
Normal file
56
internal/model/dto/order_package_invalidate_dto.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package dto
|
||||
|
||||
// CreateOrderPackageInvalidateTaskRequest 创建订单套餐失效任务请求
|
||||
type CreateOrderPackageInvalidateTaskRequest struct {
|
||||
FileKey string `json:"file_key" validate:"required,max=500" required:"true" maxLength:"500" description:"CSV文件对象存储Key(通过/storage/upload-url上传后获得;CSV单列 order_no)"`
|
||||
VoucherKeys []string `json:"voucher_keys" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(可选,最多5个)"`
|
||||
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
|
||||
}
|
||||
|
||||
// OrderPackageInvalidateTaskResponse 任务详情响应
|
||||
type OrderPackageInvalidateTaskResponse struct {
|
||||
ID uint `json:"id" description:"任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
TotalCount int `json:"total_count" description:"总行数"`
|
||||
SuccessCount int `json:"success_count" description:"成功数"`
|
||||
FailCount int `json:"fail_count" description:"失败数"`
|
||||
FileName string `json:"file_name" description:"原始文件名"`
|
||||
VoucherKeys []string `json:"voucher_keys" description:"支付凭证Key列表"`
|
||||
Remark string `json:"remark,omitempty" description:"运营备注"`
|
||||
CreatorName string `json:"creator_name" description:"操作人姓名"`
|
||||
ErrorMessage string `json:"error_message,omitempty" description:"任务级错误信息"`
|
||||
StartedAt *string `json:"started_at,omitempty" description:"开始处理时间"`
|
||||
CompletedAt *string `json:"completed_at,omitempty" description:"完成时间"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// OrderPackageInvalidateTaskDetailResponse 任务详情(含失败明细)
|
||||
type OrderPackageInvalidateTaskDetailResponse struct {
|
||||
OrderPackageInvalidateTaskResponse
|
||||
FailedItems []InvalidateFailedItem `json:"failed_items" description:"失败记录列表"`
|
||||
}
|
||||
|
||||
// InvalidateFailedItem 失效失败记录
|
||||
type InvalidateFailedItem struct {
|
||||
Line int `json:"line" description:"CSV 行号"`
|
||||
OrderNo string `json:"order_no" description:"订单号"`
|
||||
Reason string `json:"reason" description:"失败原因"`
|
||||
}
|
||||
|
||||
// ListOrderPackageInvalidateTaskRequest 任务列表查询请求
|
||||
type ListOrderPackageInvalidateTaskRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数(默认20,最大100)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"按状态过滤 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
|
||||
}
|
||||
|
||||
// OrderPackageInvalidateTaskListResponse 任务列表响应
|
||||
type OrderPackageInvalidateTaskListResponse struct {
|
||||
Total int64 `json:"total" description:"总记录数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"size" description:"每页条数"`
|
||||
List []*OrderPackageInvalidateTaskResponse `json:"items" description:"任务列表"`
|
||||
}
|
||||
@@ -5,7 +5,7 @@ type CreateRefundRequest struct {
|
||||
OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"`
|
||||
ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"`
|
||||
RefundVoucherKey string `json:"refund_voucher_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"退款凭证对象存储file_key(通过/storage/upload-url上传图片后获得)"`
|
||||
RefundVoucherKey []string `json:"refund_voucher_key" validate:"required,min=1,max=5,dive,max=500" required:"true" minItems:"1" maxItems:"5" description:"退款凭证对象存储file_key列表(至少1个,最多5个,通过/storage/upload-url上传图片后获得)"`
|
||||
RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
|
||||
PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID(可选)"`
|
||||
}
|
||||
@@ -27,7 +27,7 @@ type ResubmitRefundRequest struct {
|
||||
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
|
||||
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"`
|
||||
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"`
|
||||
RefundVoucherKey *string `json:"refund_voucher_key" validate:"omitempty,max=500" maxLength:"500" description:"退款凭证对象存储file_key(重新提交时可替换;历史记录缺失时必填)"`
|
||||
RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;历史记录缺失时必填,最多5个)"`
|
||||
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ type RefundResponse struct {
|
||||
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"`
|
||||
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
|
||||
RefundVoucherKey string `json:"refund_voucher_key,omitempty" description:"退款凭证对象存储file_key"`
|
||||
RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表(最多5个)"`
|
||||
RefundReason string `json:"refund_reason" description:"退款原因"`
|
||||
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
|
||||
@@ -33,6 +33,7 @@ type IotCardImportTask struct {
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key,omitempty"`
|
||||
CardCategory string `gorm:"column:card_category;type:varchar(20);default:normal;not null;comment:卡业务类型(normal/industry)" json:"card_category"`
|
||||
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:导入批次实名策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
|
||||
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
|
||||
}
|
||||
|
||||
// CardItem 卡信息(ICCID + MSISDN + VirtualNo)
|
||||
|
||||
@@ -70,8 +70,8 @@ type Order struct {
|
||||
// 支付配置
|
||||
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
|
||||
|
||||
// 线下支付凭证对象存储 file_key(仅线下支付订单必填)
|
||||
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500);comment:线下支付凭证对象存储file_key" json:"payment_voucher_key,omitempty"`
|
||||
// 线下支付凭证对象存储 file_key 列表(最多5个,仅线下支付订单必填)
|
||||
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:线下支付凭证对象存储file_key列表(jsonb存储[]string)" json:"payment_voucher_key"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
36
internal/model/order_package_invalidate_task.go
Normal file
36
internal/model/order_package_invalidate_task.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OrderPackageInvalidateTask 订单套餐批量失效任务模型
|
||||
type OrderPackageInvalidateTask struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex;not null;comment:任务编号" json:"task_no"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)" json:"status"`
|
||||
TotalCount int `gorm:"column:total_count;type:int;not null;default:0;comment:总行数" json:"total_count"`
|
||||
SuccessCount int `gorm:"column:success_count;type:int;not null;default:0;comment:成功数" json:"success_count"`
|
||||
FailCount int `gorm:"column:fail_count;type:int;not null;default:0;comment:失败数" json:"fail_count"`
|
||||
FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;not null;default:'[]';comment:失败记录详情(jsonb)" json:"failed_items"`
|
||||
FileName string `gorm:"column:file_name;type:varchar(255);not null;default:'';comment:原始文件名" json:"file_name"`
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);not null;default:'';comment:CSV 文件对象存储路径" json:"storage_key"`
|
||||
VoucherKeys StringJSONBArray `gorm:"column:voucher_keys;type:jsonb;not null;default:'[]';comment:支付凭证对象存储Key列表(最多5个)" json:"voucher_keys"`
|
||||
Remark string `gorm:"column:remark;type:text;not null;default:'';comment:运营备注(创建时填写,只读)" json:"remark"`
|
||||
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text;not null;default:'';comment:任务级错误信息" json:"error_message"`
|
||||
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
||||
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人ID" json:"creator"`
|
||||
Updater uint `gorm:"column:updater;not null;default:0;comment:更新人ID" json:"updater"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (OrderPackageInvalidateTask) TableName() string {
|
||||
return "tb_order_package_invalidate_task"
|
||||
}
|
||||
@@ -31,7 +31,7 @@ type RefundRequest struct {
|
||||
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount;type:bigint;comment:审批实际退款金额(分)" json:"approved_refund_amount,omitempty"`
|
||||
|
||||
// 退款凭证、原因和状态
|
||||
RefundVoucherKey string `gorm:"column:refund_voucher_key;type:varchar(500);not null;default:'';comment:退款凭证对象存储file_key" json:"refund_voucher_key,omitempty"`
|
||||
RefundVoucherKey StringJSONBArray `gorm:"column:refund_voucher_key;type:jsonb;not null;comment:退款凭证对象存储file_key列表(jsonb存储[]string)" json:"refund_voucher_key"`
|
||||
RefundReason string `gorm:"column:refund_reason;type:text;comment:退款原因" json:"refund_reason"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;index;comment:状态 1-待审批 2-已通过 3-已拒绝 4-已退回" json:"status"`
|
||||
|
||||
|
||||
32
internal/model/types.go
Normal file
32
internal/model/types.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// StringJSONBArray 用于将 []string 与 PostgreSQL jsonb 列互转
|
||||
// 读写时通过 Scan/Value 完成序列化,空切片序列化为 []
|
||||
type StringJSONBArray []string
|
||||
|
||||
// Value 写入数据库时序列化为 JSON
|
||||
func (a StringJSONBArray) Value() (driver.Value, error) {
|
||||
if a == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// Scan 从数据库读取时反序列化
|
||||
func (a *StringJSONBArray) Scan(value any) error {
|
||||
if value == nil {
|
||||
*a = StringJSONBArray{}
|
||||
return nil
|
||||
}
|
||||
b, ok := value.([]byte)
|
||||
if !ok {
|
||||
*a = StringJSONBArray{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(b, a)
|
||||
}
|
||||
@@ -125,6 +125,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
||||
if handlers.Refund != nil {
|
||||
registerRefundRoutes(authGroup, handlers.Refund, doc, basePath)
|
||||
}
|
||||
if handlers.OrderPackageInvalidate != nil {
|
||||
registerOrderPackageInvalidateRoutes(authGroup, handlers.OrderPackageInvalidate, doc, basePath)
|
||||
}
|
||||
if handlers.SuperAdmin != nil {
|
||||
registerSuperAdminRoutes(authGroup, handlers.SuperAdmin, doc, basePath)
|
||||
}
|
||||
|
||||
40
internal/routes/order_package_invalidate.go
Normal file
40
internal/routes/order_package_invalidate.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// registerOrderPackageInvalidateRoutes 注册订单套餐批量失效任务路由
|
||||
func registerOrderPackageInvalidateRoutes(router fiber.Router, handler *admin.OrderPackageInvalidateHandler, doc *openapi.Generator, basePath string) {
|
||||
group := router.Group("/order-package-invalidate-tasks")
|
||||
groupPath := basePath + "/order-package-invalidate-tasks"
|
||||
|
||||
Register(group, doc, groupPath, "POST", "", handler.Create, RouteSpec{
|
||||
Summary: "创建订单套餐批量失效任务",
|
||||
Description: "上传单列 CSV 文件(列名 order_no),批量将订单下的非终态套餐标记为已失效(status=4)。",
|
||||
Tags: []string{"订单套餐失效"},
|
||||
Input: new(dto.CreateOrderPackageInvalidateTaskRequest),
|
||||
Output: new(dto.OrderPackageInvalidateTaskResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||
Summary: "查询订单套餐失效任务列表",
|
||||
Tags: []string{"订单套餐失效"},
|
||||
Input: new(dto.ListOrderPackageInvalidateTaskRequest),
|
||||
Output: new(dto.OrderPackageInvalidateTaskListResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{
|
||||
Summary: "查询订单套餐失效任务详情",
|
||||
Tags: []string{"订单套餐失效"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: new(dto.OrderPackageInvalidateTaskDetailResponse),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -96,7 +95,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
|
||||
}
|
||||
|
||||
// 线下充值必须上传支付凭证
|
||||
if req.PaymentMethod == "offline" && strings.TrimSpace(req.PaymentVoucherKey) == "" {
|
||||
if req.PaymentMethod == "offline" && len(req.PaymentVoucherKey) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须上传支付凭证")
|
||||
}
|
||||
|
||||
@@ -147,7 +146,8 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
PaymentChannel: &paymentChannel,
|
||||
PaymentConfigID: paymentConfigID,
|
||||
PaymentVoucherKey: strings.TrimSpace(req.PaymentVoucherKey),
|
||||
PaymentVoucherKey: model.StringJSONBArray(req.PaymentVoucherKey),
|
||||
Remark: req.Remark,
|
||||
Status: constants.RechargeStatusPending,
|
||||
ShopIDTag: wallet.ShopIDTag,
|
||||
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
@@ -486,7 +486,8 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe
|
||||
AgentWalletID: record.AgentWalletID,
|
||||
Amount: record.Amount,
|
||||
PaymentMethod: record.PaymentMethod,
|
||||
PaymentVoucherKey: record.PaymentVoucherKey,
|
||||
PaymentVoucherKey: []string(record.PaymentVoucherKey),
|
||||
Remark: record.Remark,
|
||||
Status: record.Status,
|
||||
StatusName: constants.GetRechargeStatusName(record.Status),
|
||||
CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
|
||||
@@ -60,6 +60,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
|
||||
FileName: fileName,
|
||||
StorageKey: req.FileKey,
|
||||
RealnamePolicy: req.RealnamePolicy,
|
||||
CreatorName: middleware.GetUsernameFromContext(ctx),
|
||||
}
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
@@ -190,21 +191,23 @@ func (s *Service) toTaskResponse(task *model.DeviceImportTask) *dto.DeviceImport
|
||||
}
|
||||
|
||||
return &dto.DeviceImportTaskResponse{
|
||||
ID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Status: task.Status,
|
||||
StatusText: getStatusText(task.Status),
|
||||
BatchNo: task.BatchNo,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
SkipCount: task.SkipCount,
|
||||
FailCount: task.FailCount,
|
||||
WarningCount: task.WarningCount,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: completedAt,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatedAt: task.CreatedAt,
|
||||
ID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Status: task.Status,
|
||||
StatusText: getStatusText(task.Status),
|
||||
BatchNo: task.BatchNo,
|
||||
RealnamePolicy: task.RealnamePolicy,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
SkipCount: task.SkipCount,
|
||||
FailCount: task.FailCount,
|
||||
WarningCount: task.WarningCount,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: completedAt,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatorName: task.CreatorName,
|
||||
CreatedAt: task.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,14 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
|
||||
}
|
||||
}
|
||||
|
||||
// isRiskGatewayExtend 判断网关扩展状态是否为风险状态(风险停机或已销户)
|
||||
// 独立卡命中此状态时禁止发起复机
|
||||
func isRiskGatewayExtend(extend string) bool {
|
||||
trimmed := strings.TrimSpace(extend)
|
||||
return trimmed == constants.GatewayCardExtendRiskStop ||
|
||||
trimmed == constants.GatewayCardExtendCancelled
|
||||
}
|
||||
|
||||
// isPollingStopReason 判断停机原因是否为轮询系统引起(可自动复机)
|
||||
func isPollingStopReason(reason string) bool {
|
||||
switch reason {
|
||||
@@ -692,6 +700,30 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
|
||||
}
|
||||
|
||||
gatewayExtend := strings.TrimSpace(card.GatewayExtend)
|
||||
|
||||
// 独立卡处于风险停机或已销户状态时,优先返回明确的拒绝原因
|
||||
if card.IsStandalone && isRiskGatewayExtend(gatewayExtend) {
|
||||
var denyMsg string
|
||||
if gatewayExtend == constants.GatewayCardExtendRiskStop {
|
||||
denyMsg = "该卡已被运营商风险停机,不允许复机"
|
||||
} else {
|
||||
denyMsg = "该卡已被运营商销户,不允许复机"
|
||||
}
|
||||
denyErr := errors.New(errors.CodeForbidden, denyMsg)
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "机卡分离复机被拒绝(风险状态)",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
return denyErr
|
||||
}
|
||||
|
||||
if gatewayExtend != constants.GatewayCardExtendMachineSeparated {
|
||||
denyErr := errors.New(errors.CodeForbidden, constants.AgentOpenAPIResumeOnlyMachineSeparatedMessage)
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
@@ -880,6 +912,29 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
|
||||
return denyErr
|
||||
}
|
||||
|
||||
// 独立卡处于风险停机或已销户状态时,拒绝复机
|
||||
if card.IsStandalone && isRiskGatewayExtend(card.GatewayExtend) {
|
||||
var denyMsg string
|
||||
if strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendRiskStop {
|
||||
denyMsg = "该卡已被运营商风险停机,不允许复机"
|
||||
} else {
|
||||
denyMsg = "该卡已被运营商销户,不允许复机"
|
||||
}
|
||||
denyErr := errors.New(errors.CodeForbidden, denyMsg)
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "手动复机被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
return denyErr
|
||||
}
|
||||
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
|
||||
26
internal/service/iot_card/stop_resume_service_test.go
Normal file
26
internal/service/iot_card/stop_resume_service_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package iot_card
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsRiskGatewayExtend 验证网关扩展状态的风险判断逻辑
|
||||
func TestIsRiskGatewayExtend(t *testing.T) {
|
||||
cases := []struct {
|
||||
extend string
|
||||
want bool
|
||||
}{
|
||||
{"风险停机", true},
|
||||
{"已销户", true},
|
||||
{"机卡分离停机", false},
|
||||
{"待激活", false},
|
||||
{"", false},
|
||||
{" 风险停机 ", true}, // 含空白字符
|
||||
{"已注销", false}, // 非目标状态
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := isRiskGatewayExtend(tc.extend)
|
||||
if got != tc.want {
|
||||
t.Errorf("isRiskGatewayExtend(%q) = %v, 期望 %v", tc.extend, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe
|
||||
StorageKey: req.FileKey,
|
||||
CardCategory: cardCategory,
|
||||
RealnamePolicy: req.RealnamePolicy,
|
||||
CreatorName: middleware.GetUsernameFromContext(ctx),
|
||||
}
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
@@ -224,24 +225,26 @@ func (s *Service) toTaskResponse(task *model.IotCardImportTask) *dto.ImportTaskR
|
||||
}
|
||||
|
||||
return &dto.ImportTaskResponse{
|
||||
ID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Status: task.Status,
|
||||
StatusText: getStatusText(task.Status),
|
||||
CarrierID: task.CarrierID,
|
||||
CarrierType: task.CarrierType,
|
||||
CarrierName: task.CarrierName,
|
||||
BatchNo: task.BatchNo,
|
||||
CardCategory: task.CardCategory,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
SkipCount: task.SkipCount,
|
||||
FailCount: task.FailCount,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: completedAt,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatedAt: task.CreatedAt,
|
||||
ID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Status: task.Status,
|
||||
StatusText: getStatusText(task.Status),
|
||||
CarrierID: task.CarrierID,
|
||||
CarrierType: task.CarrierType,
|
||||
CarrierName: task.CarrierName,
|
||||
BatchNo: task.BatchNo,
|
||||
CardCategory: task.CardCategory,
|
||||
RealnamePolicy: task.RealnamePolicy,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
SkipCount: task.SkipCount,
|
||||
FailCount: task.FailCount,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: completedAt,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatorName: task.CreatorName,
|
||||
CreatedAt: task.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.PaymentMethod == model.PaymentMethodOffline && strings.TrimSpace(req.PaymentVoucherKey) == "" {
|
||||
if req.PaymentMethod == model.PaymentMethodOffline && len(req.PaymentVoucherKey) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下支付必须上传支付凭证")
|
||||
}
|
||||
|
||||
@@ -453,9 +453,9 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
PaymentConfigID: paymentConfigID,
|
||||
}
|
||||
|
||||
// 线下支付订单写入支付凭证 file_key
|
||||
// 线下支付订单写入支付凭证 file_key 列表
|
||||
if req.PaymentMethod == model.PaymentMethodOffline {
|
||||
order.PaymentVoucherKey = strings.TrimSpace(req.PaymentVoucherKey)
|
||||
order.PaymentVoucherKey = model.StringJSONBArray(req.PaymentVoucherKey)
|
||||
}
|
||||
|
||||
if orderBuyerType == model.BuyerTypePersonal {
|
||||
@@ -2533,7 +2533,7 @@ func (s *Service) buildOrderResponse(ctx context.Context, order *model.Order, it
|
||||
PaymentStatus: order.PaymentStatus,
|
||||
PaymentStatusText: statusText,
|
||||
PaidAt: order.PaidAt,
|
||||
PaymentVoucherKey: order.PaymentVoucherKey,
|
||||
PaymentVoucherKey: []string(order.PaymentVoucherKey),
|
||||
IsPurchaseOnBehalf: order.IsPurchaseOnBehalf,
|
||||
CommissionStatus: order.CommissionStatus,
|
||||
CommissionStatusName: constants.GetOrderCommissionStatusName(order.CommissionStatus),
|
||||
|
||||
185
internal/service/order_package_invalidate/service.go
Normal file
185
internal/service/order_package_invalidate/service.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package order_package_invalidate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
// Service 订单套餐失效任务业务逻辑层
|
||||
type Service struct {
|
||||
taskStore *postgres.OrderPackageInvalidateTaskStore
|
||||
queueClient *queue.Client
|
||||
}
|
||||
|
||||
// New 创建 Service 实例
|
||||
func New(
|
||||
taskStore *postgres.OrderPackageInvalidateTaskStore,
|
||||
queueClient *queue.Client,
|
||||
) *Service {
|
||||
return &Service{
|
||||
taskStore: taskStore,
|
||||
queueClient: queueClient,
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateTaskPayload Worker 任务载荷
|
||||
type InvalidateTaskPayload struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
}
|
||||
|
||||
// Create 创建订单套餐失效任务
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateOrderPackageInvalidateTaskRequest) (*dto.OrderPackageInvalidateTaskResponse, error) {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
task := &model.OrderPackageInvalidateTask{
|
||||
TaskNo: s.taskStore.GenerateTaskNo(),
|
||||
Status: model.ImportTaskStatusPending,
|
||||
FileName: filepath.Base(req.FileKey),
|
||||
StorageKey: req.FileKey,
|
||||
VoucherKeys: model.StringJSONBArray(req.VoucherKeys),
|
||||
Remark: req.Remark,
|
||||
CreatorName: middleware.GetUsernameFromContext(ctx),
|
||||
FailedItems: model.ImportResultItems{},
|
||||
Creator: userID,
|
||||
Updater: userID,
|
||||
}
|
||||
|
||||
if err := s.taskStore.Create(ctx, task); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建任务失败")
|
||||
}
|
||||
|
||||
payload := InvalidateTaskPayload{TaskID: task.ID}
|
||||
err := s.queueClient.EnqueueTask(
|
||||
ctx,
|
||||
constants.TaskTypeOrderPackageInvalidate,
|
||||
payload,
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeOrderPackageInvalidate)),
|
||||
)
|
||||
if err != nil {
|
||||
s.taskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
|
||||
}
|
||||
|
||||
return s.toResponse(task), nil
|
||||
}
|
||||
|
||||
// List 分页查询任务列表
|
||||
func (s *Service) List(ctx context.Context, req *dto.ListOrderPackageInvalidateTaskRequest) (*dto.OrderPackageInvalidateTaskListResponse, error) {
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = constants.DefaultPageSize
|
||||
}
|
||||
|
||||
opts := &store.QueryOptions{
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}
|
||||
|
||||
filters := map[string]interface{}{}
|
||||
if req.Status != nil {
|
||||
filters["status"] = *req.Status
|
||||
}
|
||||
|
||||
tasks, total, err := s.taskStore.List(ctx, opts, filters)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询任务列表失败")
|
||||
}
|
||||
|
||||
list := make([]*dto.OrderPackageInvalidateTaskResponse, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
list = append(list, s.toResponse(t))
|
||||
}
|
||||
|
||||
return &dto.OrderPackageInvalidateTaskListResponse{
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
List: list,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetByID 查询任务详情(含失败明细)
|
||||
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.OrderPackageInvalidateTaskDetailResponse, error) {
|
||||
task, err := s.taskStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "任务不存在")
|
||||
}
|
||||
|
||||
failedItems := make([]dto.InvalidateFailedItem, 0, len(task.FailedItems))
|
||||
for _, item := range task.FailedItems {
|
||||
failedItems = append(failedItems, dto.InvalidateFailedItem{
|
||||
Line: item.Line,
|
||||
OrderNo: item.ICCID, // 复用 ImportResultItem.ICCID 存储 order_no
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
return &dto.OrderPackageInvalidateTaskDetailResponse{
|
||||
OrderPackageInvalidateTaskResponse: *s.toResponse(task),
|
||||
FailedItems: failedItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toResponse 转换为响应 DTO
|
||||
func (s *Service) toResponse(task *model.OrderPackageInvalidateTask) *dto.OrderPackageInvalidateTaskResponse {
|
||||
resp := &dto.OrderPackageInvalidateTaskResponse{
|
||||
ID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Status: task.Status,
|
||||
StatusName: invalidateTaskStatusName(task.Status),
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
FailCount: task.FailCount,
|
||||
FileName: task.FileName,
|
||||
VoucherKeys: []string(task.VoucherKeys),
|
||||
Remark: task.Remark,
|
||||
CreatorName: task.CreatorName,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatedAt: task.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: task.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if task.StartedAt != nil {
|
||||
t := task.StartedAt.Format(time.RFC3339)
|
||||
resp.StartedAt = &t
|
||||
}
|
||||
if task.CompletedAt != nil {
|
||||
t := task.CompletedAt.Format(time.RFC3339)
|
||||
resp.CompletedAt = &t
|
||||
}
|
||||
if resp.VoucherKeys == nil {
|
||||
resp.VoucherKeys = []string{}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// invalidateTaskStatusName 状态码转中文
|
||||
func invalidateTaskStatusName(status int) string {
|
||||
switch status {
|
||||
case model.ImportTaskStatusPending:
|
||||
return "待处理"
|
||||
case model.ImportTaskStatusProcessing:
|
||||
return "处理中"
|
||||
case model.ImportTaskStatusCompleted:
|
||||
return "已完成"
|
||||
case model.ImportTaskStatusFailed:
|
||||
return "失败"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -619,11 +618,11 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
}
|
||||
refundVoucherKey := refund.RefundVoucherKey
|
||||
if req.RefundVoucherKey != nil {
|
||||
refundVoucherKey = *req.RefundVoucherKey
|
||||
}
|
||||
refundVoucherKey, err = normalizeRefundVoucherKey(refundVoucherKey)
|
||||
if err != nil {
|
||||
return err
|
||||
normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey)
|
||||
if normErr != nil {
|
||||
return normErr
|
||||
}
|
||||
refundVoucherKey = normalized
|
||||
}
|
||||
|
||||
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
|
||||
@@ -914,15 +913,14 @@ func validateApprovedRefundAmount(approvedAmount int64, requestedRefundAmount in
|
||||
return validateRequestedRefundAmountByOrder(approvedAmount, order)
|
||||
}
|
||||
|
||||
func normalizeRefundVoucherKey(voucherKey string) (string, error) {
|
||||
normalized := strings.TrimSpace(voucherKey)
|
||||
if normalized == "" {
|
||||
return "", errors.New(errors.CodeInvalidParam, "退款申请必须上传退款凭证")
|
||||
func normalizeRefundVoucherKey(keys []string) (model.StringJSONBArray, error) {
|
||||
if len(keys) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "退款申请必须上传退款凭证")
|
||||
}
|
||||
if len(normalized) > 500 {
|
||||
return "", errors.New(errors.CodeInvalidParam, "退款凭证长度不能超过500")
|
||||
if len(keys) > 5 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "退款凭证最多上传5个")
|
||||
}
|
||||
return normalized, nil
|
||||
return model.StringJSONBArray(keys), nil
|
||||
}
|
||||
|
||||
// buildRefundResponse 将退款 Model 转换为 DTO 响应
|
||||
@@ -949,7 +947,7 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
|
||||
ActualReceivedAmount: r.ActualReceivedAmount,
|
||||
RequestedRefundAmount: r.RequestedRefundAmount,
|
||||
ApprovedRefundAmount: r.ApprovedRefundAmount,
|
||||
RefundVoucherKey: r.RefundVoucherKey,
|
||||
RefundVoucherKey: []string(r.RefundVoucherKey),
|
||||
RefundReason: r.RefundReason,
|
||||
Status: r.Status,
|
||||
StatusName: constants.GetRefundStatusName(r.Status),
|
||||
|
||||
106
internal/store/postgres/order_package_invalidate_task_store.go
Normal file
106
internal/store/postgres/order_package_invalidate_task_store.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// OrderPackageInvalidateTaskStore 订单套餐失效任务数据访问层
|
||||
type OrderPackageInvalidateTaskStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewOrderPackageInvalidateTaskStore 创建 OrderPackageInvalidateTaskStore 实例
|
||||
func NewOrderPackageInvalidateTaskStore(db *gorm.DB) *OrderPackageInvalidateTaskStore {
|
||||
return &OrderPackageInvalidateTaskStore{db: db}
|
||||
}
|
||||
|
||||
// Create 创建任务
|
||||
func (s *OrderPackageInvalidateTaskStore) Create(ctx context.Context, task *model.OrderPackageInvalidateTask) error {
|
||||
return s.db.WithContext(ctx).Create(task).Error
|
||||
}
|
||||
|
||||
// GetByID 按主键查询任务(Worker 直接查询,不过滤租户)
|
||||
func (s *OrderPackageInvalidateTaskStore) GetByID(ctx context.Context, id uint) (*model.OrderPackageInvalidateTask, error) {
|
||||
var task model.OrderPackageInvalidateTask
|
||||
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// List 分页查询任务列表
|
||||
func (s *OrderPackageInvalidateTaskStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.OrderPackageInvalidateTask, int64, error) {
|
||||
var tasks []*model.OrderPackageInvalidateTask
|
||||
var total int64
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&model.OrderPackageInvalidateTask{})
|
||||
|
||||
if status, ok := filters["status"].(int); ok && status > 0 {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
opts = &store.QueryOptions{
|
||||
Page: 1,
|
||||
PageSize: constants.DefaultPageSize,
|
||||
}
|
||||
}
|
||||
offset := (opts.Page - 1) * opts.PageSize
|
||||
query = query.Offset(offset).Limit(opts.PageSize).Order("created_at DESC")
|
||||
|
||||
if err := query.Find(&tasks).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return tasks, total, nil
|
||||
}
|
||||
|
||||
// UpdateStatus 更新任务状态
|
||||
func (s *OrderPackageInvalidateTaskStore) UpdateStatus(ctx context.Context, id uint, status int, errorMessage string) error {
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if status == model.ImportTaskStatusProcessing {
|
||||
updates["started_at"] = time.Now()
|
||||
}
|
||||
if status == model.ImportTaskStatusCompleted || status == model.ImportTaskStatusFailed {
|
||||
updates["completed_at"] = time.Now()
|
||||
}
|
||||
if errorMessage != "" {
|
||||
updates["error_message"] = errorMessage
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.OrderPackageInvalidateTask{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// UpdateResult 更新任务计数和失败明细
|
||||
func (s *OrderPackageInvalidateTaskStore) UpdateResult(ctx context.Context, id uint, totalCount, successCount, failCount int, failedItems model.ImportResultItems) error {
|
||||
updates := map[string]interface{}{
|
||||
"total_count": totalCount,
|
||||
"success_count": successCount,
|
||||
"fail_count": failCount,
|
||||
"failed_items": failedItems,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.OrderPackageInvalidateTask{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// GenerateTaskNo 生成任务编号(OPINV-YYYYMMDD-XXXXXX)
|
||||
func (s *OrderPackageInvalidateTaskStore) GenerateTaskNo() string {
|
||||
now := time.Now()
|
||||
dateStr := now.Format("20060102")
|
||||
seq := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("OPINV-%s-%06d", dateStr, seq)
|
||||
}
|
||||
@@ -131,6 +131,15 @@ func (s *PackageUsageStore) GetAddonsByMasterID(ctx context.Context, masterUsage
|
||||
return usages, nil
|
||||
}
|
||||
|
||||
// ListActiveByOrderID 查询订单下所有非终态套餐记录(status NOT IN 已过期=3, 已失效=4)
|
||||
func (s *PackageUsageStore) ListActiveByOrderID(ctx context.Context, orderID uint) ([]*model.PackageUsage, error) {
|
||||
var usages []*model.PackageUsage
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("order_id = ? AND status NOT IN ?", orderID, []int{3, 4}).
|
||||
Find(&usages).Error
|
||||
return usages, err
|
||||
}
|
||||
|
||||
// BatchUpdateStatus 批量更新加油包状态
|
||||
func (s *PackageUsageStore) BatchUpdateStatus(ctx context.Context, ids []uint, status int) error {
|
||||
if len(ids) == 0 {
|
||||
|
||||
257
internal/task/order_package_invalidate.go
Normal file
257
internal/task/order_package_invalidate.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||
)
|
||||
|
||||
// OrderPackageInvalidatePayload 批量失效订单套餐任务载荷
|
||||
type OrderPackageInvalidatePayload struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
}
|
||||
|
||||
// OrderPackageInvalidateHandler 批量失效订单套餐任务处理器
|
||||
type OrderPackageInvalidateHandler struct {
|
||||
taskStore *postgres.OrderPackageInvalidateTaskStore
|
||||
orderStore *postgres.OrderStore
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
storageService *storage.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewOrderPackageInvalidateHandler 创建处理器实例
|
||||
func NewOrderPackageInvalidateHandler(
|
||||
taskStore *postgres.OrderPackageInvalidateTaskStore,
|
||||
orderStore *postgres.OrderStore,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
storageSvc *storage.Service,
|
||||
logger *zap.Logger,
|
||||
) *OrderPackageInvalidateHandler {
|
||||
return &OrderPackageInvalidateHandler{
|
||||
taskStore: taskStore,
|
||||
orderStore: orderStore,
|
||||
packageUsageStore: packageUsageStore,
|
||||
storageService: storageSvc,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 处理批量失效订单套餐任务
|
||||
// POST /api/admin/order-package-invalidate-tasks
|
||||
func (h *OrderPackageInvalidateHandler) Handle(ctx context.Context, t *asynq.Task) error {
|
||||
var payload OrderPackageInvalidatePayload
|
||||
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析批量失效任务载荷失败",
|
||||
zap.Error(err),
|
||||
zap.String("task_id", t.ResultWriter().TaskID()),
|
||||
)
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
importTask, err := h.taskStore.GetByID(ctx, payload.TaskID)
|
||||
if err != nil {
|
||||
h.logger.Error("获取批量失效任务失败",
|
||||
zap.Uint("task_id", payload.TaskID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
if importTask.Status != model.ImportTaskStatusPending {
|
||||
h.logger.Info("批量失效任务已处理,跳过",
|
||||
zap.Uint("task_id", payload.TaskID),
|
||||
zap.Int("status", importTask.Status),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusProcessing, "")
|
||||
|
||||
h.logger.Info("开始处理批量失效订单套餐任务",
|
||||
zap.Uint("task_id", importTask.ID),
|
||||
zap.String("task_no", importTask.TaskNo),
|
||||
)
|
||||
|
||||
orderNos, err := h.downloadAndParseCSV(ctx, importTask)
|
||||
if err != nil {
|
||||
h.logger.Error("下载或解析 CSV 失败",
|
||||
zap.Uint("task_id", importTask.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, err.Error())
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
successCount, failedItems := h.processRows(ctx, orderNos)
|
||||
failCount := len(failedItems)
|
||||
totalCount := len(orderNos)
|
||||
|
||||
h.taskStore.UpdateResult(ctx, importTask.ID, totalCount, successCount, failCount,
|
||||
model.ImportResultItems(toImportResultItems(failedItems)))
|
||||
|
||||
if failCount > 0 && successCount == 0 {
|
||||
h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有行均处理失败")
|
||||
} else {
|
||||
h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusCompleted, "")
|
||||
}
|
||||
|
||||
h.logger.Info("批量失效订单套餐任务完成",
|
||||
zap.Uint("task_id", importTask.ID),
|
||||
zap.Int("total", totalCount),
|
||||
zap.Int("success", successCount),
|
||||
zap.Int("fail", failCount),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidateRow 单行处理结果
|
||||
type invalidateRow struct {
|
||||
line int
|
||||
orderNo string
|
||||
reason string
|
||||
}
|
||||
|
||||
// processRows 逐行处理订单号,返回成功数和失败列表
|
||||
func (h *OrderPackageInvalidateHandler) processRows(ctx context.Context, rows []string) (int, []invalidateRow) {
|
||||
successCount := 0
|
||||
var failed []invalidateRow
|
||||
|
||||
for i, orderNo := range rows {
|
||||
line := i + 2 // 第1行为表头,数据从第2行开始
|
||||
if err := h.processOneOrder(ctx, orderNo); err != nil {
|
||||
failed = append(failed, invalidateRow{line: line, orderNo: orderNo, reason: err.Error()})
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
return successCount, failed
|
||||
}
|
||||
|
||||
// processOneOrder 处理单个订单号:查订单 → 查套餐 → 批量更新状态=4
|
||||
func (h *OrderPackageInvalidateHandler) processOneOrder(ctx context.Context, orderNo string) error {
|
||||
order, err := h.orderStore.GetByOrderNo(ctx, orderNo)
|
||||
if err != nil {
|
||||
return errOrderNotFound(orderNo)
|
||||
}
|
||||
|
||||
usages, err := h.packageUsageStore.ListActiveByOrderID(ctx, order.ID)
|
||||
if err != nil {
|
||||
return errQueryFailed(orderNo)
|
||||
}
|
||||
|
||||
if len(usages) == 0 {
|
||||
// 套餐全部已是终态,视为成功
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]uint, 0, len(usages))
|
||||
for _, u := range usages {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
|
||||
if err := h.packageUsageStore.BatchUpdateStatus(ctx, ids, constants.PackageUsageStatusInvalidated); err != nil {
|
||||
return errUpdateFailed(orderNo)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadAndParseCSV 从对象存储下载 CSV 并解析 order_no 列
|
||||
func (h *OrderPackageInvalidateHandler) downloadAndParseCSV(ctx context.Context, task *model.OrderPackageInvalidateTask) ([]string, error) {
|
||||
if h.storageService == nil {
|
||||
return nil, ErrStorageNotConfigured
|
||||
}
|
||||
if task.StorageKey == "" {
|
||||
return nil, ErrStorageKeyEmpty
|
||||
}
|
||||
|
||||
localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
return parseOrderNoCSV(localPath)
|
||||
}
|
||||
|
||||
// parseOrderNoCSV 解析单列 CSV(第一列为 order_no,首行为表头)
|
||||
func parseOrderNoCSV(filePath string) ([]string, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
reader := csv.NewReader(f)
|
||||
reader.TrimLeadingSpace = true
|
||||
|
||||
var orderNos []string
|
||||
firstRow := true
|
||||
for {
|
||||
record, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if firstRow {
|
||||
firstRow = false
|
||||
continue // 跳过表头
|
||||
}
|
||||
if len(record) == 0 {
|
||||
continue
|
||||
}
|
||||
orderNo := strings.TrimSpace(record[0])
|
||||
if orderNo == "" {
|
||||
continue
|
||||
}
|
||||
orderNos = append(orderNos, orderNo)
|
||||
}
|
||||
|
||||
return orderNos, nil
|
||||
}
|
||||
|
||||
// toImportResultItems 将内部失败记录转换为通用失败明细格式
|
||||
// 复用 ImportResultItem,ICCID 字段存储 order_no
|
||||
func toImportResultItems(rows []invalidateRow) []model.ImportResultItem {
|
||||
items := make([]model.ImportResultItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, model.ImportResultItem{
|
||||
Line: r.line,
|
||||
ICCID: r.orderNo,
|
||||
Reason: r.reason,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func errOrderNotFound(orderNo string) error {
|
||||
return orderInvalidateError("订单不存在: " + orderNo)
|
||||
}
|
||||
|
||||
func errQueryFailed(orderNo string) error {
|
||||
return orderInvalidateError("查询套餐失败: " + orderNo)
|
||||
}
|
||||
|
||||
func errUpdateFailed(orderNo string) error {
|
||||
return orderInvalidateError("更新套餐状态失败: " + orderNo)
|
||||
}
|
||||
|
||||
type orderInvalidateError string
|
||||
|
||||
func (e orderInvalidateError) Error() string { return string(e) }
|
||||
@@ -9,11 +9,23 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// shouldStopPollingForRisk 判断轮询到风险 gateway_extend 时是否应停止该卡的轮询
|
||||
// 仅独立卡(is_standalone=true)命中风险状态时才停止;绑定设备的卡不受此逻辑约束
|
||||
func shouldStopPollingForRisk(card *model.IotCard, newGatewayExtend string) bool {
|
||||
if !card.IsStandalone {
|
||||
return false
|
||||
}
|
||||
extend := strings.TrimSpace(newGatewayExtend)
|
||||
return extend == constants.GatewayCardExtendRiskStop ||
|
||||
extend == constants.GatewayCardExtendCancelled
|
||||
}
|
||||
|
||||
// PollingCardStatusHandler 卡开停机状态轮询任务处理器
|
||||
// 职责:调 Gateway 查卡状态(正常/停机/准备)→ 映射为 network_status → 写 DB → 触发停复机评估
|
||||
// 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct
|
||||
@@ -141,6 +153,19 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, t *asynq.Task) er
|
||||
h.base.updateCardCache(ctx, cardID, cacheUpdates)
|
||||
}
|
||||
|
||||
// 独立卡写入风险 gateway_extend 后:关闭轮询,终止循环
|
||||
if statusQueried && shouldStopPollingForRisk(card, gatewayExtend) {
|
||||
if updateErr := h.iotCardStore.UpdatePollingStatus(ctx, cardID, false); updateErr != nil {
|
||||
h.base.logger.Warn("关闭风险卡轮询状态失败",
|
||||
zap.Uint("card_id", cardID), zap.String("gateway_extend", gatewayExtend), zap.Error(updateErr))
|
||||
} else {
|
||||
h.base.logger.Info("独立卡命中风险状态,已关闭轮询",
|
||||
zap.Uint("card_id", cardID), zap.String("gateway_extend", gatewayExtend))
|
||||
}
|
||||
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startTime))
|
||||
return nil // 不再入队,终止轮询循环
|
||||
}
|
||||
|
||||
if statusChanged {
|
||||
h.base.logger.Info("卡状态已变化",
|
||||
zap.Uint("card_id", cardID),
|
||||
|
||||
63
internal/task/polling_cardstatus_handler_test.go
Normal file
63
internal/task/polling_cardstatus_handler_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// TestShouldStopPolling 验证风险状态独立卡的轮询停止判断逻辑
|
||||
func TestShouldStopPolling(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
isStandalone bool
|
||||
gatewayExtend string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "独立卡+风险停机 -> 停止轮询",
|
||||
isStandalone: true,
|
||||
gatewayExtend: constants.GatewayCardExtendRiskStop,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "独立卡+已销户 -> 停止轮询",
|
||||
isStandalone: true,
|
||||
gatewayExtend: constants.GatewayCardExtendCancelled,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "非独立卡+风险停机 -> 不停止轮询",
|
||||
isStandalone: false,
|
||||
gatewayExtend: constants.GatewayCardExtendRiskStop,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "独立卡+机卡分离停机 -> 不停止轮询",
|
||||
isStandalone: true,
|
||||
gatewayExtend: constants.GatewayCardExtendMachineSeparated,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "独立卡+空扩展 -> 不停止轮询",
|
||||
isStandalone: true,
|
||||
gatewayExtend: "",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
card := &model.IotCard{
|
||||
IsStandalone: tc.isStandalone,
|
||||
GatewayExtend: tc.gatewayExtend,
|
||||
}
|
||||
got := shouldStopPollingForRisk(card, tc.gatewayExtend)
|
||||
if got != tc.want {
|
||||
t.Errorf("shouldStopPollingForRisk(standalone=%v, extend=%q) = %v, 期望 %v",
|
||||
tc.isStandalone, tc.gatewayExtend, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tb_iot_card_import_task DROP COLUMN IF EXISTS creator_name;
|
||||
ALTER TABLE tb_device_import_task DROP COLUMN IF EXISTS creator_name;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 为 IoT 卡导入任务表和设备导入任务表添加操作人快照字段
|
||||
-- 旧记录 creator_name 留空字符串,新任务创建时从登录用户快照姓名
|
||||
ALTER TABLE tb_iot_card_import_task
|
||||
ADD COLUMN IF NOT EXISTS creator_name varchar(100) NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE tb_device_import_task
|
||||
ADD COLUMN IF NOT EXISTS creator_name varchar(100) NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 回滚:jsonb 数组 → varchar(500),取第一个元素;空数组 → ''
|
||||
ALTER TABLE tb_order
|
||||
ALTER COLUMN payment_voucher_key TYPE varchar(500)
|
||||
USING COALESCE(payment_voucher_key->>0, '');
|
||||
|
||||
ALTER TABLE tb_refund_request
|
||||
ALTER COLUMN refund_voucher_key TYPE varchar(500)
|
||||
USING COALESCE(refund_voucher_key->>0, '');
|
||||
|
||||
ALTER TABLE tb_agent_recharge_record
|
||||
ALTER COLUMN payment_voucher_key TYPE varchar(500)
|
||||
USING COALESCE(payment_voucher_key->>0, '');
|
||||
|
||||
ALTER TABLE tb_agent_recharge_record DROP COLUMN IF EXISTS remark;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 将三个表的凭证字段从 varchar(500) 改为 jsonb 数组存储(最多5个元素)
|
||||
-- 同时为代理预充值记录新增 remark 备注字段
|
||||
-- 注意:旧数据可能为逗号分隔多路径,用 string_to_array 拆分为数组元素
|
||||
-- USING 子句中必须显式 ::text 转换,避免 PostgreSQL 隐式将空字符串转 jsonb 报错
|
||||
|
||||
-- tb_order.payment_voucher_key: 空/NULL → [],非空 → 按逗号拆分为数组
|
||||
ALTER TABLE tb_order
|
||||
ALTER COLUMN payment_voucher_key TYPE jsonb
|
||||
USING CASE
|
||||
WHEN payment_voucher_key IS NULL OR payment_voucher_key::text = '' THEN '[]'::jsonb
|
||||
ELSE to_jsonb(string_to_array(payment_voucher_key::text, ','))
|
||||
END;
|
||||
|
||||
-- tb_refund_request.refund_voucher_key: 先删除旧 varchar DEFAULT 再转类型
|
||||
ALTER TABLE tb_refund_request
|
||||
ALTER COLUMN refund_voucher_key DROP DEFAULT;
|
||||
|
||||
ALTER TABLE tb_refund_request
|
||||
ALTER COLUMN refund_voucher_key TYPE jsonb
|
||||
USING CASE
|
||||
WHEN refund_voucher_key IS NULL OR refund_voucher_key::text = '' THEN '[]'::jsonb
|
||||
ELSE to_jsonb(string_to_array(refund_voucher_key::text, ','))
|
||||
END;
|
||||
|
||||
ALTER TABLE tb_refund_request
|
||||
ALTER COLUMN refund_voucher_key SET DEFAULT '[]'::jsonb;
|
||||
|
||||
-- tb_agent_recharge_record.payment_voucher_key: 同 tb_order
|
||||
ALTER TABLE tb_agent_recharge_record
|
||||
ALTER COLUMN payment_voucher_key TYPE jsonb
|
||||
USING CASE
|
||||
WHEN payment_voucher_key IS NULL OR payment_voucher_key::text = '' THEN '[]'::jsonb
|
||||
ELSE to_jsonb(string_to_array(payment_voucher_key::text, ','))
|
||||
END;
|
||||
|
||||
-- tb_agent_recharge_record 新增 remark 备注字段(创建后只读,NULL 表示未填写)
|
||||
ALTER TABLE tb_agent_recharge_record
|
||||
ADD COLUMN IF NOT EXISTS remark text;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS tb_order_package_invalidate_task;
|
||||
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE IF NOT EXISTS tb_order_package_invalidate_task (
|
||||
id bigserial PRIMARY KEY,
|
||||
task_no varchar(50) NOT NULL UNIQUE,
|
||||
status int NOT NULL DEFAULT 1,
|
||||
total_count int NOT NULL DEFAULT 0,
|
||||
success_count int NOT NULL DEFAULT 0,
|
||||
fail_count int NOT NULL DEFAULT 0,
|
||||
failed_items jsonb NOT NULL DEFAULT '[]',
|
||||
file_name varchar(255) NOT NULL DEFAULT '',
|
||||
storage_key varchar(500) NOT NULL DEFAULT '',
|
||||
voucher_keys jsonb NOT NULL DEFAULT '[]',
|
||||
remark text NOT NULL DEFAULT '',
|
||||
creator_name varchar(100) NOT NULL DEFAULT '',
|
||||
error_message text NOT NULL DEFAULT '',
|
||||
started_at timestamptz,
|
||||
completed_at timestamptz,
|
||||
creator bigint NOT NULL DEFAULT 0,
|
||||
updater bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_pkg_inv_task_status ON tb_order_package_invalidate_task (status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_order_pkg_inv_task_created_at ON tb_order_package_invalidate_task (created_at DESC) WHERE deleted_at IS NULL;
|
||||
@@ -67,6 +67,9 @@ const (
|
||||
TaskTypePackageQueueActivation = "package:queue:activation" // 主套餐排队激活
|
||||
TaskTypePackageDataReset = "package:data:reset" // 套餐流量重置
|
||||
|
||||
// 订单套餐失效任务类型
|
||||
TaskTypeOrderPackageInvalidate = "order:package:invalidate" // 批量失效订单套餐
|
||||
|
||||
// 订单超时任务类型
|
||||
TaskTypeOrderExpire = "order:expire" // 订单超时自动取消
|
||||
TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包
|
||||
@@ -194,6 +197,7 @@ const (
|
||||
QueuePackageFirstActivation = TaskTypePackageFirstActivation // 首次实名激活任务队列
|
||||
QueuePackageQueueActivation = TaskTypePackageQueueActivation // 主套餐排队激活任务队列
|
||||
QueuePackageDataReset = TaskTypePackageDataReset // 套餐流量重置任务队列
|
||||
QueueOrderPackageInvalidate = TaskTypeOrderPackageInvalidate // 批量失效订单套餐任务队列
|
||||
QueueOrderExpire = TaskTypeOrderExpire // 订单超时取消任务队列
|
||||
QueueAutoPurchase = TaskTypeAutoPurchaseAfterRecharge // 充值后自动购包任务队列
|
||||
QueueAlertCheck = TaskTypeAlertCheck // 告警检查任务队列
|
||||
@@ -244,6 +248,8 @@ func QueueForTaskType(taskType string) string {
|
||||
return QueuePackageQueueActivation
|
||||
case TaskTypePackageDataReset:
|
||||
return QueuePackageDataReset
|
||||
case TaskTypeOrderPackageInvalidate:
|
||||
return QueueOrderPackageInvalidate
|
||||
case TaskTypeOrderExpire:
|
||||
return QueueOrderExpire
|
||||
case TaskTypeAutoPurchaseAfterRecharge:
|
||||
@@ -268,6 +274,7 @@ func DefaultTaskQueueWeights() map[string]int {
|
||||
QueueAutoPurchase: 5,
|
||||
QueuePackageFirstActivation: 5,
|
||||
QueuePackageQueueActivation: 5,
|
||||
QueueOrderPackageInvalidate: 5,
|
||||
QueueOrderExpire: 4,
|
||||
QueueEmailSend: 4,
|
||||
QueueExportDispatch: 4,
|
||||
|
||||
@@ -60,9 +60,11 @@ const (
|
||||
GatewayCardStatusReady = "准备" // 网关卡状态:准备
|
||||
GatewayCardStatusNormal = "正常" // 网关卡状态:正常
|
||||
GatewayCardStatusStopped = "停机" // 网关卡状态:停机
|
||||
GatewayCardExtendPendingActivation = "待激活" // 网关扩展状态:待激活
|
||||
GatewayCardExtendMachineSeparated = "机卡分离停机" // 网关扩展状态:机卡分离停机
|
||||
GatewayCardExtendPendingActivation = "待激活" // 网关扩展状态:待激活
|
||||
GatewayCardExtendMachineSeparated = "机卡分离停机" // 网关扩展状态:机卡分离停机
|
||||
GatewayCardStartExtendMachineSeparated = "2" // 机卡分离复机时上传给运营商的 extend 值
|
||||
GatewayCardExtendRiskStop = "风险停机" // 网关扩展状态:运营商风险停机(独立卡不允许复机)
|
||||
GatewayCardExtendCancelled = "已销户" // 网关扩展状态:已销户(独立卡不允许复机)
|
||||
)
|
||||
|
||||
// IoT 卡停机原因
|
||||
|
||||
@@ -64,6 +64,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
||||
WechatConfig: admin.NewWechatConfigHandler(nil),
|
||||
AgentRecharge: admin.NewAgentRechargeHandler(nil, nil),
|
||||
Refund: admin.NewRefundHandler(nil),
|
||||
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(nil),
|
||||
ClientWechat: app.NewClientWechatHandler(nil, nil, nil),
|
||||
SuperAdmin: admin.NewSuperAdminHandler(nil),
|
||||
AgentOpenAPI: openapiHandler.NewHandler(nil, nil),
|
||||
|
||||
@@ -65,6 +65,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
|
||||
|
||||
h.registerIotCardImportHandler()
|
||||
h.registerDeviceImportHandler()
|
||||
h.registerOrderPackageInvalidateHandler()
|
||||
h.registerExportHandlers()
|
||||
h.registerCommissionStatsHandlers()
|
||||
h.registerCommissionCalculationHandler()
|
||||
@@ -97,6 +98,18 @@ func (h *Handler) registerIotCardImportHandler() {
|
||||
h.logger.Info("注册 IoT 卡导入任务处理器", zap.String("task_type", constants.TaskTypeIotCardImport))
|
||||
}
|
||||
|
||||
func (h *Handler) registerOrderPackageInvalidateHandler() {
|
||||
orderPkgHandler := task.NewOrderPackageInvalidateHandler(
|
||||
h.workerResult.Stores.OrderPackageInvalidateTask,
|
||||
h.workerResult.Stores.Order,
|
||||
h.workerResult.Stores.PackageUsage,
|
||||
h.storage,
|
||||
h.logger,
|
||||
)
|
||||
h.mux.HandleFunc(constants.TaskTypeOrderPackageInvalidate, orderPkgHandler.Handle)
|
||||
h.logger.Info("注册订单套餐批量失效任务处理器", zap.String("task_type", constants.TaskTypeOrderPackageInvalidate))
|
||||
}
|
||||
|
||||
func (h *Handler) registerDeviceImportHandler() {
|
||||
deviceImportHandler := task.NewDeviceImportHandler(
|
||||
h.db,
|
||||
|
||||
@@ -46,8 +46,9 @@ type WorkerStores struct {
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
}
|
||||
|
||||
// WorkerServices Worker 侧所有 Service 的集合
|
||||
|
||||
Reference in New Issue
Block a user