迭代方案确认

This commit is contained in:
2026-07-17 16:39:41 +08:00
parent bcf3e31db6
commit d022cc8788
36 changed files with 3104 additions and 6882 deletions

View File

@@ -0,0 +1,56 @@
# 需求01行业卡后台手动复机允许未实名
> 状态:原需求来源稿;实名最终规则已由数据同步方案和标准评审稿修正。
---
## 背景
**当前代码**`internal/service/iot_card/stop_resume_service.go:938`
```go
// ManualStartCard - 当前写法(错误)
if card.RealNameStatus != constants.RealNameStatusVerified {
return errors.New(errors.CodeForbidden, "卡未实名,无法操作")
}
```
`isRealnameOK()` 已经正确处理行业卡豁免:
```go
// 第234行行业卡无需实名
func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool {
return card.CardCategory == constants.CardCategoryIndustry ||
card.RealNameStatus == constants.RealNameStatusVerified
}
```
`ManualStartCard` 没有走这个函数,直接判断了 `RealNameStatus`,导致行业卡手动复机也被拦截。
---
## 修改范围
**只改一行**,影响范围极小。
**文件**`internal/service/iot_card/stop_resume_service.go`
```go
// 修改前第938行
if card.RealNameStatus != constants.RealNameStatusVerified {
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
...
}
// 修改后
if !s.isRealnameOK(card) {
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
...
}
```
---
## 前端对接
无需前端改动。复机操作界面不变,行业卡原先会报错"卡未实名,无法操作",修复后直接成功。

View File

@@ -0,0 +1,213 @@
# 需求02H5 流程顺序配置化
> 状态:原需求独立稿;最终口径以标准评审稿为准。
---
## 背景
H5 用户进入后:绑定手机号 → 充值 → 实名(当前顺序写死)
需求:允许**按资产个体**配置充值和实名的顺序,支持后台单条或批量改。
---
## 流程图
### 图一H5 登录后资产视角判断与策略读取
```mermaid
flowchart TD
A[用户扫码 / 输入虚拟号] --> B[解析 identifier]
B --> C{资产类型}
C -->|card| D{该卡是否绑定设备?}
D -->|否 独立卡| E[卡视角\n读 IotCard.realname_policy]
D -->|是| F[设备视角\n读 Device.realname_policy\n卡自身策略忽略]
C -->|device| F
E --> G{realname_policy}
F --> G
G -->|none| H[无需实名\n直接进充值页]
G -->|before_order| I[先进实名页\n实名完成后才能充值]
G -->|after_order| J[先进充值页\n充值完成后提示实名]
```
### 图二H5 充值/购买前的策略拦截逻辑
```mermaid
flowchart TD
A[用户发起充值/购买] --> B[读取 resolved.Asset.RealnamePolicy]
B --> C{策略是 before_order?}
C -->|否| D[放行,正常创建订单]
C -->|是| E{当前资产 RealNameStatus == 1?}
E -->|已实名| D
E -->|未实名| F[返回 CodeNeedRealname\nH5 跳转实名页]
```
### 图三GetEffectiveRealnamePolicy 取值逻辑
```mermaid
flowchart TD
A[GetEffectiveRealnamePolicy\ncard, device] --> B{device != nil?}
B -->|是| C[返回 device.RealnamePolicy]
B -->|否| D{card != nil?}
D -->|是| E[返回 card.RealnamePolicy]
D -->|否| F[返回 none]
```
---
## 资产类型与策略归属
系统有两类资产:**独立卡** 和 **设备**
| 资产类型 | realname_policy 归属 | H5 视角 |
|---------|---------------------|---------|
| 独立卡(无设备绑定) | 卡自身的 `realname_policy` | 卡视角 |
| 设备 | 设备自身的 `realname_policy` | 设备视角 |
| 设备下的卡 | **设备**的 `realname_policy`(卡自身策略无效) | 设备视角 |
**关键规则**:设备下的卡无法以卡视角独立登录 H5登录后自动进入设备视角因此实名流程策略由设备决定卡自身的 `realname_policy` 字段对 H5 流程无影响(但字段保留,仅作记录)。
该逻辑已在 `internal/service/asset/service.go:GetEffectiveRealnamePolicy()` 实现:设备不为 nil 时取设备策略,否则取卡策略。
---
## 当前字段状态
两个模型都已有 `realname_policy` 字段,无需迁移:
```go
// internal/model/iot_card.go
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;
comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"`
// internal/model/device.go
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;
comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"`
```
值含义:
- `none` — 无需实名
- `before_order` — 先实名后充值
- `after_order` — 先充值后实名(**当前默认**
---
## 后端实现
### 1. 单条修改接口(已有,无需新建)
```
PATCH /api/admin/assets/:identifier/realname-mode
```
该接口已在 `internal/handler/admin/asset.go:UpdateRealnamePolicy()` 实现,通过 identifier 自动解析资产类型,卡和设备都走这里,**不需要再建卡专属或设备专属路由**。
请求体(已有 DTO
```go
type UpdateAssetRealnamePolicyRequest struct {
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
}
```
### 2. 新增批量修改接口(需新建)
卡和设备分开批量接口,因为两者在后台是不同的列表页。
#### 2a. 批量修改卡实名策略
```
POST /api/admin/iot-cards/batch-update-realname-policy
```
请求体:
```go
type BatchUpdateIotCardRealnamePolicy struct {
IotCardIDs []uint `json:"iot_card_ids" validate:"required,min=1,max=500,dive,gt=0" description:"卡ID列表最多500条"`
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
}
```
Service在一个事务中校验最多 500 条 ID 均存在且均在当前账号数据范围内,再执行 `UPDATE tb_iot_card SET realname_policy = ? WHERE id IN (...)`。任一记录不合法则整批回滚,并写一条包含目标策略和 ID 数量的批量审计日志。
#### 2b. 批量修改设备实名策略
```
POST /api/admin/devices/batch-update-realname-policy
```
请求体:
```go
type BatchUpdateDeviceRealnamePolicy struct {
DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=500,dive,gt=0" description:"设备ID列表最多500条"`
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
}
```
Service与卡批量接口相同单次最多 500 条、事务内全成全败;任一设备不存在或越权则不更新任何记录。
### 3. 全局默认值(兜底)
新建卡/设备时默认 `after_order`,通过 GORM default 标签保证,不需要读 `tb_system_config`
---
## 前端对接
### 卡列表页
"操作"列或批量操作下拉增加"设置实名策略"
- **单条**:弹框选策略 → `PATCH /api/admin/assets/{iccid}/realname-mode`
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/iot-cards/batch-update-realname-policy`
> 注意:设备下的卡即使在卡列表中修改了策略,对 H5 流程也无效H5 取设备策略)。建议在卡列表展示"所属设备"列,提示运营该卡已属于某设备,实名策略需到设备处修改。
### 设备列表页
"操作"列或批量操作下拉增加"设置实名策略"
- **单条**:弹框选策略 → `PATCH /api/admin/assets/{sn}/realname-mode`
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/devices/batch-update-realname-policy`
### 字段展示
卡列表/详情、设备列表/详情均展示"实名策略"字段:
| realname_policy | 展示文案 |
|----------------|---------|
| `none` | 无需实名 |
| `before_order` | 先实名后充值 |
| `after_order` | 先充值后实名 |
### H5 侧C端
H5 读取资产初始化接口返回的 `realname_policy` 字段,决定先跳充值页还是先跳实名页。
- 独立卡登录 → 读卡的 `realname_policy`
- 设备/设备下的卡登录 → 读设备的 `realname_policy`
该逻辑由 `GetEffectiveRealnamePolicy()` 统一处理H5 无需区分资产类型,直接用接口返回值即可。
---
## 实施范围汇总
| 项目 | 状态 | 说明 |
|------|------|------|
| `IotCard.realname_policy` 字段 | ✅ 已有 | 无需迁移 |
| `Device.realname_policy` 字段 | ✅ 已有 | 无需迁移 |
| 单条修改接口 | ✅ 已有 | `PATCH /api/admin/assets/:identifier/realname-mode` |
| `GetEffectiveRealnamePolicy()` | ✅ 已有 | 设备视角取设备策略 |
| H5 充值前校验 | ✅ 已有 | `client_wallet.go` 已正确读取 |
| 批量修改卡接口 | ❌ 待建 | `POST /api/admin/iot-cards/batch-update-realname-policy` |
| 批量修改设备接口 | ❌ 待建 | `POST /api/admin/devices/batch-update-realname-policy` |
| 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 |
| 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 |

View File

@@ -0,0 +1,321 @@
# 需求03/07/11/12/13简单改动合集
> 状态:原需求独立稿;最终口径以标准评审稿为准。
---
## 需求03店铺列表搜索新增联系电话
### 后端
`Shop` 表已有 `contact_phone` 字段。仅需在列表查询接口新增过滤条件。
**文件**`internal/store/postgres/shop_store.go`(列表查询 Store 方法)
```go
// 现有过滤条件基础上追加
if req.ContactPhone != "" {
query = query.Where("contact_phone = ?", req.ContactPhone)
}
```
**DTO 变更**`internal/model/dto/shop_dto.go``ShopListRequest` 新增:
```go
ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话精确匹配11位"`
```
### 前端
店铺列表搜索栏新增"联系电话"输入框,填入后带入 `contact_phone` 参数请求。
---
## 需求07IoT卡/设备管理新增已实名/未实名筛选
### IoT 卡
`IotCard.real_name_status` 已有0=未实名, 1=已实名),`ListStandaloneIotCardRequest` 无该过滤字段,需新增。
**DTO 变更**`internal/model/dto/iot_card_dto.go``ListStandaloneIotCardRequest` 新增):
```go
RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"`
```
**Store 追加**`internal/store/postgres/iot_card_store.go`
```go
if req.RealNameStatus != nil {
query = query.Where("real_name_status = ?", *req.RealNameStatus)
}
```
### 设备
设备本身目前无 `real_name_status` 字段。语义为:任意一张绑定卡已实名 = 设备已实名。
为避免列表查询时走 EXISTS 子查询,改为**快照方案**:在 `Device` 表落盘,轮询时维护。
#### 迁移
`tb_device` 新增字段:
```sql
ALTER TABLE tb_device
ADD COLUMN real_name_status INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_device.real_name_status
IS '实名状态快照(0=未实名,1=已实名)任意绑定卡已实名则为1由轮询异步维护';
```
**Model**`internal/model/device.go`
```go
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态快照(0=未实名,1=已实名)任意绑定卡已实名则为1" json:"real_name_status"`
```
#### 快照更新时机
以下两处卡实名状态变化时,需同步更新所属设备的快照:
**1. 轮询实名处理**`internal/task/polling_realname_handler.go`
卡状态变化后,已有 `triggerDeviceRealnameActivation` 查出 `deviceID`,在此同步更新设备快照:
```go
// statusChanged 时,如果卡属于某设备,重新计算并写入设备快照
if statusChanged {
if binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, cardID); err == nil {
h.deviceStore.RefreshRealnameSnapshot(ctx, binding.DeviceID)
}
}
```
**2. 管理员手动修改卡实名状态**`internal/service/iot_card/service.go:ManualUpdateRealnameStatus`
更新卡状态成功后,查所属设备并更新快照(同上逻辑)。
#### 快照计算
`DeviceStore.RefreshRealnameSnapshot`
```go
// RefreshRealnameSnapshot 重新计算并写入设备实名状态快照
func (s *DeviceStore) RefreshRealnameSnapshot(ctx context.Context, deviceID uint) error {
var count int64
s.db.WithContext(ctx).Raw(`
SELECT COUNT(*) FROM tb_device_sim_binding dsb
JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id
WHERE dsb.device_id = ? AND dsb.deleted_at IS NULL
AND ic.real_name_status = 1 AND ic.deleted_at IS NULL
`, deviceID).Scan(&count)
status := 0
if count > 0 {
status = 1
}
return s.db.WithContext(ctx).Model(&model.Device{}).
Where("id = ?", deviceID).
Update("real_name_status", status).Error
}
```
#### DTO 变更
**请求**`internal/model/dto/device_dto.go``ListDeviceRequest` 新增):
```go
RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"`
```
**响应**`DeviceResponse` 新增):
```go
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"`
```
**Store 过滤**(直接 WHERE无需 EXISTS
```go
if req.RealNameStatus != nil {
query = query.Where("real_name_status = ?", *req.RealNameStatus)
}
```
### 前端
IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名)→ 传 `real_name_status=0|1`
设备管理同上,列表展示 `real_name_status_name` 字段。
---
## 需求11资产详情套餐到期时间与需求06合并
需求11和需求06属于同一业务需求资产层只展示当前及排队主套餐连续使用后的预计最终到期时间。详细计算、DTO 和前端规则统一见[需求04/06独立稿](./需求04-06-退款拦截与最后到期时间.md)。
现有 `current_package_expires_at` 只表示当前套餐结束时间,不能代表资产服务最终结束时间,因此不得继续作为资产详情汇总到期时间或临期判断依据。
统一使用:
```text
estimated_final_expires_at
days_until_final_expiry
expiry_estimate_status
```
前端不自行计算天数高亮颜色和临期状态直接使用需求22统一返回字段。
---
## 需求12换货管理显示修复
### 背景
换货单表 `tb_exchange_order`
- `old_asset_identifier` — 旧资产标识符快照
- `new_asset_identifier` — 新资产标识符快照
- `old_asset_id` / `new_asset_id` — 旧/新资产主键
### EXC-001/EXC-002旧/新资产标识显示不一致
**根本原因**:后端创建换货单时快照逻辑有误(`internal/service/exchange/service.go`)。
- 卡的旧资产:快照了 `card.VirtualNo`(虚拟号),**应为 `card.ICCID`**
- 卡的新资产:快照了操作员输入的 identifier 原值,未规范化,**应统一为 `card.ICCID`**
- 设备:快照 `VirtualNo` 优先,没有则 `IMEI`**逻辑正确,无需改动**
**修复**`internal/service/exchange/service.go`
`resolveAssetByIdentifierWithTx` 及锁定资产路径中,卡的 `Identifier` 改为 `card.ICCID`
```go
// 修复前
return &resolvedExchangeAsset{..., Identifier: card.VirtualNo, ...}
// 修复后
return &resolvedExchangeAsset{..., Identifier: card.ICCID, ...}
```
历史数据不回填,仅修正后续新建换货单的快照行为。
### EXC-003/EXC-004旧/新资产搜索支持 ICCID/接入号/虚拟号
**方案**:拆分为独立的旧资产和新资产搜索,搜索逻辑用**两步查询**,不用 JOIN。
**DTO 变更**`internal/model/dto/exchange_dto.go``ExchangeListRequest`
废弃原有 `Identifier` 字段,改为:
```go
OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" description:"旧资产搜索ICCID/接入号/虚拟号)"`
NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" description:"新资产搜索ICCID/接入号/虚拟号)"`
```
**Store 修改**`internal/store/postgres/exchange_order_store.go`
两步查询——先在资产表搜出 ID再过滤换货表
```go
// 步骤1旧资产关键词搜索
if req.OldAssetKeyword != "" {
kw := "%" + req.OldAssetKeyword + "%"
var cardIDs []uint
s.db.WithContext(ctx).Table("tb_iot_card").
Where("(iccid LIKE ? OR virtual_no LIKE ? OR msisdn LIKE ?) AND deleted_at IS NULL", kw, kw, kw).
Pluck("id", &cardIDs)
var deviceIDs []uint
s.db.WithContext(ctx).Table("tb_device").
Where("(virtual_no LIKE ? OR imei LIKE ?) AND deleted_at IS NULL", kw, kw).
Pluck("id", &deviceIDs)
if len(cardIDs) == 0 && len(deviceIDs) == 0 {
return &ExchangeListResult{}, nil // 无匹配,直接返回空
}
query = query.Where(
"(old_asset_type = 'iot_card' AND old_asset_id IN ?) OR (old_asset_type = 'device' AND old_asset_id IN ?)",
cardIDs, deviceIDs,
)
}
// new_asset_keyword 同理,过滤 new_asset_id
```
### 前端
- EXC-001/002后端修复后`old_asset_identifier``new_asset_identifier` 均为 ICCID或设备号设备展示直接读这两个字段即可
- EXC-003/004搜索栏拆分为"旧资产"和"新资产"两个独立输入框,分别传 `old_asset_keyword``new_asset_keyword`
---
## 需求13列表字段新增
### 核心原则
- 提交人账号名在业务单创建时快照到业务表。
- 审批节点、候选审批人和实际操作人快照统一保存在审批流任务表,不在业务表写死具体节点字段。
- 列表查询审批信息时,根据本页全部 `approval_instance_id` 批量查询并在内存分组,禁止逐条查询造成 N+1。
---
### COL-003换货管理列表新增提交人待建
> 需求文档原写"换号管理",确认为"换货管理"(系统无"换号"概念)。
**迁移**`tb_exchange_order` 新增字段:
```sql
ALTER TABLE tb_exchange_order ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
```
**Model**`internal/model/exchange_order.go`
```go
SubmitterName string `gorm:"column:submitter_name;type:varchar(50);not null;default:'';comment:提交人账号名快照" json:"submitter_name"`
```
**创建换货单时**`internal/service/exchange/service.go`)快照当前操作人 username
```go
SubmitterName: middleware.GetUsername(ctx), // 从 ctx 取当前登录账号的 username
```
**响应 DTO**`internal/model/dto/exchange_dto.go``ExchangeOrderResponse` 新增):
```go
SubmitterName string `json:"submitter_name" description:"提交人账号名"`
```
---
### COL-001退款管理列表新增提交人、审批人依赖审批流
**迁移**`tb_refund_request` 新增提交人快照字段;`approval_instance_id` 由需求18/20统一增加
```sql
ALTER TABLE tb_refund_request
ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
```
- `submitter_name`:创建退款单时快照操作人 username
- 审批状态、当前节点和审批记录:从审批实例、任务和任务审批人快照批量读取
> **实施依赖**动态审批摘要依赖审批流需求20`submitter_name` 可独立实现。
**响应 DTO**(退款列表响应新增):
```go
SubmitterName string `json:"submitter_name" description:"提交人账号名"`
ApprovalSource string `json:"approval_source" description:"审批来源 (none:无需审批, workflow:通用审批流, legacy:历史业务审批)"`
ApprovalStatus int `json:"approval_status" description:"审批状态 (1:审批中, 2:已通过, 3:已驳回, 4:已退回)"`
ApprovalStatusName string `json:"approval_status_name" description:"审批状态名称(中文)"`
CurrentApprovalNode string `json:"current_approval_node" description:"当前审批节点名称"`
ApprovalRecords []ApprovalRecordSummary `json:"approval_records" description:"审批节点和审批人摘要"`
ProcessingStatus int `json:"processing_status" description:"审批通过后的业务处理状态"`
ProcessingStatusName string `json:"processing_status_name" description:"业务处理状态名称(中文)"`
```
`ApprovalRecordSummary` 动态返回 `node_name``approval_mode``status` 和审批人列表;每位已操作审批人包含动作、审批意见和 `attachment_count`,但列表接口不返回完整附件元数据。不假设固定存在“部门领导”或“财务”节点。
停机发布前已经结束且没有流程实例的退款记录返回 `approval_source=legacy`。这类记录可以使用原 `processor_id``processed_at` 和审计日志组成只读历史摘要,但不得伪造多节点审批时间线;发布时仍待审批的记录必须先回填通用审批实例。
---
### COL-002代理充值列表新增提交人、审批人依赖审批流
与 COL-001 同理,`tb_agent_recharge_record` 仅新增 `submitter_name` 快照字段;`approval_instance_id` 由需求18/21统一增加。审批摘要从审批流批量读取。历史终态充值返回 `approval_source=legacy` 并只读展示原状态和审计信息。
> **实施依赖**`submitter_name` 本迭代可实现动态审批摘要依赖需求21充值审批流
---
### 前端
退款和充值列表增加“审批状态 / 当前节点 / 业务处理状态 / 审批记录”展示。审批记录按节点动态渲染,不能固定绑定两个审批人字段;审批已通过后的代理钱包退款可显示“回退处理中”,其他支付方式显示“待人工退款”,都不能显示成“待审批”。`approval_source=legacy` 时显示“历史审批”标识且不提供操作按钮。

View File

@@ -0,0 +1,206 @@
# 需求04退款中资产禁止换货
# 需求06/11资产预计最终到期时间
> 状态:原需求独立稿;最终口径以标准评审稿为准。
---
## 需求04退款中禁止换货
### 业务规则
资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。
拦截范围:
- `status=1` 待审批。
- `status=4` 已退回,等待提交人修改。
- `status=2` 已通过但 `processing_status!=2`,实际退款仍在处理、等待处理或失败重试。
不拦截:`status=3` 已拒绝,或 `status=2 AND processing_status=2` 已完成实际退款。`processing_status` 由需求20新增。
### 数据模型
退款模型:`RefundRequest`(表 `tb_refund_request`
资产字段为两个独立字段(无 asset_type/asset_id
- `iot_card_id *uint`IoT卡ID卡类资产
- `device_id *uint`设备ID设备类资产
状态常量(`internal/model/refund.go`
```go
RefundStatusPending = 1 // 待审批
RefundStatusApproved = 2 // 已通过
RefundStatusRejected = 3 // 已拒绝
RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货)
```
### 实现位置
换货单创建入口:`internal/service/exchange/service.go` 创建前校验。
### 后端
**Store 新增方法**`internal/store/postgres/refund_store.go`
```go
// HasActiveRefundByCard 检查指定IoT卡是否存在未结束的退款申请
func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
Where(`iot_card_id = ?
AND deleted_at IS NULL
AND (
status IN (?, ?)
OR (status = ? AND processing_status <> ?)
)`,
cardID,
model.RefundStatusPending, // 1=待审批
model.RefundStatusReturned, // 4=已退回(拦截)
model.RefundStatusApproved, // 2=审批已通过
constants.ProcessingStatusSucceeded,
).Count(&count).Error
return count > 0, err
}
// HasActiveRefundByDevice 检查指定设备是否存在未结束的退款申请
func (s *RefundStore) HasActiveRefundByDevice(ctx context.Context, deviceID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
Where(`device_id = ?
AND deleted_at IS NULL
AND (
status IN (?, ?)
OR (status = ? AND processing_status <> ?)
)`,
deviceID,
model.RefundStatusPending, // 1=待审批
model.RefundStatusReturned, // 4=已退回(拦截)
model.RefundStatusApproved, // 2=审批已通过
constants.ProcessingStatusSucceeded,
).Count(&count).Error
return count > 0, err
}
```
**Service 校验**`internal/service/exchange/service.go` 创建换货单前调用):
```go
// validateNoActiveRefund 校验资产是否有未结束的退款申请
func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *resolvedExchangeAsset) error {
var hasActive bool
var err error
if asset.CardID != nil {
hasActive, err = s.refundStore.HasActiveRefundByCard(ctx, *asset.CardID)
} else if asset.DeviceID != nil {
hasActive, err = s.refundStore.HasActiveRefundByDevice(ctx, *asset.DeviceID)
}
if err != nil {
return err
}
if hasActive {
return errors.New(errors.CodeForbidden, "该资产存在退款申请,无法操作换货")
}
return nil
}
```
### 前端
无需改动。换货申请时后端返回错误,前端展示错误信息即可。
---
## 需求06/11资产详情-预计最终到期时间
### 业务规则
需求06与需求11是同一个业务需求的两种描述不再拆成“当前套餐到期”和“所有套餐最后到期”两个资产汇总字段。资产详情页只展示**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最终到期时间**。
不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。
加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。
### 接口
后台资产详情页实际调用的是:
```
GET /api/admin/assets/resolve/:identifier
```
响应 DTO`AssetResolveResponse``internal/model/dto/asset_dto.go`
该 DTO 目前只有当前套餐到期时间,需新增统一的最终到期响应,并由前端替代原资产汇总展示。
### 后端
**DTO 新增字段**`internal/model/dto/asset_dto.go``AssetResolveResponse`
```go
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at" description:"当前及排队主套餐接续后的预计最终到期时间"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余自然日"`
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态 (exact:可推算, waiting_activation:等待未知激活时间, none:无套餐)"`
```
**Store 新增方法**`internal/store/postgres/package_usage_store.go`
```go
// GetProjectableMainPackagesByCardID 获取可推算的当前/排队主套餐。
func (s *PackageUsageStore) GetProjectableMainPackagesByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage
err := s.db.WithContext(ctx).
Where("iot_card_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", cardID,
constants.PackageUsageStatusActive, // 1=生效中
constants.PackageUsageStatusPending, // 0=待生效
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
).
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error
return usages, err
}
// GetProjectableMainPackagesByDeviceID 获取可推算的当前/排队主套餐。
func (s *PackageUsageStore) GetProjectableMainPackagesByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage
err := s.db.WithContext(ctx).
Where("device_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", deviceID,
constants.PackageUsageStatusActive, // 1=生效中
constants.PackageUsageStatusPending, // 0=待生效
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
).
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error
return usages, err
}
```
新建 `PackageUsage` 必须快照 `calendar_type_snapshot``duration_months_snapshot``duration_days_snapshot`与需求05的 `expiry_base_snapshot` 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容。
**Service 计算逻辑**(在 `ResolveAsset` 结果组装处添加):
```go
// 1. 找当前主套餐的 expires_at 作为 cursor。
// 2. 按 priority、created_at、id 遍历排队主套餐。
// 3. 每个排队套餐以 cursor 为预计激活点,使用其购买时长快照计算新的 cursor。
// 4. cursor 即预计最后到期时间。
lastExpiry, err := s.packageUsageStore.ProjectLastMainPackageExpiry(ctx, assetType, assetID, now)
if err != nil {
return nil, err
}
resp.EstimatedFinalExpiresAt = lastExpiry
```
`ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。
### 前端
资产详情“套餐信息”板块只保留一个资产汇总展示:
```
预计套餐到期时间2027-01-01
```
读取 `estimated_final_expires_at``exact` 时展示日期;`waiting_activation` 时展示“待激活后起算”;`none` 时展示“—”。当前套餐自身的 `expires_at` 仅在套餐明细列表展示,不再作为第二个资产汇总字段。
高亮和临期提醒统一使用 `days_until_final_expiry`,前端不得再根据 `current_package_expires_at` 自行计算另一套剩余天数。

View File

@@ -0,0 +1,195 @@
# 需求05套餐分配生效条件ExpiryBase 覆盖)
> 状态:原需求独立稿;最终口径以标准评审稿为准。
---
## 背景
`Package.ExpiryBase` 已存在(`from_activation` / `from_purchase`),在套餐创建时设定,控制套餐何时开始计时。
需求:分配套餐给代理时,可以对单条分配记录二次覆盖这个值。
---
## 快照链设计
```mermaid
flowchart TD
Package[套餐默认 ExpiryBase] --> Effective{分配记录是否覆盖?}
Allocation[ShopPackageAllocation.expiry_base_override] --> Effective
Effective -->|有覆盖| Override[使用分配覆盖值]
Effective -->|无覆盖| Default[使用套餐默认值]
Override --> Snapshot[订单创建时写入 PackageUsage.expiry_base_snapshot]
Default --> Snapshot
Snapshot --> Activation[套餐激活只读快照]
Legacy[旧数据快照为空] --> Fallback[兜底读取套餐默认值]
Fallback --> Activation
```
遗留数据兜底:`ExpiryBaseSnapshot` 为空(旧数据)时,回退读 `pkg.ExpiryBase`,行为不变。
---
## 数据库变更
### 1. ShopPackageAllocation 新增覆盖字段
```sql
ALTER TABLE tb_shop_package_allocation
ADD COLUMN expiry_base_override VARCHAR(30);
COMMENT ON COLUMN tb_shop_package_allocation.expiry_base_override
IS '生效条件覆盖NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效)';
```
### 2. PackageUsage 新增快照字段
```sql
ALTER TABLE tb_package_usage
ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT '',
ADD COLUMN calendar_type_snapshot VARCHAR(20) NOT NULL DEFAULT '',
ADD COLUMN duration_months_snapshot INT NOT NULL DEFAULT 0,
ADD COLUMN duration_days_snapshot INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_package_usage.expiry_base_snapshot
IS '生效条件快照(创建时从分配记录取有效值写入,空字符串=旧数据兜底读套餐原值)';
COMMENT ON COLUMN tb_package_usage.calendar_type_snapshot
IS '周期类型快照(空字符串=旧数据兜底读套餐原值)';
COMMENT ON COLUMN tb_package_usage.duration_months_snapshot
IS '月数快照0=旧数据兜底读套餐原值)';
COMMENT ON COLUMN tb_package_usage.duration_days_snapshot
IS '天数快照0=旧数据兜底读套餐原值)';
```
旧数据不回填,默认空字符串,激活时自动兜底。
---
## Model 变更
### ShopPackageAllocation`internal/model/shop_package_allocation.go`
```go
// ExpiryBaseOverride 生效条件覆盖
// NULL = 使用宿主套餐的 ExpiryBase有值 = 分配时指定,不受套餐后续修改影响
ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);comment:生效条件覆盖 NULL=使用套餐默认 from_activation=实名即生效 from_purchase=购买即生效" json:"expiry_base_override"`
```
### PackageUsage`internal/model/package.go`
```go
// ExpiryBaseSnapshot 生效条件快照(创建订单时写入,空字符串=旧数据兜底读套餐原值)
ExpiryBaseSnapshot string `gorm:"column:expiry_base_snapshot;type:varchar(30);not null;default:'';comment:生效条件快照 创建时从分配记录取有效值" json:"expiry_base_snapshot"`
// 以下三个字段和 ExpiryBaseSnapshot 一起固化,供激活和排队最终到期时间计算使用。
CalendarTypeSnapshot string `gorm:"column:calendar_type_snapshot;type:varchar(20);not null;default:'';comment:套餐周期类型快照" json:"calendar_type_snapshot"`
DurationMonthsSnapshot int `gorm:"column:duration_months_snapshot;not null;default:0;comment:套餐月数快照" json:"duration_months_snapshot"`
DurationDaysSnapshot int `gorm:"column:duration_days_snapshot;not null;default:0;comment:套餐天数快照" json:"duration_days_snapshot"`
```
---
## 业务逻辑变更
### 1. 订单创建时快照(`internal/service/order/service.go`
订单创建已通过 `GetByShopAndPackage` 查询分配记录(现有逻辑),在此基础上追加:
```go
// 取生效条件有效值:分配覆盖 > 套餐默认
expiryBase := pkg.ExpiryBase
if allocation.ExpiryBaseOverride != nil && *allocation.ExpiryBaseOverride != "" {
expiryBase = *allocation.ExpiryBaseOverride
}
// 创建 PackageUsage 时一次性写入计时快照
usage.ExpiryBaseSnapshot = expiryBase
usage.CalendarTypeSnapshot = pkg.CalendarType
usage.DurationMonthsSnapshot = pkg.DurationMonths
usage.DurationDaysSnapshot = pkg.DurationDays
```
### 2. 激活时读快照(`internal/service/package/activation_service.go`
```go
// 新订单只读购买快照;旧记录兼容回退套餐当前值。
expiryBase := usage.ExpiryBaseSnapshot
if expiryBase == "" {
expiryBase = pkg.ExpiryBase
}
calendarType := usage.CalendarTypeSnapshot
if calendarType == "" {
calendarType = pkg.CalendarType
}
durationMonths := usage.DurationMonthsSnapshot
if durationMonths == 0 {
durationMonths = pkg.DurationMonths
}
durationDays := usage.DurationDaysSnapshot
if durationDays == 0 {
durationDays = pkg.DurationDays
}
```
同文件所有激活和排队接续位置都使用同一快照解析函数,禁止某一处重新读取可修改的 `Package` 字段。
`internal/service/order/service.go` 中后台囤货路径的 `ExpiryBase` 判断也使用已创建的使用记录快照需求06的“预计最后到期时间”同样只读这组快照保证购买后套餐配置变更不会改写历史预测。
---
## API 变更
### 1. 分配套餐接口(新增参数)
```
POST /api/admin/shop-package-allocations
```
请求 DTO 新增字段:
```go
ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件覆盖(不传=使用套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"`
```
### 2. 修改已分配套餐的生效条件(新接口)
```
PATCH /api/admin/shop-package-allocations/{id}/expiry-base
```
请求 DTO
```go
type UpdateAllocationExpiryBaseRequest struct {
ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件null=恢复套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"`
}
```
> 注意:修改已有分配记录的覆盖值,**不影响**已创建的 PackageUsage快照已定只影响后续新建的订单。
---
## 前端对接
### 套餐分配弹框
新增"生效条件"选择项:
```
生效条件:
○ 跟随套餐默认(默认选中,不传 expiry_base_override
○ 购买即生效from_purchase
○ 实名即生效from_activation
```
### 已分配套餐列表
列表新增"生效条件"列:
| 值 | 展示 |
|----|------|
| NULL | 套餐默认 |
| `from_activation` | 实名即生效(已覆盖) |
| `from_purchase` | 购买即生效(已覆盖) |
操作列增加"修改生效条件"按钮,调用 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`

View File

@@ -0,0 +1,206 @@
# 需求08设备批量分配代理或套餐系列Excel导入
> 状态:原需求独立稿;最终口径以标准评审稿为准。
> 模板:前端静态资源,后端仅负责上传校验和异步处理。
---
## 背景
设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)完成两项独立操作:
1. 批量分配设备给代理。
2. 批量分配设备给套餐系列。
两项操作可以复用解析、异步任务和失败明细基础设施,但**一个任务只能执行一个业务命令**,不能在一次提交中同时修改 `shop_id``series_id`
`Device` 表已有 `shop_id *uint``series_id *uint` 字段,分配即更新这两个字段。
```mermaid
sequenceDiagram
actor User as 平台员工
participant Web as 设备管理前端
participant API as BatchAllocation API
participant DB as PostgreSQL
participant Worker as Asynq Worker
User->>Web: 选择“分配代理”或“分配套餐系列”并上传 Excel
Web->>API: POST /api/admin/devices/batch-assign-shop 或 batch-assign-series
API->>DB: 创建任务并保存上传文件引用
API-->>Web: task_id
API->>Worker: 入队处理任务
Worker->>DB: 按设备号批量查询并条件更新
Worker->>DB: 写成功数、失败数和失败明细
Web->>API: 轮询任务详情
API-->>Web: 处理结果
```
---
## 数据库变更
新建批量分配任务表(不复用 `DeviceImportTask`,业务语义不同):
```sql
CREATE TABLE tb_device_batch_allocation_task (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
task_no VARCHAR(30) NOT NULL,
source_file_key VARCHAR(500) NOT NULL,
operation_type VARCHAR(30) NOT NULL,
target_id BIGINT NOT NULL,
operator_id BIGINT NOT NULL,
total_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
fail_count INT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1,
failed_items JSONB,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_device_batch_allocation_task_no ON tb_device_batch_allocation_task(task_no) WHERE deleted_at IS NULL;
CREATE INDEX idx_device_batch_allocation_task_operation ON tb_device_batch_allocation_task(operation_type, status, created_at DESC);
```
状态常量1=处理中2=已完成3=失败。
`operation_type``assign_shop`(分配代理)或 `assign_series`(分配套餐系列)。`target_id` 随操作类型分别指向代理店铺或套餐系列;不建立数据库外键。
失败明细存 JSONB`failed_items`),失败条数有限,无需单独明细表。
---
## Model`internal/model/device_batch_allocation_task.go`
```go
// DeviceBatchAllocationTask 设备批量分配任务模型
type DeviceBatchAllocationTask struct {
gorm.Model
BaseModel `gorm:"embedded"`
TaskNo string `gorm:"column:task_no;type:varchar(30);uniqueIndex:idx_device_batch_allocation_task_no,where:deleted_at IS NULL;not null" json:"task_no"`
SourceFileKey string `gorm:"column:source_file_key;type:varchar(500);not null;comment:待处理Excel对象存储Key" json:"source_file_key"`
OperationType string `gorm:"column:operation_type;type:varchar(30);not null;comment:操作类型 assign_shop=分配代理 assign_series=分配套餐系列" json:"operation_type"`
TargetID uint `gorm:"column:target_id;not null;comment:操作目标ID代理店铺或套餐系列" json:"target_id"`
OperatorID uint `gorm:"column:operator_id;not null;comment:操作人ID" json:"operator_id"`
TotalCount int `gorm:"column:total_count;default:0;comment:总记录数" json:"total_count"`
SuccessCount int `gorm:"column:success_count;default:0;comment:成功数" json:"success_count"`
FailCount int `gorm:"column:fail_count;default:0;comment:失败数" json:"fail_count"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1=处理中 2=已完成 3=失败" json:"status"`
FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"`
StartedAt *time.Time `gorm:"column:started_at" json:"started_at"`
CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"`
}
func (DeviceBatchAllocationTask) TableName() string {
return "tb_device_batch_allocation_task"
}
```
> `ImportResultItems` 复用 `internal/model/device_import_task.go` 中已定义的类型。
---
## API 设计
### 1. 前端静态 Excel 模板
模板由前端项目作为静态资源随版本发布,后端不提供模板下载接口。
- 文件建议命名:`设备批量分配模板-v1.xlsx`
- 表头:`设备号`(一列)
- 前端“下载模板”按钮直接下载静态文件。
- 后端只校验上传文件的表头、行数和内容,不读取或生成前端模板文件。
模板字段发生变化时,前后端必须在同一发布批次升级;旧模板仍可能被用户保存在本地,因此后端错误需要明确指出缺失或未知表头。
### 2. 上传并提交
```
POST /api/admin/devices/batch-assign-shop
Content-Type: multipart/form-data
字段:
shop_id: 123 (必填,分配给哪个代理)
file: <Excel文件>
```
```text
POST /api/admin/devices/batch-assign-series
Content-Type: multipart/form-data
字段:
series_id: 45 (必填,分配给哪个套餐系列)
file: <Excel文件>
```
两个接口内部创建同一类任务表记录,但分别写入 `operation_type=assign_shop|assign_series`。请求中不接受另一个目标字段,避免前端通过隐藏参数把两个业务动作合并。
**处理逻辑Asynq 异步)**
1. API 将上传文件保存到对象存储,把 `source_file_key` 写入任务Worker 从对象存储下载并解析 Excel
2. 对设备号去重后分批查询 `tb_device`(按 `virtual_no``imei`),禁止逐行查询
3. `assign_shop` 仅按状态/归属条件批量更新 `shop_id``assign_series` 仅更新 `series_id`
4. 未找到的:记录失败原因"设备号不存在"。
5. `assign_shop` 遇到已分配给其他代理的设备:记录"该设备已分配,请先回收"`assign_series` 不改变代理归属。
6. Worker 重试时只处理仍未满足目标结果的设备;已经分配到同一目标的记录按幂等成功处理。
临时本地文件路径不能进入 Asynq 载荷。任务结束后源文件按统一对象存储生命周期清理,清理失败不影响任务结果。
限制:单文件最大 10MB、去重前最多 1000 行、Worker 每批最多 200 条、任务最多保留 1000 条失败明细。超过限制在 API 或解析阶段返回明确错误,不创建无限时长任务。
### 3. 查询任务状态
```
GET /api/admin/devices/batch-allocation/{task_id}
```
响应:
```json
{
"task_no": "DBA20240101001",
"operation_type": "assign_shop",
"target_id": 123,
"status": 2,
"status_name": "已完成",
"total_count": 100,
"success_count": 95,
"fail_count": 5,
"failed_items": [
{ "line": 3, "virtual_no": "12345", "reason": "设备号不存在" },
{ "line": 7, "virtual_no": "67890", "reason": "该设备已分配,请先回收" }
],
"started_at": "2024-01-01T10:00:00Z",
"completed_at": "2024-01-01T10:00:05Z"
}
```
---
## 前端对接
### 入口
设备管理页 > 批量操作:
- "批量分配代理"
- "批量分配套餐系列"
### 操作流程
1. 点击其中一个批量操作入口。
2. "批量分配代理"弹框只显示代理选择器和 Excel 上传区域;"批量分配套餐系列"弹框只显示套餐系列选择器和 Excel 上传区域。
3. 上传后点击"提交",分别调用 `POST /api/admin/devices/batch-assign-shop``POST /api/admin/devices/batch-assign-series`
4. 提示"任务提交成功,正在处理..."
5. 轮询 `GET /api/admin/devices/batch-allocation/{task_id}` 直到 `status != 1`
6. 展示结果成功X条失败X条失败明细在页面展示
### Excel 规范
- 表头:`设备号`
- 每行一个设备号支持虚拟号或IMEI

View File

@@ -0,0 +1,146 @@
# 需求09C端支付方式限制配置化
> 状态:原需求独立稿;最终口径以标准评审稿为准。
> 依赖:[系统配置](../基础规范/系统配置.md)
---
## 背景
代码已经写好但被注释,注释原因是**微信支付参数未申请下来**,临时注释。
注释位置:
- `internal/handler/app/client_wallet.go`(钱包充值入口)
- `internal/service/client_order/service.go`(订单支付入口)
两处均有注释:`// 第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)`
---
## 业务规则
| 资产类型 | 允许的第三方支付 | 禁止的第三方支付 |
|---------|----------------|----------------|
| IoT 卡 | 支付宝、钱包 | 微信 |
| 设备 | 微信、钱包 | 支付宝 |
**钱包支付对所有资产类型均允许。**
```mermaid
flowchart TD
Pay[用户选择支付方式] --> Asset{资产类型}
Asset -->|IoT卡| Card[读取卡允许方式]
Asset -->|设备| Device[读取设备允许方式]
Asset -->|未知| RejectUnknown[拒绝:资产类型无效]
Card --> ConfigOK{配置可用?}
Device --> ConfigOK
ConfigOK -->|是| Match{支付方式在允许集合?}
ConfigOK -->|否| SafeDefault[使用代码内安全默认集合]
SafeDefault --> Match
Match -->|是| Continue[继续支付]
Match -->|否| Reject[拒绝并返回对应中文提示]
```
---
## 实现方案
### 1. 系统配置初始化(已在系统配置文档中定义)
```go
// tb_system_config 初始数据
config_key: "c2b.payment.card_allowed_methods" ["alipay","wallet"]
config_key: "c2b.payment.device_allowed_methods" ["wechat","wallet"]
```
### 2. 恢复注释代码,改为读取配置
**文件**`internal/service/client_order/service.go`
```go
// validatePaymentMethod 校验资产类型与支付方式是否匹配
func (s *Service) validatePaymentMethod(ctx context.Context, assetType string, paymentMethod string) error {
// 钱包支付始终允许
if paymentMethod == model.PaymentMethodWallet {
return nil
}
var configKey string
switch assetType {
case model.AssetTypeIotCard: // "iot_card",定义在 internal/model/asset_identifier.go
configKey = "c2b.payment.card_allowed_methods"
case model.AssetTypeDevice: // "device",定义在 internal/model/asset_identifier.go
configKey = "c2b.payment.device_allowed_methods"
default:
return errors.New(errors.CodeInvalidParam, "资产类型无效")
}
allowedMethods, err := sysconfig.GetStringSlice(ctx, configKey)
if err != nil || len(allowedMethods) == 0 {
// 配置异常时回退到代码内安全默认值,禁止放开全部支付方式。
allowedMethods = defaultAllowedMethods(assetType)
s.logger.Error("读取支付方式配置失败,已使用安全默认值",
zap.String("config_key", configKey),
zap.Error(err))
}
for _, m := range allowedMethods {
if m == paymentMethod {
return nil
}
}
return errors.New(errors.CodeForbidden, "该资产类型不支持此支付方式")
}
```
`client_wallet.go`(充值)和 `client_order/service.go`(订单支付)的对应位置恢复调用。
系统配置更新时必须保证 `wallet` 始终存在于两个允许集合中;前端将钱包选项显示为勾选且不可取消,后端再次校验,避免配置破坏业务规则。
### 3. 错误信息
用户端错误提示(友好文案):
```go
// 根据资产类型给出具体提示
switch assetType {
case model.AssetTypeIotCard:
return errors.New(errors.CodeForbidden, "卡资产仅支持支付宝或余额支付")
case model.AssetTypeDevice:
return errors.New(errors.CodeForbidden, "设备仅支持微信或余额支付")
}
```
---
## 前端对接
### C端支付页面
前端不维护另一份固定规则。资产初始化/详情接口返回后端已经计算好的:
```go
AllowedPaymentMethods []string `json:"allowed_payment_methods" description:"当前资产允许的支付方式"`
```
支付页只展示该集合中的方式,后端支付接口再次执行相同校验。配置变化后重新进入支付页或刷新资产信息即可获得新集合。
```
allowed_payment_methods = ["alipay", "wallet"] → 展示支付宝、余额
allowed_payment_methods = ["wechat", "wallet"] → 展示微信、余额
```
### 后台配置页面
在系统配置(系统设置 > 系统配置 > `c2b.payment` 模块)中,用 CheckboxGroup 展示:
```
卡资产允许支付方式:☑ 支付宝 ☑ 余额 ☐ 微信
设备允许支付方式: ☐ 支付宝 ☑ 余额 ☑ 微信
```
余额选项固定勾选且禁用,不允许管理员取消。
修改后调用 `PUT /api/admin/system/config/c2b.payment.card_allowed_methods`
> 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。

View File

@@ -0,0 +1,136 @@
# 需求10Gateway 手动卡限速
> 状态:原需求独立稿;最终口径以标准评审稿为准。
> 已确认边界:本期只提供手动设置/取消限速Gateway 最终对象始终是卡,统一使用 `cardNo`。
---
## 一、范围
本期提供一个统一的后台能力:对一张实际联网卡设置或取消限速。
- 单卡资产:使用 `IotCard.ICCID` 作为 `cardNo`
- 设备资产:查询 `tb_device_sim_binding.is_current=true` 的当前绑定卡,再使用该卡 ICCID。
- `speed_kbps > 0` 表示设置限速;`speed_kbps = 0` 表示取消限速。
- 内部业务单位固定为 `kbps`,前端也按 `kbps` 输入和展示。
本期不做:
- 套餐限速字段或套餐编辑页限速配置。
- 套餐激活、到期、续费、停机、切卡后的自动限速或自动取消。
- 设备 IMEI 限速。
- “Gateway 当前实际限速”查询展示。上游未提供查询接口时,页面只能展示最近一次本系统操作记录。
取消限速仍调用同一个 Gateway 方法。`speed_kbps=0` 是本系统语义Gateway 所需的取消报文仅在 Infrastructure 适配器中转换,不散落在 Handler、Service 或前端。
---
## 二、流程
```mermaid
sequenceDiagram
actor User as 平台员工
participant Web as 资产详情页
participant API as SpeedLimit Application
participant DB as PostgreSQL
participant Resolver as 当前卡解析器
participant Gateway as Gateway Adapter
User->>Web: 输入限速值或点击取消
Web->>API: asset identifier + speed_kbps + request_id
API->>Resolver: 解析实际 cardNo
Resolver-->>API: ICCID 或无当前卡错误
API->>DB: 创建或复用限速操作记录
API->>Gateway: SetSpeedLimit(cardNo, speedKbps)
Gateway-->>API: 成功或失败
API->>DB: 写结果、错误摘要和操作审计
API-->>Web: 返回本次操作结果
```
设备不存在当前绑定卡时,接口返回业务错误并记录失败原因;绝不把设备 IMEI、设备 ID 或空字符串发送给 Gateway。
---
## 三、应用端口与数据
```go
// GatewaySpeedLimitPort 设置或取消卡限速。
// speedKbps=0 表示取消,具体上游参数由适配器转换。
type GatewaySpeedLimitPort interface {
SetSpeedLimit(ctx context.Context, cardNo string, speedKbps int) error
}
// SpeedLimitCardResolver 将卡或设备资产解析为实际联网卡 ICCID。
type SpeedLimitCardResolver interface {
ResolveCardNo(ctx context.Context, assetType string, assetID uint) (string, error)
}
```
新建操作记录表,既用于审计,也用于同一 `request_id` 的幂等重试:
```sql
CREATE TABLE tb_gateway_speed_limit_operation (
id BIGSERIAL PRIMARY KEY,
request_id VARCHAR(64) NOT NULL,
asset_type VARCHAR(20) NOT NULL,
asset_id BIGINT NOT NULL,
card_no VARCHAR(100) NOT NULL,
desired_speed_kbps INT NOT NULL,
status INT NOT NULL DEFAULT 1,
error_summary TEXT NOT NULL DEFAULT '',
operator_id BIGINT NOT NULL,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_gateway_speed_limit_operation_request
ON tb_gateway_speed_limit_operation(request_id);
CREATE INDEX idx_gateway_speed_limit_operation_asset
ON tb_gateway_speed_limit_operation(asset_type, asset_id, created_at DESC);
```
状态:`1=处理中, 2=成功, 3=失败`。同一 `request_id` 重试时任务、操作人、资产和目标限速一致才返回或继续原记录不一致返回冲突。Gateway 网络超时后允许使用相同目标值重试,因为 `SetSpeedLimit` 是“设为目标状态”的幂等操作。
---
## 四、接口与前端
```text
POST /api/admin/assets/{identifier}/speed-limit
```
```json
{
"request_id": "01JZ...",
"speed_kbps": 1024
}
```
- `speed_kbps` 必须为整数且大于等于 0。
- 取消操作提交 `speed_kbps=0`,不新增第二个取消接口。
- 后端根据 `identifier` 解析资产类型和数据范围,前端不得传 `cardNo`、设备 ID 或资产类型作为权威依据。
资产详情页提供两个独立命令:
- 数字输入框加“保存”用于设置限速,单位固定显示 `kbps`
- 取消按钮用于提交 `speed_kbps=0`,并使用确认弹窗避免误操作。
设备详情页显示“当前使用卡 ICCID”。没有当前卡时禁用两个命令并展示后端返回的原因。页面可展示最近一次操作的目标限速、结果、操作人和时间但不能把该记录描述为 Gateway 的实时状态。
---
## 五、审计与人工验收
每次请求记录:资产类型/ID、最终 `cardNo`、目标 `speed_kbps`、设置或取消、操作人、`request_id`、Gateway 结果和脱敏错误摘要。
人工验收覆盖:
1. 单卡按 ICCID 设置限速。
2. 设备按 `is_current=true` 的绑定卡设置限速,请求中不出现设备 IMEI。
3. 设备无当前卡时拒绝调用 Gateway。
4. `speed_kbps=0` 经同一 Gateway 方法取消限速。
5. 同一 `request_id` 重试不重复生成操作记录;不同请求复用 ID 返回冲突。
6. Gateway 失败记录错误并允许相同目标值重试。
上线前置条件Gateway 提供设置和取消所需的最终报文字段约定。业务层只保持 `cardNo + speedKbps` 契约。

View File

@@ -0,0 +1,412 @@
# 需求14导出功能
> 状态:原需求独立稿;最终口径以标准评审稿为准。
> 复用现有 ExportTask 体系(`tb_export_task` + Asynq不为每个业务模块新增一套导出路由。
---
## 导出模块总览
| 编号 | 模块 | 新增/修改 |
|------|------|---------|
| EXPD-001~003 | IoT卡导出 | 新增套餐名称、使用流量、剩余流量字段 |
| 6.8.2 | 代理资金概况-预充值钱包流水导出 | **全新** |
| 6.8.3 | 套餐列表导出 | **全新** |
| 6.8.4 | 退款管理退款列表导出 | **全新** |
| 6.8.5 | 换货管理导出 | **全新** |
| 6.8.6 | 代理充值导出 | **全新**(去掉"支付通道"字段) |
| 需求22 | 临期资产列表导出 | **全新**`scene=expiring_asset` |
---
## 现有导出体系说明
系统已有异步导出框架(`internal/exporter/`
- `tb_export_task` 表记录导出任务
- 导出逻辑通过 `DataSource` 接口实现,每个场景一个文件(如 `iot_card_scene.go`
- `registry.go``NewDefaultRegistry()` 统一注册所有场景
- Asynq Worker 根据任务里的 `scene` 字段,从 Registry 取对应 DataSource 执行
- 前端轮询任务状态后下载
```mermaid
sequenceDiagram
actor User as 后台用户
participant Web as 前端
participant API as ExportTask API
participant DB as PostgreSQL
participant Worker as Asynq Worker
participant Storage as 对象存储
User->>Web: 选择导出字段并确认
Web->>API: POST /api/admin/export-tasks
API->>API: 计算数据范围和字段权限交集
API->>DB: 保存查询、范围和字段快照
API-->>Web: task_id
API->>Worker: 入队导出任务
Worker->>DB: 按 scene 分片查询
Worker->>Storage: 上传导出文件
Worker->>DB: 更新进度和 download file_key
Web->>API: 轮询 GET /api/admin/export-tasks/{id}
API-->>Web: 状态、进度、download_url
```
新增导出模块需要:
1.`pkg/constants/constants.go` 新增 `ExportTaskSceneXxx` 场景常量
2.`internal/exporter/` 新建 `xxx_scene.go`,实现 `DataSource` 接口(`Scene()`/`Count()`/`Headers()`/`Fetch()`
3.`registry.go``NewDefaultRegistry()` 中注册,并更新 `IsSupportedScene()`
4. 扩展统一 `CreateExportTaskRequest.Scene` 的允许值,通过 `POST /api/admin/export-tasks` 创建任务
5. 在字段目录中注册稳定字段 key、中文表头、默认选择和所需导出权限
统一创建请求扩展为:
```go
type CreateExportTaskRequest struct {
Scene string `json:"scene" validate:"required" description:"导出场景"`
Format string `json:"format" validate:"required,oneof=xlsx csv" description:"导出格式"`
Query map[string]any `json:"query,omitempty" description:"与列表一致的筛选条件"`
Fields []string `json:"fields" validate:"required,min=1" description:"申请导出的字段key"`
}
```
新增场景:`agent_wallet_transaction``package``refund``exchange``agent_recharge``expiring_asset`。DTO description 和 `pkg/constants/` 必须同步维护。
---
## EXPD-001~003IoT卡导出字段新增
**修改文件**`internal/exporter/iot_card_scene.go`
`Headers()` 末尾追加三列,`Fetch()``Select` 追加字段,`iotCardExportRow` 追加字段:
```go
// Headers() 新增
"套餐名称", "使用流量(MB)", "剩余流量(MB)"
// baseQuery() 或 Fetch() 新增 JOIN
LEFT JOIN LATERAL (
SELECT pu.package_id, pu.data_usage_mb, pu.data_limit_mb, p.package_name
FROM tb_package_usage pu
JOIN tb_package p ON p.id = pu.package_id AND p.deleted_at IS NULL
WHERE pu.iot_card_id = c.id AND pu.status = 1
AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL
LIMIT 1
) AS pkg ON TRUE
// iotCardExportRow 新增
PackageName string `gorm:"column:package_name"`
DataUsageMB int64 `gorm:"column:data_usage_mb"`
DataLimitMB int64 `gorm:"column:data_limit_mb"`
```
剩余流量 = `DataLimitMB - DataUsageMB`(在行转换时计算)
---
## 6.8.2:代理资金概况-预充值钱包流水导出
**新增场景**`scene=agent_wallet_transaction`
支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。
导出字段映射:
| 字段 | 数据来源 |
|------|---------|
| 店铺名称 | JOIN `tb_shop` |
| 交易类型 | `transaction_type`(充值/扣款/退款等,中文化) |
| 交易金额 | `amount / 100` 转元 |
| 状态 | `status` 中文化 |
| 资产类型 | `asset_type` 中文化 |
| 资产标识 | `asset_identifier` |
| 交易时间 | `created_at` |
| 交易前金额 | `balance_before / 100` |
| 交易后金额 | `balance_after / 100` |
| 购买套餐名称 | 新数据读取 `tb_order_item.package_name` 不可变快照;历史缺失时才回退 `metadata.package_name`,禁止关联当前套餐名称 |
| 操作人 | JOIN `tb_account``creator` 字段关联 `tb_account.id`,取 `username` |
| 交易 ID | `id` |
| 关联业务订单号 | `reference_id` 对应的单号JOIN 对应表) |
| 交易渠道/支付方式 | `metadata``payment_method` 字段 |
---
## 6.8.3:套餐列表导出
**新增场景**`scene=package`
支持现有套餐列表筛选条件。
导出字段(按实际列举的 25 个稳定字段 Key
```go
type PackageExportRow struct {
PackageCode string `xlsx:"套餐编码"`
PackageName string `xlsx:"套餐名称"`
SeriesName string `xlsx:"套餐系列名称"` // JOIN tb_package_series
PackageType string `xlsx:"套餐类型"` // formal/addon 中文化
DurationMonths int `xlsx:"套餐时长(月)"`
DurationDaysDesc string `xlsx:"套餐时长说明"` // 剩余天数说明
CalendarType string `xlsx:"套餐周期类型"`
DurationDays int `xlsx:"套餐天数"`
RealDataMB int64 `xlsx:"真流量额度(MB)"`
VirtualDataMB int64 `xlsx:"虚流量额度(MB)"`
EnableVirtualData string `xlsx:"是否启用虚流量"` // 是/否
VirtualRatio float64 `xlsx:"虚流量比例"`
DataResetCycle string `xlsx:"流量重置周期"`
ExpiryBase string `xlsx:"到期时间基准"`
CostPrice string `xlsx:"成本价(元)"` // 分→元
SuggestedRetailPrice string `xlsx:"建议售价(元)"`
PriceConfigStatus string `xlsx:"价格配置状态"`
Status string `xlsx:"状态"`
ShelfStatus string `xlsx:"上架状态"`
IsGift string `xlsx:"是否赠送套餐"`
CreatorID uint `xlsx:"创建人ID"`
UpdaterID uint `xlsx:"更新人ID"`
CreatedAt string `xlsx:"创建时间"`
UpdatedAt string `xlsx:"更新时间"`
DeletedAt string `xlsx:"删除时间"`
}
```
---
## 6.8.4:退款管理退款列表导出
**新增场景**`scene=refund`
导出字段(去掉“退款到账方式”,审批信息使用动态摘要,不固定具体节点):
```go
type RefundExportRow struct {
RefundNo string `xlsx:"退款单号"`
ShopName string `xlsx:"代理店铺名称"`
PaymentOrderNo string `xlsx:"关联的支付订单号"`
AssetType string `xlsx:"资产类型"`
AssetIdentifier string `xlsx:"资产标识"`
PackageName string `xlsx:"套餐名称"`
OriginalAmount string `xlsx:"原订单金额"`
ActualAmount string `xlsx:"实收金额"`
RefundableAmount string `xlsx:"可退金额"`
AppliedAmount string `xlsx:"申请退款金额"`
ActualRefundAmount string `xlsx:"实际退款金额"`
Status string `xlsx:"状态"`
RefundReason string `xlsx:"退款原因"`
Remark string `xlsx:"备注"`
ApprovalSource string `xlsx:"审批来源"`
ApprovalStatus string `xlsx:"审批状态"`
ProcessingStatus string `xlsx:"退款处理状态"`
CurrentNodeName string `xlsx:"当前审批节点"`
ApprovalRecords string `xlsx:"审批记录"`
AppliedAt string `xlsx:"退款申请时间"`
CompletedAt string `xlsx:"退款完成时间"`
SubmitterName string `xlsx:"提交人"`
VoucherURLs string `xlsx:"退款凭证"`
}
```
`ApprovalRecords` 格式示例:`业务审核:张三(通过,同意,附件2个);财务审核:李四(待处理)`。导出只记录附件数量,不导出对象存储 URL 或 file_key。`ProcessingStatus` 区分待人工退款、代理钱包回退处理中、已完成和处理失败。数据源按本批业务单的 `approval_instance_id` 批量查询审批任务、审批人和附件计数,禁止逐行查询。历史终态单据没有流程实例时,`ApprovalSource` 输出“历史审批”,审批记录仅使用原业务审批字段和审计日志,不伪造多节点记录。
---
## 6.8.5:换货管理导出
**新增场景**`scene=exchange`
```go
type ExchangeExportRow struct {
ExchangeNo string `xlsx:"换货单号"`
ExchangeType string `xlsx:"换货类型"`
ExchangeReason string `xlsx:"换货原因"`
ProblemDesc string `xlsx:"问题描述"`
OldAssetType string `xlsx:"旧资产类型"`
OldAssetIdentifier string `xlsx:"旧资产标识符"`
NewAssetIdentifier string `xlsx:"新资产标识符"`
ReceiverName string `xlsx:"收货人姓名"`
ReceiverPhone string `xlsx:"收货人电话"`
ReceiverAddress string `xlsx:"收货地址"`
ExpressCompany string `xlsx:"快递公司"`
TrackingNo string `xlsx:"快递单号"`
Status string `xlsx:"状态"`
CreatorName string `xlsx:"创建人"`
CreatedAt string `xlsx:"创建时间"`
}
```
---
## 6.8.6:代理充值导出
**新增场景**`scene=agent_recharge`
去掉"支付通道"字段(需求文档中明确去掉),保留其他字段:
```go
type AgentRechargeExportRow struct {
RechargeNo string `xlsx:"充值单号"`
ShopName string `xlsx:"店铺名称"`
RechargeType string `xlsx:"充值类型"`
RechargeAmount string `xlsx:"充值金额"`
ActualAmount string `xlsx:"实付金额"`
BalanceBefore string `xlsx:"充值前余额"`
BalanceAfter string `xlsx:"充值后余额"`
Status string `xlsx:"状态"`
PaymentMethod string `xlsx:"支付方式"`
// 去掉支付通道
OperationRemark string `xlsx:"运营备注"`
RejectReason string `xlsx:"驳回原因"`
CreatedAt string `xlsx:"创建时间"`
PaidAt string `xlsx:"支付时间"`
CompletedAt string `xlsx:"完成时间"`
SubmitterName string `xlsx:"提交人"`
ApprovalSource string `xlsx:"审批来源"`
ApprovalStatus string `xlsx:"审批状态"`
CurrentNodeName string `xlsx:"当前审批节点"`
ApprovalRecords string `xlsx:"审批记录"`
VoucherURLs string `xlsx:"支付凭证"`
Remark string `xlsx:"备注"`
}
```
充值导出的审批记录格式和批量查询规则与退款导出一致。
---
## 前端对接(通用模式)
各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。
调用流程:
1. 点击“导出”后请求当前账号在该场景可导出的字段目录。
2. 弹框只展示后端允许的字段,默认勾选 `default_selected=true` 的字段。
3. 提交 `scene + format + query + fields``POST /api/admin/export-tasks`
4. 返回 `task_id` 后,前端轮询 `GET /api/admin/export-tasks/{id}` 直到终态。
5. `status=3` 时下载 `download_url`;失败时展示任务错误摘要,禁止自动重复创建任务。
(与现有导出体系完全一致,复用现有前端导出 Hook
---
## 导出字段权限
需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。
评审结论:本迭代必须实现角色级导出字段配置,不作为后续预留。
该要求涉及真流量、成本价、身份材料等敏感数据,不能以“本期先全量导出”代替。字段权限必须由后端强制执行,前端复选框只负责展示。
### 1. 字段目录
每个场景在代码中注册稳定字段 key
```go
// ExportFieldDefinition 导出字段定义
type ExportFieldDefinition struct {
Key string
Header string
DefaultSelected bool
Required bool
}
```
示例:
```text
scene=package
package_code 套餐编码 默认选择
package_name 套餐名称 默认选择
real_data_mb 真流量额度 敏感字段
virtual_data_mb 虚流量额度 默认选择
cost_price 成本价 敏感字段
```
表头中文可以调整,但 `scene + field_key` 一经发布不得随意改名,否则历史角色配置会失效。
### 2. 角色字段授权表
```sql
CREATE TABLE tb_role_export_field_permission (
id BIGSERIAL PRIMARY KEY,
role_id BIGINT NOT NULL,
scene VARCHAR(50) NOT NULL,
field_key VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
creator BIGINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX idx_role_export_field_permission
ON tb_role_export_field_permission(role_id, scene, field_key);
```
账号拥有多个角色时,字段权限按 RBAC 常规规则取并集;超级管理员拥有全部字段。数据行范围仍使用现有数据权限快照,字段权限不能扩大店铺或企业数据范围。
### 3. 权限 API
```text
GET /api/admin/export-fields?scene=package
```
返回当前账号可选择的字段:
```json
{
"scene": "package",
"fields": [
{
"key": "package_code",
"header": "套餐编码",
"default_selected": true,
"required": true
}
]
}
```
角色管理:
```text
GET /api/admin/roles/{role_id}/export-fields
PUT /api/admin/roles/{role_id}/export-fields
```
更新请求按场景提交字段 key 列表,后端校验字段存在并记录审计日志。
### 4. 创建任务时的权限快照
创建任务时计算:
```text
resolved_fields = requested_fields ∩ role_allowed_fields ∩ scene_supported_fields
```
- 必选字段由后端自动补齐。
- 交集为空时拒绝创建任务。
- `resolved_fields` 和对应 `resolved_headers` 写入任务的 `query_json`Worker 不重新根据后来变化的角色权限扩大字段。
- Worker 只按照快照字段输出,未知字段直接失败,不允许静默回退到全量字段。
### 5. 前端角色配置
- 角色编辑页增加“导出字段权限”页签,按场景分组展示复选框。
- 普通列表的导出弹框只显示当前账号被授权的字段。
- 敏感字段可以增加“敏感”标记,但标记不能替代后端权限。
- 用户取消所有可选字段时禁用提交按钮,并提示至少选择一个字段。
---
## 查询与性能约束
- 退款和充值审批记录必须先按本批 `approval_instance_id` 批量查询,再在内存按实例分组,禁止逐行查审批任务。
- 关联业务单号、套餐名称和操作人必须使用批量 JOIN 或批量查询,禁止 N+1。
- DataSource 的 Count 和 Fetch 必须使用同一份权限过滤和查询条件。
- 动态字段不代表动态拼接不受控 SQL字段 key 必须通过服务端白名单映射到固定查询列。
---
## 发布与回滚
本需求随七月迭代停机发布:
1. 维护窗口内先执行角色字段权限表迁移,并初始化现有角色的最小可用字段集。
2. 同一发布窗口部署场景常量、DataSource、管理 API、Worker 和前端字段选择/角色配置页面。
3. 开放访问前验证普通角色、敏感字段角色和超级管理员的字段集合及实际导出文件。
4. 回滚时可关闭新增场景入口,但保留任务、文件和字段权限历史。
禁止在角色权限尚未初始化时默认放开全部敏感字段;无法解析权限时应拒绝导出并记录错误。

View File

@@ -0,0 +1,587 @@
# 需求15/16/18/19/20/21 技术方案
> 状态:原需求来源稿;其中本地审批流、审批页面和操作密码方案已废弃,最终以企微审批方案和标准评审稿为准。
> 评审建议:需求 15/16、需求 18/20/21、需求 19 分三组评审,不在一次会议中混合确认。
---
## 需求15套餐下架后允许续费
### 业务规则
- 下架套餐(`shelf_status=2`)不可被**新购**
- 下架套餐**可以续费**(已在使用该套餐的客户)
- 续费仅支持**客户自己购买**(不允许代理代购下架套餐给新客户)
```mermaid
flowchart TD
Start[请求购买套餐] --> Enabled{套餐是否启用?}
Enabled -->|否| RejectDisabled[拒绝:套餐已禁用]
Enabled -->|是| Shelf{是否已下架?}
Shelf -->|否| Allow[允许继续下单]
Shelf -->|是| Renewal{当前客户资产是否有该套餐历史使用记录?}
Renewal -->|否| RejectNew[拒绝:下架套餐不可新购]
Renewal -->|是| Actor{是否客户本人续费?}
Actor -->|是| Allow
Actor -->|否| RejectProxy[拒绝:下架套餐不可代购]
```
“客户本人”必须由后端登录主体与资产归属关系判断,不能信任前端传入 `is_renewal=true`
### 后端
**当前逻辑**:下架套餐在购买时被拦截。
修改:在订单创建校验中由后端根据资产、历史使用记录和登录主体判定“新购/续费”,不能接收或信任前端 `is_renewal`
```go
// internal/service/order/service.go 或 client_order/service.go
func (s *Service) validatePackageAvailability(
ctx context.Context,
pkg *model.Package,
asset *ResolvedAsset,
actor *PurchaseActor,
) error {
if pkg.Status == constants.StatusDisabled { // 0=禁用,定义在 pkg/constants/constants.go
return errors.New(errors.CodeForbidden, "套餐已禁用")
}
if pkg.ShelfStatus == constants.ShelfStatusOff { // 2=下架
if !s.hasRenewalEligibility(ctx, asset, actor, pkg.ID) {
return errors.New(errors.CodeForbidden, "套餐已下架,仅支持资产所有人续费")
}
}
return nil
}
```
**续费资格判断**:查当前资产是否有该套餐的有效历史使用记录,并确认当前登录主体就是资产所有人。已失效、已退款或仅创建未生效的记录不能作为续费资格:
```go
func (s *Service) hasRenewalEligibility(ctx context.Context, asset *ResolvedAsset, actor *PurchaseActor, packageID uint) bool {
return actor.OwnsAsset(asset) &&
s.packageUsageStore.HasValidHistory(ctx, asset.Type, asset.ID, packageID)
}
```
### 前端
C端资产详情/当前套餐卡片:在当前套餐旁提供“续费”按钮。点击后复用现有购买套餐流程,并携带资产和当前套餐上下文;后端重新判定资格,前端传入的上下文不构成授权依据。下架套餐不出现在普通“新购套餐”列表,也不额外建设第二个续费套餐列表。
后台代购时:下架套餐的"代购"按钮禁用tooltip 提示"套餐已下架,不可代购"。
---
## 需求16代理分销码与佣金提现已移出7月迭代
> 状态:已移出本期
> 决策日期2026-07-14
> 后续处理:作为独立需求重新评审和排期,不纳入本次开发、迁移和发布
本次范围调整包含整个需求 16
- 代理/员工分销码和推广二维码。
- H5 代理申请、进度查询和退回重提。
- 代理申请接入通用审批及审批后自动开店。
- 店铺发展人关系和代理申请来源字段。
- 佣金提现合同、营业执照、法人身份证、门头照、发票及主体一致性校验。
因此 7 月迭代不创建 `tb_distribution_code``tb_agent_application`,不修改 `tb_shop` 发展人字段和提现材料字段,不注册代理申请相关 API不发布 `agent_application_approval`,也不建设对应前端页面。
通用审批流本期只接入退款和平台员工线下充值。需求 16 后续重新立项时,可以复用本期审批流、站内消息、对象存储和 Outbox 能力但必须重新评审其数据模型、H5 安全、开店幂等、提现材料及工时。
---
## 需求18多人审批APR-001~009
### 依赖
历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md) 和 [站内消息初版](../历史方案/站内消息-初版.md)。
APR-009企微审批对接= Phase 2。
```mermaid
sequenceDiagram
actor Applicant as 提交人
participant Biz as 退款/充值业务
participant Approval as 审批流
participant Notice as 站内消息
actor Approver1 as 当前节点审批人
actor Approver2 as 下一节点审批人
Applicant->>Biz: 提交申请
Biz->>Approval: 同事务创建流程实例和首任务
Approval->>Notice: TaskCreated
Notice-->>Approver1: 待审批提醒
Approver1->>Approval: 审批通过
Approval->>Notice: 下一节点 TaskCreated
Notice-->>Approver2: 待审批提醒
Approver2->>Approval: 通过/驳回/退回
Approval->>Notice: 流程结果事件
Notice-->>Applicant: 结果和原因
```
### 实现要点
| 编号 | 需求 | 实现 |
|------|------|------|
| APR-001~003 | 充值/退款多级审核 | 见需求20/21需求16已移出本期 |
| APR-004 | 审核环节:部门领导→财务 | 作为默认流程定义的两个串行节点;系统无部门模型,节点审批人由角色或指定账号配置,禁止按步骤编号或角色名称写死 |
| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` |
| APR-006 | 上一级完成后才提示下一级 | `ProcessInstance` 完成当前任务并激活下一节点,写入 `TaskCreated` Outbox 事件 |
| APR-007 | 通过后通知申请人 | `ProcessApproved` 事件处理器 |
| APR-008 | 驳回/退回后通知申请人含原因 | `ProcessRejected` / `ProcessReturned` 事件处理器 |
| APR-009 | 对接企微 | **Phase 2** |
### 默认流程与可配置边界
- 本迭代可以预置“业务审核 → 财务审核”两个节点,但这只是初始流程定义,不是引擎固定规则。
- 每个节点可配置 `role``user` 审批人来源,以及 `any``all` 完成方式。
- 节点可配置是否要求操作密码;仅触发该节点完成的审批人输入,不能按“财务节点”等名称写死。
- 角色审批在节点激活时解析当前启用账号,并将候选审批人快照到任务审批人表。
- 流程定义发布后不可修改;调整节点或审批人配置时创建新版本。
- 已发起实例始终使用发起时保存的流程定义快照。
### 业务表与审批流的关联
充值单和退款单接入审批流,各自新增 `approval_instance_id` 字段。具体增量 DDL 与业务处理状态字段分别在需求 20、需求 21 中定义,迁移文件只能创建一次。
本段是历史设计。旧接入协议见 [本地通用审批流 - 业务接入协议](../历史方案/本地通用审批流-已废弃方案.md#十一业务接入协议)。
退款和充值详情统一返回 `approval_source=none|workflow|legacy`。代理在线充值等无需审批的记录返回 `none`;发布前已经结束且没有流程实例的历史审批记录返回 `legacy` 并只读展示;发布时仍待审批的退款和员工线下充值必须在维护窗口回填流程实例。
### 前端技术方案
- 历史前端交互见 [前端共性方案历史稿](../历史方案/前端共性方案-历史稿.md#六统一审批交互);当前已改为企微审批只读页面。
- 退款、充值列表只展示审批摘要;审批详情首屏展示发起时固化的业务关键字段和业务资料,随后展示完整审批人、意见和历史实例。
- 每次通过、驳回、退回分别保存审批意见和可选附件;驳回、退回意见必填,附件不与退款/充值业务凭证混用。
- 时间线必须能查看此前审批人的动作、时间、意见和审批附件;业务资料与审批附件分区展示。
- 所有节点名称和审批人来自接口,不保留“部门领导审批人”“财务审批人”固定字段。
- APR-009 不在 Phase 1 前端中展示不可用入口;企业微信接入完成后再增加来源标识和跳转。
---
## 需求19批量订购套餐BPO-001~008
### 支付方式粒度
评审结论:支付方式按**整批统一**设计:
- 页面选择 `offline``agent_wallet`Excel 不再重复填写支付方式。
- 混合支付拆成两个批次,避免一份凭证对应多种支付语义。
- Excel 不包含支付方式列,后端拒绝同一批次混合支付。
### 业务流程
```mermaid
flowchart TD
Start[员工选择代理和支付方式] --> Upload[上传 Excel]
Upload --> Parse[解析并持久化逐行明细]
Parse --> Validate[校验资产、套餐、归属和重复行]
Validate --> Item{处理下一条有效明细}
Item -->|代理钱包| Lock[锁定钱包并校验可用余额]
Item -->|线下支付| Voucher[校验整批支付凭证]
Lock --> Order[单条事务创建订单并扣款]
Voucher --> OrderOffline[单条事务创建已支付订单]
Order --> Result[记录订单 ID 和成功状态]
OrderOffline --> Result
Result --> More{还有待处理明细?}
More -->|是| Item
More -->|否| Summary[汇总任务结果]
Validate -->|校验失败| Failed[记录结构化失败原因]
Failed --> More
```
任务允许部分成功。每一行是独立、可重试、可审计的业务单元,不能只保存一段失败 JSON。
### 数据库变更
```sql
CREATE TABLE tb_bulk_purchase_task (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
task_no VARCHAR(30) NOT NULL,
source_file_key VARCHAR(500) NOT NULL,
shop_id BIGINT NOT NULL,
operator_id BIGINT NOT NULL,
payment_method VARCHAR(20) NOT NULL,
voucher_keys JSONB NOT NULL DEFAULT '[]',
total_amount BIGINT NOT NULL DEFAULT 0,
total_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
fail_count INT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1,
error_message TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_bulk_purchase_task_no
ON tb_bulk_purchase_task(task_no)
WHERE deleted_at IS NULL;
CREATE TABLE tb_bulk_purchase_item (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
task_id BIGINT NOT NULL,
row_no INT NOT NULL,
asset_type VARCHAR(20) NOT NULL,
asset_identifier VARCHAR(100) NOT NULL,
package_code VARCHAR(50) NOT NULL,
package_name_snapshot VARCHAR(200) NOT NULL DEFAULT '',
package_id BIGINT,
amount BIGINT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1,
order_id BIGINT,
failure_code VARCHAR(50) NOT NULL DEFAULT '',
failure_reason VARCHAR(500) NOT NULL DEFAULT '',
idempotency_key VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_bulk_purchase_item_task_row
ON tb_bulk_purchase_item(task_id, row_no);
CREATE UNIQUE INDEX idx_bulk_purchase_item_idempotency
ON tb_bulk_purchase_item(idempotency_key);
CREATE INDEX idx_bulk_purchase_item_task_status
ON tb_bulk_purchase_item(task_id, status);
```
状态建议:
- 任务:`1=待处理, 2=处理中, 3=已完成, 4=部分成功, 5=失败`
- 明细:`1=待处理, 2=处理中, 3=成功, 4=失败`
### 处理与幂等
1. API 校验文件、代理、支付方式和凭证,将 Excel 保存到对象存储并创建任务,返回 `task_id`
2. Worker 根据 `source_file_key` 下载并解析 Excel将每一行先写入明细表再开始业务处理。
3. 同一任务按行顺序处理,避免对同一代理钱包制造不必要的乐观锁冲突。
4. 每行使用独立事务。代理钱包支付时锁定钱包记录,校验 `balance - frozen_balance + credit_limit` 后,在同一事务扣款、创建订单、资金流水并更新明细。
5. `idempotency_key` 使用 `bulk_purchase:{task_id}:{row_no}`。Worker 重试时,已存在成功订单的明细直接跳过。
6. 单行失败不回滚已成功行;失败原因写结构化错误码和用户可见中文原因。
7. 任务汇总从明细表计算,不信任内存计数。
Asynq 载荷只传任务 ID不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理确保 Worker 重试期间仍可读取。
建议单文件上限 1000 行;超过上限在 API 层拒绝,避免长事务和过长处理时间。
### API 设计
**前端静态 Excel 模板**
模板由前端项目随版本发布,后端不提供下载接口。按已确认的“整批统一支付方式”,模板字段为:
```text
资产类型 | 资产标识 | 套餐编码 | 套餐名称
```
`套餐名称`用于人工核对,实际匹配以稳定的 `套餐编码` 为准。后端必须校验表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。
**上传并提交**
```text
POST /api/admin/bulk-purchases
Content-Type: multipart/form-data
shop_id: 123
payment_method: offline | agent_wallet
voucher_keys: ["key1","key2"]
file: <Excel文件>
```
`offline``voucher_keys` 必填,`agent_wallet` 时忽略该字段。
**查询任务状态**
```text
GET /api/admin/bulk-purchases/{task_id}
```
**分页查询任务明细**
```text
GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50
```
### 前端技术方案
- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 ID 恢复进度。
- 提交前展示代理、支付方式、凭证数量和文件名的二次确认;钱包支付额外展示当前可用余额,但最终以 Worker 扣款时校验为准。
- 任务处理中展示总数、已处理数、成功数和失败数,轮询规则复用统一异步任务方案。
- 结果页默认显示失败明细,可切换全部/成功/失败,并可按资产标识搜索。
- 部分成功使用明确状态,不弹“全部成功”提示;再次上传失败行会创建新任务,不修改旧任务历史。
- 操作员、任务号和处理时间在页面固定展示,便于财务和运营追溯。
---
## 需求20退款审批
### 依赖
本段历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md)。
### 退款单现有状态(不变)
```
1=待审批 2=已通过 3=已拒绝 4=已退回
```
退款单状态值的原有语义不改;审批进度由 `tb_approval_process_instance` 管理,两者通过 `approval_instance_id` 关联。审批通过后,代理钱包退款自动回退到原扣款代理主钱包;微信、支付宝和线下退款由财务人工完成。业务表增加独立处理状态:
```text
processing_status0=待处理 1=处理中 2=已完成 3=处理失败
```
`status=2` 表示审批结论已通过,`processing_status` 表示实际退款动作是否完成。接口和前端必须同时展示两者。
### 数据库变更
```sql
ALTER TABLE tb_refund_request
ADD COLUMN approval_instance_id BIGINT,
ADD COLUMN processing_status INT NOT NULL DEFAULT 0,
ADD COLUMN processing_error TEXT NOT NULL DEFAULT '',
ADD COLUMN processing_started_at TIMESTAMPTZ,
ADD COLUMN processing_completed_at TIMESTAMPTZ,
ADD COLUMN manual_refund_operator_id BIGINT,
ADD COLUMN manual_refund_completed_at TIMESTAMPTZ,
ADD COLUMN manual_refund_remark TEXT NOT NULL DEFAULT '',
ADD COLUMN manual_refund_voucher_key JSONB NOT NULL DEFAULT '[]';
CREATE INDEX idx_refund_request_approval_instance
ON tb_refund_request(approval_instance_id)
WHERE approval_instance_id IS NOT NULL;
```
`processing_error` 只保存可运维排查的摘要。`manual_refund_*` 只在人工退款确认时写入,退款申请时提交的 `refund_voucher_key` 仍是申请业务资料,不能混用为财务完成凭证。
现有退款审批允许确认 `approved_refund_amount`,切换到通用审批后必须保留:配置为最终决策的节点在完成时返回金额动作字段,审批人可确认实际退款金额;省略时使用申请金额。退款动作适配器在审批事务内校验金额大于 0且不超过申请退款金额和订单实收金额并写入退款单和审批操作日志。会签需要指定金额决策人时流程定义增加其专属最终节点禁止第一位会签人预先锁定金额。
### 流程
```mermaid
sequenceDiagram
actor Applicant as 提交人
actor Approver as 审批人
participant Refund as Refund Application
participant Approval as Approval Application
participant DB as PostgreSQL
participant Worker as AgentWalletRefundHandler
actor Finance as 财务人员
Applicant->>Refund: POST /api/admin/refunds
Refund->>DB: 同事务创建退款单(status=1)
Refund->>Approval: StartProcess(refund, refund_id)
Approval->>DB: 创建实例、首任务、审批人、Outbox
Refund->>DB: 回写 approval_instance_id
Approver->>Approval: 按 task_id 审批,决策节点可提交实际退款金额
Approval->>DB: 提交 ProcessApproved/Rejected/Returned
alt 代理钱包支付且审批通过
DB-->>Worker: Outbox + Asynq 至少一次投递
Worker->>DB: claim processing_status=1status=2
Worker->>DB: 幂等回退原扣款代理主钱包并写资金流水
Worker->>DB: processing_status=2
else 非代理钱包支付且审批通过
Refund->>DB: status=2, processing_status=0待人工退款
Finance->>Refund: POST manual-complete
Refund->>DB: 条件更新处理状态并记录确认信息
else 审批拒绝
Worker->>DB: status 从 1 更新为 3
else 退回修改
Worker->>DB: status 从 1 更新为 4
end
```
`AgentWalletRefundHandler` 仅处理代理钱包订单,使用 `refund:{refund_id}` 作为业务幂等键,并读取审批事务已经持久化的 `approved_refund_amount`。它必须按原扣款资金流水定位原代理主钱包,余额、版本、钱包退款流水和处理状态在同一事务更新。处理失败时单独更新 `processing_status=3` 和错误摘要后返回可重试错误;不得回滚已经完成的审批实例,也不得重复回退。
处理器通过条件更新领取任务:`processing_status IN (0,3)`,或状态为处理中但 `processing_started_at` 已超过约定租约。重复消费者看到未过期的处理中状态时不重复执行;进程在副作用完成后崩溃时,下一次重试依靠业务幂等键恢复并补写成功状态。
本期不调用第三方退款 API也不增加商户退款号、渠道退款号或渠道结果字段。个人/客户资产钱包退款不属于本期自动回退范围,现有对应分支必须在实施时隔离或拒绝进入本流程。
人工退款确认接口:
```text
POST /api/admin/refunds/{id}/manual-complete
```
请求包含 `request_id`、可选 `remark` 和最多 5 个完成凭证。后端仅允许具备财务确认权限的账号对 `status=2 AND processing_status IN (0,3)` 的非代理钱包退款操作;实际金额沿用审批金额,不允许在确认时再次改价。确认记录操作人、时间、备注和凭证后将处理状态置为已完成。
### 退回后重新提交
```
POST /api/admin/refunds/{id}/resubmit
→ 校验 status=4
→ 请求体可修改 actual_received_amount、requested_refund_amount、refund_voucher_key、refund_reason
→ 在同一事务新建 ProcessInstance
→ 更新 approval_instance_idstatus 回到 1待审批
→ processing_status 重置为 0清空本次处理错误和人工确认记录
→ 旧审批实例保留为历史记录
```
复用当前真实路由 `POST /api/admin/refunds/{id}/resubmit`,不新增单独 `PUT`。重提命令沿用现有 `ResubmitRefundRequest` 字段范围;禁止修改订单 ID、资产快照、提交人或已形成的历史审批记录。
### API 响应与前端
退款列表和详情增加:
```json
{
"approval_instance_id": 1001,
"approval_source": "workflow",
"approval_status": 2,
"approval_status_name": "已通过",
"current_node_name": "",
"processing_status": 0,
"processing_status_name": "待人工退款",
"processing_error": ""
}
```
前端展示规则:
| 审批状态 | 处理状态 | 展示 |
|----------|----------|------|
| 审批中 | 待处理 | 待审批 + 当前节点 |
| 已通过 + 代理钱包 | 处理中 | 审批已通过,代理钱包回退处理中 |
| 已通过 + 非代理钱包 | 待处理 | 审批已通过,待人工退款;财务可确认完成 |
| 已通过 | 已完成 | 退款已完成 |
| 已通过 + 代理钱包 | 处理失败 | 系统重试中;管理员可查看错误摘要 |
| 已拒绝 | 待处理 | 已拒绝 + 原因 |
| 已退回 | 待处理 | 已退回,可编辑并重新提交 |
列表页不直接放固定审批按钮。点击进入详情后,根据审批接口返回的 `available_actions` 渲染通过、驳回和退回操作。
决策节点根据 `action_form` 展示“实际退款金额”输入,默认等于申请金额。审批通过后的非代理钱包退款展示“确认人工退款”入口,仅具备财务确认权限时显示;完成凭证与审批附件分区展示。前端只负责元/分转换和基础格式校验,金额上限以后端在审批事务中的校验为准。
停机发布后,现有按退款业务单 ID 直接通过、驳回或退回的路由不再注册;所有退款审批动作统一操作 `task_id`,避免绕过审批人快照、并发控制和操作日志。
维护窗口内需要为 `status=1 AND approval_instance_id IS NULL` 的存量退款单执行幂等回填,从 `refund_approval` 首节点创建流程实例和任务;历史终态退款不伪造流程实例。
---
## 需求21充值审核流程
### 充值单现有状态
```
tb_agent_recharge_record1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款
```
代码中已经存在 `6=已驳回`,不能改写其含义。员工线下充值走审批流时,在现有状态基础上追加 `7=已退回`
```sql
-- 现有1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 6=已驳回
-- 新增7=已退回
ALTER TABLE tb_agent_recharge_record
ADD COLUMN approval_instance_id BIGINT,
ADD COLUMN processing_status INT NOT NULL DEFAULT 0,
ADD COLUMN processing_error TEXT NOT NULL DEFAULT '',
ADD COLUMN processing_started_at TIMESTAMPTZ,
ADD COLUMN processing_completed_at TIMESTAMPTZ,
ADD COLUMN return_reason VARCHAR(500) NOT NULL DEFAULT '';
CREATE INDEX idx_agent_recharge_approval_instance
ON tb_agent_recharge_record(approval_instance_id)
WHERE approval_instance_id IS NOT NULL;
```
充值业务的状态语义:
- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 `approval_instance_id` 查询审批状态)
- `2=已支付`:在线支付已确认,或线下充值审批通过后正在执行钱包入账
- `3=已完成`:充值到账
- `4=已关闭`:取消/超时
- `5=已退款`:退款
- `6=已驳回`:审批流程拒绝
- `7=已退回`:审批人退回给提交人修改
`rejection_reason` 只保存驳回原因,新增 `return_reason` 保存退回修改原因,禁止复用一个字段导致前端无法区分终止和可重提。
充值处理状态保持:`0=未触发, 1=处理中, 2=处理成功, 3=处理失败`。退款的 `0` 已收口为“待处理”,两者不要共用中文状态名称常量。
### 流程
**代理自行充值(不走审批)**
```mermaid
flowchart LR
A[代理提交充值申请] --> B[系统生成收款码]
B --> C[代理扫码支付]
C --> D[支付回调幂等入账]
```
**员工线下代充值(走审批)**
现有 `offline-pay` 的全局操作密码校验必须保留。通用审批详情在最后一个审批节点返回 `operation_password` 动作字段;审批动作适配器调用现有 `OperationPasswordService` 校验通过后才允许流程完成。密码只在内存中参与本次校验,不落库、不写审批日志、不进入 Outbox。
```mermaid
sequenceDiagram
actor Staff as 平台员工
actor Approver as 审批人
participant Recharge as Recharge Application
participant Approval as Approval Application
participant DB as PostgreSQL
participant Worker as RechargeApprovalHandler
Staff->>Recharge: POST /api/admin/agent-recharges(payment_method=offline)
Recharge->>DB: 同事务创建充值单(status=1)
Recharge->>Approval: StartProcess(recharge, recharge_id)
Approval->>DB: 创建实例、首任务、审批人、Outbox
Recharge->>DB: 回写 approval_instance_id
Approver->>Approval: 按 task_id 审批
DB-->>Worker: 投递流程结果事件
alt 审批通过
Worker->>DB: status 从 1 更新为 2processing_status=1
Worker->>DB: 幂等增加钱包余额并写流水
Worker->>DB: status 从 2 更新为 3processing_status=2
else 审批拒绝
Worker->>DB: status 从 1 更新为 6写 rejection_reason
else 退回修改
Worker->>DB: status 从 1 更新为 7写 return_reason
end
```
充值接口独立返回审批状态和业务处理状态。`RechargeApprovalHandler` 使用 `recharge:{recharge_no}` 作为幂等键;钱包余额、版本、充值单和交易流水必须在同一事务更新。
充值处理同样使用 `processing_started_at` 作为可恢复租约。重复事件不能再次增加余额;若钱包流水已经存在而充值单状态未完成,重试只补齐充值单状态。
### 退回后重新提交
```
POST /api/admin/agent-recharges/{id}/resubmit
→ 校验 status=7
→ 请求体可修改 amount、payment_voucher_key、remark
→ 在同一事务新建 ProcessInstance
→ 更新 approval_instance_idstatus 回到 1待支付/待审批)
→ processing_status 重置为 0清空处理错误和 return_reason
→ 旧审批实例保留为历史记录
```
新增 `resubmit` 路由时沿用现有 `/api/admin/agent-recharges` 资源名,不另建 `/agent-recharge-records` 路径。店铺、支付方式和提交人不可修改;编辑与新流程创建必须同事务完成。
### 停机切换
现有 `POST /api/admin/agent-recharges/{id}/offline-pay``POST /api/admin/agent-recharges/{id}/reject` 都会绕过通用审批任务,本次不保留兼容窗口:
1. 发布前进入维护模式,停止创建和处理线下充值。
2. 执行审批关联字段迁移,同时发布新 API、Worker 和前端。
3. 初始化并启用 `recharge → recharge_approval` 绑定。
4. 为存量“平台员工创建 + 线下支付 + 尚未入账”的充值记录幂等创建流程实例,代理在线充值不回填审批。
5. 新前端创建线下充值后直接进入审批详情,不再展示“确认线下充值”按钮。
6. 新版本不注册 `offline-pay` 和业务单级 `reject` 路由;线下充值只能由 `ProcessApproved` 事件触发幂等入账,驳回统一由任务级审批接口产生 `ProcessRejected`
7. 验证审批通过、驳回、退回、处理失败重试和钱包流水后再解除维护模式。
### 前端技术方案
- 代理自行充值保留现有收款码和支付状态页面,不显示审批信息。
- 平台员工选择 `offline` 时,提交成功进入充值详情并展示审批时间线。
- `status=6` 展示“已驳回”,`status=7` 展示“已退回”;两者按钮不同,只有已退回可编辑和重新提交。
- 审批通过但 `processing_status=1` 时显示“充值处理中”;状态为 3 且处理成功后才显示最新钱包余额。
- `processing_status=3` 时不允许前端再次点击入账,只展示系统重试状态和管理员排查入口。

View File

@@ -0,0 +1,349 @@
# 需求17信用额度
> 状态:原需求独立稿;最终口径以标准评审稿为准。
> DDD 范围:仅迁移代理主钱包的复杂写用例,资金列表和统计继续走 Query
> 关联需求BPO-009~012、需求19批量订购
---
## 一、评审结论
代理信用额度在现有 `AgentWallet` 基础上落地,属于典型资金聚合:余额、冻结金额、信用额度、版本和流水必须在同一事务内保持不变量。
平台员工信用额度不实施,原因如下:
- 平台员工是操作主体,不是订单结算主体;实际付款方只能是代理钱包或线下支付主体。
- 客户角色可以保存“新建店铺默认额度”模板,但角色本身不持有余额和债务;实际额度仍写入代理主钱包。
- 系统不存在员工钱包、员工充值、员工还款和离职债务交接链路,负余额无法对账和追责。
- 批量订购已经明确由代理钱包扣款或使用线下支付,不存在必须从员工个人额度扣款的业务场景。
- 引入员工信用会与代理钱包形成两套资金来源,增加订单归属、退款去向和审计解释成本,但不产生实际业务价值。
因此信用额度只属于代理主钱包,平台员工仅通过权限决定是否可以查看或调整代理额度。
---
## 二、已确认范围:代理主钱包授信
### 2.1 业务规则
- 信用额度只作用于代理主钱包,不作用于佣金钱包和资产钱包。
- 可用金额:`balance - frozen_balance + effective_credit_limit`
- `credit_enabled=false` 时,`effective_credit_limit=0`
- 余额可以为负,最低不能小于 `-(credit_limit - frozen_balance)`
- 扣款、冻结、解冻、充值和调额都必须维护同一钱包不变量。
- 存在欠款或冻结金额导致可用金额不足时,禁止降低额度或关闭信用。
- 金额统一使用分,禁止浮点数入库。
```mermaid
flowchart TD
Debit[请求扣款] --> Lock[按钱包ID和version加载]
Lock --> Calc[计算 balance - frozen + effective_credit]
Calc --> Enough{可用金额足够?}
Enough -->|否| Reject[拒绝:可用余额不足]
Enough -->|是| Update[条件更新余额和version]
Update --> Tx[同事务写资金流水]
Tx --> Success[返回扣款后余额]
```
### 2.2 角色默认与店铺实际额度
- 客户角色可以配置 `default_credit_enabled``default_credit_limit`,作为以后新建店铺的默认值。
- 新建店铺时读取请求中的 `default_role_id`,将该角色当时的默认配置复制到新建主钱包。
- 修改角色默认值不更新任何已有店铺,也不批量扫描钱包。
- 已有店铺通过独立资金接口直接修改实际额度;店铺后续角色变化不影响钱包。
- 关闭开关时额度必须为 0。
- 打开开关时额度必须大于 0。
- 修改额度前必须校验修改后的可用金额不为负,而不是只判断 `balance >= 0`
---
## 三、数据库变更
角色字段只是新建店铺模板,不参与运行时扣款。创建店铺后,最终权威数据只读取代理主钱包;修改角色默认值不会产生角色与钱包双写同步。
```sql
ALTER TABLE tb_agent_wallet
ADD COLUMN credit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_role
ADD COLUMN default_credit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN default_credit_limit BIGINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_agent_wallet.credit_enabled IS '是否启用信用额度,仅主钱包有效';
COMMENT ON COLUMN tb_agent_wallet.credit_limit IS '信用额度上限(分),仅主钱包有效';
COMMENT ON COLUMN tb_role.default_credit_enabled IS '新建代理店铺是否默认启用信用额度,仅客户角色有效';
COMMENT ON COLUMN tb_role.default_credit_limit IS '新建代理店铺默认信用额度(分),仅客户角色有效';
ALTER TABLE tb_agent_wallet
DROP CONSTRAINT IF EXISTS chk_agent_wallet_frozen_balance;
ALTER TABLE tb_agent_wallet
ADD CONSTRAINT chk_agent_wallet_frozen_nonnegative
CHECK (frozen_balance >= 0),
ADD CONSTRAINT chk_agent_wallet_credit_nonnegative
CHECK (credit_limit >= 0),
ADD CONSTRAINT chk_agent_wallet_available_nonnegative
CHECK (
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= 0
);
```
必须先检查生产库真实约束名;`DROP CONSTRAINT IF EXISTS` 不能替代迁移前核对。历史钱包默认关闭信用,行为不变。
---
## 四、领域模型
```go
// AgentWallet 代理主钱包聚合根
type AgentWallet struct {
ID uint
WalletType string
Balance int64
FrozenBalance int64
CreditEnabled bool
CreditLimit int64
Version int64
}
// AvailableBalance 返回当前可用金额。
func (w *AgentWallet) AvailableBalance() int64 {
credit := int64(0)
if w.CreditEnabled {
credit = w.CreditLimit
}
return w.Balance - w.FrozenBalance + credit
}
// Debit 执行钱包扣款并维护信用边界。
func (w *AgentWallet) Debit(amount int64) error {
if amount <= 0 {
return ErrInvalidAmount
}
if w.AvailableBalance() < amount {
return ErrInsufficientAvailableBalance
}
w.Balance -= amount
return nil
}
// ChangeCredit 修改信用配置。
func (w *AgentWallet) ChangeCredit(enabled bool, limit int64) error {
if limit < 0 || (!enabled && limit != 0) || (enabled && limit == 0) {
return ErrInvalidCreditConfig
}
nextCredit := int64(0)
if enabled {
nextCredit = limit
}
if w.Balance-w.FrozenBalance+nextCredit < 0 {
return ErrCreditLimitBelowDebt
}
w.CreditEnabled = enabled
w.CreditLimit = limit
return nil
}
```
领域层只维护资金不变量,不查询角色、店铺名称或页面权限。角色能否授信由 Application 在调用聚合前校验。
---
## 五、应用用例与持久化
### 5.1 修改代理信用额度
```text
Handler
→ ChangeShopCreditUseCase
→ 校验操作人和代理数据权限
→ 加载代理主钱包
→ 调用 wallet.ChangeCredit()
→ 按 version 条件更新钱包
→ 写操作日志和信用变更流水
```
条件更新示例:
```sql
UPDATE tb_agent_wallet
SET credit_enabled = ?,
credit_limit = ?,
version = version + 1,
updated_at = NOW()
WHERE id = ?
AND wallet_type = 'main'
AND version = ?
AND balance - frozen_balance +
CASE WHEN ? THEN ? ELSE 0 END >= 0;
```
受影响行数为 0 时,重新读取钱包以区分并发冲突和额度低于当前欠款。
### 5.2 修改角色默认信用额度
```text
Handler
→ UpdateRoleDefaultCreditUseCase
→ 校验角色为客户角色
→ 校验开关和额度组合
→ 只更新 tb_role 默认模板
→ 写角色配置审计
→ 不查询、不更新已有店铺钱包
```
### 5.3 钱包扣款
所有现有主钱包扣款语句必须从:
```text
balance - frozen_balance >= amount
```
统一改为:
```text
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount
```
扣款、版本递增和钱包流水必须在同一事务。禁止只修改 `GetAvailableBalance()` 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。
### 5.4 受影响用例
- 后台订单和批量订购的代理钱包支付。
- C端/代理端使用代理主钱包的订单支付。
- 钱包冻结与解冻。
- 退款回充和员工线下代充值。
- 资金概况、钱包详情和导出 Query。
实施时只迁移这些被信用额度触碰的完整资金用例,不主动改造佣金钱包和资产钱包。
---
## 六、查询方案
资金概况、钱包详情、列表和导出使用 Query 直接读取:
```sql
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END AS available_balance
```
响应统一增加:
```go
CreditEnabled bool `json:"credit_enabled" description:"是否启用信用额度"`
CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"`
AvailableBalance int64 `json:"available_balance" description:"可用金额(分)"`
IsInDebt bool `json:"is_in_debt" description:"余额是否为负"`
DebtAmount int64 `json:"debt_amount" description:"欠款金额(分)"`
```
`debt_amount = max(-balance, 0)`,不包含冻结金额。
---
## 七、API 设计
### 7.1 角色默认额度
```text
PUT /api/admin/roles/{id}/default-credit
```
```go
type UpdateRoleDefaultCreditRequest struct {
EnableCredit bool `json:"enable_credit" description:"新建店铺是否默认开启信用额度"`
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新建店铺默认信用额度(分)"`
}
```
现有 `POST /api/admin/shops` 继续使用 `default_role_id`。Application 在创建店铺和主钱包的同一事务中读取角色默认值并复制到钱包;角色模板之后变化不影响该钱包。
### 7.2 修改信用额度
```text
PUT /api/admin/shops/{id}/credit-limit
```
```go
type UpdateCreditLimitRequest struct {
EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"`
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"`
Version int64 `json:"version" validate:"min=0" description:"钱包版本,用于并发控制"`
}
```
### 7.3 查询展示
现有 `GET /api/admin/shops/fund-summary` 和代理详情响应增加信用字段;不新增不存在的 `/agent-wallets/{shop_id}` 路由。
---
## 八、前端技术方案
### 8.1 角色管理
```text
新建代理默认信用额度
[开关] 默认允许使用信用额度
默认授信上限 [金额输入,单位元]
提示:修改后只影响以后新建的代理,不影响已有店铺
```
- 开关关闭时清空输入并提交 `credit_limit=0`
- 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。
- 仅客户角色展示该配置;平台角色不展示。
### 8.2 店铺资金概况和详情
```text
账面余额:-¥200.00
冻结金额¥0.00
信用额度¥1,000.00
可用金额¥800.00
```
- `balance < 0` 时账面余额显示欠款样式。
- 可用金额以接口值为准,不在前端自行重复计算。
- 修改额度弹框展示当前余额、冻结金额、额度和修改后可用金额预览;提交后仍以后端校验为准。
- 后端返回并发冲突时刷新钱包版本和最新金额,不保留旧计算结果。
- 修改店铺角色、账号角色或角色默认额度都不能覆盖已有钱包额度。
- 编辑代理基础资料不自动修改信用额度;信用调整使用独立权限和独立接口。
平台员工端不展示个人余额或个人信用额度。拥有信用额度管理权限的员工,只能在代理资金页面查看和调整代理主钱包额度。
---
## 九、审计与可观测性
每次信用配置变更记录:
- 操作人、店铺、钱包 ID。
- 变更前后开关、额度、余额、冻结金额和版本。
- `request_id`、IP、设备信息和时间。
关键日志:
- 扣款因信用额度不足被拒绝。
- 钱包 version 冲突。
- 数据库信用边界约束失败。
- 信用额度调整失败和旧值/新值。
资金流水必须能够通过订单号、批量任务号或充值/退款业务号反查,不以普通操作日志替代钱包流水。
---
## 十、发布与回滚
本需求随七月迭代停机发布:
1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加钱包信用字段和角色默认模板字段,历史钱包默认关闭。
2. 同时发布钱包聚合、全部主钱包扣款条件、查询字段和管理端信用配置页面,禁止只改余额展示而遗漏真实扣款 SQL。
3. 开放访问前保持所有代理信用开关关闭,人工验证普通余额、信用扣款、并发冲突和额度调整。
4. 系统开放后配置客户角色的新建默认额度;已有店铺需要授信时仍由管理员在店铺资金页面逐个设置。
尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。

View File

@@ -0,0 +1,297 @@
# 需求22套餐临期提醒
> 状态:原需求独立稿;最终口径以标准评审稿为准。
---
## 业务规则
### 临期定义
当资产的当前生效主套餐与全部排队主套餐连续接续后的**预计最终剩余天数**按 `Asia/Shanghai` 自然日计算处于 `015` 天时该资产进入临期状态。预计最终到期时间与需求06/11共用同一个 Query不再维护“当前套餐临期”和“最终到期”两套口径。
没有生效套餐且队首套餐仍等待无法确定时间的实名激活时,返回不可预计状态,不进入临期。已过期资产不属于临期。
### 查询与通知职责
- 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。
- 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。
- `tb_expiry_push_record` 仅用于防止同一资产、接收人、渠道、阈值重复通知。
### 颜色规则Version 2
| 剩余天数 | 颜色 |
|---------|------|
| ≤ 15天 | 粉红色 |
| ≤ 7天 | 紫色 |
| ≤ 3天 | 红色 |
### 各端提醒规则
| 场景 | 提醒方式 | 触发节点 |
|------|---------|---------|
| **后台管理** | 列表加临期天数列 + 高亮详情加临期字段≤15天高亮 | 实时计算 |
| **企业客户** | 对应店铺业务员接收站内通知;后台每日生成临期列表 | 15天/7天/3天节点 |
| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 |
| **C端公众号** | 套餐到期提醒模块≤15天展示 | 实时展示 |
```mermaid
flowchart TD
Schedule[每日定时任务] --> Query[计算预计最终到期时间]
Query --> Predictable{可以推算?}
Predictable -->|否| Skip[不进入临期提醒]
Predictable -->|是| Days[计算最终剩余自然日]
Days --> Node{命中 15/7/3 天节点?}
Node -->|否| End[本次不发送]
Node -->|是| Upsert[按资产+节点+接收人幂等写通知记录]
Upsert --> Notification[站内消息]
```
---
## 数据库变更
临期状态实时计算,不建立临期快照表,避免数据陈旧。为保证通知幂等,单独保存发送记录。
每日临期通知记录需防重,用一个 key 记录已发送或已创建的通知:
```sql
-- 临期推送记录(防重)
CREATE TABLE tb_expiry_push_record (
id BIGSERIAL PRIMARY KEY,
package_usage_id BIGINT NOT NULL,
asset_type VARCHAR(20) NOT NULL, -- iot_card | device
asset_id BIGINT NOT NULL,
recipient_id BIGINT NOT NULL,
channel VARCHAR(20) NOT NULL, -- notification
push_node INT NOT NULL, -- 推送节点3/7/15天
event_id VARCHAR(64) NOT NULL,
pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_expiry_push_idempotency
ON tb_expiry_push_record(
package_usage_id, recipient_id, channel, push_node
);
CREATE UNIQUE INDEX idx_expiry_push_event
ON tb_expiry_push_record(event_id);
```
同一资产可能同时通知代理主账号和店铺业务员,唯一约束必须包含接收人和渠道;否则第一位接收人写入记录后会错误拦截其他接收人。`package_usage_id` 让同一资产续费生成新使用记录后可以再次触发 15/7/3 天提醒。本期只使用站内通知渠道。
---
## 后端实现
### 1. 现有接口新增临期字段
#### 资产列表接口(`GET /api/admin/iot-cards` / `GET /api/admin/devices`
响应新增字段:
```go
type IotCardListItem struct {
// ...原有字段...
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态"`
IsExpiring bool `json:"is_expiring" description:"是否临期"`
}
```
列表 Query 批量加载当前和排队主套餐复用需求06/11的周期、时长快照推算逻辑禁止逐资产查询。`is_expiring``days_until_final_expiry BETWEEN 0 AND 15` 派生;设备和卡不得各写一套日期规则。
#### 资产列表筛选条件新增
```go
type IotCardListRequest struct {
// ...原有字段...
ExpiringWithinDays *int `query:"expiring_within_days" description:"临期筛选(值=15表示查剩余≤15天"`
}
```
#### 资产详情接口
后台资产详情走 `GET /api/admin/assets/resolve/:identifier`,响应 DTO 为 `AssetResolveResponse``internal/model/dto/asset_dto.go`)。
新增字段:
```go
// AssetResolveResponse 追加
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态"`
IsExpiring bool `json:"is_expiring" description:"是否临期"`
```
### 2. 新增临期列表接口(独立页面)
```
GET /api/admin/expiring-assets
```
查询参数:
```go
type ExpiringAssetsRequest struct {
AssetType string `query:"asset_type" description:"资产类型 (iot_card/device)"`
AssetIdentifier string `query:"asset_identifier" description:"资产标识ICCID/设备号)"`
PackageName string `query:"package_name" description:"套餐名称"`
ShopID *uint `query:"shop_id" description:"店铺ID"`
ExpiresAtStart string `query:"expires_at_start" description:"到期时间起"`
ExpiresAtEnd string `query:"expires_at_end" description:"到期时间止"`
MaxDaysUntilFinalExpiry *int `query:"max_days_until_final_expiry" description:"最大预计最终剩余天数如15"`
Page int `query:"page" default:"1"`
PageSize int `query:"page_size" default:"20"`
}
```
响应:
```go
type ExpiringAssetItem struct {
AssetType string `json:"asset_type"`
AssetIdentifier string `json:"asset_identifier"`
AssetStatus int `json:"asset_status"`
ShopName string `json:"shop_name"`
PackageName string `json:"package_name"`
DaysUntilFinalExpiry int `json:"days_until_final_expiry"`
ExpiresAt time.Time `json:"expires_at"`
DataUsageMB int64 `json:"data_usage_mb"`
RemainingDataMB int64 `json:"remaining_data_mb"`
}
```
### 3. 每日定时任务(仅通知)
```go
// internal/task/expiry_reminder_handler.go
// HandleExpiryReminder 每日03:00按中国自然日扫描通知阈值。
func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error {
assets, err := h.packageUsageStore.GetExpiringAssetsForNotification(ctx, 15)
if err != nil { return err }
for _, asset := range assets {
node := h.selectNearestUnsentNode(ctx, asset, []int{15, 7, 3})
if node == 0 {
continue
}
today := time.Now().In(shanghaiLocation).Format("2006-01-02") // shanghaiLocation 由 time.LoadLocation("Asia/Shanghai") 初始化
for _, recipientID := range asset.RecipientAccountIDs {
eventID := fmt.Sprintf(
"expiry:%d:%d:%d:%d:%s",
asset.PackageUsageID, node, recipientID, asset.AssetID, today,
)
// 在事务内先写 expiry_push_record再通过 Outbox 发布站内消息。
if err := h.notifyPublisher.Publish(ctx, notification.SendPayload{
EventID: eventID,
RecipientIDs: []uint{recipientID},
RecipientType: constants.NotifyRecipientAdmin,
Type: constants.NotifyTypePackageExpiring,
Title: fmt.Sprintf("套餐临期提醒(%d天节点", node),
Body: fmt.Sprintf("资产 %s 的套餐剩余 %d 天", asset.Identifier, asset.DaysUntilExpiry),
RefType: asset.AssetType,
RefID: asset.AssetID,
}); err != nil {
return err
}
}
}
return nil
}
```
定时任务每日执行一次即可,页面不参与轮询。漏跑恢复后,对每个当前仍在 `015` 天范围内的资产,仅补发一个“当前最近且尚未发送”的阈值:例如第 15 天漏跑、剩余 14 天时补发 15 天通知;剩余 6 天时补发 7 天通知,不补发多条过期阈值。
---
## 导出功能(临期列表)
使用统一导出任务:
```text
POST /api/admin/export-tasks
scene=expiring_asset
```
导出字段:
| 字段 | 说明 |
|------|------|
| 资产标识 | ICCID/设备号 |
| 资产类型 | 卡/设备 |
| 店铺名称 | |
| 套餐名称 | |
| 套餐到期时间 | |
| 剩余天数 | |
| 资产状态 | |
| 已用流量(MB) | |
| 剩余流量(MB) | |
---
## C端接口变更
### 公众号首页
现有接口(`GET /api/c/v1/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse``internal/model/dto/client_asset_dto.go`)新增字段:
```go
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
IsExpiring bool `json:"is_expiring" description:"是否临期"`
```
前端逻辑:`is_expiring=true` 时展示续费提醒模块:
```
您的套餐即将到期
卡号/设备号XXXX
剩余有效期XX 天
为避免到期后影响正常使用,请您提前完成续费。
[立即续费]
```
---
## 前端对接(后台管理)
### 资产列表IoT卡管理 / 设备管理)
1. 列表新增"剩余天数"列
2. 根据 `days_until_final_expiry` 高亮行:
- ≤ 15天行背景粉红色
- ≤ 7天行背景紫色
- ≤ 3天行背景红色
3. 筛选条件新增"临期天数"下拉≤15天/≤7天/≤3天
- 选中后传 `expiring_within_days=15`
### 临期资产独立列表页
路由:`/expiring-assets`
```
筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺
列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量
操作:导出按钮 → `POST /api/admin/export-tasks``scene=expiring_asset`
```
临期独立列表将 `days_until_final_expiry <= 3` 的资产置顶,再按预计最终到期时间升序。普通卡列表和设备列表只按颜色高亮,不改变原有排序。
### 代理端首页
在首页数据接口新增字段(需要确认代理端首页接口):
```go
type AgentDashboardResponse struct {
// ...原有字段...
ExpiringCardCount int `json:"expiring_card_count" description:"临期卡数量≤15天"`
ExpiringDeviceCount int `json:"expiring_device_count" description:"临期设备数量≤15天"`
}
```
前端展示快捷入口:"xx张卡即将到期" → 跳转资产列表并过滤 `expiring_within_days=15`