From d022cc8788999c9e5426063907d6fe3c569bd22c Mon Sep 17 00:00:00 2001 From: break Date: Fri, 17 Jul 2026 16:39:41 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=AD=E4=BB=A3=E6=96=B9=E6=A1=88=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- docs/7月迭代/00-总览.md | 254 - docs/7月迭代/7月迭代前后端任务工时表.md | 57 + docs/7月迭代/7月迭代完整技术方案-评审稿.md | 6457 ----------------- docs/7月迭代/7月迭代技术方案-标准评审稿.md | 195 +- docs/7月迭代/7月迭代禅道研发需求拆分表.md | 242 + docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md | 1889 +++++ docs/7月迭代/README.md | 62 + docs/7月迭代/{ => 来源材料}/业务需求.md | 6 +- docs/7月迭代/{ => 来源材料}/新需求.md | 2 + docs/7月迭代/物联网卡管系统-需求.csv | 329 + .../历史方案/前端共性方案-历史稿.md} | 3 +- .../历史方案/本地通用审批流-已废弃方案.md} | 5 +- .../历史方案/站内消息-初版.md} | 3 +- .../{ => 独立方案/原需求}/需求01-复机实名规则.md | 2 +- .../{ => 独立方案/原需求}/需求02-H5流程配置.md | 2 +- .../原需求}/需求03-07-11-12-13-简单改动.md | 35 +- .../原需求}/需求04-06-退款拦截与最后到期时间.md | 25 +- .../原需求}/需求05-套餐分配生效条件.md | 2 +- .../原需求}/需求08-设备批量分配Excel.md | 2 +- .../原需求}/需求09-C端支付限制配置化.md | 4 +- .../{ => 独立方案/原需求}/需求10-限速规则.md | 2 +- .../{ => 独立方案/原需求}/需求14-导出功能.md | 2 +- .../原需求}/需求15-16-18-19-20-21-复杂需求.md | 10 +- .../{ => 独立方案/原需求}/需求17-信用额度.md | 70 +- .../{ => 独立方案/原需求}/需求22-套餐临期提醒.md | 76 +- .../{ => 独立方案/基础规范}/DDD规范.md | 2 +- .../{基础设施 => 独立方案/基础规范}/系统配置.md | 2 +- .../新增需求/01-数据同步触发与轮询优化.md | 2 +- .../新增需求/02-企业微信审批接入.md | 2 +- .../新增需求/03-站内通知详细方案.md | 2 +- .../新增需求/04-全局多视角审计方案.md | 2 +- .../新增需求/05-代理钱包扫码充值.md | 2 +- .../新增需求/06-换货归属与资产换货标识.md | 72 + .../新增需求/07-店铺业务员与钱包余额预警.md | 81 + .../独立方案/新增需求/08-代理系列套餐批量授权.md | 81 + 36 files changed, 3104 insertions(+), 6882 deletions(-) delete mode 100644 docs/7月迭代/00-总览.md create mode 100644 docs/7月迭代/7月迭代前后端任务工时表.md delete mode 100644 docs/7月迭代/7月迭代完整技术方案-评审稿.md create mode 100644 docs/7月迭代/7月迭代禅道研发需求拆分表.md create mode 100644 docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md create mode 100644 docs/7月迭代/README.md rename docs/7月迭代/{ => 来源材料}/业务需求.md (97%) rename docs/7月迭代/{ => 来源材料}/新需求.md (99%) create mode 100644 docs/7月迭代/物联网卡管系统-需求.csv rename docs/7月迭代/{前端技术方案.md => 独立方案/历史方案/前端共性方案-历史稿.md} (98%) rename docs/7月迭代/{基础设施/审批流.md => 独立方案/历史方案/本地通用审批流-已废弃方案.md} (99%) rename docs/7月迭代/{基础设施/站内消息.md => 独立方案/历史方案/站内消息-初版.md} (98%) rename docs/7月迭代/{ => 独立方案/原需求}/需求01-复机实名规则.md (93%) rename docs/7月迭代/{ => 独立方案/原需求}/需求02-H5流程配置.md (99%) rename docs/7月迭代/{ => 独立方案/原需求}/需求03-07-11-12-13-简单改动.md (91%) rename docs/7月迭代/{ => 独立方案/原需求}/需求04-06-退款拦截与最后到期时间.md (81%) rename docs/7月迭代/{ => 独立方案/原需求}/需求05-套餐分配生效条件.md (98%) rename docs/7月迭代/{ => 独立方案/原需求}/需求08-设备批量分配Excel.md (99%) rename docs/7月迭代/{ => 独立方案/原需求}/需求09-C端支付限制配置化.md (97%) rename docs/7月迭代/{ => 独立方案/原需求}/需求10-限速规则.md (98%) rename docs/7月迭代/{ => 独立方案/原需求}/需求14-导出功能.md (99%) rename docs/7月迭代/{ => 独立方案/原需求}/需求15-16-18-19-20-21-复杂需求.md (97%) rename docs/7月迭代/{ => 独立方案/原需求}/需求17-信用额度.md (77%) rename docs/7月迭代/{ => 独立方案/原需求}/需求22-套餐临期提醒.md (71%) rename docs/7月迭代/{ => 独立方案/基础规范}/DDD规范.md (99%) rename docs/7月迭代/{基础设施 => 独立方案/基础规范}/系统配置.md (98%) rename docs/7月迭代/{ => 独立方案}/新增需求/01-数据同步触发与轮询优化.md (99%) rename docs/7月迭代/{ => 独立方案}/新增需求/02-企业微信审批接入.md (99%) rename docs/7月迭代/{ => 独立方案}/新增需求/03-站内通知详细方案.md (99%) rename docs/7月迭代/{ => 独立方案}/新增需求/04-全局多视角审计方案.md (99%) rename docs/7月迭代/{ => 独立方案}/新增需求/05-代理钱包扫码充值.md (99%) create mode 100644 docs/7月迭代/独立方案/新增需求/06-换货归属与资产换货标识.md create mode 100644 docs/7月迭代/独立方案/新增需求/07-店铺业务员与钱包余额预警.md create mode 100644 docs/7月迭代/独立方案/新增需求/08-代理系列套餐批量授权.md diff --git a/AGENTS.md b/AGENTS.md index 1dfbad6..1c499b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ handlers := &bootstrap.Handlers{ ## 架构演进:MVC 与 DDD 共存 -项目采用**触碰式渐进迁移**,不安排一次性全仓库重构。详细规则见 `docs/7月迭代/DDD规范.md`。 +项目采用**触碰式渐进迁移**,不安排一次性全仓库重构。详细规则见 `docs/7月迭代/独立方案/基础规范/DDD规范.md`。 ### 三条执行通道 diff --git a/docs/7月迭代/00-总览.md b/docs/7月迭代/00-总览.md deleted file mode 100644 index de6763b..0000000 --- a/docs/7月迭代/00-总览.md +++ /dev/null @@ -1,254 +0,0 @@ -# 7月迭代技术方案总览 - -> 状态:待评审 -> 负责人:待指定 -> 评审人:后端、前端、产品、验收负责人、运维待指定 -> 讨论日期:2026-07-11 -> 最后更新:2026-07-14 -> 分支:Iteration/7-11 -> 系统:junhong_cmp_fiber(已上线,渐进迭代) - ---- - -## 一、评审结论 - -当前方案的技术方向没有跑偏:渐进式 DDD、通用审批流、Outbox、异步任务复用和查询侧独立都符合当前系统约束。 - -此前版本缺少流程图、前端方案、发布方案和若干关键业务决策。本轮已经补齐这些评审材料,并确认以下口径: - -- 平台员工不建立信用额度;信用额度只属于代理主钱包。 -- 批量订购的支付方式按整批统一,Excel 不携带支付方式。 -- 角色级导出字段配置属于本期必做能力。 -- Gateway 限速只面向卡号,设备场景先解析当前卡再调用 `cardNo`;本期仅人工设置/取消,不做套餐自动限速。 -- 需求 16“代理分销码与佣金提现”已移出 7 月迭代,后续独立立项。 -- 审批结论与退款、充值等业务处理结果分别建模和展示。 -- 审批详情必须展示业务快照、业务资料、历史意见和审批附件;业务资料与审批附件分开存储和展示。 -- 非代理钱包退款由财务人工处理;代理钱包退款仅回退原扣款代理主钱包。 -- 本次采用停机发布,不保留旧审批接口兼容窗口。 - -本目录按 [技术方案评审规范](../技术方案评审规范.md) 整理。当前范围为 21 项实施需求,需求 16 仅保留移出记录。评审建议按“基础设施 → 资金与审批 → 批量任务 → 展示类需求”分组。 - ---- - -## 二、评审材料导航 - -| 材料 | 作用 | -|------|------| -| [DDD 规范](./DDD规范.md) | 判断复杂写、简单写和 Query 通道,以及旧模块如何渐进迁移 | -| [前端技术方案](./前端技术方案.md) | 跨需求的页面、状态、轮询、权限和停机发布约定 | -| [审批流](./基础设施/审批流.md) | 流程定义、实例、任务、状态机、并发、事件和业务接入 | -| [站内消息](./基础设施/站内消息.md) | 审批和临期提醒的站内通知基础设施 | -| [系统配置](./基础设施/系统配置.md) | C 端支付方式的受控动态配置 | -| 各需求专项文档 | 业务规则、接口、数据改动和专项前端差异 | - ---- - -## 三、系统上下文 - -```mermaid -flowchart LR - Admin[后台管理端] --> API[Junhong API] - Client[C端 H5/公众号] --> API - API --> PG[(PostgreSQL)] - API --> Redis[(Redis)] - API --> Gateway[Gateway] - API --> Outbox[(Outbox)] - Relay[Outbox Relay] --> Outbox - Relay --> Asynq[Asynq] - Asynq --> Worker[Worker] - Worker --> Notification[站内消息] - Worker --> Business[退款/充值/临期等业务处理器] - Worker -. Phase 2 .-> WeCom[企业微信] -``` - -关键边界: - -- API 负责同步校验、事务提交和返回可追踪的业务状态。 -- Worker 负责通知、导出、批量任务和审批后的业务处理。 -- Gateway 是本迭代唯一已确认可以直接接入的外部业务系统。 -- 企业微信相关能力属于 Phase 2,不得阻塞 Phase 1 的站内审批闭环。 - ---- - -## 四、依赖关系 - -```mermaid -flowchart TD - DDD[渐进式 DDD 规范] - Config[系统配置] - Notice[站内消息] - Approval[通用审批流] - Export[统一导出任务] - - DDD --> Approval - Notice --> Approval - Approval --> R18[需求18 多人审批] - Approval --> R20[需求20 退款审批] - Approval --> R21[需求21 充值审批] - Config --> R09[需求09 支付限制] - Notice --> R22[需求22 临期提醒] - Export --> R14[需求14 导出] - Approval --> R14 -``` - ---- - -## 五、阶段划分 - -| 阶段 | 范围 | 说明 | -|------|------|------| -| Phase 1 | 需求 1-22 中除需求16外的本系统和 Gateway 能力 | 必须形成站内闭环、人工可追踪和可回滚的后台流程 | -| Phase 2 | 需求 18 APR-009、需求 22 EXP-003 | 企业微信审批/推送与状态同步,单独评审外部 API、安全和补偿策略 | - -Phase 1 不建设 BPMN 全规范、拖拽流程设计器、任意回退、子流程和复杂规则引擎。 - ---- - -## 六、需求总览 - -### 6.1 简单改动 - -| # | 需求 | 文档 | 实现要点 | -|---|------|------|----------| -| 1 | 行业卡复机允许未实名 | [需求01](./需求01-复机实名规则.md) | 复机规则修正,无新状态 | -| 3 | 店铺列表加联系电话搜索 | [需求03](./需求03-07-11-12-13-简单改动.md#需求03店铺列表搜索新增联系电话) | Query 增加过滤条件 | -| 4 | 退款中资产禁止换货 | [需求04](./需求04-06-退款拦截与最后到期时间.md#需求04退款中禁止换货) | 创建换货前校验活跃退款 | -| 7 | IoT卡/设备加实名筛选 | [需求07](./需求03-07-11-12-13-简单改动.md#需求07iot卡设备管理新增已实名未实名筛选) | 列表筛选和索引评估 | -| 11 | 当前套餐到期高亮 | [需求11](./需求03-07-11-12-13-简单改动.md#需求11资产详情-套餐到期时间字段--15天高亮) | 复用后端日期字段,前端统一颜色规则 | -| 12 | 换货显示修复 | [需求12](./需求03-07-11-12-13-简单改动.md#需求12换货管理显示修复) | 字段和检索口径对齐 | -| 13 | 列表字段新增 | [需求13](./需求03-07-11-12-13-简单改动.md#需求13列表字段新增) | 审批人改为动态摘要 | - -简单改动不要求单独绘制系统图;涉及条件分支时用短流程图或规则表即可。 - -### 6.2 标准方案 - -| # | 需求 | 文档 | 关键评审点 | -|---|------|------|------------| -| 2 | H5流程顺序配置化 | [需求02](./需求02-H5流程配置.md) | 资产视角、配置优先级、C端跳转 | -| 5 | 套餐分配生效条件 | [需求05](./需求05-套餐分配生效条件.md) | 覆盖值到使用记录的快照链 | -| 6 | 资产最后到期时间 | [需求06](./需求04-06-退款拦截与最后到期时间.md#需求06资产详情-所有套餐的最后到期时间) | Query 聚合与排队套餐口径 | -| 8 | 设备批量分配 Excel | [需求08](./需求08-设备批量分配Excel.md) | 异步任务、部分成功、失败明细 | -| 9 | C端支付限制配置化 | [需求09](./需求09-C端支付限制配置化.md) | 前端隐藏与后端强校验一致 | -| 10 | 限速规则 | [需求10](./需求10-限速规则.md) | 手动卡限速、`kbps` 单位和 Gateway 审计 | -| 14 | 导出功能 | [需求14](./需求14-导出功能.md) | 复用 ExportTask、动态字段权限 | -| 15 | 下架套餐允许续费 | [需求15](./需求15-16-18-19-20-21-复杂需求.md#需求15套餐下架后允许续费) | 续费身份和历史使用判断 | -| 22 | 套餐临期提醒 | [需求22](./需求22-套餐临期提醒.md) | 15/7/3 天规则、去重、三端展示 | - -### 6.3 完整方案 - -| # | 需求 | 文档 | 关键评审点 | -|---|------|------|------------| -| 17 | 信用额度 | [需求17](./需求17-信用额度.md) | 钱包不变量、主体模型、角色权限 | -| 18 | 多人审批 | [需求18](./需求15-16-18-19-20-21-复杂需求.md#需求18多人审批apr-001009) | 动态审批人、串行节点、通知 | -| 19 | 批量订购套餐 | [需求19](./需求15-16-18-19-20-21-复杂需求.md#需求19批量订购套餐bpo-001008) | 逐行审计、钱包幂等、部分成功 | -| 20 | 退款审批 | [需求20](./需求15-16-18-19-20-21-复杂需求.md#需求20退款审批) | 审批与实际退款的最终一致性 | -| 21 | 充值审批 | [需求21](./需求15-16-18-19-20-21-复杂需求.md#需求21充值审核流程) | 审批与钱包入账分离、停机切换旧接口 | - -### 6.4 已移出本期 - -| # | 需求 | 状态 | 后续处理 | -|---|------|------|----------| -| 16 | 代理分销码与佣金提现 | 2026-07-14 移出 7 月迭代 | 独立立项,重新评审范围、技术方案和工时 | - ---- - -## 七、跨需求技术决策 - -- 架构迁移按完整用例触碰式进行,禁止一次性重构旧模块。 -- 管理端 API 使用 `/api/admin/*`,C 端 API 使用 `/api/c/v1/*`。 -- 复杂查询进入 Query 模块,不通过聚合根拼装报表或导出。 -- 审批人只支持角色或指定账号,当前系统不引入部门模型。 -- 审批完成事件使用 Outbox + Asynq,通知和业务处理均按至少一次投递设计。 -- 导入、批量购买和导出必须有服务端任务状态,前端轮询只是展示手段。 -- 资金、审批、导出和敏感配置必须记录操作人和审计信息。 -- 信用额度只属于代理主钱包;平台员工是操作主体,不是结算和负债主体。 -- 批量订购支付方式整批统一;线下支付按整批上传凭证,代理钱包按整批选择代理钱包。 -- 角色管理必须提供导出字段配置,导出请求字段与角色授权字段取交集。 -- 限速最终目标始终是卡,Gateway 请求只传 `cardNo`;设备入口必须解析当前绑定卡,本期仅手动设置或取消。 -- 退款和员工线下充值统一通过任务级审批接口处理。 -- 审批状态与实际退款、钱包入账等处理状态分别返回;审批详情返回业务快照、业务资料和审批时间线。 -- 本次停机发布并同时切换 API、Worker 和前端,不建设旧审批接口兼容门面。 - ---- - -## 八、已确认技术决策 - -| 编号 | 已确认结论 | 影响需求 | -|------|------------|----------| -| D-01 | 不实现平台员工信用额度,只保留代理主钱包授信 | 17、19 | -| D-02 | 角色级导出字段配置本期落地,服务端执行字段授权 | 14 | -| D-03 | 批量订购支付方式整批统一,不允许 Excel 行级混合支付 | 19 | -| D-04 | Gateway 限速统一按 `cardNo`;设备套餐取 `tb_device_sim_binding.is_current=true` 对应卡 | 10 | -| D-05 | 审批结论和实际业务处理状态分离 | 20、21 | -| D-06 | 采用停机发布,旧退款审批和线下充值直接入账/驳回接口同步下线 | 20、21 | -| D-07 | 需求16移出本期,不创建相关表、接口、流程和前端页面 | 16 | -| D-08 | 第三方退款本期由财务人工完成;代理钱包仅回退原扣款代理主钱包,客户资产钱包不走自动回退 | 20 | -| D-09 | 设备批量分配代理与套餐系列拆为两个命令和两个前端入口 | 8 | -| D-10 | 当前套餐与排队主套餐按购买时长快照推算预计最后到期时间;临期页面实时查询,定时任务只做通知 | 06、22 | - -前端源码不在当前仓库。接口评审后仍需在前端仓库完成路由、权限指令、上传组件、任务轮询组件和动态表格能力的实现映射,但这不再是业务方案决策项。 - ---- - -## 九、发布与回滚总策略 - -```mermaid -flowchart LR - S[进入维护模式并停止写入] --> M[执行数据库迁移] - M --> D[同时发布 API、Worker 和前端] - D --> C[初始化流程定义和业务绑定] - C --> L[回填存量待审批单] - L --> V[人工验证核心链路] - V --> O[开放系统访问] -``` - -发布原则: - -1. 发布前进入维护模式,停止创建订单、退款、充值和审批操作。 -2. 维护窗口内执行数据库迁移,并同时发布 API、Worker/Relay 和前端静态资源。 -3. 初始化并发布 `refund_approval`、`recharge_approval`,完成业务绑定后再验证。 -4. 使用一次性 Application 命令为存量待审批退款和员工线下充值创建流程实例;历史终态记录保持只读,不伪造审批时间线。 -5. 旧退款业务单审批接口和线下充值 `offline-pay/reject` 在新版本中不再注册,不保留兼容门面。 -6. 人工验证发起审批、存量审批、任务处理、业务回调、通知和查询后再开放访问。 -7. 开放前验证失败可回滚应用版本和可逆迁移;已经形成的审批历史、资金流水、Outbox 和审计日志不得通过清表回滚。 -8. Worker 暂停时保留 Outbox 事件,恢复后继续消费。 - ---- - -## 十、可观测性与人工验收 - -关键日志和查询维度: - -- `request_id`、`event_id`、`task_id`、`process_instance_id`、`biz_type`、`biz_id`。 -- Outbox 待投递数量、失败次数和最早积压时间。 -- 审批业务处理失败、批量任务失败和导出任务失败。 -- 钱包扣款前后余额、版本冲突和业务幂等键。 - -人工验收至少覆盖: - -- 正常路径、无权限、重复点击、并发审批、退回重提。 -- Worker 暂停后恢复,事件和任务可继续处理。 -- 维护模式下不能产生新写入;开放前确认 API、Worker 和前端版本一致。 -- 所有仍需审批的存量退款和员工线下充值均已生成流程实例,历史终态记录只读可查。 -- 旧退款审批和线下充值 `offline-pay/reject` 路由不可访问。 -- PostgreSQL 中业务状态、审批状态、任务状态、操作日志和资金流水一致。 - ---- - -## 十一、执行顺序建议 - -```text -评审批次 A:DDD 边界、系统配置、站内消息、审批流 -评审批次 B:退款、充值、信用额度、批量订购、导出权限 -评审批次 C:H5 流程、限速、临期提醒、批量分配 -评审批次 D:简单字段、筛选和显示修复 - -实施顺序: -1. 增量迁移、TxManager/Outbox、系统配置和站内消息 -2. 审批定义、实例、任务和前端待办 -3. 退款和充值接入通用审批 -4. 批量任务、代理信用额度和导出权限 -5. 其他中小需求 -``` - -每个评审批次独立形成结论,未通过的复杂需求不阻塞已经闭环的简单需求实施。 diff --git a/docs/7月迭代/7月迭代前后端任务工时表.md b/docs/7月迭代/7月迭代前后端任务工时表.md new file mode 100644 index 0000000..c33f33e --- /dev/null +++ b/docs/7月迭代/7月迭代前后端任务工时表.md @@ -0,0 +1,57 @@ +# 7月迭代前后端任务工时表 + +> 估算口径:实现工作全权交由 AI 编码代理执行,前后端可并行推进。 +> 工时单位:人时,1 人日按 8 小时计算。 +> 包含:代码实现、迁移、接口调整、页面交互、联调修复、手工接口验证和发布准备。 +> 不包含:等待企微/支付配置、产品临时改需求、外部接口申请审核和生产历史脏数据人工处理时间。 +> 前提:提供后端仓库、前端仓库、可用数据库、Gateway、企微和支付联调配置;需求范围以标准评审稿为准。 +> +> 禅道逐条录入时,使用[7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)中的拆分工时;本表用于核对总量,逐条录入稿用于填写每条研发需求的预计工时。 + +|需求|前端任务|后端任务|前端任务工时|后端任务工时|相关风险可能导致的工时延长| +|---|---|---|---:|---:|---| +|公共基础|补齐公共状态、错误展示和异步任务交互组件|增量迁移、Outbox、DDD 目录和公共幂等能力|1~2小时|4~5小时|现有迁移冲突、Outbox 基础与文档不一致会增加 2~4 小时| +|需求01:复机实名规则|调整复机失败提示,不再按行业卡写死文案|按 `realname_link_type` 判断实名要求,删除行业卡统一放行逻辑|0.5~1小时|1小时|运营商历史配置错误会增加数据修复时间| +|需求02:H5 流程配置|按有效实名策略渲染先实名/先购买流程,补批量配置交互|统一运营商实名能力和资产顺序策略,补批量更新接口|2~3小时|3~4小时|前端现有 H5 状态机分散、冲突数据较多会增加 2~4 小时| +|需求03:联系电话搜索|店铺列表增加联系电话搜索框|店铺 Query 增加 11 位联系电话精确过滤|0.5~1小时|0.5~1小时|现有列表参数命名不统一会增加约 1 小时| +|需求04:退款中禁止换货|直接展示后端拦截原因|创建换货前批量校验活跃退款状态|0.5~1小时|1~1.5小时|历史退款状态语义不一致会增加 1~2 小时| +|需求05:套餐分配生效条件|增加默认/购买生效/实名生效三选一及恢复默认|增加代理覆盖值和使用记录快照,统一生效条件计算|1.5~2.5小时|2.5~3.5小时|旧使用记录缺快照、套餐周期数据异常会增加 2~4 小时| +|需求06/11:预计最终到期时间|资产层只展示预计最终到期时间、推算状态和统一高亮|Query 按当前及排队主套餐时长快照推算,供详情、临期和导出复用|1.5~2.5小时|3~4小时|旧套餐缺购买时长快照、等待实名激活无法确定起点会增加兼容处理时间| +|需求07:实名筛选|卡和设备列表增加实名状态筛选|卡按自身状态,设备按有效绑定卡维护/查询实名状态|1~2小时|2~3小时|设备多当前卡、绑定历史异常会增加 2~3 小时| +|需求08:设备批量分配|拆分“分配代理”和“分配套餐系列”两个入口,展示任务结果|复用 Excel/Asynq,新增两个独立批量命令和失败明细|2~3小时|3~4小时|前端无现成上传任务组件或生产文件格式不统一会增加 2~4 小时| +|需求09:C 端支付限制|支付页按接口返回方式展示,后台增加配置控件|实现受控系统配置、缓存和订单支付二次校验|1.5~2.5小时|2~3小时|多端支付入口未复用同一接口会增加逐端排查时间| +|需求10:Gateway 限速|卡/设备详情增加设置和取消入口|统一 `speed_kbps` 接口,设备解析当前卡后调用 Gateway|1~2小时|2~3小时|Gateway 取消参数不明确或联调不稳定会增加 2~4 小时| +|需求11:当前套餐到期高亮|并入需求06/22,不单独建设第二个资产汇总字段|并入预计最终到期 Query|0小时|0小时|无独立工时| +|需求12:换货显示与搜索|拆分新旧资产搜索并修正字段展示|修正新建换货快照,增加新旧资产关键词过滤|1~2小时|1~2小时|历史数据不回填导致验收口径混淆会增加沟通时间| +|需求13:列表字段新增|退款、充值、换货列表增加提交人和企微审批摘要|补提交人快照,批量查询企微状态和审批人摘要|1~2小时|2~3小时|旧记录缺提交人或企微实例会增加兼容展示时间| +|需求14:导出与字段权限|角色页配置导出字段,各列表接入统一导出入口|扩展 DataSource 场景、角色字段权限和字段快照|3~4小时|4~6小时|导出字段口径变更、现有数据源 N+1 会增加 3~5 小时| +|需求15:下架套餐续费|当前套餐旁增加续费按钮,复用购买流程|统一可售策略,校验资产所有人及历史使用资格|1.5~2.5小时|2~3小时|C 端多个购买入口绕过统一策略会增加排查时间| +|需求16:代理分销码与佣金提现|不实施|不实施|0小时|0小时|需求重新加入时必须单独评估,不计入本表| +|需求17:代理主钱包信用额度|角色页配置新建店铺默认额度,店铺资金页单独调额并明确不追溯|增加角色默认模板、钱包实际额度、约束、扣款不变量和资金审计|2.5~3.5小时|4~5小时|现有负余额、CHECK 约束或创建店铺事务分散会增加 2~5 小时| +|需求18:多人审批映射|不建设本地待办,只展示企微审批摘要|退款/充值场景映射到企微模板,复用企微状态同步|1~1.5小时|1~1.5小时|企微模板审批人配置不完整会阻塞联调| +|需求19:批量订购套餐|批量上传、统一支付方式、任务进度和失败明细|任务/明细表、逐行幂等下单、钱包或线下支付处理|3~4小时|4~6小时|订单服务复用困难、钱包并发冲突会增加 3~6 小时| +|需求20:退款审批|创建和详情展示企微状态、意见附件及处理结果|退款接入企微、代理钱包回溯、人工退款终态和撤销异常|2.5~3.5小时|4~5小时|存量退款买家类型混乱、通过后撤销场景会增加 3~5 小时| +|需求21:平台员工线下充值|创建后展示企微审批状态,删除本地确认/驳回按钮|企微通过后幂等增加代理主钱包,旧接口下线|2~3小时|3~4小时|旧充值状态和钱包流水不一致会增加 2~4 小时| +|需求22:套餐临期提醒|资产列表、详情、临期页、代理首页和 C 端展示;仅临期页将3天内置顶|复用预计最终到期 Query、15/7/3 天站内通知防重和导出接入|3~4.5小时|4~6小时|时区、排队套餐快照和多接收人数据异常会增加 2~4 小时| +|禅道#98/#86:换货归属与资产标识|换货确认展示继承店铺,资产详情展示前代/后代标签和跳转|新资产继承旧资产店铺、保留旧资产归属、补换货链 Query 和索引|1~2小时|2~3小时|换货迁移涉及的租户标签或资产分配记录不完整会增加 2~4 小时| +|禅道#96/#97:业务员与余额提醒|店铺业务员选择/筛选、资金不足100元状态和通知跳转|增加店铺业务员字段,钱包跨越固定100元阈值时向主账号和业务员发站内通知|1~2小时|2~3小时|店铺主账号异常、业务员停用或钱包写入口未统一会增加 2~4 小时| +|禅道#43:系列套餐批量授权|套餐表格多选、已授权置灰、三类价格展示|复用现有批量写接口,新增套餐候选 Query 和重复授权幂等|1~2小时|0.5~1小时|前端旧页面结构不支持表格多选或成本价权限不完整会增加 1~3 小时| +|新增01:数据同步触发与轮询优化|展示轮询活跃状态和审计跳转|卡状态领域收口、活跃调频、0/3/5 任务、回调防腐层|2~3小时|7~9小时|旧同步入口数量超出预期、Gateway 超频和 ICCID 重复会增加 4~8 小时| +|新增02:企业微信审批接入|企微配置、模板映射、扫码绑定、审批运行和业务详情|Token、模板版本、上传提交、回调解密、轮询补偿和异常恢复|5~7小时|8~10小时|企微可信域名、模板 ID 变化、回调网络和真实账号权限会增加 4~8 小时| +|新增03:站内通知|顶部铃铛、通知抽屉、通知中心和受控跳转|通知表、模板注册、接收人解析、未读/已读 API|3~4小时|3~4小时|前端多端布局差异、接收人关系不完整会增加 2~3 小时| +|新增04:全局多视角审计|审计中心七个视角、详情抽屉和敏感字段展示|Audit Event、Integration Log、旧写入口切换、历史投影和脱敏|6~8小时|8~11小时|旧审计调用点遗漏、查询性能和历史字段差异会增加 5~10 小时| +|新增05:代理钱包扫码充值|支付方式选择、二维码、倒计时和支付状态轮询|微信 Native、支付宝 PreCreate、支付单分发和钱包入账恢复|3~4小时|5~7小时|支付渠道配置、真实回调、微信 v2/富友差异会增加 3~6 小时| +|全链路联调与发布|联调全部页面状态、修复交互、准备发布版本|数据核对、存量回填、旧入口清理、停机发布和恢复检查|3~4小时|4~5小时|生产数据与预期差异、外部回调不可达会增加 4~8 小时| +|**合计**|**前端约 59~89 小时**|**后端约 93~128 小时**|**约 8~12 人日**|**约 12~16 人日**|**1 后端 + 1 前端并行时,正常目标 12~15 个工作日;历史数据或换货迁移超预期时可能到 16 个工作日**| + +## 并行交付口径 + +| 项目 | 估算 | +|------|------| +| 前端投入 | 59~89 小时,约 8~12 人日 | +| 后端投入 | 93~128 小时,约 12~16 人日 | +| 合计投入 | 152~217 小时,约 19~28 人日 | +| 1 后端 + 1 前端并行周期 | 正常 12~15 个工作日,风险上限 16 个工作日 | +| 建议对外排期基线 | 14 个工作日 | + +达到第 16 个工作日的主要条件:前端旧授权页面无法复用、换货归属需要补齐多张租户标签表、生产历史数据需要大规模人工修复、企微或支付联调配置不可用。 diff --git a/docs/7月迭代/7月迭代完整技术方案-评审稿.md b/docs/7月迭代/7月迭代完整技术方案-评审稿.md deleted file mode 100644 index 1dbb3bc..0000000 --- a/docs/7月迭代/7月迭代完整技术方案-评审稿.md +++ /dev/null @@ -1,6457 +0,0 @@ -# 7月迭代完整技术方案(单文件归档稿) - -> 状态:待评审 -> 最后更新:2026-07-14 -> 代码分支:`Iteration/7-11` -> 适用系统:`junhong_cmp_fiber` 及配套后台、代理端、C 端前端 -> 文档用途:完整汇编归档;正式评审优先使用《7月迭代技术方案-标准评审稿》 - ---- - -## 一、范围说明 - -7 月迭代原编号为需求 01~22,其中需求 16“代理分销码与佣金提现”已于 2026-07-14 整体移出本期,后续独立立项。本期实际实施 21 项需求。 - -需求 16 移出后,本期不建设分销码、H5 代理申请、代理申请审批、自动开店、发展人关系和佣金提现材料;通用审批流只接入退款和平台员工线下充值。 - -## 二、已确认关键决策 - -1. 采用触碰式渐进 DDD,不一次性重构旧模块。 -2. 审批人只支持角色或指定账号,不引入部门模型。 -3. 审批详情固化业务快照,并分开展示业务资料、审批意见和审批附件。 -4. 平台员工不建立信用额度;信用额度只属于代理主钱包。 -5. 批量订购支付方式整批统一;Excel 不包含支付方式。 -6. 设备批量分配代理与套餐系列拆成两个独立命令。 -7. Gateway 仅提供手动卡限速,统一按 `cardNo` 和 `kbps`,不做套餐自动限速。 -8. 代理钱包退款只回退原扣款代理主钱包;其他支付方式由财务人工退款,本期不接第三方自动退款。 -9. 排队主套餐按购买时长快照推算预计最后到期时间;临期页面实时查询,定时任务只发送通知。 -10. 模板由前端静态资源提供,后端不提供模板下载接口。 -11. 本次采用停机发布,不保留旧退款和线下充值审批接口兼容窗口。 - -## 三、文档结构 - -本文按以下顺序完整内嵌源文档:总览、DDD、系统配置、站内消息、审批流、前端方案、需求专项方案和原始业务需求。需求 16 的原始内容仅作为移出记录保存,不属于本期实施契约。 - -## 四、评审结论记录 - -| 评审项 | 结论 | 调整项 | 负责人 | -|--------|------|--------|--------| -| 范围与需求16移出 | 待评审 | | | -| 渐进式 DDD | 待评审 | | | -| 通用审批流 | 待评审 | | | -| 资金和批量任务 | 待评审 | | | -| 前端、迁移和发布 | 待评审 | | | - ---- - - - - ---- - -> 汇编来源:`docs/7月迭代/00-总览.md` - -## 7月迭代技术方案总览 - -> 状态:待评审 -> 负责人:待指定 -> 评审人:后端、前端、产品、验收负责人、运维待指定 -> 讨论日期:2026-07-11 -> 最后更新:2026-07-14 -> 分支:Iteration/7-11 -> 系统:junhong_cmp_fiber(已上线,渐进迭代) - ---- - -### 一、评审结论 - -当前方案的技术方向没有跑偏:渐进式 DDD、通用审批流、Outbox、异步任务复用和查询侧独立都符合当前系统约束。 - -此前版本缺少流程图、前端方案、发布方案和若干关键业务决策。本轮已经补齐这些评审材料,并确认以下口径: - -- 平台员工不建立信用额度;信用额度只属于代理主钱包。 -- 批量订购的支付方式按整批统一,Excel 不携带支付方式。 -- 角色级导出字段配置属于本期必做能力。 -- Gateway 限速只面向卡号,设备场景先解析当前卡再调用 `cardNo`;本期仅人工设置/取消,不做套餐自动限速。 -- 需求 16“代理分销码与佣金提现”已移出 7 月迭代,后续独立立项。 -- 审批结论与退款、充值等业务处理结果分别建模和展示。 -- 审批详情必须展示业务快照、业务资料、历史意见和审批附件;业务资料与审批附件分开存储和展示。 -- 非代理钱包退款由财务人工处理;代理钱包退款仅回退原扣款代理主钱包。 -- 本次采用停机发布,不保留旧审批接口兼容窗口。 - -本目录按 技术方案评审规范 整理。当前范围为 21 项实施需求,需求 16 仅保留移出记录。评审建议按“基础设施 → 资金与审批 → 批量任务 → 展示类需求”分组。 - ---- - -### 二、评审材料导航 - -| 材料 | 作用 | -|------|------| -| DDD 规范 | 判断复杂写、简单写和 Query 通道,以及旧模块如何渐进迁移 | -| 前端技术方案 | 跨需求的页面、状态、轮询、权限和停机发布约定 | -| 审批流 | 流程定义、实例、任务、状态机、并发、事件和业务接入 | -| 站内消息 | 审批和临期提醒的站内通知基础设施 | -| 系统配置 | C 端支付方式的受控动态配置 | -| 各需求专项文档 | 业务规则、接口、数据改动和专项前端差异 | - ---- - -### 三、系统上下文 - -```mermaid -flowchart LR - Admin[后台管理端] --> API[Junhong API] - Client[C端 H5/公众号] --> API - API --> PG[(PostgreSQL)] - API --> Redis[(Redis)] - API --> Gateway[Gateway] - API --> Outbox[(Outbox)] - Relay[Outbox Relay] --> Outbox - Relay --> Asynq[Asynq] - Asynq --> Worker[Worker] - Worker --> Notification[站内消息] - Worker --> Business[退款/充值/临期等业务处理器] - Worker -. Phase 2 .-> WeCom[企业微信] -``` - -关键边界: - -- API 负责同步校验、事务提交和返回可追踪的业务状态。 -- Worker 负责通知、导出、批量任务和审批后的业务处理。 -- Gateway 是本迭代唯一已确认可以直接接入的外部业务系统。 -- 企业微信相关能力属于 Phase 2,不得阻塞 Phase 1 的站内审批闭环。 - ---- - -### 四、依赖关系 - -```mermaid -flowchart TD - DDD[渐进式 DDD 规范] - Config[系统配置] - Notice[站内消息] - Approval[通用审批流] - Export[统一导出任务] - - DDD --> Approval - Notice --> Approval - Approval --> R18[需求18 多人审批] - Approval --> R20[需求20 退款审批] - Approval --> R21[需求21 充值审批] - Config --> R09[需求09 支付限制] - Notice --> R22[需求22 临期提醒] - Export --> R14[需求14 导出] - Approval --> R14 -``` - ---- - -### 五、阶段划分 - -| 阶段 | 范围 | 说明 | -|------|------|------| -| Phase 1 | 需求 1-22 中除需求16外的本系统和 Gateway 能力 | 必须形成站内闭环、人工可追踪和可回滚的后台流程 | -| Phase 2 | 需求 18 APR-009、需求 22 EXP-003 | 企业微信审批/推送与状态同步,单独评审外部 API、安全和补偿策略 | - -Phase 1 不建设 BPMN 全规范、拖拽流程设计器、任意回退、子流程和复杂规则引擎。 - ---- - -### 六、需求总览 - -#### 6.1 简单改动 - -| # | 需求 | 文档 | 实现要点 | -|---|------|------|----------| -| 1 | 行业卡复机允许未实名 | 需求01 | 复机规则修正,无新状态 | -| 3 | 店铺列表加联系电话搜索 | 需求03 | Query 增加过滤条件 | -| 4 | 退款中资产禁止换货 | 需求04 | 创建换货前校验活跃退款 | -| 7 | IoT卡/设备加实名筛选 | 需求07 | 列表筛选和索引评估 | -| 11 | 当前套餐到期高亮 | 需求11 | 复用后端日期字段,前端统一颜色规则 | -| 12 | 换货显示修复 | 需求12 | 字段和检索口径对齐 | -| 13 | 列表字段新增 | 需求13 | 审批人改为动态摘要 | - -简单改动不要求单独绘制系统图;涉及条件分支时用短流程图或规则表即可。 - -#### 6.2 标准方案 - -| # | 需求 | 文档 | 关键评审点 | -|---|------|------|------------| -| 2 | H5流程顺序配置化 | 需求02 | 资产视角、配置优先级、C端跳转 | -| 5 | 套餐分配生效条件 | 需求05 | 覆盖值到使用记录的快照链 | -| 6 | 资产最后到期时间 | 需求06 | Query 聚合与排队套餐口径 | -| 8 | 设备批量分配 Excel | 需求08 | 异步任务、部分成功、失败明细 | -| 9 | C端支付限制配置化 | 需求09 | 前端隐藏与后端强校验一致 | -| 10 | 限速规则 | 需求10 | 手动卡限速、`kbps` 单位和 Gateway 审计 | -| 14 | 导出功能 | 需求14 | 复用 ExportTask、动态字段权限 | -| 15 | 下架套餐允许续费 | 需求15 | 续费身份和历史使用判断 | -| 22 | 套餐临期提醒 | 需求22 | 15/7/3 天规则、去重、三端展示 | - -#### 6.3 完整方案 - -| # | 需求 | 文档 | 关键评审点 | -|---|------|------|------------| -| 17 | 信用额度 | 需求17 | 钱包不变量、主体模型、角色权限 | -| 18 | 多人审批 | 需求18 | 动态审批人、串行节点、通知 | -| 19 | 批量订购套餐 | 需求19 | 逐行审计、钱包幂等、部分成功 | -| 20 | 退款审批 | 需求20 | 审批与实际退款的最终一致性 | -| 21 | 充值审批 | 需求21 | 审批与钱包入账分离、停机切换旧接口 | - -#### 6.4 已移出本期 - -| # | 需求 | 状态 | 后续处理 | -|---|------|------|----------| -| 16 | 代理分销码与佣金提现 | 2026-07-14 移出 7 月迭代 | 独立立项,重新评审范围、技术方案和工时 | - ---- - -### 七、跨需求技术决策 - -- 架构迁移按完整用例触碰式进行,禁止一次性重构旧模块。 -- 管理端 API 使用 `/api/admin/*`,C 端 API 使用 `/api/c/v1/*`。 -- 复杂查询进入 Query 模块,不通过聚合根拼装报表或导出。 -- 审批人只支持角色或指定账号,当前系统不引入部门模型。 -- 审批完成事件使用 Outbox + Asynq,通知和业务处理均按至少一次投递设计。 -- 导入、批量购买和导出必须有服务端任务状态,前端轮询只是展示手段。 -- 资金、审批、导出和敏感配置必须记录操作人和审计信息。 -- 信用额度只属于代理主钱包;平台员工是操作主体,不是结算和负债主体。 -- 批量订购支付方式整批统一;线下支付按整批上传凭证,代理钱包按整批选择代理钱包。 -- 角色管理必须提供导出字段配置,导出请求字段与角色授权字段取交集。 -- 限速最终目标始终是卡,Gateway 请求只传 `cardNo`;设备入口必须解析当前绑定卡,本期仅手动设置或取消。 -- 退款和员工线下充值统一通过任务级审批接口处理。 -- 审批状态与实际退款、钱包入账等处理状态分别返回;审批详情返回业务快照、业务资料和审批时间线。 -- 本次停机发布并同时切换 API、Worker 和前端,不建设旧审批接口兼容门面。 - ---- - -### 八、已确认技术决策 - -| 编号 | 已确认结论 | 影响需求 | -|------|------------|----------| -| D-01 | 不实现平台员工信用额度,只保留代理主钱包授信 | 17、19 | -| D-02 | 角色级导出字段配置本期落地,服务端执行字段授权 | 14 | -| D-03 | 批量订购支付方式整批统一,不允许 Excel 行级混合支付 | 19 | -| D-04 | Gateway 限速统一按 `cardNo`;设备套餐取 `tb_device_sim_binding.is_current=true` 对应卡 | 10 | -| D-05 | 审批结论和实际业务处理状态分离 | 20、21 | -| D-06 | 采用停机发布,旧退款审批和线下充值直接入账/驳回接口同步下线 | 20、21 | -| D-07 | 需求16移出本期,不创建相关表、接口、流程和前端页面 | 16 | -| D-08 | 第三方退款本期由财务人工完成;代理钱包仅回退原扣款代理主钱包,客户资产钱包不走自动回退 | 20 | -| D-09 | 设备批量分配代理与套餐系列拆为两个命令和两个前端入口 | 8 | -| D-10 | 当前套餐与排队主套餐按购买时长快照推算预计最后到期时间;临期页面实时查询,定时任务只做通知 | 06、22 | - -前端源码不在当前仓库。接口评审后仍需在前端仓库完成路由、权限指令、上传组件、任务轮询组件和动态表格能力的实现映射,但这不再是业务方案决策项。 - ---- - -### 九、发布与回滚总策略 - -```mermaid -flowchart LR - S[进入维护模式并停止写入] --> M[执行数据库迁移] - M --> D[同时发布 API、Worker 和前端] - D --> C[初始化流程定义和业务绑定] - C --> L[回填存量待审批单] - L --> V[人工验证核心链路] - V --> O[开放系统访问] -``` - -发布原则: - -1. 发布前进入维护模式,停止创建订单、退款、充值和审批操作。 -2. 维护窗口内执行数据库迁移,并同时发布 API、Worker/Relay 和前端静态资源。 -3. 初始化并发布 `refund_approval`、`recharge_approval`,完成业务绑定后再验证。 -4. 使用一次性 Application 命令为存量待审批退款和员工线下充值创建流程实例;历史终态记录保持只读,不伪造审批时间线。 -5. 旧退款业务单审批接口和线下充值 `offline-pay/reject` 在新版本中不再注册,不保留兼容门面。 -6. 人工验证发起审批、存量审批、任务处理、业务回调、通知和查询后再开放访问。 -7. 开放前验证失败可回滚应用版本和可逆迁移;已经形成的审批历史、资金流水、Outbox 和审计日志不得通过清表回滚。 -8. Worker 暂停时保留 Outbox 事件,恢复后继续消费。 - ---- - -### 十、可观测性与人工验收 - -关键日志和查询维度: - -- `request_id`、`event_id`、`task_id`、`process_instance_id`、`biz_type`、`biz_id`。 -- Outbox 待投递数量、失败次数和最早积压时间。 -- 审批业务处理失败、批量任务失败和导出任务失败。 -- 钱包扣款前后余额、版本冲突和业务幂等键。 - -人工验收至少覆盖: - -- 正常路径、无权限、重复点击、并发审批、退回重提。 -- Worker 暂停后恢复,事件和任务可继续处理。 -- 维护模式下不能产生新写入;开放前确认 API、Worker 和前端版本一致。 -- 所有仍需审批的存量退款和员工线下充值均已生成流程实例,历史终态记录只读可查。 -- 旧退款审批和线下充值 `offline-pay/reject` 路由不可访问。 -- PostgreSQL 中业务状态、审批状态、任务状态、操作日志和资金流水一致。 - ---- - -### 十一、执行顺序建议 - -```text -评审批次 A:DDD 边界、系统配置、站内消息、审批流 -评审批次 B:退款、充值、信用额度、批量订购、导出权限 -评审批次 C:H5 流程、限速、临期提醒、批量分配 -评审批次 D:简单字段、筛选和显示修复 - -实施顺序: -1. 增量迁移、TxManager/Outbox、系统配置和站内消息 -2. 审批定义、实例、任务和前端待办 -3. 退款和充值接入通用审批 -4. 批量任务、代理信用额度和导出权限 -5. 其他中小需求 -``` - -每个评审批次独立形成结论,未通过的复杂需求不阻塞已经闭环的简单需求实施。 - - ---- - -> 汇编来源:`docs/7月迭代/DDD规范.md` - -## 项目 DDD 设计规范 - -> 状态:待评审 -> 务实型 DDD——不是学院派,不强制 Event Sourcing,不追求完美,追求可维护、可扩展、AI 辅助友好。 -> 基准文档:`docs/改造方向.md`(绞杀者模式) - ---- - -### 一、为什么用 DDD / 什么不是 DDD - -#### 现状问题 - -- `order/service.go` 3031 行:订单创建、支付、佣金计算、代购、库存扣减全混一起 -- Model 只是 GORM 映射,没有业务行为(贫血模型) -- 新增业务联动必须修改原有 Service,牵一发动全身 - -#### 目标 - -不是重写,是**渐进替换**: - -1. **复杂写功能**:进入 Application + Domain,不继续堆入旧 Service -2. **简单写功能**:使用 Application 事务脚本,不强行创建聚合 -3. **读取功能**:进入 Query 读取模型,不通过聚合根 -4. **旧功能迁移**:只在需求触碰时迁移一个完整用例,不做模块级重写 -5. **AI 辅助**:结构清晰,AI 生成代码时自动落入正确位置 - ---- - -### 二、什么时候进入 DDD - -DDD 是解决复杂业务边界的手段,不是所有功能的默认目录模板。开始设计前,先判断该需求属于“局部数据操作”还是“独立业务能力”。 - -#### 2.1 优先使用 DDD 的场景 - -当前需求正在修改的用例满足以下任一条件时,应优先建立或扩展领域模块: - -1. 有明确生命周期、状态机、状态转换限制或并发不变量。 -2. 一个操作包含多个业务规则、跨模块协作、事务边界或可靠事件。 -3. 同一业务能力会被多个业务场景复用,需要稳定的领域接口。 -4. 需要策略扩展、规则配置、版本快照、审计追溯或幂等处理。 -5. 继续向旧 Service 增加分支,会导致职责继续膨胀或修改相互影响。 - -审批流、钱包额度、订单支付、佣金结算等属于典型 DDD 场景。 - -#### 2.2 通常不迁移的场景 - -以下需求通常保持原有 `Handler → Service → Store → Model`: - -1. 单表 CRUD、字段增删、简单列表筛选和格式转换。 -2. 局部缺陷修复,且不改变业务边界或核心规则。 -3. 没有独立领域语言、生命周期或跨模块不变量。 -4. 引入聚合、仓储接口和领域事件后只有样板代码,没有业务收益。 - -#### 2.3 触碰式迁移 - -- 默认不迁移当前需求未触碰的旧代码,禁止借功能修改之名扩大为模块级重构。 -- 迁移单位是**完整用例**,不是整个模块、文件或数据表。 -- 简单字段、筛选、格式转换和局部修复默认沿用旧结构。 -- 触碰复杂写逻辑时,只迁移完成当前需求所需的最小完整业务边界。 -- 一旦迁移某个用例,其状态规则和业务不变量必须完整收口,禁止一半留在旧 Service、一半进入 Domain。 -- 旧 Service 可以暂时作为**内部代码迁移门面**调用新 UseCase;调用方迁完后再删除。该规则不代表必须保留旧 HTTP 接口,七月迭代审批相关接口按停机方案直接切换。 -- 新旧模块通过 Application 接口、领域事件或防腐层交互,禁止绕过边界直接修改聚合数据。 - -#### 2.4 查询侧规则 - -复杂查询不通过聚合根,统一采用轻量 CQRS 读取模型: - -```text -Handler → Query → GORM/DTO -``` - -- 列表、详情、联表、统计、报表和导出属于 Query。 -- Query 可以直接使用 GORM、CTE、子查询和批量查询,并负责读取权限与分页。 -- Query 返回专用 DTO/Projection,禁止返回后用于业务写入。 -- 只有查询结果参与写操作判定时,Domain 必须重新校验,不能信任读取快照。 -- 当前需求未触碰的旧查询不迁移;复杂度明显增加时,只迁移该查询到 `internal/query/`。 -- Aggregate Repository 只负责加载和保存聚合,不承载多表列表、报表或导出查询。 - ---- - -### 三、目录结构 - -``` -internal/ -├── domain/ ← 领域层(不依赖 Fiber/Redis/GORM) -│ ├── wallet/ -│ │ ├── wallet.go ← 聚合根(含业务方法) -│ │ ├── events.go ← 领域事件定义 -│ │ ├── repository.go ← 仓储接口(interface) -│ │ └── vo.go ← 值对象(Money, CreditLimit) -│ ├── approval/ ← 新:审批流领域 -│ ├── notification/ ← 新:通知领域 -│ ├── distribution/ ← 后续预留:需求16独立立项后再创建 -│ └── package/ ← 套餐领域(迁移中) -│ -├── application/ ← 应用层(用例,只做编排,不含业务判断) -│ ├── wallet/ -│ │ ├── debit_wallet.go ← 一个文件 = 一个用例 -│ │ ├── credit_wallet.go -│ │ └── grant_credit.go ← 新:授信 -│ ├── approval/ -│ │ ├── submit_approval.go -│ │ ├── advance_step.go -│ │ └── reject_approval.go -│ └── notification/ -│ └── send_notification.go -│ -├── query/ ← 读取侧(可直接使用 GORM,返回 DTO/Projection) -│ ├── order/ -│ ├── refund/ -│ ├── approval/ -│ ├── dashboard/ -│ └── report/ -│ -├── infrastructure/ ← 基础设施层(实现 domain 里的 interface) -│ ├── persistence/ ← GORM Repository,可委托现有 Store -│ ├── messaging/ ← Outbox Relay、Asynq 发布与消费 -│ └── adapter/ ← 外部系统和旧模块防腐层 -│ -├── handler/ ← 原有 handler(保持不动) -├── service/ ← 原有 service(保持不动,新模块不在这里) -└── store/ ← 原有 store(保持不动) -``` - -**过渡期规则**: -- 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx` -- 复杂写用例:`internal/domain/xxx` + `internal/application/xxx` -- 简单写用例:`internal/application/xxx` -- 读取用例:`internal/query/xxx` -- Handler 按用例调用 Application、Query 或尚未迁移的旧 Service -- 全新领域优先使用纯领域对象;迁移旧模块时可临时复用现有 GORM Model,但不得让 Fiber、Redis 或 GORM 查询进入业务方法 - ---- - -### 四、领域模块划分 - -| 领域 | 聚合根 | 核心业务规则 | 状态 | -|------|--------|------------|------| -| **Asset(资产)** | `IotCard`, `Device` | 停复机条件、实名策略 | 迁移中 | -| **Order(订单)** | `Order` | 支付、退款、佣金触发 | 迁移中 | -| **Package(套餐)** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 | -| **Wallet(钱包)** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** | -| **Shop(代理)** | `Shop` | 层级关系、信用策略 | 部分迁移 | -| **Approval(审批)** | `ProcessDefinition`, `ProcessInstance` | 流程版本、审批人解析、状态推进、驳回 | **全新 DDD** | -| **Notification(通知)** | `Notification` | 分发、已读状态 | **全新 DDD** | -| **Distribution(分销)** | `DistributionRelation` | 发展人体系、二维码注册 | 后续独立立项,本期不创建 | - ---- - -### 五、聚合根设计原则(富模型) - -#### 5.1 核心原则 - -业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。 - -```go -// ❌ 贫血模型(禁止)——所有判断在 Service 里 -func (s *WalletService) Debit(ctx context.Context, walletID uint, amount int64) error { - wallet, _ := s.store.Get(ctx, walletID) - if wallet.Balance-wallet.FrozenBalance < amount { - return errors.New(errors.CodeInsufficientBalance) - } - wallet.Balance -= amount - s.store.Update(ctx, wallet) -} - -// ✅ 富模型(推荐)——判断逻辑在聚合根里 -func (w *AgentWallet) Debit(amount Money) error { - available := w.Balance - w.FrozenBalance + w.CreditLimit // 含信用额度 - if available < amount.Cents() { - return ErrInsufficientBalance - } - w.Balance -= amount.Cents() - w.recordEvent(WalletDebitedEvent{Amount: amount, BalanceAfter: w.Balance}) - return nil -} - -// Application 层只做编排 -func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error { - wallet, err := uc.repo.GetByID(ctx, cmd.WalletID) - if err != nil { return err } - if err := wallet.Debit(cmd.Amount); err != nil { return err } - // 完整事务和 Outbox 写入方式见“应用服务(用例)”章节 - return uc.repo.Save(ctx, wallet) -} -``` - -#### 5.2 聚合根必须包含 - -```go -type AggregateRoot struct { - // 业务字段... - - // 未发布领域事件,由 Application 在事务内写入 Outbox - domainEvents []DomainEvent -} - -// PopEvents 获取并清空领域事件 -func (a *AggregateRoot) PopEvents() []DomainEvent { - events := a.domainEvents - a.domainEvents = nil - return events -} - -func (a *AggregateRoot) recordEvent(e DomainEvent) { - a.domainEvents = append(a.domainEvents, e) -} -``` - -#### 5.3 与现有 GORM 的共存 - -迁移旧模块时,聚合根可以临时包装现有 GORM Model,减少一次性重构成本: - -```go -// internal/domain/wallet/wallet.go -// AgentWallet 钱包聚合根 -// 直接复用并扩展现有 model.AgentWallet -type AgentWallet struct { - model.AgentWallet // 迁移期复用持久化字段 - domainEvents []DomainEvent -} - -// Debit 从钱包扣款(含信用额度) -func (w *AgentWallet) Debit(amount Money) error { - available := w.Balance - w.FrozenBalance + w.CreditLimit - if available < amount.Cents() { - return ErrInsufficientBalance - } - w.Balance -= amount.Cents() - w.recordEvent(WalletDebitedEvent{...}) - return nil -} -``` - -这只是迁移期折中,不是新领域的默认形式。审批流等全新领域应优先使用纯领域对象,由 Infrastructure 负责领域对象与 GORM Model 的转换。 - ---- - -### 六、值对象设计 - -值对象:没有 ID,靠值来判断相等,不可变。 - -```go -// internal/domain/wallet/vo.go - -// Money 金额值对象(分为单位,防止浮点数精度问题) -type Money struct { - cents int64 -} - -func NewMoney(cents int64) (Money, error) { - if cents < 0 { - return Money{}, ErrNegativeMoney - } - return Money{cents: cents}, nil -} - -func (m Money) Cents() int64 { return m.cents } -func (m Money) Yuan() float64 { return float64(m.cents) / 100 } -func (m Money) Add(o Money) Money { return Money{cents: m.cents + o.cents} } - -// CreditLimit 信用额度值对象 -type CreditLimit struct { - maxCents int64 // 最大授信额度(分) - allowNegative bool // 是否允许负余额 -} -``` - ---- - -### 七、领域事件与可靠投递 - -领域对象只记录已经发生的业务事实,不直接调用 Asynq。Application 在保存聚合的同一数据库事务中写入 Outbox,再由 Relay 异步投递到 Asynq。 - -```go -// internal/domain/wallet/events.go - -// DomainEvent 领域事件接口 -type DomainEvent interface { - EventType() string - OccurredAt() time.Time -} - -// WalletDebitedEvent 钱包扣款事件 -type WalletDebitedEvent struct { - WalletID uint - ShopID uint - Amount Money - BalanceBefore int64 - BalanceAfter int64 - RefType string - RefID uint - occurredAt time.Time -} - -func (e WalletDebitedEvent) EventType() string { return "wallet.debited" } -func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt } -``` - -**事务边界**: - -```text -数据库事务 - ├── 保存聚合 - ├── 保存操作日志 - └── 保存 Outbox 事件 -提交事务 - ↓ -Outbox Relay → Asynq → 事件处理器 -``` - -**Application UseCase 保存事件**: - -```go -func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error { - return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error { - wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID) - if err != nil { - return err - } - if err := wallet.Debit(cmd.Amount); err != nil { - return err - } - if err := uc.walletRepo.Save(txCtx, wallet); err != nil { - return err - } - return uc.outboxRepo.Append(txCtx, wallet.PopEvents()) - }) -} -``` - -- Outbox 写入失败:事务回滚,避免“业务成功但关键事件永久丢失”。 -- Asynq 暂时不可用:不回滚已提交业务,Relay 后续重试。 -- 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。 - ---- - -### 八、仓储接口 - -```go -// internal/domain/wallet/repository.go - -// WalletRepository 钱包仓储接口 -type WalletRepository interface { - GetByShopID(ctx context.Context, shopID uint, walletType string) (*AgentWallet, error) - GetByID(ctx context.Context, id uint) (*AgentWallet, error) - Save(ctx context.Context, wallet *AgentWallet) error -} -``` - -实现在 `internal/infrastructure/persistence/wallet_repo.go`,可以复用现有 Store 逻辑。事务由 Application 注入的 `TxManager` 管理,Repository 从事务上下文获取 GORM `tx`,领域接口不得暴露 `*gorm.DB`。 - ---- - -### 九、应用服务(用例) - -一个文件 = 一个用例。 - -- 复杂写用例:Application 只做事务和跨聚合编排,业务不变量位于 Domain。 -- 简单写用例:Application 可以使用事务脚本完成基础校验和单表写入,不要求创建聚合。 -- Application 不负责列表、报表和复杂 DTO 拼装,这些职责属于 Query。 - -```go -// internal/application/wallet/debit_wallet.go - -// DebitCommand 扣款命令 -type DebitCommand struct { - WalletID uint - Amount domainWallet.Money - RefType string - RefID uint - OperatorID uint -} - -// DebitWalletUseCase 扣款用例 -type DebitWalletUseCase struct { - walletRepo domainWallet.WalletRepository - outboxRepo OutboxRepository - txManager TxManager -} - -// Execute 执行扣款 -func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error { - return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error { - wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID) - if err != nil { - return err - } - if err := wallet.Debit(cmd.Amount); err != nil { - return err - } - if err := uc.walletRepo.Save(txCtx, wallet); err != nil { - return err - } - return uc.outboxRepo.Append(txCtx, wallet.PopEvents()) - }) -} -``` - ---- - -### 十、Handler 层调用方式 - -- 复杂写、简单写:Handler → Application UseCase。 -- 读取:Handler → Query。 -- 尚未迁移的旧用例:Handler → Service。 -- Handler 不直接访问 GORM,也不承载业务规则或复杂 DTO 拼装。 - -```go -// internal/handler/admin/wallet_handler.go -func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error { - var req dto.GrantCreditRequest - // ... 参数解析 - - cmd := walletApp.GrantCreditCommand{ - ShopID: req.ShopID, - MaxCredit: domainWallet.NewCreditLimit(req.MaxCreditCents), - OperatorID: middleware.GetUserID(c), - } - if err := h.grantCreditUseCase.Execute(c.UserContext(), cmd); err != nil { - return err - } - return response.OK(c, nil) -} -``` - ---- - -### 十一、禁止事项 - -| 禁止 | 原因 | -|------|------| -| 在 Application 层实现复杂业务不变量 | 状态机、金额、库存等规则必须在领域对象里 | -| 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用,运行时通过 Repository 加载 | -| 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 | -| 在一个用例文件里实现多个业务流程 | 一文件一用例 | -| Repository 保存后直接发布关键事件 | 数据已提交但消息可能丢失,必须事务写 Outbox | -| Outbox 未落库仍提交业务事务 | 会造成业务成功但关键回调永久缺失 | -| 在聚合根里调用 Repository | 聚合根不依赖仓储 | -| Query 执行写操作 | Query 只负责读取模型,写入必须进入 Application | -| 使用 Query 快照替代写侧校验 | 查询结果可能已过期,Domain 必须重新验证不变量 | -| 在 Aggregate Repository 中堆叠报表联查 | 聚合仓储负责加载和保存聚合,复杂读取属于 Query | -| 只创建 domain/application 目录但业务规则仍在旧 Service | 这是目录搬迁,不是 DDD 迁移 | - ---- - -### 十二、本次迭代 DDD 落地计划 - -| 新模块 | 领域路径 | 用例路径 | -|--------|---------|---------| -| 信用额度(需求17) | `internal/domain/wallet/` | `internal/application/wallet/` | -| 审批流(需求18/20/21) | `internal/domain/approval/` | `internal/application/approval/`,使用版本快照和 Outbox | -| 站内消息(需求18/22) | `internal/domain/notification/` | `internal/application/notification/` | -| 批量订购(需求19) | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` | - -需求 16 已于 2026-07-14 移出 7 月迭代,本期不创建 `distribution` 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。 - - ---- - -> 汇编来源:`docs/7月迭代/基础设施/系统配置.md` - -## 基础设施:系统配置(tb_system_config) - -> 状态:待评审 -> 被依赖:需求 09(C端支付限制) - ---- - -### 一、设计目标 - -将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。 - -```mermaid -sequenceDiagram - actor Admin as 平台超管 - participant Web as 后台配置页 - participant API as SystemConfig Application - participant DB as PostgreSQL - participant Redis as Redis - participant Biz as 业务读取方 - - Admin->>Web: 修改受控配置表单 - Web->>API: PUT /api/admin/system/config/{config_key} - API->>API: 按配置 key 注册规则校验类型和值域 - API->>DB: 更新值并写审计日志 - API->>Redis: 删除对应缓存 - API-->>Web: 返回最新配置和更新时间 - Biz->>Redis: 下次读取缓存未命中 - Biz->>DB: 读取最新配置并回填缓存 -``` - ---- - -### 二、数据库 - -```sql --- 迁移文件:YYYYMMDD_create_tb_system_config.sql -CREATE TABLE tb_system_config ( - id BIGSERIAL PRIMARY KEY, - config_key VARCHAR(100) NOT NULL, -- 唯一键,格式:module.group.name - config_value TEXT NOT NULL DEFAULT '', -- 值(string/number/json字符串) - value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string | int | bool | json - module VARCHAR(50) NOT NULL DEFAULT 'general', -- 所属模块(便于按模块查询) - description TEXT, -- 中文说明(前端展示用) - is_readonly BOOLEAN NOT NULL DEFAULT FALSE, -- 是否只读(代码内部,不允许后台改) - creator BIGINT NOT NULL DEFAULT 0, - updater BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT uq_system_config_key UNIQUE (config_key) -); - -COMMENT ON TABLE tb_system_config IS '系统全局配置表'; - --- 初始化数据 -INSERT INTO tb_system_config (config_key, config_value, value_type, module, description) VALUES --- C端支付限制(需求9) -('c2b.payment.card_allowed_methods', '["alipay","wallet"]', 'json', 'c2b.payment', '卡资产C端允许的支付方式(alipay/wechat/wallet)'), -('c2b.payment.device_allowed_methods', '["wechat","wallet"]', 'json', 'c2b.payment', '设备C端允许的支付方式'); -``` - -需求02不写入全局默认实名策略。H5 始终读取卡或设备自身的 `realname_policy`;新建资产使用模型默认 `after_order`,避免全局 Key 与资产字段产生两套优先级。 - ---- - -### 三、Model - -```go -// internal/model/system_config.go - -// SystemConfig 系统全局配置模型 -type SystemConfig struct { - ID uint `gorm:"column:id;primaryKey" json:"id"` - ConfigKey string `gorm:"column:config_key;uniqueIndex;not null" json:"config_key"` - ConfigValue string `gorm:"column:config_value;type:text;not null;default:''" json:"config_value"` - ValueType string `gorm:"column:value_type;type:varchar(20);not null;default:'string'" json:"value_type"` - Module string `gorm:"column:module;type:varchar(50);not null;default:'general';index" json:"module"` - Description string `gorm:"column:description;type:text" json:"description"` - IsReadonly bool `gorm:"column:is_readonly;not null;default:false" json:"is_readonly"` - Creator uint `gorm:"column:creator;not null;default:0" json:"creator"` - Updater uint `gorm:"column:updater;not null;default:0" json:"updater"` - CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` -} - -func (SystemConfig) TableName() string { return "tb_system_config" } -``` - ---- - -### 四、Store - -```go -// internal/store/postgres/system_config_store.go - -type SystemConfigStore struct { - db *gorm.DB -} - -func (s *SystemConfigStore) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error) -func (s *SystemConfigStore) GetByModule(ctx context.Context, module string) ([]model.SystemConfig, error) -func (s *SystemConfigStore) UpdateValue(ctx context.Context, key string, value string, updaterID uint) error -func (s *SystemConfigStore) BatchGet(ctx context.Context, keys []string) (map[string]*model.SystemConfig, error) -``` - ---- - -### 五、配置读取辅助包 - -业务代码不直接操作 Store,通过辅助函数读取,带 Redis 缓存(5分钟TTL): - -```go -// pkg/sysconfig/config.go - -// GetString 读取字符串配置,返回默认值 -func GetString(ctx context.Context, key string, defaultVal string) string - -// GetStringSlice 读取 JSON 数组配置 -func GetStringSlice(ctx context.Context, key string) ([]string, error) - -// GetBool 读取布尔配置 -func GetBool(ctx context.Context, key string, defaultVal bool) bool - -// InvalidateCache 更新配置后清缓存(在 UpdateValue 后调用) -func InvalidateCache(ctx context.Context, key string) -``` - -Redis Key:`sys:config:{config_key}`,TTL 5分钟。 - -业务代码用法: -```go -// 读取卡的允许支付方式 -allowedMethods, _ := sysconfig.GetStringSlice(ctx, "c2b.payment.card_allowed_methods") -// 返回 ["alipay","wallet"] -``` - ---- - -### 六、API 设计 - -#### 6.1 获取配置列表 - -``` -GET /api/admin/system/config?module=c2b.payment -``` - -响应: -```json -{ - "code": 0, - "data": { - "list": [ - { - "config_key": "c2b.payment.card_allowed_methods", - "config_value": "[\"alipay\",\"wallet\"]", - "value_type": "json", - "description": "卡资产C端允许的支付方式", - "is_readonly": false, - "updated_at": "2026-07-11T10:00:00Z" - } - ] - } -} -``` - -#### 6.2 更新配置 - -``` -PUT /api/admin/system/config/{config_key} -``` - -请求体: -```json -{ - "config_value": "[\"alipay\",\"wallet\",\"wechat\"]" -} -``` - -权限:仅平台超管可操作。 - -#### DTO - -```go -// internal/model/dto/system_config_dto.go - -// SystemConfigListRequest 配置列表查询请求 -type SystemConfigListRequest struct { - Module string `query:"module" description:"按模块过滤(可选)"` -} - -// SystemConfigItem 配置项响应 -type SystemConfigItem struct { - ConfigKey string `json:"config_key"` - ConfigValue string `json:"config_value"` - ValueType string `json:"value_type" description:"值类型 (string/int/bool/json)"` - Module string `json:"module"` - Description string `json:"description"` - IsReadonly bool `json:"is_readonly"` - UpdatedAt time.Time `json:"updated_at"` -} - -// UpdateSystemConfigRequest 更新配置请求 -type UpdateSystemConfigRequest struct { - ConfigValue string `json:"config_value" validate:"required" description:"新配置值"` -} -``` - -更新接口不能只校验 `value_type`。Application 需要按 `config_key` 注册允许值,例如支付方式只能来自 `alipay/wechat/wallet`,实名策略只能来自 `none/before_order/after_order`。未知 key 默认只读,禁止通过通用页面写入任意系统配置。 - ---- - -### 七、前端对接 - -#### 页面:系统设置 > 系统配置 - -**初期可以做一个通用的 Key-Value 管理页面,按 module 分组展示。** - -调用流程: -1. 进入页面 → `GET /api/admin/system/config`(不传 module = 返回全部) -2. 按 module 分组展示,`is_readonly=true` 的配置只读 -3. 修改某项 → `PUT /api/admin/system/config/{config_key}`,body: `{config_value}` -4. 更新成功后后端立即删除该 key 的缓存,前端提示“配置已更新”并刷新当前值 - -**C端支付限制配置展示建议**(针对 `c2b.payment` 模块): - -不要让后台用户手动填 JSON,前端渲染成 CheckboxGroup: - -``` -卡资产允许支付方式: - ☑ 支付宝 ☑ 钱包 ☐ 微信 - -设备允许支付方式: - ☐ 支付宝 ☑ 钱包 ☑ 微信 -``` - -前端把选中项序列化成 `["alipay","wallet"]` 提交。 - -通用 Key-Value 页面只作为管理壳层,已知业务配置必须使用受控组件(单选、复选或开关),不向运营人员暴露 JSON 文本框。 - - ---- - -> 汇编来源:`docs/7月迭代/基础设施/站内消息.md` - -## 基础设施:站内消息(Notification) - -> 状态:待评审 -> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒) -> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口) - ---- - -### 一、设计原则 - -- **业务代码不直接写通知表**:统一通过通知发布器或领域事件异步处理 -- **关键领域事件先写 Outbox**:审批、退款、充值等事务内事件由 Outbox Relay 投递 Asynq,禁止事务提交后直接入队 -- **通知消费必须幂等**:使用稳定的 `event_id` 和接收人唯一约束,Asynq 重试不得重复生成站内消息 -- **不过度抽象**:不用 interface,用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码) -- **前端轮询**:30秒一次 `/api/admin/notifications/unread-count`,不用 WebSocket - -```mermaid -sequenceDiagram - participant Biz as 业务事务 - participant Outbox as Outbox - participant Relay as Relay - participant Worker as Notification Worker - participant DB as tb_notification - participant Web as 后台前端 - - Biz->>Outbox: 同事务写领域事件 - Relay->>Outbox: 拉取待投递事件 - Relay->>Worker: 至少一次投递 - Worker->>DB: 按 event_id + recipient 幂等插入 - Web->>DB: 经 API 轮询未读数/通知列表 - Web->>DB: 经 API 标记已读 -``` - ---- - -### 二、数据库 - -```sql --- 迁移文件:YYYYMMDD_create_tb_notification.sql -CREATE TABLE tb_notification ( - id BIGSERIAL PRIMARY KEY, - event_id VARCHAR(64) NOT NULL, -- 领域事件ID或调用方请求ID,用于幂等 - recipient_id BIGINT NOT NULL, -- 用户ID(admin user 或 agent user) - recipient_type VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | agent - type VARCHAR(50) NOT NULL, -- 通知类型(见常量定义) - title VARCHAR(200) NOT NULL, -- 标题 - body TEXT NOT NULL DEFAULT '', -- 纯文本正文 - ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | iot_card | device - ref_id BIGINT, -- 关联业务ID - is_read BOOLEAN NOT NULL DEFAULT FALSE, - read_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ -- 过期自动不展示(可选,临期提醒用) -); - -CREATE INDEX idx_notification_recipient - ON tb_notification (recipient_id, recipient_type, is_read, created_at DESC); -CREATE INDEX idx_notification_created ON tb_notification (created_at DESC); -CREATE UNIQUE INDEX uq_notification_event_recipient - ON tb_notification (event_id, recipient_id, recipient_type); - -COMMENT ON TABLE tb_notification IS '站内消息通知表'; -``` - ---- - -### 三、常量定义 - -```go -// pkg/constants/notification.go - -// 通知类型 -const ( - NotifyTypeApprovalPending = "approval.pending" // 待我审批 - NotifyTypeApprovalDone = "approval.done" // 审批完成(申请人收) - NotifyTypeApprovalRejected = "approval.rejected" // 审批驳回(申请人收,流程终止) - NotifyTypeApprovalReturned = "approval.returned" // 审批退回(申请人收,可修改后重新提交) - NotifyTypePackageExpiring = "package.expiring" // 套餐临期 - NotifyTypeSystemAlert = "system.alert" // 系统通知 -) - -// 通知接收者类型 -const ( - NotifyRecipientAdmin = "admin" // 平台用户 - NotifyRecipientAgent = "agent" // 代理用户(预留) -) -``` - ---- - -### 四、Model - -```go -// internal/model/notification.go - -// Notification 站内消息模型 -type Notification struct { - ID uint `gorm:"column:id;primaryKey" json:"id"` - EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"` - RecipientID uint `gorm:"column:recipient_id;not null;index;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_id"` - RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin';uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_type"` - Type string `gorm:"column:type;type:varchar(50);not null" json:"type"` - Title string `gorm:"column:title;type:varchar(200);not null" json:"title"` - Body string `gorm:"column:body;type:text;not null;default:''" json:"body"` - RefType *string `gorm:"column:ref_type;type:varchar(50)" json:"ref_type,omitempty"` - RefID *uint `gorm:"column:ref_id" json:"ref_id,omitempty"` - IsRead bool `gorm:"column:is_read;not null;default:false" json:"is_read"` - ReadAt *time.Time `gorm:"column:read_at" json:"read_at,omitempty"` - CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"` - ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at,omitempty"` -} - -func (Notification) TableName() string { return "tb_notification" } -``` - ---- - -### 五、发布侧:NotificationPublisher - -非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器,而由 `TaskCreated`、`ProcessApproved` 等事件处理器转换为通知载荷。 - -领域事件通知使用领域事件自身的 `event_id`;非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。 - -```go -// internal/infrastructure/messaging/notification_publisher.go - -// SendPayload 发送通知的参数 -type SendPayload struct { - EventID string // 领域事件ID或调用方请求ID,同一次重试必须保持不变 - RecipientIDs []uint // 接收人ID列表 - RecipientType string // admin | agent - Type string // 通知类型常量 - Title string // 标题 - Body string // 正文 - RefType string // 关联业务类型(可选) - RefID uint // 关联业务ID(可选) - ExpiresAt *time.Time // 过期时间(可选) -} - -// NotificationPublisher 通知发布器(非接口,简单具体实现) -type NotificationPublisher struct { - queueClient *queue.Client - logger *zap.Logger -} - -// Publish 发布通知(异步) -// 返回错误供调用方或 Asynq Handler 决定重试,禁止吞掉关键通知错误。 -func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) error { - if payload.EventID == "" { - return errors.New(errors.CodeInvalidParam, "通知事件ID不能为空") - } - if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil { - p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type)) - return err - } - return nil -} -``` - -审批事件处理器调用示例(节点激活后通知候选审批人): - -```go -return h.notifyPublisher.Publish(ctx, notification.SendPayload{ - EventID: event.EventID, - RecipientIDs: []uint{nextApproverID}, - RecipientType: constants.NotifyRecipientAdmin, - Type: constants.NotifyTypeApprovalPending, - Title: "您有一条待审批记录", - Body: fmt.Sprintf("代理「%s」提交了充值申请,请及时审批。", shopName), - RefType: "approval", - RefID: processInstanceID, -}) -``` - -审批事务中只写 `TaskCreated` Outbox 事件。即使 Redis/Asynq 暂时不可用,事件仍保留在数据库,由 Relay 重试;通知处理失败则由 Asynq 重试当前任务。 - ---- - -### 六、消费侧:Asynq Task Handler - -```go -// internal/task/notification_handler.go - -// HandleNotification 处理通知发送任务 -func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.Task) error { - var payload notification.SendPayload - if err := sonic.Unmarshal(t.Payload(), &payload); err != nil { - return fmt.Errorf("反序列化通知载荷失败: %w", err) - } - - // 批量写入 tb_notification - records := make([]model.Notification, 0, len(payload.RecipientIDs)) - for _, uid := range payload.RecipientIDs { - ref_type := (*string)(nil) - ref_id := (*uint)(nil) - if payload.RefType != "" { - ref_type = &payload.RefType - } - if payload.RefID != 0 { - ref_id = &payload.RefID - } - records = append(records, model.Notification{ - EventID: payload.EventID, - RecipientID: uid, - RecipientType: payload.RecipientType, - Type: payload.Type, - Title: payload.Title, - Body: payload.Body, - RefType: ref_type, - RefID: ref_id, - ExpiresAt: payload.ExpiresAt, - }) - } - - if err := h.db.WithContext(ctx). - Clauses(clause.OnConflict{DoNothing: true}). - CreateInBatches(records, 100).Error; err != nil { - return fmt.Errorf("批量写入通知失败: %w", err) - } - - // Phase 2:在此处加企微/短信调用 - // for _, sender := range h.extraSenders { sender.Send(ctx, payload) } - - return nil -} -``` - ---- - -### 七、API 设计 - -#### 7.1 未读数量(前端轮询用,30秒一次) - -``` -GET /api/admin/notifications/unread-count -``` - -响应: -```json -{ "code": 0, "data": { "count": 5 } } -``` - -#### 7.2 通知列表 - -``` -GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20 -``` - -请求参数: -```go -type NotificationListRequest struct { - IsRead *bool `query:"is_read" description:"是否已读(不传=全部)"` - Type string `query:"type" description:"通知类型过滤(可选)"` - Page int `query:"page" description:"页码"` - PageSize int `query:"page_size" description:"每页数量(最大50)"` -} -``` - -响应: -```json -{ - "code": 0, - "data": { - "list": [ - { - "id": 1, - "type": "approval.pending", - "title": "您有一条待审批记录", - "body": "代理「XX店」提交了充值申请,请及时审批。", - "ref_type": "approval", - "ref_id": 42, - "is_read": false, - "created_at": "2026-07-11T10:00:00Z" - } - ], - "total": 3, - "page": 1, - "page_size": 20 - } -} -``` - -#### 7.3 标记已读 - -``` -PUT /api/admin/notifications/{id}/read -``` - -#### 7.4 全部标记已读 - -``` -PUT /api/admin/notifications/read-all -``` - -可传 `type` 过滤(只把某类型全部已读): -```json -{ "type": "approval.pending" } -``` - -#### DTO - -```go -// internal/model/dto/notification_dto.go - -type NotificationListRequest struct { - IsRead *bool `query:"is_read"` - Type string `query:"type"` - Page int `query:"page" default:"1"` - PageSize int `query:"page_size" default:"20"` -} - -type NotificationItem struct { - ID uint `json:"id"` - Type string `json:"type" description:"通知类型"` - Title string `json:"title"` - Body string `json:"body"` - RefType *string `json:"ref_type,omitempty"` - RefID *uint `json:"ref_id,omitempty"` - IsRead bool `json:"is_read"` - ReadAt *time.Time `json:"read_at,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -type UnreadCountResponse struct { - Count int64 `json:"count"` -} - -type ReadAllRequest struct { - Type string `json:"type" description:"通知类型(为空则全部已读)"` -} -``` - ---- - -### 八、前端对接 - -#### 顶部导航栏铃铛 - -``` -组件挂载 → 轮询 GET /api/admin/notifications/unread-count(30秒一次) -→ count > 0 时铃铛显示红点 + 数字 -→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面 -→ 打开时调 GET /api/admin/notifications?is_read=false -→ 点击某条通知 → PUT /api/admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页 -``` - -#### 跳转逻辑(ref_type) - -| ref_type | 跳转页面 | -|----------|---------| -| `approval` | `/approvals/instances/{ref_id}` 审批详情 | -| `recharge` | `/agent-recharges/{ref_id}` 充值单详情 | -| `refund` | `/refunds/{ref_id}` 退款单详情 | -| `iot_card` | `/iot-cards/{ref_id}` IoT卡详情(临期提醒) | -| `device` | `/devices/{ref_id}` 设备详情(临期提醒) | - -#### 通知列表页(/notifications) - -筛选:通知类型(下拉)、已读状态 -操作:全部已读按钮 -列表字段:类型、标题、时间、已读状态 -点击行:跳转关联业务详情 - -前端页面不可见时暂停未读数轮询,恢复可见时立即刷新。通知正文按纯文本渲染;未来需要富文本时使用受控模板和统一净化,禁止直接渲染业务方提交的 HTML。 - ---- - -### 九、Phase 2 扩展预留 - -当需要接入企微通知时,只需在 `HandleNotification` 里追加: - -```go -// 企微通知(Phase 2) -if h.wecomClient != nil { - for _, uid := range payload.RecipientIDs { - wecomOpenID := h.userStore.GetWecomOpenID(ctx, uid) - h.wecomClient.SendMessage(wecomOpenID, payload.Title, payload.Body) - } -} -``` - -业务代码零修改,Handler 里加一段即可。 - - ---- - -> 汇编来源:`docs/7月迭代/基础设施/审批流.md` - -## 基础设施:通用审批流领域 - -> 状态:待评审 -> 被依赖:需求 18(多人审批)、需求 20(退款审批)、需求 21(充值审核) -> 范围变更:需求16已于2026-07-14移出本期,代理申请不在第一版接入范围 -> 架构:DDD,`internal/domain/approval/` + `internal/application/approval/` -> 核心原则:流程结构、审批人和审批规则通过已发布的流程定义决定,禁止写死在业务代码中。 - ---- - -### 一、背景与目标 - -当前系统没有“部门”组织模型,只有账号、角色以及账号与角色的关联。因此审批引擎不能假设“提交人的部门领导”,也不能在代码中固定“第一步部门领导、第二步财务”。 - -审批流领域只认识以下概念: - -- 业务类型和业务单号 -- 流程定义及版本 -- 流程实例 -- 审批节点和流转 -- 审批任务和候选审批人 -- 业务快照和业务资料 -- 审批动作、状态和操作日志 -- 领域事件和业务回调 - -退款、充值等业务负责说明“什么业务需要审批”,审批领域负责说明“流程怎么走、谁可以审批、什么时候完成”。 - -#### 整体运行视图 - -```mermaid -flowchart LR - BizUI[退款/充值/待办前端] --> Handler[Approval Handler] - Handler --> App[Application UseCase] - App --> Definition[ProcessDefinition 聚合] - App --> Instance[ProcessInstance 聚合] - App --> Resolver[审批人解析器] - App --> Attachment[审批附件管理端口] - App --> Repo[(GORM Repository)] - App --> Outbox[(Outbox)] - Relay[Outbox Relay] --> Outbox - Relay --> Asynq[Asynq] - Asynq --> Notice[站内消息消费者] - Asynq --> Adapter[退款/充值业务处理器] - Attachment --> Storage[(私有对象存储)] -``` - -审批事务只负责形成可靠的审批事实。通知和审批后的业务动作异步执行,并依靠稳定事件 ID 和业务幂等键承受重复投递。 - -#### 第一版支持 - -- 流程定义草稿、发布、停用和版本管理 -- 串行审批节点 -- 按角色选择审批人 -- 直接指定一个或多个审批账号 -- 或签:任意一人通过,当前节点完成 -- 会签:所有人通过,当前节点完成 -- 通过、驳回、退回修改 -- 每次审批动作的审批意见和审批附件 -- 流程定义快照、审批人快照和操作日志 -- 并发控制、请求幂等 -- Outbox + Asynq 可靠事件投递 -- 退款、充值业务通过适配器接入 -- 受控的审批动作业务字段;第一版用于退款金额确认和线下充值操作密码校验 - -#### 第一版不支持 - -- 条件表达式和复杂分支 -- 循环、子流程和 BPMN 全规范 -- 任意节点回退、动态跳转和加签 -- 超时自动通过或自动拒绝 -- 运行中修改流程定义或审批人 - -数据结构保留 `nodes + transitions`,但第一版发布校验只允许 `start → approval... → end` 的无环串行流程。 - ---- - -### 二、DDD 边界 - -```mermaid -flowchart TB - H[Handler] --> A[Application UseCase] - A --> PD[ProcessDefinition 聚合] - A --> PI[ProcessInstance 聚合] - PI --> T[ApprovalTask 实体] - A --> AR[ApproverResolver 领域端口] - A --> AT[ApprovalAttachmentStore 应用端口] - A --> PR[Repository 端口] - A --> OR[OutboxRepository 端口] - Infra[Infrastructure] --> PR - Infra --> OR - Infra --> AR - Infra --> AT - Infra --> BA[BusinessApprovalHandler 应用端口] - Infra --> GORM[GORM Repository] - Infra --> Queue[Outbox Relay + Asynq] -``` - -#### 2.1 ProcessDefinition 聚合 - -负责流程定义的合法性和版本规则: - -- 草稿可以修改。 -- 发布时校验节点、连线、审批人配置和无环约束。 -- 已发布版本不可修改。 -- 修改流程必须基于旧版本创建新版本。 -- 停用只影响新流程发起,不影响已运行实例。 - -```mermaid -stateDiagram-v2 - [*] --> Draft: 新建定义 - Draft --> Draft: 修改节点/审批人 - Draft --> Published: 发布校验通过 - Published --> Disabled: 停用 - Published --> Draft: 基于当前版本复制新草稿 - Disabled --> Draft: 基于历史版本复制新草稿 - Published --> [*] - Disabled --> [*] -``` - -`Published → Draft` 表示创建一个新版本草稿,不是把已发布记录原地改回草稿。 - -#### 2.2 ProcessInstance 聚合 - -负责运行期业务不变量: - -- 只有运行中的流程可以审批。 -- 只有待审批任务的候选审批人可以操作。 -- 同一个任务只能完成一次。 -- 或签任意一人通过后,其他候选人的待处理资格取消。 -- 会签全部通过后才推进下一节点。 -- 任意有效审批人驳回或退回时,流程终止,并取消当前节点其他待处理任务。 -- 流程推进必须使用实例保存的定义快照,不能读取后来发布的新版本。 -- 每个审批人的意见属于其唯一审批动作,提交后不可修改;会签和或签都不能用后续意见覆盖先前记录。 -- 审批附件必须与对应操作日志同时落库,不能出现任务已完成但附件关联缺失的半成品。 -- 发起时固化的业务快照是审批详情的权威展示数据;实时业务详情仅作为受原业务数据范围保护的补充。 - -#### 2.3 Application UseCase - -Application 只负责: - -- 加载流程定义、实例和账号数据。 -- 开启事务并调用聚合方法。 -- 调用审批人解析器并激活下一节点。 -- 在审批事务内调用受控的业务动作适配器,校验退款金额或充值操作密码等业务字段。 -- 保存聚合、审批日志和 Outbox 事件。 -- 编排退款、充值等业务与审批领域的接入。 - -状态是否允许转换、节点是否完成等判断必须位于 Domain。 - ---- - -### 三、流程定义 - -#### 3.1 定义示例 - -```json -{ - "code": "refund_approval", - "version": 2, - "start_node_id": "start", - "nodes": [ - { - "id": "start", - "type": "start", - "name": "开始" - }, - { - "id": "business_review", - "type": "approval", - "name": "业务审核", - "approver": { - "type": "role", - "role_id": 12, - "approval_mode": "any" - } - }, - { - "id": "finance_review", - "type": "approval", - "name": "财务审核", - "approver": { - "type": "user", - "user_ids": [101, 102], - "approval_mode": "all" - }, - "action_config": { - "require_operation_password": true - } - }, - { - "id": "end", - "type": "end", - "name": "结束" - } - ], - "transitions": [ - {"from": "start", "to": "business_review"}, - {"from": "business_review", "to": "finance_review"}, - {"from": "finance_review", "to": "end"} - ] -} -``` - -#### 3.2 审批人配置 - -第一版支持两种审批人来源: - -```go -// ApproverType 审批人来源类型 -const ( - ApproverTypeRole = "role" // 按角色解析 - ApproverTypeUser = "user" // 直接指定账号 -) - -// ApprovalMode 节点完成方式 -const ( - ApprovalModeAny = "any" // 或签,任意一人通过 - ApprovalModeAll = "all" // 会签,全部人员通过 -) -``` - -所有常量实际定义在 `pkg/constants/approval.go`,状态值使用 `int`,类型和方式使用 `string`。 - -角色审批配置存储稳定的 `role_id`,同时在定义和任务中保存角色名称快照。禁止依赖可修改的角色名称执行权限判断。 - -`action_config` 是节点定义快照的一部分。第一版只支持 `require_operation_password`:配置后,仅触发节点完成的审批人输入密码;或签由实际通过者输入,会签由最后一位完成会签者输入。密码只参与本次内存校验,不进入业务表、审批日志、Outbox、访问日志或错误日志。 - -#### 3.3 审批人解析时机 - -审批人在**节点激活时**解析: - -1. `role`:查询该角色下当前启用的账号。 -2. `user`:校验配置中的账号存在且启用。 -3. 将解析结果写入 `tb_approval_task_assignee`,形成运行期快照。 -4. 没有可用审批人时,节点激活失败,当前事务回滚。 - -角色成员后续变化不影响已经激活的任务。后续节点尚未激活时,使用激活时最新的角色成员。 - -后台审批场景只允许配置启用的平台角色以及超级管理员/平台用户账号。客户角色、代理账号和企业账号不能成为后台审批任务候选人,除非后续业务明确扩展审批主体范围。 - -#### 3.4 业务绑定 - -业务类型通过绑定表选择流程定义,业务代码不得写死具体步骤: - -```text -refund → refund_approval 的当前已发布版本 -recharge → recharge_approval 的当前已发布版本 -``` - -发起流程时解析当前已发布版本,并将完整 `definition_json` 保存到流程实例快照。 - -#### 3.5 运行时节点推进 - -```mermaid -flowchart TD - Start[读取实例中的定义快照] --> Node{当前节点类型} - Node -->|start| Next[查唯一下一节点] - Node -->|approval| Resolve[解析角色或指定账号] - Resolve --> Empty{存在可用审批人?} - Empty -->|否| Fail[事务失败并返回配置错误] - Empty -->|是| Task[创建任务和审批人快照] - Task --> Wait[等待审批] - Wait --> Decision{节点审批结果} - Decision -->|等待| Wait - Decision -->|通过| Next - Decision -->|驳回| Rejected[流程已驳回] - Decision -->|退回| Returned[流程已退回] - Next --> EndCheck{下一节点是否 end?} - EndCheck -->|否| Node - EndCheck -->|是| Approved[流程已通过] -``` - -第一版发布校验保证每个非结束节点只有一个后继节点,因此运行期不执行条件表达式,也不会出现多分支选择。 - ---- - -### 四、领域状态机 - -#### 4.1 流程实例状态 - -```text -1=审批中 -2=已通过 -3=已驳回 -4=已退回 -``` - -允许的状态转换: - -```mermaid -stateDiagram-v2 - [*] --> Running: 发起成功 - Running --> Approved: 到达结束节点 - Running --> Rejected: 审批人驳回 - Running --> Returned: 审批人退回修改 - Approved --> [*] - Rejected --> [*] - Returned --> [*] -``` - -已通过、已驳回、已退回都是终态。退回后重新提交必须创建新的流程实例,旧实例永久保留。 - -#### 4.2 审批任务状态 - -```text -1=待审批 -2=已通过 -3=已驳回 -4=已退回 -5=已取消 -``` - -任务只在节点激活时创建,不预先创建后续节点的“待审批”任务。 - -#### 4.3 审批人处理状态 - -```text -1=待处理 -2=已通过 -3=已驳回 -4=已退回 -5=已取消 -``` - -- 或签:任意审批人通过后,任务通过,其余待处理记录变为已取消。 -- 会签:全部审批人通过后,任务通过。 -- 驳回或退回:任务和流程立即进入对应终态,其余待处理记录变为已取消。 - -```mermaid -stateDiagram-v2 - [*] --> Pending - Pending --> Approved: 节点规则满足 - Pending --> Rejected: 任一有效审批人驳回 - Pending --> Returned: 任一有效审批人退回 - Pending --> Cancelled: 同节点已由他人完成/流程终止 - Approved --> [*] - Rejected --> [*] - Returned --> [*] - Cancelled --> [*] -``` - -#### 4.4 审批意见与附件 - -“审批建议”“审批备注”统一建模为 `ApprovalOpinion`,它属于某个审批人执行的一次通过、驳回或退回动作,不属于流程实例的可修改公共备注: - -- 通过:审批意见可选,最多 1000 字。 -- 驳回、退回:审批意见必填,去除首尾空白后至少 1 字,最多 1000 字。 -- 意见按纯文本保存和展示,不接受 HTML、Markdown 或富文本。 -- 审批动作提交成功后,意见和附件不可编辑、覆盖或删除;需要纠正时只能由后续审批动作形成新的审计记录。 -- 第一版不建设独立评论区或聊天式追加备注,避免绕开审批动作权限和审计语义。 - -审批附件属于本次审批意见的补充材料: - -- 每次动作可选上传 0~5 个附件。 -- 附件与审批意见、任务状态、操作日志在同一数据库事务内建立关联。 -- 退款凭证、充值凭证等仍属于业务单资料;审批附件不能替代业务必填凭证,也不能反向修改业务资料。 -- 或签、会签场景下,每个审批人拥有各自独立的意见和附件,不能把多人意见合并覆盖到任务级字段。 - ---- - -### 五、数据库设计 - -禁止建立数据库外键,关联通过 ID 维护。 - -#### 5.1 流程定义表 - -```sql -CREATE TABLE tb_approval_process_definition ( - id BIGSERIAL PRIMARY KEY, - process_code VARCHAR(50) NOT NULL, - process_name VARCHAR(100) NOT NULL, - version INT NOT NULL, - status INT NOT NULL DEFAULT 1, -- 1=草稿 2=已发布 3=已停用 - definition_json JSONB NOT NULL, - published_at TIMESTAMPTZ, - creator BIGINT NOT NULL DEFAULT 0, - updater BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ -); - -CREATE UNIQUE INDEX uq_approval_definition_code_version - ON tb_approval_process_definition (process_code, version) - WHERE deleted_at IS NULL; -``` - -#### 5.2 业务流程绑定表 - -```sql -CREATE TABLE tb_approval_process_binding ( - id BIGSERIAL PRIMARY KEY, - biz_type VARCHAR(50) NOT NULL, - process_code VARCHAR(50) NOT NULL, - status INT NOT NULL DEFAULT 1, -- 0=禁用 1=启用 - creator BIGINT NOT NULL DEFAULT 0, - updater BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX uq_approval_binding_biz_type - ON tb_approval_process_binding (biz_type); -``` - -#### 5.3 流程实例表 - -```sql -CREATE TABLE tb_approval_process_instance ( - id BIGSERIAL PRIMARY KEY, - flow_no VARCHAR(30) NOT NULL, - process_code VARCHAR(50) NOT NULL, - process_version INT NOT NULL, - definition_snapshot JSONB NOT NULL, - business_snapshot JSONB NOT NULL DEFAULT '{}', - biz_type VARCHAR(50) NOT NULL, - biz_id BIGINT NOT NULL, - biz_no VARCHAR(50) NOT NULL DEFAULT '', - submitter_type VARCHAR(30) NOT NULL, - submitter_id BIGINT, - submitter_name VARCHAR(50) NOT NULL DEFAULT '', - current_node_id VARCHAR(64) NOT NULL, - current_node_name VARCHAR(100) NOT NULL DEFAULT '', - status INT NOT NULL DEFAULT 1, - version BIGINT NOT NULL DEFAULT 1, - finish_reason TEXT, - completed_at TIMESTAMPTZ, - creator BIGINT NOT NULL DEFAULT 0, - updater BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX uq_approval_instance_flow_no - ON tb_approval_process_instance (flow_no); -CREATE INDEX idx_approval_instance_biz - ON tb_approval_process_instance (biz_type, biz_id, created_at DESC); -CREATE UNIQUE INDEX uq_approval_instance_active_biz - ON tb_approval_process_instance (biz_type, biz_id) - WHERE status = 1; -``` - -`business_snapshot` 固化业务标题、业务单号、提交人、审批关键字段和业务资料元数据。退款申请凭证、充值凭证等在其中标记为 `business_materials`;审批人动作附件仍只写入 `tb_approval_operation_attachment`,两类文件不能混用。 - -第一版业务快照最低字段: - -| 业务 | 字段和资料 | -|------|------------| -| 退款 | 退款单号、订单号、资产类型和标识、代理店铺、支付方式、订单实收、申请退款金额、退款原因、退款申请凭证 | -| 员工线下充值 | 充值单号、目标代理店铺、充值金额、支付方式、提交人、备注、支付凭证 | - -`submitter_type` 第一版支持 `admin_account`、`shop_account`。外部申请人属于需求16后续扩展,本期不引入 `external_applicant` 语义。 - -#### 5.4 审批任务表 - -```sql -CREATE TABLE tb_approval_task ( - id BIGSERIAL PRIMARY KEY, - process_instance_id BIGINT NOT NULL, - node_id VARCHAR(64) NOT NULL, - node_name VARCHAR(100) NOT NULL, - node_sequence INT NOT NULL, - approver_type VARCHAR(20) NOT NULL, - approver_config JSONB NOT NULL, - approval_mode VARCHAR(20) NOT NULL, - status INT NOT NULL DEFAULT 1, - completed_by BIGINT, - completed_by_name VARCHAR(50), - completed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX uq_approval_task_instance_node - ON tb_approval_task (process_instance_id, node_id); -CREATE INDEX idx_approval_task_status - ON tb_approval_task (status, created_at DESC); -``` - -任务表只保存节点结果。`completed_by` 表示触发节点完成的最后操作人,不代表节点只有一个审批人;人类审批意见统一保存在审批人记录和不可变操作日志中。 - -#### 5.5 任务审批人表 - -```sql -CREATE TABLE tb_approval_task_assignee ( - id BIGSERIAL PRIMARY KEY, - task_id BIGINT NOT NULL, - assignee_id BIGINT NOT NULL, - assignee_name VARCHAR(50) NOT NULL DEFAULT '', - source_type VARCHAR(20) NOT NULL, - source_id BIGINT, - source_name VARCHAR(100) NOT NULL DEFAULT '', - status INT NOT NULL DEFAULT 1, - comment TEXT NOT NULL DEFAULT '', - operation_log_id BIGINT, - operated_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX uq_approval_task_assignee - ON tb_approval_task_assignee (task_id, assignee_id); -CREATE INDEX idx_approval_assignee_pending - ON tb_approval_task_assignee (assignee_id, status, created_at DESC); -``` - -#### 5.6 操作日志表 - -```sql -CREATE TABLE tb_approval_operation_log ( - id BIGSERIAL PRIMARY KEY, - request_id VARCHAR(64) NOT NULL, - process_instance_id BIGINT NOT NULL, - task_id BIGINT, - node_id VARCHAR(64), - operator_type VARCHAR(30) NOT NULL, - operator_id BIGINT, - operator_name VARCHAR(50) NOT NULL DEFAULT '', - action VARCHAR(30) NOT NULL, - request_digest CHAR(64) NOT NULL, - before_status INT NOT NULL, - after_status INT NOT NULL, - comment TEXT NOT NULL DEFAULT '', - business_data JSONB NOT NULL DEFAULT '{}', - client_ip VARCHAR(64) NOT NULL DEFAULT '', - user_agent VARCHAR(500) NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX uq_approval_operation_request_id - ON tb_approval_operation_log (request_id); -CREATE INDEX idx_approval_operation_instance - ON tb_approval_operation_log (process_instance_id, created_at); -``` - -操作日志只允许新增,禁止更新或删除。相同 `request_id` 仅在 `task_id`、操作人、动作和规范化请求摘要 SHA-256 均一致时返回第一次操作结果;任一字段不一致返回冲突,不能把误复用的请求 ID 当成成功重试。 - -#### 5.7 审批操作附件表 - -```sql -CREATE TABLE tb_approval_operation_attachment ( - id BIGSERIAL PRIMARY KEY, - operation_log_id BIGINT NOT NULL, - file_key VARCHAR(500) NOT NULL, - file_name_snapshot VARCHAR(255) NOT NULL, - content_type_snapshot VARCHAR(100) NOT NULL, - size_bytes BIGINT NOT NULL, - object_etag VARCHAR(128) NOT NULL, - attached_by BIGINT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX uq_approval_attachment_operation_file - ON tb_approval_operation_attachment(operation_log_id, file_key); -CREATE INDEX idx_approval_attachment_operation - ON tb_approval_operation_attachment(operation_log_id); -``` - -附件表不建立数据库外键。`operation_log_id` 对应不可变操作日志;审批人记录通过 `operation_log_id` 定位该次操作的意见和附件。 - -附件提交规则: - -1. 前端生成本次动作 `request_id` 后,通过现有上传能力申请 `purpose=approval_attachment` 的一次性上传凭证;上传记录必须固化上传人账号、用途、`request_id`、临时 Key 和过期时间,临时 Key 位于 `attachments/` 前缀。 -2. 审批动作提交 `attachments=[{file_key,file_name}]`;每个临时 Key 最长 500,必须以 `attachments/` 开头且不能重复。`file_name` 清理后长度为 1~255,只作为显示快照,不作为对象定位依据。 -3. Application 在状态转换前先校验临时 Key 的上传记录属于当前账号、用途为 `approval_attachment` 且 `request_id` 一致,再调用对象存储 `Stat/HeadObject` 确认文件存在,并读取对象大小、Content-Type 和 ETag;后端同时校验文件扩展名与 Content-Type 组合,不能只信任请求体声明。 -4. 第一版每个文件最大 20MB,只允许 `.jpg/.jpeg/.png/.pdf/.doc/.docx/.xls/.xlsx`;拒绝可执行文件、脚本和普通压缩包。扩展名与 MIME 白名单定义在 `pkg/constants/approval.go`,不由前端决定。 -5. 校验通过后,后端按 `request_id` 将临时对象服务端复制到 `approval-attachments/{request_id}/` 审计前缀,并返回最终 Key、元数据和 ETag。该前缀不提供前端上传 URL,避免附件提交后被覆盖。 -6. 文件名只作为展示快照,必须去除路径和控制字符;下载时使用安全的 `Content-Disposition`。 -7. 数据库事务只保存最终审计 Key。事务提交后异步删除临时对象;事务失败产生的未引用审计对象和普通临时对象由统一生命周期清理。 - -#### 5.8 Outbox 表 - -`tb_outbox_event` 是通用基础设施表,不只服务审批流: - -```sql -CREATE TABLE tb_outbox_event ( - id BIGSERIAL PRIMARY KEY, - event_id VARCHAR(64) NOT NULL, - aggregate_type VARCHAR(50) NOT NULL, - aggregate_id VARCHAR(64) NOT NULL, - event_type VARCHAR(100) NOT NULL, - payload JSONB NOT NULL, - status INT NOT NULL DEFAULT 1, -- 1=待发布 2=已发布 3=发布失败 - retry_count INT NOT NULL DEFAULT 0, - available_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - published_at TIMESTAMPTZ, - last_error TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX uq_outbox_event_id ON tb_outbox_event (event_id); -CREATE INDEX idx_outbox_pending - ON tb_outbox_event (status, available_at) - WHERE status IN (1, 3); -``` - ---- - -### 六、核心扩展接口 - -#### 6.1 审批人解析器 - -```go -// ApproverResolver 根据节点配置解析候选审批人 -type ApproverResolver interface { - Resolve(ctx context.Context, rule ApproverRule) ([]Approver, error) -} -``` - -Infrastructure 提供: - -- `RoleApproverResolver` -- `UserApproverResolver` -- `ApproverResolverRegistry` - -注册表只是按 `approver.type` 选择策略,不承担流程推进逻辑。 - -#### 6.2 业务审批处理器 - -```go -// BusinessApprovalHandler 处理审批结果对应的业务动作 -type BusinessApprovalHandler interface { - BusinessType() string - BuildSnapshot(ctx context.Context, bizID uint) (BusinessSnapshot, error) - OnApproved(ctx context.Context, event ProcessApprovedEvent) error - OnRejected(ctx context.Context, event ProcessRejectedEvent) error - OnReturned(ctx context.Context, event ProcessReturnedEvent) error -} -``` - -`BuildSnapshot` 在业务单创建、重新提交并启动流程的同一事务前读取业务事实,返回标题、编号、提交人、关键字段和业务资料元数据;写入实例后不随业务表后续变化而变化。退款和充值分别实现处理器,并注册到 `BusinessApprovalHandlerRegistry`。异步处理器必须使用状态条件更新或业务幂等键,允许 Asynq 重试。 - -#### 6.3 审批动作业务扩展 - -少数业务需要在审批动作中确认业务字段,例如退款最终审批需要确认实际退款金额。审批引擎不解析这些字段,只提供受控扩展端口: - -```go -// BusinessApprovalActionAdapter 处理审批动作中的业务字段。 -type BusinessApprovalActionAdapter interface { - BusinessType() string - ResolveActionForm(ctx context.Context, action ActionContext) ([]ActionField, error) - ApplyBeforeApprove(ctx context.Context, tx Transaction, action ActionContext, fields map[string]any) (map[string]any, error) -} -``` - -`Transaction` 是 Application 层的事务上下文抽象,由现有 GORM `TxManager` 实现;领域对象不直接依赖 GORM。 - -- `ResolveActionForm` 返回当前任务允许提交的字段定义,前端按接口渲染,不能按节点名称写死。 -- `ApplyBeforeApprove` 在审批状态转换的同一事务中校验并保存业务字段,返回标准化结果。 -- 标准化结果写入 `tb_approval_operation_log.business_data`,便于审计和幂等重放。 -- 没有注册适配器的业务拒绝非空 `business_fields`,不能静默忽略未知字段。 -- 第一版退款和员工线下充值实现该端口:节点通过 `action_config` 声明 `approved_refund_amount` 或 `operation_password`。字段只在当前操作会完成该节点时返回。 -- 金额必须大于 0,且不能超过申请退款金额和订单实收金额。 -- 会签节点的退款金额由最后一位完成会签的审批人确认;如需让指定人员确定金额,应在流程定义中增加其专属的最终决策节点,不能由第一位会签人预先锁定。 -- `operation_password` 由现有 `OperationPasswordService` 校验,只用于本次请求,不写业务表、操作日志、Outbox、错误日志或访问日志;适配器返回的标准化审计数据必须排除敏感字段。 - -`ProcessApproved` 消费者只读取已经持久化的业务字段执行后续退款,不能在异步阶段重新接受审批人输入。 - -#### 6.4 审批附件存储端口 - -```go -// ApprovalAttachmentStore 校验临时对象并固化为不可覆盖的审计附件。 -type ApprovalAttachmentStore interface { - PrepareImmutable(ctx context.Context, requestID string, refs []AttachmentRef) ([]PreparedAttachment, error) -} -``` - -Infrastructure 使用对象存储 `HeadObject + CopyObject` 实现,并要求复制目标 Key 对同一 `request_id + source_key` 幂等:目标已存在时必须校验 ETag 一致并直接复用,禁止覆盖为不同内容。输入中的文件名只作显示候选值;大小、Content-Type 和 ETag 来自对象存储。该端口不生成下载 URL,下载授权由审批实例查询用例单独处理。 - ---- - -### 七、领域事件与事务 - -核心事件: - -```text -ProcessStarted -TaskCreated -TaskApproved -TaskRejected -TaskReturned -ProcessApproved -ProcessRejected -ProcessReturned -``` - -任务结果事件只携带 `operation_log_id` 和必要的意见摘要,不携带对象存储签名 URL。附件 Key 保留在审批附件表;站内消息第一版只提示“包含附件”,用户进入有权限的审批详情后再获取下载 URL。 - -审批动作先执行事务外预检: - -```text -1. 按 request_id 查询既有操作日志;只有任务、操作人、动作和规范化请求摘要均一致时才返回原结果,否则返回冲突 -2. 规范化并校验审批意见 -3. 调用 ApprovalAttachmentStore 检查附件并复制到不可覆盖的审计前缀 -4. 形成仅供本次请求使用的最终 Key 和附件元数据快照 -``` - -对象存储检查不得在持有流程实例行锁的数据库事务中执行。预检通过后,审批数据库事务必须同时完成: - -```text -1. 锁定或按 version 加载流程实例 -2. 校验 request_id 幂等及请求摘要 -3. 再次校验任务仍属于当前账号且状态未变化 -4. 调用业务动作适配器校验并保存扩展字段(如有) -5. 聚合执行状态转换 -6. 更新任务和审批人状态,保存该审批人的意见 -7. 创建下一节点任务和审批人快照(如需推进) -8. 使用预检快照写操作日志和审批附件记录,并回写审批人的 operation_log_id -9. 写 Outbox 事件 -10. 提交事务 -``` - -对象一旦写入审批附件记录即视为不可变引用,未引用文件清理任务不得删除它;如果预检后任务状态发生变化,数据库事务回滚,固化对象按未引用审计对象处理。 - -Outbox Relay 提交 Asynq 后,消费者负责: - -- 创建站内消息 -- 通知下一节点候选审批人 -- 通知有站内账号的申请人审批结果;外部申请人由业务投影提供结果查询 -- 调用退款、充值等业务处理器 -- 后续扩展企业微信通知 - -通知失败不能阻塞审批,但事件不能丢失。业务处理失败由 Asynq 重试并保留错误日志。 - -#### 7.1 发起流程时序 - -```mermaid -sequenceDiagram - actor User as 提交人 - participant Biz as 退款/充值 Application - participant Approval as StartProcessUseCase - participant DB as PostgreSQL - participant Relay as Outbox Relay - participant Worker as Asynq Worker - - User->>Biz: 提交业务申请 - Biz->>DB: 开启同一数据库事务 - Biz->>DB: 创建业务单 - Biz->>Approval: 发起审批(biz_type, biz_id) - Approval->>DB: 读取已发布定义和业务绑定 - Approval->>DB: 创建实例、首任务、审批人快照、操作日志 - Approval->>DB: 写 ProcessStarted/TaskCreated Outbox - Biz->>DB: 回写 approval_instance_id - DB-->>Biz: 提交成功 - Biz-->>User: 返回业务单和审批摘要 - Relay->>DB: 拉取未投递事件 - Relay->>Worker: 投递通知任务 -``` - -业务单和首个审批任务必须同事务创建。任何一步失败都不能留下“有业务单但没有审批任务”的半成品。 - -#### 7.2 审批与业务回调时序 - -```mermaid -sequenceDiagram - actor Approver as 审批人 - participant API as Approval API - participant Domain as ProcessInstance - participant DB as PostgreSQL - participant Relay as Outbox Relay - participant Worker as Asynq Worker - participant Biz as 退款/充值处理器 - - Approver->>API: approve/reject/return(task_id, request_id) - API->>DB: 开启事务并加载实例/任务/审批人 - API->>Domain: 执行状态转换 - Domain-->>API: 节点等待/推进/流程终态 - API->>DB: 保存状态、日志、下一任务和 Outbox - DB-->>API: 提交成功 - API-->>Approver: 返回最新审批摘要 - Relay->>Worker: 至少一次投递领域事件 - Worker->>Biz: 按 biz_type 调业务处理器 - Biz-->>Worker: 幂等成功或可重试错误 -``` - -审批接口成功只证明审批事实已提交,不证明退款到账或充值入账已经完成。关联业务接口必须返回独立的业务处理状态。 - ---- - -### 八、并发与幂等 - -#### 8.1 流程并发 - -流程实例使用 `version` 乐观锁: - -```sql -UPDATE tb_approval_process_instance -SET current_node_id = ?, - current_node_name = ?, - status = ?, - version = version + 1, - updated_at = NOW() -WHERE id = ? - AND status = 1 - AND version = ?; -``` - -受影响行数为 0 时,返回“审批状态已变化,请刷新后重试”。 - -#### 8.2 任务幂等 - -任务只能从待审批状态更新: - -```sql -UPDATE tb_approval_task_assignee -SET status = ?, operated_at = NOW() -WHERE task_id = ? - AND assignee_id = ? - AND status = 1; -``` - -所有审批动作必须携带 `request_id`,并由操作日志唯一索引防止重复请求。 - ---- - -### 九、应用用例 - -```text -internal/application/approval/ -├── create_definition.go -├── update_definition.go -├── publish_definition.go -├── disable_definition.go -├── bind_business_process.go -├── start_process.go -├── approve_task.go -├── reject_task.go -├── return_task.go -├── list_my_tasks.go -└── get_process_detail.go -``` - -#### 9.1 发起流程 - -```text -1. 根据 biz_type 查询启用的流程绑定 -2. 查询 process_code 当前已发布版本 -3. 校验业务单不存在运行中的审批实例 -4. 由业务处理器构建业务快照,创建定义快照和流程实例 -5. 从 start 节点找到首个 approval 节点 -6. 解析角色或指定账号 -7. 创建审批任务和审批人快照 -8. 保存业务单、流程实例、任务、操作日志和 Outbox -``` - -业务单创建与流程创建必须共享同一个事务,禁止先创建业务单后忽略审批流创建失败。 - -#### 9.2 审批通过 - -```text -1. 根据 task_id 加载流程实例、任务和当前审批人记录 -2. 校验任务属于当前账号且仍为待处理 -3. 聚合执行 Approve -4. 或签/会签策略判断节点是否完成 -5. 节点未完成:保存当前审批结果并等待其他人 -6. 节点完成:根据定义快照激活下一节点 -7. 到达 end:流程变为已通过 -8. 保存操作日志和 Outbox 事件 -``` - -#### 9.3 驳回和退回 - -- 驳回:当前流程永久终止,业务进入已拒绝状态。 -- 退回:当前流程永久终止,业务进入可编辑状态。 -- 重新提交:业务创建新的审批实例,旧实例仅用于历史追溯。 - ---- - -### 十、API 设计 - -#### 10.1 流程定义管理 - -```text -POST /api/admin/approval-definitions -PUT /api/admin/approval-definitions/{id} -POST /api/admin/approval-definitions/{id}/publish -POST /api/admin/approval-definitions/{id}/disable -GET /api/admin/approval-definitions -GET /api/admin/approval-definitions/{id} -PUT /api/admin/approval-bindings/{biz_type} -``` - -只有草稿可以修改。发布接口必须完成完整定义校验。 - -#### 10.2 运行时接口 - -```text -GET /api/admin/approval-tasks?status=1&biz_type=refund&page=1&page_size=20 -POST /api/admin/approval-tasks/{task_id}/approve -POST /api/admin/approval-tasks/{task_id}/reject -POST /api/admin/approval-tasks/{task_id}/return -GET /api/admin/approval-instances/{instance_id} -GET /api/admin/approval-instances/by-business?biz_type=refund&biz_id=123 -POST /api/admin/approval-instances/{instance_id}/attachment-download-urls -POST /api/admin/approval-instances/{instance_id}/business-material-download-urls -``` - -审批动作请求: - -```json -{ - "request_id": "0190f6c9-2a4e-7f19-b8ab-ec11d63e9970", - "comment": "同意", - "attachments": [ - { - "file_key": "attachments/2026/07/13/0190f6c9-evidence.pdf", - "file_name": "退款核对说明.pdf" - } - ], - "business_fields": { - "approved_refund_amount": 10000 - } -} -``` - -`attachments` 可省略,最多 5 个;后端先校验临时对象的上传人、用途和 `request_id`,再以 `file_key` 定位对象,文件类型和大小从对象存储读取,`file_name` 经清理后作为显示快照。`business_fields` 可省略;仅当前节点 `action_config` 声明且本次操作会完成节点的字段允许提交。其他业务或其他节点传入未声明字段时,后端返回参数错误。 - -意见规则由三个动作 DTO 分别校验:`approve` 的 `comment` 可为空,`reject/return` 的 `comment` 必填。后端必须统一去除首尾空白并限制最多 1000 字,不能只依赖前端校验。 - -附件下载请求使用附件 ID,不接受客户端直接提交任意 `file_key`: - -```json -{ - "attachment_ids": [9001, 9002] -} -``` - -单次支持 1~50 个附件 ID。后端先校验当前账号有权查看该流程实例,并确认所有附件都属于该实例的操作日志,再调用对象存储生成短期下载 URL。 - -业务资料下载请求只接受 `business_snapshot.business_materials` 中的 `file_id`,不接受任意 `file_key`。后端先校验流程详情权限,再从实例快照映射到原业务资料对象并生成短期 URL;业务资料不会被伪装成审批附件。 - -审批接口操作 `task_id`,不再直接操作 `flow_id`。 - -#### 10.3 详情响应 - -详情必须动态返回节点和审批人,不使用固定的“部门领导审批人”“财务审批人”字段。`action_form` 只在当前账号拥有对应审批动作时返回: - -```json -{ - "instance_id": 1001, - "process_name": "退款审批", - "process_version": 2, - "status": 1, - "current_node_name": "财务审核", - "business_snapshot": { - "title": "退款申请", - "biz_no": "RF202607140001", - "submitter_name": "张三", - "fields": [ - {"key": "asset_identifier", "label": "资产标识", "value": "8986..."}, - {"key": "requested_refund_amount", "label": "申请退款金额", "value": 10000} - ], - "business_materials": [ - {"file_id": "refund-voucher-1", "file_name": "退款凭证.pdf", "content_type": "application/pdf", "size_bytes": 245760} - ] - }, - "available_actions": ["approve", "reject", "return"], - "action_form": { - "fields": [ - { - "key": "approved_refund_amount", - "type": "money", - "required": false, - "default_value": 10000, - "label": "实际退款金额" - } - ] - }, - "tasks": [ - { - "node_id": "business_review", - "node_name": "业务审核", - "approval_mode": "any", - "status": 2, - "current_user_assignee_status": 2, - "assignees": [ - { - "account_id": 88, - "account_name": "zhangsan", - "status": 2, - "comment": "同意", - "attachments": [ - { - "attachment_id": 9001, - "file_name": "退款核对说明.pdf", - "content_type": "application/pdf", - "size_bytes": 245760 - } - ] - } - ] - } - ] -} -``` - -#### 10.4 权限控制 - -- 流程定义创建、修改、发布、停用和业务绑定:仅流程管理员或系统管理员。 -- 审批任务操作:当前账号必须存在于 `tb_approval_task_assignee` 且状态为待处理。 -- 待我审批列表:按当前账号 ID 查询任务审批人表,不根据前端传入角色判断。 -- 流程详情:有站内账号的申请人、当前审批候选人、业务管理员和系统管理员可以查看。上述人员可查看本实例的业务快照、业务资料、审批意见和审批附件;跳转到实时业务详情时仍受现有店铺/企业数据范围过滤。 -- 审批附件下载:继承流程详情权限,并逐个校验附件归属;响应只返回短期签名 URL,不暴露 Bucket、公网永久地址或未授权 `file_key`。 -- 角色或账号配置在发布时校验存在性;节点激活时再次校验账号启用状态。 -- 所有权限校验在后端完成,前端隐藏按钮不能作为授权依据。 -- `available_actions` 由后端根据当前账号、实例状态和审批人快照计算,前端不得用角色名称自行拼装。 - ---- - -### 十一、业务接入协议 - -#### 11.1 业务表关联 - -退款单和充值单保留当前审批实例 ID: - -```sql -ALTER TABLE tb_xxx ADD COLUMN approval_instance_id BIGINT; -``` - -历史审批通过 `biz_type + biz_id` 查询全部流程实例。业务表只保存当前实例 ID,重新提交时更新为新实例。 - -#### 11.2 业务状态归属 - -- 审批领域只维护流程和任务状态。 -- 退款、充值领域维护各自业务状态。 -- `ProcessApproved/Rejected/Returned` 通过 Outbox 可靠投递。 -- 业务处理器消费事件后,使用条件更新改变业务状态。 -- 审批通过不等同于业务处理成功;代理钱包回退和充值入账具备各自处理状态、业务幂等键和失败重试。第三方/线下退款本期由财务人工确认,不接入支付渠道自动退款。 -- 审批意见和审批附件的权威记录属于审批操作日志。业务表可以为列表、H5 或历史兼容快照最终驳回/退回原因,但不能反向覆盖审批日志,也不重复保存全部附件。 -- 退款的资金边界:仅代理钱包支付订单在审批通过后自动回退到原扣款代理主钱包;个人/客户资产钱包不纳入本期自动回退,微信、支付宝和线下退款均由财务在系统外完成后人工确认。 - -#### 11.3 默认流程 - -本迭代预置两个流程定义: - -- `refund_approval`:退款审批 -- `recharge_approval`:员工线下代充值审批 - -默认节点可配置为两个串行审批节点,但审批节点名称、角色 ID、指定账号和审批方式必须来自流程定义,禁止在代码中使用固定步骤编号或固定角色名称。 - -#### 11.4 停机切换与旧接口下线 - -本次发布明确采用停机切换,不建设新旧审批接口兼容门面: - -1. 维护窗口内停止退款、线下充值和审批写操作。 -2. 完成迁移后同时发布 API、Worker/Relay 和前端。 -3. 初始化并发布两个默认流程定义,绑定 `refund` 和 `recharge`。 -4. 退款按业务单 ID 直接审批接口、`POST /api/admin/agent-recharges/{id}/offline-pay`、`POST /api/admin/agent-recharges/{id}/reject` 在新版本中不再注册。 -5. 所有审批动作统一调用 `/api/admin/approval-tasks/{task_id}/*`。 -6. 验证流程发起、任务审批、结果通知和业务处理状态后再开放系统。 - -停机发布降低了同时维护两套状态转换语义的风险,也避免旧接口绕过任务审批人快照、请求幂等和操作日志。 - -#### 11.5 存量审批数据切换 - -停机期间必须处理存量单据,不能在旧接口下线后留下无法审批的业务记录: - -1. 已通过、已拒绝、已退回等历史终态记录不伪造流程实例,`approval_instance_id` 保持为空;查询接口返回 `approval_source=legacy`,页面只读展示原业务状态、旧审批字段和审计日志。 -2. `status=1` 的待审批退款单,使用发布后的 `refund_approval` 从首节点创建流程实例、任务和审批人快照,并回写 `approval_instance_id`。 -3. 仅对符合“平台员工创建 + `payment_method=offline` + 尚未入账”的存量充值记录创建 `recharge_approval` 实例;代理在线充值不进入审批流。 -4. 回填使用一次性 Application 命令执行,不用裸 SQL 拼装任务;命令按 `biz_type + biz_id` 幂等,已有关联实例时跳过。 -5. 开放访问前必须确认所有需要继续审批的存量业务记录都已关联运行中的流程实例,不允许前端回退到旧审批按钮。 - -历史终态记录没有完整节点、候选人和操作意见时,宁可明确显示“历史审批记录”,也不能根据角色名称或当前账号关系反推并生成虚假的审批时间线。 - ---- - -### 十二、前端技术方案 - -跨需求共性规则见 7月迭代前端技术方案,本节只描述审批流专项。 - -#### 12.1 页面与路由 - -| 页面 | 建议路由 | 说明 | -|------|----------|------| -| 待我审批 | `/approvals/tasks` | 默认仅查询当前账号待处理任务,支持业务类型和时间筛选 | -| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、流程时间线、候选人状态和审批意见 | -| 流程定义列表 | `/settings/approval-flows` | 草稿、已发布、已停用、当前业务绑定 | -| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点有序编辑、角色/账号选择、或签/会签 | - -#### 12.2 流程定义编辑器 - -第一版不做拖拽流程画布。前端使用有序节点列表: - -```text -开始 - ↓ -[业务审核] 审批人=角色:运营主管 方式=或签 - ↓ -[财务审核] 审批人=账号:张三/李四 方式=会签 - ↓ -结束 -``` - -- 开始和结束节点固定且只读。 -- 审批节点支持新增、删除、上移、下移。 -- 角色选择器提交稳定的 `role_id`,账号选择器提交 `user_ids`。 -- 发布前先做前端基础校验,再以发布接口的后端校验结果为准。 -- 已发布版本只读;修改操作创建新版本草稿。 - -#### 12.3 待办与详情 - -- 待办列表按 `task_id` 操作,行点击进入 `instance_id` 详情。 -- 详情首屏显示业务标题、单号、提交人、关键字段和业务资料;审批人可在不跳转业务页面的前提下判断审批对象。 -- 时间线按 `tasks[]` 顺序动态渲染,不固定节点数量和名称。 -- 会签节点展示“已完成人数/总人数”;或签节点展示实际完成人和被取消候选人。 -- 每位已操作审批人分别展示动作、审批意见和附件;多人意见不得合并成一段任务级备注。 -- 通过意见可选,驳回和退回意见必填;附件最多 5 个,上传完成前禁止提交。 -- 附件使用权限校验后的短期下载 URL,页面过期后重新获取,不缓存永久地址。 -- 业务资料和审批附件使用不同下载接口和区域展示;前者来自流程发起快照,后者属于具体审批动作。 -- 操作成功后重新请求实例详情和待办列表,不做乐观推进。 -- 返回并发冲突时提示“审批状态已变化,已为你刷新”,随后重新加载详情。 -- 驳回和退回意见必填规则以后端 DTO 为准,提交期间三个操作按钮统一锁定。 - -#### 12.4 通知跳转 - -`approval.pending` 通知的 `ref_id` 使用审批实例 ID,跳转审批详情。页面加载后再由 `available_actions` 判断当前账号是否可操作,不能因通知到达过就默认拥有审批权限。 - ---- - -### 十三、发布、停用与回滚 - -1. 进入维护模式并确认相关写入口已经停止。 -2. 执行增量迁移,创建定义、实例、任务、审批人、操作日志、审批附件和 Outbox 表,并为退款、充值两个业务表增加审批关联字段。 -3. 同时发布 API、Worker/Relay 和前端,旧审批路由不再注册。 -4. 创建并发布默认流程定义,配置真实角色 ID 或账号 ID,完成退款、充值两个业务绑定。 -5. 执行存量待审批退款和员工线下充值回填命令,并核对不存在应审批但 `approval_instance_id` 为空的记录。 -6. 人工验证退款、线下充值的发起、审批、退回重提、历史记录展示和业务处理状态。 -7. 验证通过后解除维护模式。 - -开放访问前失败时,回滚应用版本和可逆迁移;开放后不得删除已发布定义、历史实例、任务、日志和 Outbox。正在处理的业务事件通过管理查询确认后再决定重试或人工处理。 - ---- - -### 十四、可观测性与风险 - -#### 14.1 必备查询维度 - -- `process_instance_id`、`task_id`、`request_id`、`operation_log_id`、`attachment_id`、`event_id`。 -- `biz_type + biz_id` 对应的全部历史实例。 -- 当前节点、候选审批人和每个审批人的操作状态。 -- Outbox 投递次数、下次重试时间和最后错误。 - -#### 14.2 关键风险 - -| 风险 | 触发条件 | 处理 | -|------|----------|------| -| 节点无审批人 | 角色无人或账号被停用 | 节点激活事务失败,记录流程配置错误,不创建空任务 | -| 两人同时完成节点 | 或签/会签临界并发 | 实例 version + 审批人条件更新,冲突方刷新 | -| 审批成功但业务未完成 | Worker 或业务处理器失败 | 业务处理状态独立展示,Asynq 重试,禁止回滚审批事实 | -| 重复事件 | Relay 或 Worker 至少一次投递 | `event_id`、业务幂等键和状态条件更新 | -| 附件越权下载 | 用户猜测或获得其他流程的附件 ID | 按流程详情权限校验,并验证附件、操作日志和流程实例的完整归属链 | -| 临时附件误用 | 猜测或获得他人临时 `file_key` | 上传记录绑定上传人、用途和 `request_id`,提交时三者必须一致 | -| 非法或孤立附件 | 上传恶意文件,或上传后未提交审批 | 服务端读取对象元数据执行白名单/大小校验;未引用对象按生命周期清理 | -| 旧路由误保留 | 发布包仍注册业务单审批或直接入账接口 | 停机验收中逐个验证旧路由为不可用,审批写操作只允许任务级 API | - ---- - -### 十五、设计模式边界 - -| 能力 | 使用方式 | -|------|---------| -| 状态机 | `ProcessInstance` 聚合维护流程和任务状态转换 | -| 策略模式 | 角色/指定账号解析、或签/会签判断 | -| 适配器模式 | 退款、充值业务处理器和后续业务接入 | -| 领域事件 | 表达任务创建、流程通过、驳回、退回等业务事实 | -| Outbox Pattern | 保证领域事件不会因 Asynq 短暂故障丢失 | -| 命令模式 | Application Command DTO 即可,不额外建立命令类体系 | -| 责任链模式 | 第一版不使用,避免把校验拆成大量小 Handler | -| 条件规则引擎 | 第一版不实现,出现真实条件分支需求后再扩展 | - -审批流的核心不是堆叠设计模式,而是保持流程定义、实例状态、审批任务和业务回调之间的边界稳定。 - - ---- - -> 汇编来源:`docs/7月迭代/前端技术方案.md` - -## 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 契约。 - - ---- - -> 汇编来源:`docs/7月迭代/需求01-复机实名规则.md` - -## 需求01:行业卡后台手动复机允许未实名 - -> 状态:待评审 - ---- - -### 背景 - -**当前代码**:`internal/service/iot_card/stop_resume_service.go:938` - -```go -// ManualStartCard - 当前写法(错误) -if card.RealNameStatus != constants.RealNameStatusVerified { - return errors.New(errors.CodeForbidden, "卡未实名,无法操作") -} -``` - -`isRealnameOK()` 已经正确处理行业卡豁免: - -```go -// 第234行:行业卡无需实名 -func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool { - return card.CardCategory == constants.CardCategoryIndustry || - card.RealNameStatus == constants.RealNameStatusVerified -} -``` - -但 `ManualStartCard` 没有走这个函数,直接判断了 `RealNameStatus`,导致行业卡手动复机也被拦截。 - ---- - -### 修改范围 - -**只改一行**,影响范围极小。 - -**文件**:`internal/service/iot_card/stop_resume_service.go` - -```go -// 修改前(第938行) -if card.RealNameStatus != constants.RealNameStatusVerified { - denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") - ... -} - -// 修改后 -if !s.isRealnameOK(card) { - denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") - ... -} -``` - ---- - -### 前端对接 - -无需前端改动。复机操作界面不变,行业卡原先会报错"卡未实名,无法操作",修复后直接成功。 - - ---- - -> 汇编来源:`docs/7月迭代/需求02-H5流程配置.md` - -## 需求02:H5 流程顺序配置化 - -> 状态:待评审 - ---- - -### 背景 - -H5 用户进入后:绑定手机号 → 充值 → 实名(当前顺序写死) - -需求:允许**按资产个体**配置充值和实名的顺序,支持后台单条或批量改。 - ---- - -### 流程图 - -#### 图一:H5 登录后资产视角判断与策略读取 - -```mermaid -flowchart TD - A[用户扫码 / 输入虚拟号] --> B[解析 identifier] - B --> C{资产类型} - - C -->|card| D{该卡是否绑定设备?} - D -->|否 独立卡| E[卡视角\n读 IotCard.realname_policy] - D -->|是| F[设备视角\n读 Device.realname_policy\n卡自身策略忽略] - - C -->|device| F - - E --> G{realname_policy} - F --> G - - G -->|none| H[无需实名\n直接进充值页] - G -->|before_order| I[先进实名页\n实名完成后才能充值] - G -->|after_order| J[先进充值页\n充值完成后提示实名] -``` - -#### 图二:H5 充值/购买前的策略拦截逻辑 - -```mermaid -flowchart TD - A[用户发起充值/购买] --> B[读取 resolved.Asset.RealnamePolicy] - B --> C{策略是 before_order?} - C -->|否| D[放行,正常创建订单] - C -->|是| E{当前资产 RealNameStatus == 1?} - E -->|已实名| D - E -->|未实名| F[返回 CodeNeedRealname\nH5 跳转实名页] -``` - -#### 图三:GetEffectiveRealnamePolicy 取值逻辑 - -```mermaid -flowchart TD - A[GetEffectiveRealnamePolicy\ncard, device] --> B{device != nil?} - B -->|是| C[返回 device.RealnamePolicy] - B -->|否| D{card != nil?} - D -->|是| E[返回 card.RealnamePolicy] - D -->|否| F[返回 none] -``` - ---- - -### 资产类型与策略归属 - -系统有两类资产:**独立卡** 和 **设备**。 - -| 资产类型 | realname_policy 归属 | H5 视角 | -|---------|---------------------|---------| -| 独立卡(无设备绑定) | 卡自身的 `realname_policy` | 卡视角 | -| 设备 | 设备自身的 `realname_policy` | 设备视角 | -| 设备下的卡 | **设备**的 `realname_policy`(卡自身策略无效) | 设备视角 | - -**关键规则**:设备下的卡无法以卡视角独立登录 H5,登录后自动进入设备视角,因此实名流程策略由设备决定,卡自身的 `realname_policy` 字段对 H5 流程无影响(但字段保留,仅作记录)。 - -该逻辑已在 `internal/service/asset/service.go:GetEffectiveRealnamePolicy()` 实现:设备不为 nil 时取设备策略,否则取卡策略。 - ---- - -### 当前字段状态 - -两个模型都已有 `realname_policy` 字段,无需迁移: - -```go -// internal/model/iot_card.go -RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null; - comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"` - -// internal/model/device.go -RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null; - comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"` -``` - -值含义: -- `none` — 无需实名 -- `before_order` — 先实名后充值 -- `after_order` — 先充值后实名(**当前默认**) - ---- - -### 后端实现 - -#### 1. 单条修改接口(已有,无需新建) - -``` -PATCH /api/admin/assets/:identifier/realname-mode -``` - -该接口已在 `internal/handler/admin/asset.go:UpdateRealnamePolicy()` 实现,通过 identifier 自动解析资产类型,卡和设备都走这里,**不需要再建卡专属或设备专属路由**。 - -请求体(已有 DTO): -```go -type UpdateAssetRealnamePolicyRequest struct { - RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" - description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"` -} -``` - -#### 2. 新增批量修改接口(需新建) - -卡和设备分开批量接口,因为两者在后台是不同的列表页。 - -##### 2a. 批量修改卡实名策略 - -``` -POST /api/admin/iot-cards/batch-update-realname-policy -``` - -请求体: -```go -type BatchUpdateIotCardRealnamePolicy struct { - IotCardIDs []uint `json:"iot_card_ids" validate:"required,min=1,max=500,dive,gt=0" description:"卡ID列表(最多500条)"` - RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" - description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"` -} -``` - -Service:在一个事务中校验最多 500 条 ID 均存在且均在当前账号数据范围内,再执行 `UPDATE tb_iot_card SET realname_policy = ? WHERE id IN (...)`。任一记录不合法则整批回滚,并写一条包含目标策略和 ID 数量的批量审计日志。 - -##### 2b. 批量修改设备实名策略 - -``` -POST /api/admin/devices/batch-update-realname-policy -``` - -请求体: -```go -type BatchUpdateDeviceRealnamePolicy struct { - DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=500,dive,gt=0" description:"设备ID列表(最多500条)"` - RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" - description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"` -} -``` - -Service:与卡批量接口相同,单次最多 500 条、事务内全成全败;任一设备不存在或越权则不更新任何记录。 - -#### 3. 全局默认值(兜底) - -新建卡/设备时默认 `after_order`,通过 GORM default 标签保证,不需要读 `tb_system_config`。 - ---- - -### 前端对接 - -#### 卡列表页 - -"操作"列或批量操作下拉增加"设置实名策略": - -- **单条**:弹框选策略 → `PATCH /api/admin/assets/{iccid}/realname-mode` -- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/iot-cards/batch-update-realname-policy` - -> 注意:设备下的卡即使在卡列表中修改了策略,对 H5 流程也无效(H5 取设备策略)。建议在卡列表展示"所属设备"列,提示运营该卡已属于某设备,实名策略需到设备处修改。 - -#### 设备列表页 - -"操作"列或批量操作下拉增加"设置实名策略": - -- **单条**:弹框选策略 → `PATCH /api/admin/assets/{sn}/realname-mode` -- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/devices/batch-update-realname-policy` - -#### 字段展示 - -卡列表/详情、设备列表/详情均展示"实名策略"字段: - -| realname_policy | 展示文案 | -|----------------|---------| -| `none` | 无需实名 | -| `before_order` | 先实名后充值 | -| `after_order` | 先充值后实名 | - -#### H5 侧(C端) - -H5 读取资产初始化接口返回的 `realname_policy` 字段,决定先跳充值页还是先跳实名页。 - -- 独立卡登录 → 读卡的 `realname_policy` -- 设备/设备下的卡登录 → 读设备的 `realname_policy` - -该逻辑由 `GetEffectiveRealnamePolicy()` 统一处理,H5 无需区分资产类型,直接用接口返回值即可。 - ---- - -### 实施范围汇总 - -| 项目 | 状态 | 说明 | -|------|------|------| -| `IotCard.realname_policy` 字段 | ✅ 已有 | 无需迁移 | -| `Device.realname_policy` 字段 | ✅ 已有 | 无需迁移 | -| 单条修改接口 | ✅ 已有 | `PATCH /api/admin/assets/:identifier/realname-mode` | -| `GetEffectiveRealnamePolicy()` | ✅ 已有 | 设备视角取设备策略 | -| H5 充值前校验 | ✅ 已有 | `client_wallet.go` 已正确读取 | -| 批量修改卡接口 | ❌ 待建 | `POST /api/admin/iot-cards/batch-update-realname-policy` | -| 批量修改设备接口 | ❌ 待建 | `POST /api/admin/devices/batch-update-realname-policy` | -| 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 | -| 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 | - - ---- - -> 汇编来源:`docs/7月迭代/需求03-07-11-12-13-简单改动.md` - -## 需求03/07/11/12/13:简单改动合集 - -> 状态:待评审 - ---- - -### 需求03:店铺列表搜索新增联系电话 - -#### 后端 - -`Shop` 表已有 `contact_phone` 字段。仅需在列表查询接口新增过滤条件。 - -**文件**:`internal/store/postgres/shop_store.go`(列表查询 Store 方法) - -```go -// 现有过滤条件基础上追加 -if req.ContactPhone != "" { - query = query.Where("contact_phone = ?", req.ContactPhone) -} -``` - -**DTO 变更**:`internal/model/dto/shop_dto.go` 的 `ShopListRequest` 新增: - -```go -ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话(精确匹配,11位)"` -``` - -#### 前端 - -店铺列表搜索栏新增"联系电话"输入框,填入后带入 `contact_phone` 参数请求。 - ---- - -### 需求07:IoT卡/设备管理新增已实名/未实名筛选 - -#### IoT 卡 - -`IotCard.real_name_status` 已有(0=未实名, 1=已实名),`ListStandaloneIotCardRequest` 无该过滤字段,需新增。 - -**DTO 变更**(`internal/model/dto/iot_card_dto.go` → `ListStandaloneIotCardRequest` 新增): - -```go -RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"` -``` - -**Store 追加**(`internal/store/postgres/iot_card_store.go`): -```go -if req.RealNameStatus != nil { - query = query.Where("real_name_status = ?", *req.RealNameStatus) -} -``` - -#### 设备 - -设备本身目前无 `real_name_status` 字段。语义为:任意一张绑定卡已实名 = 设备已实名。 - -为避免列表查询时走 EXISTS 子查询,改为**快照方案**:在 `Device` 表落盘,轮询时维护。 - -##### 迁移 - -`tb_device` 新增字段: - -```sql -ALTER TABLE tb_device - ADD COLUMN real_name_status INT NOT NULL DEFAULT 0; - -COMMENT ON COLUMN tb_device.real_name_status - IS '实名状态快照(0=未实名,1=已实名),任意绑定卡已实名则为1,由轮询异步维护'; -``` - -**Model**(`internal/model/device.go`): -```go -RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态快照(0=未实名,1=已实名),任意绑定卡已实名则为1" json:"real_name_status"` -``` - -##### 快照更新时机 - -以下两处卡实名状态变化时,需同步更新所属设备的快照: - -**1. 轮询实名处理**(`internal/task/polling_realname_handler.go`) - -卡状态变化后,已有 `triggerDeviceRealnameActivation` 查出 `deviceID`,在此同步更新设备快照: - -```go -// statusChanged 时,如果卡属于某设备,重新计算并写入设备快照 -if statusChanged { - if binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, cardID); err == nil { - h.deviceStore.RefreshRealnameSnapshot(ctx, binding.DeviceID) - } -} -``` - -**2. 管理员手动修改卡实名状态**(`internal/service/iot_card/service.go:ManualUpdateRealnameStatus`) - -更新卡状态成功后,查所属设备并更新快照(同上逻辑)。 - -##### 快照计算 - -`DeviceStore.RefreshRealnameSnapshot`: - -```go -// RefreshRealnameSnapshot 重新计算并写入设备实名状态快照 -func (s *DeviceStore) RefreshRealnameSnapshot(ctx context.Context, deviceID uint) error { - var count int64 - s.db.WithContext(ctx).Raw(` - SELECT COUNT(*) FROM tb_device_sim_binding dsb - JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id - WHERE dsb.device_id = ? AND dsb.deleted_at IS NULL - AND ic.real_name_status = 1 AND ic.deleted_at IS NULL - `, deviceID).Scan(&count) - status := 0 - if count > 0 { - status = 1 - } - return s.db.WithContext(ctx).Model(&model.Device{}). - Where("id = ?", deviceID). - Update("real_name_status", status).Error -} -``` - -##### DTO 变更 - -**请求**(`internal/model/dto/device_dto.go` → `ListDeviceRequest` 新增): -```go -RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"` -``` - -**响应**(`DeviceResponse` 新增): -```go -RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"` -RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"` -``` - -**Store 过滤**(直接 WHERE,无需 EXISTS): -```go -if req.RealNameStatus != nil { - query = query.Where("real_name_status = ?", *req.RealNameStatus) -} -``` - -#### 前端 - -IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名)→ 传 `real_name_status=0|1`。 -设备管理同上,列表展示 `real_name_status_name` 字段。 - ---- - -### 需求11:资产详情-套餐到期时间字段 + 15天高亮 - -#### 后端 - -**无需改动。** - -后台资产详情页实际调用的是: - -``` -GET /api/admin/assets/resolve/:identifier -``` - -该接口的 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)已包含: - -```go -CurrentPackage string `json:"current_package"` // 当前套餐名称 -CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at"` // 开始时间 -CurrentPackageExpiresAt *time.Time `json:"current_package_expires_at"` // 到期时间(无套餐为 null) -``` - -到期时间字段已有,前端直接读 `current_package_expires_at` 即可,不需要新增后端字段。 - -#### 前端 - -资产详情页"套餐信息"板块展示到期时间,并在剩余 ≤15 天时高亮: - -- 读取 `resolve` 接口返回的 `current_package_expires_at` -- 若为 `null`:展示"暂无套餐" -- 剩余天数由前端计算:`Math.ceil((expiresAt - now) / 86400000)` -- 剩余 ≤15 天:**红色/高亮**展示(建议红色文字 + 标签) - ---- - -### 需求12:换货管理显示修复 - -#### 背景 - -换货单表 `tb_exchange_order`: -- `old_asset_identifier` — 旧资产标识符快照 -- `new_asset_identifier` — 新资产标识符快照 -- `old_asset_id` / `new_asset_id` — 旧/新资产主键 - -#### EXC-001/EXC-002:旧/新资产标识显示不一致 - -**根本原因**:后端创建换货单时快照逻辑有误(`internal/service/exchange/service.go`)。 - -- 卡的旧资产:快照了 `card.VirtualNo`(虚拟号),**应为 `card.ICCID`** -- 卡的新资产:快照了操作员输入的 identifier 原值,未规范化,**应统一为 `card.ICCID`** -- 设备:快照 `VirtualNo` 优先,没有则 `IMEI`,**逻辑正确,无需改动** - -**修复**(`internal/service/exchange/service.go`): - -`resolveAssetByIdentifierWithTx` 及锁定资产路径中,卡的 `Identifier` 改为 `card.ICCID`: - -```go -// 修复前 -return &resolvedExchangeAsset{..., Identifier: card.VirtualNo, ...} - -// 修复后 -return &resolvedExchangeAsset{..., Identifier: card.ICCID, ...} -``` - -历史数据不回填,仅修正后续新建换货单的快照行为。 - -#### EXC-003/EXC-004:旧/新资产搜索支持 ICCID/接入号/虚拟号 - -**方案**:拆分为独立的旧资产和新资产搜索,搜索逻辑用**两步查询**,不用 JOIN。 - -**DTO 变更**(`internal/model/dto/exchange_dto.go` → `ExchangeListRequest`): - -废弃原有 `Identifier` 字段,改为: -```go -OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" description:"旧资产搜索(ICCID/接入号/虚拟号)"` -NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" description:"新资产搜索(ICCID/接入号/虚拟号)"` -``` - -**Store 修改**(`internal/store/postgres/exchange_order_store.go`): - -两步查询——先在资产表搜出 ID,再过滤换货表: - -```go -// 步骤1:旧资产关键词搜索 -if req.OldAssetKeyword != "" { - kw := "%" + req.OldAssetKeyword + "%" - var cardIDs []uint - s.db.WithContext(ctx).Table("tb_iot_card"). - Where("(iccid LIKE ? OR virtual_no LIKE ? OR msisdn LIKE ?) AND deleted_at IS NULL", kw, kw, kw). - Pluck("id", &cardIDs) - var deviceIDs []uint - s.db.WithContext(ctx).Table("tb_device"). - Where("(virtual_no LIKE ? OR imei LIKE ?) AND deleted_at IS NULL", kw, kw). - Pluck("id", &deviceIDs) - - if len(cardIDs) == 0 && len(deviceIDs) == 0 { - return &ExchangeListResult{}, nil // 无匹配,直接返回空 - } - query = query.Where( - "(old_asset_type = 'iot_card' AND old_asset_id IN ?) OR (old_asset_type = 'device' AND old_asset_id IN ?)", - cardIDs, deviceIDs, - ) -} -// new_asset_keyword 同理,过滤 new_asset_id -``` - -#### 前端 - -- EXC-001/002:后端修复后,`old_asset_identifier` 和 `new_asset_identifier` 均为 ICCID(卡)或设备号(设备),展示直接读这两个字段即可 -- EXC-003/004:搜索栏拆分为"旧资产"和"新资产"两个独立输入框,分别传 `old_asset_keyword` 和 `new_asset_keyword` - ---- - -### 需求13:列表字段新增 - -#### 核心原则 - -- 提交人账号名在业务单创建时快照到业务表。 -- 审批节点、候选审批人和实际操作人快照统一保存在审批流任务表,不在业务表写死具体节点字段。 -- 列表查询审批信息时,根据本页全部 `approval_instance_id` 批量查询并在内存分组,禁止逐条查询造成 N+1。 - ---- - -#### COL-003:换货管理列表新增提交人(待建) - -> 需求文档原写"换号管理",确认为"换货管理"(系统无"换号"概念)。 - -**迁移**:`tb_exchange_order` 新增字段: -```sql -ALTER TABLE tb_exchange_order ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT ''; -``` - -**Model**(`internal/model/exchange_order.go`): -```go -SubmitterName string `gorm:"column:submitter_name;type:varchar(50);not null;default:'';comment:提交人账号名快照" json:"submitter_name"` -``` - -**创建换货单时**(`internal/service/exchange/service.go`)快照当前操作人 username: -```go -SubmitterName: middleware.GetUsername(ctx), // 从 ctx 取当前登录账号的 username -``` - -**响应 DTO**(`internal/model/dto/exchange_dto.go` → `ExchangeOrderResponse` 新增): -```go -SubmitterName string `json:"submitter_name" description:"提交人账号名"` -``` - ---- - -#### COL-001:退款管理列表新增提交人、审批人(依赖审批流) - -**迁移**:`tb_refund_request` 新增提交人快照字段;`approval_instance_id` 由需求18/20统一增加: -```sql -ALTER TABLE tb_refund_request - ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT ''; -``` - -- `submitter_name`:创建退款单时快照操作人 username -- 审批状态、当前节点和审批记录:从审批实例、任务和任务审批人快照批量读取 - -> **实施依赖**:动态审批摘要依赖审批流(需求20);`submitter_name` 可独立实现。 - -**响应 DTO**(退款列表响应新增): -```go -SubmitterName string `json:"submitter_name" description:"提交人账号名"` -ApprovalSource string `json:"approval_source" description:"审批来源 (none:无需审批, workflow:通用审批流, legacy:历史业务审批)"` -ApprovalStatus int `json:"approval_status" description:"审批状态 (1:审批中, 2:已通过, 3:已驳回, 4:已退回)"` -ApprovalStatusName string `json:"approval_status_name" description:"审批状态名称(中文)"` -CurrentApprovalNode string `json:"current_approval_node" description:"当前审批节点名称"` -ApprovalRecords []ApprovalRecordSummary `json:"approval_records" description:"审批节点和审批人摘要"` -ProcessingStatus int `json:"processing_status" description:"审批通过后的业务处理状态"` -ProcessingStatusName string `json:"processing_status_name" description:"业务处理状态名称(中文)"` -``` - -`ApprovalRecordSummary` 动态返回 `node_name`、`approval_mode`、`status` 和审批人列表;每位已操作审批人包含动作、审批意见和 `attachment_count`,但列表接口不返回完整附件元数据。不假设固定存在“部门领导”或“财务”节点。 - -停机发布前已经结束且没有流程实例的退款记录返回 `approval_source=legacy`。这类记录可以使用原 `processor_id`、`processed_at` 和审计日志组成只读历史摘要,但不得伪造多节点审批时间线;发布时仍待审批的记录必须先回填通用审批实例。 - ---- - -#### COL-002:代理充值列表新增提交人、审批人(依赖审批流) - -与 COL-001 同理,`tb_agent_recharge_record` 仅新增 `submitter_name` 快照字段;`approval_instance_id` 由需求18/21统一增加。审批摘要从审批流批量读取。历史终态充值返回 `approval_source=legacy` 并只读展示原状态和审计信息。 - -> **实施依赖**:`submitter_name` 本迭代可实现;动态审批摘要依赖需求21(充值审批流)。 - ---- - -#### 前端 - -退款和充值列表增加“审批状态 / 当前节点 / 业务处理状态 / 审批记录”展示。审批记录按节点动态渲染,不能固定绑定两个审批人字段;审批已通过后的代理钱包退款可显示“回退处理中”,其他支付方式显示“待人工退款”,都不能显示成“待审批”。`approval_source=legacy` 时显示“历史审批”标识且不提供操作按钮。 - - ---- - -> 汇编来源:`docs/7月迭代/需求04-06-退款拦截与最后到期时间.md` - -## 需求04:退款中资产禁止换货 -## 需求06:资产最后到期时间 - -> 状态:待评审 - ---- - -### 需求04:退款中禁止换货 - -#### 业务规则 - -资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。 - -拦截范围: - -- `status=1` 待审批。 -- `status=4` 已退回,等待提交人修改。 -- `status=2` 已通过但 `processing_status!=2`,实际退款仍在处理、等待处理或失败重试。 - -不拦截:`status=3` 已拒绝,或 `status=2 AND processing_status=2` 已完成实际退款。`processing_status` 由需求20新增。 - -#### 数据模型 - -退款模型:`RefundRequest`(表 `tb_refund_request`) - -资产字段为两个独立字段(无 asset_type/asset_id): -- `iot_card_id *uint`:IoT卡ID(卡类资产) -- `device_id *uint`:设备ID(设备类资产) - -状态常量(`internal/model/refund.go`): -```go -RefundStatusPending = 1 // 待审批 -RefundStatusApproved = 2 // 已通过 -RefundStatusRejected = 3 // 已拒绝 -RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货) -``` - -#### 实现位置 - -换货单创建入口:`internal/service/exchange/service.go` 创建前校验。 - -#### 后端 - -**Store 新增方法**(`internal/store/postgres/refund_store.go`): - -```go -// HasActiveRefundByCard 检查指定IoT卡是否存在未结束的退款申请 -func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (bool, error) { - var count int64 - err := s.db.WithContext(ctx).Model(&model.RefundRequest{}). - Where(`iot_card_id = ? - AND deleted_at IS NULL - AND ( - status IN (?, ?) - OR (status = ? AND processing_status <> ?) - )`, - cardID, - model.RefundStatusPending, // 1=待审批 - model.RefundStatusReturned, // 4=已退回(拦截) - model.RefundStatusApproved, // 2=审批已通过 - constants.ProcessingStatusSucceeded, - ).Count(&count).Error - return count > 0, err -} - -// HasActiveRefundByDevice 检查指定设备是否存在未结束的退款申请 -func (s *RefundStore) HasActiveRefundByDevice(ctx context.Context, deviceID uint) (bool, error) { - var count int64 - err := s.db.WithContext(ctx).Model(&model.RefundRequest{}). - Where(`device_id = ? - AND deleted_at IS NULL - AND ( - status IN (?, ?) - OR (status = ? AND processing_status <> ?) - )`, - deviceID, - model.RefundStatusPending, // 1=待审批 - model.RefundStatusReturned, // 4=已退回(拦截) - model.RefundStatusApproved, // 2=审批已通过 - constants.ProcessingStatusSucceeded, - ).Count(&count).Error - return count > 0, err -} -``` - -**Service 校验**(`internal/service/exchange/service.go` 创建换货单前调用): - -```go -// validateNoActiveRefund 校验资产是否有未结束的退款申请 -func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *resolvedExchangeAsset) error { - var hasActive bool - var err error - if asset.CardID != nil { - hasActive, err = s.refundStore.HasActiveRefundByCard(ctx, *asset.CardID) - } else if asset.DeviceID != nil { - hasActive, err = s.refundStore.HasActiveRefundByDevice(ctx, *asset.DeviceID) - } - if err != nil { - return err - } - if hasActive { - return errors.New(errors.CodeForbidden, "该资产存在退款申请,无法操作换货") - } - return nil -} -``` - -#### 前端 - -无需改动。换货申请时后端返回错误,前端展示错误信息即可。 - ---- - -### 需求06:资产详情-所有套餐的最后到期时间 - -#### 业务规则 - -资产详情页展示:**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最后到期时间**。 - -不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。 - -加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。 - -#### 接口 - -后台资产详情页实际调用的是: - -``` -GET /api/admin/assets/resolve/:identifier -``` - -响应 DTO:`AssetResolveResponse`(`internal/model/dto/asset_dto.go`) - -该 DTO 目前**不含** `last_package_expires_at` 字段,需新增。 - -#### 后端 - -**DTO 新增字段**(`internal/model/dto/asset_dto.go` → `AssetResolveResponse`): - -```go -// 当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为 null -LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为null"` -``` - -**Store 新增方法**(`internal/store/postgres/package_usage_store.go`): - -```go -// GetProjectableMainPackagesByCardID 获取可推算的当前/排队主套餐。 -func (s *PackageUsageStore) GetProjectableMainPackagesByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) { - var usages []*model.PackageUsage - err := s.db.WithContext(ctx). - Where("iot_card_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", cardID, - constants.PackageUsageStatusActive, // 1=生效中 - constants.PackageUsageStatusPending, // 0=待生效 - constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期 - ). - Order("priority ASC, created_at ASC, id ASC"). - Find(&usages).Error - return usages, err -} - -// GetProjectableMainPackagesByDeviceID 获取可推算的当前/排队主套餐。 -func (s *PackageUsageStore) GetProjectableMainPackagesByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) { - var usages []*model.PackageUsage - err := s.db.WithContext(ctx). - Where("device_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", deviceID, - constants.PackageUsageStatusActive, // 1=生效中 - constants.PackageUsageStatusPending, // 0=待生效 - constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期 - ). - Order("priority ASC, created_at ASC, id ASC"). - Find(&usages).Error - return usages, err -} -``` - -新建 `PackageUsage` 必须快照 `calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot`,与需求05的 `expiry_base_snapshot` 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容。 - -**Service 计算逻辑**(在 `ResolveAsset` 结果组装处添加): - -```go -// 1. 找当前主套餐的 expires_at 作为 cursor。 -// 2. 按 priority、created_at、id 遍历排队主套餐。 -// 3. 每个排队套餐以 cursor 为预计激活点,使用其购买时长快照计算新的 cursor。 -// 4. cursor 即预计最后到期时间。 -lastExpiry, err := s.packageUsageStore.ProjectLastMainPackageExpiry(ctx, assetType, assetID, now) -if err != nil { - return nil, err -} -resp.LastPackageExpiresAt = lastExpiry -``` - -`ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。 - -#### 前端 - -资产详情"套餐信息"板块新增展示: - -``` -预计最后到期时间:2027-01-01 -``` - -读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。 - - ---- - -> 汇编来源:`docs/7月迭代/需求05-套餐分配生效条件.md` - -## 需求05:套餐分配生效条件(ExpiryBase 覆盖) - -> 状态:待评审 - ---- - -### 背景 - -`Package.ExpiryBase` 已存在(`from_activation` / `from_purchase`),在套餐创建时设定,控制套餐何时开始计时。 - -需求:分配套餐给代理时,可以对单条分配记录二次覆盖这个值。 - ---- - -### 快照链设计 - -```mermaid -flowchart TD - Package[套餐默认 ExpiryBase] --> Effective{分配记录是否覆盖?} - Allocation[ShopPackageAllocation.expiry_base_override] --> Effective - Effective -->|有覆盖| Override[使用分配覆盖值] - Effective -->|无覆盖| Default[使用套餐默认值] - Override --> Snapshot[订单创建时写入 PackageUsage.expiry_base_snapshot] - Default --> Snapshot - Snapshot --> Activation[套餐激活只读快照] - Legacy[旧数据快照为空] --> Fallback[兜底读取套餐默认值] - Fallback --> Activation -``` - -遗留数据兜底:`ExpiryBaseSnapshot` 为空(旧数据)时,回退读 `pkg.ExpiryBase`,行为不变。 - ---- - -### 数据库变更 - -#### 1. ShopPackageAllocation 新增覆盖字段 - -```sql -ALTER TABLE tb_shop_package_allocation - ADD COLUMN expiry_base_override VARCHAR(30); - -COMMENT ON COLUMN tb_shop_package_allocation.expiry_base_override - IS '生效条件覆盖(NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效)'; -``` - -#### 2. PackageUsage 新增快照字段 - -```sql -ALTER TABLE tb_package_usage - ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT '', - ADD COLUMN calendar_type_snapshot VARCHAR(20) NOT NULL DEFAULT '', - ADD COLUMN duration_months_snapshot INT NOT NULL DEFAULT 0, - ADD COLUMN duration_days_snapshot INT NOT NULL DEFAULT 0; - -COMMENT ON COLUMN tb_package_usage.expiry_base_snapshot - IS '生效条件快照(创建时从分配记录取有效值写入,空字符串=旧数据兜底读套餐原值)'; -COMMENT ON COLUMN tb_package_usage.calendar_type_snapshot - IS '周期类型快照(空字符串=旧数据兜底读套餐原值)'; -COMMENT ON COLUMN tb_package_usage.duration_months_snapshot - IS '月数快照(0=旧数据兜底读套餐原值)'; -COMMENT ON COLUMN tb_package_usage.duration_days_snapshot - IS '天数快照(0=旧数据兜底读套餐原值)'; -``` - -旧数据不回填,默认空字符串,激活时自动兜底。 - ---- - -### Model 变更 - -#### ShopPackageAllocation(`internal/model/shop_package_allocation.go`) - -```go -// ExpiryBaseOverride 生效条件覆盖 -// NULL = 使用宿主套餐的 ExpiryBase;有值 = 分配时指定,不受套餐后续修改影响 -ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);comment:生效条件覆盖 NULL=使用套餐默认 from_activation=实名即生效 from_purchase=购买即生效" json:"expiry_base_override"` -``` - -#### PackageUsage(`internal/model/package.go`) - -```go -// ExpiryBaseSnapshot 生效条件快照(创建订单时写入,空字符串=旧数据兜底读套餐原值) -ExpiryBaseSnapshot string `gorm:"column:expiry_base_snapshot;type:varchar(30);not null;default:'';comment:生效条件快照 创建时从分配记录取有效值" json:"expiry_base_snapshot"` - -// 以下三个字段和 ExpiryBaseSnapshot 一起固化,供激活和排队最终到期时间计算使用。 -CalendarTypeSnapshot string `gorm:"column:calendar_type_snapshot;type:varchar(20);not null;default:'';comment:套餐周期类型快照" json:"calendar_type_snapshot"` -DurationMonthsSnapshot int `gorm:"column:duration_months_snapshot;not null;default:0;comment:套餐月数快照" json:"duration_months_snapshot"` -DurationDaysSnapshot int `gorm:"column:duration_days_snapshot;not null;default:0;comment:套餐天数快照" json:"duration_days_snapshot"` -``` - ---- - -### 业务逻辑变更 - -#### 1. 订单创建时快照(`internal/service/order/service.go`) - -订单创建已通过 `GetByShopAndPackage` 查询分配记录(现有逻辑),在此基础上追加: - -```go -// 取生效条件有效值:分配覆盖 > 套餐默认 -expiryBase := pkg.ExpiryBase -if allocation.ExpiryBaseOverride != nil && *allocation.ExpiryBaseOverride != "" { - expiryBase = *allocation.ExpiryBaseOverride -} -// 创建 PackageUsage 时一次性写入计时快照 -usage.ExpiryBaseSnapshot = expiryBase -usage.CalendarTypeSnapshot = pkg.CalendarType -usage.DurationMonthsSnapshot = pkg.DurationMonths -usage.DurationDaysSnapshot = pkg.DurationDays -``` - -#### 2. 激活时读快照(`internal/service/package/activation_service.go`) - -```go -// 新订单只读购买快照;旧记录兼容回退套餐当前值。 -expiryBase := usage.ExpiryBaseSnapshot -if expiryBase == "" { - expiryBase = pkg.ExpiryBase -} -calendarType := usage.CalendarTypeSnapshot -if calendarType == "" { - calendarType = pkg.CalendarType -} -durationMonths := usage.DurationMonthsSnapshot -if durationMonths == 0 { - durationMonths = pkg.DurationMonths -} -durationDays := usage.DurationDaysSnapshot -if durationDays == 0 { - durationDays = pkg.DurationDays -} -``` - -同文件所有激活和排队接续位置都使用同一快照解析函数,禁止某一处重新读取可修改的 `Package` 字段。 - -`internal/service/order/service.go` 中后台囤货路径的 `ExpiryBase` 判断也使用已创建的使用记录快照;需求06的“预计最后到期时间”同样只读这组快照,保证购买后套餐配置变更不会改写历史预测。 - ---- - -### API 变更 - -#### 1. 分配套餐接口(新增参数) - -``` -POST /api/admin/shop-package-allocations -``` - -请求 DTO 新增字段: - -```go -ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件覆盖(不传=使用套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"` -``` - -#### 2. 修改已分配套餐的生效条件(新接口) - -``` -PATCH /api/admin/shop-package-allocations/{id}/expiry-base -``` - -请求 DTO: - -```go -type UpdateAllocationExpiryBaseRequest struct { - ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件(null=恢复套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"` -} -``` - -> 注意:修改已有分配记录的覆盖值,**不影响**已创建的 PackageUsage(快照已定),只影响后续新建的订单。 - ---- - -### 前端对接 - -#### 套餐分配弹框 - -新增"生效条件"选择项: - -``` -生效条件: - ○ 跟随套餐默认(默认选中,不传 expiry_base_override) - ○ 购买即生效(from_purchase) - ○ 实名即生效(from_activation) -``` - -#### 已分配套餐列表 - -列表新增"生效条件"列: - -| 值 | 展示 | -|----|------| -| NULL | 套餐默认 | -| `from_activation` | 实名即生效(已覆盖) | -| `from_purchase` | 购买即生效(已覆盖) | - -操作列增加"修改生效条件"按钮,调用 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`。 - - ---- - -> 汇编来源:`docs/7月迭代/需求08-设备批量分配Excel.md` - -## 需求08:设备批量分配代理或套餐系列(Excel导入) - -> 状态:待评审 -> 模板:前端静态资源,后端仅负责上传校验和异步处理。 - ---- - -### 背景 - -设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)完成两项独立操作: - -1. 批量分配设备给代理。 -2. 批量分配设备给套餐系列。 - -两项操作可以复用解析、异步任务和失败明细基础设施,但**一个任务只能执行一个业务命令**,不能在一次提交中同时修改 `shop_id` 和 `series_id`。 - -`Device` 表已有 `shop_id *uint` 和 `series_id *uint` 字段,分配即更新这两个字段。 - -```mermaid -sequenceDiagram - actor User as 平台员工 - participant Web as 设备管理前端 - participant API as BatchAllocation API - participant DB as PostgreSQL - participant Worker as Asynq Worker - - User->>Web: 选择“分配代理”或“分配套餐系列”并上传 Excel - Web->>API: POST /api/admin/devices/batch-assign-shop 或 batch-assign-series - API->>DB: 创建任务并保存上传文件引用 - API-->>Web: task_id - API->>Worker: 入队处理任务 - Worker->>DB: 按设备号批量查询并条件更新 - Worker->>DB: 写成功数、失败数和失败明细 - Web->>API: 轮询任务详情 - API-->>Web: 处理结果 -``` - ---- - -### 数据库变更 - -新建批量分配任务表(不复用 `DeviceImportTask`,业务语义不同): - -```sql -CREATE TABLE tb_device_batch_allocation_task ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - creator BIGINT NOT NULL DEFAULT 0, - updater BIGINT NOT NULL DEFAULT 0, - task_no VARCHAR(30) NOT NULL, - source_file_key VARCHAR(500) NOT NULL, - operation_type VARCHAR(30) NOT NULL, - target_id BIGINT NOT NULL, - operator_id BIGINT NOT NULL, - total_count INT NOT NULL DEFAULT 0, - success_count INT NOT NULL DEFAULT 0, - fail_count INT NOT NULL DEFAULT 0, - status INT NOT NULL DEFAULT 1, - failed_items JSONB, - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ -); - -CREATE UNIQUE INDEX idx_device_batch_allocation_task_no ON tb_device_batch_allocation_task(task_no) WHERE deleted_at IS NULL; -CREATE INDEX idx_device_batch_allocation_task_operation ON tb_device_batch_allocation_task(operation_type, status, created_at DESC); -``` - -状态常量:1=处理中,2=已完成,3=失败。 - -`operation_type`:`assign_shop`(分配代理)或 `assign_series`(分配套餐系列)。`target_id` 随操作类型分别指向代理店铺或套餐系列;不建立数据库外键。 - -失败明细存 JSONB(`failed_items`),失败条数有限,无需单独明细表。 - ---- - -### Model(`internal/model/device_batch_allocation_task.go`) - -```go -// DeviceBatchAllocationTask 设备批量分配任务模型 -type DeviceBatchAllocationTask struct { - gorm.Model - BaseModel `gorm:"embedded"` - TaskNo string `gorm:"column:task_no;type:varchar(30);uniqueIndex:idx_device_batch_allocation_task_no,where:deleted_at IS NULL;not null" json:"task_no"` - SourceFileKey string `gorm:"column:source_file_key;type:varchar(500);not null;comment:待处理Excel对象存储Key" json:"source_file_key"` - OperationType string `gorm:"column:operation_type;type:varchar(30);not null;comment:操作类型 assign_shop=分配代理 assign_series=分配套餐系列" json:"operation_type"` - TargetID uint `gorm:"column:target_id;not null;comment:操作目标ID(代理店铺或套餐系列)" json:"target_id"` - OperatorID uint `gorm:"column:operator_id;not null;comment:操作人ID" json:"operator_id"` - TotalCount int `gorm:"column:total_count;default:0;comment:总记录数" json:"total_count"` - SuccessCount int `gorm:"column:success_count;default:0;comment:成功数" json:"success_count"` - FailCount int `gorm:"column:fail_count;default:0;comment:失败数" json:"fail_count"` - Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1=处理中 2=已完成 3=失败" json:"status"` - FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"` - StartedAt *time.Time `gorm:"column:started_at" json:"started_at"` - CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"` -} - -func (DeviceBatchAllocationTask) TableName() string { - return "tb_device_batch_allocation_task" -} -``` - -> `ImportResultItems` 复用 `internal/model/device_import_task.go` 中已定义的类型。 - ---- - -### API 设计 - -#### 1. 前端静态 Excel 模板 - -模板由前端项目作为静态资源随版本发布,后端不提供模板下载接口。 - -- 文件建议命名:`设备批量分配模板-v1.xlsx` -- 表头:`设备号`(一列) -- 前端“下载模板”按钮直接下载静态文件。 -- 后端只校验上传文件的表头、行数和内容,不读取或生成前端模板文件。 - -模板字段发生变化时,前后端必须在同一发布批次升级;旧模板仍可能被用户保存在本地,因此后端错误需要明确指出缺失或未知表头。 - -#### 2. 上传并提交 - -``` -POST /api/admin/devices/batch-assign-shop -Content-Type: multipart/form-data - -字段: - shop_id: 123 (必填,分配给哪个代理) - file: -``` - -```text -POST /api/admin/devices/batch-assign-series -Content-Type: multipart/form-data - -字段: - series_id: 45 (必填,分配给哪个套餐系列) - file: -``` - -两个接口内部创建同一类任务表记录,但分别写入 `operation_type=assign_shop|assign_series`。请求中不接受另一个目标字段,避免前端通过隐藏参数把两个业务动作合并。 - -**处理逻辑(Asynq 异步)**: - -1. API 将上传文件保存到对象存储,把 `source_file_key` 写入任务;Worker 从对象存储下载并解析 Excel -2. 对设备号去重后分批查询 `tb_device`(按 `virtual_no` 或 `imei`),禁止逐行查询 -3. `assign_shop` 仅按状态/归属条件批量更新 `shop_id`;`assign_series` 仅更新 `series_id`。 -4. 未找到的:记录失败原因"设备号不存在"。 -5. `assign_shop` 遇到已分配给其他代理的设备:记录"该设备已分配,请先回收";`assign_series` 不改变代理归属。 -6. Worker 重试时只处理仍未满足目标结果的设备;已经分配到同一目标的记录按幂等成功处理。 - -临时本地文件路径不能进入 Asynq 载荷。任务结束后源文件按统一对象存储生命周期清理,清理失败不影响任务结果。 - -限制:单文件最大 10MB、去重前最多 1000 行、Worker 每批最多 200 条、任务最多保留 1000 条失败明细。超过限制在 API 或解析阶段返回明确错误,不创建无限时长任务。 - -#### 3. 查询任务状态 - -``` -GET /api/admin/devices/batch-allocation/{task_id} -``` - -响应: - -```json -{ - "task_no": "DBA20240101001", - "operation_type": "assign_shop", - "target_id": 123, - "status": 2, - "status_name": "已完成", - "total_count": 100, - "success_count": 95, - "fail_count": 5, - "failed_items": [ - { "line": 3, "virtual_no": "12345", "reason": "设备号不存在" }, - { "line": 7, "virtual_no": "67890", "reason": "该设备已分配,请先回收" } - ], - "started_at": "2024-01-01T10:00:00Z", - "completed_at": "2024-01-01T10:00:05Z" -} -``` - ---- - -### 前端对接 - -#### 入口 - -设备管理页 > 批量操作: - -- "批量分配代理" -- "批量分配套餐系列" - -#### 操作流程 - -1. 点击其中一个批量操作入口。 -2. "批量分配代理"弹框只显示代理选择器和 Excel 上传区域;"批量分配套餐系列"弹框只显示套餐系列选择器和 Excel 上传区域。 -3. 上传后点击"提交",分别调用 `POST /api/admin/devices/batch-assign-shop` 或 `POST /api/admin/devices/batch-assign-series`。 -4. 提示"任务提交成功,正在处理..." -5. 轮询 `GET /api/admin/devices/batch-allocation/{task_id}` 直到 `status != 1` -6. 展示结果:成功X条,失败X条,失败明细在页面展示 - -#### Excel 规范 - -- 表头:`设备号` -- 每行一个设备号(支持虚拟号或IMEI) - - ---- - -> 汇编来源:`docs/7月迭代/需求09-C端支付限制配置化.md` - -## 需求09:C端支付方式限制配置化 - -> 状态:待评审 -> 依赖:系统配置 - ---- - -### 背景 - -代码已经写好但被注释,注释原因是**微信支付参数未申请下来**,临时注释。 - -注释位置: -- `internal/handler/app/client_wallet.go`(钱包充值入口) -- `internal/service/client_order/service.go`(订单支付入口) - -两处均有注释:`// 第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)` - ---- - -### 业务规则 - -| 资产类型 | 允许的第三方支付 | 禁止的第三方支付 | -|---------|----------------|----------------| -| IoT 卡 | 支付宝、钱包 | 微信 | -| 设备 | 微信、钱包 | 支付宝 | - -**钱包支付对所有资产类型均允许。** - -```mermaid -flowchart TD - Pay[用户选择支付方式] --> Asset{资产类型} - Asset -->|IoT卡| Card[读取卡允许方式] - Asset -->|设备| Device[读取设备允许方式] - Asset -->|未知| RejectUnknown[拒绝:资产类型无效] - Card --> ConfigOK{配置可用?} - Device --> ConfigOK - ConfigOK -->|是| Match{支付方式在允许集合?} - ConfigOK -->|否| SafeDefault[使用代码内安全默认集合] - SafeDefault --> Match - Match -->|是| Continue[继续支付] - Match -->|否| Reject[拒绝并返回对应中文提示] -``` - ---- - -### 实现方案 - -#### 1. 系统配置初始化(已在系统配置文档中定义) - -```go -// tb_system_config 初始数据 -config_key: "c2b.payment.card_allowed_methods" → ["alipay","wallet"] -config_key: "c2b.payment.device_allowed_methods" → ["wechat","wallet"] -``` - -#### 2. 恢复注释代码,改为读取配置 - -**文件**:`internal/service/client_order/service.go` - -```go -// validatePaymentMethod 校验资产类型与支付方式是否匹配 -func (s *Service) validatePaymentMethod(ctx context.Context, assetType string, paymentMethod string) error { - // 钱包支付始终允许 - if paymentMethod == model.PaymentMethodWallet { - return nil - } - - var configKey string - switch assetType { - case model.AssetTypeIotCard: // "iot_card",定义在 internal/model/asset_identifier.go - configKey = "c2b.payment.card_allowed_methods" - case model.AssetTypeDevice: // "device",定义在 internal/model/asset_identifier.go - configKey = "c2b.payment.device_allowed_methods" - default: - return errors.New(errors.CodeInvalidParam, "资产类型无效") - } - - allowedMethods, err := sysconfig.GetStringSlice(ctx, configKey) - if err != nil || len(allowedMethods) == 0 { - // 配置异常时回退到代码内安全默认值,禁止放开全部支付方式。 - allowedMethods = defaultAllowedMethods(assetType) - s.logger.Error("读取支付方式配置失败,已使用安全默认值", - zap.String("config_key", configKey), - zap.Error(err)) - } - - for _, m := range allowedMethods { - if m == paymentMethod { - return nil - } - } - return errors.New(errors.CodeForbidden, "该资产类型不支持此支付方式") -} -``` - -在 `client_wallet.go`(充值)和 `client_order/service.go`(订单支付)的对应位置恢复调用。 - -系统配置更新时必须保证 `wallet` 始终存在于两个允许集合中;前端将钱包选项显示为勾选且不可取消,后端再次校验,避免配置破坏业务规则。 - -#### 3. 错误信息 - -用户端错误提示(友好文案): - -```go -// 根据资产类型给出具体提示 -switch assetType { -case model.AssetTypeIotCard: - return errors.New(errors.CodeForbidden, "卡资产仅支持支付宝或余额支付") -case model.AssetTypeDevice: - return errors.New(errors.CodeForbidden, "设备仅支持微信或余额支付") -} -``` - ---- - -### 前端对接 - -#### C端支付页面 - -前端不维护另一份固定规则。资产初始化/详情接口返回后端已经计算好的: - -```go -AllowedPaymentMethods []string `json:"allowed_payment_methods" description:"当前资产允许的支付方式"` -``` - -支付页只展示该集合中的方式,后端支付接口再次执行相同校验。配置变化后重新进入支付页或刷新资产信息即可获得新集合。 - -``` -allowed_payment_methods = ["alipay", "wallet"] → 展示支付宝、余额 -allowed_payment_methods = ["wechat", "wallet"] → 展示微信、余额 -``` - -#### 后台配置页面 - -在系统配置(系统设置 > 系统配置 > `c2b.payment` 模块)中,用 CheckboxGroup 展示: - -``` -卡资产允许支付方式:☑ 支付宝 ☑ 余额 ☐ 微信 -设备允许支付方式: ☐ 支付宝 ☑ 余额 ☑ 微信 -``` - -余额选项固定勾选且禁用,不允许管理员取消。 - -修改后调用 `PUT /api/admin/system/config/c2b.payment.card_allowed_methods`。 - -> 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。 - - ---- - -> 汇编来源:`docs/7月迭代/需求10-限速规则.md` - -## 需求10:Gateway 手动卡限速 - -> 状态:待评审 -> 已确认边界:本期只提供手动设置/取消限速;Gateway 最终对象始终是卡,统一使用 `cardNo`。 - ---- - -### 一、范围 - -本期提供一个统一的后台能力:对一张实际联网卡设置或取消限速。 - -- 单卡资产:使用 `IotCard.ICCID` 作为 `cardNo`。 -- 设备资产:查询 `tb_device_sim_binding.is_current=true` 的当前绑定卡,再使用该卡 ICCID。 -- `speed_kbps > 0` 表示设置限速;`speed_kbps = 0` 表示取消限速。 -- 内部业务单位固定为 `kbps`,前端也按 `kbps` 输入和展示。 - -本期不做: - -- 套餐限速字段或套餐编辑页限速配置。 -- 套餐激活、到期、续费、停机、切卡后的自动限速或自动取消。 -- 设备 IMEI 限速。 -- “Gateway 当前实际限速”查询展示。上游未提供查询接口时,页面只能展示最近一次本系统操作记录。 - -取消限速仍调用同一个 Gateway 方法。`speed_kbps=0` 是本系统语义,Gateway 所需的取消报文仅在 Infrastructure 适配器中转换,不散落在 Handler、Service 或前端。 - ---- - -### 二、流程 - -```mermaid -sequenceDiagram - actor User as 平台员工 - participant Web as 资产详情页 - participant API as SpeedLimit Application - participant DB as PostgreSQL - participant Resolver as 当前卡解析器 - participant Gateway as Gateway Adapter - - User->>Web: 输入限速值或点击取消 - Web->>API: asset identifier + speed_kbps + request_id - API->>Resolver: 解析实际 cardNo - Resolver-->>API: ICCID 或无当前卡错误 - API->>DB: 创建或复用限速操作记录 - API->>Gateway: SetSpeedLimit(cardNo, speedKbps) - Gateway-->>API: 成功或失败 - API->>DB: 写结果、错误摘要和操作审计 - API-->>Web: 返回本次操作结果 -``` - -设备不存在当前绑定卡时,接口返回业务错误并记录失败原因;绝不把设备 IMEI、设备 ID 或空字符串发送给 Gateway。 - ---- - -### 三、应用端口与数据 - -```go -// GatewaySpeedLimitPort 设置或取消卡限速。 -// speedKbps=0 表示取消,具体上游参数由适配器转换。 -type GatewaySpeedLimitPort interface { - SetSpeedLimit(ctx context.Context, cardNo string, speedKbps int) error -} - -// SpeedLimitCardResolver 将卡或设备资产解析为实际联网卡 ICCID。 -type SpeedLimitCardResolver interface { - ResolveCardNo(ctx context.Context, assetType string, assetID uint) (string, error) -} -``` - -新建操作记录表,既用于审计,也用于同一 `request_id` 的幂等重试: - -```sql -CREATE TABLE tb_gateway_speed_limit_operation ( - id BIGSERIAL PRIMARY KEY, - request_id VARCHAR(64) NOT NULL, - asset_type VARCHAR(20) NOT NULL, - asset_id BIGINT NOT NULL, - card_no VARCHAR(100) NOT NULL, - desired_speed_kbps INT NOT NULL, - status INT NOT NULL DEFAULT 1, - error_summary TEXT NOT NULL DEFAULT '', - operator_id BIGINT NOT NULL, - completed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX uq_gateway_speed_limit_operation_request - ON tb_gateway_speed_limit_operation(request_id); -CREATE INDEX idx_gateway_speed_limit_operation_asset - ON tb_gateway_speed_limit_operation(asset_type, asset_id, created_at DESC); -``` - -状态:`1=处理中, 2=成功, 3=失败`。同一 `request_id` 重试时,任务、操作人、资产和目标限速一致才返回或继续原记录;不一致返回冲突。Gateway 网络超时后允许使用相同目标值重试,因为 `SetSpeedLimit` 是“设为目标状态”的幂等操作。 - ---- - -### 四、接口与前端 - -```text -POST /api/admin/assets/{identifier}/speed-limit -``` - -```json -{ - "request_id": "01JZ...", - "speed_kbps": 1024 -} -``` - -- `speed_kbps` 必须为整数且大于等于 0。 -- 取消操作提交 `speed_kbps=0`,不新增第二个取消接口。 -- 后端根据 `identifier` 解析资产类型和数据范围,前端不得传 `cardNo`、设备 ID 或资产类型作为权威依据。 - -资产详情页提供两个独立命令: - -- 数字输入框加“保存”用于设置限速,单位固定显示 `kbps`。 -- 取消按钮用于提交 `speed_kbps=0`,并使用确认弹窗避免误操作。 - -设备详情页显示“当前使用卡 ICCID”。没有当前卡时禁用两个命令并展示后端返回的原因。页面可展示最近一次操作的目标限速、结果、操作人和时间,但不能把该记录描述为 Gateway 的实时状态。 - ---- - -### 五、审计与人工验收 - -每次请求记录:资产类型/ID、最终 `cardNo`、目标 `speed_kbps`、设置或取消、操作人、`request_id`、Gateway 结果和脱敏错误摘要。 - -人工验收覆盖: - -1. 单卡按 ICCID 设置限速。 -2. 设备按 `is_current=true` 的绑定卡设置限速,请求中不出现设备 IMEI。 -3. 设备无当前卡时拒绝调用 Gateway。 -4. `speed_kbps=0` 经同一 Gateway 方法取消限速。 -5. 同一 `request_id` 重试不重复生成操作记录;不同请求复用 ID 返回冲突。 -6. Gateway 失败记录错误并允许相同目标值重试。 - -上线前置条件:Gateway 提供设置和取消所需的最终报文字段约定。业务层只保持 `cardNo + speedKbps` 契约。 - - ---- - -> 汇编来源:`docs/7月迭代/需求14-导出功能.md` - -## 需求14:导出功能 - -> 状态:待评审 -> 复用现有 ExportTask 体系(`tb_export_task` + Asynq),不为每个业务模块新增一套导出路由。 - ---- - -### 导出模块总览 - -| 编号 | 模块 | 新增/修改 | -|------|------|---------| -| EXPD-001~003 | IoT卡导出 | 新增套餐名称、使用流量、剩余流量字段 | -| 6.8.2 | 代理资金概况-预充值钱包流水导出 | **全新** | -| 6.8.3 | 套餐列表导出 | **全新** | -| 6.8.4 | 退款管理退款列表导出 | **全新** | -| 6.8.5 | 换货管理导出 | **全新** | -| 6.8.6 | 代理充值导出 | **全新**(去掉"支付通道"字段) | -| 需求22 | 临期资产列表导出 | **全新**,`scene=expiring_asset` | - ---- - -### 现有导出体系说明 - -系统已有异步导出框架(`internal/exporter/`): -- `tb_export_task` 表记录导出任务 -- 导出逻辑通过 `DataSource` 接口实现,每个场景一个文件(如 `iot_card_scene.go`) -- `registry.go` 的 `NewDefaultRegistry()` 统一注册所有场景 -- Asynq Worker 根据任务里的 `scene` 字段,从 Registry 取对应 DataSource 执行 -- 前端轮询任务状态后下载 - -```mermaid -sequenceDiagram - actor User as 后台用户 - participant Web as 前端 - participant API as ExportTask API - participant DB as PostgreSQL - participant Worker as Asynq Worker - participant Storage as 对象存储 - - User->>Web: 选择导出字段并确认 - Web->>API: POST /api/admin/export-tasks - API->>API: 计算数据范围和字段权限交集 - API->>DB: 保存查询、范围和字段快照 - API-->>Web: task_id - API->>Worker: 入队导出任务 - Worker->>DB: 按 scene 分片查询 - Worker->>Storage: 上传导出文件 - Worker->>DB: 更新进度和 download file_key - Web->>API: 轮询 GET /api/admin/export-tasks/{id} - API-->>Web: 状态、进度、download_url -``` - -新增导出模块需要: -1. 在 `pkg/constants/constants.go` 新增 `ExportTaskSceneXxx` 场景常量 -2. 在 `internal/exporter/` 新建 `xxx_scene.go`,实现 `DataSource` 接口(`Scene()`/`Count()`/`Headers()`/`Fetch()`) -3. 在 `registry.go` 的 `NewDefaultRegistry()` 中注册,并更新 `IsSupportedScene()` -4. 扩展统一 `CreateExportTaskRequest.Scene` 的允许值,通过 `POST /api/admin/export-tasks` 创建任务 -5. 在字段目录中注册稳定字段 key、中文表头、默认选择和所需导出权限 - -统一创建请求扩展为: - -```go -type CreateExportTaskRequest struct { - Scene string `json:"scene" validate:"required" description:"导出场景"` - Format string `json:"format" validate:"required,oneof=xlsx csv" description:"导出格式"` - Query map[string]any `json:"query,omitempty" description:"与列表一致的筛选条件"` - Fields []string `json:"fields" validate:"required,min=1" description:"申请导出的字段key"` -} -``` - -新增场景:`agent_wallet_transaction`、`package`、`refund`、`exchange`、`agent_recharge`、`expiring_asset`。DTO description 和 `pkg/constants/` 必须同步维护。 - ---- - -### EXPD-001~003:IoT卡导出字段新增 - -**修改文件**:`internal/exporter/iot_card_scene.go` - -在 `Headers()` 末尾追加三列,`Fetch()` 的 `Select` 追加字段,`iotCardExportRow` 追加字段: - -```go -// Headers() 新增 -"套餐名称", "使用流量(MB)", "剩余流量(MB)" - -// baseQuery() 或 Fetch() 新增 JOIN -LEFT JOIN LATERAL ( - SELECT pu.package_id, pu.data_usage_mb, pu.data_limit_mb, p.package_name - FROM tb_package_usage pu - JOIN tb_package p ON p.id = pu.package_id AND p.deleted_at IS NULL - WHERE pu.iot_card_id = c.id AND pu.status = 1 - AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL - LIMIT 1 -) AS pkg ON TRUE - -// iotCardExportRow 新增 -PackageName string `gorm:"column:package_name"` -DataUsageMB int64 `gorm:"column:data_usage_mb"` -DataLimitMB int64 `gorm:"column:data_limit_mb"` -``` - -剩余流量 = `DataLimitMB - DataUsageMB`(在行转换时计算) - ---- - -### 6.8.2:代理资金概况-预充值钱包流水导出 - -**新增场景**:`scene=agent_wallet_transaction` - -支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。 - -导出字段映射: - -| 字段 | 数据来源 | -|------|---------| -| 店铺名称 | JOIN `tb_shop` | -| 交易类型 | `transaction_type`(充值/扣款/退款等,中文化) | -| 交易金额 | `amount / 100` 转元 | -| 状态 | `status` 中文化 | -| 资产类型 | `asset_type` 中文化 | -| 资产标识 | `asset_identifier` | -| 交易时间 | `created_at` | -| 交易前金额 | `balance_before / 100` | -| 交易后金额 | `balance_after / 100` | -| 购买套餐名称 | 新数据读取 `tb_order_item.package_name` 不可变快照;历史缺失时才回退 `metadata.package_name`,禁止关联当前套餐名称 | -| 操作人 | JOIN `tb_account`(`creator` 字段关联 `tb_account.id`,取 `username`) | -| 交易 ID | `id` | -| 关联业务订单号 | `reference_id` 对应的单号(JOIN 对应表) | -| 交易渠道/支付方式 | `metadata` 中 `payment_method` 字段 | - ---- - -### 6.8.3:套餐列表导出 - -**新增场景**:`scene=package` - -支持现有套餐列表筛选条件。 - -导出字段(按实际列举的 25 个稳定字段 Key): - -```go -type PackageExportRow struct { - PackageCode string `xlsx:"套餐编码"` - PackageName string `xlsx:"套餐名称"` - SeriesName string `xlsx:"套餐系列名称"` // JOIN tb_package_series - PackageType string `xlsx:"套餐类型"` // formal/addon 中文化 - DurationMonths int `xlsx:"套餐时长(月)"` - DurationDaysDesc string `xlsx:"套餐时长说明"` // 剩余天数说明 - CalendarType string `xlsx:"套餐周期类型"` - DurationDays int `xlsx:"套餐天数"` - RealDataMB int64 `xlsx:"真流量额度(MB)"` - VirtualDataMB int64 `xlsx:"虚流量额度(MB)"` - EnableVirtualData string `xlsx:"是否启用虚流量"` // 是/否 - VirtualRatio float64 `xlsx:"虚流量比例"` - DataResetCycle string `xlsx:"流量重置周期"` - ExpiryBase string `xlsx:"到期时间基准"` - CostPrice string `xlsx:"成本价(元)"` // 分→元 - SuggestedRetailPrice string `xlsx:"建议售价(元)"` - PriceConfigStatus string `xlsx:"价格配置状态"` - Status string `xlsx:"状态"` - ShelfStatus string `xlsx:"上架状态"` - IsGift string `xlsx:"是否赠送套餐"` - CreatorID uint `xlsx:"创建人ID"` - UpdaterID uint `xlsx:"更新人ID"` - CreatedAt string `xlsx:"创建时间"` - UpdatedAt string `xlsx:"更新时间"` - DeletedAt string `xlsx:"删除时间"` -} -``` - ---- - -### 6.8.4:退款管理退款列表导出 - -**新增场景**:`scene=refund` - -导出字段(去掉“退款到账方式”,审批信息使用动态摘要,不固定具体节点): - -```go -type RefundExportRow struct { - RefundNo string `xlsx:"退款单号"` - ShopName string `xlsx:"代理店铺名称"` - PaymentOrderNo string `xlsx:"关联的支付订单号"` - AssetType string `xlsx:"资产类型"` - AssetIdentifier string `xlsx:"资产标识"` - PackageName string `xlsx:"套餐名称"` - OriginalAmount string `xlsx:"原订单金额"` - ActualAmount string `xlsx:"实收金额"` - RefundableAmount string `xlsx:"可退金额"` - AppliedAmount string `xlsx:"申请退款金额"` - ActualRefundAmount string `xlsx:"实际退款金额"` - Status string `xlsx:"状态"` - RefundReason string `xlsx:"退款原因"` - Remark string `xlsx:"备注"` - ApprovalSource string `xlsx:"审批来源"` - ApprovalStatus string `xlsx:"审批状态"` - ProcessingStatus string `xlsx:"退款处理状态"` - CurrentNodeName string `xlsx:"当前审批节点"` - ApprovalRecords string `xlsx:"审批记录"` - AppliedAt string `xlsx:"退款申请时间"` - CompletedAt string `xlsx:"退款完成时间"` - SubmitterName string `xlsx:"提交人"` - VoucherURLs string `xlsx:"退款凭证"` -} -``` - -`ApprovalRecords` 格式示例:`业务审核:张三(通过,同意,附件2个);财务审核:李四(待处理)`。导出只记录附件数量,不导出对象存储 URL 或 file_key。`ProcessingStatus` 区分待人工退款、代理钱包回退处理中、已完成和处理失败。数据源按本批业务单的 `approval_instance_id` 批量查询审批任务、审批人和附件计数,禁止逐行查询。历史终态单据没有流程实例时,`ApprovalSource` 输出“历史审批”,审批记录仅使用原业务审批字段和审计日志,不伪造多节点记录。 - ---- - -### 6.8.5:换货管理导出 - -**新增场景**:`scene=exchange` - -```go -type ExchangeExportRow struct { - ExchangeNo string `xlsx:"换货单号"` - ExchangeType string `xlsx:"换货类型"` - ExchangeReason string `xlsx:"换货原因"` - ProblemDesc string `xlsx:"问题描述"` - OldAssetType string `xlsx:"旧资产类型"` - OldAssetIdentifier string `xlsx:"旧资产标识符"` - NewAssetIdentifier string `xlsx:"新资产标识符"` - ReceiverName string `xlsx:"收货人姓名"` - ReceiverPhone string `xlsx:"收货人电话"` - ReceiverAddress string `xlsx:"收货地址"` - ExpressCompany string `xlsx:"快递公司"` - TrackingNo string `xlsx:"快递单号"` - Status string `xlsx:"状态"` - CreatorName string `xlsx:"创建人"` - CreatedAt string `xlsx:"创建时间"` -} -``` - ---- - -### 6.8.6:代理充值导出 - -**新增场景**:`scene=agent_recharge` - -去掉"支付通道"字段(需求文档中明确去掉),保留其他字段: - -```go -type AgentRechargeExportRow struct { - RechargeNo string `xlsx:"充值单号"` - ShopName string `xlsx:"店铺名称"` - RechargeType string `xlsx:"充值类型"` - RechargeAmount string `xlsx:"充值金额"` - ActualAmount string `xlsx:"实付金额"` - BalanceBefore string `xlsx:"充值前余额"` - BalanceAfter string `xlsx:"充值后余额"` - Status string `xlsx:"状态"` - PaymentMethod string `xlsx:"支付方式"` - // 去掉支付通道 - OperationRemark string `xlsx:"运营备注"` - RejectReason string `xlsx:"驳回原因"` - CreatedAt string `xlsx:"创建时间"` - PaidAt string `xlsx:"支付时间"` - CompletedAt string `xlsx:"完成时间"` - SubmitterName string `xlsx:"提交人"` - ApprovalSource string `xlsx:"审批来源"` - ApprovalStatus string `xlsx:"审批状态"` - CurrentNodeName string `xlsx:"当前审批节点"` - ApprovalRecords string `xlsx:"审批记录"` - VoucherURLs string `xlsx:"支付凭证"` - Remark string `xlsx:"备注"` -} -``` - -充值导出的审批记录格式和批量查询规则与退款导出一致。 - ---- - -### 前端对接(通用模式) - -各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。 - -调用流程: -1. 点击“导出”后请求当前账号在该场景可导出的字段目录。 -2. 弹框只展示后端允许的字段,默认勾选 `default_selected=true` 的字段。 -3. 提交 `scene + format + query + fields` 到 `POST /api/admin/export-tasks`。 -4. 返回 `task_id` 后,前端轮询 `GET /api/admin/export-tasks/{id}` 直到终态。 -5. `status=3` 时下载 `download_url`;失败时展示任务错误摘要,禁止自动重复创建任务。 - -(与现有导出体系完全一致,复用现有前端导出 Hook) - ---- - -### 导出字段权限 - -需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。 - -评审结论:本迭代必须实现角色级导出字段配置,不作为后续预留。 - -该要求涉及真流量、成本价、身份材料等敏感数据,不能以“本期先全量导出”代替。字段权限必须由后端强制执行,前端复选框只负责展示。 - -#### 1. 字段目录 - -每个场景在代码中注册稳定字段 key: - -```go -// ExportFieldDefinition 导出字段定义 -type ExportFieldDefinition struct { - Key string - Header string - DefaultSelected bool - Required bool -} -``` - -示例: - -```text -scene=package -package_code 套餐编码 默认选择 -package_name 套餐名称 默认选择 -real_data_mb 真流量额度 敏感字段 -virtual_data_mb 虚流量额度 默认选择 -cost_price 成本价 敏感字段 -``` - -表头中文可以调整,但 `scene + field_key` 一经发布不得随意改名,否则历史角色配置会失效。 - -#### 2. 角色字段授权表 - -```sql -CREATE TABLE tb_role_export_field_permission ( - id BIGSERIAL PRIMARY KEY, - role_id BIGINT NOT NULL, - scene VARCHAR(50) NOT NULL, - field_key VARCHAR(100) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - creator BIGINT NOT NULL DEFAULT 0 -); - -CREATE UNIQUE INDEX idx_role_export_field_permission - ON tb_role_export_field_permission(role_id, scene, field_key); -``` - -账号拥有多个角色时,字段权限按 RBAC 常规规则取并集;超级管理员拥有全部字段。数据行范围仍使用现有数据权限快照,字段权限不能扩大店铺或企业数据范围。 - -#### 3. 权限 API - -```text -GET /api/admin/export-fields?scene=package -``` - -返回当前账号可选择的字段: - -```json -{ - "scene": "package", - "fields": [ - { - "key": "package_code", - "header": "套餐编码", - "default_selected": true, - "required": true - } - ] -} -``` - -角色管理: - -```text -GET /api/admin/roles/{role_id}/export-fields -PUT /api/admin/roles/{role_id}/export-fields -``` - -更新请求按场景提交字段 key 列表,后端校验字段存在并记录审计日志。 - -#### 4. 创建任务时的权限快照 - -创建任务时计算: - -```text -resolved_fields = requested_fields ∩ role_allowed_fields ∩ scene_supported_fields -``` - -- 必选字段由后端自动补齐。 -- 交集为空时拒绝创建任务。 -- `resolved_fields` 和对应 `resolved_headers` 写入任务的 `query_json`,Worker 不重新根据后来变化的角色权限扩大字段。 -- Worker 只按照快照字段输出,未知字段直接失败,不允许静默回退到全量字段。 - -#### 5. 前端角色配置 - -- 角色编辑页增加“导出字段权限”页签,按场景分组展示复选框。 -- 普通列表的导出弹框只显示当前账号被授权的字段。 -- 敏感字段可以增加“敏感”标记,但标记不能替代后端权限。 -- 用户取消所有可选字段时禁用提交按钮,并提示至少选择一个字段。 - ---- - -### 查询与性能约束 - -- 退款和充值审批记录必须先按本批 `approval_instance_id` 批量查询,再在内存按实例分组,禁止逐行查审批任务。 -- 关联业务单号、套餐名称和操作人必须使用批量 JOIN 或批量查询,禁止 N+1。 -- DataSource 的 Count 和 Fetch 必须使用同一份权限过滤和查询条件。 -- 动态字段不代表动态拼接不受控 SQL;字段 key 必须通过服务端白名单映射到固定查询列。 - ---- - -### 发布与回滚 - -本需求随七月迭代停机发布: - -1. 维护窗口内先执行角色字段权限表迁移,并初始化现有角色的最小可用字段集。 -2. 同一发布窗口部署场景常量、DataSource、管理 API、Worker 和前端字段选择/角色配置页面。 -3. 开放访问前验证普通角色、敏感字段角色和超级管理员的字段集合及实际导出文件。 -4. 回滚时可关闭新增场景入口,但保留任务、文件和字段权限历史。 - -禁止在角色权限尚未初始化时默认放开全部敏感字段;无法解析权限时应拒绝导出并记录错误。 - - ---- - -> 汇编来源:`docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md` - -## 需求15/16/18/19/20/21 技术方案 - -> 状态:待评审 -> 评审建议:需求 15/16、需求 18/20/21、需求 19 分三组评审,不在一次会议中混合确认。 - ---- - -### 需求15:套餐下架后允许续费 - -#### 业务规则 - -- 下架套餐(`shelf_status=2`)不可被**新购** -- 下架套餐**可以续费**(已在使用该套餐的客户) -- 续费仅支持**客户自己购买**(不允许代理代购下架套餐给新客户) - -```mermaid -flowchart TD - Start[请求购买套餐] --> Enabled{套餐是否启用?} - Enabled -->|否| RejectDisabled[拒绝:套餐已禁用] - Enabled -->|是| Shelf{是否已下架?} - Shelf -->|否| Allow[允许继续下单] - Shelf -->|是| Renewal{当前客户资产是否有该套餐历史使用记录?} - Renewal -->|否| RejectNew[拒绝:下架套餐不可新购] - Renewal -->|是| Actor{是否客户本人续费?} - Actor -->|是| Allow - Actor -->|否| RejectProxy[拒绝:下架套餐不可代购] -``` - -“客户本人”必须由后端登录主体与资产归属关系判断,不能信任前端传入 `is_renewal=true`。 - -#### 后端 - -**当前逻辑**:下架套餐在购买时被拦截。 - -修改:在订单创建校验中由后端根据资产、历史使用记录和登录主体判定“新购/续费”,不能接收或信任前端 `is_renewal`: - -```go -// internal/service/order/service.go 或 client_order/service.go -func (s *Service) validatePackageAvailability( - ctx context.Context, - pkg *model.Package, - asset *ResolvedAsset, - actor *PurchaseActor, -) error { - if pkg.Status == constants.StatusDisabled { // 0=禁用,定义在 pkg/constants/constants.go - return errors.New(errors.CodeForbidden, "套餐已禁用") - } - if pkg.ShelfStatus == constants.ShelfStatusOff { // 2=下架 - if !s.hasRenewalEligibility(ctx, asset, actor, pkg.ID) { - return errors.New(errors.CodeForbidden, "套餐已下架,仅支持资产所有人续费") - } - } - return nil -} -``` - -**续费资格判断**:查当前资产是否有该套餐的有效历史使用记录,并确认当前登录主体就是资产所有人。已失效、已退款或仅创建未生效的记录不能作为续费资格: - -```go -func (s *Service) hasRenewalEligibility(ctx context.Context, asset *ResolvedAsset, actor *PurchaseActor, packageID uint) bool { - return actor.OwnsAsset(asset) && - s.packageUsageStore.HasValidHistory(ctx, asset.Type, asset.ID, packageID) -} -``` - -#### 前端 - -C端资产详情/当前套餐卡片:在当前套餐旁提供“续费”按钮。点击后复用现有购买套餐流程,并携带资产和当前套餐上下文;后端重新判定资格,前端传入的上下文不构成授权依据。下架套餐不出现在普通“新购套餐”列表,也不额外建设第二个续费套餐列表。 - -后台代购时:下架套餐的"代购"按钮禁用,tooltip 提示"套餐已下架,不可代购"。 - ---- - -### 需求16:代理分销码与佣金提现(已移出7月迭代) - -> 状态:已移出本期 -> 决策日期:2026-07-14 -> 后续处理:作为独立需求重新评审和排期,不纳入本次开发、迁移和发布 - -本次范围调整包含整个需求 16: - -- 代理/员工分销码和推广二维码。 -- H5 代理申请、进度查询和退回重提。 -- 代理申请接入通用审批及审批后自动开店。 -- 店铺发展人关系和代理申请来源字段。 -- 佣金提现合同、营业执照、法人身份证、门头照、发票及主体一致性校验。 - -因此 7 月迭代不创建 `tb_distribution_code`、`tb_agent_application`,不修改 `tb_shop` 发展人字段和提现材料字段,不注册代理申请相关 API,不发布 `agent_application_approval`,也不建设对应前端页面。 - -通用审批流本期只接入退款和平台员工线下充值。需求 16 后续重新立项时,可以复用本期审批流、站内消息、对象存储和 Outbox 能力,但必须重新评审其数据模型、H5 安全、开店幂等、提现材料及工时。 - ---- - -### 需求18:多人审批(APR-001~009) - -#### 依赖 - -基于 审批流基础设施 和 站内消息。 - -APR-009(企微审批对接)= Phase 2。 - -```mermaid -sequenceDiagram - actor Applicant as 提交人 - participant Biz as 退款/充值业务 - participant Approval as 审批流 - participant Notice as 站内消息 - actor Approver1 as 当前节点审批人 - actor Approver2 as 下一节点审批人 - - Applicant->>Biz: 提交申请 - Biz->>Approval: 同事务创建流程实例和首任务 - Approval->>Notice: TaskCreated - Notice-->>Approver1: 待审批提醒 - Approver1->>Approval: 审批通过 - Approval->>Notice: 下一节点 TaskCreated - Notice-->>Approver2: 待审批提醒 - Approver2->>Approval: 通过/驳回/退回 - Approval->>Notice: 流程结果事件 - Notice-->>Applicant: 结果和原因 -``` - -#### 实现要点 - -| 编号 | 需求 | 实现 | -|------|------|------| -| APR-001~003 | 充值/退款多级审核 | 见需求20/21;需求16已移出本期 | -| APR-004 | 审核环节:部门领导→财务 | 作为默认流程定义的两个串行节点;系统无部门模型,节点审批人由角色或指定账号配置,禁止按步骤编号或角色名称写死 | -| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` | -| APR-006 | 上一级完成后才提示下一级 | `ProcessInstance` 完成当前任务并激活下一节点,写入 `TaskCreated` Outbox 事件 | -| APR-007 | 通过后通知申请人 | `ProcessApproved` 事件处理器 | -| APR-008 | 驳回/退回后通知申请人含原因 | `ProcessRejected` / `ProcessReturned` 事件处理器 | -| APR-009 | 对接企微 | **Phase 2** | - -#### 默认流程与可配置边界 - -- 本迭代可以预置“业务审核 → 财务审核”两个节点,但这只是初始流程定义,不是引擎固定规则。 -- 每个节点可配置 `role` 或 `user` 审批人来源,以及 `any` 或 `all` 完成方式。 -- 节点可配置是否要求操作密码;仅触发该节点完成的审批人输入,不能按“财务节点”等名称写死。 -- 角色审批在节点激活时解析当前启用账号,并将候选审批人快照到任务审批人表。 -- 流程定义发布后不可修改;调整节点或审批人配置时创建新版本。 -- 已发起实例始终使用发起时保存的流程定义快照。 - -#### 业务表与审批流的关联 - -充值单和退款单接入审批流,各自新增 `approval_instance_id` 字段。具体增量 DDL 与业务处理状态字段分别在需求 20、需求 21 中定义,迁移文件只能创建一次。 - -业务表只保存当前审批实例 ID,历史实例通过 `biz_type + biz_id` 查询。接入协议详见 审批流文档 - 业务接入协议。 - -退款和充值详情统一返回 `approval_source=none|workflow|legacy`。代理在线充值等无需审批的记录返回 `none`;发布前已经结束且没有流程实例的历史审批记录返回 `legacy` 并只读展示;发布时仍待审批的退款和员工线下充值必须在维护窗口回填流程实例。 - -#### 前端技术方案 - -- 新增“待我审批”和动态审批详情,具体页面、状态和请求幂等规则见 前端技术方案。 -- 退款、充值列表只展示审批摘要;审批详情首屏展示发起时固化的业务关键字段和业务资料,随后展示完整审批人、意见和历史实例。 -- 每次通过、驳回、退回分别保存审批意见和可选附件;驳回、退回意见必填,附件不与退款/充值业务凭证混用。 -- 时间线必须能查看此前审批人的动作、时间、意见和审批附件;业务资料与审批附件分区展示。 -- 所有节点名称和审批人来自接口,不保留“部门领导审批人”“财务审批人”固定字段。 -- APR-009 不在 Phase 1 前端中展示不可用入口;企业微信接入完成后再增加来源标识和跳转。 - ---- - -### 需求19:批量订购套餐(BPO-001~008) - -#### 支付方式粒度 - -评审结论:支付方式按**整批统一**设计: - -- 页面选择 `offline` 或 `agent_wallet`,Excel 不再重复填写支付方式。 -- 混合支付拆成两个批次,避免一份凭证对应多种支付语义。 -- Excel 不包含支付方式列,后端拒绝同一批次混合支付。 - -#### 业务流程 - -```mermaid -flowchart TD - Start[员工选择代理和支付方式] --> Upload[上传 Excel] - Upload --> Parse[解析并持久化逐行明细] - Parse --> Validate[校验资产、套餐、归属和重复行] - Validate --> Item{处理下一条有效明细} - Item -->|代理钱包| Lock[锁定钱包并校验可用余额] - Item -->|线下支付| Voucher[校验整批支付凭证] - Lock --> Order[单条事务创建订单并扣款] - Voucher --> OrderOffline[单条事务创建已支付订单] - Order --> Result[记录订单 ID 和成功状态] - OrderOffline --> Result - Result --> More{还有待处理明细?} - More -->|是| Item - More -->|否| Summary[汇总任务结果] - Validate -->|校验失败| Failed[记录结构化失败原因] - Failed --> More -``` - -任务允许部分成功。每一行是独立、可重试、可审计的业务单元,不能只保存一段失败 JSON。 - -#### 数据库变更 - -```sql -CREATE TABLE tb_bulk_purchase_task ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - creator BIGINT NOT NULL DEFAULT 0, - updater BIGINT NOT NULL DEFAULT 0, - task_no VARCHAR(30) NOT NULL, - source_file_key VARCHAR(500) NOT NULL, - shop_id BIGINT NOT NULL, - operator_id BIGINT NOT NULL, - payment_method VARCHAR(20) NOT NULL, - voucher_keys JSONB NOT NULL DEFAULT '[]', - total_amount BIGINT NOT NULL DEFAULT 0, - total_count INT NOT NULL DEFAULT 0, - success_count INT NOT NULL DEFAULT 0, - fail_count INT NOT NULL DEFAULT 0, - status INT NOT NULL DEFAULT 1, - error_message TEXT NOT NULL DEFAULT '', - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ -); - -CREATE UNIQUE INDEX idx_bulk_purchase_task_no - ON tb_bulk_purchase_task(task_no) - WHERE deleted_at IS NULL; - -CREATE TABLE tb_bulk_purchase_item ( - id BIGSERIAL PRIMARY KEY, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - task_id BIGINT NOT NULL, - row_no INT NOT NULL, - asset_type VARCHAR(20) NOT NULL, - asset_identifier VARCHAR(100) NOT NULL, - package_code VARCHAR(50) NOT NULL, - package_name_snapshot VARCHAR(200) NOT NULL DEFAULT '', - package_id BIGINT, - amount BIGINT NOT NULL DEFAULT 0, - status INT NOT NULL DEFAULT 1, - order_id BIGINT, - failure_code VARCHAR(50) NOT NULL DEFAULT '', - failure_reason VARCHAR(500) NOT NULL DEFAULT '', - idempotency_key VARCHAR(100) NOT NULL, - processed_at TIMESTAMPTZ -); - -CREATE UNIQUE INDEX idx_bulk_purchase_item_task_row - ON tb_bulk_purchase_item(task_id, row_no); - -CREATE UNIQUE INDEX idx_bulk_purchase_item_idempotency - ON tb_bulk_purchase_item(idempotency_key); - -CREATE INDEX idx_bulk_purchase_item_task_status - ON tb_bulk_purchase_item(task_id, status); -``` - -状态建议: - -- 任务:`1=待处理, 2=处理中, 3=已完成, 4=部分成功, 5=失败`。 -- 明细:`1=待处理, 2=处理中, 3=成功, 4=失败`。 - -#### 处理与幂等 - -1. API 校验文件、代理、支付方式和凭证,将 Excel 保存到对象存储并创建任务,返回 `task_id`。 -2. Worker 根据 `source_file_key` 下载并解析 Excel,将每一行先写入明细表,再开始业务处理。 -3. 同一任务按行顺序处理,避免对同一代理钱包制造不必要的乐观锁冲突。 -4. 每行使用独立事务。代理钱包支付时锁定钱包记录,校验 `balance - frozen_balance + credit_limit` 后,在同一事务扣款、创建订单、资金流水并更新明细。 -5. `idempotency_key` 使用 `bulk_purchase:{task_id}:{row_no}`。Worker 重试时,已存在成功订单的明细直接跳过。 -6. 单行失败不回滚已成功行;失败原因写结构化错误码和用户可见中文原因。 -7. 任务汇总从明细表计算,不信任内存计数。 - -Asynq 载荷只传任务 ID,不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理,确保 Worker 重试期间仍可读取。 - -建议单文件上限 1000 行;超过上限在 API 层拒绝,避免长事务和过长处理时间。 - -#### API 设计 - -**前端静态 Excel 模板**: - -模板由前端项目随版本发布,后端不提供下载接口。按已确认的“整批统一支付方式”,模板字段为: - -```text -资产类型 | 资产标识 | 套餐编码 | 套餐名称 -``` - -`套餐名称`用于人工核对,实际匹配以稳定的 `套餐编码` 为准。后端必须校验表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。 - -**上传并提交**: - -```text -POST /api/admin/bulk-purchases -Content-Type: multipart/form-data - -shop_id: 123 -payment_method: offline | agent_wallet -voucher_keys: ["key1","key2"] -file: -``` - -`offline` 时 `voucher_keys` 必填,`agent_wallet` 时忽略该字段。 - -**查询任务状态**: - -```text -GET /api/admin/bulk-purchases/{task_id} -``` - -**分页查询任务明细**: - -```text -GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50 -``` - -#### 前端技术方案 - -- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 ID 恢复进度。 -- 提交前展示代理、支付方式、凭证数量和文件名的二次确认;钱包支付额外展示当前可用余额,但最终以 Worker 扣款时校验为准。 -- 任务处理中展示总数、已处理数、成功数和失败数,轮询规则复用统一异步任务方案。 -- 结果页默认显示失败明细,可切换全部/成功/失败,并可按资产标识搜索。 -- 部分成功使用明确状态,不弹“全部成功”提示;再次上传失败行会创建新任务,不修改旧任务历史。 -- 操作员、任务号和处理时间在页面固定展示,便于财务和运营追溯。 - ---- - -### 需求20:退款审批 - -#### 依赖 - -基于 审批流基础设施。 - -#### 退款单现有状态(不变) - -``` -1=待审批 2=已通过 3=已拒绝 4=已退回 -``` - -退款单状态值的原有语义不改;审批进度由 `tb_approval_process_instance` 管理,两者通过 `approval_instance_id` 关联。审批通过后,代理钱包退款自动回退到原扣款代理主钱包;微信、支付宝和线下退款由财务人工完成。业务表增加独立处理状态: - -```text -processing_status:0=待处理 1=处理中 2=已完成 3=处理失败 -``` - -`status=2` 表示审批结论已通过,`processing_status` 表示实际退款动作是否完成。接口和前端必须同时展示两者。 - -#### 数据库变更 - -```sql -ALTER TABLE tb_refund_request - ADD COLUMN approval_instance_id BIGINT, - ADD COLUMN processing_status INT NOT NULL DEFAULT 0, - ADD COLUMN processing_error TEXT NOT NULL DEFAULT '', - ADD COLUMN processing_started_at TIMESTAMPTZ, - ADD COLUMN processing_completed_at TIMESTAMPTZ, - ADD COLUMN manual_refund_operator_id BIGINT, - ADD COLUMN manual_refund_completed_at TIMESTAMPTZ, - ADD COLUMN manual_refund_remark TEXT NOT NULL DEFAULT '', - ADD COLUMN manual_refund_voucher_key JSONB NOT NULL DEFAULT '[]'; - -CREATE INDEX idx_refund_request_approval_instance - ON tb_refund_request(approval_instance_id) - WHERE approval_instance_id IS NOT NULL; -``` - -`processing_error` 只保存可运维排查的摘要。`manual_refund_*` 只在人工退款确认时写入,退款申请时提交的 `refund_voucher_key` 仍是申请业务资料,不能混用为财务完成凭证。 - -现有退款审批允许确认 `approved_refund_amount`,切换到通用审批后必须保留:配置为最终决策的节点在完成时返回金额动作字段,审批人可确认实际退款金额;省略时使用申请金额。退款动作适配器在审批事务内校验金额大于 0,且不超过申请退款金额和订单实收金额,并写入退款单和审批操作日志。会签需要指定金额决策人时,流程定义增加其专属最终节点,禁止第一位会签人预先锁定金额。 - -#### 流程 - -```mermaid -sequenceDiagram - actor Applicant as 提交人 - actor Approver as 审批人 - participant Refund as Refund Application - participant Approval as Approval Application - participant DB as PostgreSQL - participant Worker as AgentWalletRefundHandler - actor Finance as 财务人员 - - Applicant->>Refund: POST /api/admin/refunds - Refund->>DB: 同事务创建退款单(status=1) - Refund->>Approval: StartProcess(refund, refund_id) - Approval->>DB: 创建实例、首任务、审批人、Outbox - Refund->>DB: 回写 approval_instance_id - Approver->>Approval: 按 task_id 审批,决策节点可提交实际退款金额 - Approval->>DB: 提交 ProcessApproved/Rejected/Returned - alt 代理钱包支付且审批通过 - DB-->>Worker: Outbox + Asynq 至少一次投递 - Worker->>DB: claim processing_status=1,status=2 - Worker->>DB: 幂等回退原扣款代理主钱包并写资金流水 - Worker->>DB: processing_status=2 - else 非代理钱包支付且审批通过 - Refund->>DB: status=2, processing_status=0(待人工退款) - Finance->>Refund: POST manual-complete - Refund->>DB: 条件更新处理状态并记录确认信息 - else 审批拒绝 - Worker->>DB: status 从 1 更新为 3 - else 退回修改 - Worker->>DB: status 从 1 更新为 4 - end -``` - -`AgentWalletRefundHandler` 仅处理代理钱包订单,使用 `refund:{refund_id}` 作为业务幂等键,并读取审批事务已经持久化的 `approved_refund_amount`。它必须按原扣款资金流水定位原代理主钱包,余额、版本、钱包退款流水和处理状态在同一事务更新。处理失败时单独更新 `processing_status=3` 和错误摘要后返回可重试错误;不得回滚已经完成的审批实例,也不得重复回退。 - -处理器通过条件更新领取任务:`processing_status IN (0,3)`,或状态为处理中但 `processing_started_at` 已超过约定租约。重复消费者看到未过期的处理中状态时不重复执行;进程在副作用完成后崩溃时,下一次重试依靠业务幂等键恢复并补写成功状态。 - -本期不调用第三方退款 API,也不增加商户退款号、渠道退款号或渠道结果字段。个人/客户资产钱包退款不属于本期自动回退范围,现有对应分支必须在实施时隔离或拒绝进入本流程。 - -人工退款确认接口: - -```text -POST /api/admin/refunds/{id}/manual-complete -``` - -请求包含 `request_id`、可选 `remark` 和最多 5 个完成凭证。后端仅允许具备财务确认权限的账号对 `status=2 AND processing_status IN (0,3)` 的非代理钱包退款操作;实际金额沿用审批金额,不允许在确认时再次改价。确认记录操作人、时间、备注和凭证后将处理状态置为已完成。 - -#### 退回后重新提交 - -``` -POST /api/admin/refunds/{id}/resubmit - → 校验 status=4 - → 请求体可修改 actual_received_amount、requested_refund_amount、refund_voucher_key、refund_reason - → 在同一事务新建 ProcessInstance - → 更新 approval_instance_id,status 回到 1(待审批) - → processing_status 重置为 0,清空本次处理错误和人工确认记录 - → 旧审批实例保留为历史记录 -``` - -复用当前真实路由 `POST /api/admin/refunds/{id}/resubmit`,不新增单独 `PUT`。重提命令沿用现有 `ResubmitRefundRequest` 字段范围;禁止修改订单 ID、资产快照、提交人或已形成的历史审批记录。 - -#### API 响应与前端 - -退款列表和详情增加: - -```json -{ - "approval_instance_id": 1001, - "approval_source": "workflow", - "approval_status": 2, - "approval_status_name": "已通过", - "current_node_name": "", - "processing_status": 0, - "processing_status_name": "待人工退款", - "processing_error": "" -} -``` - -前端展示规则: - -| 审批状态 | 处理状态 | 展示 | -|----------|----------|------| -| 审批中 | 待处理 | 待审批 + 当前节点 | -| 已通过 + 代理钱包 | 处理中 | 审批已通过,代理钱包回退处理中 | -| 已通过 + 非代理钱包 | 待处理 | 审批已通过,待人工退款;财务可确认完成 | -| 已通过 | 已完成 | 退款已完成 | -| 已通过 + 代理钱包 | 处理失败 | 系统重试中;管理员可查看错误摘要 | -| 已拒绝 | 待处理 | 已拒绝 + 原因 | -| 已退回 | 待处理 | 已退回,可编辑并重新提交 | - -列表页不直接放固定审批按钮。点击进入详情后,根据审批接口返回的 `available_actions` 渲染通过、驳回和退回操作。 - -决策节点根据 `action_form` 展示“实际退款金额”输入,默认等于申请金额。审批通过后的非代理钱包退款展示“确认人工退款”入口,仅具备财务确认权限时显示;完成凭证与审批附件分区展示。前端只负责元/分转换和基础格式校验,金额上限以后端在审批事务中的校验为准。 - -停机发布后,现有按退款业务单 ID 直接通过、驳回或退回的路由不再注册;所有退款审批动作统一操作 `task_id`,避免绕过审批人快照、并发控制和操作日志。 - -维护窗口内需要为 `status=1 AND approval_instance_id IS NULL` 的存量退款单执行幂等回填,从 `refund_approval` 首节点创建流程实例和任务;历史终态退款不伪造流程实例。 - ---- - -### 需求21:充值审核流程 - -#### 充值单现有状态 - -``` -tb_agent_recharge_record:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 -``` - -代码中已经存在 `6=已驳回`,不能改写其含义。员工线下充值走审批流时,在现有状态基础上追加 `7=已退回`: - -```sql --- 现有:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 6=已驳回 --- 新增:7=已退回 - -ALTER TABLE tb_agent_recharge_record - ADD COLUMN approval_instance_id BIGINT, - ADD COLUMN processing_status INT NOT NULL DEFAULT 0, - ADD COLUMN processing_error TEXT NOT NULL DEFAULT '', - ADD COLUMN processing_started_at TIMESTAMPTZ, - ADD COLUMN processing_completed_at TIMESTAMPTZ, - ADD COLUMN return_reason VARCHAR(500) NOT NULL DEFAULT ''; - -CREATE INDEX idx_agent_recharge_approval_instance - ON tb_agent_recharge_record(approval_instance_id) - WHERE approval_instance_id IS NOT NULL; -``` - -充值业务的状态语义: -- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 `approval_instance_id` 查询审批状态) -- `2=已支付`:在线支付已确认,或线下充值审批通过后正在执行钱包入账 -- `3=已完成`:充值到账 -- `4=已关闭`:取消/超时 -- `5=已退款`:退款 -- `6=已驳回`:审批流程拒绝 -- `7=已退回`:审批人退回给提交人修改 - -`rejection_reason` 只保存驳回原因,新增 `return_reason` 保存退回修改原因,禁止复用一个字段导致前端无法区分终止和可重提。 - -充值处理状态保持:`0=未触发, 1=处理中, 2=处理成功, 3=处理失败`。退款的 `0` 已收口为“待处理”,两者不要共用中文状态名称常量。 - -#### 流程 - -**代理自行充值(不走审批)**: - -```mermaid -flowchart LR - A[代理提交充值申请] --> B[系统生成收款码] - B --> C[代理扫码支付] - C --> D[支付回调幂等入账] -``` - -**员工线下代充值(走审批)**: - -现有 `offline-pay` 的全局操作密码校验必须保留。通用审批详情在最后一个审批节点返回 `operation_password` 动作字段;审批动作适配器调用现有 `OperationPasswordService` 校验通过后才允许流程完成。密码只在内存中参与本次校验,不落库、不写审批日志、不进入 Outbox。 - -```mermaid -sequenceDiagram - actor Staff as 平台员工 - actor Approver as 审批人 - participant Recharge as Recharge Application - participant Approval as Approval Application - participant DB as PostgreSQL - participant Worker as RechargeApprovalHandler - - Staff->>Recharge: POST /api/admin/agent-recharges(payment_method=offline) - Recharge->>DB: 同事务创建充值单(status=1) - Recharge->>Approval: StartProcess(recharge, recharge_id) - Approval->>DB: 创建实例、首任务、审批人、Outbox - Recharge->>DB: 回写 approval_instance_id - Approver->>Approval: 按 task_id 审批 - DB-->>Worker: 投递流程结果事件 - alt 审批通过 - Worker->>DB: status 从 1 更新为 2,processing_status=1 - Worker->>DB: 幂等增加钱包余额并写流水 - Worker->>DB: status 从 2 更新为 3,processing_status=2 - else 审批拒绝 - Worker->>DB: status 从 1 更新为 6,写 rejection_reason - else 退回修改 - Worker->>DB: status 从 1 更新为 7,写 return_reason - end -``` - -充值接口独立返回审批状态和业务处理状态。`RechargeApprovalHandler` 使用 `recharge:{recharge_no}` 作为幂等键;钱包余额、版本、充值单和交易流水必须在同一事务更新。 - -充值处理同样使用 `processing_started_at` 作为可恢复租约。重复事件不能再次增加余额;若钱包流水已经存在而充值单状态未完成,重试只补齐充值单状态。 - -#### 退回后重新提交 - -``` -POST /api/admin/agent-recharges/{id}/resubmit - → 校验 status=7 - → 请求体可修改 amount、payment_voucher_key、remark - → 在同一事务新建 ProcessInstance - → 更新 approval_instance_id,status 回到 1(待支付/待审批) - → processing_status 重置为 0,清空处理错误和 return_reason - → 旧审批实例保留为历史记录 -``` - -新增 `resubmit` 路由时沿用现有 `/api/admin/agent-recharges` 资源名,不另建 `/agent-recharge-records` 路径。店铺、支付方式和提交人不可修改;编辑与新流程创建必须同事务完成。 - -#### 停机切换 - -现有 `POST /api/admin/agent-recharges/{id}/offline-pay` 和 `POST /api/admin/agent-recharges/{id}/reject` 都会绕过通用审批任务,本次不保留兼容窗口: - -1. 发布前进入维护模式,停止创建和处理线下充值。 -2. 执行审批关联字段迁移,同时发布新 API、Worker 和前端。 -3. 初始化并启用 `recharge → recharge_approval` 绑定。 -4. 为存量“平台员工创建 + 线下支付 + 尚未入账”的充值记录幂等创建流程实例,代理在线充值不回填审批。 -5. 新前端创建线下充值后直接进入审批详情,不再展示“确认线下充值”按钮。 -6. 新版本不注册 `offline-pay` 和业务单级 `reject` 路由;线下充值只能由 `ProcessApproved` 事件触发幂等入账,驳回统一由任务级审批接口产生 `ProcessRejected`。 -7. 验证审批通过、驳回、退回、处理失败重试和钱包流水后再解除维护模式。 - -#### 前端技术方案 - -- 代理自行充值保留现有收款码和支付状态页面,不显示审批信息。 -- 平台员工选择 `offline` 时,提交成功进入充值详情并展示审批时间线。 -- `status=6` 展示“已驳回”,`status=7` 展示“已退回”;两者按钮不同,只有已退回可编辑和重新提交。 -- 审批通过但 `processing_status=1` 时显示“充值处理中”;状态为 3 且处理成功后才显示最新钱包余额。 -- `processing_status=3` 时不允许前端再次点击入账,只展示系统重试状态和管理员排查入口。 - - ---- - -> 汇编来源:`docs/7月迭代/需求17-信用额度.md` - -## 需求17:信用额度 - -> 状态:待评审 -> DDD 范围:仅迁移代理主钱包的复杂写用例,资金列表和统计继续走 Query -> 关联需求:BPO-009~012、需求19批量订购 - ---- - -### 一、评审结论 - -代理信用额度在现有 `AgentWallet` 基础上落地,属于典型资金聚合:余额、冻结金额、信用额度、版本和流水必须在同一事务内保持不变量。 - -平台员工信用额度不实施,原因如下: - -- 平台员工是操作主体,不是订单结算主体;实际付款方只能是代理钱包或线下支付主体。 -- 角色表达权限范围,不表达资产、余额和债务,给角色配置资金会混淆 RBAC 与财务账本。 -- 系统不存在员工钱包、员工充值、员工还款和离职债务交接链路,负余额无法对账和追责。 -- 批量订购已经明确由代理钱包扣款或使用线下支付,不存在必须从员工个人额度扣款的业务场景。 -- 引入员工信用会与代理钱包形成两套资金来源,增加订单归属、退款去向和审计解释成本,但不产生实际业务价值。 - -因此信用额度只属于代理主钱包,平台员工仅通过权限决定是否可以查看或调整代理额度。 - ---- - -### 二、已确认范围:代理主钱包授信 - -#### 2.1 业务规则 - -- 信用额度只作用于代理主钱包,不作用于佣金钱包和资产钱包。 -- 可用金额:`balance - frozen_balance + effective_credit_limit`。 -- `credit_enabled=false` 时,`effective_credit_limit=0`。 -- 余额可以为负,最低不能小于 `-(credit_limit - frozen_balance)`。 -- 扣款、冻结、解冻、充值和调额都必须维护同一钱包不变量。 -- 存在欠款或冻结金额导致可用金额不足时,禁止降低额度或关闭信用。 -- 金额统一使用分,禁止浮点数入库。 - -```mermaid -flowchart TD - Debit[请求扣款] --> Lock[按钱包ID和version加载] - Lock --> Calc[计算 balance - frozen + effective_credit] - Calc --> Enough{可用金额足够?} - Enough -->|否| Reject[拒绝:可用余额不足] - Enough -->|是| Update[条件更新余额和version] - Update --> Tx[同事务写资金流水] - Tx --> Success[返回扣款后余额] -``` - -#### 2.2 信用开关 - -- 创建代理时可以提交 `enable_credit` 和 `credit_limit`。 -- 关闭开关时额度必须为 0。 -- 打开开关时额度必须大于 0。 -- 修改额度前必须校验修改后的可用金额不为负,而不是只判断 `balance >= 0`。 - ---- - -### 三、数据库变更 - -信用额度属于钱包,不在 `tb_shop` 和 `tb_agent_wallet` 各保存一份,避免双写失真。创建/编辑代理接口可以接收信用参数,但最终权威数据写入代理主钱包。 - -```sql -ALTER TABLE tb_agent_wallet - ADD COLUMN credit_enabled BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0; - -COMMENT ON COLUMN tb_agent_wallet.credit_enabled IS '是否启用信用额度,仅主钱包有效'; -COMMENT ON COLUMN tb_agent_wallet.credit_limit IS '信用额度上限(分),仅主钱包有效'; - -ALTER TABLE tb_agent_wallet - DROP CONSTRAINT IF EXISTS chk_agent_wallet_frozen_balance; - -ALTER TABLE tb_agent_wallet - ADD CONSTRAINT chk_agent_wallet_frozen_nonnegative - CHECK (frozen_balance >= 0), - ADD CONSTRAINT chk_agent_wallet_credit_nonnegative - CHECK (credit_limit >= 0), - ADD CONSTRAINT chk_agent_wallet_available_nonnegative - CHECK ( - balance - frozen_balance + - CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= 0 - ); -``` - -必须先检查生产库真实约束名;`DROP CONSTRAINT IF EXISTS` 不能替代迁移前核对。历史钱包默认关闭信用,行为不变。 - ---- - -### 四、领域模型 - -```go -// AgentWallet 代理主钱包聚合根 -type AgentWallet struct { - ID uint - WalletType string - Balance int64 - FrozenBalance int64 - CreditEnabled bool - CreditLimit int64 - Version int64 -} - -// AvailableBalance 返回当前可用金额。 -func (w *AgentWallet) AvailableBalance() int64 { - credit := int64(0) - if w.CreditEnabled { - credit = w.CreditLimit - } - return w.Balance - w.FrozenBalance + credit -} - -// Debit 执行钱包扣款并维护信用边界。 -func (w *AgentWallet) Debit(amount int64) error { - if amount <= 0 { - return ErrInvalidAmount - } - if w.AvailableBalance() < amount { - return ErrInsufficientAvailableBalance - } - w.Balance -= amount - return nil -} - -// ChangeCredit 修改信用配置。 -func (w *AgentWallet) ChangeCredit(enabled bool, limit int64) error { - if limit < 0 || (!enabled && limit != 0) || (enabled && limit == 0) { - return ErrInvalidCreditConfig - } - nextCredit := int64(0) - if enabled { - nextCredit = limit - } - if w.Balance-w.FrozenBalance+nextCredit < 0 { - return ErrCreditLimitBelowDebt - } - w.CreditEnabled = enabled - w.CreditLimit = limit - return nil -} -``` - -领域层只维护资金不变量,不查询角色、店铺名称或页面权限。角色能否授信由 Application 在调用聚合前校验。 - ---- - -### 五、应用用例与持久化 - -#### 5.1 修改代理信用额度 - -```text -Handler - → ChangeShopCreditUseCase - → 校验操作人和代理数据权限 - → 加载代理主钱包 - → 调用 wallet.ChangeCredit() - → 按 version 条件更新钱包 - → 写操作日志和信用变更流水 -``` - -条件更新示例: - -```sql -UPDATE tb_agent_wallet -SET credit_enabled = ?, - credit_limit = ?, - version = version + 1, - updated_at = NOW() -WHERE id = ? - AND wallet_type = 'main' - AND version = ? - AND balance - frozen_balance + - CASE WHEN ? THEN ? ELSE 0 END >= 0; -``` - -受影响行数为 0 时,重新读取钱包以区分并发冲突和额度低于当前欠款。 - -#### 5.2 钱包扣款 - -所有现有主钱包扣款语句必须从: - -```text -balance - frozen_balance >= amount -``` - -统一改为: - -```text -balance - frozen_balance + -CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount -``` - -扣款、版本递增和钱包流水必须在同一事务。禁止只修改 `GetAvailableBalance()` 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。 - -#### 5.3 受影响用例 - -- 后台订单和批量订购的代理钱包支付。 -- C端/代理端使用代理主钱包的订单支付。 -- 钱包冻结与解冻。 -- 退款回充和员工线下代充值。 -- 资金概况、钱包详情和导出 Query。 - -实施时只迁移这些被信用额度触碰的完整资金用例,不主动改造佣金钱包和资产钱包。 - ---- - -### 六、查询方案 - -资金概况、钱包详情、列表和导出使用 Query 直接读取: - -```sql -balance - frozen_balance + -CASE WHEN credit_enabled THEN credit_limit ELSE 0 END AS available_balance -``` - -响应统一增加: - -```go -CreditEnabled bool `json:"credit_enabled" description:"是否启用信用额度"` -CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"` -AvailableBalance int64 `json:"available_balance" description:"可用金额(分)"` -IsInDebt bool `json:"is_in_debt" description:"余额是否为负"` -DebtAmount int64 `json:"debt_amount" description:"欠款金额(分)"` -``` - -`debt_amount = max(-balance, 0)`,不包含冻结金额。 - ---- - -### 七、API 设计 - -#### 7.1 创建代理 - -现有: - -```text -POST /api/admin/shops -``` - -新增参数: - -```go -EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"` -CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"` -``` - -Application 创建店铺和主钱包时,将信用配置写入钱包;任何一步失败整笔事务回滚。 - -#### 7.2 修改信用额度 - -```text -PUT /api/admin/shops/{id}/credit-limit -``` - -```go -type UpdateCreditLimitRequest struct { - EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"` - CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"` - Version int64 `json:"version" validate:"min=0" description:"钱包版本,用于并发控制"` -} -``` - -#### 7.3 查询展示 - -现有 `GET /api/admin/shops/fund-summary` 和代理详情响应增加信用字段;不新增不存在的 `/agent-wallets/{shop_id}` 路由。 - ---- - -### 八、前端技术方案 - -#### 8.1 创建/编辑代理 - -```text -信用额度 -[开关] 允许使用信用额度 -授信上限 [金额输入,单位元] -``` - -- 开关关闭时清空输入并提交 `credit_limit=0`。 -- 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。 -- 编辑代理基础资料不自动覆盖信用额度;信用调整使用独立权限和独立接口。 - -#### 8.2 资金概况和详情 - -```text -账面余额:-¥200.00 -冻结金额:¥0.00 -信用额度:¥1,000.00 -可用金额:¥800.00 -``` - -- `balance < 0` 时账面余额显示欠款样式。 -- 可用金额以接口值为准,不在前端自行重复计算。 -- 修改额度弹框展示当前余额、冻结金额、额度和修改后可用金额预览;提交后仍以后端校验为准。 -- 后端返回并发冲突时刷新钱包版本和最新金额,不保留旧计算结果。 - -平台员工端不展示个人余额或个人信用额度。拥有信用额度管理权限的员工,只能在代理资金页面查看和调整代理主钱包额度。 - ---- - -### 九、审计与可观测性 - -每次信用配置变更记录: - -- 操作人、店铺、钱包 ID。 -- 变更前后开关、额度、余额、冻结金额和版本。 -- `request_id`、IP、设备信息和时间。 - -关键日志: - -- 扣款因信用额度不足被拒绝。 -- 钱包 version 冲突。 -- 数据库信用边界约束失败。 -- 信用额度调整失败和旧值/新值。 - -资金流水必须能够通过订单号、批量任务号或充值/退款业务号反查,不以普通操作日志替代钱包流水。 - ---- - -### 十、发布与回滚 - -本需求随七月迭代停机发布: - -1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加信用字段,历史数据默认关闭。 -2. 同时发布钱包聚合、全部主钱包扣款条件、查询字段和管理端信用配置页面,禁止只改余额展示而遗漏真实扣款 SQL。 -3. 开放访问前保持所有代理信用开关关闭,人工验证普通余额、信用扣款、并发冲突和额度调整。 -4. 系统开放后再由有权限的管理员对指定代理逐个开启额度。 - -尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。 - - ---- - -> 汇编来源:`docs/7月迭代/需求22-套餐临期提醒.md` - -## 需求22:套餐临期提醒 - -> 状态:待评审 - ---- - -### 业务规则 - -#### 临期定义 - -当资产的**当前生效主套餐**(`PackageUsage.status=1`)按 `Asia/Shanghai` 自然日计算的剩余天数在 `0~15` 天时,该资产进入临期状态。 - -资产有可接续的排队主套餐时不进入临期提醒,因为服务不会在当前套餐到期后中断;需求06仍会单独展示排队套餐接续后的“预计最后到期时间”。已过期资产不属于临期。 - -#### 查询与通知职责 - -- 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。 -- 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。 -- `tb_expiry_push_record` 仅用于防止同一资产、接收人、渠道、阈值重复通知。 - -#### 颜色规则(Version 2) - -| 剩余天数 | 颜色 | -|---------|------| -| ≤ 15天 | 粉红色 | -| ≤ 7天 | 紫色 | -| ≤ 3天 | 黄色 | - -#### 各端提醒规则 - -| 场景 | 提醒方式 | 触发节点 | -|------|---------|---------| -| **后台管理** | 列表加临期天数列 + 高亮;详情加临期字段(≤15天高亮) | 实时计算 | -| **企业客户** | 企微推送(Phase 2);后台每日生成临期列表 | 15天/7天/3天节点 | -| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 | -| **C端公众号** | 套餐到期提醒模块(≤15天展示) | 实时展示 | - -```mermaid -flowchart TD - Schedule[每日定时任务] --> Query[批量查询实时临期候选] - Query --> QueueCheck{存在排队套餐?} - QueueCheck -->|是| Skip[不进入临期提醒] - QueueCheck -->|否| Days[计算剩余自然日] - Days --> Node{命中 15/7/3 天节点?} - Node -->|否| End[本次不发送] - Node -->|是| Upsert[按资产+节点+接收人幂等写通知记录] - Upsert --> Notification[站内消息] - Upsert -. Phase 2 .-> WeCom[企业微信推送] -``` - ---- - -### 数据库变更 - -临期状态实时计算,不建立临期快照表,避免数据陈旧。为保证通知幂等,单独保存发送记录。 - -每日临期通知记录需防重,用一个 key 记录已发送或已创建的通知: - -```sql --- 临期推送记录(防重) -CREATE TABLE tb_expiry_push_record ( - id BIGSERIAL PRIMARY KEY, - package_usage_id BIGINT NOT NULL, - asset_type VARCHAR(20) NOT NULL, -- iot_card | device - asset_id BIGINT NOT NULL, - recipient_id BIGINT NOT NULL, - channel VARCHAR(20) NOT NULL, -- notification | wecom - push_node INT NOT NULL, -- 推送节点(3/7/15天) - event_id VARCHAR(64) NOT NULL, - pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE UNIQUE INDEX idx_expiry_push_idempotency - ON tb_expiry_push_record( - package_usage_id, recipient_id, channel, push_node - ); - -CREATE UNIQUE INDEX idx_expiry_push_event - ON tb_expiry_push_record(event_id); -``` - -同一资产可能对应多个业务员,唯一约束必须包含接收人和渠道;否则第一位业务员写入记录后会错误拦截其他接收人。`package_usage_id` 让同一资产续费生成新使用记录后可以再次触发 15/7/3 天提醒。站内消息和 Phase 2 企业微信都使用该表防重。 - ---- - -### 后端实现 - -#### 1. 现有接口新增临期字段 - -##### 资产列表接口(`GET /api/admin/iot-cards` / `GET /api/admin/devices`) - -响应新增字段: -```go -type IotCardListItem struct { - // ...原有字段... - DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐为null)"` - IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"` -} -``` - -**计算方式**(在 SQL 层计算,避免 N+1): - -```sql --- 列表查询时 JOIN PackageUsage 获取剩余天数 -SELECT - ic.*, - CASE - WHEN pu.expires_at IS NULL THEN NULL - WHEN (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date - < (NOW() AT TIME ZONE 'Asia/Shanghai')::date THEN NULL - WHEN EXISTS ( - SELECT 1 FROM tb_package_usage q - WHERE q.iot_card_id = ic.id AND q.status = 0 AND q.deleted_at IS NULL - ) THEN NULL -- 有排队套餐,不临期 - ELSE (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date - - (NOW() AT TIME ZONE 'Asia/Shanghai')::date - END AS days_until_expiry -FROM tb_iot_card ic -LEFT JOIN tb_package_usage pu - ON pu.iot_card_id = ic.id AND pu.status = 1 AND pu.deleted_at IS NULL -``` - -`is_expiring` 由同一 SQL 表达式派生:`days_until_expiry IS NOT NULL AND days_until_expiry BETWEEN 0 AND 15`。设备查询复用同一规则,不能另写一套日期计算。 - -##### 资产列表筛选条件新增 - -```go -type IotCardListRequest struct { - // ...原有字段... - ExpiringWithinDays *int `query:"expiring_within_days" description:"临期筛选(值=15表示查剩余≤15天)"` -} -``` - -##### 资产详情接口 - -后台资产详情走 `GET /api/admin/assets/resolve/:identifier`,响应 DTO 为 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)。 - -新增字段: -```go -// AssetResolveResponse 追加 -DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐或有排队套餐时为null)"` -IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"` -``` - -#### 2. 新增临期列表接口(独立页面) - -``` -GET /api/admin/expiring-assets -``` - -查询参数: -```go -type ExpiringAssetsRequest struct { - AssetType string `query:"asset_type" description:"资产类型 (iot_card/device)"` - AssetIdentifier string `query:"asset_identifier" description:"资产标识(ICCID/设备号)"` - PackageName string `query:"package_name" description:"套餐名称"` - ShopID *uint `query:"shop_id" description:"店铺ID"` - ExpiresAtStart string `query:"expires_at_start" description:"到期时间起"` - ExpiresAtEnd string `query:"expires_at_end" description:"到期时间止"` - MaxDaysUntilExpiry *int `query:"max_days_until_expiry" description:"最大剩余天数(如15)"` - Page int `query:"page" default:"1"` - PageSize int `query:"page_size" default:"20"` -} -``` - -响应: -```go -type ExpiringAssetItem struct { - AssetType string `json:"asset_type"` - AssetIdentifier string `json:"asset_identifier"` - AssetStatus int `json:"asset_status"` - ShopName string `json:"shop_name"` - PackageName string `json:"package_name"` - DaysUntilExpiry int `json:"days_until_expiry"` - ExpiresAt time.Time `json:"expires_at"` - DataUsageMB int64 `json:"data_usage_mb"` - RemainingDataMB int64 `json:"remaining_data_mb"` -} -``` - -#### 3. 每日定时任务(仅通知) - -```go -// internal/task/expiry_reminder_handler.go - -// HandleExpiryReminder 每日03:00按中国自然日扫描通知阈值。 -func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error { - assets, err := h.packageUsageStore.GetExpiringAssetsForNotification(ctx, 15) - if err != nil { return err } - - for _, asset := range assets { - node := h.selectNearestUnsentNode(ctx, asset, []int{15, 7, 3}) - if node == 0 { - continue - } - today := time.Now().In(shanghaiLocation).Format("2006-01-02") // shanghaiLocation 由 time.LoadLocation("Asia/Shanghai") 初始化 - for _, recipientID := range asset.SalesmanIDs { - eventID := fmt.Sprintf( - "expiry:%d:%d:%d:%d:%s", - asset.PackageUsageID, node, recipientID, asset.AssetID, today, - ) - - // 在事务内先写 expiry_push_record,再通过 Outbox 发布站内消息。 - if err := h.notifyPublisher.Publish(ctx, notification.SendPayload{ - EventID: eventID, - RecipientIDs: []uint{recipientID}, - RecipientType: constants.NotifyRecipientAdmin, - Type: constants.NotifyTypePackageExpiring, - Title: fmt.Sprintf("套餐临期提醒(%d天节点)", node), - Body: fmt.Sprintf("资产 %s 的套餐剩余 %d 天", asset.Identifier, asset.DaysUntilExpiry), - RefType: asset.AssetType, - RefID: asset.AssetID, - }); err != nil { - return err - } - } - } - return nil -} -``` - -定时任务每日执行一次即可,页面不参与轮询。漏跑恢复后,对每个当前仍在 `0~15` 天范围内的资产,仅补发一个“当前最近且尚未发送”的阈值:例如第 15 天漏跑、剩余 14 天时补发 15 天通知;剩余 6 天时补发 7 天通知,不补发多条过期阈值。 - ---- - -### 导出功能(临期列表) - -使用统一导出任务: - -```text -POST /api/admin/export-tasks -scene=expiring_asset -``` - -导出字段: - -| 字段 | 说明 | -|------|------| -| 资产标识 | ICCID/设备号 | -| 资产类型 | 卡/设备 | -| 店铺名称 | | -| 套餐名称 | | -| 套餐到期时间 | | -| 剩余天数 | | -| 资产状态 | | -| 已用流量(MB) | | -| 剩余流量(MB) | | - ---- - -### C端接口变更 - -#### 公众号首页 - -现有接口(`GET /api/c/v1/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse`(`internal/model/dto/client_asset_dto.go`)新增字段: - -```go -DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(≤15天时有值,有排队套餐时为null)"` -IsExpiring bool `json:"is_expiring" description:"是否临期"` -``` - -前端逻辑:`is_expiring=true` 时展示续费提醒模块: - -``` -您的套餐即将到期 - -卡号/设备号:XXXX -剩余有效期:XX 天 - -为避免到期后影响正常使用,请您提前完成续费。 - -[立即续费] -``` - ---- - -### 前端对接(后台管理) - -#### 资产列表(IoT卡管理 / 设备管理) - -1. 列表新增"剩余天数"列 -2. 根据 `days_until_expiry` 高亮行: - - ≤ 15天:行背景粉红色 - - ≤ 7天:行背景紫色 - - ≤ 3天:行背景黄色 -3. 筛选条件新增"临期天数"(下拉:≤15天/≤7天/≤3天) - - 选中后传 `expiring_within_days=15` - -#### 临期资产独立列表页 - -路由:`/expiring-assets` - -``` -筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺 - -列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量 - -操作:导出按钮 → `POST /api/admin/export-tasks`,`scene=expiring_asset` -``` - -#### 代理端首页 - -在首页数据接口新增字段(需要确认代理端首页接口): - -```go -type AgentDashboardResponse struct { - // ...原有字段... - ExpiringCardCount int `json:"expiring_card_count" description:"临期卡数量(≤15天)"` - ExpiringDeviceCount int `json:"expiring_device_count" description:"临期设备数量(≤15天)"` -} -``` - -前端展示快捷入口:"xx张卡即将到期" → 跳转资产列表并过滤 `expiring_within_days=15` - - ---- - -> 汇编来源:`docs/7月迭代/业务需求.md` - -> 本文保留原始业务需求措辞。涉及架构、字段和接口的最终技术口径,以同目录的专项技术方案和 `基础设施/` 文档为准。 - -其中有一些需要对接第三方系统的,除了gateway,都可以划分阶段 - - - -1.当前所有的卡在后台手动操作复机必须要实名,实际上行业卡应当允许未实名复机 -2. 用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置,例如这一批设备需要强制先充值后实名,这一批资产可以先实名后充值? -3.店铺列表搜索栏新增一项:联系电话,便于搜索 -4.操作拦截:若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请 -5. 套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本,已经分配出去的套餐不会被后续的宿主套餐修改影响,需要回收后重新分配才能生效 -6. 在资产详情页增加所有待生效套餐加上生效套餐加起来的最后到期时间 -7.lot卡管理和设备管理新增已实名/未实名的筛选查询条件 -8.设备批量分配代理和套餐系列:因设备号不是连号,故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为:设备号 - -> 评审结论:拆成“批量分配代理”和“批量分配套餐系列”两个独立命令、两个前端入口和两个任务类型;可复用 Excel 解析与任务基础设施,但单个任务不得同时修改两个字段。 -9.C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝 -10.限速规则:根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则,限速接口由gateway提供 - -> 技术口径说明:Gateway 只支持按 `cardNo` 限速,不存在设备级限速。单卡直接使用 ICCID;设备场景先查 `tb_device_sim_binding.is_current=true` 的当前卡,再使用该卡 ICCID。取消限速仍重复调用同一个限速接口,只是发送取消参数。本期仅提供后台手动设置/取消,内部单位为 `kbps`;不做套餐字段和自动限速规则。 - -11. 资产信息详情字段新增:资产信息页面卡信息/设备信息板块,将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。 -12. 换货管理: -| 编号 | 需求 | -| ------- | ---------------------------------------------------- | -| EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 | -| EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 | -| EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 | -| EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 | -13.列表字段新增: -| 编号 | 模块 | 新增字段 | -| ------- | ------------ | -------------- | -| COL-001 | 退款管理列表 | 提交人、审批人 | -| COL-002 | 代理充值列表 | 提交人、审批人 | -| COL-003 | 换号管理列表 | 提交人 | -14. 导出功能: -### 6.8.1 lot 卡导出 - -#### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。 - -| 编号 | 需求 | -| -------- | ---------------------------- | -| EXPD-001 | lot 卡导出字段新增套餐名称。 | -| EXPD-002 | lot 卡导出字段新增使用流量。 | -| EXPD-003 | lot 卡导出字段新增剩余流量。 | - -### 6.8.2 代理资金概况-预充值钱包流水导出 - -导出字段: - -| 字段 | 说明 | -| ------------------- | ------------------------------------------------------------ | -| 店铺名称 | 代理店铺名称 | -| 交易类型 | 充值、扣款、退款等 | -| 交易金额 | 以元为单位 | -| 状态 | 交易状态 | -| 资产类型 | 卡/设备等 | -| 资产标识 | ICCID/设备号等 | -| 交易时间 | 流水生成时间 | -| 交易前金额 | 交易前余额 | -| 交易后金额 | 交易后余额 | -| 购买套餐名称 | 资产此条扣款记录对应的套餐名称 | -| 操作人 | 明确交易执行主体:代理账号 / 平台账号(明确账号名称) | -| 交易 ID | 每一笔流水的全局唯一主键,彻底避免重复流水、对账串号、精准定位单条交易 | -| 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 | -| 交易渠道 / 支付方式 | 明确交易来源:余额支付等 | - -### 6.8.3 套餐列表导出 - -导出字段: - -| 字段 | -| -------------- | -| 套餐编码 | -| 套餐名称 | -| 套餐系列名称 | -| 套餐类型 | -| 套餐时长(月) | -| 套餐时长说明 | -| 套餐周期类型 | -| 套餐天数 | -| 真流量额度(MB) | -| 虚流量额度(MB) | -| 是否启用虚流量 | -| 虚流量比例 | -| 流量重置周期 | -| 到期时间基准 | -| 成本价(元) | -| 建议售价(元) | -| 价格配置状态 | -| 状态 | -| 上架状态 | -| 是否赠送套餐 | -| 创建人ID | -| 更新人ID | -| 创建时间 | -| 更新时间 | -| 删除时间 | - -### 6.8.4 退款管理退款列表导出 - -导出字段: - -| 字段 | -| ---------------- | -| 退款单号 | -| 代理店铺名称 | -| 关联的支付订单号 | -| 资产类型 | -| 资产标识 | -| 套餐名称 | -| 原订单金额 | -| 实收金额 | -| 可退金额 | -| 申请退款金额 | -| 实际退款金额 | -| 退款到账方式 | -| 状态 | -| 退款原因 | -| 备注 | -| 审批备注 | -| 退款申请时间 | -| 退款完成时间 | -| 提交人 | -| 部门领导审批人 | -| 财务审批人 | -| 退款凭证 | - -### 6.8.5 换货管理导出 - -#### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。 - -导出字段: - -| 字段 | -| ------------ | -| 换货单号 | -| 换货类型 | -| 换货原因 | -| 问题描述 | -| 旧资产类型 | -| 旧资产标识符 | -| 新资产标识符 | -| 收货人姓名 | -| 收货人电话 | -| 收货地址 | -| 快递公司 | -| 快递单号 | -| 状态 | -| 创建人 | -| 创建时间 | - -### 6.8.6 代理充值导出 - -导出字段: - -| 字段 | -| -------------- | -| 充值单号 | -| 店铺名称 | -| 充值类型 | -| 充值金额 | -| 实付金额 | -| 充值前余额 | -| 充值后余额 | -| 状态 | -| 支付方式 | -| 支付通道 | -| 运营备注 | -| 驳回原因 | -| 创建时间 | -| 支付时间 | -| 完成时间 | -| 提交人 | -| 部门领导审批人 | -| 财务审批人 | -| 支付凭证 | -| 备注 | - -| 套餐时长说明 | :这是剩余天数 - -| 退款到账方式 | - -这个好像没有 : - -#### 之后是否需要增加?因加入财务审批操作退款,可能有原路退回或不同的退款方式如支付宝退款或微信退款? - -| 部门领导审批人 | - -| 财务审批人 | - -这两个好像也没有 - -#### 目前是没有呀,但是之前不是说了审批流程需要加上吗?列表字段同时需要新增 - -| 支付通道 | - -这是啥玩意 - -#### 去掉 - -"导出功能可以根据不同权限显示的字段不同且可以自己选择需要导出的字段。像现在这样全量导出,大家都可以看到虚流量等不想让所有人都看到的字段。" 什么叫不同权限,这个不同权限显示字段不同是固定的吗,平台就是固定的,代理就是固定的等等,如果是这样的话需要标记对应的导出字段的权限划分 - -#### 因为不同的角色,权限不一致,列表的字段不能给每个用户看到类似真流量这样的字段,比如客服只能看到虚流量跟客户看到的一样,应当在角色管理中新增一个导出字段配置 - -> 评审结论:角色级导出字段配置属于本期范围。后端返回当前角色允许导出的字段,最终导出字段取“角色授权字段”与“用户本次选择字段”的交集,前端不能绕过服务端授权。 - - -15. 套餐相关: 套餐下架后,正在使用该套餐的客户仍可续费。,下架套餐续费仅支持客户自己购买。 ,下架套餐不可被新购买。 -16. 代理分销码与佣金提现 - -> 迭代范围变更(2026-07-14):需求 16 整体移出 7 月迭代,后续独立立项和评审。以下内容仅保留原始需求记录,本期不开发、不迁移、不发布。 - -#### 员工可作为代理发展人进行标识。(在系统中如何体现?) - -| 编号 | 需求 | -| ------- | ---------------------------------------------------- | -| DST-001 | 新建代理时自动建立分销归属关系。 | -| DST-002 | 员工可作为代理发展人进行标识。(在系统中如何体现?) | -| DST-003 | 佣金提现前,代理必须签署合同。(必填) | -| DST-004 | 佣金提现需上传营业执照。(可选) | -| DST-005 | 佣金提现需上传法人身份证。(必填) | -| DST-006 | 佣金提现可选上传门头照。(可选) | -| DST-007 | 佣金提现需上传发票,且公司主体需与合同一致。(可选) | - -> 当前结论:原技术方案不再属于 7 月迭代范围。后续重新立项时再评审分销关系、代理申请审批、开店幂等和提现材料。 - -17. 不同渠道信用额度,钱包支持信用额度支付 -| 编号 | 需求 | -| ------- | ------------------------------------------------------------ | -| BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 | -| BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 | -| BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 | -| BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 | - -> 评审结论:信用额度只属于代理主钱包。平台员工是操作主体而不是结算主体,不建立员工钱包、不配置员工信用额度,也不展示员工负余额;角色仅控制谁可以查看或调整代理额度。 - -18. 系统原先对于审核都是单人审核,现在希望增加多人审批以及相关设置 -| 编号 | 需求 | -| ------- | ------------------------------------------------------------ | -| APR-001 | 代理可在系统提交充值申请。 | -| APR-002 | 提交充值申请后系统展示收款二维码,代理扫码支付。 | -| APR-003 | 充值和退款均支持多级审核。 | -| APR-004 | 审核环节包括提交人部门领导审核和财务审核。 | -| APR-005 | 待审核订单需有消息提示。 | -| APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 | -| APR-007 | 审核通过后通知申请人。 | -| APR-008 | 审核驳回后通知申请人,并附带驳回原因。 | -| APR-009 | 审核流程需对接企业微信审批流程。 | - -> 技术口径说明:当前系统没有部门组织模型。“部门领导审核、财务审核”作为默认流程节点名称处理,实际审批人由流程定义配置的角色或指定账号产生,不根据提交人部门自动推导,也不在代码中固定角色名称。每次通过、驳回、退回都形成不可修改的审批意见记录,并可附带最多 5 个审批附件;驳回和退回意见必填。 - -> 审批详情必须展示业务单号、提交人、审批关键字段、业务资料、此前审批人的意见和审批附件。业务资料与审批附件分开存储和展示;流程启动时固化业务快照,实时业务页仍按原业务数据范围校验。 - -19.批量订购套餐 -内部员工进入批量订购页面。 -2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号 -3. Excel表头:跳转至6.3会显示。 -4. 系统校验导入文件和资产状态。 -5. 校验通过的资产从代理余额扣款并完成订购。 -6. 校验失败的明细在页面展示失败原因。 -7. 系统记录操作员、导入明细、导入数量和导入时间。 - -| 编号 | 需求 | -| ------- | ---------------------------------------------------- | -| BPO-001 | 批量订购由内部员工操作。 | -| BPO-002 | 支持代理自行充值钱包后,由员工批量订购并从余额扣款。 | -| BPO-003 | 支持员工代充值至代理余额后,再批量订购并从余额扣款。 | -| BPO-004 | 批量订购无需审核。 | -| BPO-005 | 资产标识支持 ICCID/设备号。 | -| BPO-006 | 支持 Excel 模板导入。 | -| BPO-007 | 需记录操作员、导入明细、导入数量和导入时间。 | -| BPO-008 | 导入失败明细需展示在页面,并显示失败原因。 | - -excel表导入字段: -| 字段 | -| ----------------------------- | -| 资产类型 | -| 资产标识 | -| 套餐系列名称 | -| 套餐名称 | -| 代理名称 | -| 支付方式:代理商账户/员工账户 | - -excel表导入字段修改为以下: - -| 字段 | - -| ----------------------------- | - -| 资产类型 | - -| 资产标识 | - -| 套餐编码 | - -| 套餐名称| - -| 支付方式:线下支付/代理钱包支付 | - -如果用批量订购应当上传对应凭证 - -> 评审结论:支付方式按整批统一。页面选择“线下支付”或“代理钱包支付”,Excel 不再包含支付方式列;线下支付按整批上传凭证,混合支付必须拆成不同批次。 - -20.退款审批 -(审批均可通过企微进行提醒和显示并将最新状态同步至卡管) -1. 员工提交退款申请。 -2. 退款单进入多级审核流程。 -3. 按部门领导、财务顺序审批。 -4. 当前环节审批完成后,下一环节审批人收到消息提示。 -5. 审批通过或驳回后通知申请人。 - -> 评审结论:微信、支付宝和线下退款由财务在系统外人工完成后确认;本期不接入第三方自动退款。代理钱包支付的退款仅自动回退原扣款代理主钱包,客户资产钱包不在本期自动回退范围。 -21. 充值审核流程 -代理自己充值: -1. 代理在系统提交充值申请。 -2. 系统展示收款二维码。 -3. 代理扫码支付。 - -员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管) -1. 充值单进入多级审核流程。 -2. 提交人部门领导先审批。 -3. 财务在上一审批人通过后收到待办提醒并审批。 -4. 审批通过后通知申请人。 -5. 审批驳回后通知申请人,并展示驳回原因。 - -> 技术口径说明:审批通过只表示审批结论成立,不表示退款已经到账或充值已经入账。退款、充值分别维护业务处理状态并支持异步重试。此次采用停机发布,旧退款业务单审批接口和线下充值 `offline-pay/reject` 不保留兼容窗口。 - -22. 套餐临期提醒 -1. 系统每日计算卡/设备套餐剩余有效期。 -2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。 -3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。 -4. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。 -5. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒,并显示立即续费按钮。 -6. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。 -### 6.1.1 企业客户临期提醒 -| 编号 | 需求 | -| ------- | ------------------------------------------------------------ | -| EXP-001 | 后台每日生成企业客户临期列表。 | -| EXP-002 | 临期节点包括 15 天、7 天、3 天。 | -| EXP-003 | 系统需对接企业微信,将临期列表推送给对应业务员。 | -| EXP-004 | 同一资产在不同临期节点可重复触发对应节点提醒,但同一节点每日不可重复推送给同一业务员。 | - -### 6.1.2 代理端临期提醒 - -| 编号 | 需求 | -| ------- | ------------------------------------------------------------ | -| EXP-005 | 代理端首页分别展示临期卡数量和临期设备数量。 | -| EXP-006 | 代理端资产列表对临期资产进行颜色标记。 | -| EXP-007 | 剩余 15 天标记为粉色,剩余 7 天标记为紫色,剩余 3 天标记为红色。 | -| EXP-008 | 剩余 3 天资产在列表中置顶优先展示。 | - -### 6.1.3 C 端公众号首页提醒 - -| 编号 | 需求 | -| ------- | ------------------------------------------------------------ | -| EXP-009 | 当客户套餐剩余有效期小于或等于 15 天时,公众号首页展示套餐到期提醒。 | -| EXP-010 | 提醒展示卡号/设备号、剩余有效期和续费引导文案。 | -| EXP-011 | 按钮文案为“立即续费”。 | -| EXP-012 | 剩余天数需要按日期每天自动更新。 | -| EXP-013 | 当套餐已续费或不再满足临期条件时,提醒不再展示。 | -推荐展示文案: -您的套餐即将到期 - -卡号/设备号:xxx -剩余有效期:xx 天 - -为避免到期后影响正常使用,请您提前完成续费。 - -当一个资产拥有排队中的套餐时不属于临期,不参与临期提醒 - -后台管理只需要在列表检索中新增临期时间字段 条件为小于等于15天的 - -后台管理中资产详情需要新增一个字段临期时间从15天开始显示,前端应当高亮或者变红 - -后台管理中资产列表需要新增返回字段,套餐还有多少天过期 - -临期列表页面需要确认列表展示什么,检索有什么,导出要什么 - - ##### 展示:资产标识、资产类型、店铺、套餐名称、剩余天数、到期时间、资产状态、已用流量、剩余流量 - - ##### 检索条件:资产标识、资产类型、套餐名称、到期时间范围、剩余天数、店铺 - - ##### 导出:资产标识、资产类型、店铺名称、套餐名称、套餐到期时间、剩余天数、资产状态、已用流量、剩余流量 - -临期规则 -企业客户 15,7,3天 -代理 15,7,3天 -C端公众号 小于等于15天 - -颜色规则 -临期天数小于等于15天时,显示粉红色; -临期天数小于等于7天时,显示紫色; -临期天数小于等于3天时,显示黄色; diff --git a/docs/7月迭代/7月迭代技术方案-标准评审稿.md b/docs/7月迭代/7月迭代技术方案-标准评审稿.md index af164a7..a588b14 100644 --- a/docs/7月迭代/7月迭代技术方案-标准评审稿.md +++ b/docs/7月迭代/7月迭代技术方案-标准评审稿.md @@ -1,13 +1,13 @@ # 7月迭代技术方案(标准评审稿) > 状态:待评审 -> 最后更新:2026-07-15 +> 最后更新:2026-07-17 > 分支:`Iteration/7-11` > 系统:`junhong_cmp_fiber` 及配套后台、代理端、C 端前端 > 负责人:待指定 > 评审人:后端、前端、产品、验收负责人、运维 -> 关联需求:7月迭代需求 01~22(需求16已移出)及新增的数据同步、企微审批、站内通知、全局审计、代理钱包扫码充值 -> 说明:本文是技术评审主文档;完整版和专项文档保留为设计素材,不作为会议逐页评审内容。 +> 关联需求:7月迭代需求 01~22(需求16已移出)、禅道补充需求 #43/#86/#96/#97/#98,以及新增的数据同步、企微审批、站内通知、全局审计、代理钱包扫码充值 +> 说明:本文是唯一技术评审主文档;`独立方案/` 和 `来源材料/` 仅用于实施细节与方案溯源。 --- @@ -17,7 +17,7 @@ 当前退款、线下充值审批各自维护,卡实名、流量和网络状态又被轮询、手动刷新及业务操作分别写入,容易出现同一业务动作只更新部分状态。现有账号和资产审计也没有统一模型,无法按人员、资源、请求、资金链路和外部交互追踪。复杂资金和状态业务仍集中在旧 Service 中,配置、批量任务、通知和导出权限缺少统一契约。 -本次评审覆盖原需求 01~22(需求16移出)及五项新增技术需求,主要解决以下问题: +本次评审覆盖原需求 01~22(需求16移出)、禅道补充需求 #43/#86/#96/#97/#98 及五项新增技术需求,主要解决以下问题: - 退款和平台员工线下充值直接接入企业微信审批,本系统只维护业务快照、审批状态镜像和终态处理。 - 将卡实名、流量和网络状态写入收口到统一 DDD 用例,以轮询兜底、业务事件阶梯触发和运营商回调提高同步及时性。 @@ -25,6 +25,7 @@ - 补齐代理后台微信/支付宝扫码充值,在线支付成功后直接幂等入代理主钱包,不进入审批。 - 将信用额度、批量订购、退款和充值中的资金规则收敛为可并发校验的不变量。 - 补齐异步任务、站内消息、动态配置、导出字段权限和 Gateway 限速等基础能力。 +- 补齐换货归属继承与资产换货链、店铺业务员、固定余额预警和代理系列套餐批量授权。 - 统一后台、代理端和 C 端的页面状态、异步反馈和异常展示。 - 在不一次性重构旧系统的前提下,按完整用例渐进迁移 DDD。 @@ -40,6 +41,7 @@ - 后端提供 Excel 模板下载接口;模板由前端静态资源随版本发布。 - 微信/支付宝自动退款,以及基于套餐规则的自动限速。 - 运营商解除实名后自动回滚本地实名状态;第一版只保留防腐层入口和集成记录。 +- 禅道草稿 #41“代理查询限制”和 #51“不同品类资产换货”;草稿不进入本期开发、工时和验收。 ### 1.3 已确认决策 @@ -58,12 +60,18 @@ | D-11 | 采用停机发布,同时切换数据库、API、Worker 和前端,旧审批接口同步下线 | 20、21 | | D-12 | 企微模板 ID 和控件 ID 均按不可变版本映射;编辑模板前暂停场景,发布新映射后原子切换 | 企微审批 | | D-13 | 设备批量分配代理与套餐系列拆为两个独立命令 | 08 | -| D-14 | 排队套餐使用购买时长快照推算预计最后到期时间;临期页面实时查询,定时任务仅发送通知 | 06、22 | +| D-14 | 排队套餐使用购买时长快照推算预计最终到期时间;资产详情、临期和导出共用实时 Query,定时任务仅发送通知 | 06、11、22 | | D-15 | 行业卡是否需要实名由运营商 `realname_link_type` 决定,`card_category` 不参与实名、复机和轮询判断 | 01、02、数据同步 | | D-16 | 数据同步保留轮询兜底,关键业务事件按立即、3 分钟、5 分钟触发;超频不建立退避状态 | 数据同步 | | D-17 | 企微账号由已登录平台员工扫码自助绑定,普通运营不录入 `userid` | 企微审批 | | D-18 | 全系统审计本次一次性切换到 Audit Event + Integration Log,多视角 API 和前端同时发布 | 全局 | | D-19 | 代理在线充值最低 100 元,支持微信 Native 和支付宝 PreCreate,支付成功直接入主钱包且不审批 | 21、新增充值 | +| D-20 | 资产层只展示一个预计最终到期时间;当前套餐和全部排队主套餐共同参与推算,临期也使用同一结果 | 06、11、22 | +| D-21 | 换货完成时新资产自动继承旧资产店铺;旧资产保留原店铺用于历史查询和权限追踪 | 禅道 #98 | +| D-22 | 店铺可选绑定一个平台业务员;该字段只表达业务归属,不恢复需求16的分销和佣金关系 | 禅道 #96 | +| D-23 | 代理主钱包现金可用余额降至 100 元及以下时,向代理主账号和店铺业务员发送一次站内通知;信用额度不参与预警计算 | 禅道 #97 | +| D-24 | 客户角色只提供新建店铺的默认信用配置;修改角色配置不更新任何已有店铺,已有店铺额度只能单独调整 | 17 | +| D-25 | 代理系列套餐授权复用现有批量接口;前端批量选择,已授权套餐明确标记并置灰 | 禅道 #43 | ### 1.4 本期边界 @@ -115,9 +123,9 @@ flowchart LR | 场景 | 选择 | 判断依据 | |------|------|----------| -| 钱包扣款、授信、卡状态应用、退款状态转换 | Domain | 有不变量、状态机、并发或跨表一致性 | -| 单字段修改、简单开关、局部 CRUD | Application 事务脚本 | 规则少且不会形成复杂状态 | -| 列表、报表、导出、跨聚合详情 | Query | 只读、字段多、需要 JOIN/聚合/分页 | +| 钱包扣款、授信、换货完成、卡状态应用、退款状态转换 | Domain | 有不变量、状态机、并发或跨表一致性 | +| 店铺业务员、角色默认额度、简单开关、局部 CRUD | Application 事务脚本 | 规则少且不会形成复杂状态 | +| 系列套餐候选、换货链、列表、报表、导出、跨聚合详情 | Query | 只读、字段多、需要 JOIN/聚合/分页 | | 未被本次用例触碰的旧逻辑 | 保持旧架构 | 避免扩大认知和回归范围 | 站内消息只是幂等写入和已读状态管理,采用轻量 Application/Infrastructure 即可,不为满足目录形式强行创建空洞聚合。 @@ -529,6 +537,10 @@ POST /api/admin/audit/exports | 站内消息 | `/notifications` | 未读数、列表、已读和受控业务跳转 | | 全局审计 | `/operations/audit` | 全局、人员、资源、链路、资金、风险和外部集成多视角 | | 系统配置 | `/settings/system-config` | 受控单选、复选和开关 | +| 店铺管理 | 现有店铺创建、编辑、列表和详情 | 平台业务员绑定、业务员筛选和信用额度来源展示 | +| 代理资金概况 | 现有资金页面 | 固定 100 元余额预警、角色默认额度和店铺单独调额 | +| 代理系列授权 | 现有系列授权创建和详情页 | 套餐候选批量选择、已授权标记和不可重复选择 | +| 资产详情 | 现有卡/设备详情页 | 预计最终到期时间、换货前代/后代标识和受控跳转 | | 异步任务 | 复用各业务页面 | 导入、批量订购、导出进度和失败明细 | 全局状态只保存登录用户、权限和通知未读数。列表、详情和表单草稿保留在页面或模块级状态,避免复制服务端状态后长期失真。 @@ -606,9 +618,8 @@ stateDiagram-v2 | 需求 01 行业卡复机 | 是否要求实名只看运营商 `realname_link_type`;`none` 可未实名复机,`template/gateway` 仍需实名 | 删除按 `card_category=industry` 放行或跳过实名的逻辑,复用卡状态领域规则 | 验证同为行业卡但不同运营商实名能力时行为不同,卡类别不再影响结果 | | 需求 03 联系电话搜索 | 店铺联系电话 11 位精确查询 | 店铺列表 Query 增加 `contact_phone` | 增加搜索框;空参数不影响原查询,非法号码返回参数错误 | | 需求 04 退款中禁止换货 | 企微审批中、历史已退回、企微已通过但退款终态未完成的资产禁止换货;已拒绝、已撤销或退款完成后放行 | 创建换货前批量校验活跃退款,返回“该资产存在退款申请” | 前端直接展示后端错误,不自行判断退款状态 | -| 需求 06 最后到期时间 | 返回当前生效及排队主套餐全部接续后的预计最终到期时间 | 资产详情 Query 按队列顺序使用购买时长快照推演;无套餐返回 `null` | 文案使用“预计最后到期时间”,实时查询,不保存冗余结果 | +| 需求 06/11 套餐到期时间 | 资产层只展示当前生效及排队主套餐全部接续后的预计最终到期时间;当前套餐自身到期时间只保留在套餐明细 | 资产详情 Query 按队列顺序使用购买时长快照推演;无套餐返回 `null`,等待未知实名激活时返回不可预计状态 | 文案使用“预计套餐到期时间”;高亮和临期统一使用最终剩余天数,不维护第二套资产汇总字段 | | 需求 07 实名筛选 | 卡按自身实名状态;设备任一有效绑定卡实名即视为设备实名 | 设备增加 `real_name_status` 快照;实名变化、绑定、解绑、换卡均刷新旧/新设备 | 卡和设备列表增加全部/已实名/未实名筛选 | -| 需求 11 当前套餐到期 | 统一并入需求 22 的剩余天数和颜色规则 | 不再单独维护第二套计算 | 资产详情直接使用需求 22 字段 | | 需求 12 换货显示与搜索 | 卡的新旧资产标识统一快照 ICCID;设备维持设备号;历史数据不回填 | 创建快照逻辑修正;列表增加 `old_asset_keyword/new_asset_keyword`,支持 ICCID、接入号、虚拟号 | 搜索框拆为旧资产和新资产;验收大结果集查询性能 | | 需求 13 列表字段 | 提交人写业务快照;企微审批摘要动态读取 | 换货、退款、充值增加 `submitter_name`;按本页企微实例批量查询状态和审批人摘要,禁止 N+1 | 展示企微状态、当前审批人摘要、处理状态和历史审批标识 | | 需求 15 下架套餐续费 | 禁用套餐始终不可购买;下架套餐只允许资产所有人基于有效历史使用记录续费,禁止代理代购 | 复用统一套餐可售策略;后端根据资产和登录主体判定续费资格 | C 端当前套餐旁展示“续费”,复用购买流程;新购入口隐藏,后台代购禁用 | @@ -802,23 +813,24 @@ PUT /api/admin/roles/{role_id}/export-fields - Count 和 Fetch 必须使用相同权限及查询条件。 - 退款和充值审批摘要按本批实例批量查询,禁止 N+1。 - 导出审批附件只输出数量,不输出对象 Key 或永久 URL。 +- `scene=iot_card` 支持按预计最终到期时间筛选 30 天内资产;该导出条件独立于页面 15 天临期定义,复用需求 06/11/22 的最终到期 Query。 ### 5.8 需求 22:套餐临期提醒 -临期定义:按 `Asia/Shanghai` 自然日计算,当前生效套餐剩余 `0~15` 天,且资产没有可接续排队套餐。已过期资产不按 0 天计入临期。需求 06、11、22 共用查询侧日期计算,但需求06另行推算排队套餐接续后的预计最后到期时间。 +临期定义:按 `Asia/Shanghai` 自然日计算,资产当前生效主套餐与全部排队主套餐连续接续后的**预计最终剩余天数**为 `0~15` 天。已过期资产不按 0 天计入临期;没有生效套餐且队首仍等待无法确定时间的实名激活时,不伪造到期日期,也不进入临期。需求 06、11、22 共用同一个最终到期 Query。 | 剩余天数 | 展示颜色 | 通知节点 | |----------|----------|----------| | 8~15 天 | 粉红色 | 15 天 | | 4~7 天 | 紫色 | 7 天 | -| 0~3 天 | 黄色 | 3 天 | +| 0~3 天 | 红色 | 3 天 | ```mermaid flowchart TD - Schedule[每日定时任务] --> Query[批量查询实时临期候选] - Query --> Queue{存在排队套餐?} - Queue -->|是| Skip[不临期、不通知] - Queue -->|否| Days[计算剩余自然日] + Schedule[每日定时任务] --> Query[计算预计最终到期时间] + Query --> Predictable{可以推算?} + Predictable -->|否| Skip[不临期、不通知] + Predictable -->|是| Days[计算最终剩余自然日] Days --> Node{存在最近未发送阈值?} Node -->|是| Notify[按使用记录+节点+接收人幂等通知] Node -->|否| End[结束] @@ -826,13 +838,87 @@ flowchart TD 后端: -- 卡/设备列表和详情增加 `days_until_expiry`、`is_expiring`。 +- 卡/设备列表和详情增加 `estimated_final_expires_at`、`days_until_final_expiry`、`expiry_estimate_status`、`is_expiring`。 - 列表、详情、临期页、代理首页和 C 端均实时 SQL 计算,不读取每日任务快照,也不需要前端轮询临期状态。 - 新增 `GET /api/admin/expiring-assets`,支持资产、套餐、店铺、到期范围和剩余天数筛选。 +- 临期独立列表固定将 `0~3` 天资产置顶,再按预计最终到期时间升序;普通卡/设备列表只高亮,不改变原排序。 - 代理首页增加临期卡/设备数量。 - C 端 `GET /api/c/v1/asset/info` 增加临期字段并提供续费入口。 - 临期导出复用 `scene=expiring_asset`。 - `tb_expiry_push_record` 从本期起按 `package_usage_id + recipient + channel + node` 防重;漏跑后只补发当前最近且尚未发送的阈值,避免一次补发多条旧通知。 +- 本期只发送站内通知,不发送企业微信临期消息;店铺业务员通过系统账号接收站内通知。 + +### 5.9 禅道补充需求:换货、业务员、余额预警和系列授权 + +#### 5.9.1 #98 换货新资产归属 + +- 新资产允许处于平台库存或已经属于旧资产店铺;如果已经属于其他店铺则拒绝换货。 +- 换货完成事务内先将新资产 `shop_id`、租户标签和资产分配记录切换为旧资产店铺,再迁移客户绑定、钱包/套餐资料并更新新旧资产状态。 +- 旧资产保留原 `shop_id`,状态改为已换货,确保原店铺仍可查看历史订单、退款和换货链。 +- 前端选择新资产后展示“完成后将归属:{旧资产店铺}”,不增加人工选择目标店铺的控件。 +- 换货完成是跨资产、客户绑定、钱包、套餐和状态的一致性用例,迁移到 `exchange` Domain;旧 Service 不再保留第二套完成逻辑。 + +#### 5.9.2 #86 资产详情换货标识 + +资产详情 Query 基于已完成换货单返回: + +```json +{ + "exchange_trace": { + "previous_asset": null, + "next_asset": { + "asset_type": "iot_card", + "asset_id": 2002, + "identifier": "89860...", + "exchange_no": "EXC202607170001" + } + } +} +``` + +- 存在 `previous_asset` 时显示“换货新资产”;存在 `next_asset` 时显示“已换出旧资产”。链路中间资产可以同时存在前代和后代。 +- 关联资产仍需通过后端数据权限校验;无权限时只显示标识,不返回可跳转的资产 ID。 +- 复用 `tb_exchange_order`,不新建换货关系表;补充旧资产查询已完成换货和新资产查询索引。 + +#### 5.9.3 #96 店铺业务员 + +- `tb_shop` 增加可空 `business_owner_account_id`,只允许绑定启用的平台账号。 +- 创建、编辑、列表、详情和筛选接口返回业务员 ID、账号名和手机号摘要。 +- 业务员仅表示店铺业务归属和通知接收关系,不参与店铺层级、数据权限、佣金或分销关系计算。 +- 业务员停用或删除后保留店铺字段和历史审计,通知时跳过不可用账号;运营可重新绑定。 + +#### 5.9.4 #97 固定 100 元钱包余额预警 + +预警阈值固定为 `10000` 分,不提供系统配置、角色配置或店铺配置: + +```text +cash_available = balance - frozen_balance +变动前 cash_available > 10000 +变动后 cash_available <= 10000 +=> 发送一次低余额站内通知 +``` + +- 信用额度不参与余额预警,避免授信掩盖代理现金不足。 +- 接收人是店铺主账号和可用的店铺业务员;站内通知使用稳定业务键防重。 +- 余额回升到 100 元以上后重新布防,再次跌破时可重新通知;持续低于阈值的连续扣款不重复提醒。 +- 资金概况只展示“现金余额不足 100 元”状态,不提供阈值编辑控件。 +- 钱包聚合在余额变更后产生低余额领域事件,事务内写 Outbox;通知失败不能回滚资金事务。 + +#### 5.9.5 #43 代理系列套餐批量授权 + +现有 `PUT /api/admin/shop-series-grants/{id}/packages` 已接受套餐数组,继续作为批量写接口。新增套餐候选 Query: + +```text +GET /api/admin/shop-series-grants/{id}/package-options +``` + +返回 `package_id`、名称、编码、公司成本价、当前代理授权成本价、建议售价和 `is_authorized`。首次授权系列和后续管理套餐都使用同一候选列表: + +- 已授权套餐显示“已授权”并置灰,不可重复选择。 +- 未授权套餐支持复选框多选并一次提交。 +- 后端对重复套餐按幂等处理,不能依赖前端置灰保证一致性。 +- `company_cost_price`、`authorized_cost_price` 和 `suggested_retail_price` 分字段返回,禁止继续使用含义不明确的单一 `cost_price` 展示。 +- 套餐候选属于 Query,批量授权属于轻量 Application 事务脚本,不为此创建空洞聚合。 --- @@ -842,13 +928,20 @@ flowchart TD > 状态:2026-07-14 移出 7 月迭代 -本期不建设分销码、H5 代理申请、代理申请审批、自动开店、发展人关系和佣金提现材料。本需求后续独立立项时重新评审数据模型、H5 安全、审批接入、开店幂等、提现材料和工时。 +本期不建设分销码、H5 代理申请、代理申请审批、自动开店、分销发展关系和佣金提现材料。禅道 #96 新增的店铺业务员字段只用于业务归属、筛选和通知,不产生分销层级或佣金关系。本需求后续独立立项时重新评审数据模型、H5 安全、审批接入、开店幂等、提现材料和工时。 本期审批流仅接入退款和平台员工线下充值;迁移、API、前端、发布及工时均不包含需求 16。 ### 6.2 需求 17:代理主钱包信用额度 -平台员工不是结算和负债主体,不建立员工钱包或信用额度。角色只表达权限,不能承载财务余额和债务。 +平台员工不是结算和负债主体,不建立员工钱包或信用额度。客户角色可以保存新建店铺的默认信用模板,但不能成为债务主体;真正参与资金计算的信用额度始终写入代理主钱包。 + +角色默认规则: + +- 新建店铺时读取 `default_role_id` 的默认信用配置并初始化主钱包。 +- 修改角色默认信用配置只影响以后新建的店铺,不更新任何已有店铺。 +- 已有店铺通过独立资金接口直接修改实际额度,之后也不跟随角色变化。 +- 店铺后续增加其他角色不改变钱包信用额度,避免多角色组合影响资金事实。 #### 不变量 @@ -873,6 +966,15 @@ credit_enabled BOOLEAN NOT NULL DEFAULT FALSE credit_limit BIGINT NOT NULL DEFAULT 0 ``` +`tb_role` 增加: + +```text +default_credit_enabled BOOLEAN NOT NULL DEFAULT FALSE +default_credit_limit BIGINT NOT NULL DEFAULT 0 +``` + +角色字段仅允许客户角色使用。平台角色固定关闭;运行时扣款不 JOIN 角色表。 + 数据库 CHECK 至少保证: ```sql @@ -894,17 +996,20 @@ CHECK ( | 用例 | 规则 | |------|------| +| 修改角色默认额度 | 只更新角色模板和审计,不扫描、不修改任何已有店铺钱包 | +| 创建店铺 | 在创建事务中读取默认角色模板并初始化代理主钱包实际额度 | | 修改额度 | 校验权限、加载主钱包、校验新可用金额、按 `version` 条件更新、写信用变更审计 | | 钱包扣款 | 使用有效额度计算可用金额,同事务更新余额、版本和资金流水 | | 查询/导出 | Query 返回 `credit_enabled`、`credit_limit`、`available_balance`、`is_in_debt`、`debt_amount` | ```text POST /api/admin/shops 创建代理时可初始化信用配置 +PUT /api/admin/roles/{id}/default-credit 修改新建店铺默认信用模板 PUT /api/admin/shops/{id}/credit-limit 独立调整信用额度 GET /api/admin/shops/fund-summary 返回信用和可用金额 ``` -前端只展示接口返回的可用金额,不自行重新计算。额度调整弹框显示修改前后金额预览;并发冲突时刷新最新钱包版本。 +角色页面显示“新建代理默认信用额度”,并明确提示“修改后不会影响已有店铺”。店铺资金页面独立显示和修改实际信用额度。前端只展示接口返回的可用金额,不自行重新计算;额度调整弹框显示修改前后金额预览,并发冲突时刷新最新钱包版本。 ### 6.3 需求 18:多人审批业务映射 @@ -1129,8 +1234,11 @@ POST /api/admin/agent-recharges/{id}/reject | 修改 | `tb_polling_config` | 收口为全局活跃/不活跃间隔,不再按卡类别形成轮询资格 | | 修改 | `tb_iot_card.iccid_19` | 未删除数据范围 Partial Unique Index | | 修改 | `tb_shop_package_allocation`、`tb_package_usage` | 生效条件、周期和时长快照 | -| 修改 | `tb_exchange_order` | 提交人快照;新建记录修正资产标识快照 | -| 修改 | `tb_agent_wallet` | 信用开关、额度和 CHECK 约束 | +| 修改 | `tb_exchange_order` | 提交人快照、资产标识快照和新旧资产查询索引 | +| 修改 | `tb_iot_card`、`tb_device` | 换货完成时新资产继承旧资产店铺和租户标签 | +| 修改 | `tb_shop` | 可空平台业务员字段 `business_owner_account_id` | +| 修改 | `tb_role` | 新建店铺默认信用开关和额度模板 | +| 修改 | `tb_agent_wallet` | 实际信用开关、额度和 CHECK 约束 | | 修改 | `tb_refund_request` | 提交人、企微实例轮次、撤销状态和终态处理结果 | | 修改 | `tb_agent_recharge_record` | 提交人、企微实例、支付状态和入账处理结果 | | 修改 | `tb_payment` | 支持 `order_type=agent_recharge` 和创建时支付配置快照 | @@ -1196,6 +1304,10 @@ flowchart LR | C-15 | 企微账号只允许扫码绑定;一个 `userid` 不能覆盖绑定到第二个系统账号。 | | C-16 | 代理在线充值最低 100 元,支付成功直接入主钱包,不因金额大进入审批。 | | C-17 | 全局审计本次停止旧表新写入;历史只读投影,不双写、不在线回填。 | +| C-18 | 角色默认信用配置只作用于以后新建的店铺;修改角色不更新已有店铺。 | +| C-19 | 钱包预警阈值固定 100 元,按 `balance - frozen_balance` 判断,不包含信用额度。 | +| C-20 | 临期只发站内通知;3 天内使用红色,且只在临期独立列表置顶。 | +| C-21 | 换货新资产继承旧资产店铺;旧资产保留原归属,已属于其他店铺的新资产不得换入。 | Gateway 的取消限速具体报文不是产品决策:上线前由上游接口契约确定,业务层始终只传 `speed_kbps=0`。 @@ -1217,6 +1329,9 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口 | 限速对象错误 | 设备无当前卡或错误把 IMEI 当卡号 | 统一解析当前卡 ICCID;无卡拒绝调用并记录审计 | | 导出敏感字段泄露 | 权限缺失或 Worker 回退全字段 | 服务端字段交集、任务快照、失败关闭 | | 临期重复通知 | 定时任务重试 | 稳定事件 ID 和接收人维度唯一约束 | +| 余额预警刷屏 | 钱包持续低于 100 元并发生多次扣款 | 仅跨越阈值时发送,回升后才重新布防 | +| 换货归属越权 | 新资产已属于其他店铺或关联资产无查看权限 | 事务内归属校验;关联跳转继续执行后端数据权限 | +| 角色额度误改存量 | 管理员修改角色默认额度 | API 只更新角色模板,不批量更新钱包;页面明确影响范围 | --- @@ -1247,12 +1362,16 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口 | 数据同步 | 活跃调频、0/3/5 阶梯、同场景合并、单卡互斥、超频不退避、19/20位回调、解除实名忽略、旧入口收口 | | 全局审计 | 关键事务失败回滚、人员/资源/请求/资金/风险/集成视角、历史投影、脱敏、旧表停止新写入 | | 钱包 | 普通余额扣款、信用扣款、额度降低失败、并发版本冲突、负余额回充 | +| 角色默认额度 | 新建店铺继承默认值、修改角色不影响已有店铺、店铺独立调额 | +| 余额预警 | 现金余额跨越 100 元阈值、持续低余额不重复、回升后再次跌破、代理和业务员接收通知 | | 批量订购 | 钱包/线下两种支付、部分成功、重复提交、Worker 重试、失败明细、模板错误 | | 退款 | 金额发起时固定、财务人工退款企微确认、代理钱包回退原主钱包、个人资产钱包不误入、通过后撤销不冲正 | | 充值 | 微信/支付宝最低100元扫码、重复回调不重复入账、已支付恢复、在线不审批、线下企微通过自动入账且无操作密码 | | 导出 | 普通角色、敏感字段角色、超级管理员、权限为空、审批摘要批量查询 | | 限速 | 单卡、设备当前卡、无当前卡、设置、取消、Gateway 失败和审计记录 | -| 临期 | 有/无排队套餐、15/7/3 天、漏跑补发、重复定时任务、不同接收人、列表/详情/C端一致 | +| 临期 | 当前与排队套餐最终到期推算、等待实名不可预计、15/7/3 天、3天红色和临期页置顶、漏跑补发、列表/详情/C端一致 | +| 换货补充 | 新资产继承店铺、其他店铺资产拒绝、旧资产保留归属、前代/后代标识和受控跳转 | +| 系列授权 | 首次和后续批量选择、已授权置灰、重复提交幂等、三类价格含义正确 | | 发布 | 存量退款和充值回填、历史 `legacy`、旧路由不可访问、Worker 暂停后恢复 | 验收使用接口调用、PostgreSQL 数据核对、日志检查和页面操作,不以自动化测试作为本项目交付前提。 @@ -1267,8 +1386,8 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口 3. 卡状态领域、轮询活跃调频、事件阶梯和运营商回调防腐层 4. 企微连接、模板版本、账号扫码绑定、提交/回调/轮询 5. 退款和平台员工线下充值接入企微,代理微信/支付宝扫码充值 -6. 钱包信用额度、批量订购、导出权限、设备批量分配、限速和临期提醒 -7. 其他查询、字段和显示修复、存量回填和停机发布演练 +6. 钱包信用额度、业务员和余额预警、批量订购、导出权限、设备批量分配、系列套餐批量授权、限速和临期提醒 +7. 换货归属与换货链、其他查询和显示修复、存量回填及停机发布演练 ``` 每个复杂用例按“迁移 → Application/Domain → Infrastructure → Handler → 前端 → 人工验收”形成完整闭环,不在同一任务中顺带重构未触碰模块。 @@ -1280,7 +1399,7 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口 ### 11.1 估算口径 - 1 人日按 8 小时计算。 -- 当前固定投入为 1 名后端和 1 名前端,两人并行开发,目标自然周期为 11~15 个工作日。 +- 当前固定投入为 1 名后端和 1 名前端,两人并行开发,正常目标为 12~15 个工作日,风险上限为 16 个工作日。 - 包含本期企业微信审批、数据同步重构、全局审计一次性切换、代理微信/支付宝扫码充值及原 7 月迭代范围。 - 不包含套餐临期企业微信消息推送、自动第三方退款、运营商解除实名回滚和需求16。 - 包含后端、前端、第三方联调、人工接口验证、PostgreSQL 数据核对、存量回填和停机发布;不单独预留长期缓冲。 @@ -1293,28 +1412,28 @@ Gateway 的取消限速具体报文不是产品决策:上线前由上游接口 | 工作包 | 后端人日 | 前端人日 | 快速交付说明 | |--------|----------|----------|--------------| -| 迁移、Outbox、全局审计和站内通知 | 2 | 2 | 复用现有日志、列表和抽屉组件,先完成核心多视角 | -| 数据同步领域收口、活跃轮询和运营商回调 | 2~3 | 1 | 后端为主,前端只补状态和审计跳转 | -| 企微模板、扫码绑定、提交/回调/轮询 | 2~3 | 2~3 | 复用企微官方页面和现有业务详情布局 | -| 退款、线下充值终态和代理扫码充值 | 2~3 | 2~3 | 复用现有钱包、支付回调和充值页面 | -| 信用、批量、导出、限速、临期及其他需求 | 2~2.5 | 2~3 | 多个小需求并行处理,避免额外抽象 | -| 联调、数据核对、回填和停机发布 | 1~1.5 | 1~2 | 随开发持续联调,最后集中验证核心链路 | -| **合计投入** | **11~15** | **10~14** | **总投入约 21~29 人日,两人并行 11~15 个工作日** | +| 迁移、Outbox、全局审计和站内通知 | 2 | 1.5~2 | 复用现有日志、列表和抽屉组件,先完成核心多视角 | +| 数据同步领域收口、活跃轮询和运营商回调 | 2~3 | 0.5~1 | 后端为主,前端只补状态和审计跳转 | +| 企微模板、扫码绑定、提交/回调/轮询 | 2~3 | 1.5~2 | 复用企微官方页面和现有业务详情布局 | +| 退款、线下充值终态和代理扫码充值 | 2~3 | 1.5~2 | 复用现有钱包、支付回调和充值页面 | +| 信用、业务员预警、换货、系列授权、批量、导出、限速和临期 | 2.5~3.5 | 2.5~3 | 系列授权和换货链已有后端基础,只补增量能力 | +| 联调、数据核对、回填和停机发布 | 1~1.5 | 0.5~1 | 随开发持续联调,最后集中验证核心链路 | +| **合计投入** | **约 12~16** | **约 8~12** | **总投入约 19~28 人日,两人并行正常 12~15 个工作日,风险上限 16 天** | -排期基线按 **13 个工作日**,对外承诺范围为 **11~15 个工作日**。如真实企微/支付配置无法按时提供、生产 ICCID 或审计旧入口存在大量异常,问题记录为外部阻塞,不在开发阶段扩展额外重构范围。 +排期基线按 **14 个工作日**,正常范围为 **12~15 个工作日**。如换货归属需要补齐超出预期的租户标签表、真实企微/支付配置无法按时提供、生产 ICCID 或审计旧入口存在大量异常,风险上限为第 16 个工作日,不在开发阶段扩展额外重构范围。 ### 11.3 当前团队排期 -当前团队固定为 **1 后端 + 1 前端**,两人从第一天开始并行,预计 **11~15 个工作日**。推荐节奏: +当前团队固定为 **1 后端 + 1 前端**,两人从第一天开始并行,正常预计 **12~15 个工作日**,风险上限 **16 个工作日**。推荐节奏: ```text 第 1~2 天:迁移、Outbox、审计/通知骨架,前端同步搭建页面框架 第 3~5 天:数据同步收口、活跃轮询、回调防腐层和审计外部集成视角 第 4~7 天:企微模板、扫码绑定、审批提交/回调/轮询及前端配置页面 第 6~9 天:退款、线下充值终态、代理微信/支付宝扫码充值 -第 8~11 天:信用、批量、导出、限速、临期和其他小需求 -第 11~13 天:全链路联调、数据核对、旧入口清理和存量回填演练 -第 14~15 天:问题修复和停机发布缓冲;进度稳定时可提前 +第 8~12 天:信用、业务员预警、换货归属与标识、系列批量授权、批量、导出、限速和临期 +第 12~14 天:全链路联调、数据核对、旧入口清理和存量回填演练 +第 15 天:问题修复和停机发布;换货租户标签或历史数据超预期时使用第 16 天风险缓冲 ``` --- diff --git a/docs/7月迭代/7月迭代禅道研发需求拆分表.md b/docs/7月迭代/7月迭代禅道研发需求拆分表.md new file mode 100644 index 0000000..9111452 --- /dev/null +++ b/docs/7月迭代/7月迭代禅道研发需求拆分表.md @@ -0,0 +1,242 @@ +# 7月迭代禅道研发需求拆分表 + +> 来源:禅道用户需求导出与7月迭代标准评审稿。 +> 范围:只包含激活需求;草稿 #41/#51 和已关闭重复需求 #54 不创建研发需求。 +> +> 本文件用于总览、排期和关联关系,不用于逐条复制。实际录入禅道请使用:[7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)。录入稿中每条 FE/BE/INT 都有可独立复制的标题和完整描述。 + +## 一、拆分规则 + +每条用户需求下创建两条研发需求: + +```text +[FE][UR#编号] 前端交付名称 +[BE][UR#编号] 后端交付名称 +``` + +- FE、BE 研发需求分别指派给前端和后端,可并行进入开发。 +- 研发需求必须描述可独立交付的页面或接口能力,不能只写“配合前端”“配合后端”。 +- 联调属于研发需求完成后的交付活动,不为每条用户需求机械创建第三条研发需求。 +- 跨页面、跨模块、异步任务或第三方系统场景,统一创建链路级联调任务。 +- 简单搜索、字段展示等需求在 FE/BE 研发需求中完成自验,不单独创建联调任务。 + +推荐状态依赖: + +```text +FE/BE 研发需求开发完成 + -> 接口契约冻结 + -> 进入对应联调任务 + -> 问题回到原 FE/BE 研发需求修复 + -> 联调任务验收通过 +``` + +## 二、用户需求到研发需求映射 + +| 用户需求 | 前端研发需求 | 后端研发需求 | 联调归属 | +|---|---|---|---| +| #98 换货管理新资产归属 | `[FE][UR#98] 换货新资产归属继承提示`:选择新资产后展示将继承的店铺 | `[BE][UR#98] 换货新资产继承旧资产店铺`:事务内迁移归属、租户标签和分配记录 | INT-02 换货链路 | +| #97 代理钱包阈值提醒 | `[FE][UR#97] 代理现金余额不足100元展示与通知跳转` | `[BE][UR#97] 主钱包固定100元余额预警`:跨越阈值、重新布防、站内通知 | INT-05 钱包支付 | +| #96 员工作为发展人进行标识 | `[FE][UR#96] 店铺业务员选择、展示和筛选` | `[BE][UR#96] 店铺业务员关联与查询`:只绑定启用平台账号 | INT-05 钱包支付 | +| #94 系统状态同步优化以及回调处理 | `[FE][UR#94] 资产同步状态与审计入口展示` | `[BE][UR#94] 资产状态同步DDD收口`:轮询、0/3/5事件、回调防腐层 | INT-01 实名与同步 | +| #86 资产详情中换货标识 | `[FE][UR#86] 资产详情前代/后代换货标识与跳转` | `[BE][UR#86] 资产换货链Query`:返回 previous/next asset并校验权限 | INT-02 换货链路 | +| #73 行业卡操作停复机 | `[FE][UR#73] 复机实名校验结果与错误提示适配` | `[BE][UR#73] 按运营商实名能力控制复机` | INT-01 实名与同步 | +| #62 H5设置先充值后实名 | `[FE][UR#62] H5实名与购买顺序流程`:先实名/先购买/无需实名 | `[BE][UR#62] 资产实名顺序策略与批量配置接口` | INT-01 实名与同步 | +| #60 店铺联系电话检索 | `[FE][UR#60] 店铺联系电话搜索控件` | `[BE][UR#60] 店铺联系电话精确查询` | 不单建,FE/BE自验 | +| #57 退款中禁止换货 | `[FE][UR#57] 换货退款拦截错误展示` | `[BE][UR#57] 换货前活跃退款校验` | INT-02 换货链路 | +| #55 套餐生效条件 | `[FE][UR#55] 套餐分配生效条件选择与恢复默认` | `[BE][UR#55] 套餐分配生效条件覆盖与购买快照` | INT-03 套餐生命周期 | +| #53 资产实名状态筛选 | `[FE][UR#53] 卡和设备实名状态筛选` | `[BE][UR#53] 卡/设备实名状态查询与设备快照维护` | INT-01 实名与同步 | +| #49 设备批量分配代理和套餐系列 | `[FE][UR#49] 设备批量分配代理/套餐系列双入口` | `[BE][UR#49] 两类设备批量分配任务与失败明细` | INT-04 批量与导出 | +| #48 不同资产使用不同支付方式 | `[FE][UR#48] C端按资产展示允许支付方式` | `[BE][UR#48] 支付方式配置与订单二次校验` | INT-05 钱包支付 | +| #47 限速规则 | `[FE][UR#47] 卡/设备手动设置与取消限速` | `[BE][UR#47] Gateway按cardNo限速统一接口` | INT-07 Gateway限速 | +| #46 资产信息详情字段新增 | `[FE][UR#46] 预计最终到期时间与临期高亮` | `[BE][UR#46] 当前及排队套餐最终到期Query` | INT-03 套餐生命周期 | +| #45 换货管理 | `[FE][UR#45] 换货新旧资产展示与独立搜索` | `[BE][UR#45] 换货标识快照修正与新旧资产查询` | INT-02 换货链路 | +| #44 列表字段新增 | `[FE][UR#44] 退款/充值/换货提交人与审批摘要展示` | `[BE][UR#44] 提交人快照与企微审批摘要批量查询` | INT-06 企微审批 | +| #43 代理系列授权 | `[FE][UR#43] 系列套餐批量选择与已授权置灰` | `[BE][UR#43] 系列套餐候选Query与重复授权幂等` | INT-03 套餐生命周期 | +| #42 导出功能 | `[FE][UR#42] 导出字段选择、权限展示和任务进度` | `[BE][UR#42] 导出场景、角色字段权限与30天到期筛选` | INT-04 批量与导出 | +| #40 套餐设计 | `[FE][UR#40] C端下架套餐续费入口` | `[BE][UR#40] 下架套餐历史用户续费资格校验` | INT-03 套餐生命周期 | +| #38 不同渠道额度处理 | `[FE][UR#38] 角色默认信用和店铺实际额度管理` | `[BE][UR#38] 代理主钱包信用额度与并发资金不变量` | INT-05 钱包支付 | +| #37 审核流转 | `[FE][UR#37] 企微审批状态、意见附件和结果通知展示` | `[BE][UR#37] 企微审批模板、账号绑定、回调和轮询补偿` | INT-06 企微审批 | +| #36 批量订购套餐 | `[FE][UR#36] 批量订购上传、支付方式、进度和失败明细` | `[BE][UR#36] 批量订购任务、逐行幂等下单和钱包扣款` | INT-04 批量与导出、INT-05 钱包支付 | +| #35 退款审核 | `[FE][UR#35] 退款企微审批详情与业务处理状态` | `[BE][UR#35] 退款企微终态、人工退款和代理钱包回溯` | INT-06 企微审批 | +| #34 充值审核流程 | `[FE][UR#34] 代理扫码充值与员工线下审批状态页面` | `[BE][UR#34] 微信/支付宝充值入账和线下充值企微终态` | INT-05 钱包支付、INT-06 企微审批 | +| #33 套餐临期提醒 | `[FE][UR#33] 临期列表、各端高亮、3天置顶和续费入口` | `[BE][UR#33] 最终到期临期Query与15/7/3站内通知` | INT-03 套餐生命周期 | + +## 三、前后端并行约定 + +研发需求创建前先冻结本表中的接口契约。前端按 Mock 数据开发页面,后端按同一契约实现接口。 + +- 响应统一为 `{code,msg,data,timestamp}`,下表只描述 `data`。 +- 分页统一返回 `{items,total,page,size}`。 +- 金额入参和返回均使用分;前端显示元。 +- 生命周期判断使用 `status`,展示使用 `status_name`。 +- 字段允许增加但不能改名或改变类型;确需调整时,必须同步修改 FE、BE 两条研发需求。 +- 前端先完成页面、交互、Mock、加载/空/错误状态;后端完成后只替换数据源并进入联调。 + +每组需求按以下顺序启动: + +```text +1. FE/BE共同确认路径、方法、入参、返回和枚举 +2. 后端先补DTO/OpenAPI契约或提供固定JSON示例 +3. 前端按JSON示例完成页面和Mock请求 +4. 后端实现真实查询、写入、权限、幂等和审计 +5. 前端切换真实接口,进入对应INT联调任务 +``` + +契约确认时必须标记字段是必返、可空还是仅特定状态返回,避免前端通过猜测补默认值。 + +## 四、研发需求压缩总览 + +> 下表仅用于快速核对,不建议复制到禅道。前端页面结构、交互状态、接口入参和返回字段以[逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)为准。 + +| 用户需求 | 前端研发需求说明 | 后端研发需求与接口契约 | +|---|---|---| +| #98 换货新资产归属 | **标题:**换货新资产归属继承提示。
**页面:**换货创建、发货确认。选择新资产后展示“完成后归属到某店铺”,提交中禁止重复操作,失败展示后端原因。 | **标题:**换货新资产继承旧资产店铺。
**接口:**复用 `POST /api/admin/exchanges`、`POST /api/admin/exchanges/{id}/ship`、`POST /api/admin/exchanges/{id}/complete`。
**入参:**旧/新资产标识、换货类型、是否迁移资料。
**返回:**换货单及 `inherited_shop_id/inherited_shop_name`。
**规则:**平台库存新资产继承旧店铺;其他店铺资产拒绝。 | +| #97 钱包100元预警 | **标题:**代理现金余额不足100元展示。
**页面:**资金概况显示红色预警;顶部通知和通知中心支持跳转店铺资金页,不提供阈值配置。 | **标题:**代理主钱包固定100元余额预警。
**接口:**`GET /api/admin/shops/fund-summary` 增加 `cash_available/low_balance_warning`;通知复用 `/api/admin/notifications`。
**规则:**`balance-frozen_balance<=10000`,不含信用额度;只在跨越阈值时通知,回升后重新布防。 | +| #96 店铺业务员 | **标题:**店铺业务员选择、展示与筛选。
**页面:**店铺新建/编辑增加平台业务员下拉,列表和详情展示业务员,筛选栏支持按业务员查询。 | **标题:**店铺业务员关联与查询。
**接口:**`POST /api/admin/shops`、`PUT /api/admin/shops/{id}` 入参增加 `business_owner_account_id`;`GET /api/admin/shops` 增加同名筛选。
**返回:**`business_owner_account_id/business_owner_name`。只允许启用的平台账号。 | +| #94 状态同步优化 | **标题:**资产同步状态与审计入口。
**页面:**资产详情展示活跃级别、最后活跃、下次轮询;保留原刷新按钮,增加“查看同步轨迹”跳转。 | **标题:**资产状态同步DDD收口。
**接口:**不新增刷新入口,复用 `POST /api/admin/assets/{identifier}/refresh`;`GET /api/admin/assets/resolve/{identifier}` 返回 `polling`;轨迹使用 `GET /api/admin/audit/integrations`。
**规则:**轮询、0/3/5事件和回调统一应用状态。 | +| #86 资产换货标识 | **标题:**资产详情换货前代/后代展示。
**页面:**显示“换货新资产”“已换出旧资产”标签,可查看前代和后代;无权限时不可点击。 | **标题:**资产换货链Query。
**接口:**`GET /api/admin/assets/resolve/{identifier}` 增加 `exchange_trace.previous_asset/next_asset`。
**返回:**资产类型、ID、标识、换货单号和 `can_view`。 | +| #73 行业卡复机 | **标题:**复机实名结果和错误提示。
**页面:**沿用资产详情复机按钮;未满足实名条件时展示后端中文原因。 | **标题:**按运营商实名能力控制复机。
**接口:**复用 `POST /api/admin/assets/{identifier}/start`。
**返回:**最新资产状态。
**规则:**只看运营商 `realname_link_type`,不按行业卡类型统一放行。 | +| #62 H5实名顺序 | **标题:**H5先实名/先购买流程及后台批量配置。
**页面:**C端按接口策略跳转;后台卡和设备列表提供批量修改入口。 | **标题:**资产实名顺序策略。
**接口:**`PATCH /api/admin/assets/{identifier}/realname-mode`;`POST /api/admin/iot-cards/batch-update-realname-policy`;`POST /api/admin/devices/batch-update-realname-policy`。
**入参:**资产标识列表、`policy=none/before_order/after_order`。
**返回:**生效策略及成功数量。 | +| #60 联系电话搜索 | **标题:**店铺联系电话搜索。
**页面:**店铺列表新增11位联系电话输入框,支持清空和重新查询。 | **标题:**店铺联系电话精确查询。
**接口:**`GET /api/admin/shops?contact_phone=`。
**入参:**11位手机号。
**返回:**原店铺分页结构。 | +| #57 退款中禁止换货 | **标题:**换货退款拦截提示。
**页面:**创建换货失败时原地展示“该资产存在退款申请”,不自行判断退款状态。 | **标题:**换货前活跃退款校验。
**接口:**复用 `POST /api/admin/exchanges`。
**规则:**审批中、已退回或退款处理未完成时返回业务错误;已拒绝、撤销或处理完成后放行。 | +| #55 套餐生效条件 | **标题:**套餐分配生效条件选择。
**页面:**授权/分配弹框提供跟随默认、购买生效、实名生效三选一;已分配记录支持修改和恢复默认。 | **标题:**套餐分配生效条件覆盖与快照。
**接口:**`POST /api/admin/shop-package-allocations`;`PATCH /api/admin/shop-package-allocations/{id}/expiry-base`。
**入参:**`expiry_base_override`,`null` 表示恢复默认。
**返回:**默认值、覆盖值和最终生效值。 | +| #53 实名状态筛选 | **标题:**卡和设备实名状态筛选。
**页面:**两个资产列表增加全部/已实名/未实名筛选和状态列。 | **标题:**卡和设备实名状态查询。
**接口:**`GET /api/admin/iot-cards?real_name_status=0\|1`;`GET /api/admin/devices?real_name_status=0\|1`。
**返回:**`real_name_status/real_name_status_name`。 | +| #49 设备批量分配 | **标题:**设备批量分配代理和套餐系列双入口。
**页面:**两个独立上传弹框,展示总数、成功数、失败数和失败原因。 | **标题:**设备两类批量分配任务。
**接口:**`POST /api/admin/devices/batch-assign-shop`、`POST /api/admin/devices/batch-assign-series`、`GET /api/admin/devices/batch-allocation/{task_id}`。
**入参:**文件和目标ID。
**返回:**任务状态及失败明细。 | +| #48 支付方式限制 | **标题:**C端按资产展示支付方式。
**页面:**支付页只展示接口返回的支付方式;无可用方式时禁止提交。 | **标题:**资产支付方式配置与订单校验。
**接口:**`GET /api/c/v1/asset/info` 返回 `allowed_payment_methods`;`POST /api/c/v1/orders/create`、`POST /api/c/v1/orders/{id}/pay` 再次校验。
**后台:**`PUT /api/admin/system/config/{config_key}`。 | +| #47 Gateway限速 | **标题:**卡和设备手动限速。
**页面:**详情页提供设置、取消入口;设备显示最终使用的当前卡ICCID。 | **标题:**Gateway按cardNo统一限速。
**接口:**`POST /api/admin/assets/{identifier}/speed-limit`。
**入参:**`speed_kbps`,0表示取消。
**返回:**资产标识、最终 `card_no`、目标值和执行结果。 | +| #46 预计最终到期 | **标题:**预计套餐到期时间和临期高亮。
**页面:**资产详情只显示一个最终到期字段;不可预计时显示“待激活后起算”。 | **标题:**当前及排队套餐最终到期Query。
**接口:**`GET /api/admin/assets/resolve/{identifier}`。
**返回:**`estimated_final_expires_at/days_until_final_expiry/expiry_estimate_status/is_expiring`。 | +| #45 换货管理 | **标题:**换货新旧资产展示和独立搜索。
**页面:**列表分别搜索旧资产、新资产,展示统一卡ICCID或设备号。 | **标题:**换货标识快照和查询。
**接口:**`GET /api/admin/exchanges?old_asset_keyword=&new_asset_keyword=`。
**返回:**新旧资产类型、ID、标识及换货状态。新建换货统一保存规范化标识。 | +| #44 列表字段新增 | **标题:**退款、充值、换货列表提交人与审批摘要。
**页面:**增加提交人、企微状态、当前审批人摘要和业务处理状态。 | **标题:**提交人快照与企微摘要查询。
**接口:**复用 `GET /api/admin/refunds`、`GET /api/admin/agent-recharges`、`GET /api/admin/exchanges`。
**返回:**`submitter_name/approval_status_name/current_approver_summary/processing_status_name`。 | +| #43 系列批量授权 | **标题:**系列套餐批量选择与已授权置灰。
**页面:**首次和后续授权共用多选表格;已授权套餐置灰,展示公司成本、授权成本和建议售价。 | **标题:**系列套餐候选和批量授权。
**接口:**`GET /api/admin/shop-series-grants/{id}/package-options`;`PUT /api/admin/shop-series-grants/{id}/packages`。
**返回:**三类价格、`is_authorized`;重复授权幂等。 | +| #42 导出功能 | **标题:**导出字段选择、权限和任务进度。
**页面:**导出弹框加载可选字段,提交后展示进度、失败和下载。 | **标题:**统一导出场景和字段权限。
**接口:**`GET /api/admin/export-fields?scene=`、`POST /api/admin/export-tasks`、`GET /api/admin/export-tasks/{id}`。
**入参:**`scene/format/query/fields`。
**返回:**任务进度和下载地址。 | +| #40 下架套餐续费 | **标题:**C端当前套餐续费入口。
**页面:**当前套餐旁显示续费;下架套餐不出现在新购列表。 | **标题:**下架套餐续费资格。
**接口:**`GET /api/c/v1/asset/packages` 返回 `can_purchase/purchase_mode/disabled_reason`;`POST /api/c/v1/orders/create` 强校验资产所有人和历史使用记录。 | +| #38 代理信用额度 | **标题:**角色默认信用和店铺实际额度管理。
**页面:**客户角色配置新建默认额度并提示不影响存量;店铺资金页单独调整实际额度。 | **标题:**代理主钱包信用额度。
**接口:**`PUT /api/admin/roles/{id}/default-credit`、`PUT /api/admin/shops/{id}/credit-limit`、`GET /api/admin/shops/fund-summary`。
**入参:**开关、额度、钱包版本。
**返回:**额度、可用金额、欠款和版本。 | +| #37 企微审核流转 | **标题:**企微配置、账号绑定和审批详情。
**页面:**企微配置页、个人扫码绑定、审批运行列表;业务详情只读展示意见和附件。 | **标题:**企业微信审批接入。
**接口:**`GET /api/admin/wecom/status`、`POST /api/admin/wecom/account-binding/sessions`、`GET /api/admin/wecom/approvals`、`POST /api/admin/wecom/approvals/{id}/sync`。
**业务详情:**统一返回 `approval` 对象。 | +| #36 批量订购套餐 | **标题:**批量订购上传、支付、进度和失败明细。
**页面:**选择代理和整批支付方式,上传Excel及线下凭证,展示部分成功。 | **标题:**批量订购任务。
**接口:**`POST /api/admin/bulk-purchases`、`GET /api/admin/bulk-purchases/{task_id}`、`GET /api/admin/bulk-purchases/{task_id}/items`。
**入参:**代理、支付方式、文件、凭证。
**返回:**任务和逐行结果。 | +| #35 退款审核 | **标题:**退款企微审批和退款处理状态。
**页面:**创建时上传备注附件;详情展示审批、人工退款说明和业务处理结果。 | **标题:**退款企微终态处理。
**接口:**`POST /api/admin/refunds`、`GET /api/admin/refunds/{id}`、`POST /api/admin/refunds/{id}/resubmit`。
**返回:**退款数据、`approval` 和 `processing_status`。代理钱包通过后幂等回溯。 | +| #34 充值审核流程 | **标题:**代理扫码充值与员工线下充值审批。
**页面:**在线充值展示支付方式、二维码和支付状态;线下充值展示只读企微审批状态。 | **标题:**代理在线充值和员工线下审批。
**接口:**`GET /api/admin/agent-recharges/payment-methods`、`POST /api/admin/agent-recharges`、`GET /api/admin/agent-recharges/{id}/payment-status`、`GET /api/admin/agent-recharges/{id}`。
**返回:**二维码、过期时间、支付/审批/入账状态。 | +| #33 套餐临期提醒 | **标题:**临期列表、各端高亮和续费入口。
**页面:**临期页3天内置顶;普通资产列表只高亮;代理首页显示数量;C端显示续费按钮。 | **标题:**预计最终到期临期Query和站内通知。
**接口:**`GET /api/admin/expiring-assets`、资产列表/详情增加临期字段、`GET /api/c/v1/asset/info`、通知接口。
**返回:**最终到期、剩余天数、颜色节点;15/7/3天通知防重。 | + +## 五、前端Mock公共样例 + +资产详情扩展字段: + +```json +{ + "estimated_final_expires_at": "2026-08-01T23:59:59+08:00", + "days_until_final_expiry": 15, + "expiry_estimate_status": "exact", + "is_expiring": true, + "allowed_payment_methods": ["alipay", "wallet"], + "polling": { + "enabled": true, + "activity_level": "active", + "last_activity_at": "2026-07-17T10:00:00+08:00", + "next_poll_at": "2026-07-17T10:03:00+08:00" + }, + "exchange_trace": { + "previous_asset": null, + "next_asset": null + } +} +``` + +统一异步任务: + +```json +{ + "task_id": 1001, + "status": 2, + "status_name": "处理中", + "total_count": 100, + "success_count": 60, + "failed_count": 3, + "failed_items": [] +} +``` + +代理资金概况: + +```json +{ + "balance": 8000, + "frozen_balance": 0, + "cash_available": 8000, + "credit_enabled": true, + "credit_limit": 100000, + "available_balance": 108000, + "low_balance_warning": true, + "version": 3 +} +``` + +企微审批摘要: + +```json +{ + "source": "wecom", + "instance_id": 123, + "sp_no": "202607170001", + "status": 1, + "status_name": "审批中", + "current_approver_summary": "财务审批", + "approvers": [], + "attachments": [], + "business_process_result": "pending" +} +``` + +## 六、联调任务拆分 + +联调项建议创建为项目执行任务,而不是研发需求。每个任务可关联多条 FE/BE 研发需求和用户需求。 + +如果团队流程强制要求联调也必须使用“研发需求”类型,则先新增一条技术用户需求“7月迭代跨模块联调与发布验收”,再将下列 INT-01~08 建成它的子研发需求。不要把同一条联调需求重复挂到每个业务用户需求下。 + +| 编号 | 联调任务名称 | 关联用户需求 | 参与工时 | 进入条件 | 主要验收链路 | +|---|---|---|---|---|---| +| INT-01 | 资产实名、复机与状态同步联调 | #94、#73、#62、#53 | FE 1~1.5h / BE 1~1.5h,已含 | 实名策略接口、同步任务和H5页面完成 | 不同运营商实名能力、先实名/先购买、0/3/5同步、回调后状态和筛选一致 | +| INT-02 | 换货完整链路联调 | #98、#86、#57、#45 | FE 0.5~1h / BE 0.5~1h,已含 | 换货接口、详情和列表页面完成 | 退款拦截、新资产继承店铺、完成换货、列表搜索、前代/后代跳转 | +| INT-03 | 套餐授权、购买、续费、到期与临期联调 | #55、#46、#43、#40、#33 | FE 1~1.5h / BE 1~1.5h,已含 | 套餐快照、授权候选和各端到期字段完成 | 批量授权、购买生效条件、下架续费、排队套餐最终到期、临期高亮和通知 | +| INT-04 | Excel批量任务与导出联调 | #49、#36、#42 | FE 1~1.5h / BE 1~1.5h,已含 | 上传、任务详情、Worker和导出场景完成 | 模板校验、部分成功、失败明细、任务恢复、字段权限和文件下载 | +| INT-05 | 支付、代理钱包、信用和余额预警联调 | #48、#38、#34、#36、#96、#97 | FE 1~1.5h / BE 1~1.5h,已含 | 支付配置、钱包领域和业务员接口完成 | 支付方式限制、扫码充值、信用扣款、角色默认额度、店铺调额、100元预警 | +| INT-06 | 企业微信审批、退款和线下充值联调 | #37、#35、#34、#44 | FE 1.5~2h / BE 1.5~2h,已含 | 企微模板、绑定、回调、轮询和业务详情完成 | 扫码绑定、发起审批、意见附件、通过/驳回/撤销、退款/充值终态和列表摘要 | +| INT-07 | Gateway卡限速联调 | #47 | FE 0.5~1h / BE 0.5~1h,已含 | Gateway联调配置和卡/设备入口完成 | 单卡限速、设备解析当前卡、取消限速、无当前卡、失败审计 | +| INT-08 | 七月迭代全链路与停机发布验收 | 全部激活需求 | FE 3~4h / BE 4~5h,额外 | INT-01~07完成 | 权限、通知、审计、历史数据、旧入口关闭、Worker恢复和发布检查 | + +## 七、联调任务责任方式 + +- 涉及企微、支付、Gateway、运营商回调和异步 Worker 的联调任务由后端主责,前端参与。 +- 主要是页面与接口契约的批量、导出、套餐和换货联调可由前端主责,后端参与。 +- 禅道只有一个指派人时,指派给主责人;另一人写入抄送和参与人,不拆成两条重复联调任务。 +- 联调发现的代码问题回到对应 FE/BE 研发需求处理;联调任务只记录场景、阻塞和最终结论。 + +## 八、研发需求描述模板 + +前端研发需求至少填写:页面入口、交互状态、调用接口、权限、异常状态、完成标准。 + +后端研发需求至少填写:业务规则、数据变更、API、权限、幂等/并发、事件与审计、完成标准。 + +联调任务至少填写:关联研发需求、环境和账号、测试数据、完整操作步骤、预期结果、阻塞项和最终结论。 + +## 九、额外禅道项 + +当前用户需求 CSV 没有覆盖公共开发基础、公共站内通知和全局审计。建议补建三条技术用户需求: + +```text +用户需求:七月迭代公共开发基础 +用户需求:公共站内通知 +用户需求:全局多视角审计与外部集成追踪 +``` + +再分别拆分: + +```text +[FE] 七月迭代公共状态与异步任务交互 +[BE] 七月迭代公共迁移幂等与异步任务基础 +[FE] 顶部通知铃铛与站内通知中心 +[BE] 站内通知基础设施与受控跳转 +[FE] 全局多视角审计中心 +[BE] Audit Event与Integration Log统一审计 +``` + +具体描述和工时直接使用[逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)。 diff --git a/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md b/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md new file mode 100644 index 0000000..1940fae --- /dev/null +++ b/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md @@ -0,0 +1,1889 @@ +# 7月迭代禅道研发需求逐条录入稿 + +> 用法:每条研发需求分别复制“标题”和“描述”到禅道。前端描述已包含页面结构、交互状态和接口契约,可先按 Mock 开发;后端描述包含业务规则和交付边界。 +> +> 公共约定:接口统一返回 `{code,msg,data,timestamp}`,下文只写 `data`;金额单位为分;时间为 ISO 8601;分页结构为 `{items,total,page,size}`;状态判断使用 `status`,中文展示使用 `status_name`。返回字段除明确标注 `null` 或“特定状态返回”外均为必返字段,前端 Mock 不得自行改名或改变类型。 + +## 工时口径 + +- 单位为人时,1人日按8小时计算。 +- FE/BE工时包含实现、自验、关联联调问题修复,不包含等待企微、支付、Gateway等外部配置的时间。 +- INT-01~INT-07用于组织联调,不重复增加总工时;如禅道必须填写联调工时,应从关联FE/BE研发需求中拆出同等工时。 +- INT-08为额外的全链路验收和停机发布准备,前端3~4小时、后端4~5小时。 +- 按逐条工时汇总并去除联调重复计算后:前端58.5~89小时,后端92.5~127.5小时;对外取整仍为前端59~89小时、后端93~128小时。 +- 总体仍按1前端+1后端并行12~15个工作日、风险上限16个工作日执行。 + +## UR#98 换货管理新资产归属 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#98] 换货新资产归属继承提示 +``` + +**描述** + +```markdown +目标:换货操作时明确新资产最终归属,避免运营误认为可以手工选择目标店铺。 + +预计工时:前端0.5~1小时。 + +页面入口:现有换货创建页、发货确认页、换货详情页。 + +页面结构: +1. 选择旧资产后展示旧资产所属店铺。 +2. 选择新资产后增加只读提示“换货完成后将归属:{店铺名称}”。 +3. 不增加目标店铺选择控件。 +4. 详情页展示继承后的店铺名称。 + +接口约定: +- 复用 POST /api/admin/exchanges、POST /api/admin/exchanges/{id}/ship、POST /api/admin/exchanges/{id}/complete。 +- 创建入参沿用旧资产、新资产、换货类型和资料迁移字段。 +- 返回 data 增加 inherited_shop_id:int64、inherited_shop_name:string。 + +交互规则:新资产属于其他店铺时展示后端错误;提交期间禁用按钮;成功后重新拉取详情。 + +完成标准:创建、发货、完成三个页面均能正确展示目标店铺,并覆盖加载中、失败和重复提交状态。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#98] 换货新资产继承旧资产店铺 +``` + +**描述** + +```markdown +目标:换货完成时由系统自动维护新资产归属,不允许前端指定目标店铺。 + +预计工时:后端1~1.5小时。 + +接口:复用 POST /api/admin/exchanges、POST /api/admin/exchanges/{id}/ship、POST /api/admin/exchanges/{id}/complete。 + +规则: +1. 新资产允许处于平台库存,或已经属于旧资产店铺;属于其他店铺时拒绝。 +2. 完成换货事务内迁移新资产 shop_id、租户标签和资产分配记录,再迁移客户、钱包、套餐和状态。 +3. 旧资产保留原 shop_id,仅更新为已换货状态。 +4. 返回 inherited_shop_id、inherited_shop_name,写入换货审计。 + +架构:换货完成逻辑迁入 exchange Domain,旧 Service 不保留第二套完成逻辑。 + +完成标准:跨资产数据在同一事务内一致,重复完成保持幂等,其他店铺资产不能被越权换入。 +``` + +## UR#97 代理钱包阈值提醒 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#97] 代理现金余额不足100元展示 +``` + +**描述** + +```markdown +目标:现金可用余额不足100元时给运营明确提示,不提供阈值配置。 + +预计工时:前端0.5~1小时,不含公共站内通知页面工时。 + +页面入口:代理资金概况、顶部通知抽屉、站内通知中心。 + +页面结构: +1. 资金概况显示现金余额、冻结金额和“现金余额不足100元”红色状态。 +2. 不展示阈值输入框,不把信用额度计入预警文案。 +3. 通知点击后跳转对应店铺资金页。 + +接口约定: +- GET /api/admin/shops/fund-summary 返回 balance:int64、frozen_balance:int64、cash_available:int64、low_balance_warning:bool。 +- 通知复用 GET /api/admin/notifications,目标解析复用 GET /api/admin/notifications/{id}/target。 + +交互规则:只使用 low_balance_warning 控制提示,不由前端自行计算阈值;金额按分转元展示。 + +完成标准:余额高于、等于、低于100元三种状态展示正确,通知能跳转且无权限时按统一错误页处理。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#97] 代理主钱包固定100元余额预警 +``` + +**描述** + +```markdown +目标:主钱包现金可用余额跨越固定阈值时可靠发送一次站内通知。 + +预计工时:后端1~1.5小时,不含公共站内通知基础设施工时。 + +规则: +1. cash_available=balance-frozen_balance,不包含信用额度。 +2. 变动前>10000分且变动后<=10000分时发送通知。 +3. 余额回升到10000分以上后重新布防;持续低余额不重复发送。 +4. 接收人为店铺主账号和可用的店铺业务员。 + +接口:GET /api/admin/shops/fund-summary 增加 cash_available、low_balance_warning;通知复用现有通知接口。 + +实现:钱包聚合产生低余额领域事件,事务内写 Outbox,通知使用稳定业务键防重并记录审计。 + +完成标准:扣款、冻结、解冻、充值和退款回充都能正确触发或重新布防,通知失败不回滚资金事务。 +``` + +## UR#96 员工作为发展人进行标识 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#96] 店铺业务员选择展示与筛选 +``` + +**描述** + +```markdown +目标:为店铺绑定平台业务员,只表示业务归属和通知关系。 + +预计工时:前端0.5~1小时。 + +页面入口:店铺新建、店铺编辑、店铺列表、店铺详情。 + +页面结构: +1. 新建和编辑表单增加“平台业务员”可搜索下拉,可为空。 +2. 候选项展示账号名和手机号摘要,只显示启用的平台账号。 +3. 店铺列表增加业务员列和业务员筛选项。 +4. 店铺详情展示业务员名称和手机号摘要。 + +接口约定: +- POST /api/admin/shops、PUT /api/admin/shops/{id} 增加 business_owner_account_id:int64|null。 +- GET /api/admin/shops 增加 business_owner_account_id 查询参数。 +- 业务员候选复用 GET /api/admin/accounts?account_type=platform&status=1,items至少返回 account_id、account_name、phone_masked。 +- 列表和详情返回 business_owner_account_id:int64|null、business_owner_name:string、business_owner_phone_masked:string。 + +交互规则:不出现分销、佣金或发展层级文案;已停用业务员仍可在历史详情显示,但编辑候选中不可选。 + +完成标准:创建、编辑、筛选、详情展示一致,并覆盖清空业务员和无可选账号状态。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#96] 店铺业务员关联与查询 +``` + +**描述** + +```markdown +目标:保存店铺的平台业务员归属,并用于查询和站内通知接收人计算。 + +预计工时:后端1~1.5小时。 + +数据:tb_shop 增加 business_owner_account_id,可空,不建立数据库外键。 + +接口:POST /api/admin/shops、PUT /api/admin/shops/{id} 支持 business_owner_account_id;GET /api/admin/shops 支持同名筛选;列表和详情返回账号名称及手机号摘要。 + +规则: +1. 只允许绑定启用的平台账号。 +2. 字段不参与店铺层级、数据权限、佣金或分销关系计算。 +3. 账号停用或删除后保留历史关联,通知时跳过不可用账号。 +4. 变更前后值写入审计。 + +完成标准:创建、修改、清空和筛选均生效,不能绑定代理账号或停用平台账号。 +``` + +## UR#94 系统状态同步优化以及回调处理 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#94] 资产同步状态与同步轨迹入口 +``` + +**描述** + +```markdown +目标:让运营能看懂资产当前轮询策略和最近同步情况,不新增第二个同步按钮。 + +预计工时:前端2~3小时。 + +页面入口:卡详情、设备详情、全局审计外部集成页。 + +页面结构: +1. 详情页同步区域展示轮询是否启用、活跃级别、最后活跃时间、最后活跃场景和下次轮询时间。 +2. 保留现有手动刷新按钮。 +3. 增加“查看同步轨迹”,跳转全局审计并自动带入资产筛选。 +4. 同步轨迹按立即、3分钟、5分钟连续展示结果。 + +接口约定: +- GET /api/admin/assets/resolve/{identifier} 返回 polling:{enabled:bool,activity_level:string,last_activity_at:string|null,last_activity_scene:string,next_poll_at:string|null}。 +- 现有手动刷新接口保持原契约。 +- GET /api/admin/audit/integrations 支持 resource_type、resource_key、correlation_id 查询。 + +交互规则:rate_limited 只展示本次失败,不显示自动退避倒计时;无下次轮询时显示“-”。 + +完成标准:卡和设备均能展示同步状态并跳转到过滤后的轨迹页,加载、空记录和失败状态完整。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#94] 资产状态同步DDD收口与轮询优化 +``` + +**描述** + +```markdown +目标:将轮询、业务事件、手动刷新和运营商回调统一进入卡状态应用用例。 + +预计工时:后端7~9小时。 + +规则: +1. 只保留全局 enable_polling 开关,按活跃和不活跃卡使用不同轮询间隔。 +2. 关键业务成功边界创建立即、3分钟、5分钟三个无自动重试任务;达到预期状态后后续任务提前完成。 +3. Gateway 超频只记录 rate_limited,不做 blocked_until 或指数退避。 +4. ApplyCardObservation 是实名、流量和网络状态的唯一写入口。 +5. 行业卡是否实名由运营商 realname_link_type 决定。 + +接口:不新增显式同步接口;资产详情 Query 返回 polling;同步轨迹进入统一 Integration Log 和审计 Query。 + +架构:迁移到 card Domain、同步 Application、Gateway Adapter 和 Query,旧轮询及刷新逻辑改为调用统一用例。 + +完成标准:三条自动通道和手动刷新结果一致,事件序列可追踪,旧逻辑不再直接修改卡状态。 +``` + +## UR#86 资产详情中换货标识 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#86] 资产详情前代后代换货标识 +``` + +**描述** + +```markdown +目标:在资产详情中明确当前资产在换货链中的位置。 + +预计工时:前端0.5~1小时。 + +页面入口:卡详情、设备详情。 + +页面结构: +1. previous_asset 存在时显示“换货新资产”标签和前代资产信息。 +2. next_asset 存在时显示“已换出旧资产”标签和后代资产信息。 +3. 中间资产可同时显示前代和后代。 +4. can_view=true 时支持点击跳转;false 时只显示标识文本。 + +接口约定:GET /api/admin/assets/resolve/{identifier} 返回 exchange_trace.previous_asset 和 exchange_trace.next_asset;单项结构为 asset_type:string、asset_id:int64|null、identifier:string、exchange_no:string、can_view:bool。 + +交互规则:不根据换货状态自行拼链路;关联资产为空时不展示对应区域。 + +完成标准:旧资产、新资产、链路中间资产和无权限四类状态均显示正确。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#86] 资产换货链Query +``` + +**描述** + +```markdown +目标:基于已完成换货单返回资产前代和后代关系。 + +预计工时:后端1~1.5小时。 + +接口:GET /api/admin/assets/resolve/{identifier} 增加 exchange_trace.previous_asset、exchange_trace.next_asset。 + +返回结构:asset_type、asset_id、identifier、exchange_no、can_view;无权限时 asset_id 返回 null,仍可返回脱敏后的标识和 can_view=false。 + +规则:复用 tb_exchange_order,不新建换货关系表;只使用已完成换货单;补充旧资产和新资产查询索引;查询遵守现有数据范围。 + +完成标准:卡和设备均可查询前后关系,无 N+1,越权用户不能获得可跳转资源 ID。 +``` + +## UR#73 行业卡操作停复机 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#73] 复机实名校验提示适配 +``` + +**描述** + +```markdown +目标:沿用现有复机入口,根据后端实名规则展示结果,不在前端按行业卡类型放行。 + +预计工时:前端0.5~1小时。 + +页面入口:卡详情、设备详情的复机操作。 + +页面结构:保持现有复机确认框;失败时原地展示后端中文业务原因。 + +接口约定:复用 POST /api/admin/assets/{identifier}/start;成功返回最新资产 status、status_name、real_name_status、real_name_status_name。 + +交互规则:前端不读取 card_category 判断实名要求;提交中禁用按钮;成功后重新拉取详情。 + +完成标准:同为行业卡但运营商实名能力不同的情况下,页面能正确展示放行或拦截结果。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#73] 按运营商实名能力控制复机 +``` + +**描述** + +```markdown +目标:删除“行业卡统一无需实名”的错误规则。 + +预计工时:后端1小时。 + +接口:复用 POST /api/admin/assets/{identifier}/start。 + +规则: +1. realname_link_type=none 时允许未实名复机。 +2. realname_link_type=template 或 gateway 时仍要求实名。 +3. card_category 只用于分类展示,不参与复机判断。 +4. 复机成功后触发实名、流量和网络状态的0/3/5同步序列。 + +架构:复机资格进入卡状态 Domain 规则,Handler/Service 不保留行业卡分支。 + +完成标准:不同运营商特性的行业卡行为正确,错误返回统一业务错误码并写审计。 +``` + +## UR#62 H5设置先充值后实名 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#62] H5实名购买顺序与后台批量配置 +``` + +**描述** + +```markdown +目标:支持无需实名、先实名后购买、先购买后实名三种流程,并提供后台批量配置。 + +预计工时:前端2~3小时。 + +页面入口:C端资产初始化流程;后台卡列表和设备列表。 + +页面结构: +1. C端按 effective_realname_policy 决定直接购买、先实名或购买后提示实名。 +2. 后台卡和设备列表分别增加“批量修改实名顺序”入口。 +3. 弹框提供无需实名、先实名后购买、先购买后实名三选一,并展示已选数量。 +4. 修改设备下卡策略时提示“实际H5流程由设备策略决定”。 + +接口约定: +- PATCH /api/admin/assets/{identifier}/realname-mode,入参 realname_policy:none|before_order|after_order。 +- POST /api/admin/iot-cards/batch-update-realname-policy、POST /api/admin/devices/batch-update-realname-policy,入参 asset_ids:int64[]、realname_policy:string。 +- C端初始化返回 effective_realname_policy:string、realname_required:bool、realname_status:int。 + +交互规则:单次最多500条;批量接口全成全败;前端不根据资产类型自行覆盖策略。 + +完成标准:三种C端流程和卡/设备批量配置均可运行,冲突数据能展示明确错误。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#62] 资产实名顺序策略与批量配置 +``` + +**描述** + +```markdown +目标:统一独立卡、设备和设备下卡的有效实名顺序计算。 + +预计工时:后端3~4小时。 + +接口:PATCH /api/admin/assets/{identifier}/realname-mode;POST /api/admin/iot-cards/batch-update-realname-policy;POST /api/admin/devices/batch-update-realname-policy。 + +规则: +1. 独立卡读取卡策略;设备及设备下卡读取设备策略。 +2. realname_link_type=none 时有效策略固定为 none。 +3. 需要实名的运营商配置为 none 属于冲突数据,运行时拒绝静默放行。 +4. 批量最多500条,事务内全成全败。 +5. 新资产默认 after_order。 + +完成标准:单条和批量配置使用同一校验规则,C端只需读取有效策略,配置变更写审计。 +``` + +## UR#60 店铺联系电话检索 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#60] 店铺联系电话搜索控件 +``` + +**描述** + +```markdown +目标:在店铺列表按11位联系电话精确检索。 + +预计工时:前端0.5~1小时。 + +页面入口:店铺列表筛选区。 + +页面结构:增加“联系电话”输入框、查询按钮和清空能力;输入框限制11位数字。 + +接口约定:GET /api/admin/shops?contact_phone=13800138000,返回原店铺分页结构。 + +交互规则:空值不传参数;非法号码不发请求并提示;清空后恢复原列表条件。 + +完成标准:有效号码精确命中,空结果、加载和参数错误状态完整。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#60] 店铺联系电话精确查询 +``` + +**描述** + +```markdown +目标:扩展店铺列表Query,支持联系电话精确查询。 + +预计工时:后端0.5~1小时。 + +接口:GET /api/admin/shops 增加 contact_phone 查询参数。 + +规则:只接受合法11位手机号;空参数不影响原查询;查询继续应用现有数据权限和分页。 + +完成标准:命中、未命中、非法参数和越权数据范围均符合统一接口规范,查询可使用现有联系电话索引或补充必要索引。 +``` + +## UR#57 退款中禁止换货 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#57] 换货退款拦截错误展示 +``` + +**描述** + +```markdown +目标:创建换货时直接展示后端的退款拦截结果。 + +预计工时:前端0.5~1小时。 + +页面入口:换货创建页。 + +页面结构:不新增退款状态查询和预检查区域;提交失败时在表单顶部或资产行展示“该资产存在退款申请”。 + +接口约定:复用 POST /api/admin/exchanges;错误使用统一 code、msg,不改变成功返回结构。 + +交互规则:前端不自行判断审批或退款处理状态;失败后保留已填写表单,允许更换资产后重新提交。 + +完成标准:存在活跃退款时不进入下一步,已拒绝、撤销或处理完成的资产可正常换货。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#57] 换货前活跃退款校验 +``` + +**描述** + +```markdown +目标:换货创建前统一拦截仍可能影响资产和资金状态的退款申请。 + +预计工时:后端1~1.5小时。 + +接口:复用 POST /api/admin/exchanges。 + +规则:企微审批中、历史已退回、企微已通过但退款业务处理未完成时禁止换货;已拒绝、已撤销、已删除或退款处理完成后放行。 + +实现:按提交资产批量查询活跃退款,禁止逐资产N+1;返回统一错误“该资产存在退款申请”;校验与换货创建保持一致性。 + +完成标准:所有退款状态边界明确,批量换货场景能指出失败资产且不产生半成品换货单。 +``` + +## UR#55 套餐生效条件 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#55] 套餐分配生效条件选择 +``` + +**描述** + +```markdown +目标:允许代理套餐分配覆盖套餐默认生效条件,并明确只影响未来购买。 + +预计工时:前端1.5~2.5小时。 + +页面入口:套餐授权/分配弹框、已分配套餐详情或编辑弹框。 + +页面结构: +1. 增加“生效条件”单选:跟随套餐默认、购买即生效、实名即生效。 +2. 同时展示 default_expiry_base、override_expiry_base 和 effective_expiry_base 的中文名称。 +3. 修改时提示“仅影响后续新订单,不影响已购买套餐”。 + +接口约定: +- POST /api/admin/shop-package-allocations,入参增加 expiry_base_override:string|null。 +- PATCH /api/admin/shop-package-allocations/{id}/expiry-base,body 为 {expiry_base_override:null|"from_purchase"|"from_realname"}。 +- 返回 default_expiry_base、expiry_base_override、effective_expiry_base 及对应 name 字段。 + +交互规则:选择“跟随默认”必须显式发送 null;不能省略字段代替恢复默认。 + +完成标准:新建、修改、恢复默认均可用,页面能区分默认值、覆盖值和最终值。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#55] 套餐分配生效条件覆盖与购买快照 +``` + +**描述** + +```markdown +目标:建立“套餐默认→代理分配覆盖→购买使用记录快照”的完整规则。 + +预计工时:后端2.5~3.5小时。 + +接口:POST /api/admin/shop-package-allocations;PATCH /api/admin/shop-package-allocations/{id}/expiry-base。 + +数据:分配表增加 nullable expiry_base_override;PackageUsage 增加 expiry_base、周期类型和时长快照字段。 + +规则:null表示跟随默认;购买时计算有效值并写入快照;后续修改套餐或分配配置不影响已购买记录;旧数据仅做兼容回退。 + +架构:购买快照和激活规则进入套餐生命周期 Domain;查询侧返回默认、覆盖和最终值。 + +完成标准:新旧订单行为可区分,排队套餐激活和最终到期推算只读取购买快照。 +``` + +## UR#53 资产实名状态筛选 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#53] 卡和设备实名状态筛选 +``` + +**描述** + +```markdown +目标:在卡和设备列表按实名状态筛选并展示状态。 + +预计工时:前端1~2小时。 + +页面入口:卡列表、设备列表。 + +页面结构:筛选区增加全部、已实名、未实名;表格增加“实名状态”列,展示 real_name_status_name。 + +接口约定: +- GET /api/admin/iot-cards?real_name_status=0|1。 +- GET /api/admin/devices?real_name_status=0|1。 +- items 返回 real_name_status:int、real_name_status_name:string。 + +交互规则:设备状态直接使用接口结果,不在前端遍历绑定卡计算。 + +完成标准:卡和设备的全部、已实名、未实名筛选正确,分页切换保留筛选条件。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#53] 卡设备实名状态查询与设备快照 +``` + +**描述** + +```markdown +目标:提供统一实名状态筛选,设备状态由有效绑定卡快照维护。 + +预计工时:后端2~3小时。 + +接口:GET /api/admin/iot-cards、GET /api/admin/devices 增加 real_name_status=0|1。 + +规则:卡读取自身实名状态;设备存在任一有效绑定实名卡即为已实名;实名变化、绑定、解绑和换卡时刷新旧设备及新设备快照。 + +返回:real_name_status、real_name_status_name;查询继续应用现有分页和数据权限。 + +完成标准:设备快照不因换卡残留,列表查询不做逐设备子查询,历史数据完成一次性初始化。 +``` + +## UR#49 设备批量分配代理和套餐系列 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#49] 设备批量分配代理与套餐系列双入口 +``` + +**描述** + +```markdown +目标:把“批量分配代理”和“批量分配套餐系列”作为两个独立功能,复用同一任务进度交互。 + +预计工时:前端2~3小时。 + +页面入口:设备列表工具栏。 + +页面结构: +1. 两个独立按钮:批量分配代理、批量分配套餐系列。 +2. 两个弹框都包含前端静态模板下载、目标选择、Excel上传和提交按钮。 +3. 创建成功后进入任务进度区,展示状态、总数、成功数、失败数和失败明细。 +4. 页面刷新后根据 task_id 恢复进度。 + +接口约定: +- POST /api/admin/devices/batch-assign-shop,multipart包含 file、shop_id、request_id。 +- POST /api/admin/devices/batch-assign-series,multipart包含 file、series_id、request_id。 +- GET /api/admin/devices/batch-allocation/{task_id} 返回 task_id、operation_type、status、status_name、total_count、success_count、failed_count、failed_items。 + +交互规则:一个任务只能选择一种操作;任务处理中按2、3、5秒后最大10秒轮询;页面不可见时暂停。 + +完成标准:两个入口互不混淆,部分成功和失败原因可查看,模板由前端静态文件提供。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#49] 设备两类批量分配任务 +``` + +**描述** + +```markdown +目标:使用统一批量基础设施分别执行设备代理分配和套餐系列分配。 + +预计工时:后端3~4小时。 + +接口:POST /api/admin/devices/batch-assign-shop;POST /api/admin/devices/batch-assign-series;GET /api/admin/devices/batch-allocation/{task_id}。 + +规则: +1. operation_type 只能是 assign_shop 或 assign_series,一个任务只修改一个目标字段。 +2. 文件最大10MB、最多1000行、Worker每批200条。 +3. 设备号去重后批量查询;失败明细最多保存1000条。 +4. 已属于目标值按幂等成功;assign_shop 遇到其他代理资产失败;assign_series 不修改 shop_id。 +5. Asynq载荷只传结构化ID和对象存储Key,不传本地路径或文件字节。 + +完成标准:任务支持部分成功、进度恢复和request_id防重,两种命令的数据边界清晰。 +``` + +## UR#48 不同资产使用不同支付方式 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#48] C端按资产展示允许支付方式 +``` + +**描述** + +```markdown +目标:C端支付页只展示当前资产允许使用的支付方式。 + +预计工时:前端1.5~2.5小时。 + +页面入口:卡和设备的套餐购买、充值支付页;后台系统配置页。 + +页面结构: +1. 支付方式使用单选列表,只渲染 allowed_payment_methods 中的选项。 +2. 没有可用方式时展示原因并禁用提交。 +3. 后台配置使用卡/设备两个分组的复选框,不允许直接编辑JSON。 + +接口约定: +- GET /api/c/v1/asset/info 返回 allowed_payment_methods:string[],值为 alipay、wechat、wallet。 +- POST /api/c/v1/orders/create 和 POST /api/c/v1/orders/{id}/pay 入参 payment_method:string。 +- GET /api/admin/system/config?module=payment 查询配置;PUT /api/admin/system/config/{config_key} 保存卡和设备允许的支付方式。 + +交互规则:钱包是否可选完全使用后端返回;创建或支付时后端拒绝的方式直接展示错误,不由前端兜底放行。 + +完成标准:卡、设备、配置异常和无可用方式四类场景展示正确。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#48] 资产支付方式配置与订单校验 +``` + +**描述** + +```markdown +目标:按资产类型返回允许支付方式,并在订单创建及支付阶段强校验。 + +预计工时:后端2~3小时。 + +接口:GET /api/c/v1/asset/info 增加 allowed_payment_methods;订单创建和支付接口校验 payment_method;后台复用系统配置接口。 + +规则:卡默认支付宝和钱包;设备默认微信和钱包;钱包始终允许;配置异常时使用安全默认值并记录错误,不能放开全部方式。 + +完成标准:前端隐藏不能绕过后端校验,订单创建和支付使用同一策略,配置变更写审计。 +``` + +## UR#47 限速规则 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#47] 卡设备手动设置与取消限速 +``` + +**描述** + +```markdown +目标:在卡和设备详情提供统一的手动限速入口,不展示自动限速规则。 + +预计工时:前端1~2小时。 + +页面入口:卡详情、设备详情。 + +页面结构: +1. “设置限速”弹框包含 speed_kbps 正整数输入框和确认按钮。 +2. “取消限速”使用独立确认操作,提交 speed_kbps=0。 +3. 设备详情必须显示本次实际作用的当前卡ICCID;无当前卡时禁用操作并展示原因。 +4. 不展示“Gateway当前实际限速”,除非接口未来明确返回查询结果。 + +接口约定:POST /api/admin/assets/{identifier}/speed-limit,body={speed_kbps:int};返回 asset_type、asset_identifier、card_no、speed_kbps、result、message。 + +交互规则:单位固定展示kbps;提交中禁用按钮;失败保留输入值;成功后展示后端结果。 + +完成标准:卡限速、设备解析当前卡、取消限速和无当前卡四类场景完整。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#47] Gateway按cardNo统一限速接口 +``` + +**描述** + +```markdown +目标:只开放一个手动限速接口,最终始终按卡ICCID调用Gateway。 + +预计工时:后端2~3小时。 + +接口:POST /api/admin/assets/{identifier}/speed-limit,入参 speed_kbps;正数为设置,0为取消。 + +规则:资产为卡时读取ICCID;资产为设备时解析 is_current=true 的有效当前卡;不存在当前卡则拒绝;绝不把设备号传给Gateway。取消仍调用同一Gateway端口,由适配器转换上游取消参数。 + +非目标:不建立套餐固定限速,不因激活、到期、停机或切卡自动限速,不增加自动补偿Worker。 + +完成标准:返回最终card_no和调用结果,每次请求记录操作审计及Integration Log。 +``` + +## UR#46 资产信息详情字段新增 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#46] 预计最终到期时间展示 +``` + +**描述** + +```markdown +目标:资产层只展示当前及全部排队主套餐接续后的一个预计最终到期时间。 + +预计工时:前端1.5~2.5小时。 + +页面入口:卡详情、设备详情和相关资产列表。 + +页面结构: +1. 字段名称统一为“预计套餐到期时间”。 +2. exact 时展示 estimated_final_expires_at。 +3. waiting_activation 等不可预计状态展示“待激活后起算”,不伪造日期。 +4. is_expiring=true 时按剩余天数使用临期颜色,普通资产列表不改变排序。 + +接口约定:GET /api/admin/assets/resolve/{identifier} 及资产列表返回 estimated_final_expires_at:string|null、days_until_final_expiry:int|null、expiry_estimate_status:string、is_expiring:bool。 + +交互规则:当前套餐自身到期时间仅保留在套餐明细;前端不叠加套餐时长自行计算。 + +完成标准:无套餐、仅当前套餐、存在多个排队套餐和等待未知激活时间四类状态正确。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#46] 当前及排队套餐最终到期Query +``` + +**描述** + +```markdown +目标:统一计算资产当前生效及全部排队主套餐连续接续后的预计最终到期时间。 + +预计工时:后端3~4小时。 + +接口:资产详情和列表Query返回 estimated_final_expires_at、days_until_final_expiry、expiry_estimate_status、is_expiring。 + +规则:按套餐队列顺序和购买时长快照推演;无套餐返回null;等待无法确定时间的实名激活时返回不可预计状态;临期、导出和C端复用同一Query。 + +完成标准:不维护第二套资产汇总到期字段,查询避免N+1,边界按Asia/Shanghai自然日计算。 +``` + +## UR#45 换货管理 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#45] 换货新旧资产展示与独立搜索 +``` + +**描述** + +```markdown +目标:换货列表能分别检索和识别旧资产、新资产。 + +预计工时:前端1~2小时。 + +页面入口:换货管理列表。 + +页面结构: +1. 筛选区拆为“旧资产”和“新资产”两个输入框。 +2. 表格分别展示旧资产类型、旧资产标识、新资产类型、新资产标识。 +3. 卡统一展示ICCID,设备展示设备号。 + +接口约定:GET /api/admin/exchanges?old_asset_keyword=&new_asset_keyword=;items返回 old_asset_type、old_asset_id、old_asset_identifier、new_asset_type、new_asset_id、new_asset_identifier、status、status_name。 + +交互规则:两个条件可单独或组合查询;空条件不传;不在前端转换接入号或虚拟号。 + +完成标准:旧资产和新资产不会混列,ICCID、接入号、虚拟号均可通过后端命中对应卡。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#45] 换货标识快照修正与新旧资产查询 +``` + +**描述** + +```markdown +目标:规范换货单的新旧资产标识,并支持独立查询。 + +预计工时:后端1~2小时。 + +接口:GET /api/admin/exchanges 增加 old_asset_keyword、new_asset_keyword。 + +规则:卡的新旧资产快照统一保存ICCID,设备保存设备号;查询卡时支持ICCID、接入号和虚拟号映射;历史记录不强制回填;新建换货使用规范化标识。 + +完成标准:两个筛选条件可组合,查询遵守数据权限并具备必要索引,大结果集不出现逐行反查。 +``` + +## UR#44 列表字段新增 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#44] 退款充值换货列表提交人与审批摘要 +``` + +**描述** + +```markdown +目标:在业务列表直接看到谁提交、企微审批到哪一步、业务是否处理完成。 + +预计工时:前端1~2小时。 + +页面入口:退款列表、代理充值列表、换货列表。 + +页面结构:增加提交人、审批状态、当前审批人摘要、业务处理状态四列;历史本地审批显示“历史审批”,不提供操作按钮。 + +接口约定:GET /api/admin/refunds、GET /api/admin/agent-recharges、GET /api/admin/exchanges 的items增加 submitter_name、approval_source、approval_status、approval_status_name、current_approver_summary、processing_status、processing_status_name。 + +交互规则:approval_source=none时审批列显示“-”;legacy只读展示;wecom展示企微状态。长审批人摘要使用省略和悬浮完整文本。 + +完成标准:三类列表字段和空值规则一致,分页切换不会额外逐行请求审批详情。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#44] 提交人快照与企微审批摘要批量查询 +``` + +**描述** + +```markdown +目标:为退款、充值和换货列表提供统一提交人及审批摘要。 + +预计工时:后端2~3小时。 + +接口:扩展现有三类列表返回 submitter_name、approval_source、approval_status_name、current_approver_summary、processing_status_name。 + +规则:提交人保存业务快照;企微摘要按当前页实例ID批量查询,禁止N+1;历史本地审批返回approval_source=legacy;无审批返回none。 + +完成标准:列表查询次数稳定,历史数据可读,审批摘要与详情状态一致。 +``` + +## UR#43 代理系列授权 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#43] 系列套餐批量选择与已授权置灰 +``` + +**描述** + +```markdown +目标:首次授权和后续追加套餐都支持批量选择,并明确哪些套餐已经授权。 + +预计工时:前端1~2小时。 + +页面入口:代理系列首次授权页、已授权系列的套餐管理页。 + +页面结构: +1. 两个入口复用同一套餐候选表格。 +2. 列包含套餐名称、编码、公司成本价、当前授权成本价、建议售价、授权状态。 +3. is_authorized=true 的行显示“已授权”、复选框置灰且不可全选。 +4. 未授权套餐支持多选并一次提交。 + +接口约定: +- GET /api/admin/shop-series-grants/{id}/package-options 返回 items:{package_id,package_name,package_code,company_cost_price,authorized_cost_price,suggested_retail_price,is_authorized}。 +- PUT /api/admin/shop-series-grants/{id}/packages,body={package_ids:int64[]}。 + +交互规则:三类价格按分转元;提交成功后重新加载候选列表;空候选和全部已授权状态有明确提示。 + +完成标准:首次和后续授权行为一致,已授权套餐不能被再次选择。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#43] 系列套餐候选Query与重复授权幂等 +``` + +**描述** + +```markdown +目标:复用现有批量授权接口,新增可供前端一次选择的套餐候选Query。 + +预计工时:后端0.5~1小时。 + +接口:GET /api/admin/shop-series-grants/{id}/package-options;复用 PUT /api/admin/shop-series-grants/{id}/packages。 + +返回:package_id、名称、编码、company_cost_price、authorized_cost_price、suggested_retail_price、is_authorized。 + +规则:重复套餐按幂等处理;后端不依赖前端置灰;查询遵守代理、系列和套餐可见范围;三个价格字段语义分开。 + +架构:候选列表走Query,批量授权走轻量Application事务脚本。 + +完成标准:首次和后续授权共用契约,重复提交不生成重复关系。 +``` + +## UR#42 导出功能 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#42] 导出字段选择权限配置与任务进度 +``` + +**描述** + +```markdown +目标:用户按权限选择导出字段,管理员可给角色配置各场景可导出字段。 + +预计工时:前端3~4小时。 + +页面入口:卡、钱包流水、套餐、退款、换货、充值、临期列表的导出弹框;角色权限配置页。 + +页面结构: +1. 导出弹框先加载当前scene可选字段,使用复选框选择,未授权字段不展示。 +2. 支持xlsx/csv格式及当前列表查询条件。 +3. 创建后展示任务状态、进度、失败原因和下载按钮。 +4. 角色配置按scene分组展示字段复选框并保存。 + +接口约定: +- GET /api/admin/export-fields?scene= 返回 fields:{field_key,label,selected_by_default}[]。 +- POST /api/admin/export-tasks,body={scene,format,query,fields:string[]},返回 task_id。 +- GET /api/admin/export-tasks/{id} 返回 status、status_name、progress、download_url、error_message。 +- GET/PUT /api/admin/roles/{role_id}/export-fields 查询和保存场景字段。 + +交互规则:下载地址为空时禁用下载;任务轮询复用统一异步任务规则;字段为空时禁止提交。 + +完成标准:字段权限、任务恢复、失败重试和文件下载流程完整。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#42] 统一导出场景与角色字段权限 +``` + +**描述** + +```markdown +目标:复用现有导出任务体系,增加本期场景和角色字段级权限。 + +预计工时:后端4~6小时。 + +接口:GET /api/admin/export-fields;POST /api/admin/export-tasks;GET /api/admin/export-tasks/{id};GET/PUT /api/admin/roles/{role_id}/export-fields。 + +规则:最终字段=用户申请字段∩角色授权字段并集∩场景支持字段;超级管理员拥有全部字段;字段授权不能扩大数据行范围;创建任务时快照字段和表头;权限解析失败直接拒绝。 + +场景:iot_card、agent_wallet_transaction、package、refund、exchange、agent_recharge、expiring_asset。审批附件只导出数量,不导出对象Key或永久URL。 + +完成标准:Count与Fetch条件一致,审批摘要批量查询,导出任务可恢复且无越权字段。 +``` + +## UR#40 套餐设计 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#40] C端下架套餐续费入口 +``` + +**描述** + +```markdown +目标:下架套餐不出现在新购列表,但历史使用资产可在当前套餐旁直接续费。 + +预计工时:前端1.5~2.5小时。 + +页面入口:C端资产当前套餐区域、套餐购买流程。 + +页面结构: +1. 当前套餐旁根据 can_purchase 显示“续费”按钮。 +2. purchase_mode=renew_only 时只从当前套餐入口进入,不在套餐商城列表展示。 +3. 不可续费时按钮禁用或隐藏,并可展示 disabled_reason。 +4. 点击续费复用现有购买套餐流程,不拆出第二个套餐列表。 + +接口约定:GET /api/c/v1/asset/packages 返回 package_id、status、status_name、can_purchase、purchase_mode、disabled_reason;POST /api/c/v1/orders/create 沿用资产和套餐入参。 + +交互规则:前端不根据套餐上下架状态自行判断资格,以接口字段为准。 + +完成标准:正常在售购买、历史下架续费、禁用套餐和无历史资格四类场景正确。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#40] 下架套餐历史用户续费资格校验 +``` + +**描述** + +```markdown +目标:统一套餐可售策略,支持资产所有人续费历史使用过的下架套餐。 + +预计工时:后端2~3小时。 + +接口:GET /api/c/v1/asset/packages 返回 can_purchase、purchase_mode、disabled_reason;POST /api/c/v1/orders/create 强校验。 + +规则:禁用套餐始终不可购买;下架套餐只允许当前资产所有人基于有效历史使用记录续费;禁止代理代购;新购列表不返回下架套餐。 + +完成标准:批量订购和普通下单复用同一可售策略,不能通过直接请求绕过资格校验。 +``` + +## UR#38 不同渠道额度处理 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#38] 角色默认信用与店铺实际额度管理 +``` + +**描述** + +```markdown +目标:角色只配置未来新建店铺的默认信用,已有店铺在资金页单独修改实际额度。 + +预计工时:前端2.5~3.5小时。 + +页面入口:客户角色配置页、店铺资金概况页、店铺创建页。 + +页面结构: +1. 客户角色增加“新建代理默认信用”开关和额度输入,并提示“修改后不会影响已有店铺”。 +2. 店铺资金页展示现金余额、冻结金额、实际信用额度、可用金额、欠款金额和版本。 +3. 店铺实际额度使用独立调整弹框,展示修改前后金额预览。 +4. 平台员工角色不展示信用配置。 + +接口约定: +- PUT /api/admin/roles/{id}/default-credit,body={credit_enabled:bool,credit_limit:int64}。 +- PUT /api/admin/shops/{id}/credit-limit,body={credit_enabled:bool,credit_limit:int64,version:int64}。 +- GET /api/admin/shops/fund-summary 返回 balance、frozen_balance、credit_enabled、credit_limit、available_balance、is_in_debt、debt_amount、version。 + +交互规则:金额不在前端重新计算;并发冲突时刷新最新资金概况;关闭信用时额度输入归零。 + +完成标准:角色默认、创建店铺初始化、已有店铺调额和并发冲突提示完整。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#38] 代理主钱包信用额度与资金不变量 +``` + +**描述** + +```markdown +目标:信用额度只属于代理主钱包,角色配置只作为新店铺初始化模板。 + +预计工时:后端4~5小时。 + +接口:PUT /api/admin/roles/{id}/default-credit;PUT /api/admin/shops/{id}/credit-limit;GET /api/admin/shops/fund-summary;创建店铺时读取默认角色模板。 + +不变量:available=balance-frozen_balance+effective_credit且必须>=0;关闭信用时额度为0;存在欠款或冻结导致新可用金额<0时禁止降额或关闭。 + +规则:修改角色不更新已有店铺;店铺后续角色变化不影响钱包;余额、冻结、信用、版本和资金流水在同一事务维护;调额按version乐观锁更新。 + +架构:钱包资金规则迁入Wallet Domain,查询走资金Query。 + +完成标准:扣款、冻结、解冻、充值、退款回充和调额都维护同一不变量并写资金审计。 +``` + +## UR#37 审核流转 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#37] 企微配置账号绑定与审批只读详情 +``` + +**描述** + +```markdown +目标:使用企业微信完成审批,本系统只负责配置、账号扫码绑定、状态查看和异常恢复。 + +预计工时:前端6~8.5小时,包含多人审批业务映射展示。 + +页面入口:/system/wecom、个人中心、/operations/wecom-approvals、退款和线下充值详情。 + +页面结构: +1. 企微配置页:连接状态、审批场景状态、模板版本列表、模板读取和业务字段到控件的可视化映射发布。 +2. 个人中心:绑定状态、成员名称、扫码绑定、重新绑定、解绑;不提供userid输入框。 +3. 审批运行页:业务类型、业务单号、sp_no、状态、模板版本、申请人、更新时间和异常标识;详情抽屉展示审批人、意见、附件、时间线和业务处理结果。 +4. 业务详情复用统一approval区块,只读展示,不提供通过、驳回、退回按钮。 + +接口约定: +- GET /api/admin/wecom/status。 +- PUT /api/admin/wecom/approval-scenes/{scene_code}/status。 +- POST /api/admin/wecom/approval-templates/inspect、POST /api/admin/wecom/approval-templates/publish、GET /api/admin/wecom/approval-templates。 +- POST /api/admin/wecom/account-binding/sessions,返回 session_id、login_url、expires_at;GET /api/admin/wecom/account-binding/sessions/{session_id} 查询结果;GET/DELETE /api/admin/wecom/account-binding/me。 +- GET /api/admin/wecom/approvals、GET /api/admin/wecom/approvals/{id}、POST /api/admin/wecom/approvals/{id}/sync。 + +交互规则:扫码绑定页面轮询会话;未绑定创建审批时原地提供绑定入口;立即同步只拉取企微详情;通过后撤销且业务已执行时显示高风险提示。 + +完成标准:配置、绑定、列表、详情、同步和异常状态均有加载、空、失败及权限状态。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#37] 企业微信审批模板账号绑定回调与补偿 +``` + +**描述** + +```markdown +目标:以稳定业务场景码接入企微审批,替代本地审批流。 + +预计工时:后端9~11.5小时,包含多人审批场景映射。 + +接口:实现企微状态、场景暂停恢复、模板读取发布、扫码绑定、绑定管理、审批列表详情、立即同步及企微回调接口。 + +规则: +1. 场景码固定为 refund_approval、offline_recharge_approval,模板ID和控件ID按不可变版本映射。 +2. 模板编辑前暂停场景,发布新映射后恢复;历史实例保留模板和提交快照。 +3. 系统账号通过5分钟一次性会话扫码绑定企微userid,account_id和userid均唯一。 +4. 回调只验签解密并触发统一SyncApprovalStatus;getapprovaldetail为状态权威来源;审批中实例每2分钟兜底轮询。 +5. 首次终态同事务写业务Outbox,business_processed_at保证只执行一次。 + +架构:使用wecomapproval、wecomidentity Domain/Application,外部API和加解密进入WeCom Adapter,列表详情走Query。 + +完成标准:不注册本地审批动作接口,提交未知、模板变更、回调重复和轮询重复均可恢复且可审计。 +``` + +## UR#36 批量订购套餐 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#36] 批量订购上传支付进度与失败明细 +``` + +**描述** + +```markdown +目标:运营按一个代理和一种支付方式批量导入套餐订单,并查看逐行结果。 + +预计工时:前端3~4小时。 + +页面入口:批量订购套餐页或现有订单页批量入口。 + +页面结构: +1. 选择代理、整批支付方式(线下或代理钱包)、上传Excel;线下支付时上传整批凭证。 +2. 模板下载使用前端静态文件。 +3. 创建后展示任务号、状态、总数、成功数、失败数、金额汇总和失败明细表。 +4. 失败明细包含行号、资产、套餐编码、错误原因;支持按任务ID恢复页面。 + +接口约定: +- POST /api/admin/bulk-purchases,multipart包含 shop_id、payment_method、file、voucher_file、request_id。 +- GET /api/admin/bulk-purchases/{task_id} 返回任务汇总。 +- GET /api/admin/bulk-purchases/{task_id}/items?page=&size=&status= 返回逐行结果。 + +交互规则:一个批次不能混合支付方式;部分成功视为任务终态;钱包余额不足只影响对应行或后续行,不回滚已成功行。 + +完成标准:上传、进度恢复、部分成功、失败筛选和凭证展示完整。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#36] 批量订购任务与逐行幂等下单 +``` + +**描述** + +```markdown +目标:以异步任务逐行创建订单,允许部分成功且每行可审计。 + +预计工时:后端4~6小时。 + +接口:POST /api/admin/bulk-purchases;GET /api/admin/bulk-purchases/{task_id};GET /api/admin/bulk-purchases/{task_id}/items。 + +规则:整批选择offline或agent_wallet;模板按套餐编码匹配;文件最大10MB、最多1000行;request_id唯一返回原任务;行幂等键为bulk_purchase:{task_id}:{row_no}。 + +钱包行事务:锁主钱包,按信用不变量校验,订单、扣款、流水和明细成功状态同事务;单行失败不回滚其他行;任务统计从明细重新聚合。线下凭证只做本批资料,不校验跨批唯一。 + +完成标准:Worker处理租约、重复消费、进程中断恢复和部分成功均不产生重复订单或重复扣款。 +``` + +## UR#35 退款审核 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#35] 退款企微审批详情与业务处理状态 +``` + +**描述** + +```markdown +目标:退款创建时提交业务资料,审批在企微完成,本系统只读展示审批及退款处理结果。 + +预计工时:前端2.5~3.5小时。 + +页面入口:退款创建、退款列表、退款详情。 + +页面结构: +1. 创建表单包含退款金额、原因、备注和附件;金额提交后企微审批不可修改。 +2. 详情分为退款业务信息、企微审批信息、业务处理结果三个区域。 +3. 审批区展示sp_no、状态、申请人、审批人、意见、附件和时间线。 +4. 处理区展示processing_status、失败摘要和“系统重试中/联系管理员”。 +5. 不显示本地通过、驳回、退回或人工退款确认按钮。 + +接口约定: +- POST /api/admin/refunds 创建退款。 +- GET /api/admin/refunds/{id} 返回退款数据、approval对象和processing_status。 +- POST /api/admin/refunds/{id}/resubmit,仅已驳回、已撤销或已删除可重新申请。 +- attachments使用现有对象存储上传结果,提交结构为 {file_key,file_name,file_size}[]。 +- approval结构至少包含 source、sp_no、status、status_name、template_version、applicant、approvers、comments、attachments、timeline、business_process_result。 + +交互规则:非代理钱包由财务在系统外人工退款后再在企微通过;重新申请可改金额、凭证和原因,不可改订单、资产和提交人快照。 + +完成标准:审批中、通过处理中、处理成功、驳回、撤销和通过后撤销异常状态均展示明确。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#35] 退款企微终态与人工退款处理 +``` + +**描述** + +```markdown +目标:退款通过企微审批驱动业务终态,系统只自动回溯代理主钱包支付。 + +预计工时:后端4~5小时。 + +接口:POST /api/admin/refunds;GET /api/admin/refunds/{id};POST /api/admin/refunds/{id}/resubmit;下线原approve/reject/return路由。 + +规则: +1. 非代理钱包支付由财务人工退款,企微通过代表人工退款已确认,不调用渠道退款API。 +2. 代理钱包订单通过后按原扣款流水幂等回溯原代理主钱包并写退款流水。 +3. 个人客户或资产钱包不自动回款。 +4. 驳回更新为已拒绝;撤销/删除更新为已撤销;通过后撤销且资金已执行不自动冲正,记录critical审计。 +5. 重新申请创建round_no+1企微实例并保留历史。 + +完成标准:审批状态与业务处理状态分离,重复终态不重复回款,失败任务可可靠重试。 +``` + +## UR#34 充值审核流程 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#34] 代理扫码充值与员工线下充值审批页面 +``` + +**描述** + +```markdown +目标:区分代理在线扫码充值和平台员工线下代充值,两条路径不混用。 + +预计工时:前端5~7小时,包含在线扫码充值和线下审批两条页面链路。 + +页面入口:代理资金充值页、后台线下代充值创建页、充值列表和详情。 + +页面结构: +1. 在线充值:金额输入最低100元、支付方式选择、二维码、过期倒计时和支付状态;不显示审批区域。 +2. 线下代充值:选择店铺、金额、备注、附件;提交后显示企微审批和入账处理状态。 +3. 充值详情按approval_source显示:none隐藏审批区,wecom显示只读企微详情。 +4. 不显示本地确认入账、驳回按钮和操作密码输入框。 + +接口约定: +- GET /api/admin/agent-recharges/payment-methods 返回 methods:string[]、min_amount:int64。 +- POST /api/admin/agent-recharges,在线入参 amount、payment_method、request_id;线下入参 shop_id、amount、payment_method=offline、remark、attachments、request_id。 +- 在线返回 recharge_id、recharge_no、qr_content、expires_at、payment_status、approval_source=none。 +- GET /api/admin/agent-recharges/{id}/payment-status 返回 payment_status、payment_status_name、wallet_posting_status。 +- GET /api/admin/agent-recharges/{id} 返回充值详情、approval和processing_status。 +- 线下attachments使用现有对象存储上传结果,结构为 {file_key,file_name,file_size}[]。 + +交互规则:在线状态每3秒轮询,页面不可见暂停;二维码过期后重新创建新支付单;线下提交失败保留表单。 + +完成标准:微信、支付宝、二维码过期、重复回调后的最终状态、线下审批通过入账和驳回状态均可展示。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#34] 代理在线充值入账与线下充值企微终态 +``` + +**描述** + +```markdown +目标:实现无需审批的代理在线充值,以及需要企微审批的平台员工线下代充值。 + +预计工时:后端8~11小时,包含支付渠道接入和线下审批入账。 + +接口:GET /api/admin/agent-recharges/payment-methods;POST /api/admin/agent-recharges;GET /api/admin/agent-recharges/{id}/payment-status;GET /api/admin/agent-recharges/{id};下线offline-pay和reject旧接口。 + +在线规则:最低10000分;支持微信Native和支付宝预下单;统一返回qr_content;支付回调校验渠道、金额、配置、交易号和业务单;支付单、充值单、主钱包、版本、唯一流水和审计同事务推进;重复回调不重复入账。 + +线下规则:创建后提交企微;通过后自动增加代理主钱包并写流水,无操作密码;recharge:{recharge_no}防重;驳回为已驳回,撤销/删除为已关闭;通过后撤销且已入账不自动扣回。 + +完成标准:两条路径状态隔离,支付成功但入账失败可补偿,企微终态重复同步不重复加钱。 +``` + +## UR#33 套餐临期提醒 + +### 前端研发需求 + +**标题** + +```text +[FE][UR#33] 临期列表高亮置顶通知与续费入口 +``` + +**描述** + +```markdown +目标:统一展示资产预计最终到期的临期状态,并提供站内通知和续费入口。 + +预计工时:前端3~4.5小时,不含公共站内通知中心工时。 + +页面入口:/operations/expiring-assets或现有临期页、卡/设备列表和详情、代理首页、C端资产页、通知中心。 + +页面结构: +1. 临期独立列表展示资产、店铺、当前套餐、预计最终到期、剩余天数和颜色;0-3天固定置顶,再按到期时间升序。 +2. 普通资产列表只按颜色高亮,不改变原排序。 +3. 代理首页展示临期卡数量和设备数量。 +4. C端资产页展示临期状态和续费按钮。 +5. 通知中心展示15天、7天、3天站内提醒,不展示企微临期消息。 + +接口约定: +- GET /api/admin/expiring-assets 支持 asset_type、keyword、shop_id、package_id、days_min、days_max、expires_from、expires_to、page、size。 +- items返回 asset_type、asset_id、identifier、shop_name、package_name、estimated_final_expires_at、days_until_final_expiry、expiry_level、expiry_level_name、can_renew。 +- 资产列表/详情和GET /api/c/v1/asset/info返回同一临期字段。 +- 通知复用通知接口。 + +交互规则:颜色为8-15天粉红、4-7天紫色、0-3天红色;已过期和不可预计资产不进入临期页。 + +完成标准:列表排序、普通列表高亮、首页计数、C端续费和通知跳转使用同一到期结果。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][UR#33] 最终到期临期Query与15/7/3站内通知 +``` + +**描述** + +```markdown +目标:基于统一预计最终到期Query提供临期查询和站内通知。 + +预计工时:后端4~6小时,不含公共站内通知基础设施工时。 + +接口:GET /api/admin/expiring-assets;扩展卡/设备列表详情、代理首页和GET /api/c/v1/asset/info;复用通知接口和scene=expiring_asset导出。 + +规则:按Asia/Shanghai自然日计算0-15天;已过期和不可预计资产不计入;临期页0-3天优先,再按最终到期升序;普通列表不改排序。 + +通知:每日任务按package_usage_id+recipient+channel+node防重,节点为15/7/3天;漏跑只补当前最近未发送节点;接收店铺主账号和业务员;只发站内通知。 + +完成标准:查询、首页计数、C端和通知共用同一最终到期算法,重复任务不重复通知。 +``` + +## 技术用户需求 七月迭代公共开发基础 + +> 建议与全局审计、站内通知一起挂到“七月迭代公共基础设施”技术用户需求下。 + +### 前端研发需求 + +**标题** + +```text +[FE][TECH] 七月迭代公共状态与异步任务交互 +``` + +**描述** + +```markdown +目标:为批量分配、批量订购、导出等页面提供一致的加载、错误和异步任务交互。 + +预计工时:前端1~2小时。 + +交付内容: +1. 统一加载、空数据、权限不足、接口失败和重试状态。 +2. 统一异步任务进度结构:任务状态、总数、成功数、失败数、部分成功和失败明细。 +3. 创建任务后按2秒、3秒、5秒递增轮询,最大间隔10秒;页面不可见暂停,恢复后立即刷新。 +4. 页面刷新后通过task_id恢复任务详情。 + +完成标准:设备批量分配、批量订购和导出页面复用同一交互规则,不各自实现不同状态语义。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][TECH] 七月迭代公共迁移幂等与异步任务基础 +``` + +**描述** + +```markdown +目标:补齐本期跨需求共用的数据迁移、Outbox、幂等和异步任务能力。 + +预计工时:后端4~5小时。 + +交付内容: +1. 按标准稿准备增量迁移、索引、约束和停机迁移检查。 +2. 统一Outbox写入和消费状态,供企微终态、钱包通知和状态同步使用。 +3. 统一request_id防重、状态条件更新、乐观锁和Worker处理租约。 +4. 统一异步任务状态及失败明细Query,Asynq载荷只传结构化数据。 +5. 为渐进DDD新增的Domain、Application、Query和Adapter提供项目内一致目录及装配方式。 + +完成标准:业务研发需求复用公共能力,不分别创建不兼容的幂等、Outbox和任务状态实现。 +``` + +## 技术用户需求 公共站内通知 + +### 前端研发需求 + +**标题** + +```text +[FE][TECH] 顶部通知铃铛与站内通知中心 +``` + +**描述** + +```markdown +目标:提供本期余额预警、临期提醒、审批结果和系统告警共用的站内通知界面。 + +预计工时:前端3~4小时。 + +页面入口:顶部全局导航、/notifications。 + +页面结构: +1. 顶部铃铛固定宽度显示0、1~99或99+,点击展示最近10条通知抽屉。 +2. 抽屉按全部、审批、临期、同步/系统分类,并提供进入通知中心入口。 +3. 通知中心支持分类、类型、严重级别、已读状态筛选及全部已读。 +4. 点击通知先标记已读,再根据ref_type受控跳转;未知目标只展示正文。 + +接口约定:GET /api/admin/notifications/unread-count、unread-summary、notifications;PUT /api/admin/notifications/{id}/read、read-all;GET /api/admin/notifications/{id}/target。C端使用对应/api/c/v1/notifications接口。 + +完成标准:余额、临期、审批和系统通知展示一致,未读数和列表状态同步,无任意URL跳转。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][TECH] 站内通知基础设施与受控跳转 +``` + +**描述** + +```markdown +目标:为本期所有通知场景提供统一存储、模板、接收人、防重、未读和受控跳转能力。 + +预计工时:后端3~4小时。 + +接口:实现管理端和C端未读数、分类汇总、分页列表、单条已读、全部已读及受控目标解析接口。 + +规则: +1. 通知使用稳定业务键防重,按场景解析店铺主账号、业务员、申请人等接收人。 +2. target只返回受控ref_type和ref_id,不保存或返回任意URL。 +3. 通知失败不回滚资金、审批或套餐业务事务,由Outbox可靠重试。 +4. 余额预警、15/7/3临期、审批结果和系统告警复用统一模板注册。 + +完成标准:重复事件不重复通知,接收人停用时跳过,管理端和C端数据范围正确。 +``` + +## 技术用户需求 全局多视角审计与外部集成追踪 + +> 当前禅道CSV没有对应父用户需求。先创建该技术用户需求,再创建以下FE、BE研发需求。 + +### 前端研发需求 + +**标题** + +```text +[FE][TECH] 全局多视角审计中心 +``` + +**描述** + +```markdown +目标:提供一个工作台式审计中心,从全局、人员、资源、链路、资金、风险和外部集成多个视角追踪系统操作。 + +预计工时:前端6~8小时。 + +页面入口:/operations/audit。 + +页面结构: +1. Tab:全局事件、人员行为、资源轨迹、请求/业务链路、资金审计、风险事件、外部集成。 +2. 公共筛选:时间、操作编码、结果、风险级别、操作者、资源、request_id、correlation_id。 +3. 列表展示时间、操作者、操作、主要资源、结果、风险和链路编号;详情抽屉展示脱敏前后数据、关联资源和上下游事件。 +4. 资源轨迹先搜索候选资源再打开时间线;外部集成按provider、operation、触发来源和序列展示立即/3分钟/5分钟结果。 +5. 敏感字段默认脱敏;查看敏感值和导出使用独立权限。 + +接口约定:GET /api/admin/audit/events及详情、actors、resources/search及timeline、requests和correlations timeline、risks、integrations、finance/timeline;POST /api/admin/audit/exports。 + +完成标准:每个视角有独立筛选和空状态,可通过request_id/correlation_id在事件间跳转,旧历史日志可只读展示。 +``` + +### 后端研发需求 + +**标题** + +```text +[BE][TECH] Audit Event与Integration Log统一审计 +``` + +**描述** + +```markdown +目标:一次停机发布切换全局审计写入,并提供多视角Query。 + +预计工时:后端8~11小时。 + +数据:tb_audit_event、tb_audit_event_resource、tb_integration_log;Audit Event不可变,支持主资源、影响资源和引用资源。 + +接口:实现事件、人员、资源、request、correlation、风险、外部集成、资金和导出Query。 + +规则:资金、权限、审批和关键配置变更与业务事务同事务写审计;失败和拒绝在回滚后短事务记录;外部交互写Integration Log;密码、Token、Secret、私钥、验证码、完整证件、签名URL和media_id禁止入库。 + +迁移:新旧敏感写操作统一接入Audit Writer,旧日志停止新写入;历史数据通过Query UNION ALL只读投影,不双写。 + +完成标准:多资源关联、链路追踪、脱敏、保留周期和审计自身权限完整,关键审计失败能阻止对应业务提交。 +``` + +## 联调研发需求 + +> 建议先建立技术用户需求“7月迭代跨模块联调与发布验收”,以下每条作为可独立指派的联调研发需求。问题修复仍回到对应FE/BE研发需求。 + +### INT-01 资产实名、复机与状态同步联调 + +**标题** + +```text +[INT-01] 资产实名复机与状态同步联调 +``` + +**描述** + +```markdown +关联需求:UR#94、UR#73、UR#62、UR#53。 + +参与工时参考:前端1~1.5小时、后端1~1.5小时,从关联FE/BE研发需求工时中拆出,不新增总工时。 + +进入条件:H5流程、实名策略接口、卡状态统一应用用例、轮询和0/3/5任务已完成。 + +联调范围: +1. 不同realname_link_type下的实名要求和行业卡复机结果。 +2. none、before_order、after_order三种H5流程。 +3. 查询、获取实名链接、停复机、支付、套餐激活等埋点后的立即/3分钟/5分钟轨迹。 +4. 19位和20位ICCID实名回调路由。 +5. 卡和设备实名筛选及设备快照更新。 + +完成标准:前端状态、业务数据、审计轨迹和上游调用结果一致,无旧逻辑直接改卡状态。 +``` + +### INT-02 换货完整链路联调 + +**标题** + +```text +[INT-02] 换货完整链路联调 +``` + +**描述** + +```markdown +关联需求:UR#98、UR#86、UR#57、UR#45。 + +参与工时参考:前端0.5~1小时、后端0.5~1小时,从关联FE/BE研发需求工时中拆出,不新增总工时。 + +进入条件:换货创建/完成接口、退款拦截、列表搜索和资产详情换货链已完成。 + +联调范围:退款中拦截、平台库存新资产继承旧店铺、其他店铺新资产拒绝、完成换货、旧店铺历史可见、新旧资产独立搜索、前代后代跳转和无权限显示。 + +完成标准:换货事务数据一致,列表、详情、资产归属和审计结果一致。 +``` + +### INT-03 套餐生命周期联调 + +**标题** + +```text +[INT-03] 套餐授权购买续费到期与临期联调 +``` + +**描述** + +```markdown +关联需求:UR#55、UR#46、UR#43、UR#40、UR#33。 + +参与工时参考:前端1~1.5小时、后端1~1.5小时,从关联FE/BE研发需求工时中拆出,不新增总工时。 + +进入条件:套餐授权候选、生效条件快照、可售策略、最终到期Query和临期页面已完成。 + +联调范围:批量授权及已授权置灰、默认/覆盖生效条件、购买快照、下架套餐历史续费、多个排队套餐最终到期、临期高亮/置顶、15/7/3通知和C端续费。 + +完成标准:后台、C端、临期列表、通知和导出使用同一套餐生命周期结果。 +``` + +### INT-04 Excel批量任务与导出联调 + +**标题** + +```text +[INT-04] Excel批量任务与导出联调 +``` + +**描述** + +```markdown +关联需求:UR#49、UR#36、UR#42。 + +参与工时参考:前端1~1.5小时、后端1~1.5小时,从关联FE/BE研发需求工时中拆出,不新增总工时。 + +进入条件:前端静态模板、上传页面、异步任务、Worker、失败明细和导出场景完成。 + +联调范围:表头校验、文件限制、重复request_id、部分成功、失败明细、任务刷新恢复、代理/系列双命令隔离、批量订购钱包扣款、导出字段权限和文件下载。 + +完成标准:任务中断可恢复,不重复分配、下单或扣款,前端进度与后端统计一致。 +``` + +### INT-05 支付钱包信用与余额预警联调 + +**标题** + +```text +[INT-05] 支付钱包信用充值与余额预警联调 +``` + +**描述** + +```markdown +关联需求:UR#48、UR#38、UR#34、UR#36、UR#96、UR#97。 + +参与工时参考:前端1~1.5小时、后端1~1.5小时,从关联FE/BE研发需求工时中拆出,不新增总工时。 + +进入条件:支付配置、在线充值、钱包领域、信用调额、业务员关联和低余额通知完成。 + +联调范围:卡/设备支付方式、微信/支付宝扫码充值、支付回调幂等、角色默认信用、已有店铺调额、批量订购扣款、现金余额跨越100元阈值及店铺主账号/业务员通知。 + +完成标准:资金不变量成立,支付或审批重复回调不重复入账,信用额度不参与现金余额预警。 +``` + +### INT-06 企业微信审批退款与线下充值联调 + +**标题** + +```text +[INT-06] 企业微信审批退款与线下充值联调 +``` + +**描述** + +```markdown +关联需求:UR#37、UR#35、UR#34、UR#44。 + +参与工时参考:前端1.5~2小时、后端1.5~2小时,从关联FE/BE研发需求工时中拆出,不新增总工时。 + +进入条件:模板映射、扫码绑定、审批提交、回调、2分钟轮询、退款和线下充值终态处理完成。 + +联调范围:账号扫码绑定、未绑定拦截、模板发布、发起审批、意见附件、通过/驳回/撤销/删除、回调重复、立即同步、退款人工处理、代理钱包回溯、线下充值入账和列表审批摘要。 + +完成标准:本系统无审批按钮,企微状态与业务处理状态分离,终态重复同步不重复执行资金动作。 +``` + +### INT-07 Gateway卡限速联调 + +**标题** + +```text +[INT-07] Gateway卡限速联调 +``` + +**描述** + +```markdown +关联需求:UR#47。 + +参与工时参考:前端0.5~1小时、后端0.5~1小时,从关联FE/BE研发需求工时中拆出,不新增总工时。 + +进入条件:统一限速接口、卡和设备详情入口、Gateway联调配置完成。 + +联调范围:单卡设置、设备解析当前卡、speed_kbps单位、0取消限速、设备无当前卡、Gateway失败及审计记录。 + +完成标准:Gateway收到的cardNo始终为卡ICCID,设备号不会被发送,上游结果在页面和审计中可追踪。 +``` + +### INT-08 全链路与停机发布验收 + +**标题** + +```text +[INT-08] 7月迭代全链路与停机发布验收 +``` + +**描述** + +```markdown +关联需求:全部激活用户需求及全局审计技术需求。 + +预计工时:前端3~4小时、后端4~5小时,本项为额外发布工时并计入总工时。 + +进入条件:INT-01至INT-07完成,迁移脚本、配置、Worker和发布清单已准备。 + +验收范围:权限与越权、通知跳转、审计多视角、历史数据兼容、旧审批接口下线、旧审计停止写入、异步任务恢复、停机迁移、配置校验和回滚边界。 + +完成标准:前端和后端使用冻结接口契约,核心链路人工验收通过,发布检查项和已知风险已记录。 +``` diff --git a/docs/7月迭代/README.md b/docs/7月迭代/README.md new file mode 100644 index 0000000..da7cc20 --- /dev/null +++ b/docs/7月迭代/README.md @@ -0,0 +1,62 @@ +# 7月迭代文档索引 + +本目录只保留一份当前有效的评审结论和可追溯的独立来源稿。 + +## 当前有效方案 + +- [7月迭代技术方案(标准评审稿)](./7月迭代技术方案-标准评审稿.md) +- [7月迭代前后端任务工时表](./7月迭代前后端任务工时表.md) +- [7月迭代禅道研发需求拆分表](./7月迭代禅道研发需求拆分表.md) +- [7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md) + +评审、开发和验收均以标准评审稿为准。独立稿用于解释方案来源;与标准稿冲突时,标准稿优先。 + +## 原需求独立稿 + +| 需求 | 来源文件 | +|------|----------| +| 01 | [复机实名规则](./独立方案/原需求/需求01-复机实名规则.md) | +| 02 | [H5 流程配置](./独立方案/原需求/需求02-H5流程配置.md) | +| 03、07、11、12、13 | [简单改动合集](./独立方案/原需求/需求03-07-11-12-13-简单改动.md) | +| 04、06 | [退款拦截与最后到期时间](./独立方案/原需求/需求04-06-退款拦截与最后到期时间.md) | +| 05 | [套餐分配生效条件](./独立方案/原需求/需求05-套餐分配生效条件.md) | +| 08 | [设备批量分配 Excel](./独立方案/原需求/需求08-设备批量分配Excel.md) | +| 09 | [C 端支付限制配置化](./独立方案/原需求/需求09-C端支付限制配置化.md) | +| 10 | [Gateway 限速规则](./独立方案/原需求/需求10-限速规则.md) | +| 14 | [导出功能](./独立方案/原需求/需求14-导出功能.md) | +| 15、16、18、19、20、21 | [复杂需求来源稿](./独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md) | +| 17 | [信用额度](./独立方案/原需求/需求17-信用额度.md) | +| 22 | [套餐临期提醒](./独立方案/原需求/需求22-套餐临期提醒.md) | + +需求16已经移出本期,仅在来源稿中保留讨论记录。 + +## 新增需求独立稿 + +| 编号 | 来源文件 | +|------|----------| +| 新增01 | [数据同步触发与轮询优化](./独立方案/新增需求/01-数据同步触发与轮询优化.md) | +| 新增02 | [企业微信审批接入](./独立方案/新增需求/02-企业微信审批接入.md) | +| 新增03 | [站内通知详细方案](./独立方案/新增需求/03-站内通知详细方案.md) | +| 新增04 | [全局多视角审计方案](./独立方案/新增需求/04-全局多视角审计方案.md) | +| 新增05 | [代理钱包扫码充值](./独立方案/新增需求/05-代理钱包扫码充值.md) | +| 禅道 #98/#86 | [换货归属与资产换货标识](./独立方案/新增需求/06-换货归属与资产换货标识.md) | +| 禅道 #96/#97 | [店铺业务员与钱包余额预警](./独立方案/新增需求/07-店铺业务员与钱包余额预警.md) | +| 禅道 #43 | [代理系列套餐批量授权](./独立方案/新增需求/08-代理系列套餐批量授权.md) | + +## 基础规范与历史方案 + +- [DDD 规范](./独立方案/基础规范/DDD规范.md) +- [系统配置独立方案](./独立方案/基础规范/系统配置.md) +- [本地通用审批流(已废弃)](./独立方案/历史方案/本地通用审批流-已废弃方案.md) +- [站内消息初版(已被详细方案替代)](./独立方案/历史方案/站内消息-初版.md) +- [前端共性方案历史稿](./独立方案/历史方案/前端共性方案-历史稿.md) + +原始讨论材料: + +- [原始业务需求](./来源材料/业务需求.md) +- [新增需求讨论与示例代码](./来源材料/新需求.md) +- [禅道用户需求导出](./物联网卡管系统-需求.csv) + +这些文件只用于追溯,不是技术实施结论。 + +已删除的 `00-总览.md` 和 `7月迭代完整技术方案-评审稿.md` 都是重复汇编,不包含独立来源信息。 diff --git a/docs/7月迭代/业务需求.md b/docs/7月迭代/来源材料/业务需求.md similarity index 97% rename from docs/7月迭代/业务需求.md rename to docs/7月迭代/来源材料/业务需求.md index d4b5328..03eccba 100644 --- a/docs/7月迭代/业务需求.md +++ b/docs/7月迭代/来源材料/业务需求.md @@ -1,4 +1,4 @@ -> 本文保留原始业务需求措辞。涉及架构、字段和接口的最终技术口径,以同目录的专项技术方案和 `基础设施/` 文档为准。 +> 本文只保留原始业务需求措辞,不作为技术实施结论。最终口径以 [标准评审稿](../7月迭代技术方案-标准评审稿.md) 为准。 其中有一些需要对接第三方系统的,除了gateway,都可以划分阶段 @@ -233,7 +233,7 @@ | BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 | | BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 | -> 评审结论:信用额度只属于代理主钱包。平台员工是操作主体而不是结算主体,不建立员工钱包、不配置员工信用额度,也不展示员工负余额;角色仅控制谁可以查看或调整代理额度。 +> 评审结论:信用额度只属于代理主钱包。平台员工是操作主体而不是结算主体,不建立员工钱包、不配置员工信用额度,也不展示员工负余额。客户角色可以配置以后新建店铺的默认额度;修改角色不更新已有店铺,已有店铺只能在店铺资金页面直接调整实际额度。 18. 系统原先对于审核都是单人审核,现在希望增加多人审批以及相关设置 | 编号 | 需求 | @@ -392,3 +392,5 @@ C端公众号 小于等于15天 临期天数小于等于15天时,显示粉红色; 临期天数小于等于7天时,显示紫色; 临期天数小于等于3天时,显示黄色; + +> 最终评审口径:资产临期按当前及排队主套餐的预计最终到期时间计算;3天内改为红色,只在临期独立列表置顶。七月迭代不发送企业微信临期消息,只发送站内通知。 diff --git a/docs/7月迭代/新需求.md b/docs/7月迭代/来源材料/新需求.md similarity index 99% rename from docs/7月迭代/新需求.md rename to docs/7月迭代/来源材料/新需求.md index bb2505f..995182a 100644 --- a/docs/7月迭代/新需求.md +++ b/docs/7月迭代/来源材料/新需求.md @@ -1,3 +1,5 @@ +> 本文保留新增需求讨论和示例代码,不作为技术实施结论。最终口径以 [标准评审稿](../7月迭代技术方案-标准评审稿.md) 及对应新增需求独立稿为准。 + 处理回调的代码 ```golang package main diff --git a/docs/7月迭代/物联网卡管系统-需求.csv b/docs/7月迭代/物联网卡管系统-需求.csv new file mode 100644 index 0000000..ef29dfd --- /dev/null +++ b/docs/7月迭代/物联网卡管系统-需求.csv @@ -0,0 +1,329 @@ +"","Ʒ","ģ","ƻ","Դ","Դע","û","","ձ׼","㼶","","ؼ","ȼ","Ԥƹʱ","ǰ״̬","׶","","T","B","C","˭","","ָɸ","ָ","͸","","ʱ","˭ر","ر","رԭ","޸","޸","","ظ","" +"98 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲ","ĬϻѾʲȨһ𻮹ȥ +","","1 ","0 ","","3(#3)","0.50 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-15 14:08:57","ƠD","2026-07-16 11:51:00",""," +ƠD(#huang)","2026-07-16 11:50:00","","","","ƠD","2026-07-16 11:51:00","","","","" +"97 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","Ǯֵ","ʽſԤͬòͬԤȡﵽֵʱѴ䷢չˡ +","","1 ","0 ","","3(#3)","0.50 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-15 11:23:35","ƠD","2026-07-16 15:40:38",""," +ƠD(#huang)","2026-07-16 15:40:00","","","","ƠD","2026-07-16 15:40:38","","","","" +"96 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ԱΪչ˽бʶ","½ҵԱֶΣԽָӦҵԱDZͬʱбԼչֶΡչҲΪչµص̡ +","","1 ","0 ","","2(#2)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-15 11:22:00","ƠD","2026-07-15 11:23:15",""," +ƠD(#huang)","2026-07-15 11:23:00","","","","","2026-07-15 11:23:42","","","","" +"94 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ϵͳ״̬ͬŻԼص","ʲǨҪ漰ص״̬ͬʽŻ +","","1 ","0 ","","1(#1)","40.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-14 10:53:29","ƠD","2026-07-14 11:00:14",""," +ƠD(#huang)","2026-07-14 10:59:00","","","","","2026-07-14 11:01:12","","","","" +"86 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲлʶ","ʲҳϢ豸ϢбҪʾǷǻʶעʲʲʲҪʲ"index.php?m=file&f=read&t=png&fileID=128""index.php?m=file&f=read&t=png&fileID=129" +","","1 ","0 ","","1(#1)","2.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-13 11:50:17","ƠD","2026-07-13 11:54:36",""," +ƠD(#huang)","2026-07-13 11:54:00","","","","ƠD","2026-07-13 11:54:36","","","","" +"73 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ҵͣ","ҵδʵ +","","1 ","0 ","","3(#3)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-11 10:02:33","ƠD","2026-07-11 14:22:26",""," +ƠD(#huang)","2026-07-11 14:22:00","","","","ƠD","2026-07-11 14:22:26","","","","" +"62 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","¿H5Ҫȳֵʵ","ûH5ҪȰֻźǿȳֵǿʵܷк̨ãһ豸Ҫǿȳֵʵһʲʵֵ +","","1 ","0 ","","3(#3)","2.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 17:04:27","","2026-07-09 18:13:07",""," +ƠD(#huang)","2026-07-11 14:23:00","","","","ƠD","2026-07-11 14:23:36","","","","" +"60 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","̹","бһϵ绰 +","","1 ","0 ","","3(#3)","0.10 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 16:00:56","ƠD","2026-07-09 17:37:57",""," +ƠD(#huang)","2026-07-09 17:37:00","","","","ƠD","2026-07-09 17:37:57","","","","" +"57 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","","ǰʲ˿ʱΪͨʱʲҳʾʲ˿ +","","1 ","0 ","","3(#3)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 15:07:17","ƠD","2026-07-09 17:32:56",""," +ƠD(#huang)","2026-07-09 17:32:00","","","","","2026-07-09 17:33:21","","","","" +"55 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ײЧ","ײʱѡЧʵЧͬʱײͷʱṩ޸ĹܣԱΪհ汾 +","","1 ","0 ","","1(#1)","4.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 11:57:51","ƠD","2026-07-09 16:54:53",""," +ƠD(#huang)","2026-07-09 16:54:00","","","","ƠD","2026-07-09 16:54:53","","","","" +"54 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲҳײ͵ʱʾ","дЧײͼЧײͼʱ +","","1 ","0 ","","2(#2)","","ѹر(#closed)","ѹر(#closed)","(#feature)","","","","","2026-07-09 11:56:50","closed","2026-07-09 17:26:38",""," +ƠD(#huang)","2026-07-09 17:26:00","ƠD","2026-07-09 17:26:38","ظ(#duplicate)","ƠD","2026-07-09 17:26:38","","#46 ʲϢֶ","","" +"53 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲѯֶ","lot豸ʵ/δʵɸѡѯ +","","1 ","0 ","","1(#1)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 09:39:53","ƠD","2026-07-09 16:53:29",""," +ƠD(#huang)","2026-07-09 16:53:00","","","","ƠD","2026-07-09 16:53:29","","","","" +"51 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ͬƷʲĻ","򻻻ڲͬʲĻҴڲ۽п豸IJײͺ豸ײͲͬڲ۵ȲԭײʧЧʲ豸Ҫʹ豸ײֱ͡ӲײͻͬײͣԹ˾˵ɱӡ +","","1 ","0 ","","1(#1)","","ݸ(#draft)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 17:54:32","","2026-07-09 16:53:05","","","2026-07-09 16:52:00","","","","ƠD","2026-07-09 16:53:05","","","","" +"49 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","豸ײϵ","豸ŲţҪṩexcelķʽײϵСexcleͷΪ豸 +","","1 ","0 ","","1(#1)","2.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 17:43:58","ƠD","2026-07-08 17:43:58",""," +ƠD(#huang)","2026-07-09 16:45:00","","","","ƠD","2026-07-09 16:46:08","","","","" +"48 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ݲͬʲʹò֧ͬʽ","C֧ʱ,ʲֻ֧֧ԼǮ֧,΢֧;ܾ,豸ֻ΢֧ԼǮ֧,֧֧;ܾ","","1 ","0 ","","1(#1)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:56:52","ƠD","2026-07-09 16:45:21",""," +ƠD(#huang)","2026-07-09 16:45:00","","","","ƠD","2026-07-09 16:45:21","","","","" +"47 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ٹ","ݲͬӪٹ򣬻ײòͬĿ/豸ٹ","","1 ","0 ","","3(#3)","4.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:54:39","ƠD","2026-07-09 17:30:42",""," +ƠD(#huang)","2026-07-09 17:27:00","","","","ƠD","2026-07-09 17:30:42","","","","" +"46 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲϢֶ","ʲϢҳ濨Ϣ/豸Ϣ飬ǰЧײ͵ĹʱΪһֶʾײͻʣ15쵽ʱֶθʾ","","1 ","0 ","","2(#2)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:52:36","ƠD","2026-07-09 17:25:53",""," +ƠD(#huang)","2026-07-09 17:25:00","","","","ƠD","2026-07-09 17:25:53","","","","" +"45 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","","| | | +| ------- | ---------------------------------------------------- | +| EXC-001 | бʲʶʲʶʾ⡣ | +| EXC-002 | боʲʶʲʶʾΪ ICCID | +| EXC-003 | ʲѯ֧ ICCIDšš | +| EXC-004 | ʲѯ֧ ICCIDšš |","","1 ","0 ","","1(#1)","3.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:51:13","ƠD","2026-07-09 16:44:46",""," +ƠD(#huang)","2026-07-09 16:44:00","","","","ƠD","2026-07-09 16:44:46","","","","" +"44 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","бֶ","| | ģ | ֶ | +| ------- | ------------ | -------------- | +| COL-001 | ˿б | ύˡ | +| COL-002 | ֵб | ύˡ | +| COL-003 | Źб | ύ |","","1 ","0 ","","1(#1)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:51:13","ƠD","2026-07-09 16:44:20",""," +ƠD(#huang)","2026-07-09 16:44:00","","","","ƠD","2026-07-09 16:44:20","","","","" +"43 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ϵȨ","| | | +| ------- | ------------------------------------------------------------ | +| AUT-001 | ϵȨ-ײбУȨײ֧һԶѡײ͡ | +| AUT-002 | Ȩײʱչʾۼۡ | +| AUT-003 | Ȩײʱչʾ˾ɱۡ | +| AUT-004 | ѷײͺδײҪ֡ | +| AUT-005 | ϵȨʱѡײȷʾǰѾײ͡ |","","1 ","0 ","","2(#2)","5.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:51:13","ƠD","2026-07-09 17:25:42",""," +ƠD(#huang)","2026-07-09 17:12:00","","","","ƠD","2026-07-09 17:25:42","","","","" +"42 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","","#### 6.8.1 lot + +##### ֧ײ30ʲĵֶΰտ/豸ĵе + +| | | +| -------- | ---------------------------- | +| EXPD-001 | lot ֶײơ | +| EXPD-002 | lot ֶʹ | +| EXPD-003 | lot ֶʣ | + +#### 6.8.2 ʽſ-ԤֵǮˮ + +ֶΣ + +| ֶ | ˵ | +| ------------------- | ------------------------------------------------------------ | +| | | +| | ֵۿ˿ | +| ׽ | ԪΪλ | +| ״̬ | ״̬ | +| ʲ | /豸 | +| ʲʶ | ICCID/豸ŵ | +| ʱ | ˮʱ | +| ǰ | ǰ | +| ׺ | ׺ | +| ײ | ʲۿ¼Ӧײ | +| | ȷִ壺˺ / ƽ̨˺ţȷ˺ƣ | +| ID | ÿһˮȫΨһױظˮ˴š׼λ | +| ҵ񶩵 | ֵۿ˿Ƚ׶Ӧԭʼҵ񶩵 | +| / ֧ʽ | ȷԴ֧ | + +#### 6.8.3 ײб + +ֶΣ + +| ֶ | +| -------------- | +| ײͱ | +| ײ | +| ײϵ | +| ײ | +| ײʱ() | +| ײʱ˵ | +| ײ | +| ײ | +| (MB) | +| (MB) | +| Ƿ | +| | +| | +| ʱ׼ | +| ɱ(Ԫ) | +| ۼ(Ԫ) | +| ۸״̬ | +| ״̬ | +| ϼ״̬ | +| Ƿײ | +| ID | +| ID | +| ʱ | +| ʱ | +| ɾʱ | + +#### 6.8.4 ˿˿б + +ֶΣ + +| ֶ | +| ---------------- | +| ˿ | +| | +| ֧ | +| ʲ | +| ʲʶ | +| ײ | +| ԭ | +| ʵս | +| ˽ | +| ˿ | +| ʵ˿ | +| ˿˷ʽ | +| ״̬ | +| ˿ԭ | +| ע | +| ע | +| ˿ʱ | +| ˿ʱ | +| ύ | +| 쵼 | +| | +| ˿ƾ֤ | + +#### 6.8.5 + +##### C˿ͻԼΨһʶ롣ԸC˿ͻ¼ÿͻ豸򿨡ͬʱʲҲʶ + +ֶΣ + +| ֶ | +| ------------ | +| | +| | +| ԭ | +| | +| ʲ | +| ʲʶ | +| ʲʶ | +| ջ | +| ջ˵绰 | +| ջַ | +| ݹ˾ | +| ݵ | +| ״̬ | +| | +| ʱ | + +#### 6.8.6 ֵ + +ֶΣ + +| ֶ | +| -------------- | +| ֵ | +| | +| ֵ | +| ֵ | +| ʵ | +| ֵǰ | +| ֵ | +| ״̬ | +| ֧ʽ | +| ֧ͨ | +| Ӫע | +| ԭ | +| ʱ | +| ֧ʱ | +| ʱ | +| ύ | +| 쵼 | +| | +| ֧ƾ֤ | +| ע |","","1 ","0 ","","1(#1)","16.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:42:07",""," +ƠD(#huang)","2026-07-09 16:40:00","","","","ƠD","2026-07-09 16:42:07","","","","" +"41 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ѯƣƿֿͻ","¼ֻܿϼϢapiԽӿͻ޷ͨϼ鵽ǹ˾Ϣ +| | | | ------- | -------------------------------------------------------- | | API-001 | ֧ô API ԽӲѯơ¼ͻ/޷缶ѯ | +","","1 ","0 ","","1(#1)","","ݸ(#draft)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","","2026-07-09 16:42:55","","","2026-07-11 14:24:00","","","","ƠD","2026-07-11 14:25:30","","","","" +"40 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ײ","| | | +| ------- | ------------------------------------------ | +| PKG-001 | ײ׼ | +| PKG-002 | ײ¼ܺʹøײ͵ĿͻԿѡ | +| PKG-003 | ¼ײѽֿ֧ͻԼ | +| PKG-004 | ¼ײͲɱ¹ |","","1 ","0 ","","2(#2)","3.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 17:11:34",""," +ƠD(#huang)","2026-07-09 17:10:00","","","","ƠD","2026-07-09 17:11:34","","","","" +"38 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ͬȴ","| | | +| ------- | ------------------------------------------------------------ | +| BPO-009 | ½ʱǷȨȡءƽ̨û˺ĬӵȨȡ | +| BPO-010 | ͬòͬޡƽ̨ûɸݲͬĽɫòͬĶޡ | +| BPO-011 | Ȩڶײֵ͡Ҫ漰ģ顣 | +| BPO-012 | Ȩȴʾƽ̨ûҲʾ |","","1 ","0 ","","2(#2)","16.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:58:16",""," +ƠD(#huang)","2026-07-09 16:57:00","","","","ƠD","2026-07-09 16:58:16","","","","" +"37 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ת","| | | +| ------- | ------------------------------------------------------------ | +| APR-001 | ϵͳύֵ롣 | +| APR-002 | ύֵϵͳչʾտά룬ɨ֧ | +| APR-003 | ֵ˿ֶ֧༶ˡ | +| APR-004 | ˻ڰύ˲쵼˺Ͳˡ | +| APR-005 | ˶Ϣʾ | +| APR-006 | Ѱ̻ڴһʾһˡ | +| APR-007 | ֪ͨͨˡ | +| APR-008 | ˲غ֪ͨˣԭ | +| APR-009 | Խҵ΢̡ |","","1 ","0 ","","2(#2)","24.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:57:35",""," +ƠD(#huang)","2026-07-09 16:57:00","","","","ƠD","2026-07-09 16:57:35","","","","" +"36 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ײ","1. ڲԱҳ档 +2. ѡICCIDŶ/豸šײ͡ϴ Excel ļʲʶ֧ ICCID/豸 +3. Excelͷת6.3ʾ +4. ϵͳУ鵼ļʲ״̬ +5. УͨʲӴۿɶ +6. Уʧܵϸҳչʾʧԭ +7. ϵͳ¼Աϸ͵ʱ䡣 + +| | | +| ------- | ---------------------------------------------------- | +| BPO-001 | ڲԱ | +| BPO-002 | ִ֧гֵǮԱۿ | +| BPO-003 | ֧Աֵۿ | +| BPO-004 | ˡ | +| BPO-005 | ʲʶ֧ ICCID/豸š | +| BPO-006 | ֧ Excel ģ嵼롣 | +| BPO-007 | ¼Աϸ͵ʱ䡣 | +| BPO-008 | ʧϸչʾҳ棬ʾʧԭ | + +excelֶΣ +| ֶ | +| ----------------------------- | +| ʲ | +| ʲʶ | +| ײϵ | +| ײ | +| | +| ֧ʽ˻/Ա˻ |","","1 ","0 ","","1(#1)","4.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:31:53",""," +ƠD(#huang)","2026-07-09 16:31:00","","","","ƠD","2026-07-09 16:31:53","","","","" +"35 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","˿","ͨ΢Ѻʾ״̬ͬܣ +1. Աύ˿롣 +2. ˿༶̡ +3. 쵼˳ +4. ǰɺһյϢʾ +5. ͨ򲵻غ֪ͨˡ","","1 ","0 ","","2(#2)","24.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:56:31",""," +ƠD(#huang)","2026-07-09 16:55:00","","","","ƠD","2026-07-09 16:56:31","","","","" +"34 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ֵ","Լֵ +1. ϵͳύֵ롣 +2. ϵͳչʾտά롣 +3. ɨ֧ + +Աֵͨ΢Ѻʾ״̬ͬܣ +1. ֵ༶̡ +2. ύ˲쵼 +3. һͨյѲ +4. ֪ͨͨˡ +5. غ֪ͨˣչʾԭ","","1 ","0 ","","2(#2)","32.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:55:42",""," +ƠD(#huang)","2026-07-09 16:55:00","","","","ƠD","2026-07-09 16:55:42","","","","" +"33 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ײ","1. ϵͳÿռ㿨/豸ײʣЧڡ +2. ڹݡѹ򣺰 15 졢7 졢3 ڵֱвͬʽѡ +3. ҵͻ 15 졢7 졢3 ڵбͨҵ΢͸ӦҵԱ +4. ˳չʾҳչʾ豸ʲб +5. C ˳չʾںҳײʣЧСڻ 15ʱչʾѣʾѰť +6. ʲѳɹʱԶȡ +#### 6.1.1 ҵͻ +| | | +| ------- | ------------------------------------------------------------ | +| EXP-001 | ̨ÿҵͻб | +| EXP-002 | ڽڵ 15 졢7 졢3 졣 | +| EXP-003 | ϵͳԽҵ΢ţб͸ӦҵԱ | +| EXP-004 | ͬһʲڲͬڽڵظӦڵѣͬһڵÿղظ͸ͬһҵԱ | + +#### 6.1.2 + +| | | +| ------- | ------------------------------------------------------------ | +| EXP-005 | ҳֱչʾڿ豸 | +| EXP-006 | ʲбʲɫǡ | +| EXP-007 | ʣ 15 Ϊɫʣ 7 Ϊɫʣ 3 Ϊɫ | +| EXP-008 | ʣ 3 ʲбöչʾ | + +#### 6.1.3 C ˹ںҳ + +| | | +| ------- | ------------------------------------------------------------ | +| EXP-009 | ͻײʣЧСڻ 15 ʱںҳչʾײ͵ѡ | +| EXP-010 | չʾ/豸šʣЧںİ | +| EXP-011 | ťİΪѡ | +| EXP-012 | ʣҪÿԶ¡ | +| EXP-013 | ײѻʱѲչʾ | +Ƽչʾİ +ײͼ + +/豸ţxxx +ʣЧڣxx + +Ϊ⵽ںӰʹãǰѡ","","1 ","0 ","","1(#1)","","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:30:06",""," +ƠD(#huang)","2026-07-09 16:29:00","","","","ƠD","2026-07-09 16:30:06","","","","" diff --git a/docs/7月迭代/前端技术方案.md b/docs/7月迭代/独立方案/历史方案/前端共性方案-历史稿.md similarity index 98% rename from docs/7月迭代/前端技术方案.md rename to docs/7月迭代/独立方案/历史方案/前端共性方案-历史稿.md index dd6cbb2..69e3af3 100644 --- a/docs/7月迭代/前端技术方案.md +++ b/docs/7月迭代/独立方案/历史方案/前端共性方案-历史稿.md @@ -1,6 +1,7 @@ # 7月迭代前端技术方案 -> 状态:待评审 +> 历史状态:共性前端来源稿;本地审批交互已废弃,最终以前端标准章节和各独立方案为准。 +> 当前方案:[标准评审稿](../../7月迭代技术方案-标准评审稿.md)。 > 适用端:后台管理端、代理端、C 端 H5/公众号 > 最后更新:2026-07-14 > 说明:当前仓库不包含前端源码,本文定义页面、交互和接口契约;实际目录、状态库和组件名称由前端仓库现状映射,禁止据此凭空更换前端技术栈。 diff --git a/docs/7月迭代/基础设施/审批流.md b/docs/7月迭代/独立方案/历史方案/本地通用审批流-已废弃方案.md similarity index 99% rename from docs/7月迭代/基础设施/审批流.md rename to docs/7月迭代/独立方案/历史方案/本地通用审批流-已废弃方案.md index 7ca9403..a972fa5 100644 --- a/docs/7月迭代/基础设施/审批流.md +++ b/docs/7月迭代/独立方案/历史方案/本地通用审批流-已废弃方案.md @@ -1,6 +1,7 @@ # 基础设施:通用审批流领域 -> 状态:待评审 +> 历史状态:已废弃,仅保留用于追溯本地审批引擎方案;当前采用企业微信审批。 +> 替代方案:[企业微信审批独立稿](../新增需求/02-企业微信审批接入.md) 和 [标准评审稿](../../7月迭代技术方案-标准评审稿.md)。 > 被依赖:需求 18(多人审批)、需求 20(退款审批)、需求 21(充值审核) > 范围变更:需求16已于2026-07-14移出本期,代理申请不在第一版接入范围 > 架构:DDD,`internal/domain/approval/` + `internal/application/approval/` @@ -1068,7 +1069,7 @@ ALTER TABLE tb_xxx ADD COLUMN approval_instance_id BIGINT; ## 十二、前端技术方案 -跨需求共性规则见 [7月迭代前端技术方案](../前端技术方案.md),本节只描述审批流专项。 +历史跨需求规则见 [前端共性方案历史稿](./前端共性方案-历史稿.md),本节只保留旧审批流设计。 ### 12.1 页面与路由 diff --git a/docs/7月迭代/基础设施/站内消息.md b/docs/7月迭代/独立方案/历史方案/站内消息-初版.md similarity index 98% rename from docs/7月迭代/基础设施/站内消息.md rename to docs/7月迭代/独立方案/历史方案/站内消息-初版.md index 8dd33ad..ff528c1 100644 --- a/docs/7月迭代/基础设施/站内消息.md +++ b/docs/7月迭代/独立方案/历史方案/站内消息-初版.md @@ -1,6 +1,7 @@ # 基础设施:站内消息(Notification) -> 状态:待评审 +> 历史状态:初版方案,已被站内通知详细方案替代。 +> 替代方案:[站内通知详细方案](../新增需求/03-站内通知详细方案.md) 和 [标准评审稿](../../7月迭代技术方案-标准评审稿.md)。 > 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒) > Phase 2 扩展:企业微信推送、短信通知(预留插拔接口) diff --git a/docs/7月迭代/需求01-复机实名规则.md b/docs/7月迭代/独立方案/原需求/需求01-复机实名规则.md similarity index 93% rename from docs/7月迭代/需求01-复机实名规则.md rename to docs/7月迭代/独立方案/原需求/需求01-复机实名规则.md index e612405..30a80f7 100644 --- a/docs/7月迭代/需求01-复机实名规则.md +++ b/docs/7月迭代/独立方案/原需求/需求01-复机实名规则.md @@ -1,6 +1,6 @@ # 需求01:行业卡后台手动复机允许未实名 -> 状态:待评审 +> 状态:原需求来源稿;实名最终规则已由数据同步方案和标准评审稿修正。 --- diff --git a/docs/7月迭代/需求02-H5流程配置.md b/docs/7月迭代/独立方案/原需求/需求02-H5流程配置.md similarity index 99% rename from docs/7月迭代/需求02-H5流程配置.md rename to docs/7月迭代/独立方案/原需求/需求02-H5流程配置.md index 6ba7aeb..748018c 100644 --- a/docs/7月迭代/需求02-H5流程配置.md +++ b/docs/7月迭代/独立方案/原需求/需求02-H5流程配置.md @@ -1,6 +1,6 @@ # 需求02:H5 流程顺序配置化 -> 状态:待评审 +> 状态:原需求独立稿;最终口径以标准评审稿为准。 --- diff --git a/docs/7月迭代/需求03-07-11-12-13-简单改动.md b/docs/7月迭代/独立方案/原需求/需求03-07-11-12-13-简单改动.md similarity index 91% rename from docs/7月迭代/需求03-07-11-12-13-简单改动.md rename to docs/7月迭代/独立方案/原需求/需求03-07-11-12-13-简单改动.md index f8ab7d7..f9cb5a3 100644 --- a/docs/7月迭代/需求03-07-11-12-13-简单改动.md +++ b/docs/7月迭代/独立方案/原需求/需求03-07-11-12-13-简单改动.md @@ -1,6 +1,6 @@ # 需求03/07/11/12/13:简单改动合集 -> 状态:待评审 +> 状态:原需求独立稿;最终口径以标准评审稿为准。 --- @@ -145,36 +145,21 @@ IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名) --- -## 需求11:资产详情-套餐到期时间字段 + 15天高亮 +## 需求11:资产详情套餐到期时间(与需求06合并) -### 后端 +需求11和需求06属于同一业务需求:资产层只展示当前及排队主套餐连续使用后的预计最终到期时间。详细计算、DTO 和前端规则统一见[需求04/06独立稿](./需求04-06-退款拦截与最后到期时间.md)。 -**无需改动。** +现有 `current_package_expires_at` 只表示当前套餐结束时间,不能代表资产服务最终结束时间,因此不得继续作为资产详情汇总到期时间或临期判断依据。 -后台资产详情页实际调用的是: +统一使用: -``` -GET /api/admin/assets/resolve/:identifier +```text +estimated_final_expires_at +days_until_final_expiry +expiry_estimate_status ``` -该接口的 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)已包含: - -```go -CurrentPackage string `json:"current_package"` // 当前套餐名称 -CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at"` // 开始时间 -CurrentPackageExpiresAt *time.Time `json:"current_package_expires_at"` // 到期时间(无套餐为 null) -``` - -到期时间字段已有,前端直接读 `current_package_expires_at` 即可,不需要新增后端字段。 - -### 前端 - -资产详情页"套餐信息"板块展示到期时间,并在剩余 ≤15 天时高亮: - -- 读取 `resolve` 接口返回的 `current_package_expires_at` -- 若为 `null`:展示"暂无套餐" -- 剩余天数由前端计算:`Math.ceil((expiresAt - now) / 86400000)` -- 剩余 ≤15 天:**红色/高亮**展示(建议红色文字 + 标签) +前端不自行计算天数;高亮颜色和临期状态直接使用需求22统一返回字段。 --- diff --git a/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md b/docs/7月迭代/独立方案/原需求/需求04-06-退款拦截与最后到期时间.md similarity index 81% rename from docs/7月迭代/需求04-06-退款拦截与最后到期时间.md rename to docs/7月迭代/独立方案/原需求/需求04-06-退款拦截与最后到期时间.md index 244ac32..a51e645 100644 --- a/docs/7月迭代/需求04-06-退款拦截与最后到期时间.md +++ b/docs/7月迭代/独立方案/原需求/需求04-06-退款拦截与最后到期时间.md @@ -1,7 +1,7 @@ # 需求04:退款中资产禁止换货 -# 需求06:资产最后到期时间 +# 需求06/11:资产预计最终到期时间 -> 状态:待评审 +> 状态:原需求独立稿;最终口径以标准评审稿为准。 --- @@ -111,11 +111,11 @@ func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *res --- -## 需求06:资产详情-所有套餐的最后到期时间 +## 需求06/11:资产详情-预计最终到期时间 ### 业务规则 -资产详情页展示:**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最后到期时间**。 +需求06与需求11是同一个业务需求的两种描述,不再拆成“当前套餐到期”和“所有套餐最后到期”两个资产汇总字段。资产详情页只展示:**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最终到期时间**。 不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。 @@ -131,15 +131,16 @@ GET /api/admin/assets/resolve/:identifier 响应 DTO:`AssetResolveResponse`(`internal/model/dto/asset_dto.go`) -该 DTO 目前**不含** `last_package_expires_at` 字段,需新增。 +该 DTO 目前只有当前套餐到期时间,需新增统一的最终到期响应,并由前端替代原资产汇总展示。 ### 后端 **DTO 新增字段**(`internal/model/dto/asset_dto.go` → `AssetResolveResponse`): ```go -// 当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为 null -LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为null"` +EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at" description:"当前及排队主套餐接续后的预计最终到期时间"` +DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余自然日"` +ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态 (exact:可推算, waiting_activation:等待未知激活时间, none:无套餐)"` ``` **Store 新增方法**(`internal/store/postgres/package_usage_store.go`): @@ -187,17 +188,19 @@ lastExpiry, err := s.packageUsageStore.ProjectLastMainPackageExpiry(ctx, assetTy if err != nil { return nil, err } -resp.LastPackageExpiresAt = lastExpiry +resp.EstimatedFinalExpiresAt = lastExpiry ``` `ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。 ### 前端 -资产详情"套餐信息"板块新增展示: +资产详情“套餐信息”板块只保留一个资产汇总展示: ``` -预计最后到期时间:2027-01-01 +预计套餐到期时间:2027-01-01 ``` -读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。 +读取 `estimated_final_expires_at`。`exact` 时展示日期;`waiting_activation` 时展示“待激活后起算”;`none` 时展示“—”。当前套餐自身的 `expires_at` 仅在套餐明细列表展示,不再作为第二个资产汇总字段。 + +高亮和临期提醒统一使用 `days_until_final_expiry`,前端不得再根据 `current_package_expires_at` 自行计算另一套剩余天数。 diff --git a/docs/7月迭代/需求05-套餐分配生效条件.md b/docs/7月迭代/独立方案/原需求/需求05-套餐分配生效条件.md similarity index 98% rename from docs/7月迭代/需求05-套餐分配生效条件.md rename to docs/7月迭代/独立方案/原需求/需求05-套餐分配生效条件.md index 9d7b0b0..cd11ec2 100644 --- a/docs/7月迭代/需求05-套餐分配生效条件.md +++ b/docs/7月迭代/独立方案/原需求/需求05-套餐分配生效条件.md @@ -1,6 +1,6 @@ # 需求05:套餐分配生效条件(ExpiryBase 覆盖) -> 状态:待评审 +> 状态:原需求独立稿;最终口径以标准评审稿为准。 --- diff --git a/docs/7月迭代/需求08-设备批量分配Excel.md b/docs/7月迭代/独立方案/原需求/需求08-设备批量分配Excel.md similarity index 99% rename from docs/7月迭代/需求08-设备批量分配Excel.md rename to docs/7月迭代/独立方案/原需求/需求08-设备批量分配Excel.md index cbbb0f1..f262b3a 100644 --- a/docs/7月迭代/需求08-设备批量分配Excel.md +++ b/docs/7月迭代/独立方案/原需求/需求08-设备批量分配Excel.md @@ -1,6 +1,6 @@ # 需求08:设备批量分配代理或套餐系列(Excel导入) -> 状态:待评审 +> 状态:原需求独立稿;最终口径以标准评审稿为准。 > 模板:前端静态资源,后端仅负责上传校验和异步处理。 --- diff --git a/docs/7月迭代/需求09-C端支付限制配置化.md b/docs/7月迭代/独立方案/原需求/需求09-C端支付限制配置化.md similarity index 97% rename from docs/7月迭代/需求09-C端支付限制配置化.md rename to docs/7月迭代/独立方案/原需求/需求09-C端支付限制配置化.md index f6c9b9a..841e0a6 100644 --- a/docs/7月迭代/需求09-C端支付限制配置化.md +++ b/docs/7月迭代/独立方案/原需求/需求09-C端支付限制配置化.md @@ -1,7 +1,7 @@ # 需求09:C端支付方式限制配置化 -> 状态:待评审 -> 依赖:[系统配置](./基础设施/系统配置.md) +> 状态:原需求独立稿;最终口径以标准评审稿为准。 +> 依赖:[系统配置](../基础规范/系统配置.md) --- diff --git a/docs/7月迭代/需求10-限速规则.md b/docs/7月迭代/独立方案/原需求/需求10-限速规则.md similarity index 98% rename from docs/7月迭代/需求10-限速规则.md rename to docs/7月迭代/独立方案/原需求/需求10-限速规则.md index d89d634..14fadfb 100644 --- a/docs/7月迭代/需求10-限速规则.md +++ b/docs/7月迭代/独立方案/原需求/需求10-限速规则.md @@ -1,6 +1,6 @@ # 需求10:Gateway 手动卡限速 -> 状态:待评审 +> 状态:原需求独立稿;最终口径以标准评审稿为准。 > 已确认边界:本期只提供手动设置/取消限速;Gateway 最终对象始终是卡,统一使用 `cardNo`。 --- diff --git a/docs/7月迭代/需求14-导出功能.md b/docs/7月迭代/独立方案/原需求/需求14-导出功能.md similarity index 99% rename from docs/7月迭代/需求14-导出功能.md rename to docs/7月迭代/独立方案/原需求/需求14-导出功能.md index 2b87436..766a133 100644 --- a/docs/7月迭代/需求14-导出功能.md +++ b/docs/7月迭代/独立方案/原需求/需求14-导出功能.md @@ -1,6 +1,6 @@ # 需求14:导出功能 -> 状态:待评审 +> 状态:原需求独立稿;最终口径以标准评审稿为准。 > 复用现有 ExportTask 体系(`tb_export_task` + Asynq),不为每个业务模块新增一套导出路由。 --- diff --git a/docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md b/docs/7月迭代/独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md similarity index 97% rename from docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md rename to docs/7月迭代/独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md index 15c0d3e..093ef41 100644 --- a/docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md +++ b/docs/7月迭代/独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md @@ -1,6 +1,6 @@ # 需求15/16/18/19/20/21 技术方案 -> 状态:待评审 +> 状态:原需求来源稿;其中本地审批流、审批页面和操作密码方案已废弃,最终以企微审批方案和标准评审稿为准。 > 评审建议:需求 15/16、需求 18/20/21、需求 19 分三组评审,不在一次会议中混合确认。 --- @@ -95,7 +95,7 @@ C端资产详情/当前套餐卡片:在当前套餐旁提供“续费”按钮 ### 依赖 -基于 [审批流基础设施](./基础设施/审批流.md) 和 [站内消息](./基础设施/站内消息.md)。 +历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md) 和 [站内消息初版](../历史方案/站内消息-初版.md)。 APR-009(企微审批对接)= Phase 2。 @@ -145,13 +145,13 @@ sequenceDiagram 充值单和退款单接入审批流,各自新增 `approval_instance_id` 字段。具体增量 DDL 与业务处理状态字段分别在需求 20、需求 21 中定义,迁移文件只能创建一次。 -业务表只保存当前审批实例 ID,历史实例通过 `biz_type + biz_id` 查询。接入协议详见 [审批流文档 - 业务接入协议](./基础设施/审批流.md#十一业务接入协议)。 +本段是历史设计。旧接入协议见 [本地通用审批流 - 业务接入协议](../历史方案/本地通用审批流-已废弃方案.md#十一业务接入协议)。 退款和充值详情统一返回 `approval_source=none|workflow|legacy`。代理在线充值等无需审批的记录返回 `none`;发布前已经结束且没有流程实例的历史审批记录返回 `legacy` 并只读展示;发布时仍待审批的退款和员工线下充值必须在维护窗口回填流程实例。 ### 前端技术方案 -- 新增“待我审批”和动态审批详情,具体页面、状态和请求幂等规则见 [前端技术方案](./前端技术方案.md#六统一审批交互)。 +- 历史前端交互见 [前端共性方案历史稿](../历史方案/前端共性方案-历史稿.md#六统一审批交互);当前已改为企微审批只读页面。 - 退款、充值列表只展示审批摘要;审批详情首屏展示发起时固化的业务关键字段和业务资料,随后展示完整审批人、意见和历史实例。 - 每次通过、驳回、退回分别保存审批意见和可选附件;驳回、退回意见必填,附件不与退款/充值业务凭证混用。 - 时间线必须能查看此前审批人的动作、时间、意见和审批附件;业务资料与审批附件分区展示。 @@ -325,7 +325,7 @@ GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50 ### 依赖 -基于 [审批流基础设施](./基础设施/审批流.md)。 +本段历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md)。 ### 退款单现有状态(不变) diff --git a/docs/7月迭代/需求17-信用额度.md b/docs/7月迭代/独立方案/原需求/需求17-信用额度.md similarity index 77% rename from docs/7月迭代/需求17-信用额度.md rename to docs/7月迭代/独立方案/原需求/需求17-信用额度.md index 6216e1e..6a88d33 100644 --- a/docs/7月迭代/需求17-信用额度.md +++ b/docs/7月迭代/独立方案/原需求/需求17-信用额度.md @@ -1,6 +1,6 @@ # 需求17:信用额度 -> 状态:待评审 +> 状态:原需求独立稿;最终口径以标准评审稿为准。 > DDD 范围:仅迁移代理主钱包的复杂写用例,资金列表和统计继续走 Query > 关联需求:BPO-009~012、需求19批量订购 @@ -13,7 +13,7 @@ 平台员工信用额度不实施,原因如下: - 平台员工是操作主体,不是订单结算主体;实际付款方只能是代理钱包或线下支付主体。 -- 角色表达权限范围,不表达资产、余额和债务,给角色配置资金会混淆 RBAC 与财务账本。 +- 客户角色可以保存“新建店铺默认额度”模板,但角色本身不持有余额和债务;实际额度仍写入代理主钱包。 - 系统不存在员工钱包、员工充值、员工还款和离职债务交接链路,负余额无法对账和追责。 - 批量订购已经明确由代理钱包扣款或使用线下支付,不存在必须从员工个人额度扣款的业务场景。 - 引入员工信用会与代理钱包形成两套资金来源,增加订单归属、退款去向和审计解释成本,但不产生实际业务价值。 @@ -45,9 +45,12 @@ flowchart TD Tx --> Success[返回扣款后余额] ``` -### 2.2 信用开关 +### 2.2 角色默认与店铺实际额度 -- 创建代理时可以提交 `enable_credit` 和 `credit_limit`。 +- 客户角色可以配置 `default_credit_enabled` 和 `default_credit_limit`,作为以后新建店铺的默认值。 +- 新建店铺时读取请求中的 `default_role_id`,将该角色当时的默认配置复制到新建主钱包。 +- 修改角色默认值不更新任何已有店铺,也不批量扫描钱包。 +- 已有店铺通过独立资金接口直接修改实际额度;店铺后续角色变化不影响钱包。 - 关闭开关时额度必须为 0。 - 打开开关时额度必须大于 0。 - 修改额度前必须校验修改后的可用金额不为负,而不是只判断 `balance >= 0`。 @@ -56,15 +59,21 @@ flowchart TD ## 三、数据库变更 -信用额度属于钱包,不在 `tb_shop` 和 `tb_agent_wallet` 各保存一份,避免双写失真。创建/编辑代理接口可以接收信用参数,但最终权威数据写入代理主钱包。 +角色字段只是新建店铺模板,不参与运行时扣款。创建店铺后,最终权威数据只读取代理主钱包;修改角色默认值不会产生角色与钱包双写同步。 ```sql ALTER TABLE tb_agent_wallet ADD COLUMN credit_enabled BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0; +ALTER TABLE tb_role + ADD COLUMN default_credit_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN default_credit_limit BIGINT NOT NULL DEFAULT 0; + COMMENT ON COLUMN tb_agent_wallet.credit_enabled IS '是否启用信用额度,仅主钱包有效'; COMMENT ON COLUMN tb_agent_wallet.credit_limit IS '信用额度上限(分),仅主钱包有效'; +COMMENT ON COLUMN tb_role.default_credit_enabled IS '新建代理店铺是否默认启用信用额度,仅客户角色有效'; +COMMENT ON COLUMN tb_role.default_credit_limit IS '新建代理店铺默认信用额度(分),仅客户角色有效'; ALTER TABLE tb_agent_wallet DROP CONSTRAINT IF EXISTS chk_agent_wallet_frozen_balance; @@ -173,7 +182,19 @@ WHERE id = ? 受影响行数为 0 时,重新读取钱包以区分并发冲突和额度低于当前欠款。 -### 5.2 钱包扣款 +### 5.2 修改角色默认信用额度 + +```text +Handler + → UpdateRoleDefaultCreditUseCase + → 校验角色为客户角色 + → 校验开关和额度组合 + → 只更新 tb_role 默认模板 + → 写角色配置审计 + → 不查询、不更新已有店铺钱包 +``` + +### 5.3 钱包扣款 所有现有主钱包扣款语句必须从: @@ -190,7 +211,7 @@ CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount 扣款、版本递增和钱包流水必须在同一事务。禁止只修改 `GetAvailableBalance()` 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。 -### 5.3 受影响用例 +### 5.4 受影响用例 - 后台订单和批量订购的代理钱包支付。 - C端/代理端使用代理主钱包的订单支付。 @@ -227,22 +248,20 @@ DebtAmount int64 `json:"debt_amount" description:"欠款金额(分)"` ## 七、API 设计 -### 7.1 创建代理 - -现有: +### 7.1 角色默认额度 ```text -POST /api/admin/shops +PUT /api/admin/roles/{id}/default-credit ``` -新增参数: - ```go -EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"` -CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"` +type UpdateRoleDefaultCreditRequest struct { + EnableCredit bool `json:"enable_credit" description:"新建店铺是否默认开启信用额度"` + CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新建店铺默认信用额度(分)"` +} ``` -Application 创建店铺和主钱包时,将信用配置写入钱包;任何一步失败整笔事务回滚。 +现有 `POST /api/admin/shops` 继续使用 `default_role_id`。Application 在创建店铺和主钱包的同一事务中读取角色默认值并复制到钱包;角色模板之后变化不影响该钱包。 ### 7.2 修改信用额度 @@ -266,19 +285,20 @@ type UpdateCreditLimitRequest struct { ## 八、前端技术方案 -### 8.1 创建/编辑代理 +### 8.1 角色管理 ```text -信用额度 -[开关] 允许使用信用额度 -授信上限 [金额输入,单位元] +新建代理默认信用额度 +[开关] 默认允许使用信用额度 +默认授信上限 [金额输入,单位元] +提示:修改后只影响以后新建的代理,不影响已有店铺 ``` - 开关关闭时清空输入并提交 `credit_limit=0`。 - 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。 -- 编辑代理基础资料不自动覆盖信用额度;信用调整使用独立权限和独立接口。 +- 仅客户角色展示该配置;平台角色不展示。 -### 8.2 资金概况和详情 +### 8.2 店铺资金概况和详情 ```text 账面余额:-¥200.00 @@ -291,6 +311,8 @@ type UpdateCreditLimitRequest struct { - 可用金额以接口值为准,不在前端自行重复计算。 - 修改额度弹框展示当前余额、冻结金额、额度和修改后可用金额预览;提交后仍以后端校验为准。 - 后端返回并发冲突时刷新钱包版本和最新金额,不保留旧计算结果。 +- 修改店铺角色、账号角色或角色默认额度都不能覆盖已有钱包额度。 +- 编辑代理基础资料不自动修改信用额度;信用调整使用独立权限和独立接口。 平台员工端不展示个人余额或个人信用额度。拥有信用额度管理权限的员工,只能在代理资金页面查看和调整代理主钱包额度。 @@ -319,9 +341,9 @@ type UpdateCreditLimitRequest struct { 本需求随七月迭代停机发布: -1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加信用字段,历史数据默认关闭。 +1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加钱包信用字段和角色默认模板字段,历史钱包默认关闭。 2. 同时发布钱包聚合、全部主钱包扣款条件、查询字段和管理端信用配置页面,禁止只改余额展示而遗漏真实扣款 SQL。 3. 开放访问前保持所有代理信用开关关闭,人工验证普通余额、信用扣款、并发冲突和额度调整。 -4. 系统开放后再由有权限的管理员对指定代理逐个开启额度。 +4. 系统开放后配置客户角色的新建默认额度;已有店铺需要授信时仍由管理员在店铺资金页面逐个设置。 尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。 diff --git a/docs/7月迭代/需求22-套餐临期提醒.md b/docs/7月迭代/独立方案/原需求/需求22-套餐临期提醒.md similarity index 71% rename from docs/7月迭代/需求22-套餐临期提醒.md rename to docs/7月迭代/独立方案/原需求/需求22-套餐临期提醒.md index 877ed03..779b64a 100644 --- a/docs/7月迭代/需求22-套餐临期提醒.md +++ b/docs/7月迭代/独立方案/原需求/需求22-套餐临期提醒.md @@ -1,6 +1,6 @@ # 需求22:套餐临期提醒 -> 状态:待评审 +> 状态:原需求独立稿;最终口径以标准评审稿为准。 --- @@ -8,9 +8,9 @@ ### 临期定义 -当资产的**当前生效主套餐**(`PackageUsage.status=1`)按 `Asia/Shanghai` 自然日计算的剩余天数在 `0~15` 天时,该资产进入临期状态。 +当资产的当前生效主套餐与全部排队主套餐连续接续后的**预计最终剩余天数**按 `Asia/Shanghai` 自然日计算处于 `0~15` 天时,该资产进入临期状态。预计最终到期时间与需求06/11共用同一个 Query,不再维护“当前套餐临期”和“最终到期”两套口径。 -资产有可接续的排队主套餐时不进入临期提醒,因为服务不会在当前套餐到期后中断;需求06仍会单独展示排队套餐接续后的“预计最后到期时间”。已过期资产不属于临期。 +没有生效套餐且队首套餐仍等待无法确定时间的实名激活时,返回不可预计状态,不进入临期。已过期资产不属于临期。 ### 查询与通知职责 @@ -24,28 +24,27 @@ |---------|------| | ≤ 15天 | 粉红色 | | ≤ 7天 | 紫色 | -| ≤ 3天 | 黄色 | +| ≤ 3天 | 红色 | ### 各端提醒规则 | 场景 | 提醒方式 | 触发节点 | |------|---------|---------| | **后台管理** | 列表加临期天数列 + 高亮;详情加临期字段(≤15天高亮) | 实时计算 | -| **企业客户** | 企微推送(Phase 2);后台每日生成临期列表 | 15天/7天/3天节点 | +| **企业客户** | 对应店铺业务员接收站内通知;后台每日生成临期列表 | 15天/7天/3天节点 | | **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 | | **C端公众号** | 套餐到期提醒模块(≤15天展示) | 实时展示 | ```mermaid flowchart TD - Schedule[每日定时任务] --> Query[批量查询实时临期候选] - Query --> QueueCheck{存在排队套餐?} - QueueCheck -->|是| Skip[不进入临期提醒] - QueueCheck -->|否| Days[计算剩余自然日] + Schedule[每日定时任务] --> Query[计算预计最终到期时间] + Query --> Predictable{可以推算?} + Predictable -->|否| Skip[不进入临期提醒] + Predictable -->|是| Days[计算最终剩余自然日] Days --> Node{命中 15/7/3 天节点?} Node -->|否| End[本次不发送] Node -->|是| Upsert[按资产+节点+接收人幂等写通知记录] Upsert --> Notification[站内消息] - Upsert -. Phase 2 .-> WeCom[企业微信推送] ``` --- @@ -64,7 +63,7 @@ CREATE TABLE tb_expiry_push_record ( asset_type VARCHAR(20) NOT NULL, -- iot_card | device asset_id BIGINT NOT NULL, recipient_id BIGINT NOT NULL, - channel VARCHAR(20) NOT NULL, -- notification | wecom + channel VARCHAR(20) NOT NULL, -- notification push_node INT NOT NULL, -- 推送节点(3/7/15天) event_id VARCHAR(64) NOT NULL, pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -79,7 +78,7 @@ CREATE UNIQUE INDEX idx_expiry_push_event ON tb_expiry_push_record(event_id); ``` -同一资产可能对应多个业务员,唯一约束必须包含接收人和渠道;否则第一位业务员写入记录后会错误拦截其他接收人。`package_usage_id` 让同一资产续费生成新使用记录后可以再次触发 15/7/3 天提醒。站内消息和 Phase 2 企业微信都使用该表防重。 +同一资产可能同时通知代理主账号和店铺业务员,唯一约束必须包含接收人和渠道;否则第一位接收人写入记录后会错误拦截其他接收人。`package_usage_id` 让同一资产续费生成新使用记录后可以再次触发 15/7/3 天提醒。本期只使用站内通知渠道。 --- @@ -93,34 +92,14 @@ CREATE UNIQUE INDEX idx_expiry_push_event ```go type IotCardListItem struct { // ...原有字段... - DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐为null)"` - IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"` + EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"` + DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"` + ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态"` + IsExpiring bool `json:"is_expiring" description:"是否临期"` } ``` -**计算方式**(在 SQL 层计算,避免 N+1): - -```sql --- 列表查询时 JOIN PackageUsage 获取剩余天数 -SELECT - ic.*, - CASE - WHEN pu.expires_at IS NULL THEN NULL - WHEN (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date - < (NOW() AT TIME ZONE 'Asia/Shanghai')::date THEN NULL - WHEN EXISTS ( - SELECT 1 FROM tb_package_usage q - WHERE q.iot_card_id = ic.id AND q.status = 0 AND q.deleted_at IS NULL - ) THEN NULL -- 有排队套餐,不临期 - ELSE (pu.expires_at AT TIME ZONE 'Asia/Shanghai')::date - - (NOW() AT TIME ZONE 'Asia/Shanghai')::date - END AS days_until_expiry -FROM tb_iot_card ic -LEFT JOIN tb_package_usage pu - ON pu.iot_card_id = ic.id AND pu.status = 1 AND pu.deleted_at IS NULL -``` - -`is_expiring` 由同一 SQL 表达式派生:`days_until_expiry IS NOT NULL AND days_until_expiry BETWEEN 0 AND 15`。设备查询复用同一规则,不能另写一套日期计算。 +列表 Query 批量加载当前和排队主套餐,复用需求06/11的周期、时长快照推算逻辑,禁止逐资产查询。`is_expiring` 由 `days_until_final_expiry BETWEEN 0 AND 15` 派生;设备和卡不得各写一套日期规则。 #### 资产列表筛选条件新增 @@ -138,8 +117,10 @@ type IotCardListRequest struct { 新增字段: ```go // AssetResolveResponse 追加 -DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐或有排队套餐时为null)"` -IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"` + EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"` + DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"` + ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态"` + IsExpiring bool `json:"is_expiring" description:"是否临期"` ``` ### 2. 新增临期列表接口(独立页面) @@ -157,7 +138,7 @@ type ExpiringAssetsRequest struct { ShopID *uint `query:"shop_id" description:"店铺ID"` ExpiresAtStart string `query:"expires_at_start" description:"到期时间起"` ExpiresAtEnd string `query:"expires_at_end" description:"到期时间止"` - MaxDaysUntilExpiry *int `query:"max_days_until_expiry" description:"最大剩余天数(如15)"` + MaxDaysUntilFinalExpiry *int `query:"max_days_until_final_expiry" description:"最大预计最终剩余天数(如15)"` Page int `query:"page" default:"1"` PageSize int `query:"page_size" default:"20"` } @@ -171,7 +152,7 @@ type ExpiringAssetItem struct { AssetStatus int `json:"asset_status"` ShopName string `json:"shop_name"` PackageName string `json:"package_name"` - DaysUntilExpiry int `json:"days_until_expiry"` + DaysUntilFinalExpiry int `json:"days_until_final_expiry"` ExpiresAt time.Time `json:"expires_at"` DataUsageMB int64 `json:"data_usage_mb"` RemainingDataMB int64 `json:"remaining_data_mb"` @@ -194,7 +175,7 @@ func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asy continue } today := time.Now().In(shanghaiLocation).Format("2006-01-02") // shanghaiLocation 由 time.LoadLocation("Asia/Shanghai") 初始化 - for _, recipientID := range asset.SalesmanIDs { + for _, recipientID := range asset.RecipientAccountIDs { eventID := fmt.Sprintf( "expiry:%d:%d:%d:%d:%s", asset.PackageUsageID, node, recipientID, asset.AssetID, today, @@ -255,8 +236,9 @@ scene=expiring_asset 现有接口(`GET /api/c/v1/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse`(`internal/model/dto/client_asset_dto.go`)新增字段: ```go -DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(≤15天时有值,有排队套餐时为null)"` -IsExpiring bool `json:"is_expiring" description:"是否临期"` +EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"` +DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"` +IsExpiring bool `json:"is_expiring" description:"是否临期"` ``` 前端逻辑:`is_expiring=true` 时展示续费提醒模块: @@ -279,10 +261,10 @@ IsExpiring bool `json:"is_expiring" description:"是否临期"` ### 资产列表(IoT卡管理 / 设备管理) 1. 列表新增"剩余天数"列 -2. 根据 `days_until_expiry` 高亮行: +2. 根据 `days_until_final_expiry` 高亮行: - ≤ 15天:行背景粉红色 - ≤ 7天:行背景紫色 - - ≤ 3天:行背景黄色 + - ≤ 3天:行背景红色 3. 筛选条件新增"临期天数"(下拉:≤15天/≤7天/≤3天) - 选中后传 `expiring_within_days=15` @@ -298,6 +280,8 @@ IsExpiring bool `json:"is_expiring" description:"是否临期"` 操作:导出按钮 → `POST /api/admin/export-tasks`,`scene=expiring_asset` ``` +临期独立列表将 `days_until_final_expiry <= 3` 的资产置顶,再按预计最终到期时间升序。普通卡列表和设备列表只按颜色高亮,不改变原有排序。 + ### 代理端首页 在首页数据接口新增字段(需要确认代理端首页接口): diff --git a/docs/7月迭代/DDD规范.md b/docs/7月迭代/独立方案/基础规范/DDD规范.md similarity index 99% rename from docs/7月迭代/DDD规范.md rename to docs/7月迭代/独立方案/基础规范/DDD规范.md index 71c5061..9a3c6c6 100644 --- a/docs/7月迭代/DDD规范.md +++ b/docs/7月迭代/独立方案/基础规范/DDD规范.md @@ -1,6 +1,6 @@ # 项目 DDD 设计规范 -> 状态:待评审 +> 状态:项目渐进迁移规范;7月迭代标准稿已采用。 > 务实型 DDD——不是学院派,不强制 Event Sourcing,不追求完美,追求可维护、可扩展、AI 辅助友好。 > 基准文档:`docs/改造方向.md`(绞杀者模式) diff --git a/docs/7月迭代/基础设施/系统配置.md b/docs/7月迭代/独立方案/基础规范/系统配置.md similarity index 98% rename from docs/7月迭代/基础设施/系统配置.md rename to docs/7月迭代/独立方案/基础规范/系统配置.md index 4558b49..aba716d 100644 --- a/docs/7月迭代/基础设施/系统配置.md +++ b/docs/7月迭代/独立方案/基础规范/系统配置.md @@ -1,6 +1,6 @@ # 基础设施:系统配置(tb_system_config) -> 状态:待评审 +> 状态:独立方案来源稿;最终口径以7月迭代标准评审稿为准。 > 被依赖:需求 09(C端支付限制) --- diff --git a/docs/7月迭代/新增需求/01-数据同步触发与轮询优化.md b/docs/7月迭代/独立方案/新增需求/01-数据同步触发与轮询优化.md similarity index 99% rename from docs/7月迭代/新增需求/01-数据同步触发与轮询优化.md rename to docs/7月迭代/独立方案/新增需求/01-数据同步触发与轮询优化.md index d861adb..59df808 100644 --- a/docs/7月迭代/新增需求/01-数据同步触发与轮询优化.md +++ b/docs/7月迭代/独立方案/新增需求/01-数据同步触发与轮询优化.md @@ -1,7 +1,7 @@ # 新增需求 01:数据同步触发与轮询优化 > 状态:已合并至标准评审稿,本文保留为实施明细。 -> 评审主文档:`../7月迭代技术方案-标准评审稿.md` +> 评审主文档:`../../7月迭代技术方案-标准评审稿.md` > 范围:IoT 卡实名、流量、网络状态和设备实时数据同步。 ## 一、已确认决策 diff --git a/docs/7月迭代/新增需求/02-企业微信审批接入.md b/docs/7月迭代/独立方案/新增需求/02-企业微信审批接入.md similarity index 99% rename from docs/7月迭代/新增需求/02-企业微信审批接入.md rename to docs/7月迭代/独立方案/新增需求/02-企业微信审批接入.md index 895712b..5c9af82 100644 --- a/docs/7月迭代/新增需求/02-企业微信审批接入.md +++ b/docs/7月迭代/独立方案/新增需求/02-企业微信审批接入.md @@ -1,7 +1,7 @@ # 新增需求 02:企业微信审批接入 > 状态:已合并至标准评审稿,本文保留为实施明细。 -> 评审主文档:`../7月迭代技术方案-标准评审稿.md` +> 评审主文档:`../../7月迭代技术方案-标准评审稿.md` > 范围:退款审批、平台员工线下充值审批,以及企业微信模板配置、回调和轮询补偿。 ## 一、已确认决策 diff --git a/docs/7月迭代/新增需求/03-站内通知详细方案.md b/docs/7月迭代/独立方案/新增需求/03-站内通知详细方案.md similarity index 99% rename from docs/7月迭代/新增需求/03-站内通知详细方案.md rename to docs/7月迭代/独立方案/新增需求/03-站内通知详细方案.md index 9ee90e0..0479643 100644 --- a/docs/7月迭代/新增需求/03-站内通知详细方案.md +++ b/docs/7月迭代/独立方案/新增需求/03-站内通知详细方案.md @@ -1,7 +1,7 @@ # 新增需求 03:站内通知详细方案 > 状态:已合并至标准评审稿,本文保留为实施明细。 -> 评审主文档:`../7月迭代技术方案-标准评审稿.md` +> 评审主文档:`../../7月迭代技术方案-标准评审稿.md` > 范围:后台账号、代理账号、企业账号和个人客户的站内通知。 ## 一、定位 diff --git a/docs/7月迭代/新增需求/04-全局多视角审计方案.md b/docs/7月迭代/独立方案/新增需求/04-全局多视角审计方案.md similarity index 99% rename from docs/7月迭代/新增需求/04-全局多视角审计方案.md rename to docs/7月迭代/独立方案/新增需求/04-全局多视角审计方案.md index e1b728d..270a0c6 100644 --- a/docs/7月迭代/新增需求/04-全局多视角审计方案.md +++ b/docs/7月迭代/独立方案/新增需求/04-全局多视角审计方案.md @@ -1,7 +1,7 @@ # 新增需求 04:全局多视角审计方案 > 状态:已合并至标准评审稿,本文保留为实施明细。 -> 评审主文档:`../7月迭代技术方案-标准评审稿.md` +> 评审主文档:`../../7月迭代技术方案-标准评审稿.md` > 范围:全系统业务写操作、安全操作、外部集成和审计查询前端。 ## 一、目标 diff --git a/docs/7月迭代/新增需求/05-代理钱包扫码充值.md b/docs/7月迭代/独立方案/新增需求/05-代理钱包扫码充值.md similarity index 99% rename from docs/7月迭代/新增需求/05-代理钱包扫码充值.md rename to docs/7月迭代/独立方案/新增需求/05-代理钱包扫码充值.md index 37862be..23a46c4 100644 --- a/docs/7月迭代/新增需求/05-代理钱包扫码充值.md +++ b/docs/7月迭代/独立方案/新增需求/05-代理钱包扫码充值.md @@ -1,7 +1,7 @@ # 新增需求 05:代理钱包扫码充值 > 状态:已合并至标准评审稿,本文保留为实施明细。 -> 评审主文档:`../7月迭代技术方案-标准评审稿.md` +> 评审主文档:`../../7月迭代技术方案-标准评审稿.md` > 范围:代理在后台使用微信或支付宝扫码充值代理主钱包。 ## 一、已确认决策 diff --git a/docs/7月迭代/独立方案/新增需求/06-换货归属与资产换货标识.md b/docs/7月迭代/独立方案/新增需求/06-换货归属与资产换货标识.md new file mode 100644 index 0000000..56aa488 --- /dev/null +++ b/docs/7月迭代/独立方案/新增需求/06-换货归属与资产换货标识.md @@ -0,0 +1,72 @@ +# 禅道补充需求:换货归属与资产换货标识 + +> 来源:禅道 #98、#86。最终口径以标准评审稿为准。 + +## 一、现状 + +当前换货要求新旧资产已经属于同一店铺;归属不一致时直接拒绝。换货完成只更新资产状态,没有把旧资产店铺归属赋给平台库存中的新资产。 + +资产已有 `asset_status`、`generation`,换货单也保存新旧资产 ID,但资产详情没有统一返回前代和后代资产关系。 + +## 二、换货归属规则 + +- 新资产处于平台库存或已经属于旧资产店铺时可以换入。 +- 新资产已经属于其他店铺时拒绝换货,不能覆盖其他代理资产。 +- 换货完成事务内,新资产继承旧资产 `shop_id` 和租户标签,并写资产分配记录。 +- 旧资产保留原 `shop_id`,状态改为已换货,继续用于历史订单、退款和换货查询。 +- 前端不提供目标店铺选择;选择新资产后展示将继承的店铺名称。 + +处理顺序: + +```text +锁定换货单和新旧资产 + -> 校验新资产库存及归属 + -> 新资产继承旧资产店铺 + -> 迁移客户绑定、钱包和套餐资料 + -> 更新新旧资产状态 + -> 完成换货单 + -> 写资产分配记录和审计事件 +``` + +任一步失败整笔事务回滚。 + +## 三、资产详情换货链 + +卡和设备详情统一返回: + +```json +{ + "exchange_trace": { + "previous_asset": { + "asset_type": "iot_card", + "asset_id": 1001, + "identifier": "89860...001", + "exchange_no": "EXC202607170001" + }, + "next_asset": { + "asset_type": "iot_card", + "asset_id": 1003, + "identifier": "89860...003", + "exchange_no": "EXC202607180001" + } + } +} +``` + +- 有前代时显示“换货新资产”。 +- 有后代时显示“已换出旧资产”。 +- A→B→C 场景中,B 可以同时展示前代 A 和后代 C。 +- 关联资产必须再次经过后端数据权限校验;无权限时只返回标识,不返回可跳转 ID。 + +复用 `tb_exchange_order`,补充按旧资产查询已完成换货的方法,以及 `new_asset_type + new_asset_id + status` 查询索引,不新增关系表。 + +## 四、接口与前端 + +现有卡、设备详情接口增加 `exchange_trace`,不新增独立换货链接口。 + +前端在资产基础信息附近展示状态标签和关联资产链接。链接进入对应卡或设备详情,不直接跳换货单;换货单号作为辅助信息展示。 + +## 五、审计 + +记录旧资产、新资产、换货单、原店铺、新店铺、状态变更和操作人。新资产归属继承属于关键资产变更,必须与换货事务同时写 Audit Event。 + diff --git a/docs/7月迭代/独立方案/新增需求/07-店铺业务员与钱包余额预警.md b/docs/7月迭代/独立方案/新增需求/07-店铺业务员与钱包余额预警.md new file mode 100644 index 0000000..2c1f847 --- /dev/null +++ b/docs/7月迭代/独立方案/新增需求/07-店铺业务员与钱包余额预警.md @@ -0,0 +1,81 @@ +# 禅道补充需求:店铺业务员与钱包余额预警 + +> 来源:禅道 #96、#97。最终口径以标准评审稿为准。 + +## 一、边界 + +店铺业务员只表示平台内部的业务归属,用于列表筛选和通知接收。它不建立代理分销关系,不参与店铺上下级、佣金、提现或数据权限计算。 + +钱包预警阈值固定为 100 元,不做系统配置、角色配置或店铺配置。 + +## 二、店铺业务员 + +`tb_shop` 增加: + +```text +business_owner_account_id BIGINT NULL +``` + +- 只允许绑定启用的平台账号。 +- 创建、编辑店铺时可不选择。 +- 店铺列表支持按业务员筛选,列表和详情返回账号名称。 +- 业务员停用或删除后保留关联和历史审计,但不再作为通知接收人;运营可以重新绑定。 +- 不建立数据库外键,Application 显式校验账号类型和状态。 + +API 调整: + +```text +POST /api/admin/shops +PUT /api/admin/shops/{id} +GET /api/admin/shops?business_owner_account_id={id} +``` + +## 三、固定 100 元余额预警 + +固定常量: + +```go +const AgentWalletLowBalanceThreshold int64 = 10000 // 代理主钱包现金余额预警阈值:100元 +``` + +只计算代理主钱包现金可用余额: + +```text +cash_available = balance - frozen_balance +``` + +信用额度不参与预警。触发条件: + +```text +before_cash_available > 10000 +after_cash_available <= 10000 +``` + +接收人: + +- 店铺启用的主账号。 +- 店铺绑定且仍启用的平台业务员。 + +本期只发送站内通知,不发送企业微信消息。 + +## 四、防重复 + +- 首次跌至 100 元及以下时发送一次。 +- 持续低于阈值时,后续扣款不重复通知。 +- 余额充值到 100 元以上后重新布防,再次跌破可以重新发送。 +- 通知使用钱包 ID、预警轮次和接收人组成稳定业务键。 + +可以在主钱包保存 `low_balance_alert_active`,或者使用通知状态投影记录当前预警轮次;无论实现方式如何,资金事务不能因为通知失败而回滚,事件先写 Outbox。 + +## 五、前端 + +- 店铺创建、编辑增加可搜索的平台业务员下拉框。 +- 店铺列表和详情展示业务员,并支持筛选。 +- 资金概况在现金可用余额小于等于 100 元时显示红色“现金余额不足 100 元”。 +- 不展示阈值编辑入口。 +- 通知点击后受控跳转到对应店铺资金概况。 + +## 六、审计 + +记录业务员绑定变更、预警触发时的余额/冻结金额、接收人、通知事件 ID 和失败原因。信用额度不得写入余额预警判断结果。 + diff --git a/docs/7月迭代/独立方案/新增需求/08-代理系列套餐批量授权.md b/docs/7月迭代/独立方案/新增需求/08-代理系列套餐批量授权.md new file mode 100644 index 0000000..337f1df --- /dev/null +++ b/docs/7月迭代/独立方案/新增需求/08-代理系列套餐批量授权.md @@ -0,0 +1,81 @@ +# 禅道补充需求:代理系列套餐批量授权 + +> 来源:禅道 #43。最终口径以标准评审稿为准。 + +## 一、现状与范围 + +系统已经存在代理系列授权和套餐授权数据,后端 `PUT /api/admin/shop-series-grants/{id}/packages` 也已经接受套餐数组。本需求不是重建授权模型,而是补齐套餐候选 Query 和前端批量交互。 + +当前主要问题: + +- 前端只能逐个授权套餐。 +- 首次授权系列和后续管理套餐时,看不出哪些套餐已经授权给当前代理。 +- 公司成本价、代理授权成本价和建议售价缺少明确区分。 + +## 二、套餐候选 Query + +新增: + +```text +GET /api/admin/shop-series-grants/{id}/package-options +``` + +查询参数支持套餐名称、编码和授权状态。响应: + +```json +{ + "package_id": 1001, + "package_name": "移动月包", + "package_code": "CMCC-M01", + "company_cost_price": 5000, + "authorized_cost_price": 6500, + "suggested_retail_price": 8800, + "is_authorized": true, + "shelf_status": 1, + "status": 1 +} +``` + +金额单位统一为分。`authorized_cost_price` 在未授权时返回 `null`。 + +## 三、批量授权 + +继续使用现有批量写接口: + +```text +PUT /api/admin/shop-series-grants/{id}/packages +``` + +请求一次提交多个套餐: + +```json +{ + "packages": [ + {"package_id": 1001, "cost_price": 6500}, + {"package_id": 1002, "cost_price": 7000} + ] +} +``` + +- 同一请求内套餐 ID 必须去重。 +- 已经授权且价格相同的套餐按幂等成功处理。 +- 已经授权但价格不同的套餐不能被“新增授权”静默改价,应走明确的价格更新操作。 +- 套餐必须属于当前授权系列,后端不能只相信前端候选列表。 +- 批量写入在同一事务完成,任一套餐不合法则整批失败。 + +## 四、前端 + +首次创建系列授权和后续“管理套餐”共用同一个套餐选择组件: + +- 表格使用复选框多选。 +- 展示套餐名称、编码、公司成本价、当前授权成本价和建议售价。 +- 已授权套餐显示“已授权”标签并置灰,不可选择。 +- 支持只看未授权套餐和关键词搜索。 +- 提交前显示本次新增套餐数量和价格摘要。 + +已授权置灰只是交互反馈,后端仍负责幂等和归属校验。 + +## 五、权限与审计 + +公司成本价属于敏感字段,只有具备系列授权和成本价查看权限的角色可以读取。审计记录代理、系列、套餐列表、授权价格、操作人和变更前后数据。 +