迭代方案确认

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,271 @@
# 7月迭代前端技术方案
> 历史状态:共性前端来源稿;本地审批交互已废弃,最终以前端标准章节和各独立方案为准。
> 当前方案:[标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
> 适用端后台管理端、代理端、C 端 H5/公众号
> 最后更新2026-07-14
> 说明:当前仓库不包含前端源码,本文定义页面、交互和接口契约;实际目录、状态库和组件名称由前端仓库现状映射,禁止据此凭空更换前端技术栈。
---
## 一、目标与边界
本方案解决七月迭代中跨需求的前端共性问题:
- 审批任务、退款和充值使用同一套动态审批展示。
- Excel 导入、批量订购和导出使用统一的异步任务交互。
- 站内消息统一未读数、列表、已读和业务跳转。
- 配置类页面不让用户直接编辑 JSON 或依赖前端自行校验业务规则。
- 金额、状态、时间、权限和错误展示使用统一口径。
本文不指定 Vue、React、Pinia、Redux 或具体 UI 组件库。实现时必须优先复用前端仓库现有的请求封装、权限指令、上传组件、表格和轮询 Hook。
---
## 二、系统上下文
```mermaid
flowchart LR
AdminUser[平台/代理后台用户] --> AdminWeb[后台管理前端]
Customer[C端客户] --> ClientWeb[C端 H5/公众号]
AdminWeb -->|/api/admin/*| API[Junhong API]
ClientWeb -->|/api/c/v1/*| API
API --> DB[(PostgreSQL)]
API --> Redis[(Redis)]
API --> Queue[Asynq]
Queue --> Worker[Worker]
Worker --> APIData[业务数据/对象存储/Gateway]
AdminWeb -->|轮询未读数、任务状态| API
```
前端只根据 API 返回的权限、状态和可操作项渲染,不自行推导“谁能审批”“是否允许扣款”或“当前流程下一步是谁”。
---
## 三、模块边界
建议在现有前端目录结构中映射以下模块,不要求创建新的全局架构:
| 模块 | 负责内容 | 不负责内容 |
|------|----------|------------|
| Approval | 待办列表、流程详情、流程定义配置、审批动作 | 退款或充值的业务表单 |
| Notification | 未读数、通知列表、标记已读、业务跳转 | 审批状态计算 |
| AsyncTask | 导入、批量订购、导出的轮询与结果展示 | 各业务文件解析 |
| Refund | 退款申请、编辑、重新提交、业务处理状态 | 动态审批节点渲染的内部规则 |
| Recharge | 代理在线充值、员工线下代充值、重新提交 | 钱包入账和审批人判断 |
| SystemConfig | 配置表单和版本刷新 | 直接编辑任意 JSON |
全局状态只保留跨页面共享的数据:登录用户、菜单/按钮权限、通知未读数。列表数据、详情数据和表单草稿默认留在页面或模块级状态,避免把服务端状态复制到全局 Store 后长期失真。
---
## 四、接口与类型约定
### 4.1 路径前缀
- 后台管理:`/api/admin/*`
- C 端:`/api/c/v1/*`
- 回调接口不由前端调用。
专项文档出现省略 `/api` 的路径时,以本节和真实路由注册为准,并应在评审前修正。
### 4.2 枚举和显示
- 生命周期状态使用后端返回的 `status` 做逻辑判断,使用 `status_name` 做中文展示。
- 前端不得维护另一份与后端重复的中文状态映射;只有颜色、图标等纯展示映射可以留在前端。
- 金额 API 统一使用“分”,输入组件展示“元”,提交前做整数转换,禁止浮点数直接乘除后提交。
- 时间统一使用后端 ISO 8601 值,展示层按现有项目时区和格式化工具处理。
### 4.3 请求幂等
审批等敏感写操作由前端生成 `request_id`。一次用户操作从首次提交到网络重试必须复用同一个值;用户明确重新发起操作时才生成新值。
按钮提交后进入 loading 并禁止重复点击。前端防重只是体验控制,后端仍必须执行状态条件更新和唯一约束。
---
## 五、统一异步任务交互
适用:设备批量分配、批量订购、导出任务。
不适用于临期状态:临期列表、详情和首页数量由接口实时 SQL 计算;每日任务只生成 15/7/3 天通知,前端不轮询或维护临期快照。
```mermaid
stateDiagram-v2
[*] --> Editing: 填写参数/选择文件
Editing --> Submitting: 提交
Submitting --> Processing: 创建任务成功
Submitting --> Editing: 参数或上传失败
Processing --> Processing: 轮询进度
Processing --> Completed: 全部或部分完成
Processing --> Failed: 任务失败
Processing --> Cancelled: 用户取消且后端确认
Completed --> [*]
Failed --> Editing: 修正后重新提交
Cancelled --> [*]
```
### 5.1 轮询规则
- 创建成功后立即请求一次详情,再按 2 秒、3 秒、5 秒逐步退避,最大间隔 10 秒。
- 页面不可见时暂停轮询,恢复可见时立即刷新。
- 达到终态、离开页面或组件销毁时停止轮询。
- 连续网络失败不把业务任务标记为失败,展示“状态获取失败,点击重试”。
- 服务端返回 `retry_after_seconds` 时优先采用服务端建议。
### 5.2 结果展示
- 必须同时展示总数、成功数、失败数和任务状态。
- 部分成功不能只弹一个成功 Toast失败明细要留在页面并支持下载或复制具体能力按专项方案。
- 导出任务完成后展示下载按钮和链接过期时间;链接过期时重新获取任务详情,不重新创建导出任务。
### 5.3 Excel 模板
- 设备批量分配、批量订购等 Excel 模板由前端作为静态资源维护,后端不提供模板下载 API。
- 模板文件名包含版本号,下载入口与对应上传表单放在同一页面。
- 后端仍必须严格校验表头和内容,不能因为模板由前端提供就信任文件结构。
- 模板字段变更需要前后端同批发布,并保留对用户本地旧模板的可理解错误提示。
---
## 六、统一审批交互
```mermaid
stateDiagram-v2
[*] --> Loading
Loading --> ReadOnly: 当前用户无待处理资格
Loading --> Actionable: 当前用户是待处理审批人
Actionable --> EditingAction: 打开通过/驳回/退回弹框
EditingAction --> Uploading: 上传附件
Uploading --> EditingAction: 上传成功或失败后返回
EditingAction --> Submitting: 意见和附件校验通过
Submitting --> EditingAction: 请求失败且任务仍可操作
Submitting --> ReadOnly: 操作成功或状态已被他人改变
ReadOnly --> [*]
```
### 6.1 页面建议
| 页面 | 建议路由 | 主要能力 |
|------|----------|----------|
| 待我审批 | `/approvals/tasks` | 状态、业务类型、提交人、当前节点、提交时间筛选 |
| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、动态节点时间线、审批人和意见、当前可操作按钮 |
| 流程定义 | `/settings/approval-flows` | 草稿、发布版本、停用、业务绑定 |
| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点配置、角色/账号选择、或签/会签、发布前校验 |
第一版只支持串行节点,前端使用“有序节点列表编辑器”,不建设拖拽 DAG/BPMN 设计器。节点可上移、下移、增加和删除;开始、结束节点由系统生成且不可删除。
### 6.2 动态详情
审批详情按接口返回的 `tasks[]``assignees[]` 渲染,禁止写死“部门领导”和“财务”两个字段。
详情首屏必须先渲染 `business_snapshot`:业务标题、业务单号、提交人、审批关键字段和业务资料。业务资料来自发起时快照,审批附件来自某位审批人的操作记录,页面用两个区域展示;审批人不需要跳转到实时业务页才能知道正在审批什么。
操作按钮显示条件同时满足:
- 流程状态为审批中。
- 当前任务状态为待审批。
- 当前账号对应的审批人状态为待处理。
- 接口返回相应操作权限。
操作成功后重新请求审批实例和关联业务详情,不做乐观状态推进。
审批详情可返回 `action_form.fields`。前端只渲染后端声明的受控字段类型;退款金额和操作密码均由流程节点配置决定,且只有当前动作会完成该节点时才出现。金额展示元、提交分;操作密码不写入全局 Store、本地存储或重试缓存请求结束立即清空。节点没有动作字段时不显示额外表单禁止根据“财务审核”等节点名称自行添加输入项。
每个通过、驳回、退回弹框统一包含“审批意见”和“附件”:
- 通过意见可选;驳回、退回意见必填,最多 1000 字。
- 意见使用普通多行文本框,不提供富文本编辑器。
- 附件最多 5 个、单个默认不超过 20MB允许图片、PDF、Word、Excel打开动作弹框时先生成 `request_id`,申请 `purpose=approval_attachment` 且绑定该 `request_id` 的上传凭证,上传完成后提交 `file_key + file_name`。前端校验只用于体验,最终以后端对象元数据和上传归属校验为准。
- 文件仍在上传时禁止提交审批;删除尚未提交的附件只影响本地表单,不调用删除历史审批附件。
- 网络重试复用原 `request_id`、意见和附件集合;操作成功后清空本地表单。
- 时间线按审批人展示各自意见和附件。审批附件和业务资料分别通过审批实例权限校验接口换取短期 URL不直接拼对象存储地址。
### 6.3 业务处理中的展示
审批通过与退款到账、钱包入账不是同一时刻。退款和充值详情必须同时展示:
- 审批状态。
- 业务处理状态。
- 业务处理失败原因和“系统重试中/联系管理员”的提示;非代理钱包退款审批通过后显示“待人工退款”,仅财务确认权限可见确认完成入口。
不能仅根据业务单原状态显示“待审批”。
### 6.4 存量历史记录
业务详情接口增加 `approval_source`
- `none`:当前业务不需要审批,例如代理在线充值;隐藏审批区域。
- `workflow`:存在通用审批实例,展示动态时间线和 `available_actions`
- `legacy`:发布前已经结束的历史记录,没有完整流程实例;只读展示原业务状态、旧审批摘要和审计信息,不生成虚假节点。
如果一条仍需审批的退款或员工线下充值返回 `approval_instance_id=null`,前端按数据迁移异常展示并禁止任何审批操作,不能退回旧业务单审批接口。
---
## 七、页面与需求映射
| 需求 | 端 | 页面/入口 | 关键交互 |
|------|----|-----------|----------|
| 01 | 后台 | 资产详情复机操作 | 界面不变,展示后端真实结果 |
| 02 | 后台 + C端 | 卡/设备列表实名策略C端流程页 | 单条/批量设置C端按接口策略跳转 |
| 03/07/11/12/13 | 后台 | 原有列表和详情 | 新筛选、新字段、动态审批摘要 |
| 05 | 后台 | 套餐分配弹框、已分配列表 | 生效条件覆盖和修改提示 |
| 08 | 后台 | 设备管理批量操作 | “批量分配代理”和“批量分配套餐系列”两个入口,上传、任务轮询、失败明细 |
| 09 | 后台 + C端 | 系统配置;支付页 | 后台配置支付方式C端隐藏并由后端兜底拦截 |
| 10 | 后台 | 卡/设备资产详情 | 手动设置或取消当前卡限速,单位 `kbps`,不提供套餐限速配置 |
| 14 | 后台 | 各业务列表导出 | 字段选择、权限过滤、统一导出任务 |
| 15 | C端 + 后台 | 当前套餐卡片、后台代购 | 当前套餐旁“续费”复用购买流程;下架套餐仅在合法续费入口展示 |
| 16 | - | 已移出 7 月迭代 | 不建设分销码、代理申请、提现材料和相关审批页面 |
| 17 | 后台/代理端 | 代理信用、钱包详情 | 元/分转换、欠款状态、额度权限 |
| 18/20/21 | 后台 | 待办、退款、充值详情 | 动态审批时间线、处理状态、退回重提 |
| 19 | 后台 | 批量订购 | 参数确认、上传、逐行结果和金额汇总 |
| 22 | 后台 + 代理端 + C端 | 临期列表、首页提醒 | 15/7/3 天分级、续费跳转、到期后自动消失 |
---
## 八、站内消息
- 登录后和进入后台布局时立即获取未读数,之后每 30 秒轮询。
- 页面不可见时暂停,恢复时立即刷新。
- 铃铛数字超过 99 显示 `99+`
- 通知点击只按受控的 `ref_type + ref_id` 路由表跳转,不接受后端返回任意 URL。
- 标记已读失败不阻止查看业务详情,但需要在下次轮询时恢复真实未读状态。
- 通知正文按纯文本展示;若未来支持富文本,必须使用受控模板和统一净化,不直接渲染任意 HTML。
---
## 九、权限与敏感数据
- 菜单和按钮根据登录返回的权限控制可见性,但后端权限校验是最终依据。
- 审批人资格由任务接口返回,前端不根据角色名称推导。
- 导出字段由后端返回允许字段集合;前端只能在允许集合中选择。
- 退款凭证、充值凭证、身份证、营业执照和审批附件使用现有对象存储上传流程,不提交本地路径或长期公开 URL。审批附件额外提交清理后的原文件名作为展示快照。
- 列表和详情对无权限与不存在统一展示,避免通过前端文案泄露资源存在性。
---
## 十、停机发布
1. 发布前进入维护模式,前端统一展示维护页并停止提交写请求。
2. 维护窗口内执行数据库迁移,同时发布 API、Worker/Relay 和前端静态资源。
3. 初始化并启用退款、充值流程定义和业务绑定,执行存量待审批单回填。
4. 前端只调用任务级审批 API不保留退款或线下充值的业务单级通过/驳回按钮,也不保留线下充值“确认入账”按钮。
5. 人工验证登录、菜单权限、流程发起、存量历史展示、待办、审批详情、退回重提和业务处理状态。
6. 验证通过后解除维护模式;失败则在开放访问前回滚整套应用版本。
前端必须容忍新增响应字段且不依赖字段顺序,但本次不要求支持旧后端与新前端或新后端与旧前端交叉运行。
---
## 十一、待前端仓库确认
以下内容不影响当前接口和交互评审,但实施前必须在前端仓库确认:
- 后台、代理端和 C 端分别使用的框架版本与目录结构。
- 现有权限指令、请求封装、上传组件和导出 Hook 的真实名称。
- 是否已有通用任务轮询组件和流程时间线组件。
- 实际菜单路由和按钮权限编码。
- 表格是否支持服务端返回的动态导出字段配置。
确认后只补充实现映射,不改变本文已经评审通过的业务状态和 API 契约。

View File

@@ -0,0 +1,383 @@
# 基础设施站内消息Notification
> 历史状态:初版方案,已被站内通知详细方案替代。
> 替代方案:[站内通知详细方案](../新增需求/03-站内通知详细方案.md) 和 [标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
> 被依赖:需求 18/20/21审批流通知、需求 22临期提醒
> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口)
---
## 一、设计原则
- **业务代码不直接写通知表**:统一通过通知发布器或领域事件异步处理
- **关键领域事件先写 Outbox**:审批、退款、充值等事务内事件由 Outbox Relay 投递 Asynq禁止事务提交后直接入队
- **通知消费必须幂等**:使用稳定的 `event_id` 和接收人唯一约束Asynq 重试不得重复生成站内消息
- **不过度抽象**:不用 interface用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码)
- **前端轮询**30秒一次 `/api/admin/notifications/unread-count`,不用 WebSocket
```mermaid
sequenceDiagram
participant Biz as 业务事务
participant Outbox as Outbox
participant Relay as Relay
participant Worker as Notification Worker
participant DB as tb_notification
participant Web as 后台前端
Biz->>Outbox: 同事务写领域事件
Relay->>Outbox: 拉取待投递事件
Relay->>Worker: 至少一次投递
Worker->>DB: 按 event_id + recipient 幂等插入
Web->>DB: 经 API 轮询未读数/通知列表
Web->>DB: 经 API 标记已读
```
---
## 二、数据库
```sql
-- 迁移文件YYYYMMDD_create_tb_notification.sql
CREATE TABLE tb_notification (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(64) NOT NULL, -- 领域事件ID或调用方请求ID用于幂等
recipient_id BIGINT NOT NULL, -- 用户IDadmin user 或 agent user
recipient_type VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | agent
type VARCHAR(50) NOT NULL, -- 通知类型(见常量定义)
title VARCHAR(200) NOT NULL, -- 标题
body TEXT NOT NULL DEFAULT '', -- 纯文本正文
ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | iot_card | device
ref_id BIGINT, -- 关联业务ID
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ -- 过期自动不展示(可选,临期提醒用)
);
CREATE INDEX idx_notification_recipient
ON tb_notification (recipient_id, recipient_type, is_read, created_at DESC);
CREATE INDEX idx_notification_created ON tb_notification (created_at DESC);
CREATE UNIQUE INDEX uq_notification_event_recipient
ON tb_notification (event_id, recipient_id, recipient_type);
COMMENT ON TABLE tb_notification IS '站内消息通知表';
```
---
## 三、常量定义
```go
// pkg/constants/notification.go
// 通知类型
const (
NotifyTypeApprovalPending = "approval.pending" // 待我审批
NotifyTypeApprovalDone = "approval.done" // 审批完成(申请人收)
NotifyTypeApprovalRejected = "approval.rejected" // 审批驳回(申请人收,流程终止)
NotifyTypeApprovalReturned = "approval.returned" // 审批退回(申请人收,可修改后重新提交)
NotifyTypePackageExpiring = "package.expiring" // 套餐临期
NotifyTypeSystemAlert = "system.alert" // 系统通知
)
// 通知接收者类型
const (
NotifyRecipientAdmin = "admin" // 平台用户
NotifyRecipientAgent = "agent" // 代理用户(预留)
)
```
---
## 四、Model
```go
// internal/model/notification.go
// Notification 站内消息模型
type Notification struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"`
RecipientID uint `gorm:"column:recipient_id;not null;index;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_id"`
RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin';uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_type"`
Type string `gorm:"column:type;type:varchar(50);not null" json:"type"`
Title string `gorm:"column:title;type:varchar(200);not null" json:"title"`
Body string `gorm:"column:body;type:text;not null;default:''" json:"body"`
RefType *string `gorm:"column:ref_type;type:varchar(50)" json:"ref_type,omitempty"`
RefID *uint `gorm:"column:ref_id" json:"ref_id,omitempty"`
IsRead bool `gorm:"column:is_read;not null;default:false" json:"is_read"`
ReadAt *time.Time `gorm:"column:read_at" json:"read_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"`
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at,omitempty"`
}
func (Notification) TableName() string { return "tb_notification" }
```
---
## 五、发布侧NotificationPublisher
非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器,而由 `TaskCreated``ProcessApproved` 等事件处理器转换为通知载荷。
领域事件通知使用领域事件自身的 `event_id`;非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。
```go
// internal/infrastructure/messaging/notification_publisher.go
// SendPayload 发送通知的参数
type SendPayload struct {
EventID string // 领域事件ID或调用方请求ID同一次重试必须保持不变
RecipientIDs []uint // 接收人ID列表
RecipientType string // admin | agent
Type string // 通知类型常量
Title string // 标题
Body string // 正文
RefType string // 关联业务类型(可选)
RefID uint // 关联业务ID可选
ExpiresAt *time.Time // 过期时间(可选)
}
// NotificationPublisher 通知发布器(非接口,简单具体实现)
type NotificationPublisher struct {
queueClient *queue.Client
logger *zap.Logger
}
// Publish 发布通知(异步)
// 返回错误供调用方或 Asynq Handler 决定重试,禁止吞掉关键通知错误。
func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) error {
if payload.EventID == "" {
return errors.New(errors.CodeInvalidParam, "通知事件ID不能为空")
}
if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil {
p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type))
return err
}
return nil
}
```
审批事件处理器调用示例(节点激活后通知候选审批人):
```go
return h.notifyPublisher.Publish(ctx, notification.SendPayload{
EventID: event.EventID,
RecipientIDs: []uint{nextApproverID},
RecipientType: constants.NotifyRecipientAdmin,
Type: constants.NotifyTypeApprovalPending,
Title: "您有一条待审批记录",
Body: fmt.Sprintf("代理「%s」提交了充值申请请及时审批。", shopName),
RefType: "approval",
RefID: processInstanceID,
})
```
审批事务中只写 `TaskCreated` Outbox 事件。即使 Redis/Asynq 暂时不可用,事件仍保留在数据库,由 Relay 重试;通知处理失败则由 Asynq 重试当前任务。
---
## 六、消费侧Asynq Task Handler
```go
// internal/task/notification_handler.go
// HandleNotification 处理通知发送任务
func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.Task) error {
var payload notification.SendPayload
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("反序列化通知载荷失败: %w", err)
}
// 批量写入 tb_notification
records := make([]model.Notification, 0, len(payload.RecipientIDs))
for _, uid := range payload.RecipientIDs {
ref_type := (*string)(nil)
ref_id := (*uint)(nil)
if payload.RefType != "" {
ref_type = &payload.RefType
}
if payload.RefID != 0 {
ref_id = &payload.RefID
}
records = append(records, model.Notification{
EventID: payload.EventID,
RecipientID: uid,
RecipientType: payload.RecipientType,
Type: payload.Type,
Title: payload.Title,
Body: payload.Body,
RefType: ref_type,
RefID: ref_id,
ExpiresAt: payload.ExpiresAt,
})
}
if err := h.db.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
CreateInBatches(records, 100).Error; err != nil {
return fmt.Errorf("批量写入通知失败: %w", err)
}
// Phase 2在此处加企微/短信调用
// for _, sender := range h.extraSenders { sender.Send(ctx, payload) }
return nil
}
```
---
## 七、API 设计
### 7.1 未读数量前端轮询用30秒一次
```
GET /api/admin/notifications/unread-count
```
响应:
```json
{ "code": 0, "data": { "count": 5 } }
```
### 7.2 通知列表
```
GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20
```
请求参数:
```go
type NotificationListRequest struct {
IsRead *bool `query:"is_read" description:"是否已读(不传=全部)"`
Type string `query:"type" description:"通知类型过滤(可选)"`
Page int `query:"page" description:"页码"`
PageSize int `query:"page_size" description:"每页数量最大50"`
}
```
响应:
```json
{
"code": 0,
"data": {
"list": [
{
"id": 1,
"type": "approval.pending",
"title": "您有一条待审批记录",
"body": "代理「XX店」提交了充值申请请及时审批。",
"ref_type": "approval",
"ref_id": 42,
"is_read": false,
"created_at": "2026-07-11T10:00:00Z"
}
],
"total": 3,
"page": 1,
"page_size": 20
}
}
```
### 7.3 标记已读
```
PUT /api/admin/notifications/{id}/read
```
### 7.4 全部标记已读
```
PUT /api/admin/notifications/read-all
```
可传 `type` 过滤(只把某类型全部已读):
```json
{ "type": "approval.pending" }
```
### DTO
```go
// internal/model/dto/notification_dto.go
type NotificationListRequest struct {
IsRead *bool `query:"is_read"`
Type string `query:"type"`
Page int `query:"page" default:"1"`
PageSize int `query:"page_size" default:"20"`
}
type NotificationItem struct {
ID uint `json:"id"`
Type string `json:"type" description:"通知类型"`
Title string `json:"title"`
Body string `json:"body"`
RefType *string `json:"ref_type,omitempty"`
RefID *uint `json:"ref_id,omitempty"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type UnreadCountResponse struct {
Count int64 `json:"count"`
}
type ReadAllRequest struct {
Type string `json:"type" description:"通知类型(为空则全部已读)"`
}
```
---
## 八、前端对接
### 顶部导航栏铃铛
```
组件挂载 → 轮询 GET /api/admin/notifications/unread-count30秒一次
→ count > 0 时铃铛显示红点 + 数字
→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面
→ 打开时调 GET /api/admin/notifications?is_read=false
→ 点击某条通知 → PUT /api/admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页
```
### 跳转逻辑ref_type
| ref_type | 跳转页面 |
|----------|---------|
| `approval` | `/approvals/instances/{ref_id}` 审批详情 |
| `recharge` | `/agent-recharges/{ref_id}` 充值单详情 |
| `refund` | `/refunds/{ref_id}` 退款单详情 |
| `iot_card` | `/iot-cards/{ref_id}` IoT卡详情临期提醒 |
| `device` | `/devices/{ref_id}` 设备详情(临期提醒) |
### 通知列表页(/notifications
筛选:通知类型(下拉)、已读状态
操作:全部已读按钮
列表字段:类型、标题、时间、已读状态
点击行:跳转关联业务详情
前端页面不可见时暂停未读数轮询,恢复可见时立即刷新。通知正文按纯文本渲染;未来需要富文本时使用受控模板和统一净化,禁止直接渲染业务方提交的 HTML。
---
## 九、Phase 2 扩展预留
当需要接入企微通知时,只需在 `HandleNotification` 里追加:
```go
// 企微通知Phase 2
if h.wecomClient != nil {
for _, uid := range payload.RecipientIDs {
wecomOpenID := h.userStore.GetWecomOpenID(ctx, uid)
h.wecomClient.SendMessage(wecomOpenID, payload.Title, payload.Body)
}
}
```
业务代码零修改Handler 里加一段即可。

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`

View File

@@ -0,0 +1,456 @@
# 项目 DDD 设计规范
> 状态项目渐进迁移规范7月迭代标准稿已采用。
> 务实型 DDD——不是学院派不强制 Event Sourcing不追求完美追求可维护、可扩展、AI 辅助友好。
> 基准文档:`docs/改造方向.md`(绞杀者模式)
---
## 一、为什么用 DDD / 什么不是 DDD
### 现状问题
- `order/service.go` 3031 行:订单创建、支付、佣金计算、代购、库存扣减全混一起
- Model 只是 GORM 映射,没有业务行为(贫血模型)
- 新增业务联动必须修改原有 Service牵一发动全身
### 目标
不是重写,是**渐进替换**
1. **复杂写功能**:进入 Application + Domain不继续堆入旧 Service
2. **简单写功能**:使用 Application 事务脚本,不强行创建聚合
3. **读取功能**:进入 Query 读取模型,不通过聚合根
4. **旧功能迁移**:只在需求触碰时迁移一个完整用例,不做模块级重写
5. **AI 辅助**结构清晰AI 生成代码时自动落入正确位置
---
## 二、什么时候进入 DDD
DDD 是解决复杂业务边界的手段,不是所有功能的默认目录模板。开始设计前,先判断该需求属于“局部数据操作”还是“独立业务能力”。
### 2.1 优先使用 DDD 的场景
当前需求正在修改的用例满足以下任一条件时,应优先建立或扩展领域模块:
1. 有明确生命周期、状态机、状态转换限制或并发不变量。
2. 一个操作包含多个业务规则、跨模块协作、事务边界或可靠事件。
3. 同一业务能力会被多个业务场景复用,需要稳定的领域接口。
4. 需要策略扩展、规则配置、版本快照、审计追溯或幂等处理。
5. 继续向旧 Service 增加分支,会导致职责继续膨胀或修改相互影响。
审批流、钱包额度、订单支付、佣金结算等属于典型 DDD 场景。
### 2.2 通常不迁移的场景
以下需求通常保持原有 `Handler → Service → Store → Model`
1. 单表 CRUD、字段增删、简单列表筛选和格式转换。
2. 局部缺陷修复,且不改变业务边界或核心规则。
3. 没有独立领域语言、生命周期或跨模块不变量。
4. 引入聚合、仓储接口和领域事件后只有样板代码,没有业务收益。
### 2.3 触碰式迁移
- 默认不迁移当前需求未触碰的旧代码,禁止借功能修改之名扩大为模块级重构。
- 迁移单位是**完整用例**,不是整个模块、文件或数据表。
- 简单字段、筛选、格式转换和局部修复默认沿用旧结构。
- 触碰复杂写逻辑时,只迁移完成当前需求所需的最小完整业务边界。
- 一旦迁移某个用例,其状态规则和业务不变量必须完整收口,禁止一半留在旧 Service、一半进入 Domain。
- 旧 Service 可以暂时作为**内部代码迁移门面**调用新 UseCase调用方迁完后再删除。该规则不代表必须保留旧 HTTP 接口,七月迭代审批相关接口按停机方案直接切换。
- 新旧模块通过 Application 接口、领域事件或防腐层交互,禁止绕过边界直接修改聚合数据。
### 2.4 查询侧规则
复杂查询不通过聚合根,统一采用轻量 CQRS 读取模型:
```text
Handler → Query → GORM/DTO
```
- 列表、详情、联表、统计、报表和导出属于 Query。
- Query 可以直接使用 GORM、CTE、子查询和批量查询并负责读取权限与分页。
- Query 返回专用 DTO/Projection禁止返回后用于业务写入。
- 只有查询结果参与写操作判定时Domain 必须重新校验,不能信任读取快照。
- 当前需求未触碰的旧查询不迁移;复杂度明显增加时,只迁移该查询到 `internal/query/<context>`
- Aggregate Repository 只负责加载和保存聚合,不承载多表列表、报表或导出查询。
---
## 三、目录结构
```
internal/
├── domain/ ← 领域层(不依赖 Fiber/Redis/GORM
│ ├── wallet/
│ │ ├── wallet.go ← 聚合根(含业务方法)
│ │ ├── events.go ← 领域事件定义
│ │ ├── repository.go ← 仓储接口interface
│ │ └── vo.go ← 值对象Money, CreditLimit
│ ├── approval/ ← 新:审批流领域
│ ├── notification/ ← 新:通知领域
│ ├── distribution/ ← 后续预留需求16独立立项后再创建
│ └── package/ ← 套餐领域(迁移中)
├── application/ ← 应用层(用例,只做编排,不含业务判断)
│ ├── wallet/
│ │ ├── debit_wallet.go ← 一个文件 = 一个用例
│ │ ├── credit_wallet.go
│ │ └── grant_credit.go ← 新:授信
│ ├── approval/
│ │ ├── submit_approval.go
│ │ ├── advance_step.go
│ │ └── reject_approval.go
│ └── notification/
│ └── send_notification.go
├── query/ ← 读取侧(可直接使用 GORM返回 DTO/Projection
│ ├── order/
│ ├── refund/
│ ├── approval/
│ ├── dashboard/
│ └── report/
├── infrastructure/ ← 基础设施层(实现 domain 里的 interface
│ ├── persistence/ ← GORM Repository可委托现有 Store
│ ├── messaging/ ← Outbox Relay、Asynq 发布与消费
│ └── adapter/ ← 外部系统和旧模块防腐层
├── handler/ ← 原有 handler保持不动
├── service/ ← 原有 service保持不动新模块不在这里
└── store/ ← 原有 store保持不动
```
**过渡期规则**
- 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx`
- 复杂写用例:`internal/domain/xxx` + `internal/application/xxx`
- 简单写用例:`internal/application/xxx`
- 读取用例:`internal/query/xxx`
- Handler 按用例调用 Application、Query 或尚未迁移的旧 Service
- 全新领域优先使用纯领域对象;迁移旧模块时可临时复用现有 GORM Model但不得让 Fiber、Redis 或 GORM 查询进入业务方法
---
## 四、领域模块划分
| 领域 | 聚合根 | 核心业务规则 | 状态 |
|------|--------|------------|------|
| **Asset资产** | `IotCard`, `Device` | 停复机条件、实名策略 | 迁移中 |
| **Order订单** | `Order` | 支付、退款、佣金触发 | 迁移中 |
| **Package套餐** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 |
| **Wallet钱包** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** |
| **Shop代理** | `Shop` | 层级关系、信用策略 | 部分迁移 |
| **Approval审批** | `ProcessDefinition`, `ProcessInstance` | 流程版本、审批人解析、状态推进、驳回 | **全新 DDD** |
| **Notification通知** | `Notification` | 分发、已读状态 | **全新 DDD** |
| **Distribution分销** | `DistributionRelation` | 发展人体系、二维码注册 | 后续独立立项,本期不创建 |
---
## 五、聚合根设计原则(富模型)
### 5.1 核心原则
业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。
```go
// ❌ 贫血模型(禁止)——所有判断在 Service 里
func (s *WalletService) Debit(ctx context.Context, walletID uint, amount int64) error {
wallet, _ := s.store.Get(ctx, walletID)
if wallet.Balance-wallet.FrozenBalance < amount {
return errors.New(errors.CodeInsufficientBalance)
}
wallet.Balance -= amount
s.store.Update(ctx, wallet)
}
// ✅ 富模型(推荐)——判断逻辑在聚合根里
func (w *AgentWallet) Debit(amount Money) error {
available := w.Balance - w.FrozenBalance + w.CreditLimit // 含信用额度
if available < amount.Cents() {
return ErrInsufficientBalance
}
w.Balance -= amount.Cents()
w.recordEvent(WalletDebitedEvent{Amount: amount, BalanceAfter: w.Balance})
return nil
}
// Application 层只做编排
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
wallet, err := uc.repo.GetByID(ctx, cmd.WalletID)
if err != nil { return err }
if err := wallet.Debit(cmd.Amount); err != nil { return err }
// 完整事务和 Outbox 写入方式见“应用服务(用例)”章节
return uc.repo.Save(ctx, wallet)
}
```
### 5.2 聚合根必须包含
```go
type AggregateRoot struct {
// 业务字段...
// 未发布领域事件,由 Application 在事务内写入 Outbox
domainEvents []DomainEvent
}
// PopEvents 获取并清空领域事件
func (a *AggregateRoot) PopEvents() []DomainEvent {
events := a.domainEvents
a.domainEvents = nil
return events
}
func (a *AggregateRoot) recordEvent(e DomainEvent) {
a.domainEvents = append(a.domainEvents, e)
}
```
### 5.3 与现有 GORM 的共存
迁移旧模块时,聚合根可以临时包装现有 GORM Model减少一次性重构成本
```go
// internal/domain/wallet/wallet.go
// AgentWallet 钱包聚合根
// 直接复用并扩展现有 model.AgentWallet
type AgentWallet struct {
model.AgentWallet // 迁移期复用持久化字段
domainEvents []DomainEvent
}
// Debit 从钱包扣款(含信用额度)
func (w *AgentWallet) Debit(amount Money) error {
available := w.Balance - w.FrozenBalance + w.CreditLimit
if available < amount.Cents() {
return ErrInsufficientBalance
}
w.Balance -= amount.Cents()
w.recordEvent(WalletDebitedEvent{...})
return nil
}
```
这只是迁移期折中,不是新领域的默认形式。审批流等全新领域应优先使用纯领域对象,由 Infrastructure 负责领域对象与 GORM Model 的转换。
---
## 六、值对象设计
值对象:没有 ID靠值来判断相等不可变。
```go
// internal/domain/wallet/vo.go
// Money 金额值对象(分为单位,防止浮点数精度问题)
type Money struct {
cents int64
}
func NewMoney(cents int64) (Money, error) {
if cents < 0 {
return Money{}, ErrNegativeMoney
}
return Money{cents: cents}, nil
}
func (m Money) Cents() int64 { return m.cents }
func (m Money) Yuan() float64 { return float64(m.cents) / 100 }
func (m Money) Add(o Money) Money { return Money{cents: m.cents + o.cents} }
// CreditLimit 信用额度值对象
type CreditLimit struct {
maxCents int64 // 最大授信额度(分)
allowNegative bool // 是否允许负余额
}
```
---
## 七、领域事件与可靠投递
领域对象只记录已经发生的业务事实,不直接调用 Asynq。Application 在保存聚合的同一数据库事务中写入 Outbox再由 Relay 异步投递到 Asynq。
```go
// internal/domain/wallet/events.go
// DomainEvent 领域事件接口
type DomainEvent interface {
EventType() string
OccurredAt() time.Time
}
// WalletDebitedEvent 钱包扣款事件
type WalletDebitedEvent struct {
WalletID uint
ShopID uint
Amount Money
BalanceBefore int64
BalanceAfter int64
RefType string
RefID uint
occurredAt time.Time
}
func (e WalletDebitedEvent) EventType() string { return "wallet.debited" }
func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt }
```
**事务边界**
```text
数据库事务
├── 保存聚合
├── 保存操作日志
└── 保存 Outbox 事件
提交事务
Outbox Relay → Asynq → 事件处理器
```
**Application UseCase 保存事件**
```go
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error {
wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID)
if err != nil {
return err
}
if err := wallet.Debit(cmd.Amount); err != nil {
return err
}
if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
return err
}
return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
})
}
```
- Outbox 写入失败:事务回滚,避免“业务成功但关键事件永久丢失”。
- Asynq 暂时不可用不回滚已提交业务Relay 后续重试。
- 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。
---
## 八、仓储接口
```go
// internal/domain/wallet/repository.go
// WalletRepository 钱包仓储接口
type WalletRepository interface {
GetByShopID(ctx context.Context, shopID uint, walletType string) (*AgentWallet, error)
GetByID(ctx context.Context, id uint) (*AgentWallet, error)
Save(ctx context.Context, wallet *AgentWallet) error
}
```
实现在 `internal/infrastructure/persistence/wallet_repo.go`,可以复用现有 Store 逻辑。事务由 Application 注入的 `TxManager` 管理Repository 从事务上下文获取 GORM `tx`,领域接口不得暴露 `*gorm.DB`
---
## 九、应用服务(用例)
一个文件 = 一个用例。
- 复杂写用例Application 只做事务和跨聚合编排,业务不变量位于 Domain。
- 简单写用例Application 可以使用事务脚本完成基础校验和单表写入,不要求创建聚合。
- Application 不负责列表、报表和复杂 DTO 拼装,这些职责属于 Query。
```go
// internal/application/wallet/debit_wallet.go
// DebitCommand 扣款命令
type DebitCommand struct {
WalletID uint
Amount domainWallet.Money
RefType string
RefID uint
OperatorID uint
}
// DebitWalletUseCase 扣款用例
type DebitWalletUseCase struct {
walletRepo domainWallet.WalletRepository
outboxRepo OutboxRepository
txManager TxManager
}
// Execute 执行扣款
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error {
wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID)
if err != nil {
return err
}
if err := wallet.Debit(cmd.Amount); err != nil {
return err
}
if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
return err
}
return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
})
}
```
---
## 十、Handler 层调用方式
- 复杂写、简单写Handler → Application UseCase。
- 读取Handler → Query。
- 尚未迁移的旧用例Handler → Service。
- Handler 不直接访问 GORM也不承载业务规则或复杂 DTO 拼装。
```go
// internal/handler/admin/wallet_handler.go
func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error {
var req dto.GrantCreditRequest
// ... 参数解析
cmd := walletApp.GrantCreditCommand{
ShopID: req.ShopID,
MaxCredit: domainWallet.NewCreditLimit(req.MaxCreditCents),
OperatorID: middleware.GetUserID(c),
}
if err := h.grantCreditUseCase.Execute(c.UserContext(), cmd); err != nil {
return err
}
return response.OK(c, nil)
}
```
---
## 十一、禁止事项
| 禁止 | 原因 |
|------|------|
| 在 Application 层实现复杂业务不变量 | 状态机、金额、库存等规则必须在领域对象里 |
| 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用,运行时通过 Repository 加载 |
| 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 |
| 在一个用例文件里实现多个业务流程 | 一文件一用例 |
| Repository 保存后直接发布关键事件 | 数据已提交但消息可能丢失,必须事务写 Outbox |
| Outbox 未落库仍提交业务事务 | 会造成业务成功但关键回调永久缺失 |
| 在聚合根里调用 Repository | 聚合根不依赖仓储 |
| Query 执行写操作 | Query 只负责读取模型,写入必须进入 Application |
| 使用 Query 快照替代写侧校验 | 查询结果可能已过期Domain 必须重新验证不变量 |
| 在 Aggregate Repository 中堆叠报表联查 | 聚合仓储负责加载和保存聚合,复杂读取属于 Query |
| 只创建 domain/application 目录但业务规则仍在旧 Service | 这是目录搬迁,不是 DDD 迁移 |
---
## 十二、本次迭代 DDD 落地计划
| 新模块 | 领域路径 | 用例路径 |
|--------|---------|---------|
| 信用额度需求17 | `internal/domain/wallet/` | `internal/application/wallet/` |
| 审批流需求18/20/21 | `internal/domain/approval/` | `internal/application/approval/`,使用版本快照和 Outbox |
| 站内消息需求18/22 | `internal/domain/notification/` | `internal/application/notification/` |
| 批量订购需求19 | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` |
需求 16 已于 2026-07-14 移出 7 月迭代,本期不创建 `distribution` 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。

View File

@@ -0,0 +1,237 @@
# 基础设施系统配置tb_system_config
> 状态独立方案来源稿最终口径以7月迭代标准评审稿为准。
> 被依赖:需求 09C端支付限制
---
## 一、设计目标
将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。
```mermaid
sequenceDiagram
actor Admin as 平台超管
participant Web as 后台配置页
participant API as SystemConfig Application
participant DB as PostgreSQL
participant Redis as Redis
participant Biz as 业务读取方
Admin->>Web: 修改受控配置表单
Web->>API: PUT /api/admin/system/config/{config_key}
API->>API: 按配置 key 注册规则校验类型和值域
API->>DB: 更新值并写审计日志
API->>Redis: 删除对应缓存
API-->>Web: 返回最新配置和更新时间
Biz->>Redis: 下次读取缓存未命中
Biz->>DB: 读取最新配置并回填缓存
```
---
## 二、数据库
```sql
-- 迁移文件YYYYMMDD_create_tb_system_config.sql
CREATE TABLE tb_system_config (
id BIGSERIAL PRIMARY KEY,
config_key VARCHAR(100) NOT NULL, -- 唯一键格式module.group.name
config_value TEXT NOT NULL DEFAULT '', -- 值string/number/json字符串
value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string | int | bool | json
module VARCHAR(50) NOT NULL DEFAULT 'general', -- 所属模块(便于按模块查询)
description TEXT, -- 中文说明(前端展示用)
is_readonly BOOLEAN NOT NULL DEFAULT FALSE, -- 是否只读(代码内部,不允许后台改)
creator BIGINT NOT NULL DEFAULT 0,
updater BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_system_config_key UNIQUE (config_key)
);
COMMENT ON TABLE tb_system_config IS '系统全局配置表';
-- 初始化数据
INSERT INTO tb_system_config (config_key, config_value, value_type, module, description) VALUES
-- C端支付限制需求9
('c2b.payment.card_allowed_methods', '["alipay","wallet"]', 'json', 'c2b.payment', '卡资产C端允许的支付方式alipay/wechat/wallet'),
('c2b.payment.device_allowed_methods', '["wechat","wallet"]', 'json', 'c2b.payment', '设备C端允许的支付方式');
```
需求02不写入全局默认实名策略。H5 始终读取卡或设备自身的 `realname_policy`;新建资产使用模型默认 `after_order`,避免全局 Key 与资产字段产生两套优先级。
---
## 三、Model
```go
// internal/model/system_config.go
// SystemConfig 系统全局配置模型
type SystemConfig struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
ConfigKey string `gorm:"column:config_key;uniqueIndex;not null" json:"config_key"`
ConfigValue string `gorm:"column:config_value;type:text;not null;default:''" json:"config_value"`
ValueType string `gorm:"column:value_type;type:varchar(20);not null;default:'string'" json:"value_type"`
Module string `gorm:"column:module;type:varchar(50);not null;default:'general';index" json:"module"`
Description string `gorm:"column:description;type:text" json:"description"`
IsReadonly bool `gorm:"column:is_readonly;not null;default:false" json:"is_readonly"`
Creator uint `gorm:"column:creator;not null;default:0" json:"creator"`
Updater uint `gorm:"column:updater;not null;default:0" json:"updater"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (SystemConfig) TableName() string { return "tb_system_config" }
```
---
## 四、Store
```go
// internal/store/postgres/system_config_store.go
type SystemConfigStore struct {
db *gorm.DB
}
func (s *SystemConfigStore) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error)
func (s *SystemConfigStore) GetByModule(ctx context.Context, module string) ([]model.SystemConfig, error)
func (s *SystemConfigStore) UpdateValue(ctx context.Context, key string, value string, updaterID uint) error
func (s *SystemConfigStore) BatchGet(ctx context.Context, keys []string) (map[string]*model.SystemConfig, error)
```
---
## 五、配置读取辅助包
业务代码不直接操作 Store通过辅助函数读取带 Redis 缓存5分钟TTL
```go
// pkg/sysconfig/config.go
// GetString 读取字符串配置,返回默认值
func GetString(ctx context.Context, key string, defaultVal string) string
// GetStringSlice 读取 JSON 数组配置
func GetStringSlice(ctx context.Context, key string) ([]string, error)
// GetBool 读取布尔配置
func GetBool(ctx context.Context, key string, defaultVal bool) bool
// InvalidateCache 更新配置后清缓存(在 UpdateValue 后调用)
func InvalidateCache(ctx context.Context, key string)
```
Redis Key`sys:config:{config_key}`TTL 5分钟。
业务代码用法:
```go
// 读取卡的允许支付方式
allowedMethods, _ := sysconfig.GetStringSlice(ctx, "c2b.payment.card_allowed_methods")
// 返回 ["alipay","wallet"]
```
---
## 六、API 设计
### 6.1 获取配置列表
```
GET /api/admin/system/config?module=c2b.payment
```
响应:
```json
{
"code": 0,
"data": {
"list": [
{
"config_key": "c2b.payment.card_allowed_methods",
"config_value": "[\"alipay\",\"wallet\"]",
"value_type": "json",
"description": "卡资产C端允许的支付方式",
"is_readonly": false,
"updated_at": "2026-07-11T10:00:00Z"
}
]
}
}
```
### 6.2 更新配置
```
PUT /api/admin/system/config/{config_key}
```
请求体:
```json
{
"config_value": "[\"alipay\",\"wallet\",\"wechat\"]"
}
```
权限:仅平台超管可操作。
### DTO
```go
// internal/model/dto/system_config_dto.go
// SystemConfigListRequest 配置列表查询请求
type SystemConfigListRequest struct {
Module string `query:"module" description:"按模块过滤(可选)"`
}
// SystemConfigItem 配置项响应
type SystemConfigItem struct {
ConfigKey string `json:"config_key"`
ConfigValue string `json:"config_value"`
ValueType string `json:"value_type" description:"值类型 (string/int/bool/json)"`
Module string `json:"module"`
Description string `json:"description"`
IsReadonly bool `json:"is_readonly"`
UpdatedAt time.Time `json:"updated_at"`
}
// UpdateSystemConfigRequest 更新配置请求
type UpdateSystemConfigRequest struct {
ConfigValue string `json:"config_value" validate:"required" description:"新配置值"`
}
```
更新接口不能只校验 `value_type`。Application 需要按 `config_key` 注册允许值,例如支付方式只能来自 `alipay/wechat/wallet`,实名策略只能来自 `none/before_order/after_order`。未知 key 默认只读,禁止通过通用页面写入任意系统配置。
---
## 七、前端对接
### 页面:系统设置 > 系统配置
**初期可以做一个通用的 Key-Value 管理页面,按 module 分组展示。**
调用流程:
1. 进入页面 → `GET /api/admin/system/config`(不传 module = 返回全部)
2. 按 module 分组展示,`is_readonly=true` 的配置只读
3. 修改某项 → `PUT /api/admin/system/config/{config_key}`body: `{config_value}`
4. 更新成功后后端立即删除该 key 的缓存,前端提示“配置已更新”并刷新当前值
**C端支付限制配置展示建议**(针对 `c2b.payment` 模块):
不要让后台用户手动填 JSON前端渲染成 CheckboxGroup
```
卡资产允许支付方式:
☑ 支付宝 ☑ 钱包 ☐ 微信
设备允许支付方式:
☐ 支付宝 ☑ 钱包 ☑ 微信
```
前端把选中项序列化成 `["alipay","wallet"]` 提交。
通用 Key-Value 页面只作为管理壳层,已知业务配置必须使用受控组件(单选、复选或开关),不向运营人员暴露 JSON 文本框。

View File

@@ -0,0 +1,625 @@
# 新增需求 01数据同步触发与轮询优化
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 范围IoT 卡实名、流量、网络状态和设备实时数据同步。
## 一、已确认决策
1. 轮询、运营商回调、业务事件触发是三条相互隔离的自动同步通道。
2. 轮询始终保留,负责回调失效、事件漏网和上游偶发失败时的最终兜底。
3. 资产是否轮询只保留现有全局开关 `enable_polling`;不再设计“实名资格、流量资格、状态资格”等业务资格开关。
4. 轮询优化只区分活跃卡和不活跃卡,通过不同轮询间隔降低无效请求,不因业务类型直接永久排除某类卡。
5. 行业卡是否需要实名不能由 `card_category=industry` 推断,统一以运营商 `realname_link_type` 判断。
6. 业务事件触发不是新增一个显式同步接口,而是埋点到关键查询、停复机、支付、实名和设备操作用例中。
7. 每次事件默认形成三个独立尝试:立即、事件发生后 3 分钟、事件发生后 5 分钟;三次结束后由常规轮询继续兜底。
8. 不设置统一五分钟冷却。重复请求控制拆成同场景触发合并、单卡请求互斥和运营商最小请求间隔Gateway 超频只记录当前失败,不建立退避状态。
9. 运营商实名回调百分百信任,不做来源真实性校验;但必须经过防腐层完成报文解析、标识转换和状态语义转换。
10. 解除实名第一版不修改业务状态,只保留回调入口、写入统一集成审计并返回运营商要求的成功报文。
11. 三条自动通道和现有手动刷新接口共享同一套“获取上游观测并应用到业务”的 DDD 用例,禁止各入口直接更新卡字段。
12. 同步执行记录属于全局审计的 Integration Log不在本方案新建 `tb_card_sync_execution` 或独立同步监控系统。
13. 本次迁移触碰到的旧实名、流量和状态写逻辑必须删除或改为调用新用例,不保留长期双实现。
## 二、现状问题
当前代码存在以下明确问题:
- `internal/task/polling_realname_handler.go` 直接跳过所有行业卡,与“是否实名由运营商特性决定”冲突。
- `PollingRealnameHandler``PollingCarddataHandler``PollingCardStatusHandler` 同时承担 Gateway 查询、字段更新和业务联动。
- `internal/service/iot_card/service.go:RefreshCardDataFromGateway` 又实现一套实名、流量和网络状态同步。
- `ManualUpdateRealnameStatus` 单独实现实名状态变更联动,无法保证与轮询、回调行为一致。
- `ClientRealnameHandler`、部分设备 Handler 直接调用 Gateway业务事件无法在统一用例边界埋点。
- 现有获取实名链接后的自动检查借用了 `ManualTriggerService`,把系统事件伪装成平台用户手工操作。
- 调度任务载荷只有 `card_id`,缺少触发场景、来源、序列和关联链路。
- `last_sync_time` 被实名、流量和状态共同覆盖,无法说明最后同步了什么。
- 现有显式刷新接口已经存在,再增加 `/card-sync/requests` 只会形成重复入口。
因此,本次不是增加更多同步 Service而是先收口写侧用例再把轮询、回调和事件触发接入同一边界。
## 三、目标架构
```mermaid
flowchart LR
Polling[轮询调度] --> Activity[按活跃度计算下次间隔]
Activity --> Request[RequestCardObservation]
Business[关键业务用例] --> Trigger[CreateSyncTriggerSeries]
Query[关键查询用例] --> Trigger
Trigger --> A1[立即尝试]
Trigger --> A2[3分钟后尝试]
Trigger --> A3[5分钟后尝试]
A1 --> Gate[请求协调器]
A2 --> Gate
A3 --> Gate
Gate --> Request
Manual[现有手动刷新接口] --> Request
Callback[运营商实名回调] --> ACL[运营商防腐层]
ACL --> RealObs[标准实名观测]
Request --> Gateway[Gateway Adapter]
Gateway --> Observation[标准化观测值]
RealObs --> Apply[ApplyCardObservation]
Observation --> Apply
Apply --> Card[保存卡状态]
Apply --> Events[记录领域事件和 Outbox]
Events --> Package[套餐激活与流量扣减]
Events --> StopResume[停复机评估]
Events --> ActivityMark[更新活跃标记]
Request --> Integration[统一 Integration Log]
ACL --> Integration
Events --> Audit[关键业务 Audit Event]
```
各入口边界:
| 入口 | 是否调用 Gateway | 执行方式 | 作用 |
|---|---:|---|---|
| 轮询 | 是 | 异步、持续重排 | 最终一致性兜底 |
| 运营商回调 | 否 | 同步应用状态,联动异步可靠投递 | 实名成功急速通道 |
| 业务事件触发 | 是 | 异步 `0/3/5` 三次 Best Effort | 提高正在操作资产的命中概率 |
| 现有手动刷新 | 是 | 保持现有响应契约 | 用户明确要求立即刷新 |
手动刷新不是第四种自动策略,也不能被业务埋点调用。
## 四、DDD 设计
### 4.1 目录
```text
internal/
├── domain/cardstate/
│ ├── card.go 卡状态聚合及状态转换
│ ├── observation.go 实名、流量、网络状态观测值
│ ├── events.go 状态变化领域事件
│ └── repository.go 聚合仓储接口
├── application/cardsync/
│ ├── request_observation.go 查询 Gateway 并获取标准观测
│ ├── apply_observation.go 在事务内应用观测和领域事件
│ ├── create_trigger_series.go 创建事件触发序列
│ ├── execute_trigger_attempt.go 执行单次阶梯尝试
│ ├── mark_activity.go 更新活跃标记
│ └── refresh_asset.go 承接现有显式刷新接口
├── infrastructure/adapter/gateway/
│ └── card_observation_adapter.go 复用现有 Gateway Client
├── infrastructure/adapter/carrier_callback/
│ ├── translator.go 防腐层统一接口
│ ├── cmcc.go
│ ├── cucc.go
│ └── ctcc.go
├── infrastructure/messaging/cardsync/
│ ├── trigger_publisher.go Outbox/Asynq 任务发布
│ └── trigger_handler.go 阶梯任务处理器
└── query/cardsync/
└── activity_query.go 活跃状态和下次轮询查询
```
### 4.2 实名要求判断
不得继续使用以下判断:
```go
if card.CardCategory == constants.CardCategoryIndustry {
// 跳过实名
}
```
现有 `tb_carrier.realname_link_type` 可以直接决定该运营商接入是否需要实名,不新增重复能力字段:
| `realname_link_type` | 实名要求 | 实名入口 |
|---|---|---|
| `none` | 不需要实名 | 不提供实名链接,不创建实名查询任务 |
| `template` | 需要实名 | 使用模板生成实名链接 |
| `gateway` | 需要实名 | 调用 Gateway 获取实名链接 |
- 资产 `realname_policy` 只决定“先实名后购买”或“先购买后实名”的业务顺序。
- `realname_link_type=none` 时,资产有效实名策略统一视为 `none`
- `realname_link_type!=none` 时,资产 `realname_policy=none` 属于冲突数据,发布前列出并修正,禁止运行时静默选择其中一方。
- `card_category` 只保留业务分类和展示用途,不参与实名轮询、复机或套餐激活判断。
运营商是否配置回调 Adapter 只影响有没有回调急速通道,不改变 `realname_link_type` 对实名要求的判断。
### 4.3 标准化观测值
```go
type SyncSource string
const (
SyncSourcePolling SyncSource = "polling"
SyncSourceBusinessEvent SyncSource = "business_event"
SyncSourceOpenAPI SyncSource = "open_api"
SyncSourceManualRefresh SyncSource = "manual_refresh"
SyncSourceCarrierCallback SyncSource = "carrier_callback"
)
type ObservationMeta struct {
Source SyncSource
TriggerScene string
TriggerSeries string
Attempt int
Provider string
ObservedAt time.Time
RequestID string
CorrelationID string
}
type RealnameObservation struct {
CardID uint
ICCID string
Verified bool
Meta ObservationMeta
}
type TrafficObservation struct {
CardID uint
GatewayReadingMB float64
Meta ObservationMeta
}
type NetworkObservation struct {
CardID uint
CardStatus string
Extend string
GatewayIMEI string
Meta ObservationMeta
}
```
领域层不使用 `map[string]any` 传递核心状态,防止不同入口遗漏字段或混淆单位。
### 4.4 观测应用规则
`ApplyCardObservation` 是唯一允许把上游数据写入卡领域状态的入口:
1. 加载并锁定卡聚合。
2. 根据观测类型执行实名、流量或网络状态规则。
3. 状态未变化时只更新时间和观测来源,不重复产生业务事件。
4. 状态变化时保存聚合,并在同一事务写 Outbox 和关键 Audit Event。
5. 实名 `0 -> 1` 产生 `CardRealnamed`,触发卡级/设备级套餐激活和停复机评估。
6. 流量正增量产生 `CardTrafficIncreased`,由套餐应用服务扣减套餐流量。
7. 网络状态变化产生 `CardNetworkStatusChanged`,由停复机策略重新评估。
8. 状态变化或流量增加时更新卡活跃标记。
解除实名回调不构造 `Verified=false` 观测,不修改卡状态。
## 五、轮询优化:只做活跃度调频
### 5.1 全局开关
`enable_polling` 是资产是否参与自动轮询的唯一业务开关:
- `false`:从所有轮询队列移除,事件触发也不因此失效;用户主动操作仍可触发 Best Effort 同步。
- `true`:按照活跃度和运营商能力进入对应 Gateway 查询队列。
- 设备关闭轮询时,继续沿用现有设备与绑定卡级联规则。
不再提供“实名轮询开关、流量轮询开关、状态轮询开关”给业务人员配置。
### 5.2 活跃标记
卡增加或维护以下轮询调度字段:
```text
polling_activity_level active / inactive
last_polling_activity_at 最后活跃时间
last_polling_activity_scene 最后活跃场景
last_upstream_change_at 上游观测最后发生变化时间
```
以下情况标记为活跃:
| 场景 | 说明 |
|---|---|
| C 端、后台或开放接口查询资产实时信息 | 表明当前有人关心该资产 |
| 获取实名链接 | 表明即将发生实名操作 |
| 停机、复机、切卡、重启、恢复出厂设置 | 表明上游状态正在变化 |
| 套餐支付、激活、失效或流量重置 | 可能影响流量和停复机 |
| 运营商回调 | 上游已发生变化 |
| 轮询发现实名、流量或网络状态变化 | 卡当前确实活跃 |
卡在配置时间内没有业务活动,且连续轮询未发现上游变化时转为 `inactive`。该判断在每次重排队时完成,不新增全表扫描任务。
第一版建议配置而非写死:
```text
inactive_after = 30m
active_realname_interval = 现有实名默认间隔
active_traffic_interval = 现有流量默认间隔
active_network_interval = 现有状态默认间隔
inactive_realname_interval = 15m
inactive_traffic_interval = 15m
inactive_network_interval = 15m
```
现有 `tb_polling_config` 的有效间隔迁移为活跃卡默认值,但不再按 `card_condition/card_category` 匹配多套业务资格。第一版调整为一套全局活跃/不活跃间隔;运营商配置只描述接口能力和上游限频,不再决定某张卡是否“有资格”轮询。上线前使用生产数据做只读测算,确认 Gateway QPS 和最长兜底延迟后再调整数值。
### 5.3 调度规则
```text
enable_polling=false
-> 不入自动轮询队列
enable_polling=true + active
-> 使用全局活跃间隔
enable_polling=true + inactive
-> 使用全局不活跃间隔
realname_link_type=none
-> 不创建实名查询任务
```
已实名卡仍可低频查询实名状态,以发现上游实名逆转;未实名行业卡也不能因卡类别被排除。
## 六、业务事件触发
### 6.1 不是新增接口
本需求不新增 `POST /api/admin/card-sync/requests`,也不新增独立“事件同步”按钮。
现有接口保持:
```http
POST /api/admin/assets/:identifier/refresh
POST /api/c/v1/asset/refresh
```
现有批量轮询运维接口可以保留,但必须改为调用新的 Application 用例,不再自行维护另一套同步逻辑。
业务事件触发由 Application UseCase 或 Query 完成后调用内部端口:
```go
type SyncTriggerCommand struct {
Scene string
ResourceType string
ResourceID uint
CardIDs []uint
SyncTypes []string
ExpectedState map[string]any
Source SyncSource
RequestID string
CorrelationID string
}
```
写操作在业务成功后触发;有数据库事务的关键写操作通过 Outbox 发布触发事件,避免提交成功后进程退出造成埋点丢失。读操作只查询本地快照,不等待 Gateway在返回前 Best Effort 写入 Asynq入队失败不得改变原接口响应。
### 6.2 阶梯式尝试
每个触发序列默认创建三个 Asynq 任务:
| 尝试 | 计划时间 | Asynq 自动重试 |
|---:|---:|---:|
| 1 | 立即 | 0 |
| 2 | 事件发生后 3 分钟 | 0 |
| 3 | 事件发生后 5 分钟 | 0 |
每次任务都有确定性的 `series_id + attempt` 幂等键。单次失败不会额外重试,也不会取消后续两次;三次结束后由常规轮询兜底。
存在明确预期状态时允许提前完成序列:
- 复机后已观测为开机。
- 停机后已观测为停机。
- 获取实名链接后已通过回调或查询确认实名。
- 切卡后设备当前卡已经是目标 ICCID。
序列提前完成后,剩余任务启动时检查状态并直接记为 `completed`,不再请求 Gateway。
### 6.3 关键埋点
埋点放在应用用例成功边界,不散落在 Handler 的 `response.Success` 前后。现有 Handler 直接调用 Gateway 的路径在迁移时收口到 Application。
| 现有入口/用例 | 触发内容 | 预期状态 |
|---|---|---|
| `GET /api/c/v1/asset/info` | 资产绑定卡的实名、流量、网络状态 | 无 |
| `GET /api/admin/assets/:identifier/realtime-status` | 对应卡或设备绑定卡的实时数据 | 无 |
| `GET /api/open/v1/cards/traffic` | 流量 | 无 |
| `GET /api/open/v1/cards/status` | 网络状态 | 无 |
| `GET /api/open/v1/cards/realname-status` | 实名 | 无 |
| `GET /api/open/v1/devices/traffic` | 设备信息、绑定卡流量 | 无 |
| C 端和后台获取实名链接 | 实名 | 已实名 |
| 后台、C 端、开放接口停机/复机成功 | 网络状态 | 停机或开机 |
| 自动停复机 Gateway 调用成功 | 网络状态 | 停机或开机 |
| 订单支付、钱包购买套餐、套餐激活成功 | 实名、流量、网络状态 | 无 |
| 设备切卡或切换模式成功 | 设备信息、源卡和目标卡网络状态、目标卡流量 | 目标 ICCID |
| 设备重启、恢复出厂设置、WiFi 设置成功 | 设备信息、绑定卡网络状态 | 无 |
| 卡绑定/解绑、设备或卡分配/回收 | 只标记活跃;确有上游操作时再创建同步序列 | 无 |
补充规则:
- `POST /api/admin/assets/:identifier/refresh``POST /api/c/v1/asset/refresh` 已经直接同步,不再额外创建 `0/3/5` 序列。
- 运营商实名回调直接应用观测,不再反查 Gateway回调成功后终止相同卡的待执行实名序列。
- 轮询发现状态变化只更新活跃标记,不反向创建新的事件序列。
- 同一次设备操作涉及多张绑定卡时使用同一 `correlation_id`,但每张卡独立限流和记录结果。
### 6.4 冷却、合并与限流
不使用一个固定五分钟 `SET NX` 键拦截所有事件。请求协调器只处理同场景合并、单卡互斥和最小请求间隔Gateway 返回超频后不建立额外退避状态。
#### 同场景触发合并
```text
cardsync:series:{scene}:{resource_type}:{resource_id}:{sync_type}
```
- 同一场景、同一资源、同一同步类型已有未结束序列时,新触发合并到原序列。
- 合并只防止页面轮询、开放接口重试等重复创建大量 `0/3/5` 任务。
- 停复机、支付、切卡等不同业务场景不会被一个查询场景长期压制。
- 序列键只覆盖最后一次计划任务和短暂缓冲,不作为全局五分钟冷却。
#### 单卡请求互斥
```text
cardsync:inflight:{provider}:{sync_type}:{card_id}
```
- 只覆盖一次 Gateway 请求的执行时间TTL 为请求超时加安全余量。
- 防止轮询、手动刷新和事件任务同时请求并重复应用同一观测。
- 轮询命中互斥时延迟短时间重排;事件尝试命中互斥时只跳过当前尝试,后续阶梯任务仍存在。
#### 运营商最小请求间隔
最小间隔按运营商接入和接口类型配置,例如实名、流量、状态可以不同,不能统一写死为五分钟。
- 默认兜底值建议为 10 秒,仅用于阻止近乎同时的重复调用。
- 如果上游明确给出更严格限制,以运营商配置为准。
- 高价值状态变更事件命中最小间隔时,延迟到最近允许时间,不直接丢弃整个序列。
#### 超频结果处理
- Gateway 返回超频时,当前尝试记录为 `rate_limited` 后直接结束。
- 不记录 `blocked_until`,不读取 `Retry-After`,也不执行 `30s/60s/120s` 退避。
- 不为当前尝试额外补发任务,原定 3 分钟、5 分钟尝试保持不变。
- 后续轮询仍按正常调度时间继续,是否再次超频由当时上游实际情况决定。
- 轮询、事件和手动刷新仍共享本系统的并发控制,避免本系统自身在同一时刻并发打满 Gateway。
因此“5 分钟”是第三次尝试的计划时间,不是禁止新业务事件同步的冷却时间。
### 6.5 开放接口行为
开放接口继续返回本地快照,不等待 Gateway
```mermaid
sequenceDiagram
participant Agent as 代理系统
participant API as Open API
participant DB as PostgreSQL
participant Trigger as CreateSyncTriggerSeries
participant Worker as Sync Attempt Worker
Agent->>API: 查询实名/状态/流量
API->>DB: 查询当前本地快照
DB-->>API: 当前数据
API-->>Agent: 按现有结构立即返回
API->>Trigger: Best Effort 创建 0/3/5 序列
Trigger-->>Worker: 同场景重复请求自动合并
```
## 七、运营商实名回调防腐层
### 7.1 防腐层职责
“百分百信任回调”表示信任其业务结论,不表示让运营商报文直接进入领域模型。
```go
type CarrierRealnameCallbackTranslator interface {
TranslateSuccess(body []byte) (CarrierRealnameNotice, error)
TranslateRemoval(body []byte) (CarrierRealnameRemovalNotice, error)
SuccessResponse() CallbackResponse
FailureResponse(err error) CallbackResponse
}
```
各运营商 Adapter 负责:
- 解析 JSON、XML 或表单字段。
- 提取并校验 19/20 位 ICCID、MSISDN 等标识。
- 把运营商状态码翻译为“实名成功”或“解除实名通知”。
- 屏蔽运营商字段名、状态码和成功响应格式。
- 生成脱敏 Integration Log 摘要。
领域层只接收标准 `RealnameObservation`,不依赖移动、联通、电信的报文结构。
#### ICCID 19/20 位解析
回调必须复用现有双列存储和按长度路由规则:
```text
收到 19 位 ICCID
-> 原值查询 tb_iot_card.iccid_19
收到 20 位 ICCID
-> 原值查询 tb_iot_card.iccid_20
收到其他长度
-> invalid_payload不进入实名用例
```
- 先去除首尾空白,再调用现有 ICCID Validator只接受 19 位或 20 位字母数字值。
- 禁止把 20 位回调值截成 19 位后降级查询。
- 禁止为 19 位值自行补第 20 位校验位。
- 查询 Miss 时不切换另一列重试,记录原始长度、运营商和脱敏 ICCID 摘要。
- 复用 `IotCardStore.GetByICCID` 的长度路由语义;防腐层只负责提取和校验,不自己拼接模糊 SQL。
- `iccid_19` 必须在未删除卡中保持唯一。发布前检查重复前缀并建立 Partial Unique Index避免 19 位回调错误命中任意一张卡。
- Repository 查询若发现多条匹配必须返回冲突,不允许使用 `First` 静默选择。
- Integration Log 的 `resource_key` 保存回调原始 19/20 位值,展示时按审计权限脱敏。
### 7.2 路由
```http
POST /api/callback/carriers/cmcc/realname
POST /api/callback/carriers/cucc/realname
POST /api/callback/carriers/ctcc/realname
POST /api/callback/carriers/cmcc/realname/remove
POST /api/callback/carriers/cucc/realname/remove
POST /api/callback/carriers/ctcc/realname/remove
```
- 成功回调:防腐层转换后直接调用 `ApplyCardObservation(Verified=true)`
- 解除回调:只写统一 Integration Log状态记为 `ignored`,不修改卡状态。
- 广电或其他没有回调能力的接入继续依赖事件和轮询。
- 不验证回调是否真的来自运营商,不执行 Gateway 二次查询。
- 不调用旧平台 `inner_callback`、第三方推送或删除实名接口。
### 7.3 响应原则
- 找到卡并应用成功:返回运营商要求的成功报文。
- 重复成功回调:幂等返回成功,不重复激活套餐。
- 找不到卡Integration Log 记 `not_found`,仍返回成功,避免无意义高频重推。
- 报文无法解析:记录 `invalid_payload`;若运营商有固定成功应答要求,仍按接入约定返回,避免不可控重试风暴。
- 状态已落库但异步联动失败:返回成功,联动通过 Outbox 重试。
## 八、统一审计契约
同步运行数据不在本方案定义独立表和独立页面,统一由 `04-全局多视角审计方案.md` 管理。
本模块需要向审计系统提供:
| 记录 | 进入位置 |
|---|---|
| 每次 Gateway 实际请求或被限流、合并的尝试 | Integration Log |
| 每次运营商实名/解除实名回调 | Integration Log |
| 实名、流量、网络状态发生业务变化 | Audit Event + Integration Log |
| 手动刷新、手动实名修改 | Audit Event + Integration Log |
| 连续失败达到阈值、超频持续异常 | 风险 Audit Event |
| 普通高频轮询成功且数据未变化 | 仅 Integration Log |
必须携带:
```text
provider / operation / resource / trigger_scene / trigger_series
attempt / result / duration / request_id / correlation_id
state_changed / provider_code / error_summary
```
资产详情的“查看同步轨迹”跳转审计中心外部集成视角,不再建设 `/operations/card-sync` 独立监控页。
## 九、接口和前端调整
### 9.1 后端接口
本需求不新增显式同步接口。
现有手动刷新接口内部改为调用 `RefreshAssetUseCase`,响应结构和权限保持不变。现有轮询监控接口增加:
- 活跃卡数量、不活跃卡数量。
- 各活跃级别的实际轮询 QPS。
- 因互斥或运营商最小间隔而延迟的任务数,以及 Gateway 超频失败次数。
资产实时状态或详情 Query 增加可选调度信息:
```json
{
"polling": {
"enabled": true,
"activity_level": "active",
"last_activity_at": "2026-07-15T10:00:00+08:00",
"last_activity_scene": "client_asset_info",
"next_poll_at": "2026-07-15T10:01:00+08:00"
}
}
```
同步明细查询复用全局审计接口:
```http
GET /api/admin/audit/integrations
?provider=gateway
&resource_type=iot_card
&resource_key=89860...
&trigger_scene=
&trigger_series=
```
### 9.2 前端
保留现有资产刷新按钮,不新增第二个同步按钮。
资产详情增加紧凑的轮询状态展示:
```text
自动轮询:已开启
活跃状态:活跃
最后活跃2分钟前C端资产详情
下次兜底约1分钟后
同步轨迹:查看
```
“查看”跳转:
```text
/operations/audit?tab=integrations&resource_type=iot_card&resource_key={identifier}
```
轮询监控页只增加活跃/不活跃分布和限流状态,不重复实现 Integration Log 表格、详情抽屉和导出。
## 十、代码迁移范围
### 10.1 必须删除或收口
- 删除 `PollingRealnameHandler``CardCategoryIndustry` 跳过实名的判断。
- 修正 `pkg/constants/iot.go` 中“普通卡必需实名、行业卡无需实名”的绝对化注释和依赖逻辑。
- `PollingRealnameHandler``PollingCarddataHandler``PollingCardStatusHandler` 只调用 Application 用例,不再直接更新卡或执行业务联动。
- 删除重复流量增量算法,只保留领域实现。
- `RefreshCardDataFromGateway` 改为调用 `RequestCardObservation + ApplyCardObservation`
- `ManualUpdateRealnameStatus` 改为调用领域用例。
- 获取实名链接后的 `ManualTriggerService.TriggerSingle` 改为 `CreateSyncTriggerSeries`
- C 端和后台设备操作中直接调用 Gateway 的路径迁入 Application再在用例成功边界创建触发序列。
### 10.2 保留并复用
- Gateway Client 的加密、签名和 HTTP 封装。
- Redis 分片 Sorted Set 调度基础设施。
- Asynq Worker。
- 卡流量同步锁,迁移为通用请求互斥实现。
- 现有资产手动刷新接口和权限契约。
- 现有批量轮询运维能力;其执行逻辑和记录迁入新用例与统一审计后,再下线旧专用日志表。
### 10.3 数据变更
- 不新增实名要求字段,继续以 `tb_carrier.realname_link_type` 判断是否需要实名及链接生成方式。
- 发布前检查 `iccid_19` 重复数据并建立未删除数据范围内的唯一索引,保证 19 位回调精确命中。
- 卡增加轮询活跃标记字段,或由独立调度状态表保存;实现阶段根据写入频率决定,不能把高频调度心跳写入核心卡表。
- 现有 `tb_polling_config` 条件匹配迁移为单一全局活跃/不活跃策略,不再使用 `card_condition/card_category` 形成隐式轮询资格。
- 事件任务载荷增加 `scene/series_id/attempt/source/request_id/correlation_id/expected_state`
- 不创建 `tb_card_sync_execution`
## 十一、发布与人工验证
停机发布API 与 Worker 同时切换:
1. 验证 `realname_link_type=none/template/gateway` 分别对应无需实名、模板实名和 Gateway 实名,不再按行业卡统一跳过。
2. 验证 `enable_polling=false` 会移除自动轮询,但业务事件和手动刷新仍能按各自规则工作。
3. 验证活跃卡使用活跃间隔,不活跃卡使用低频间隔,重新活跃后立即恢复。
4. 验证一次业务事件形成立即、3 分钟、5 分钟三个任务。
5. 验证停复机、实名和切卡达到预期状态后,剩余任务不再请求 Gateway。
6. 验证同场景高频查询只合并重复序列,不压制新的停复机或支付事件。
7. 验证单卡互斥只覆盖请求执行时间,不形成五分钟全局冷却。
8. 验证 Gateway 超频时当前尝试直接记为 `rate_limited`,不创建退避状态,后续阶梯任务仍保留。
9. 验证现有后台和 C 端刷新接口契约不变,且不额外创建阶梯序列。
10. 验证移动、联通、电信回调均先经过防腐层19 位和 20 位 ICCID 分别按双列精确路由,重复成功回调不重复激活套餐。
11. 验证解除实名回调只写 Integration Log不修改卡实名状态。
12. 验证开放接口仍立即返回本地数据,事件触发失败不改变响应。
13. 验证普通同步、状态变化、手动刷新和连续失败能够在审计中心按资源和触发序列查询。

View File

@@ -0,0 +1,878 @@
# 新增需求 02企业微信审批接入
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 范围:退款审批、平台员工线下充值审批,以及企业微信模板配置、回调和轮询补偿。
## 一、已确认决策
1. 不建设通用审批流引擎,审批节点、审批人、会签/或签和流程配置全部由企业微信审批模板负责。
2. 本系统只建设企业微信审批接入、状态镜像和审批终态后的业务处理。
3. 退款金额在申请时固定,企微只决定通过或驳回;不允许审批人在终态修改退款金额。
4. 线下充值不再要求操作密码,企微审批通过后自动入账。
5. 企微审批通过后又撤销,不自动冲正已经完成的退款或充值,只记录高等级异常、发送通知并人工处理。
6. 回调是主通道,每 2 分钟查询审批详情作为待审批单兜底。
7. 回调与轮询必须进入同一个状态同步用例,业务终态处理只能执行一次。
8. 企微模板 ID 和控件 ID 都可能因管理员编辑模板而变化,必须使用稳定业务场景码、不可变模板版本和控件映射快照。
9. 本次触碰的退款、线下充值审批逻辑迁移到 Domain/Application旧业务单级通过、驳回、退回和确认入账接口下线不保留两套审批入口。
10. 系统无法从旧模板 ID 自动发现编辑后生成的新模板 ID模板变更必须先暂停业务场景再发布新映射并原子切换避免继续向旧模板提交。
11. 退款仍为财务人工退款,本系统不调用微信、支付宝等支付渠道退款接口;只有代理钱包支付订单自动回溯原扣款代理主钱包,不向个人客户或资产钱包自动回款。
12. 系统账号与企微成员使用 Web 登录二维码自助绑定,普通运营不手工查找或录入 `userid`;管理端只查看状态和强制解绑。
## 二、系统边界
企业微信负责:
- 审批模板编辑。
- 审批节点和审批人配置。
- 审批中的通过、驳回、撤销和删除。
- 审批意见和审批附件展示。
- 企业微信端待办提醒。
本系统负责:
- 退款和线下充值业务单创建。
- 把本地业务快照和附件提交到企微。
- 保存企微审批单号和状态镜像。
- 接收、解密企微回调并查询审批详情。
- 审批通过后记录人工退款终态、按规则回溯代理主钱包,或执行线下充值入账。
- 审批驳回、撤销、删除后的本地业务状态更新。
- 站内结果通知、异常告警和审计。
本系统不再保存审批节点、审批任务、审批人候选集合或本地审批动作。
## 三、总体流程
```mermaid
sequenceDiagram
actor User as 平台员工
participant API as Refund/Recharge Application
participant DB as PostgreSQL
participant Outbox as Outbox
participant Worker as WeCom Submit Worker
participant WeCom as 企业微信审批
participant Callback as 企微回调
participant Sync as SyncApprovalStatus
participant Biz as 业务终态用例
User->>API: 创建退款/线下充值申请
API->>DB: 保存业务单和企微审批实例(submitting)
API->>Outbox: 同事务写 SubmissionRequested
API-->>User: 返回业务单和提交中状态
Outbox->>Worker: 投递提交任务
Worker->>WeCom: 上传附件、applyevent
WeCom-->>Worker: sp_no
Worker->>DB: 保存 sp_no状态改为 pending
WeCom->>Callback: 加密审批事件
Callback->>Callback: 验签、解密、保存事件
Callback->>Sync: 提交状态同步
Sync->>WeCom: getapprovaldetail
WeCom-->>Sync: 审批详情
Sync->>DB: 幂等更新审批状态和详情快照
Sync->>Outbox: 首次进入终态时写业务处理事件
Outbox->>Biz: 可靠执行退款/充值终态用例
```
## 四、基础配置
企微基础连接配置使用现有 Viper 统一配置体系,不通过通用 Key-Value 页面展示明文密钥:
```yaml
wecom:
corp_id: ""
agent_id: 0
agent_secret: ""
callback_token: ""
callback_encoding_aes_key: ""
callback_path: "/api/callback/wecom/approval"
account_binding_redirect_url: "https://后台域名/api/callback/wecom/account-binding"
approval_poll_interval: 2m
template_verify_interval: 10m
request_timeout: 10s
```
环境变量覆盖敏感项:
```text
JUNHONG_WECOM_CORP_ID
JUNHONG_WECOM_AGENT_ID
JUNHONG_WECOM_AGENT_SECRET
JUNHONG_WECOM_CALLBACK_TOKEN
JUNHONG_WECOM_CALLBACK_ENCODING_AES_KEY
JUNHONG_WECOM_ACCOUNT_BINDING_REDIRECT_URL
```
后台只能查询“是否已配置、最近连通时间、最近错误”,不能返回 Secret、Token 或 EncodingAESKey。
企微自建应用还必须配置:
- Web 登录回调可信域名与 `account_binding_redirect_url` 域名一致。
- 应用可见范围覆盖需要发起退款或线下充值审批的平台员工,否则扫码时企微会提示无权限。
- `CorpID``AgentID`、用于换取身份的 Access Token 必须属于同一个自建应用配置。
用户提供的 demo 中已经出现完整密钥正式接入前必须在企业微信后台轮换并禁止将新值写入代码、Markdown、日志或审计快照。
## 五、模板映射与版本
### 5.1 为什么不能只配置 template_id
企业微信模板编辑后可能生成新的模板 ID删除并重新增加控件时控件 ID 也会变化。业务代码如果写死:
```go
templateID = "..."
amountControlID = "Text-..."
```
模板一旦编辑,新审批立即提交失败,历史审批也无法说明当时使用了哪套字段。
因此分为三层:
```text
稳定业务场景 scene_code
↓ 当前启用
不可变模板版本 template_version
↓ 包含
template_id + control_mapping + template_snapshot
```
### 5.2 稳定业务场景
第一版固定两个场景:
| scene_code | 中文名称 | 业务类型 |
|---|---|---|
| `refund_approval` | 退款审批 | refund |
| `offline_recharge_approval` | 线下充值审批 | agent_recharge |
业务代码只引用 `scene_code`,不直接引用企微模板 ID。
### 5.3 场景运行状态
```sql
CREATE TABLE tb_wecom_approval_scene (
scene_code VARCHAR(64) PRIMARY KEY,
scene_name VARCHAR(255) NOT NULL,
status INT NOT NULL DEFAULT 0,
current_template_version_id BIGINT,
paused_reason VARCHAR(255) NOT NULL DEFAULT '',
version BIGINT NOT NULL DEFAULT 0,
updated_by BIGINT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
`status``0=已暂停, 1=启用, 2=暂停中`。不建立数据库外键,`current_template_version_id` 由领域规则保证引用有效版本。
企微不会根据旧模板 ID 告诉本系统“这个模板已经产生了一个新 ID”。如果旧 ID 仍可访问,仅轮询 `gettemplatedetail` 也无法发现新模板。因此模板编辑采用明确的维护流程:
```text
将 scene_code 改为暂停中,立即阻止新申请和新提交租约
→ 等待已经取得提交租约的 Worker 完成或释放
→ 场景进入已暂停
→ 在企微编辑模板并取得新 template_id
→ 本系统读取新模板并完成控件映射
→ 发布不可变模板版本
→ 同一事务切换 current_template_version_id 并恢复场景
```
场景暂停后,退款或线下充值创建接口在写业务单前直接拒绝新申请,并返回“审批模板维护中”;已取得 `sp_no` 的历史审批继续接收回调和轮询,不受影响。
提交 Worker 调用企微前通过条件更新取得短期 `submit_lease_until`。暂停操作先把场景改为 `暂停中`,此后不再发放新租约;租约全部释放或到期后才显示“已暂停”。这样运营人员看到“已暂停”时,可以确认没有旧模板提交正在进行。
租约覆盖附件上传和 `applyevent` 调用并由 Worker 定时续期;处理结束时使用 `defer` 释放,提交结果未知也必须先持久化状态再释放。暂停流程只认可未过期租约已经全部消失,不能仅按任务进程是否存活判断。
### 5.4 模板版本表
```sql
CREATE TABLE tb_wecom_approval_template_version (
id BIGSERIAL PRIMARY KEY,
scene_code VARCHAR(64) NOT NULL,
version_no INT NOT NULL,
template_id VARCHAR(128) NOT NULL,
template_name VARCHAR(255) NOT NULL DEFAULT '',
status INT NOT NULL DEFAULT 1,
control_mapping JSONB NOT NULL,
template_snapshot JSONB NOT NULL,
template_fingerprint VARCHAR(64) NOT NULL,
last_verified_at TIMESTAMPTZ,
last_verify_error TEXT,
published_by BIGINT NOT NULL,
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
disabled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (scene_code, version_no)
);
CREATE UNIQUE INDEX uq_wecom_template_active_scene
ON tb_wecom_approval_template_version (scene_code)
WHERE status = 1;
```
`status``0=已停用, 1=启用, 2=失效`
发布新版本时,在一个事务内停用旧版本并创建新版本。旧审批实例继续引用旧版本记录,不更新历史快照。
### 5.5 控件映射
稳定业务字段映射到当前模板控件:
```json
{
"shop_name": {
"control": "Text",
"control_id": "Text-xxx",
"required": true
},
"business_no": {
"control": "Text",
"control_id": "Text-yyy",
"required": true
},
"amount": {
"control": "Money",
"control_id": "Money-zzz",
"required": true
},
"attachment": {
"control": "File",
"control_id": "File-aaa",
"required": true
}
}
```
退款场景必填业务字段:
```text
shop_name, refund_no, order_no, asset_identifier,
requested_refund_amount, refund_reason, attachment, submitter
```
线下充值场景必填业务字段:
```text
shop_name, recharge_no, amount, remark, attachment, submitter
```
模板发布时必须调用 `gettemplatedetail` 校验:
- `template_id` 可访问。
- 映射的每个控件 ID 确实存在。
- 控件类型与业务字段要求一致。
- 必填业务字段全部完成映射。
- 同一个控件不能绑定两个业务字段。
### 5.6 模板失效检测
后台任务每 10 分钟验证所有启用模板:
1. 调用 `gettemplatedetail`
2. 使用控件 ID、类型和名称生成 SHA-256 fingerprint。
3. 模板不可访问或 fingerprint 变化时,将版本标记为 `status=2`
4. 模板失效时同时暂停对应场景,禁止创建和提交新审批,但不影响已提交审批的回调和状态查询。
5. 发送站内系统告警,提示管理员发布新的模板映射版本。
该检测只能发现“当前模板 ID 已失效或内容发生变化”,不能自动发现企微生成的新模板 ID所以不能替代上述暂停和发布流程。
提交审批前,如果 `last_verified_at` 超过验证间隔Worker 先同步验证一次。验证失败不调用 `applyevent`
## 六、内部账号与企微成员绑定
创建人必须有明确的企微 `userid`,不使用固定手机号冒充所有申请人,也不要求运营人员进入企微后台查找成员 ID。
### 6.1 绑定流程
```mermaid
sequenceDiagram
actor User as 已登录平台员工
participant FE as 管理后台
participant API as WeComIdentity Application
participant Redis as Redis绑定会话
participant Login as 企业微信Web登录
participant WeCom as 企业微信API
participant DB as PostgreSQL
User->>FE: 点击绑定企业微信
FE->>API: 创建绑定会话
API->>Redis: 保存一次性stateTTL 5分钟
API-->>FE: 返回企微登录URL和session_id
FE->>Login: 新窗口打开企微二维码
User->>Login: 使用企业微信扫码确认
Login->>API: 回调code + state
API->>Redis: 原子消费state
API->>WeCom: auth/getuserinfo(code)
WeCom-->>API: userid
API->>DB: 校验唯一性并保存绑定
API-->>FE: postMessage通知绑定结果
```
后端构造官方 Web 登录地址:
```text
https://login.work.weixin.qq.com/wwlogin/sso/login
?login_type=CorpApp
&appid={CorpID}
&agentid={AgentID}
&redirect_uri={URLEncode后的回调地址}
&state={一次性随机值}
```
回调取得的 `code` 只能使用一次且 5 分钟过期。后端使用同一自建应用的 Access Token 调用:
```http
GET https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo
?access_token={access_token}
&code={code}
```
返回 `userid` 才允许绑定;返回 `openid` 表示不是本企业成员,直接拒绝。企微身份接口不返回 `corp_id`,企业归属由登录 URL 中的 `CorpID` 和用于换取身份的自建应用 Access Token 共同限定。
第一版使用官方登录页面新窗口,不引入前端 SDK。回调成功页使用固定后台 Origin 的 `window.opener.postMessage` 通知原页面并关闭窗口;窗口通信失败时,原页面通过 `session_id` 轮询会话状态兜底。
### 6.2 绑定会话
绑定会话只存 Redis不建长期数据库表。`state` 与前端查询会话分开保存,避免回调消费 `state` 后无法查询结果:
```text
key: RedisWecomAccountBindingStateKey(state)
ttl: 5分钟
value:
session_id
key: RedisWecomAccountBindingSessionKey(session_id)
ttl: 10分钟
value:
target_account_id
initiator_account_id
allowed_origin
status
error_code
created_at
```
- 只有已登录且启用的超级管理员、平台用户可以为自己创建绑定会话。
- `state` 使用密码学安全随机数,回调时通过 Lua 原子读取并删除;后续成功或失败结果写入独立的 `session_id` 会话。
- 回调不接受前端传入的 `account_id`,绑定目标只能来自服务端会话。
- 同一账号再次创建会话时,旧会话立即失效。
- `allowed_origin` 从服务端后台域名配置生成,不能接受请求参数覆盖。
- `session_id` 仅用于当前登录用户查询结果,不能作为绑定凭证。
### 6.3 绑定数据
```sql
CREATE TABLE tb_account_wecom_mapping (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL UNIQUE,
wecom_userid VARCHAR(128) NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL DEFAULT '',
corp_id VARCHAR(64) NOT NULL,
agent_id BIGINT NOT NULL,
bind_source VARCHAR(32) NOT NULL DEFAULT 'self_scan',
bound_by BIGINT NOT NULL,
status INT NOT NULL DEFAULT 1,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
- 平台员工创建退款或线下充值时必须存在启用绑定,否则拒绝创建并返回可直接拉起绑定窗口的错误状态。
- `account_id``wecom_userid` 都是一对一唯一;企微成员已绑定其他账号时拒绝覆盖,必须先由超级管理员解除旧绑定。
- 重新绑定同一账号必须再次扫码,成功后在事务内替换旧身份并记录前后值审计。
- 自助解绑不影响历史审批;历史实例继续使用提交时保存的 `creator_wecom_userid` 快照,新审批在重新绑定前禁止创建。
- `display_name` 通过读取成员接口获取;权限不足时允许为空,但不影响以 `userid` 发起审批。
- 企微审批详情中的审批人 `userid` 原样保存为快照;能匹配本地账号时额外返回本地账号 ID。
- 用户离职或映射失效不修改历史审批人快照。
普通运营界面不提供手工录入 `userid`。超级管理员仅能查看、强制解绑并要求员工重新扫码,不提供直接改写成员 ID 的入口。
## 七、审批实例
```sql
CREATE TABLE tb_wecom_approval_instance (
id BIGSERIAL PRIMARY KEY,
biz_type VARCHAR(64) NOT NULL,
biz_id BIGINT NOT NULL,
biz_no VARCHAR(64) NOT NULL DEFAULT '',
round_no INT NOT NULL DEFAULT 1,
scene_code VARCHAR(64) NOT NULL,
template_version_id BIGINT NOT NULL,
template_id_snapshot VARCHAR(128) NOT NULL,
control_mapping_snapshot JSONB NOT NULL,
creator_account_id BIGINT NOT NULL,
creator_wecom_userid VARCHAR(128) NOT NULL,
sp_no VARCHAR(128),
status INT NOT NULL DEFAULT 0,
submitted_snapshot JSONB NOT NULL,
approval_detail_snapshot JSONB,
approver_snapshot JSONB,
apply_error TEXT,
submit_lease_until TIMESTAMPTZ,
callback_at TIMESTAMPTZ,
last_polled_at TIMESTAMPTZ,
status_changed_at TIMESTAMPTZ,
business_processed_at TIMESTAMPTZ,
business_process_result VARCHAR(20),
business_process_error TEXT,
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (biz_type, biz_id, round_no)
);
CREATE UNIQUE INDEX uq_wecom_approval_sp_no
ON tb_wecom_approval_instance (sp_no)
WHERE sp_no IS NOT NULL;
```
内部状态:
| status | 含义 | 对应企微状态 |
|---:|---|---|
| 0 | 提交中 | 尚未取得 sp_no |
| 1 | 审批中 | `sp_status=1` |
| 2 | 已通过 | `sp_status=2` |
| 3 | 已驳回 | `sp_status=3` |
| 4 | 已撤销 | `sp_status=4` |
| 5 | 通过后撤销 | `sp_status=6` |
| 6 | 已删除 | `sp_status=7` |
| 7 | 提交失败 | 明确未创建审批 |
| 8 | 提交结果未知 | 请求超时,无法确认是否创建 |
`submitted_snapshot` 保存提交时的业务展示数据和本地附件 Key不保存临时 `media_id`、签名 URL 或敏感密钥。
## 八、DDD 设计
```text
internal/
├── domain/wecomapproval/
│ ├── approval.go 审批状态机和终态幂等规则
│ ├── scene.go 场景暂停、启用和版本切换不变量
│ ├── template.go 不可变模板版本和控件映射
│ ├── events.go 审批通过/驳回/异常事件
│ └── repository.go
├── application/wecomapproval/
│ ├── change_scene_status.go
│ ├── publish_template_version.go
│ ├── submit_approval.go
│ ├── sync_approval_status.go
│ ├── handle_callback.go
│ ├── poll_pending.go
│ └── resubmit_approval.go
├── domain/wecomidentity/
│ ├── binding.go 账号与企微成员一对一绑定规则
│ └── repository.go
├── application/wecomidentity/
│ ├── create_binding_session.go
│ ├── complete_binding.go
│ ├── get_my_binding.go
│ └── unbind_account.go
├── domain/refund/
│ ├── refund.go 退款金额和状态不变量
│ └── events.go
├── application/refund/
│ ├── create_refund.go
│ ├── complete_wecom_approved.go
│ └── reject_from_wecom.go
├── application/recharge/
│ ├── create_offline_recharge.go
│ ├── complete_wecom_approved.go
│ └── reject_from_wecom.go
└── infrastructure/adapter/wecom/
├── client.go
├── token_provider.go
├── web_login.go
├── identity.go
├── approval.go
├── media.go
└── callback_crypto.go
```
退款和充值的旧 Service 不再作为审批业务入口。列表查询进入 `internal/query/refund``internal/query/recharge``internal/query/wecomapproval`
## 九、审批提交
### 9.1 为什么异步提交
本地业务单与企微远程审批无法放在同一个数据库事务中,因此采用本地事务 + Outbox
```text
业务单创建成功
审批实例 status=0
Outbox: WeComApprovalSubmissionRequested
```
Worker 处理:
1. 仅在场景启用且租约为空或已过期时,通过条件更新取得提交租约。
2. 加载不可变模板版本和提交快照。
3. 确认实例引用的模板版本有效且最近验证成功。
4. 从对象存储下载本地附件到临时文件。
5. 逐个调用 `media/upload` 获取临时 `media_id`
6. 按控件映射构建 `apply_data`
7. 使用模板审批人配置,固定 `use_template_approver=1`
8. 调用 `applyevent`
9. 保存 `sp_no`,状态变为审批中并释放租约。
附件的本地对象存储 Key 是权威记录;企微 `media_id` 只是提交期间使用的临时值。
### 9.2 提交结果未知
`applyevent` 网络超时后可能已经在企微创建审批,因此不能无脑重试,否则可能产生重复审批:
- 收到明确 `errcode != 0`:状态改为提交失败,可修正配置后重新提交。
- 建连失败且确认请求未发送:允许自动重试。
- 请求已发送但响应超时/连接中断:状态改为提交结果未知,不自动再次调用 `applyevent`
- 提交结果未知进入异常页面,由管理员在企微核对后选择“确认未创建并重新提交”或“绑定已有 sp_no”。
## 十、回调和轮询
### 10.1 回调路由
```http
GET /api/callback/wecom/approval
POST /api/callback/wecom/approval
```
- GET 按企微协议完成 URL 校验。
- POST 使用 SHA1 签名校验和 AES-CBC 解密。
- 解密后的 `receiveID` 必须等于配置的 CorpID。
- 回调原始密文、解密结果摘要和处理状态写 Integration Log不写文件系统。
- 回调快速保存事件并返回 `success`,实际状态以 `getapprovaldetail` 为准。
### 10.2 轮询补偿
每 2 分钟查询:
```sql
status = 1
AND (last_polled_at IS NULL OR last_polled_at <= NOW() - INTERVAL '2 minutes')
```
单批限制 100 条,按 `last_polled_at` 排序。回调和轮询都调用 `SyncApprovalStatus`
### 10.3 状态同步幂等
```text
1. 根据 sp_no 加载审批实例
2. 调 getapprovaldetail
3. 保存审批详情和审批人快照
4. 使用 version 乐观锁更新状态
5. 状态没有变化时结束
6. 首次进入终态时在同一事务写对应业务终态 Outbox 事件
7. 业务 Worker 调用对应终态用例,失败按错误类型重试
8. business_processed_at 非空时不得重复执行资金动作
```
## 十一、业务状态处理
### 11.1 退款
创建退款时:
- `requested_refund_amount` 已完成合法性校验并固定。
- `approved_refund_amount` 不再由审批接口输入;企微通过时写为申请金额。
- 退款凭证必须先保存本地对象存储,再上传企微副本。
- 财务在系统外完成人工退款并通过企微审批确认结果;本系统不请求任何支付渠道退款 API。
退款状态在现有枚举基础上追加:
| status | 含义 |
|---:|---|
| 1 | 待审批 |
| 2 | 已通过/人工退款已确认 |
| 3 | 已拒绝 |
| 4 | 已退回,仅保留历史兼容,新企微审批不再产生 |
| 5 | 已撤销/审批已删除 |
不得将历史 `4=已退回` 改写为撤销,避免旧数据语义变化。
企微状态处理:
| 企微状态 | 本地退款动作 |
|---|---|
| 通过 | 记录人工退款完成;代理钱包支付订单回溯原扣款代理主钱包;更新订单状态,随后异步佣金回扣和资产处理 |
| 驳回 | 退款状态改为已拒绝,保存审批详情摘要 |
| 撤销/删除 | 退款状态改为已撤销;允许申请人修改后创建下一轮审批 |
| 通过后撤销 | 已退款则不冲正,记录严重异常并通知财务;尚未执行则阻止退款 |
`POST /api/admin/refunds/{id}/approve``reject``return` 下线。
重新申请使用:
```http
POST /api/admin/refunds/{id}/resubmit
```
它只允许已驳回、已撤销或已删除且业务尚未退款的申请,修改业务资料后创建 `round_no + 1` 的企微审批。
审批通过后的业务 Worker 处理失败时,审批实例保持“已通过”,`business_process_result=failed`,由任务重试;前端明确区分“审批已通过”和“退款终态处理失败”。只有退款终态事务成功后才写 `business_processed_at`
现有 `internal/service/refund/service.go``BuyerTypePersonal -> refundAssetWalletPayment` 与本次确认规则冲突,迁移退款用例时删除该分支及其专用退款回款逻辑。个人客户或资产钱包相关订单只记录人工退款结果,不自动增加资产钱包余额;代理钱包支付订单继续按原扣款流水定位并回溯原代理主钱包。
### 11.2 平台员工线下充值
创建线下充值后立即创建企微审批实例,不再暴露本地“确认入账”和“驳回”按钮。
| 企微状态 | 本地充值动作 |
|---|---|
| 通过 | 无操作密码,自动增加代理主钱包余额并写钱包流水 |
| 驳回 | 状态改为已驳回,保存企微审批摘要 |
| 撤销/删除 | 状态改为已关闭,可重新创建充值申请 |
| 通过后撤销 | 已入账不自动扣回,记录严重异常并通知财务 |
`POST /api/admin/agent-recharges/{id}/offline-pay``reject` 下线。
线上微信/支付宝充值不进入企微审批,保持支付回调流程。
审批通过后的入账任务失败时按相同规则重试;只有钱包余额和流水事务成功后才写 `business_processed_at`。若审批先变为通过后撤销且入账任务尚未成功,后续入账任务检测当前状态后终止,不再加钱。
## 十二、API 设计
### 12.1 基础配置状态
```http
GET /api/admin/wecom/config/status
```
返回是否配置、Token 最近获取时间、回调最近成功时间、最近错误,不返回任何密钥。
### 12.2 审批场景状态
```http
GET /api/admin/wecom/approval-scenes
PUT /api/admin/wecom/approval-scenes/{scene_code}/status
```
```json
{
"status": 0,
"reason": "企微模板编辑中"
}
```
暂停和恢复必须写高等级审计。请求暂停后可能先返回 `status=2`,前端轮询场景列表,只有进入 `status=0` 才允许提示运营人员开始编辑企微模板。已有启用版本的场景只允许在暂停状态发布新版本;首次初始化没有当前版本时可直接发布。
### 12.3 读取企微模板
```http
POST /api/admin/wecom/approval-templates/inspect
```
```json
{
"scene_code": "refund_approval",
"template_id": "企微模板ID"
}
```
返回模板名称和可映射控件列表。
### 12.4 发布模板映射
```http
POST /api/admin/wecom/approval-templates/publish
```
```json
{
"scene_code": "refund_approval",
"template_id": "企微模板ID",
"control_mapping": {
"shop_name": "Text-xxx",
"refund_no": "Text-yyy",
"requested_refund_amount": "Money-zzz",
"attachment": "File-aaa"
}
}
```
后端重新读取模板并校验,不能信任前端提交的控件类型和名称。发布成功后在同一事务内停用旧版本、写入新版本、把尚未取得 `sp_no` 且仍为 `status=0` 的实例改绑到新版本、更新场景当前版本并恢复场景;任一步失败都保持暂停,不允许部分切换。
### 12.5 模板版本列表
```http
GET /api/admin/wecom/approval-templates?scene_code=refund_approval
```
### 12.6 账号绑定
```http
GET /api/admin/wecom/account-binding/me
POST /api/admin/wecom/account-binding/sessions
GET /api/admin/wecom/account-binding/sessions/{session_id}
DELETE /api/admin/wecom/account-binding/me
GET /api/admin/wecom/account-bindings?page=1&page_size=20&binding_status=
DELETE /api/admin/wecom/account-bindings/{account_id}
GET /api/callback/wecom/account-binding?code={code}&state={state}
```
创建会话返回:
```json
{
"session_id": "01J...",
"login_url": "https://login.work.weixin.qq.com/wwlogin/sso/login?...",
"expires_at": "2026-07-15T15:05:00+08:00"
}
```
列表接口不返回 Secret 或完整企微配置。普通平台用户只能查询和解绑自己的绑定;绑定列表和强制解绑仅超级管理员可用,强制解绑必须记录高等级审计。
### 12.7 审批记录
```http
GET /api/admin/wecom/approvals
?biz_type=refund
&status=1
&sp_no=
&biz_no=
&page=1&page_size=20
GET /api/admin/wecom/approvals/{id}
POST /api/admin/wecom/approvals/{id}/sync
POST /api/admin/wecom/approvals/{id}/bind-sp-no
```
`bind-sp-no` 仅用于提交结果未知的人工恢复,必须写高等级审计日志。
### 12.8 业务详情响应
退款和充值详情统一增加:
```json
{
"approval": {
"source": "wecom",
"instance_id": 123,
"sp_no": "202607150001",
"status": 2,
"status_name": "已通过",
"template_name": "退款审批",
"round_no": 1,
"creator_name": "张三",
"approvers": [],
"submitted_at": "2026-07-15T10:00:00+08:00",
"status_changed_at": "2026-07-15T10:05:00+08:00",
"business_process_result": "success"
}
}
```
## 十三、前端方案
### 13.1 审批入口变化
- 删除本系统待审批任务、审批节点配置、审批人配置和审批动作页面。
- 退款和线下充值列表不再显示“通过、驳回、退回、确认入账”按钮。
- 创建成功后展示“正在提交企业微信审批”;取得 `sp_no` 后展示“企业微信审批中”。
- 审批操作全部在企业微信完成。
### 13.2 业务详情
退款和充值详情增加只读“企业微信审批”区块:
- 审批单号。
- 当前状态和状态更新时间。
- 模板名称和模板版本。
- 申请人。
- 审批人、审批结果、意见和时间线。
- 业务处理结果。
- “立即同步”图标按钮,仅触发 `getapprovaldetail`,不提供审批按钮。
通过后撤销且业务已执行时使用红色异常状态条,明确显示“资金动作未自动冲正,需人工处理”。
### 13.3 企微配置页
路由:`/system/wecom`
Tab
1. **连接状态**:只显示配置状态和最近连通结果。
2. **审批模板**:按业务场景展示当前版本、模板 ID、验证状态和历史版本。
3. **账号绑定**:查看平台员工绑定状态、企微名称和最近验证时间;不允许编辑 userid。
4. **异常审批**:提交未知、模板失效、终态业务处理失败、通过后撤销。
模板发布交互:
```text
暂停业务场景并等待状态变为“已暂停”
→ 在企微完成模板编辑并取得新 template_id
→ 选择业务场景
→ 输入新 template_id
→ 点击“读取模板”
→ 左侧显示业务字段,右侧下拉选择企微控件
→ 后端校验
→ 发布新版本并自动恢复场景
```
不允许运营人员直接编辑 `control_mapping` JSON。
场景处于暂停状态时必须在退款和线下充值创建页明确显示维护原因,不能等异步提交后才暴露错误。
账号绑定交互:
- 当前用户个人中心显示“未绑定/已绑定/已失效”、企微成员名称和最近验证时间。
- 点击“绑定企业微信”后打开官方企微二维码窗口,原页面显示 5 分钟倒计时。
- 绑定成功后窗口自动关闭,原页面刷新绑定状态;失败时显示企微返回的可操作原因。
- 创建退款或线下充值时发现未绑定,页面原地展示“绑定企业微信”按钮,绑定成功后继续填写,不要求用户先去配置页。
- 管理员的账号绑定列表只提供筛选、查看和强制解绑;不提供 userid 输入框。
### 13.4 审批运行页
列表路由:`/operations/wecom-approvals`
详情路由:`/operations/wecom-approvals/{id}`,复用同一页面并打开详情抽屉,供业务详情和站内通知稳定跳转。
页面用于运行监控,不提供审批动作:
- 按业务类型、企微状态、提交状态和业务处理结果筛选。
- 查询 `sp_no`、业务单号、申请人和模板版本。
- 查看企微审批详情快照和本地业务处理结果。
- 对审批中记录执行“立即同步”。
- 对提交结果未知记录执行“绑定已有 sp_no”或“确认未创建并重新提交”。
- 对业务处理失败记录查看错误和任务重试状态。
## 十四、Token、日志和限流
- Access Token 缓存在 RedisTTL 使用 `expires_in - 300秒`
- 使用 Redis 锁避免多个进程同时刷新 Token。
- `auth/getuserinfo`、读取成员、`gettemplatedetail``applyevent``getapprovaldetail``media/upload` 分别记录接口名称、耗时、errcode、errmsg 和 request_id。
- 日志禁止记录 access_token、Secret、EncodingAESKey、解密密钥和完整附件内容。
- 绑定、换绑、自助解绑、管理员强制解绑写统一审计日志Access Token 获取和企微身份查询写 Integration Log。
- `applyevent` 不做网络层盲目重试;详情查询允许指数退避重试。
- 审批轮询与回调同步共享企微 API 并发限制。
## 十五、存量数据和发布
停机发布:
1. 在企微自建应用配置 Web 登录可信域名和平台员工可见范围。
2. 创建企微模板版本、账号绑定和审批实例表。
3. 发布企微身份 Adapter、账号绑定回调、审批回调、轮询 Worker 和业务终态用例。
4. 要求会发起审批的平台员工完成扫码绑定。
5. 发布并验证退款、线下充值两个模板映射版本。
6. 下线本地审批动作路由和前端按钮。
7. 存量已通过/已拒绝记录保留原业务审批快照,展示 `approval.source=legacy`
8. 存量待审批退款和线下充值仅在创建人已绑定企微时提交;未绑定记录进入迁移待处理列表。
9. 提交失败的存量记录进入异常审批页面,不允许继续走旧接口处理。
## 十六、人工验证
1. 场景进入暂停中后,新退款或充值申请和新提交租约被阻止;进入已暂停时没有旧模板提交仍在执行,历史审批仍可正常同步。
2. 编辑企微模板导致 template_id 变化后,旧模板版本仍可展示,发布新映射时原子切换并恢复场景。
3. 删除再新增控件后,旧 control ID 不会被继续使用。
4. 退款审批通过后按申请金额确认人工退款结果,重复回调和轮询不会重复处理退款终态。
5. 线下充值审批通过后无需操作密码自动入账,重复终态不会重复加钱。
6. 驳回、撤销、删除状态正确同步。
7. 通过后撤销不自动冲正,异常页面、站内消息和审计记录完整。
8. 回调失效时2 分钟轮询可以推进终态。
9. 回调和轮询同时到达时,只有一个处理器执行业务动作。
10. 附件始终保留本地对象存储 Key企微 media_id 失效不影响历史资料下载。
11. Secret、Token、EncodingAESKey 和附件内容不出现在 API、日志和审计详情中。
12. 已登录平台员工扫码后自动获得企微 userid不需要人工查询或录入成员 ID。
13. 绑定 `state` 过期、重复回调、非企业成员扫码时均不会产生绑定。
14. 同一企微 userid 尝试绑定第二个系统账号时被拒绝,原绑定不被覆盖。
15. 自助解绑和管理员强制解绑不影响历史审批快照,但会阻止该账号发起新审批。

View File

@@ -0,0 +1,457 @@
# 新增需求 03站内通知详细方案
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 范围:后台账号、代理账号、企业账号和个人客户的站内通知。
## 一、定位
站内通知只负责“用户登录本系统后可以看到的消息”,不承担企业微信审批流程,也不直接发送企业微信消息。
第一版消息来源:
| 来源 | 接收人 | 示例 |
|---|---|---|
| 企微审批结果 | 业务申请人、相关平台角色 | 退款审批通过、线下充值审批驳回 |
| 套餐临期 | 代理账号、企业业务员、个人客户 | 套餐剩余 15/7/3 天 |
| 数据同步异常 | 平台运维角色 | 模板失效、回调找不到资产、连续同步失败 |
| 系统配置异常 | 平台超管 | 企微配置不可用、支付配置失效 |
企微审批中的“待我审批”由企业微信自身提醒,不在本系统重复创建审批待办。
## 二、设计原则
1. 业务事务不直接写 `tb_notification`,关键事件通过 Outbox 可靠投递。
2. 一条事件向多个接收人发送时,每个接收人保存一条独立通知。
3. 使用 `event_id + recipient_kind + recipient_id` 保证重复消费不生成重复消息。
4. 通知正文是发送时快照,使用受控纯文本模板,不保存任意 HTML。
5. 通知只能跳转到受控业务类型,前端不接受后端返回任意 URL。
6. 通知已读状态只属于当前接收人,任何查询和更新都必须带接收人条件。
7. 过期通知不进入列表和未读数,但历史数据按保留策略清理,不由用户删除。
8. 站内通知是简单写模型,使用 Application + Query + Infrastructure不为形式强行创建领域聚合。
9. 后续新增短信、企微消息等外部渠道时使用独立 Delivery不在站内通知 Handler 中追加外部调用。
## 三、流程
```mermaid
sequenceDiagram
participant Biz as 业务 Application
participant DB as 业务数据库
participant Outbox as Outbox
participant Relay as Outbox Relay
participant Worker as Notification Worker
participant Notice as tb_notification
participant Web as 前端
Biz->>DB: 提交业务状态
Biz->>Outbox: 同事务写通知事件
Relay->>Worker: 至少一次投递
Worker->>Worker: 解析接收人和受控模板
Worker->>Notice: 幂等批量插入
Web->>Notice: 查询未读数/列表
Web->>Notice: 当前接收人标记已读
```
非关键系统告警允许直接入 Asynq但必须携带稳定 `event_id`;不能在重试时重新生成。
## 四、数据模型
```sql
CREATE TABLE tb_notification (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(64) NOT NULL,
recipient_kind VARCHAR(32) NOT NULL,
recipient_id BIGINT NOT NULL,
category VARCHAR(32) NOT NULL,
type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL DEFAULT '',
ref_type VARCHAR(64),
ref_id BIGINT,
ref_key VARCHAR(128),
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_id, recipient_kind, recipient_id)
);
CREATE INDEX idx_notification_recipient_unread
ON tb_notification (recipient_kind, recipient_id, is_read, created_at DESC);
CREATE INDEX idx_notification_recipient_category
ON tb_notification (recipient_kind, recipient_id, category, created_at DESC);
CREATE INDEX idx_notification_expired
ON tb_notification (expires_at)
WHERE expires_at IS NOT NULL;
```
字段说明:
| 字段 | 说明 |
|---|---|
| `recipient_kind` | `account``personal_customer` |
| `category` | `approval / expiry / sync / system` |
| `type` | 具体通知类型常量 |
| `severity` | `info / warning / error / critical` |
| `ref_type` | 受控业务引用类型 |
| `ref_id` | 业务主键,可为空 |
| `ref_key` | 业务编号或资产标识快照,用于列表展示和资源删除后的追溯 |
不在通知表保存接收人名称、店铺名称等实时关联字段;正文已经是发送时快照,查询时也不需要联表才能展示。
## 五、通知类型
```go
const (
NotifyTypeWeComApprovalApproved = "wecom.approval.approved"
NotifyTypeWeComApprovalRejected = "wecom.approval.rejected"
NotifyTypeWeComApprovalCancelled = "wecom.approval.cancelled"
NotifyTypeWeComApprovalRevoked = "wecom.approval.revoked_after_approved"
NotifyTypeWeComTemplateInvalid = "wecom.template.invalid"
NotifyTypePackageExpiring = "package.expiring"
NotifyTypeCardSyncFailed = "card_sync.failed"
NotifyTypeCarrierCallbackCardNotFound = "carrier_callback.card_not_found"
NotifyTypeSystemAlert = "system.alert"
)
```
类别映射由后端常量维护,前端只按返回值展示,不自行猜测:
```go
func NotificationCategory(notifyType string) string
```
## 六、发布命令
```go
type Recipient struct {
Kind string
ID uint
}
type PublishNotificationCommand struct {
EventID string
Recipients []Recipient
Type string
Severity string
TemplateData map[string]any
RefType string
RefID uint
RefKey string
ExpiresAt *time.Time
}
```
Application 只提交通知类型和受控模板数据,不直接提交最终 HTML。Notification Worker 通过代码内模板注册表渲染:
```go
type TemplateRenderer func(data map[string]any) (title string, body string, err error)
var notificationTemplates = map[string]TemplateRenderer{
constants.NotifyTypeWeComApprovalApproved: renderWeComApprovalApproved,
constants.NotifyTypePackageExpiring: renderPackageExpiring,
}
```
模板字段不足时任务失败并重试,禁止生成标题为空或只有业务编号的残缺通知。
## 七、接收人解析
业务 Application 在发布事件前确定稳定业务接收人:
| 场景 | 接收人来源 |
|---|---|
| 退款/充值审批结果 | 申请人账号 ID |
| 通过后撤销 | 申请人 + 财务角色当前启用账号 |
| 企微模板失效 | 平台超管角色账号 |
| 数据同步连续失败 | 平台运维角色账号 |
| 代理套餐临期 | 资产所属代理及配置的业务员账号 |
| 企业套餐临期 | 企业关联业务员账号 |
| 个人客户套餐临期 | 资产当前绑定的个人客户 ID |
角色接收人应在事件消费时批量解析当前启用账号,具体用户接收人直接使用事件快照中的 ID。
不存在接收人时记录消费结果 `no_recipient`,不能无限重试。
## 八、API
后台账号、代理账号和企业账号统一使用 `/api/admin`,后端从登录上下文取得当前账号 ID。
### 8.1 未读总数
```http
GET /api/admin/notifications/unread-count
GET /api/c/v1/notifications/unread-count
```
响应:
```json
{
"code": 0,
"data": {
"count": 12,
"display_count": "12"
}
}
```
超过 99 时 `display_count` 返回 `99+`,前端也必须兼容直接按 count 计算。
查询条件:
```sql
recipient_kind = ?
AND recipient_id = ?
AND is_read = FALSE
AND (expires_at IS NULL OR expires_at > NOW())
```
第一版直接查询 PostgreSQL不额外维护 Redis 未读计数,避免数据库与缓存双写不一致。
### 8.2 分类未读数
```http
GET /api/admin/notifications/unread-summary
```
```json
{
"code": 0,
"data": {
"total": 12,
"categories": {
"approval": 3,
"expiry": 5,
"sync": 2,
"system": 2
}
}
}
```
个人客户第一版只返回总数,不开放运维类分类。
### 8.3 通知列表
```http
GET /api/admin/notifications
?category=approval
&type=
&severity=
&is_read=false
&page=1&page_size=20
GET /api/c/v1/notifications?page=1&page_size=20
```
响应项:
```json
{
"id": 1001,
"category": "approval",
"type": "wecom.approval.approved",
"severity": "info",
"title": "退款审批已通过",
"body": "退款单 RF202607150001 已通过企业微信审批。",
"ref_type": "refund",
"ref_id": 42,
"ref_key": "RF202607150001",
"is_read": false,
"read_at": null,
"created_at": "2026-07-15T10:00:00+08:00"
}
```
列表固定按 `created_at DESC, id DESC` 排序并服务端分页,最大 `page_size=50`
### 8.4 单条标记已读
```http
PUT /api/admin/notifications/{id}/read
PUT /api/c/v1/notifications/{id}/read
```
条件更新:
```sql
UPDATE tb_notification
SET is_read = TRUE, read_at = NOW()
WHERE id = ?
AND recipient_kind = ?
AND recipient_id = ?
AND is_read = FALSE;
```
记录不存在、无权访问或已经已读时统一返回成功,保持幂等且不泄露其他用户通知是否存在。
### 8.5 批量标记已读
```http
PUT /api/admin/notifications/read-all
PUT /api/c/v1/notifications/read-all
```
```json
{
"category": "approval"
}
```
`category` 为空表示当前接收人的全部未过期通知。接口返回本次实际更新数量。
### 8.6 点击并读取跳转信息
前端通常可直接使用列表中的 `ref_type/ref_id`。为了处理业务被删除、权限变化等情况,允许:
```http
GET /api/admin/notifications/{id}/target
```
后端先校验通知属于当前用户,再返回受控目标:
```json
{
"target_type": "refund_detail",
"target_id": 42,
"available": true
}
```
该接口不返回任意 URL。
## 九、受控跳转
| ref_type | target_type | 后台页面 |
|---|---|---|
| `refund` | `refund_detail` | `/refunds/{id}` |
| `agent_recharge` | `agent_recharge_detail` | `/agent-recharges/{id}` |
| `wecom_approval` | `wecom_approval_detail` | `/operations/wecom-approvals/{id}` |
| `iot_card` | `iot_card_detail` | `/iot-cards/{id}` |
| `device` | `device_detail` | `/devices/{id}` |
| `card_sync` | `card_sync_execution` | `/operations/card-sync?execution_id={id}` |
| `system_config` | `system_config` | `/system/config` |
前端维护 `target_type -> route` 映射。未知类型只展示通知,不执行跳转。
跳转目标页面仍按原业务权限重新校验,拥有通知不等于永久拥有业务数据访问权。
## 十、前端方案
### 10.1 顶部铃铛
```text
登录成功/布局挂载
→ 立即请求 unread-count
→ 每 30 秒轮询
→ 页面不可见时暂停
→ 恢复可见时立即刷新
```
- 未读为 0 时不显示数字。
- 199 显示实际数字,超过 99 显示 `99+`
- 请求失败保留上一次数字,不闪回 0。
- 铃铛区域固定宽度,数字变化不得造成导航布局位移。
### 10.2 通知抽屉
点击铃铛打开右侧抽屉:
- 顶部显示“全部、审批、临期、系统”分段控件。
- 默认加载最近 10 条,提供“查看全部消息”。
- 未读消息使用左侧状态点,不用整行高饱和背景。
- 点击消息先本地进入已读视觉状态,再并行调用标记已读和目标解析。
- 标记已读失败时,下次未读轮询恢复服务端真实状态。
### 10.3 通知中心
后台路由:`/notifications`
页面结构:
```text
分类 Tab + 已读状态筛选 + 严重级别筛选
通知时间线列表
全部已读按钮
服务端分页
```
错误和严重通知显示级别图标;普通通知不使用警告色。
个人客户使用简化通知列表,只显示套餐临期、订单和资产相关消息,不显示平台运维消息。
## 十一、权限与数据安全
- Notification Query 从登录 Context 固定注入接收人,不接收前端传入 `recipient_id`
- 后台账号只能查看自己的消息,平台超管也不能通过通知接口查看其他人的消息。
- 运维需要排查通知投递时,使用全局审计/运维查询接口,不复用用户通知列表接口。
- 正文禁止包含操作密码、支付密钥、身份证完整号码、长期对象存储 URL 和完整企微回调正文。
- 附件只提示“包含附件”,用户进入有权限的业务详情后获取短期下载 URL。
- 所有已读接口不区分“通知不存在”和“通知不属于当前用户”。
## 十二、保留与清理
| 通知类型 | 展示期限 | 数据保留期限 |
|---|---:|---:|
| 套餐临期 | 到期后自动不展示 | 180 天 |
| 审批结果 | 不自动过期 | 365 天 |
| 同步异常 | 30 天 | 180 天 |
| 系统告警 | 由事件指定 | 365 天 |
每日低峰期使用现有数据清理任务按批次删除超过保留期限的数据。清理不修改业务审计和领域流水。
## 十三、外部渠道扩展
未来如果需要企微消息或短信,新增独立投递表:
```text
tb_notification_delivery
notification_event_id
recipient
channel
status
attempts
provider_message_id
last_error
```
站内通知写入和外部渠道发送分别消费同一业务事件,互不影响。不能在 `NotificationHandler` 写入站内消息后直接循环调用企微或短信接口,否则某个外部渠道故障会拖慢所有站内消息。
## 十四、代码结构
```text
internal/
├── application/notification/
│ ├── publish_notification.go
│ ├── mark_read.go
│ └── mark_all_read.go
├── query/notification/
│ ├── list_notifications.go
│ ├── unread_count.go
│ └── resolve_target.go
├── infrastructure/messaging/
│ └── notification_event_handler.go
├── infrastructure/persistence/
│ └── notification_repository.go
├── handler/admin/notification.go
└── handler/app/client_notification.go
```
通知没有复杂状态机,不创建空洞的 `domain/notification` 聚合。Application 负责写操作和幂等Query 直接使用 GORM 查询读取模型。
## 十五、人工验证
1. 同一事件重复消费不会为同一接收人生成重复通知。
2. 同一事件的两个接收人各自生成一条通知,已读状态互不影响。
3. 用户无法读取或标记其他用户通知。
4. 过期通知不进入未读数和列表。
5. 全部已读只影响当前用户和指定分类。
6. 业务权限变化后,通知仍可展示,但目标接口重新校验权限。
7. 前端隐藏页签时暂停轮询,恢复后立即刷新。
8. 企微审批待办不重复生成站内待办,审批结果可以正常通知申请人。
9. 外部消息渠道失败不影响站内通知生成。
10. 通知正文、日志和 API 不包含密钥、操作密码或长期附件 URL。

View File

@@ -0,0 +1,809 @@
# 新增需求 04全局多视角审计方案
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 范围:全系统业务写操作、安全操作、外部集成和审计查询前端。
## 一、目标
审计系统必须能够从不同视角回答:
```text
谁做了什么?
某个资源经历了什么?
一次请求引发了哪些跨模块变化?
哪些操作失败、被拒绝或存在风险?
外部 Gateway、运营商和企业微信交互发生了什么
资金变化对应哪个业务动作和审批结果?
```
不是把所有日志塞进一张表,而是建立统一审计事件,并通过 Query 组合访问日志、领域流水和外部集成记录。
## 二、当前代码问题
现有实现已经有账号审计和资产审计,但没有形成全系统能力:
- `tb_account_operation_log``tb_asset_operation_log` 字段、操作类型和查询入口不一致。
- 账号、资产审计通过 goroutine Best Effort 写入,进程退出或数据库短暂失败时会丢失。
- 部分调用方又在审计服务外增加一层 goroutine形成双重异步。
- 退款审批等资金操作没有统一业务审计。
- 线下充值借用账号操作日志,资源类型和关联关系不准确。
- 访问日志只脱敏请求体,响应体仍按原文记录,可能泄露 Token、个人信息和敏感配置。
- 现有审计表只能从单个账号或单个资产查看,无法按请求、关联业务、异常等级和外部交互串联。
## 三、四类记录边界
| 类型 | 权威性 | 存储 | 用途 |
|---|---|---|---|
| Access Log | 非业务权威 | 文件/日志平台 | HTTP 调试、性能和请求追踪 |
| Audit Event | 业务审计权威 | PostgreSQL | 谁对什么做了什么、结果如何 |
| Domain Ledger | 领域事实权威 | 现有业务表 | 钱包流水、退款单、审批实例、订单状态 |
| Integration Log | 外部交互权威 | PostgreSQL | Gateway、运营商回调、企微 API、回调和同步尝试 |
规则:
- Audit Event 不替代钱包流水、退款记录和企微审批详情。
- Access Log 不作为业务审计依据。
- 数据同步的轮询、事件阶梯尝试、手动刷新和运营商回调统一进入 `tb_integration_log`,不再新建 `tb_card_sync_execution`
- 高频轮询只有在状态变化、人工强制触发、连续失败或高风险异常时生成 Audit Event。
- Audit Query 可以把四类记录组合成时间线,但必须标明数据来源。
## 四、审计事件模型
### 4.1 主表
```sql
CREATE TABLE tb_audit_event (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(64) NOT NULL UNIQUE,
occurred_at TIMESTAMPTZ NOT NULL,
category VARCHAR(32) NOT NULL,
action VARCHAR(128) NOT NULL,
action_name VARCHAR(255) NOT NULL,
actor_kind VARCHAR(32) NOT NULL,
actor_id BIGINT,
actor_name VARCHAR(255) NOT NULL DEFAULT '',
actor_user_type INT,
source VARCHAR(64) NOT NULL,
tenant_type VARCHAR(32),
tenant_id BIGINT,
shop_id BIGINT,
enterprise_id BIGINT,
result VARCHAR(16) NOT NULL,
risk_level VARCHAR(16) NOT NULL DEFAULT 'normal',
summary TEXT NOT NULL,
error_code VARCHAR(64),
error_message TEXT,
before_data JSONB,
after_data JSONB,
metadata JSONB,
request_id VARCHAR(64),
correlation_id VARCHAR(64),
parent_event_id VARCHAR(64),
ip_address VARCHAR(64),
user_agent TEXT,
request_path VARCHAR(255),
request_method VARCHAR(16),
content_hash VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_event_time
ON tb_audit_event (occurred_at DESC, id DESC);
CREATE INDEX idx_audit_event_actor
ON tb_audit_event (actor_kind, actor_id, occurred_at DESC);
CREATE INDEX idx_audit_event_action
ON tb_audit_event (category, action, occurred_at DESC);
CREATE INDEX idx_audit_event_result
ON tb_audit_event (risk_level, result, occurred_at DESC);
CREATE INDEX idx_audit_event_request
ON tb_audit_event (request_id, occurred_at ASC);
CREATE INDEX idx_audit_event_correlation
ON tb_audit_event (correlation_id, occurred_at ASC);
```
### 4.2 资源关系表
一次退款审批通过会同时影响退款单、订单、钱包、钱包流水、套餐和资产,只在主表保存一个 `resource_id` 无法完整查询。
```sql
CREATE TABLE tb_audit_event_resource (
id BIGSERIAL PRIMARY KEY,
audit_event_id BIGINT NOT NULL,
resource_type VARCHAR(64) NOT NULL,
resource_id BIGINT,
resource_key VARCHAR(128) NOT NULL DEFAULT '',
relation VARCHAR(20) NOT NULL DEFAULT 'affected',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_audit_event_resource
ON tb_audit_event_resource (
audit_event_id,
resource_type,
COALESCE(resource_id, 0),
resource_key,
relation
);
CREATE INDEX idx_audit_resource_timeline
ON tb_audit_event_resource (resource_type, resource_id, audit_event_id DESC);
CREATE INDEX idx_audit_resource_key
ON tb_audit_event_resource (resource_type, resource_key, audit_event_id DESC);
```
不建立数据库外键。`relation`
| relation | 含义 |
|---|---|
| `primary` | 本次操作的主要对象 |
| `affected` | 被本次操作修改的对象 |
| `reference` | 只用于解释上下文的关联对象 |
### 4.3 操作者
`actor_kind`
```text
admin_user
agent_user
enterprise_user
personal_customer
open_api_account
system_task
carrier_callback
wecom_callback
```
系统和外部回调允许 `actor_id=NULL`,但必须保存清晰的 `actor_name`,例如“移动实名回调”“企微审批状态同步”“套餐到期任务”。
### 4.4 来源
`source` 表示入口,不表示操作者:
```text
admin_api
personal_api
open_api
asynq
scheduled_job
gateway
carrier_callback.cmcc
carrier_callback.cucc
carrier_callback.ctcc
wecom_callback
wecom_polling
data_migration
```
### 4.5 结果与风险
`result``success / failed / denied / partial`
`risk_level``normal / warning / high / critical`
默认风险示例:
| 操作 | 风险等级 |
|---|---|
| 普通字段修改成功 | normal |
| 批量部分失败 | warning |
| 权限拒绝、重复回调冲突 | warning |
| 钱包余额、退款、角色权限、支付配置修改 | high |
| 企微通过后撤销但资金已执行、审计写入失败 | critical |
## 五、领域模型和代码结构
```text
internal/
├── domain/audit/
│ ├── event.go 不可变 AuditEvent 实体及不变量
│ ├── actor.go 操作者值对象
│ ├── resource.go 多资源关联
│ ├── sanitizer.go 审计数据脱敏规则
│ ├── action_registry.go 操作编码、名称、分类和默认风险
│ └── repository.go
├── application/audit/
│ ├── append_event.go 成功/失败审计写入
│ ├── append_with_tx.go 关键业务事务内写入
│ └── append_system_event.go
├── query/audit/
│ ├── event_list.go
│ ├── actor_view.go
│ ├── resource_view.go
│ ├── request_view.go
│ ├── correlation_view.go
│ ├── risk_view.go
│ ├── integration_view.go
│ └── finance_view.go
└── infrastructure/persistence/
└── audit_repository.go
```
Audit Event 具有独立 `event_id`因此是不可变领域实体不是值对象Actor、Resource 和字段差异是值对象。事件创建后不提供 Update/Delete Repository 方法。
## 六、操作注册表
操作编码禁止在各 Service 中自由拼字符串:
```go
type ActionDefinition struct {
Code string
Name string
Category string
DefaultRiskLevel string
SensitiveFields []string
}
var ActionRegistry = map[string]ActionDefinition{
"account.role.assign": {
Name: "分配账号角色", Category: "security", DefaultRiskLevel: "high",
},
"refund.wecom.approved": {
Name: "企微审批通过并确认人工退款", Category: "finance", DefaultRiskLevel: "high",
},
"recharge.wecom.approved": {
Name: "企微审批通过并完成线下充值", Category: "finance", DefaultRiskLevel: "high",
},
"iot_card.realname.callback": {
Name: "运营商回调更新实名状态", Category: "asset", DefaultRiskLevel: "normal",
},
}
```
DTO `description`、前端中文名称和筛选项都从同一注册表生成,避免操作编码与展示名称不一致。
## 七、写入可靠性
### 7.1 关键成功事件
以下事件必须与业务变更同事务写入:
- 钱包余额变化。
- 人工退款结果确认和代理钱包回溯。
- 线下充值入账。
- 账号角色和权限变化。
- 支付、企微和系统关键配置变化。
- 卡实名、网络状态被人工修改。
- 手工绑定企微 `sp_no`
Application 在事务内调用:
```go
auditWriter.AppendWithTx(ctx, tx, event)
```
审计写入失败时事务回滚,禁止业务成功但关键审计缺失。
### 7.2 异步系统事件
轮询、事件阶梯同步、运营商回调和企微轮询等外部交互统一进入 Integration Log。只有以下情况同时写 Audit Event
- 状态发生业务变化。
- 连续失败达到阈值。
- 人工强制触发。
- 出现高风险异常。
系统事件通过同一业务事务或 Outbox 可靠写入,不启动裸 goroutine。
### 7.3 失败和拒绝事件
业务事务已经回滚时,使用独立短事务写 `failed/denied` 审计。审计写入失败不能把原业务错误改成成功,但必须记录 `critical` 应用日志和监控指标。
## 八、关联链路
### 8.1 request_id
表示一次 HTTP 请求,由现有 Request ID 中间件产生。一次请求触发的所有审计事件使用同一个 `request_id`
### 8.2 correlation_id
表示跨请求、跨任务的完整业务链路:
```text
退款申请创建
→ 企微审批提交
→ 企微回调
→ 人工退款结果入账
→ 代理钱包退款流水(仅代理钱包支付订单)
→ 佣金回扣
→ 套餐失效和停机
```
以上事件共享退款申请创建时生成的 `correlation_id`。Asynq、Outbox、企微实例和同步任务载荷都必须传递该值。
### 8.3 parent_event_id
表示直接因果关系。例如“套餐失效”事件的父事件是“退款终态处理成功”,用于前端画出树状链路。
## 九、数据脱敏
### 9.1 永不进入审计的数据
```text
password
operation_password
access_token / refresh_token
agent_secret / app_secret
callback_token / encoding_aes_key
支付私钥、公钥原文
短信验证码
完整身份证号
对象存储签名 URL
企微 media_id
```
这些字段直接删除,不保存为 `******`,避免字段存在本身造成误用。
### 9.2 按权限展示的数据
手机、IP、ICCID、钱包余额和第三方交易号可以存储受控快照但 Query 根据权限返回:
- 普通审计权限:脱敏展示。
- 敏感审计权限:展示完整值。
- 导出权限单独控制,不能因为可查看页面就默认可导出完整值。
### 9.3 大字段
`before_data``after_data``metadata` 分别限制 16KB。超过后保存
```json
{
"_truncated": true,
"_original_bytes": 38210,
"_summary": "批量修改500条资产",
"_artifact_ref": "batch_task:123"
}
```
批量明细保存在对应任务结果表或对象存储,不塞入审计 JSON。
## 十、外部集成日志
企业微信、运营商回调、Gateway 请求和事件同步尝试使用通用 Integration Log。同步尝试即使在真正发送 HTTP 前因合并、限流或预期状态已满足而结束,也写一条结果记录,保证能够解释“为什么没有请求上游”。
```sql
CREATE TABLE tb_integration_log (
id BIGSERIAL PRIMARY KEY,
integration_id VARCHAR(64) NOT NULL UNIQUE,
provider VARCHAR(32) NOT NULL,
direction VARCHAR(16) NOT NULL,
operation VARCHAR(64) NOT NULL,
external_id VARCHAR(128),
resource_type VARCHAR(64),
resource_id BIGINT,
resource_key VARCHAR(128),
trigger_source VARCHAR(32),
trigger_scene VARCHAR(128),
trigger_series VARCHAR(64),
scheduled_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
attempt INT NOT NULL DEFAULT 1,
result VARCHAR(20) NOT NULL,
http_status INT,
provider_code VARCHAR(64),
provider_message TEXT,
request_summary JSONB,
response_summary JSONB,
duration_ms BIGINT NOT NULL DEFAULT 0,
state_changed BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB,
request_id VARCHAR(64),
correlation_id VARCHAR(64),
audit_event_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_integration_log_time
ON tb_integration_log (created_at DESC, id DESC);
CREATE INDEX idx_integration_log_provider
ON tb_integration_log (provider, operation, result, created_at DESC);
CREATE INDEX idx_integration_log_resource
ON tb_integration_log (resource_type, resource_id, created_at DESC);
CREATE INDEX idx_integration_log_resource_key
ON tb_integration_log (resource_type, resource_key, created_at DESC);
CREATE INDEX idx_integration_log_trigger
ON tb_integration_log (trigger_series, attempt);
CREATE INDEX idx_integration_log_correlation
ON tb_integration_log (correlation_id, created_at ASC);
```
`direction``inbound / outbound`
`result` 至少支持:
```text
success
failed
not_found
invalid_payload
ignored
merged
rate_limited
completed
cancelled
```
数据同步字段约定:
- `provider=gateway`:实名、流量、卡状态和设备信息查询。
- `provider=cmcc/cucc/ctcc``direction=inbound`:运营商实名或解除实名回调。
- `trigger_source``polling/business_event/open_api/manual_refresh/carrier_callback`
- `trigger_scene`:触发埋点场景,例如 `client_asset_info``card_resume``device_switch_card`
- `trigger_series + attempt`:关联一次 `0/3/5` 阶梯同步。
- `result=merged/rate_limited/completed` 时允许没有 HTTP 状态和响应摘要,因为没有真正请求上游。
- `state_changed=true` 时通过 `audit_event_id` 关联对应业务状态变化事件。
普通高频轮询成功也写 Integration Log但不生成 Audit Event。查询和清理必须使用时间索引及分批删除不能在业务请求中同步聚合全量历史。
外部交互正文只保存脱敏摘要。企微加密报文、运营商原始回调和 Gateway 加密请求不进入普通审计详情。
## 十一、审计范围
### 11.1 必须审计的写操作
| 模块 | 操作 |
|---|---|
| 账号权限 | 创建、禁用、删除、角色分配、密码重置 |
| 资产 | 分配、回收、绑定、解绑、停复机、实名策略、手工实名、限速 |
| 套餐 | 创建、修改、上下架、分配、价格、已用量和到期时间调整 |
| 钱包资金 | 充值、扣款、退款、信用变化、人工调整 |
| 订单退款 | 创建退款、企微终态、人工退款确认、代理钱包回溯、资产后处理 |
| 线下充值 | 创建、企微终态、入账、通过后撤销异常 |
| 系统配置 | 支付配置、企微配置状态、模板版本发布、导出字段权限 |
| 数据同步 | 人工强制同步、回调更新业务状态、连续失败告警 |
| 导入导出 | 创建任务、下载敏感导出、批量任务结果 |
| 登录安全 | 登录成功/失败、Token 撤销、开放接口签名失败和重放拒绝 |
### 11.2 不进入业务审计的普通读操作
- 普通列表、详情和未读数查询只进入 Access Log。
- 下载敏感附件、导出、查看完整密钥状态、查看完整个人信息属于敏感读取,需要 Audit Event。
## 十二、多视角 API
### 12.1 全局事件视角
```http
GET /api/admin/audit/events
?category=finance
&action=
&result=success
&risk_level=high
&source=wecom_callback
&start_at=
&end_at=
&keyword=
&page=1&page_size=50
```
`keyword` 只匹配事件摘要、资源编号、操作者名称和 request_id不对 JSONB 做无索引模糊扫描。
### 12.2 事件详情
```http
GET /api/admin/audit/events/{event_id}
```
返回:
- 操作者快照。
- 入口、租户和请求信息。
- 主要资源、影响资源和引用资源。
- 前后数据差异。
- 错误信息和风险等级。
- request/correlation/parent 关联。
- 可访问的领域流水和 Integration Log 链接。
### 12.3 操作者视角
```http
GET /api/admin/audit/actors
?actor_kind=admin_user
&keyword=
&range=30d
&page=1&page_size=20
GET /api/admin/audit/actors/{actor_kind}/{actor_id}/summary?range=30d
GET /api/admin/audit/actors/{actor_kind}/{actor_id}/events?page=1&page_size=50
```
人员列表返回操作者 ID、名称、类型、最近操作时间、操作量、失败量和最高风险等级`keyword` 只做 ID、账号和名称的前缀匹配不扫描审计 JSON。Summary 返回操作量、失败量、拒绝量、高风险量、常用操作、常操作资源和最近登录 IP。
### 12.4 资源视角
```http
GET /api/admin/audit/resources/search
?resource_type=
&keyword=RF202607150001
&page=1&page_size=20
GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline
?include_related=true
&page=1&page_size=50
```
示例资源:`iot_card / device / refund / order / agent_wallet / wecom_approval / account / system_config`
资源搜索属于 Query 模块,允许直接查询退款、订单、充值、账号和资产等读模型,不要求加载领域聚合。返回候选项的 `resource_type/resource_id/resource_key/display_name`,解决同一关键词命中多个资源的问题。静态 `resources/search` 路由必须先于动态资源路由注册。
`include_related=true` 时通过资源关系表返回影响该资源的跨模块事件,但不无限递归加载关联资源。
### 12.5 请求视角
```http
GET /api/admin/audit/requests/{request_id}/timeline
```
按时间展示一次 HTTP 请求产生的 Access Log 摘要、Audit Event、Outbox 和 Integration Log。
### 12.6 业务链路视角
```http
GET /api/admin/audit/correlations/{correlation_id}/timeline
```
用于查看退款、企微审批、钱包和资产处理等跨请求链路。返回节点包含 `parent_event_id`,前端可展示因果树。
### 12.7 风险视角
```http
GET /api/admin/audit/risks/overview?range=24h
GET /api/admin/audit/risks/events?risk_level=critical&page=1&page_size=50
```
Overview
- 高风险和严重事件数量。
- 权限拒绝、开放接口重放、资金冲突和外部回调异常趋势。
- 按操作人、来源和资源类型聚合。
### 12.8 外部集成视角
```http
GET /api/admin/audit/integrations
?provider=wecom
&direction=inbound
&operation=getapprovaldetail
&result=failed
&external_id=
&resource_type=
&resource_key=
&trigger_source=
&trigger_scene=
&trigger_series=
&page=1&page_size=50
GET /api/admin/audit/integrations/{integration_log_id}
```
列表查询 `tb_integration_log`,并返回关联审计事件和业务链路 ID详情返回脱敏后的请求摘要、响应摘要、耗时、错误、尝试次数、计划时间、触发场景和关联资源不返回密钥、完整回调正文或附件内容。
`trigger_series` 有值时详情同时返回该序列的全部尝试前端按“立即、3 分钟、5 分钟”展示,不要求用户逐条搜索。同步记录不再通过独立 `/card-sync/executions` 接口查询。
### 12.9 资金视角
```http
GET /api/admin/audit/finance/timeline
?business_no=
&wallet_id=
&transaction_no=
&page=1&page_size=50
```
Finance Query 组合:
- Audit Event。
- 代理钱包和资产钱包流水。
- 退款单、充值单和订单。
- 企微审批实例。
每条记录明确 `record_source`,钱包流水仍是金额变化的权威数据。
### 12.10 导出
```http
POST /api/admin/audit/exports
```
复用现有导出任务系统。导出字段按角色配置,默认不允许导出完整 IP、手机号、ICCID、前后 JSON 和外部响应摘要。
## 十三、权限
建议权限码:
```text
audit:global:view
audit:actor:view
audit:resource:view
audit:request:view
audit:risk:view
audit:integration:view
audit:finance:view
audit:sensitive:view
audit:export
```
第一版审计中心只开放给超级管理员和具有对应权限的平台角色。
代理、企业账号不进入全局审计中心;它们在资产、订单、充值等业务详情页只能查看自己有权限资源的资源时间线,且返回字段经过脱敏。
Service/Query 必须重新应用资源权限,前端隐藏 Tab 不能替代后端授权。
## 十四、前端多视角界面
### 14.1 审计中心
路由:`/operations/audit`
使用工作台式紧凑布局,不做营销式卡片页面。顶部为时间范围、关键词和常用过滤器,下方使用 Tab
1. **全局事件**:按时间倒序的审计事件表。
2. **人员行为**:操作者列表、风险统计和行为时间线。
3. **资源轨迹**:输入资源类型和标识,展示资源生命周期。
4. **请求链路**:按 request_id/correlation_id 展示跨模块时间线。
5. **资金审计**:订单、审批、钱包流水和退款/充值组合视图。
6. **风险事件**:失败、拒绝、高风险和严重事件。
7. **外部集成**Gateway、企微、运营商回调和事件同步阶梯尝试。
Tab 根据权限返回,前端不展示无权限入口。
外部集成 Tab 增加紧凑的来源切换:`全部 / Gateway 同步 / 运营商回调 / 企业微信`。选择 Gateway 同步后展示触发来源、触发场景、资源、序列、尝试、结果、耗时和状态是否变化;点击序列打开三次尝试时间线。
### 14.2 全局事件表
字段:
```text
时间 | 风险 | 操作者 | 操作 | 主要资源 | 来源 | 结果 | request_id
```
- 支持服务端分页和列筛选。
- 点击行打开右侧详情抽屉。
- 风险仅用图标和有限颜色表达,普通成功事件保持中性色。
- `request_id`、资源编号可点击进入对应视角。
### 14.3 事件详情抽屉
分区:
```text
事件摘要
操作者与入口
关联资源
字段变更对比
请求与业务链路
错误和外部交互
```
字段变更使用结构化键值对比,不直接把整段 JSON 原样堆在页面。未知字段可以折叠显示原始 JSON。
敏感字段默认脱敏;有 `audit:sensitive:view` 权限时提供“显示敏感数据”按钮,并对该次查看再写一条敏感读取审计。
### 14.4 人员行为视角
左侧为操作者搜索和筛选,右侧显示:
- 30 天操作量、失败率、高风险操作数。
- 常用来源 IP。
- 操作类型分布。
- 最近行为时间线。
- 涉及资源列表。
不做基于行为的自动封禁,只提供审计判断依据。
左侧人员列表调用 `/api/admin/audit/actors`,选择人员后再并行加载 Summary 和事件列表,避免前端从全局事件自行聚合。
### 14.5 资源轨迹视角
支持输入 ICCID、设备虚拟号、退款单号、订单号、充值单号、账号名称等先调用 `/api/admin/audit/resources/search` 返回候选资源;只有一个候选时直接打开时间线,多个候选时由用户选择,前端不自行猜测资源类型。
时间线同时展示:
- 业务状态变化。
- 人工操作。
- 企微审批结果。
- 资金流水链接。
- 重要 Gateway/回调事件。
普通高频轮询成功不进入资源审计时间线,避免有效信息被淹没。
### 14.6 请求和业务链路视角
使用纵向时间线展示节点,节点固定包含时间、来源、动作、结果和耗时。父子事件使用缩进和连接线表达,不使用复杂自由拖拽图编辑器。
### 14.7 风险视角
顶部显示 24 小时趋势和风险分类,下方是风险事件表。支持一键跳转操作者、资源和 correlation 链路,但第一版不建设风险处置工单。
## 十五、Access Log 修正
当前访问日志的响应体需要使用与请求体相同的递归脱敏逻辑:
```go
responseBody := sanitizeBody(c.Response().Body())
```
并增加路由级策略:
| 路由 | Body 记录策略 |
|---|---|
| 登录、Token、支付配置 | 只记录字段名和长度,不记录值 |
| 企微/支付/运营商回调 | 记录摘要和哈希,不记录完整正文 |
| 文件上传下载 | 不记录文件内容和签名 URL |
| 普通 JSON API | 脱敏后最多 50KB |
Access Log 建议保留 30 天;不得因为 Access Log 已存在而省略关键业务 Audit Event。
## 十六、保留策略
| 数据 | 保留期限 |
|---|---:|
| 资金、权限、审批和关键配置 Audit Event | 5 年 |
| 普通资产和业务操作 Audit Event | 2 年 |
| Integration Log | 180 天 |
| Gateway 高频同步 Integration Log | 正常 30 天,异常或状态变化 180 天 |
| Access Log | 30 天 |
Audit Event 默认不在线删除。数据量达到单表维护阈值后按月分区,超期分区归档到低成本存储,再由专用维护流程删除;应用账号不具备 Update/Delete 审计表权限。
## 十七、一次性审计切换
本需求与普通 DDD 触碰式迁移不同审计基础设施、写入入口、Query API 和前端多视角界面在同一次停机发布中全局切换,不能让新旧审计长期并行。
### 17.1 新写入
- 数据同步、企微审批、站内通知和后续新功能只写统一 `tb_audit_event``tb_integration_log`
- 账号、角色、权限、资产、套餐、订单、退款、充值、钱包、系统配置、导入导出和登录安全等现有敏感操作同时切换到统一 Audit Writer。
- 卡同步不创建 `tb_card_sync_execution`;轮询、手动刷新、事件阶梯尝试和运营商回调统一写 Integration Log。
- 发布后旧 `tb_account_operation_log``tb_asset_operation_log`、手动轮询触发日志等表不再产生新记录。
- 禁止双写新旧审计表,避免同一操作产生两条不一致记录。
一次性切换审计不等于一次性把所有旧业务模块迁移为 DDD。未迁移的旧 Service 通过统一审计 Adapter 写入新模型;退款、资金、卡状态等复杂且本次触碰的用例按 DDD 规范迁入 Application/Domain。
### 17.2 历史数据
- 旧审计表保留只读,不在线回填到新表。
- 审计 Query 使用 `UNION ALL` 把旧账号、资产和手动触发日志标准化为历史投影,返回 `record_source=legacy_account/legacy_asset/legacy_polling`
- 历史记录只用于查询,不允许更新或补写。
- 后续达到归档条件后离线迁移或归档旧表,不影响本次新写入一次性切换。
## 十八、代码迁移重点
- 删除 `account_audit.Service.LogOperation``asset_audit.Service.LogOperation` 内部裸 goroutine。
- 删除调用方外围 `go auditService.LogOperation(...)`
- 为尚未迁入 DDD 的旧 Service 提供统一 Audit Writer Adapter并在本次发布中替换全部敏感操作旧写入口。
- 退款、线下充值资金事务使用 `AppendWithTx`
- 卡实名领域用例在状态变化事务内写审计。
- 轮询、事件同步、手动刷新和运营商回调统一写 Integration Log只有状态变化、连续失败、手动操作和高风险异常追加 Audit Event。
- 企微模板发布、手工绑定 sp_no、通过后撤销写高风险事件。
- `pkg/logger/middleware.go` 对响应体和特殊路由实施脱敏策略。
- 现有账号、资产和业务日志详情接口统一改为调用 `query/audit` 的资源时间线或历史投影。
## 十九、人工验证
1. 关键资金事务在审计写入失败时整体回滚。
2. 同一事件不会因 Outbox/Asynq 重试生成重复审计。
3. 一次退款能够从退款单、订单、钱包和资产四个资源视角查到同一事件。
4. request_id 能串联一次 API 请求产生的多条审计记录。
5. correlation_id 能串联退款申请、企微审批、退款、佣金和资产处理。
6. 普通用户不能访问全局、人员、风险和集成视角。
7. 敏感字段默认脱敏,查看完整敏感字段的动作本身再次被审计。
8. Access Log 请求和响应均不泄露 Token、Secret、操作密码和签名 URL。
9. 高频轮询成功只进入外部集成视角,不会淹没业务资源时间线,状态变化和连续失败仍可见。
10. 企微通过后撤销且资金已执行时生成 critical 事件,不自动冲正。
11. 历史账号/资产日志可以通过统一审计页面查询,发布后的新写入不再双写旧表。
12. 审计导出字段受角色权限控制,页面可见不等于可导出完整敏感值。
13. 人员、资源和集成视角都有独立查询接口,前端不通过下载全局事件后自行聚合。
14. 同一次事件同步的立即、3 分钟、5 分钟尝试能通过 `trigger_series` 连续展示,合并、限流和提前完成都有可解释结果。
15. 发布后旧账号、资产和轮询日志表不再新增记录,新旧历史仍能在同一审计界面查询。

View File

@@ -0,0 +1,446 @@
# 新增需求 05代理钱包扫码充值
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 范围:代理在后台使用微信或支付宝扫码充值代理主钱包。
## 一、已确认决策
1. 代理在后台为自己的店铺主钱包充值。
2. 支付方式支持微信扫码和支付宝扫码。
3. 单笔最低充值金额为 100 元,即 `10000` 分。
4. 代理在线充值不进入企业微信审批,支付成功后直接幂等增加代理主钱包余额。
5. 平台员工线下代充值仍按 `02-企业微信审批接入.md` 走企微审批,与本方案隔离。
6. 后端返回支付二维码内容,前端使用现有二维码组件渲染,不由后端生成或保存二维码图片文件。
7. 微信使用 Native 支付,支付宝使用 `alipay.trade.precreate` 当面付预创建。
8. 支付回调、钱包入账、钱包流水和审计必须幂等,重复回调不能重复加钱。
9. 当前代理充值复杂写逻辑迁移到 Application/Domain旧 Service 不再保留另一套在线入账逻辑。
## 二、现状与缺口
现有代码已经具备代理充值记录、微信/富友回调和钱包入账骨架,但还不能满足本需求:
- `CreateAgentRechargeRequest` 只允许 `wechat/offline`,没有支付宝。
- `AgentRechargeMinAmount=1`,当前最低金额是 1 分。
- 创建接口只生成充值记录,没有创建统一 `tb_payment` 支付单,也没有真正向支付渠道预下单获取二维码。
- 微信支付仅保存当前生效支付配置,响应中没有 `code_url`
- 支付宝回调只分发套餐订单和客户资产钱包充值,没有代理充值订单类型。
- `HandlePaymentCallback` 在旧 Service 中直接更新充值单、钱包和流水,业务状态和幂等边界没有收口为独立用例。
- 前端技术方案只写了“保留收款码和支付状态页面”,没有支付方式选择、最低金额、二维码过期和支付成功状态细节。
现有评审中“代理在线充值不审批”的结论保持不变,本次补齐的是扫码支付和最低金额的完整技术方案。
## 三、业务流程
```mermaid
sequenceDiagram
actor Agent as 代理用户
participant Web as 代理后台
participant API as Recharge Application
participant DB as PostgreSQL
participant Pay as 微信/支付宝
participant Callback as 支付回调
participant Wallet as AgentWallet Domain
Agent->>Web: 输入金额并选择支付方式
Web->>API: 创建扫码充值单
API->>DB: 创建充值单和支付单
API->>Pay: Native/PreCreate 预下单
Pay-->>API: 二维码内容
API-->>Web: 返回二维码和过期时间
Web-->>Agent: 展示二维码并轮询支付状态
Agent->>Pay: 扫码完成支付
Pay->>Callback: 异步支付通知
Callback->>API: ConfirmAgentRechargePayment
API->>DB: 校验支付单、金额和状态
API->>Wallet: CreditRecharge
Wallet->>DB: 同事务增加余额、写流水、完成充值单
API-->>Pay: 返回成功
Web->>API: 查询到已完成
Web-->>Agent: 展示最新钱包余额
```
支付成功不创建 `wecom_approval_instance`,充值详情固定返回:
```json
{
"approval_source": "none",
"approval": null
}
```
## 四、DDD 设计
### 4.1 目录
```text
internal/
├── domain/agentwallet/
│ ├── wallet.go 代理主钱包聚合及余额不变量
│ ├── recharge.go 充值单状态转换
│ ├── events.go 充值到账领域事件
│ └── repository.go 钱包与充值聚合仓储接口
├── application/agentrecharge/
│ ├── create_qr_recharge.go 创建充值单和扫码支付
│ ├── confirm_payment.go 支付回调确认并入账
│ ├── close_expired.go 关闭过期未支付充值单
│ └── get_payment_status.go 轻量支付状态查询
├── infrastructure/adapter/payment/
│ ├── wechat_native.go 微信 Native 预下单
│ └── alipay_precreate.go 支付宝当面付预创建
├── infrastructure/persistence/
│ └── agent_recharge_repository.go
└── query/agentrecharge/
├── list.go
└── detail.go
```
### 4.2 领域状态
继续使用现有充值状态,不为在线充值增加审批状态:
| 状态 | 含义 | 在线充值使用方式 |
|---:|---|---|
| 1 | 待支付 | 已创建二维码,等待扫码 |
| 2 | 已支付 | 支付已确认,钱包入账事务处理中 |
| 3 | 已完成 | 钱包余额和流水已完成 |
| 4 | 已关闭 | 超时未支付或主动取消 |
| 5 | 已退款 | 历史或后续人工退款结果 |
| 6 | 已驳回 | 仅旧数据或线下审批兼容,在线充值不产生 |
`status=2` 是短暂业务处理状态。支付回调事务正常完成时直接推进到 `3`;若钱包入账发生可恢复错误,则保持 `2` 并由可靠任务继续处理。
### 4.3 钱包入账不变量
`AgentWallet.CreditRecharge` 必须保证:
- 只允许向目标店铺的 `wallet_type=main` 钱包入账。
- 入账金额必须等于充值单金额和支付单金额。
- 同一充值单只能生成一条成功钱包流水。
- 钱包余额、`version`、充值单状态和钱包流水在同一数据库事务更新。
- 钱包乐观锁冲突时由 Application 重新加载后有限重试,不能重复创建流水。
- 支付渠道成功不等于业务已经完成;只有钱包事务成功后充值单才变为已完成。
## 五、支付预下单
### 5.1 统一支付单
代理在线充值必须创建 `tb_payment` 记录,不能只依赖 `ARCH` 单号前缀判断支付渠道。
新增支付业务类型:
```go
const PaymentOrderTypeAgentRecharge = "agent_recharge"
```
充值单和支付单在同一事务创建:
```text
tb_agent_recharge_record.status = 1
tb_payment.status = 0
tb_payment.order_type = agent_recharge
tb_payment.order_id = recharge_id
tb_payment.payment_method = wechat / alipay
tb_payment.amount = recharge_amount
tb_payment.payment_config_id = 创建时使用的配置ID
```
先完成本地事务,再调用第三方预下单。预下单失败时把支付单标记为失败并关闭本次充值单,代理重新创建,不复用来源不明确的旧二维码。
### 5.2 微信扫码
微信使用 Native 下单:
```text
微信支付 v3 TransactionNative
-> 返回 code_url
```
现有微信 SDK 已包含 `TransactionNative`,需要在项目支付 Adapter 中封装,不在 Handler 直接调用 SDK。
若当前生效支付配置为:
- `wechat`:使用微信 v3 Native。
- `wechat_v2`:补充 v2 Native 统一下单实现。
- `fuiou`:只有现有富友配置明确支持后台扫码产品时才返回微信可用;不支持时前端隐藏微信扫码入口,不擅自用 JSAPI 代替。
### 5.3 支付宝扫码
支付宝使用当前 SDK 已提供的:
```text
alipay.trade.precreate
-> 返回 qr_code
```
不复用现有 WAP 支付 URL。创建时校验当前支付配置中的
```text
ali_app_id
ali_private_key
ali_public_key
ali_notify_url
```
配置不完整时支付宝方式显示为不可用,不能创建只有本地记录而没有有效二维码的充值单。
### 5.4 二维码响应
后端统一返回二维码内容,不返回二维码图片:
```json
{
"recharge_id": 88,
"recharge_no": "ARCH20260715143000000001",
"payment_no": "ARCH20260715143000000001",
"payment_method": "wechat",
"amount": 10000,
"qr_content": "weixin://wxpay/bizpayurl?...",
"expires_at": "2026-07-15T15:00:00+08:00",
"status": 1,
"status_name": "待支付"
}
```
微信返回 `code_url`、支付宝返回 `qr_code`Application 统一映射为 `qr_content`
## 六、支付回调与直接入账
### 6.1 回调校验
微信和支付宝回调继续执行现有签名、安全和金额校验,并增加代理充值分发:
```text
payment.order_type = agent_recharge
-> ConfirmAgentRechargePayment
```
必须校验:
- 支付单存在且支付方式与回调渠道一致。
- `payment_config_id` 与创建支付单时配置一致。
- 回调金额等于支付单和充值单金额。
- 第三方交易号没有被其他支付单占用。
- 充值单属于支付单中的 `order_id`,不能只依赖订单号前缀。
### 6.2 回调事务
```text
1. 按 payment_no 加载支付单
2. 支付单 pending -> paid 条件更新
3. 充值单 1 -> 2 条件更新
4. 加载代理主钱包并执行 CreditRecharge
5. 钱包余额和 version 更新
6. 创建唯一钱包流水
7. 充值单 2 -> 3写 paid_at/completed_at
8. 写 Audit Event
```
幂等键:
```text
agent_recharge:{recharge_id}:credit
```
数据库还需要保证钱包流水 `reference_type=agent_recharge + reference_id=recharge_id` 唯一。Redis 只用于削减重复并发,不能替代数据库幂等。
### 6.3 回调失败恢复
- 第三方校验失败:拒绝回调,不改变业务状态。
- 支付状态已成功、钱包事务失败:充值单保持已支付,写 Outbox/恢复任务继续入账。
- 钱包流水已存在但充值单未完成:恢复任务只补齐充值单状态。
- 重复回调:查询到支付单或充值单已完成后直接返回渠道成功报文。
- 本功能不引入审批,也不会因为支付金额较大转入审批。
## 七、金额与权限
### 7.1 金额
```go
const AgentRechargeMinAmount int64 = 10000
```
- 单位固定为分。
- DTO 使用 `min=10000`Service/Application 必须再次校验。
- 前端输入单位为元,提交前转换为分。
- 最大金额继续沿用现有系统上限,后续调整单独配置。
- 禁止前端通过浮点数直接计算金额,元转分使用字符串或十进制定点处理。
### 7.2 权限
- 代理只能为当前登录账号所属店铺的主钱包充值。
- 后端从登录上下文校验 `shop_id`,不能只相信请求参数。
- 平台和超级管理员可以查看全部充值记录;是否允许代代理发起在线扫码充值保持现有权限。
- 企业账号无权访问代理充值接口。
- 代理不能查看其他店铺的充值单、支付状态或二维码。
## 八、API 设计
### 8.1 可用支付方式
```http
GET /api/admin/agent-recharges/payment-methods
```
响应:
```json
{
"items": [
{"method": "wechat", "name": "微信支付", "enabled": true},
{"method": "alipay", "name": "支付宝", "enabled": true}
],
"min_amount": 10000,
"max_amount": 100000000
}
```
该路由必须先于 `/:id` 动态路由注册。
### 8.2 创建扫码充值
沿用现有接口:
```http
POST /api/admin/agent-recharges
```
```json
{
"shop_id": 101,
"amount": 10000,
"payment_method": "wechat",
"request_id": "01J2RECHARGE..."
}
```
`payment_method` 调整为:
```text
wechat / alipay / offline
```
其中 `offline` 仅平台员工线下代充值使用,并继续走企微审批;代理用户只能选择 `wechat/alipay`
`request_id` 用于防止前端重复点击创建多个二维码订单,同一店铺下建立业务唯一约束或 Redis 防重键。
### 8.3 查询支付状态
```http
GET /api/admin/agent-recharges/{id}/payment-status
```
```json
{
"status": 3,
"status_name": "已完成",
"payment_status": 1,
"payment_status_name": "已支付",
"paid_at": "2026-07-15T14:35:00+08:00",
"completed_at": "2026-07-15T14:35:01+08:00",
"wallet_balance": 510000
}
```
该接口只返回当前登录代理有权访问的充值单,不返回支付密钥、签名参数或其他店铺余额。
## 九、前端方案
### 9.1 入口
代理后台钱包页面保留“充值”按钮,点击后打开充值弹窗或抽屉,不新增营销页面。
控件:
- 金额使用数字输入框,单位为元,明确最低 100 元。
- 支付方式使用微信/支付宝分段控件,带对应图标。
- 不可用渠道禁用并显示简短原因。
- 主按钮为“生成支付二维码”。
### 9.2 二维码状态
创建成功后展示:
```text
充值金额
支付方式
二维码
二维码剩余有效时间
支付状态
取消/重新生成
```
- 前端使用 `qr_content` 生成二维码,不请求后端图片文件。
- 页面可见时每 3 秒查询一次轻量支付状态。
- 页面隐藏时暂停轮询,恢复可见时立即查询。
- 状态变为已完成、已关闭或离开页面时停止轮询。
- 二维码过期后禁用原二维码,提供“重新生成”命令,重新创建充值单和支付单。
- 支付完成后关闭二维码区域,刷新钱包余额并展示充值成功结果。
- 全流程不展示审批状态或企微审批区块。
### 9.3 列表和详情
代理充值列表增加支付方式和支付状态:
```text
充值单号 | 金额 | 支付方式 | 支付状态 | 充值状态 | 创建时间 | 完成时间
```
在线充值详情返回 `approval_source=none`。平台员工线下代充值详情继续展示企微审批信息,两类记录按 `payment_method` 区分。
## 十、审计与通知
统一审计至少记录:
```text
代理创建充值单
支付预下单成功/失败
微信/支付宝支付回调成功/失败
钱包入账成功/失败
重复回调被幂等忽略
充值单超时关闭
```
支付渠道交互写 `tb_integration_log`,钱包余额变化写关键 `Audit Event`,并关联充值单、支付单、代理钱包和钱包流水。
在线充值不产生审批通知。充值成功后可以生成普通资金结果站内通知,但不能显示“审批通过”。
## 十一、代码迁移范围
### 11.1 必须修改
- `AgentRechargeMinAmount``1` 调整为 `10000`
- `CreateAgentRechargeRequest.payment_method` 增加 `alipay`,金额校验改为 `min=10000`
- 创建代理在线充值时同时创建 `tb_payment` 记录。
- 新增 `PaymentOrderTypeAgentRecharge`
- 微信支付 Adapter 增加 Native 预下单。
- 支付宝 Adapter 增加 `TradePreCreate`
- 支付宝回调增加代理充值分发。
- 微信/富友代理充值回调统一改为按支付单分发,不只依赖 `ARCH` 前缀。
-`agent_recharge.Service.HandlePaymentCallback` 迁入 `ConfirmAgentRechargePayment` 用例。
- 前端增加支付方式查询、二维码展示和支付状态轮询。
### 11.2 保持不变
- 代理在线充值不进入企微审批。
- 平台员工线下代充值继续走企微审批。
- 代理只能充值自己的店铺主钱包。
- 钱包流水仍是资金变化权威记录。
- 现有支付回调验签和金额校验原则保持不变。
## 十二、发布与人工验证
停机发布API 与回调服务同时切换:
1. 验证 99.99 元被后端拒绝100 元可以创建充值单。
2. 验证代理只能为自己的店铺创建微信或支付宝充值。
3. 验证微信 Native 返回有效 `code_url`,前端能够扫码支付。
4. 验证支付宝 PreCreate 返回有效 `qr_code`,前端能够扫码支付。
5. 验证支付回调通过支付单类型分发到代理充值用例。
6. 验证微信、支付宝回调金额不一致时不会增加钱包余额。
7. 验证支付成功后不创建企微审批实例,直接完成钱包入账。
8. 验证重复回调只产生一条钱包流水,余额只增加一次。
9. 验证支付成功但钱包事务暂时失败时能够恢复完成,不需要代理重复支付。
10. 验证二维码过期后充值单关闭,旧二维码不能继续显示为有效。
11. 验证代理充值详情固定返回 `approval_source=none`,不展示审批区域。
12. 验证平台员工线下代充值仍按原企微审批方案执行,不受在线充值改造影响。

View File

@@ -0,0 +1,72 @@
# 禅道补充需求:换货归属与资产换货标识
> 来源:禅道 #98、#86。最终口径以标准评审稿为准。
## 一、现状
当前换货要求新旧资产已经属于同一店铺;归属不一致时直接拒绝。换货完成只更新资产状态,没有把旧资产店铺归属赋给平台库存中的新资产。
资产已有 `asset_status``generation`,换货单也保存新旧资产 ID但资产详情没有统一返回前代和后代资产关系。
## 二、换货归属规则
- 新资产处于平台库存或已经属于旧资产店铺时可以换入。
- 新资产已经属于其他店铺时拒绝换货,不能覆盖其他代理资产。
- 换货完成事务内,新资产继承旧资产 `shop_id` 和租户标签,并写资产分配记录。
- 旧资产保留原 `shop_id`,状态改为已换货,继续用于历史订单、退款和换货查询。
- 前端不提供目标店铺选择;选择新资产后展示将继承的店铺名称。
处理顺序:
```text
锁定换货单和新旧资产
-> 校验新资产库存及归属
-> 新资产继承旧资产店铺
-> 迁移客户绑定、钱包和套餐资料
-> 更新新旧资产状态
-> 完成换货单
-> 写资产分配记录和审计事件
```
任一步失败整笔事务回滚。
## 三、资产详情换货链
卡和设备详情统一返回:
```json
{
"exchange_trace": {
"previous_asset": {
"asset_type": "iot_card",
"asset_id": 1001,
"identifier": "89860...001",
"exchange_no": "EXC202607170001"
},
"next_asset": {
"asset_type": "iot_card",
"asset_id": 1003,
"identifier": "89860...003",
"exchange_no": "EXC202607180001"
}
}
}
```
- 有前代时显示“换货新资产”。
- 有后代时显示“已换出旧资产”。
- A→B→C 场景中B 可以同时展示前代 A 和后代 C。
- 关联资产必须再次经过后端数据权限校验;无权限时只返回标识,不返回可跳转 ID。
复用 `tb_exchange_order`,补充按旧资产查询已完成换货的方法,以及 `new_asset_type + new_asset_id + status` 查询索引,不新增关系表。
## 四、接口与前端
现有卡、设备详情接口增加 `exchange_trace`,不新增独立换货链接口。
前端在资产基础信息附近展示状态标签和关联资产链接。链接进入对应卡或设备详情,不直接跳换货单;换货单号作为辅助信息展示。
## 五、审计
记录旧资产、新资产、换货单、原店铺、新店铺、状态变更和操作人。新资产归属继承属于关键资产变更,必须与换货事务同时写 Audit Event。

View File

@@ -0,0 +1,81 @@
# 禅道补充需求:店铺业务员与钱包余额预警
> 来源:禅道 #96、#97。最终口径以标准评审稿为准。
## 一、边界
店铺业务员只表示平台内部的业务归属,用于列表筛选和通知接收。它不建立代理分销关系,不参与店铺上下级、佣金、提现或数据权限计算。
钱包预警阈值固定为 100 元,不做系统配置、角色配置或店铺配置。
## 二、店铺业务员
`tb_shop` 增加:
```text
business_owner_account_id BIGINT NULL
```
- 只允许绑定启用的平台账号。
- 创建、编辑店铺时可不选择。
- 店铺列表支持按业务员筛选,列表和详情返回账号名称。
- 业务员停用或删除后保留关联和历史审计,但不再作为通知接收人;运营可以重新绑定。
- 不建立数据库外键Application 显式校验账号类型和状态。
API 调整:
```text
POST /api/admin/shops
PUT /api/admin/shops/{id}
GET /api/admin/shops?business_owner_account_id={id}
```
## 三、固定 100 元余额预警
固定常量:
```go
const AgentWalletLowBalanceThreshold int64 = 10000 // 代理主钱包现金余额预警阈值100元
```
只计算代理主钱包现金可用余额:
```text
cash_available = balance - frozen_balance
```
信用额度不参与预警。触发条件:
```text
before_cash_available > 10000
after_cash_available <= 10000
```
接收人:
- 店铺启用的主账号。
- 店铺绑定且仍启用的平台业务员。
本期只发送站内通知,不发送企业微信消息。
## 四、防重复
- 首次跌至 100 元及以下时发送一次。
- 持续低于阈值时,后续扣款不重复通知。
- 余额充值到 100 元以上后重新布防,再次跌破可以重新发送。
- 通知使用钱包 ID、预警轮次和接收人组成稳定业务键。
可以在主钱包保存 `low_balance_alert_active`,或者使用通知状态投影记录当前预警轮次;无论实现方式如何,资金事务不能因为通知失败而回滚,事件先写 Outbox。
## 五、前端
- 店铺创建、编辑增加可搜索的平台业务员下拉框。
- 店铺列表和详情展示业务员,并支持筛选。
- 资金概况在现金可用余额小于等于 100 元时显示红色“现金余额不足 100 元”。
- 不展示阈值编辑入口。
- 通知点击后受控跳转到对应店铺资金概况。
## 六、审计
记录业务员绑定变更、预警触发时的余额/冻结金额、接收人、通知事件 ID 和失败原因。信用额度不得写入余额预警判断结果。

View File

@@ -0,0 +1,81 @@
# 禅道补充需求:代理系列套餐批量授权
> 来源:禅道 #43。最终口径以标准评审稿为准。
## 一、现状与范围
系统已经存在代理系列授权和套餐授权数据,后端 `PUT /api/admin/shop-series-grants/{id}/packages` 也已经接受套餐数组。本需求不是重建授权模型,而是补齐套餐候选 Query 和前端批量交互。
当前主要问题:
- 前端只能逐个授权套餐。
- 首次授权系列和后续管理套餐时,看不出哪些套餐已经授权给当前代理。
- 公司成本价、代理授权成本价和建议售价缺少明确区分。
## 二、套餐候选 Query
新增:
```text
GET /api/admin/shop-series-grants/{id}/package-options
```
查询参数支持套餐名称、编码和授权状态。响应:
```json
{
"package_id": 1001,
"package_name": "移动月包",
"package_code": "CMCC-M01",
"company_cost_price": 5000,
"authorized_cost_price": 6500,
"suggested_retail_price": 8800,
"is_authorized": true,
"shelf_status": 1,
"status": 1
}
```
金额单位统一为分。`authorized_cost_price` 在未授权时返回 `null`
## 三、批量授权
继续使用现有批量写接口:
```text
PUT /api/admin/shop-series-grants/{id}/packages
```
请求一次提交多个套餐:
```json
{
"packages": [
{"package_id": 1001, "cost_price": 6500},
{"package_id": 1002, "cost_price": 7000}
]
}
```
- 同一请求内套餐 ID 必须去重。
- 已经授权且价格相同的套餐按幂等成功处理。
- 已经授权但价格不同的套餐不能被“新增授权”静默改价,应走明确的价格更新操作。
- 套餐必须属于当前授权系列,后端不能只相信前端候选列表。
- 批量写入在同一事务完成,任一套餐不合法则整批失败。
## 四、前端
首次创建系列授权和后续“管理套餐”共用同一个套餐选择组件:
- 表格使用复选框多选。
- 展示套餐名称、编码、公司成本价、当前授权成本价和建议售价。
- 已授权套餐显示“已授权”标签并置灰,不可选择。
- 支持只看未授权套餐和关键词搜索。
- 提交前显示本次新增套餐数量和价格摘要。
已授权置灰只是交互反馈,后端仍负责幂等和归属校验。
## 五、权限与审计
公司成本价属于敏感字段,只有具备系列授权和成本价查看权限的角色可以读取。审计记录代理、系列、套餐列表、授权价格、操作人和变更前后数据。