迭代计划准备

This commit is contained in:
2026-07-16 15:08:07 +08:00
parent c4f430ccb3
commit bcf3e31db6
11 changed files with 12223 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,270 @@
# 7月迭代前端技术方案
> 状态:待评审
> 适用端后台管理端、代理端、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,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,727 @@
处理回调的代码
```golang
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"io/ioutil"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
ranNumLib "math/rand"
"github.com/google/uuid"
)
// XML请求体结构
type ContractRoot struct {
XMLName xml.Name `xml:"ContractRoot"`
Type string `xml:"TYPE"`
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
StatusInfo string `xml:"STATUSINFO"`
AccNbr string `xml:"ACCNBR"`
ICCID string `xml:"ICCID"`
SendDt string `xml:"SENDDT"`
AcceptType string `xml:"ACCEPTTYPE"`
AcceptMsg string `xml:"ACCEPTMSG"`
StatusDt string `xml:"STATUSDT"`
ResultMsg string `xml:"RESULTMSG"`
}
// 第三方推送数据结构
type PushData struct {
Msg string `json:"msg"`
Code int `json:"code"`
Data struct {
RequestID string `json:"requestId"`
RealStatus bool `json:"realStatus"`
ICCID string `json:"iccid"`
} `json:"data"`
}
func parseCallback(jsonStr []byte) (string, string, error) {
// 定义匿名结构体用于解析外层JSON
var cb struct {
Data string `json:"data"`
}
err := json.Unmarshal(jsonStr, &cb)
if err != nil {
return "", "", fmt.Errorf("failed to unmarshal outer JSON: %v", err)
}
// 定义匿名结构体用于解析内层数据
var inner struct {
DateChanged string `json:"dateChanged"`
ICCID string `json:"iccid"`
}
err = json.Unmarshal([]byte(cb.Data), &inner)
if err != nil {
return "", "", fmt.Errorf("failed to unmarshal inner data JSON: %v", err)
}
return inner.ICCID, inner.DateChanged, nil
}
// 5GCMP实名后推送到第三方平台
func pushToThirdParty(iccid string) (string, error) {
// 构建推送数据
pushData := PushData{
Msg: "查询成功",
Code: 200,
}
pushData.Data.RequestID = uuid.New().String()
pushData.Data.RealStatus = true
pushData.Data.ICCID = iccid
// 序列化为JSON
jsonData, err := json.Marshal(pushData)
if err != nil {
return "", fmt.Errorf("序列化推送数据失败: %v", err)
}
// 发送HTTP POST请求
pushURL := "http://jh.whjhft.com/gswlpushapi/recv.do?type=3"
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Post(pushURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("HTTP请求失败: %v", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
}
return string(respBody), nil
}
// 获取日志文件路径
func getLogFilePath() string {
// 创建logs目录
logsDir := "logs"
if err := os.MkdirAll(logsDir, 0755); err != nil {
log.Printf("创建日志目录失败: %v", err)
}
// 按日期生成文件名
dateStr := time.Now().Format("2006-01-02")
fileName := fmt.Sprintf("5gcmp_callback_%s.log", dateStr)
return filepath.Join(logsDir, fileName)
}
func GetQcRandNum() (ranNum string) {
randomFloat := ranNumLib.Float64()
if randomFloat < 0.5 {
randomFloat = 1 - randomFloat
}
ranNum = fmt.Sprintf("%.16f", randomFloat)
return
}
// 写入日志
func writeLog(content string) {
logFile := getLogFilePath()
// 打开或创建日志文件
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
log.Printf("打开日志文件失败: %v", err)
return
}
defer file.Close()
// 写入日志内容
if _, err := file.WriteString(content); err != nil {
log.Printf("写入日志失败: %v", err)
}
}
// 向管理平台发送删除实名请求
func DelRealName(iccid string) (res string) {
account := Account{UserName: "18627991016", Password: "y123456"}
sessionid := account.Login()
realnameId := getRealnameIdByIccid(iccid, sessionid)
if realnameId == "" {
return
}
log.Printf("sessinid:%s,realnameId:%s", sessionid, realnameId)
url := "http://jh.whjhft.com/realnamerecord/deleteById.do?responseFunction=initUpdate&id=" + realnameId + "&rfm=" + GetQcRandNum()
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return ""
}
client := &http.Client{
Timeout: 10 * time.Second,
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
log.Printf("删除实名响应: %s", respBody)
res = string(respBody)
return
}
func getRealnameIdByIccid(iccid, sessionid string) (realnameId string) {
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=0." + GetQcRandNum()
client := &http.Client{
Timeout: 10 * time.Second,
}
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
var JSONData struct {
Code string `json:"code"`
Data struct {
PageNo int `json:"pageNo"`
PageCount int `json:"pageCount"`
PageSize int `json:"pageSize"`
PageStartOffset int `json:"pageStartOffset"`
Total int `json:"total"`
Rows []struct {
MybatisRecordCount int `json:"mybatisRecordCount"`
OrderNo string `json:"orderNo"`
JSONUpdateFlag string `json:"jsonUpdateFlag"`
ID string `json:"id"`
IccidMark string `json:"iccidMark"`
Phone string `json:"phone"`
AccountID string `json:"accountId"`
AccountName string `json:"accountName"`
Status int `json:"status"`
CreateName string `json:"createName"`
CreateDate string `json:"createDate"`
StatusStr string `json:"statusStr"`
} `json:"rows"`
Framework string `json:"framework"`
Data string `json:"data"`
Count int `json:"count"`
Limit int `json:"limit"`
Page int `json:"page"`
Layui bool `json:"layui"`
} `json:"data"`
CurrentSessionUserResourceIdsIndex []string `json:"current_session_user_resource_ids_index"`
AppResultKey string `json:"app_result_key"`
SystemResultKey string `json:"system_result_key"`
}
json.Unmarshal(respBody, &JSONData)
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
realnameId = JSONData.Data.Rows[0].ID
}
log.Printf("响应体: %s", respBody)
return
}
func getIccidByMsisdn(msisdn, sessionid string) (iccid string) {
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=" + GetQcRandNum()
client := &http.Client{
Timeout: 10 * time.Second,
}
data := fmt.Sprintf("status=1&phone=%s", msisdn)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
var JSONData struct {
Code string `json:"code"`
Data struct {
Rows []struct {
IccidMark string `json:"iccidMark"`
} `json:"rows"`
Count int `json:"count"`
} `json:"data"`
AppResultKey string `json:"app_result_key"`
SystemResultKey string `json:"system_result_key"`
}
json.Unmarshal(respBody, &JSONData)
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
iccid = JSONData.Data.Rows[0].IccidMark
}
log.Printf("响应体: %s", respBody)
return
}
func ModifyDate(iccid, dateChanged string) {
var jsonData = map[string]interface{}{
"iccid": iccid,
"dateChanged": dateChanged,
}
jsonDataBs, _ := json.Marshal(jsonData)
// 创建请求
req, err := http.NewRequest("POST", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", bytes.NewReader(jsonDataBs))
if err != nil {
writeLog(fmt.Sprintf("创建请求失败:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
// 设置Content-Type为x-www-form-urlencoded
req.Header.Set("Content-Type", "application/json")
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
defer resp.Body.Close()
// 读取响应内容
respBody, err := io.ReadAll(resp.Body)
if err != nil {
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
// 检查响应状态
if resp.StatusCode != http.StatusOK {
fmt.Printf("请求失败,状态码: %d, 响应: %s\n", resp.StatusCode, string(respBody))
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]成功,[%s]\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged, string(respBody)))
}
// 处理回调请求
func handleCallback(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
// 处理XML请求体和第三方推送
var pushResponse string
log.Printf("body:%s\r\n", string(body))
if len(body) > 0 {
// 尝试解析XML
var contractRoot ContractRoot
if err := xml.Unmarshal(body, &contractRoot); err == nil {
logBuilder.WriteString("--- XML解析结果 ---\n")
logBuilder.WriteString(fmt.Sprintf("TYPE: %s\n", contractRoot.Type))
logBuilder.WriteString(fmt.Sprintf("ICCID: %s\n", contractRoot.ICCID))
logBuilder.WriteString(fmt.Sprintf("STATUSINFO: %s\n", contractRoot.StatusInfo))
// 如果TYPE=1表示实名认证成功需要推送到第三方
if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息补录") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("触发条件: TYPE=%s (实名认证成功)\n", contractRoot.Type))
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", contractRoot.ICCID))
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
ModifyDate(contractRoot.ICCID, timestampStr)
// 执行推送
if resp, err := pushToThirdParty(contractRoot.ICCID); err != nil {
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
pushResponse = fmt.Sprintf("推送失败: %v", err)
} else {
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
pushResponse = resp
}
} else if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息清除") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
logBuilder.WriteString("--- 第三方推送删除实名 ---\n")
res := DelRealName(contractRoot.ICCID)
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
} else {
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("跳过推送: TYPE=%s,%s,%s (非实名认证成功)\n", contractRoot.Type, contractRoot.AcceptMsg, contractRoot.ResultMsg))
}
} else {
logBuilder.WriteString(fmt.Sprintf("XML解析失败: %v\n", err))
}
}
// 记录处理时间
processTime := time.Since(startTime)
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
logBuilder.WriteString("========================================\n")
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 返回成功响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
// 如果有推送响应,添加到响应中
if pushResponse != "" {
responseData["pushResponse"] = pushResponse
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
// 获取管理平台登录凭证
func (ac Account) Login() (sessionid string) {
var password string
password = ac.Password
var requestBody bytes.Buffer
multipartWriter := multipart.NewWriter(&requestBody)
multipartWriter.WriteField("username", ac.UserName)
multipartWriter.WriteField("password", password)
multipartWriter.Close()
req, _ := http.NewRequest("POST", "http://jh.whjhft.com/pages/login.do", &requestBody)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
//client1 := http.DefaultClient
client1 := &http.Client{
CheckRedirect: func(req1 *http.Request, via []*http.Request) error {
strs := strings.Split(req1.URL.Path, ";")
log.Printf("%s\r\n", req1.URL.Path)
if len(strs) == 2 {
strs1 := strings.Split(strs[1], "=")
if len(strs1) == 2 {
sessionid = strs1[1]
}
}
// fmt.Printf("Redirect from '%s' to '%s'\n", via[0].URL, req1.URL.Path)
return nil
},
}
resp, err := client1.Do(req)
if err != nil {
fmt.Println("Failed to send request:", err)
return
}
defer resp.Body.Close()
// 处理响应
_, err = ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println("Failed to read response:", err)
return
}
//fmt.Println("Response:", string(respBody))
return
}
// 移动实名回调
func ChinaMobileCallback(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString("【移动实名回调】\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
var JSONData struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
RegStatus string `json:"regStatus"`
BusiSeq string `json:"busiSeq"`
Msisdn string `json:"msisdn"`
Iccid string `json:"iccid"`
} `json:"result"`
}
json.Unmarshal(body, &JSONData)
if JSONData.Status == "0" && JSONData.Message == "正确" && len(JSONData.Result) > 0 && JSONData.Result[0].RegStatus == "00000" {
iccid := JSONData.Result[0].Iccid
if iccid == "" {
msisdn := JSONData.Result[0].Msisdn //接入号
account := Account{UserName: "18627991016", Password: "y123456"}
sessionid := account.Login()
iccid = getIccidByMsisdn(msisdn, sessionid)
}
if iccid == "" {
return
}
//推送修改过期时间
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
ModifyDate(iccid, timestampStr)
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", iccid))
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
// 执行推送实名状态
if resp, err := pushToThirdParty(iccid); err != nil {
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
} else {
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
}
logBuilder.WriteString("--- 第三方推送 ---\n")
} else {
logBuilder.WriteString("(实名认证失败)\n")
}
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
defer r.Body.Close()
// 记录处理时间
processTime := time.Since(startTime)
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
logBuilder.WriteString("========================================\n")
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 返回200 OK响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
// 联通解除实名回调
func UniRealnameRemove(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
defer r.Body.Close()
// 解析回调数据
iccid, dateChanged, err := parseCallback(body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("解析回调数据失败: %v\n", err))
} else {
if len(iccid) == 20 {
//取前面19位
iccid = iccid[:19]
//删除实名
res := DelRealName(iccid)
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
}
logBuilder.WriteString(fmt.Sprintf("解析回调数据成功: ICCID=%s, DateChanged=%s\n", iccid, dateChanged))
}
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
func main() {
// 注册路由
// res := DelRealName("8986112422108176397")
// log.Printf("删除实名响应: %s", res)
http.HandleFunc("/5gcmp/callback/realname", handleCallback)
http.HandleFunc("/unicom/callback/realname/remove", UniRealnameRemove)
http.HandleFunc("/mobile/callback/realname", ChinaMobileCallback)
// 启动服务器
port := ":16159"
fmt.Printf("5GCMP回调服务器启动成功\n")
fmt.Printf("监听端口: %s\n", port)
fmt.Printf("电信回调地址: %s/5gcmp/callback/realname\n", port)
fmt.Printf("联通回调地址: %s/unicom/callback/realname/remove\n", port)
fmt.Printf("移动回调地址: %s/mobile/callback/realname\n", port)
fmt.Printf("日志目录: logs/\n")
fmt.Printf("按 Ctrl+C 停止服务器\n\n")
if err := http.ListenAndServe(port, nil); err != nil {
log.Fatalf("启动服务器失败: %v", err)
}
}
type Account struct {
UserName string
Password string
}
```
优化轮询以及添加事件/触发式 数据同步
目前我们系统过于依赖轮询系统,且轮询系统的黑盒属性过于严重
所以现在想加入事件触发以及实名回调,目前已知的可配置实名回调只有移动,联通,电信三个运营商,广电是没有实名回调的,上方是之前已经实现过的实名回调
需求差不多是这么个需求,主要就是想让数据同步这一块的及时率达到一种很快的地步,看是怎么埋点,而且开放接口的埋点还需要特殊处理,不然的话代理拿着我们的开放接口乱调用的话就等于外置了一个轮询系统了

View File

@@ -0,0 +1,33 @@
# 批量购买套餐脚本功能总结
## 功能说明
新增 Python 运维脚本 `scripts/batch_package_purchase/batch_purchase.py`,用于读取单列 CSV并逐资产调用后台 `POST /api/admin/orders` 接口购买同一个套餐。
## 输入与配置
- CSV 每行一个 ICCID 或虚拟号,首行表头可选。
- `--base-url` 配置接口地址。
- `--package-id` 指定本批次统一购买的套餐 ID。
- 认证支持已有 Access Token或使用后台账号自动登录获取 Token。
- 支付方式固定为 `wallet`
## 安全控制
- 默认仅预演,显式增加 `--execute` 后才会真实下单。
- 下单前统一校验 CSV重复资产会阻断整批执行。
- 每个资产独立调用一次接口,结果逐条刷新到 CSV。
- POST 请求不自动重试,避免响应丢失造成重复购买。
- Token 和密码支持环境变量传入,不写入结果文件。
## 输出
结果 CSV 包含:
- CSV 原始行号和资产标识。
- 成功或失败状态。
- HTTP 状态码和业务错误码。
- 接口消息。
- 成功订单的订单 ID、订单号和订单金额。
脚本执行结束时,成功数量与输入总数一致则退出码为 `0`;存在失败或中途因认证失效停止时退出码为 `2`参数、CSV 或登录错误时退出码为 `1`

View File

@@ -0,0 +1,182 @@
# 技术方案评审规范
> 适用范围:新功能、复杂业务改造、跨模块变更、基础设施建设和需要多人评审的技术方案。
> 核心目标:让评审人能够判断“为什么这样做、是否能落地、失败后怎么办”,而不是仅看到表结构和代码清单。
---
## 一、方案分级
技术方案不要求统一篇幅,也不要求每个需求都强行画图。
| 等级 | 典型场景 | 最低要求 |
|------|----------|----------|
| 简化方案 | 单字段、单表 CRUD、局部筛选、纯展示修复 | 背景、改动范围、接口/字段、前端影响、兼容性、人工验收点 |
| 标准方案 | 新接口、异步任务、跨表写入、配置化、第三方调用 | 简化方案全部内容 + 关键流程图 + 数据与异常处理 + 发布回滚 |
| 完整方案 | 金额、审批、状态机、并发、跨模块事务、可靠事件、公共基础设施 | 标准方案全部内容 + 系统上下文图 + 状态图/时序图 + 前后端方案 + 风险与待决策项 |
以下场景即使代码量不大,也必须使用完整方案:
- 涉及资金、余额、信用额度、退款或充值。
- 涉及审批、权限、审计或敏感数据导出。
- 涉及异步任务、消息投递、最终一致性或第三方回调。
- 修改公共基础设施或多个业务模块共同依赖的契约。
---
## 二、文档状态
需要进入评审的文档,开头必须标明状态:
```text
状态:草稿 | 待评审 | 评审通过 | 已废弃
负责人:姓名或角色
评审人:后端、前端、产品、验收负责人、运维(按实际参与方)
最后更新YYYY-MM-DD
关联需求:需求编号或 OpenSpec change
```
状态含义:
- `草稿`:允许存在未收敛内容,不可直接进入开发。
- `待评审`:方案作者认为信息已经完整,待评审人确认。
- `评审通过`:关键决策和待办已有结论,可以进入实施。
- `已废弃`:保留历史原因,并链接替代方案。
存在评审阻塞项时,禁止标记为“评审通过”。
---
## 三、必备内容
### 3.1 背景、目标与非目标
必须回答:
- 当前问题是什么,谁受到影响。
- 本次要达成的业务结果和可观察结果是什么。
- 本次明确不做什么,为什么不做。
- 当前系统有哪些不能忽略的约束。
### 3.2 现状与改造范围
- 标出当前入口、主要调用链、现有表和已有接口。
- 列出新增、修改、废弃的模块和接口。
- 说明是否触发 DDD 迁移,以及选择复杂写、简单写或 Query 通道的依据。
- 禁止只写理想结构而不说明与旧代码如何共存。
### 3.3 架构与关键流程
按问题选择图,不为凑数量画图:
| 图 | 适用问题 |
|----|----------|
| 系统上下文图 | 谁调用谁、系统边界和外部依赖是什么 |
| 组件图 | Handler、Application、Domain、Repository、队列等如何协作 |
| 流程图 | 条件分支、人工操作和业务步骤如何推进 |
| 状态图 | 哪些状态允许转换,哪些是终态 |
| 时序图 | 事务边界、异步事件、回调和失败重试的先后顺序 |
| 数据关系图 | 新增多张表且关系仅靠文字难以理解 |
图使用 Mermaid图下必须补充关键约束。图不能替代状态枚举、接口字段和异常规则。
### 3.4 后端技术方案
至少包含:
- 领域或模块边界,以及核心不变量归属。
- 数据模型、索引、唯一约束、迁移和历史数据处理。
- API 请求、响应、错误码、权限、分页和兼容策略。
- 事务边界、并发控制、幂等策略和失败重试。
- 异步任务的载荷、状态、重试、死信或人工补偿入口。
- 查询性能、N+1 风险和必要的批量查询方案。
- 审批类方案必须明确审批意见、附件、必填规则、不可修改审计和下载权限,不能只设计状态字段。
### 3.5 前端技术方案
不能只写“前端新增按钮”或“前端展示字段”。至少说明:
- 涉及哪些端后台管理、代理端、企业端、C 端 H5/公众号。
- 页面入口、建议路由、页面间跳转和权限控制。
- API 模块、请求参数、响应状态和刷新策略。
- 表单、列表、详情、弹框、上传和异步任务的交互状态。
- 加载、空数据、重复提交、部分成功、失败、重试和过期状态。
- 金额单位、时间格式、枚举显示和后端字段的唯一口径。
- 滚动发布时说明新旧前后端兼容方式;停机发布时说明维护页、版本一致性和开放访问前检查。
当前仓库不包含前端源码时,方案可以保持框架无关,但必须标明需要前端仓库确认的实现映射,不能凭空指定状态库或组件库。
### 3.6 非功能设计
按实际风险覆盖:
- 权限和数据范围。
- 敏感字段与导出控制。
- 审计日志。
- 性能目标和容量假设。
- 日志、指标、告警和人工排查入口。
- 外部依赖不可用时的降级策略。
### 3.7 发布、回滚与兼容
至少回答:
- 数据库、后端、Worker、前端和配置按什么顺序发布。
- 是否需要先加字段、双写、回填、迁移存量进行中业务或兼容旧接口。
- 如何关闭新入口或停用新流程。
- 回滚时哪些数据不能删除,哪些状态需要人工处理。
- 滚动发布时说明新旧版本并存风险;停机发布时说明如何阻止新写入、迁移存量任务并确认整套版本一致。
### 3.8 风险、待决策与验收
- 风险必须写触发条件、影响和应对方式。
- 待决策项必须给出推荐方案、备选方案和最晚确认时间。
- 验收使用本项目允许的人工方式接口调用、PostgreSQL 数据核对、日志检查和页面操作。
- 不得用“后续再看”掩盖会影响数据模型、接口契约或安全边界的问题。
---
## 四、评审检查清单
### 业务与范围
- [ ] 目标、非目标和验收结果明确。
- [ ] 原始需求中的歧义已经决策或明确列为阻塞项。
- [ ] Phase 1 与后续阶段边界清晰。
### 架构与数据
- [ ] 现状调用链和目标调用链都能解释。
- [ ] DDD 使用有业务理由,没有为简单 CRUD 强行建模。
- [ ] 状态、事务、并发、幂等和最终一致性闭环。
- [ ] 审批意见、附件和业务凭证边界明确,历史操作不可被覆盖。
- [ ] PostgreSQL DDL、索引和历史数据策略可执行。
- [ ] 存量进行中业务有明确的回填、结束或人工处理方案,不会因旧入口下线而悬空。
### 前端与接口
- [ ] 前端页面、状态和异常分支完整。
- [ ] API 路径使用项目真实前缀,字段和枚举一致。
- [ ] 权限不依赖前端隐藏按钮。
- [ ] 异步任务和审批完成后有明确刷新或轮询策略。
### 上线与运维
- [ ] 发布顺序、功能开关或停用方式明确。
- [ ] 回滚不会删除历史审批、资金流水或审计记录。
- [ ] 关键失败能够通过日志、任务状态或管理页面定位。
---
## 五、合格标准
“业内合格”不等于文档很长,而是评审人能基于文档回答以下问题:
1. 方案是否解决了正确的问题。
2. 前后端和外部系统的边界是否一致。
3. 正常路径、异常路径和并发路径是否闭环。
4. 数据是否可追溯,资金和权限是否安全。
5. 能否分阶段发布,失败后能否止损和恢复。
6. 实施人员是否还需要自行猜测关键业务规则。
任一关键问题仍需实施人员自行猜测时,方案只能保持“待评审”,不能视为评审通过。