Files
junhong_cmp_fiber/docs/7月迭代/7月迭代完整技术方案-评审稿.md
2026-07-16 15:08:07 +08:00

256 KiB
Raw Blame History

7月迭代完整技术方案单文件归档稿

状态:待评审
最后更新2026-07-14
代码分支:Iteration/7-11
适用系统:junhong_cmp_fiber 及配套后台、代理端、C 端前端
文档用途完整汇编归档正式评审优先使用《7月迭代技术方案-标准评审稿》


一、范围说明

7 月迭代原编号为需求 0122其中需求 16“代理分销码与佣金提现”已于 2026-07-14 整体移出本期,后续独立立项。本期实际实施 21 项需求。

需求 16 移出后本期不建设分销码、H5 代理申请、代理申请审批、自动开店、发展人关系和佣金提现材料;通用审批流只接入退款和平台员工线下充值。

二、已确认关键决策

  1. 采用触碰式渐进 DDD不一次性重构旧模块。
  2. 审批人只支持角色或指定账号,不引入部门模型。
  3. 审批详情固化业务快照,并分开展示业务资料、审批意见和审批附件。
  4. 平台员工不建立信用额度;信用额度只属于代理主钱包。
  5. 批量订购支付方式整批统一Excel 不包含支付方式。
  6. 设备批量分配代理与套餐系列拆成两个独立命令。
  7. Gateway 仅提供手动卡限速,统一按 cardNokbps,不做套餐自动限速。
  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 端支付方式的受控动态配置
各需求专项文档 业务规则、接口、数据改动和专项前端差异

三、系统上下文

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 的站内审批闭环。

四、依赖关系

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

前端源码不在当前仓库。接口评审后仍需在前端仓库完成路由、权限指令、上传组件、任务轮询组件和动态表格能力的实现映射,但这不再是业务方案决策项。


九、发布与回滚总策略

flowchart LR
    S[进入维护模式并停止写入] --> M[执行数据库迁移]
    M --> D[同时发布 API、Worker 和前端]
    D --> C[初始化流程定义和业务绑定]
    C --> L[回填存量待审批单]
    L --> V[人工验证核心链路]
    V --> O[开放系统访问]

发布原则:

  1. 发布前进入维护模式,停止创建订单、退款、充值和审批操作。
  2. 维护窗口内执行数据库迁移,并同时发布 API、Worker/Relay 和前端静态资源。
  3. 初始化并发布 refund_approvalrecharge_approval,完成业务绑定后再验证。
  4. 使用一次性 Application 命令为存量待审批退款和员工线下充值创建流程实例;历史终态记录保持只读,不伪造审批时间线。
  5. 旧退款业务单审批接口和线下充值 offline-pay/reject 在新版本中不再注册,不保留兼容门面。
  6. 人工验证发起审批、存量审批、任务处理、业务回调、通知和查询后再开放访问。
  7. 开放前验证失败可回滚应用版本和可逆迁移已经形成的审批历史、资金流水、Outbox 和审计日志不得通过清表回滚。
  8. Worker 暂停时保留 Outbox 事件,恢复后继续消费。

十、可观测性与人工验收

关键日志和查询维度:

  • request_idevent_idtask_idprocess_instance_idbiz_typebiz_id
  • Outbox 待投递数量、失败次数和最早积压时间。
  • 审批业务处理失败、批量任务失败和导出任务失败。
  • 钱包扣款前后余额、版本冲突和业务幂等键。

人工验收至少覆盖:

  • 正常路径、无权限、重复点击、并发审批、退回重提。
  • Worker 暂停后恢复,事件和任务可继续处理。
  • 维护模式下不能产生新写入;开放前确认 API、Worker 和前端版本一致。
  • 所有仍需审批的存量退款和员工线下充值均已生成流程实例,历史终态记录只读可查。
  • 旧退款审批和线下充值 offline-pay/reject 路由不可访问。
  • PostgreSQL 中业务状态、审批状态、任务状态、操作日志和资金流水一致。

十一、执行顺序建议

评审批次 ADDD 边界、系统配置、站内消息、审批流
评审批次 B退款、充值、信用额度、批量订购、导出权限
评审批次 CH5 流程、限速、临期提醒、批量分配
评审批次 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 读取模型:

Handler → Query → GORM/DTO
  • 列表、详情、联表、统计、报表和导出属于 Query。
  • Query 可以直接使用 GORM、CTE、子查询和批量查询并负责读取权限与分页。
  • Query 返回专用 DTO/Projection禁止返回后用于业务写入。
  • 只有查询结果参与写操作判定时Domain 必须重新校验,不能信任读取快照。
  • 当前需求未触碰的旧查询不迁移;复杂度明显增加时,只迁移该查询到 internal/query/<context>
  • Aggregate Repository 只负责加载和保存聚合,不承载多表列表、报表或导出查询。

三、目录结构

internal/
├── domain/                     ← 领域层(不依赖 Fiber/Redis/GORM
│   ├── wallet/
│   │   ├── wallet.go           ← 聚合根(含业务方法)
│   │   ├── events.go           ← 领域事件定义
│   │   ├── repository.go       ← 仓储接口interface
│   │   └── vo.go               ← 值对象Money, CreditLimit
│   ├── approval/               ← 新:审批流领域
│   ├── notification/           ← 新:通知领域
│   ├── distribution/           ← 后续预留需求16独立立项后再创建
│   └── package/                ← 套餐领域(迁移中)
│
├── application/                ← 应用层(用例,只做编排,不含业务判断)
│   ├── wallet/
│   │   ├── debit_wallet.go     ← 一个文件 = 一个用例
│   │   ├── credit_wallet.go
│   │   └── grant_credit.go     ← 新:授信
│   ├── approval/
│   │   ├── submit_approval.go
│   │   ├── advance_step.go
│   │   └── reject_approval.go
│   └── notification/
│       └── send_notification.go
│
├── query/                      ← 读取侧(可直接使用 GORM返回 DTO/Projection
│   ├── order/
│   ├── refund/
│   ├── approval/
│   ├── dashboard/
│   └── report/
│
├── infrastructure/             ← 基础设施层(实现 domain 里的 interface
│   ├── persistence/            ← GORM Repository可委托现有 Store
│   ├── messaging/              ← Outbox Relay、Asynq 发布与消费
│   └── adapter/                ← 外部系统和旧模块防腐层
│
├── handler/                    ← 原有 handler保持不动
├── service/                    ← 原有 service保持不动新模块不在这里
└── store/                      ← 原有 store保持不动

过渡期规则

  • 旧模块:internal/service/xxx + internal/store/postgres/xxx
  • 复杂写用例:internal/domain/xxx + internal/application/xxx
  • 简单写用例:internal/application/xxx
  • 读取用例:internal/query/xxx
  • Handler 按用例调用 Application、Query 或尚未迁移的旧 Service
  • 全新领域优先使用纯领域对象;迁移旧模块时可临时复用现有 GORM Model但不得让 Fiber、Redis 或 GORM 查询进入业务方法

四、领域模块划分

领域 聚合根 核心业务规则 状态
Asset资产 IotCard, Device 停复机条件、实名策略 迁移中
Order订单 Order 支付、退款、佣金触发 迁移中
Package套餐 Package, PackageUsage 生效条件、临期计算 迁移中
Wallet钱包 AgentWallet 余额扣减、信用额度、负余额 新功能用 DDD
Shop代理 Shop 层级关系、信用策略 部分迁移
Approval审批 ProcessDefinition, ProcessInstance 流程版本、审批人解析、状态推进、驳回 全新 DDD
Notification通知 Notification 分发、已读状态 全新 DDD
Distribution分销 DistributionRelation 发展人体系、二维码注册 后续独立立项,本期不创建

五、聚合根设计原则(富模型)

5.1 核心原则

业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。

// ❌ 贫血模型(禁止)——所有判断在 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 聚合根必须包含

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减少一次性重构成本

// 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靠值来判断相等不可变。

// 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。

// 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 }

事务边界

数据库事务
  ├── 保存聚合
  ├── 保存操作日志
  └── 保存 Outbox 事件
提交事务
  ↓
Outbox Relay → Asynq → 事件处理器

Application UseCase 保存事件

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 后续重试。
  • 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。

八、仓储接口

// 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。
// 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 拼装。
// 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

状态:待评审 被依赖:需求 09C端支付限制


一、设计目标

将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。

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: 读取最新配置并回填缓存

二、数据库

-- 迁移文件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

// 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

// 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

// 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 Keysys:config:{config_key}TTL 5分钟。

业务代码用法:

// 读取卡的允许支付方式
allowedMethods, _ := sysconfig.GetStringSlice(ctx, "c2b.payment.card_allowed_methods")
// 返回 ["alipay","wallet"]

六、API 设计

6.1 获取配置列表

GET /api/admin/system/config?module=c2b.payment

响应:

{
  "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}

请求体:

{
  "config_value": "[\"alipay\",\"wallet\",\"wechat\"]"
}

权限:仅平台超管可操作。

DTO

// 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
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 标记已读

二、数据库

-- 迁移文件YYYYMMDD_create_tb_notification.sql
CREATE TABLE tb_notification (
    id             BIGSERIAL PRIMARY KEY,
    event_id       VARCHAR(64) NOT NULL,          -- 领域事件ID或调用方请求ID用于幂等
    recipient_id   BIGINT NOT NULL,          -- 用户IDadmin user 或 agent user
    recipient_type VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | agent
    type           VARCHAR(50) NOT NULL,     -- 通知类型(见常量定义)
    title          VARCHAR(200) NOT NULL,    -- 标题
    body           TEXT NOT NULL DEFAULT '', -- 纯文本正文
    ref_type       VARCHAR(50),              -- 关联业务类型 approval | recharge | refund | iot_card | device
    ref_id         BIGINT,                   -- 关联业务ID
    is_read        BOOLEAN NOT NULL DEFAULT FALSE,
    read_at        TIMESTAMPTZ,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at     TIMESTAMPTZ               -- 过期自动不展示(可选,临期提醒用)
);

CREATE INDEX idx_notification_recipient
    ON tb_notification (recipient_id, recipient_type, is_read, created_at DESC);
CREATE INDEX idx_notification_created   ON tb_notification (created_at DESC);
CREATE UNIQUE INDEX uq_notification_event_recipient
    ON tb_notification (event_id, recipient_id, recipient_type);

COMMENT ON TABLE tb_notification IS '站内消息通知表';

三、常量定义

// 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

// 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

非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器,而由 TaskCreatedProcessApproved 等事件处理器转换为通知载荷。

领域事件通知使用领域事件自身的 event_id;非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。

// 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
}

审批事件处理器调用示例(节点激活后通知候选审批人):

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

// 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

响应:

{ "code": 0, "data": { "count": 5 } }

7.2 通知列表

GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20

请求参数:

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"`
}

响应:

{
  "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 过滤(只把某类型全部已读):

{ "type": "approval.pending" }

DTO

// internal/model/dto/notification_dto.go

type NotificationListRequest struct {
    IsRead   *bool  `query:"is_read"`
    Type     string `query:"type"`
    Page     int    `query:"page" default:"1"`
    PageSize int    `query:"page_size" default:"20"`
}

type NotificationItem struct {
    ID        uint       `json:"id"`
    Type      string     `json:"type" description:"通知类型"`
    Title     string     `json:"title"`
    Body      string     `json:"body"`
    RefType   *string    `json:"ref_type,omitempty"`
    RefID     *uint      `json:"ref_id,omitempty"`
    IsRead    bool       `json:"is_read"`
    ReadAt    *time.Time `json:"read_at,omitempty"`
    CreatedAt time.Time  `json:"created_at"`
}

type UnreadCountResponse struct {
    Count int64 `json:"count"`
}

type ReadAllRequest struct {
    Type string `json:"type" description:"通知类型(为空则全部已读)"`
}

八、前端对接

顶部导航栏铃铛

组件挂载 → 轮询 GET /api/admin/notifications/unread-count30秒一次
→ count > 0 时铃铛显示红点 + 数字
→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面
→ 打开时调 GET /api/admin/notifications?is_read=false
→ 点击某条通知 → PUT /api/admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页

跳转逻辑ref_type

ref_type 跳转页面
approval /approvals/instances/{ref_id} 审批详情
recharge /agent-recharges/{ref_id} 充值单详情
refund /refunds/{ref_id} 退款单详情
iot_card /iot-cards/{ref_id} IoT卡详情临期提醒
device /devices/{ref_id} 设备详情(临期提醒)

通知列表页(/notifications

筛选:通知类型(下拉)、已读状态 操作:全部已读按钮 列表字段:类型、标题、时间、已读状态 点击行:跳转关联业务详情

前端页面不可见时暂停未读数轮询,恢复可见时立即刷新。通知正文按纯文本渲染;未来需要富文本时使用受控模板和统一净化,禁止直接渲染业务方提交的 HTML。


九、Phase 2 扩展预留

当需要接入企微通知时,只需在 HandleNotification 里追加:

// 企微通知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移出本期代理申请不在第一版接入范围 架构DDDinternal/domain/approval/ + internal/application/approval/ 核心原则:流程结构、审批人和审批规则通过已发布的流程定义决定,禁止写死在业务代码中。


一、背景与目标

当前系统没有“部门”组织模型,只有账号、角色以及账号与角色的关联。因此审批引擎不能假设“提交人的部门领导”,也不能在代码中固定“第一步部门领导、第二步财务”。

审批流领域只认识以下概念:

  • 业务类型和业务单号
  • 流程定义及版本
  • 流程实例
  • 审批节点和流转
  • 审批任务和候选审批人
  • 业务快照和业务资料
  • 审批动作、状态和操作日志
  • 领域事件和业务回调

退款、充值等业务负责说明“什么业务需要审批”,审批领域负责说明“流程怎么走、谁可以审批、什么时候完成”。

整体运行视图

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 边界

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 聚合

负责流程定义的合法性和版本规则:

  • 草稿可以修改。
  • 发布时校验节点、连线、审批人配置和无环约束。
  • 已发布版本不可修改。
  • 修改流程必须基于旧版本创建新版本。
  • 停用只影响新流程发起,不影响已运行实例。
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 定义示例

{
  "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 审批人配置

第一版支持两种审批人来源:

// 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 业务绑定

业务类型通过绑定表选择流程定义,业务代码不得写死具体步骤:

refund   → refund_approval 的当前已发布版本
recharge → recharge_approval 的当前已发布版本

发起流程时解析当前已发布版本,并将完整 definition_json 保存到流程实例快照。

3.5 运行时节点推进

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 流程实例状态

1=审批中
2=已通过
3=已驳回
4=已退回

允许的状态转换:

stateDiagram-v2
    [*] --> Running: 发起成功
    Running --> Approved: 到达结束节点
    Running --> Rejected: 审批人驳回
    Running --> Returned: 审批人退回修改
    Approved --> [*]
    Rejected --> [*]
    Returned --> [*]

已通过、已驳回、已退回都是终态。退回后重新提交必须创建新的流程实例,旧实例永久保留。

4.2 审批任务状态

1=待审批
2=已通过
3=已驳回
4=已退回
5=已取消

任务只在节点激活时创建,不预先创建后续节点的“待审批”任务。

4.3 审批人处理状态

1=待处理
2=已通过
3=已驳回
4=已退回
5=已取消
  • 或签:任意审批人通过后,任务通过,其余待处理记录变为已取消。
  • 会签:全部审批人通过后,任务通过。
  • 驳回或退回:任务和流程立即进入对应终态,其余待处理记录变为已取消。
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 流程定义表

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 业务流程绑定表

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 流程实例表

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_accountshop_account。外部申请人属于需求16后续扩展本期不引入 external_applicant 语义。

5.4 审批任务表

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 任务审批人表

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 操作日志表

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 审批操作附件表

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_attachmentrequest_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 是通用基础设施表,不只服务审批流:

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 审批人解析器

// ApproverResolver 根据节点配置解析候选审批人
type ApproverResolver interface {
    Resolve(ctx context.Context, rule ApproverRule) ([]Approver, error)
}

Infrastructure 提供:

  • RoleApproverResolver
  • UserApproverResolver
  • ApproverResolverRegistry

注册表只是按 approver.type 选择策略,不承担流程推进逻辑。

6.2 业务审批处理器

// 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 审批动作业务扩展

少数业务需要在审批动作中确认业务字段,例如退款最终审批需要确认实际退款金额。审批引擎不解析这些字段,只提供受控扩展端口:

// 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_amountoperation_password。字段只在当前操作会完成该节点时返回。
  • 金额必须大于 0且不能超过申请退款金额和订单实收金额。
  • 会签节点的退款金额由最后一位完成会签的审批人确认;如需让指定人员确定金额,应在流程定义中增加其专属的最终决策节点,不能由第一位会签人预先锁定。
  • operation_password 由现有 OperationPasswordService 校验只用于本次请求不写业务表、操作日志、Outbox、错误日志或访问日志适配器返回的标准化审计数据必须排除敏感字段。

ProcessApproved 消费者只读取已经持久化的业务字段执行后续退款,不能在异步阶段重新接受审批人输入。

6.4 审批附件存储端口

// 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下载授权由审批实例查询用例单独处理。


七、领域事件与事务

核心事件:

ProcessStarted
TaskCreated
TaskApproved
TaskRejected
TaskReturned
ProcessApproved
ProcessRejected
ProcessReturned

任务结果事件只携带 operation_log_id 和必要的意见摘要,不携带对象存储签名 URL。附件 Key 保留在审批附件表;站内消息第一版只提示“包含附件”,用户进入有权限的审批详情后再获取下载 URL。

审批动作先执行事务外预检:

1. 按 request_id 查询既有操作日志;只有任务、操作人、动作和规范化请求摘要均一致时才返回原结果,否则返回冲突
2. 规范化并校验审批意见
3. 调用 ApprovalAttachmentStore 检查附件并复制到不可覆盖的审计前缀
4. 形成仅供本次请求使用的最终 Key 和附件元数据快照

对象存储检查不得在持有流程实例行锁的数据库事务中执行。预检通过后,审批数据库事务必须同时完成:

1. 锁定或按 version 加载流程实例
2. 校验 request_id 幂等及请求摘要
3. 再次校验任务仍属于当前账号且状态未变化
4. 调用业务动作适配器校验并保存扩展字段(如有)
5. 聚合执行状态转换
6. 更新任务和审批人状态,保存该审批人的意见
7. 创建下一节点任务和审批人快照(如需推进)
8. 使用预检快照写操作日志和审批附件记录,并回写审批人的 operation_log_id
9. 写 Outbox 事件
10. 提交事务

对象一旦写入审批附件记录即视为不可变引用,未引用文件清理任务不得删除它;如果预检后任务状态发生变化,数据库事务回滚,固化对象按未引用审计对象处理。

Outbox Relay 提交 Asynq 后,消费者负责:

  • 创建站内消息
  • 通知下一节点候选审批人
  • 通知有站内账号的申请人审批结果;外部申请人由业务投影提供结果查询
  • 调用退款、充值等业务处理器
  • 后续扩展企业微信通知

通知失败不能阻塞审批,但事件不能丢失。业务处理失败由 Asynq 重试并保留错误日志。

7.1 发起流程时序

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 审批与业务回调时序

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 乐观锁:

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 任务幂等

任务只能从待审批状态更新:

UPDATE tb_approval_task_assignee
SET status = ?, operated_at = NOW()
WHERE task_id = ?
  AND assignee_id = ?
  AND status = 1;

所有审批动作必须携带 request_id,并由操作日志唯一索引防止重复请求。


九、应用用例

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 发起流程

1. 根据 biz_type 查询启用的流程绑定
2. 查询 process_code 当前已发布版本
3. 校验业务单不存在运行中的审批实例
4. 由业务处理器构建业务快照,创建定义快照和流程实例
5. 从 start 节点找到首个 approval 节点
6. 解析角色或指定账号
7. 创建审批任务和审批人快照
8. 保存业务单、流程实例、任务、操作日志和 Outbox

业务单创建与流程创建必须共享同一个事务,禁止先创建业务单后忽略审批流创建失败。

9.2 审批通过

1. 根据 task_id 加载流程实例、任务和当前审批人记录
2. 校验任务属于当前账号且仍为待处理
3. 聚合执行 Approve
4. 或签/会签策略判断节点是否完成
5. 节点未完成:保存当前审批结果并等待其他人
6. 节点完成:根据定义快照激活下一节点
7. 到达 end流程变为已通过
8. 保存操作日志和 Outbox 事件

9.3 驳回和退回

  • 驳回:当前流程永久终止,业务进入已拒绝状态。
  • 退回:当前流程永久终止,业务进入可编辑状态。
  • 重新提交:业务创建新的审批实例,旧实例仅用于历史追溯。

十、API 设计

10.1 流程定义管理

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 运行时接口

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

审批动作请求:

{
  "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 分别校验:approvecomment 可为空,reject/returncomment 必填。后端必须统一去除首尾空白并限制最多 1000 字,不能只依赖前端校验。

附件下载请求使用附件 ID不接受客户端直接提交任意 file_key

{
  "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 只在当前账号拥有对应审批动作时返回:

{
  "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

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. 初始化并发布两个默认流程定义,绑定 refundrecharge
  4. 退款按业务单 ID 直接审批接口、POST /api/admin/agent-recharges/{id}/offline-payPOST /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 流程定义编辑器

第一版不做拖拽流程画布。前端使用有序节点列表:

开始
  ↓
[业务审核] 审批人=角色:运营主管 方式=或签
  ↓
[财务审核] 审批人=账号:张三/李四 方式=会签
  ↓
结束
  • 开始和结束节点固定且只读。
  • 审批节点支持新增、删除、上移、下移。
  • 角色选择器提交稳定的 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_idtask_idrequest_idoperation_log_idattachment_idevent_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。


二、系统上下文

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 天通知,前端不轮询或维护临期快照。

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。
  • 模板文件名包含版本号,下载入口与对应上传表单放在同一页面。
  • 后端仍必须严格校验表头和内容,不能因为模板由前端提供就信任文件结构。
  • 模板字段变更需要前后端同批发布,并保留对用户本地旧模板的可理解错误提示。

六、统一审批交互

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

// ManualStartCard - 当前写法(错误)
if card.RealNameStatus != constants.RealNameStatusVerified {
    return errors.New(errors.CodeForbidden, "卡未实名,无法操作")
}

isRealnameOK() 已经正确处理行业卡豁免:

// 第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

// 修改前第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

需求02H5 流程顺序配置化

状态:待评审


背景

H5 用户进入后:绑定手机号 → 充值 → 实名(当前顺序写死)

需求:允许按资产个体配置充值和实名的顺序,支持后台单条或批量改。


流程图

图一H5 登录后资产视角判断与策略读取

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 充值/购买前的策略拦截逻辑

flowchart TD
    A[用户发起充值/购买] --> B[读取 resolved.Asset.RealnamePolicy]
    B --> C{策略是 before_order?}
    C -->|否| D[放行,正常创建订单]
    C -->|是| E{当前资产 RealNameStatus == 1?}
    E -->|已实名| D
    E -->|未实名| F[返回 CodeNeedRealname\nH5 跳转实名页]

图三GetEffectiveRealnamePolicy 取值逻辑

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 字段,无需迁移:

// 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

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

请求体:

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

请求体:

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 方法)

// 现有过滤条件基础上追加
if req.ContactPhone != "" {
    query = query.Where("contact_phone = ?", req.ContactPhone)
}

DTO 变更internal/model/dto/shop_dto.goShopListRequest 新增:

ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话精确匹配11位"`

前端

店铺列表搜索栏新增"联系电话"输入框,填入后带入 contact_phone 参数请求。


需求07IoT卡/设备管理新增已实名/未实名筛选

IoT 卡

IotCard.real_name_status 已有0=未实名, 1=已实名),ListStandaloneIotCardRequest 无该过滤字段,需新增。

DTO 变更internal/model/dto/iot_card_dto.goListStandaloneIotCardRequest 新增):

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

if req.RealNameStatus != nil {
    query = query.Where("real_name_status = ?", *req.RealNameStatus)
}

设备

设备本身目前无 real_name_status 字段。语义为:任意一张绑定卡已实名 = 设备已实名。

为避免列表查询时走 EXISTS 子查询,改为快照方案:在 Device 表落盘,轮询时维护。

迁移

tb_device 新增字段:

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由轮询异步维护';

Modelinternal/model/device.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,在此同步更新设备快照:

// 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

// 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.goListDeviceRequest 新增):

RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"`

响应DeviceResponse 新增):

RealNameStatus     int    `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"`

Store 过滤(直接 WHERE无需 EXISTS

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

该接口的 AssetResolveResponseinternal/model/dto/asset_dto.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

// 修复前
return &resolvedExchangeAsset{..., Identifier: card.VirtualNo, ...}

// 修复后
return &resolvedExchangeAsset{..., Identifier: card.ICCID, ...}

历史数据不回填,仅修正后续新建换货单的快照行为。

EXC-003/EXC-004旧/新资产搜索支持 ICCID/接入号/虚拟号

方案:拆分为独立的旧资产和新资产搜索,搜索逻辑用两步查询,不用 JOIN。

DTO 变更internal/model/dto/exchange_dto.goExchangeListRequest

废弃原有 Identifier 字段,改为:

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再过滤换货表

// 步骤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_identifiernew_asset_identifier 均为 ICCID或设备号设备展示直接读这两个字段即可
  • EXC-003/004搜索栏拆分为"旧资产"和"新资产"两个独立输入框,分别传 old_asset_keywordnew_asset_keyword

需求13列表字段新增

核心原则

  • 提交人账号名在业务单创建时快照到业务表。
  • 审批节点、候选审批人和实际操作人快照统一保存在审批流任务表,不在业务表写死具体节点字段。
  • 列表查询审批信息时,根据本页全部 approval_instance_id 批量查询并在内存分组,禁止逐条查询造成 N+1。

COL-003换货管理列表新增提交人待建

需求文档原写"换号管理",确认为"换货管理"(系统无"换号"概念)。

迁移tb_exchange_order 新增字段:

ALTER TABLE tb_exchange_order ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';

Modelinternal/model/exchange_order.go

SubmitterName string `gorm:"column:submitter_name;type:varchar(50);not null;default:'';comment:提交人账号名快照" json:"submitter_name"`

创建换货单时internal/service/exchange/service.go)快照当前操作人 username

SubmitterName: middleware.GetUsername(ctx), // 从 ctx 取当前登录账号的 username

响应 DTOinternal/model/dto/exchange_dto.goExchangeOrderResponse 新增):

SubmitterName string `json:"submitter_name" description:"提交人账号名"`

COL-001退款管理列表新增提交人、审批人依赖审批流

迁移tb_refund_request 新增提交人快照字段;approval_instance_id 由需求18/20统一增加

ALTER TABLE tb_refund_request
    ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
  • submitter_name:创建退款单时快照操作人 username
  • 审批状态、当前节点和审批记录:从审批实例、任务和任务审批人快照批量读取

实施依赖动态审批摘要依赖审批流需求20submitter_name 可独立实现。

响应 DTO(退款列表响应新增):

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_nameapproval_modestatus 和审批人列表;每位已操作审批人包含动作、审批意见和 attachment_count,但列表接口不返回完整附件元数据。不假设固定存在“部门领导”或“财务”节点。

停机发布前已经结束且没有流程实例的退款记录返回 approval_source=legacy。这类记录可以使用原 processor_idprocessed_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 *uintIoT卡ID卡类资产
  • device_id *uint设备ID设备类资产

状态常量(internal/model/refund.go

RefundStatusPending  = 1 // 待审批
RefundStatusApproved = 2 // 已通过
RefundStatusRejected = 3 // 已拒绝
RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货)

实现位置

换货单创建入口:internal/service/exchange/service.go 创建前校验。

后端

Store 新增方法internal/store/postgres/refund_store.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 创建换货单前调用):

// 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

响应 DTOAssetResolveResponseinternal/model/dto/asset_dto.go

该 DTO 目前不含 last_package_expires_at 字段,需新增。

后端

DTO 新增字段internal/model/dto/asset_dto.goAssetResolveResponse

// 当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为 null
LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"当前主套餐及排队主套餐接续后的预计最后到期时间无可推算套餐时为null"`

Store 新增方法internal/store/postgres/package_usage_store.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_snapshotduration_months_snapshotduration_days_snapshot与需求05的 expiry_base_snapshot 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容。

Service 计算逻辑(在 ResolveAsset 结果组装处添加):

// 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),在套餐创建时设定,控制套餐何时开始计时。

需求:分配套餐给代理时,可以对单条分配记录二次覆盖这个值。


快照链设计

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 新增覆盖字段

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 新增快照字段

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 变更

ShopPackageAllocationinternal/model/shop_package_allocation.go

// ExpiryBaseOverride 生效条件覆盖
// NULL = 使用宿主套餐的 ExpiryBase有值 = 分配时指定,不受套餐后续修改影响
ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);comment:生效条件覆盖 NULL=使用套餐默认 from_activation=实名即生效 from_purchase=购买即生效" json:"expiry_base_override"`

PackageUsageinternal/model/package.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 查询分配记录(现有逻辑),在此基础上追加:

// 取生效条件有效值:分配覆盖 > 套餐默认
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

// 新订单只读购买快照;旧记录兼容回退套餐当前值。
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 新增字段:

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

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_idseries_id

Device 表已有 shop_id *uintseries_id *uint 字段,分配即更新这两个字段。

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,业务语义不同):

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_typeassign_shop(分配代理)或 assign_series(分配套餐系列)。target_id 随操作类型分别指向代理店铺或套餐系列;不建立数据库外键。

失败明细存 JSONBfailed_items),失败条数有限,无需单独明细表。


Modelinternal/model/device_batch_allocation_task.go

// DeviceBatchAllocationTask 设备批量分配任务模型
type DeviceBatchAllocationTask struct {
    gorm.Model
    BaseModel    `gorm:"embedded"`
    TaskNo       string            `gorm:"column:task_no;type:varchar(30);uniqueIndex:idx_device_batch_allocation_task_no,where:deleted_at IS NULL;not null" json:"task_no"`
    SourceFileKey string            `gorm:"column:source_file_key;type:varchar(500);not null;comment:待处理Excel对象存储Key" json:"source_file_key"`
    OperationType string            `gorm:"column:operation_type;type:varchar(30);not null;comment:操作类型 assign_shop=分配代理 assign_series=分配套餐系列" json:"operation_type"`
    TargetID     uint              `gorm:"column:target_id;not null;comment:操作目标ID代理店铺或套餐系列" json:"target_id"`
    OperatorID   uint              `gorm:"column:operator_id;not null;comment:操作人ID" json:"operator_id"`
    TotalCount   int               `gorm:"column:total_count;default:0;comment:总记录数" json:"total_count"`
    SuccessCount int               `gorm:"column:success_count;default:0;comment:成功数" json:"success_count"`
    FailCount    int               `gorm:"column:fail_count;default:0;comment:失败数" json:"fail_count"`
    Status       int               `gorm:"column:status;type:int;default:1;not null;comment:状态 1=处理中 2=已完成 3=失败" json:"status"`
    FailedItems  ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"`
    StartedAt    *time.Time        `gorm:"column:started_at" json:"started_at"`
    CompletedAt  *time.Time        `gorm:"column:completed_at" json:"completed_at"`
}

func (DeviceBatchAllocationTask) TableName() string {
    return "tb_device_batch_allocation_task"
}

ImportResultItems 复用 internal/model/device_import_task.go 中已定义的类型。


API 设计

1. 前端静态 Excel 模板

模板由前端项目作为静态资源随版本发布,后端不提供模板下载接口。

  • 文件建议命名:设备批量分配模板-v1.xlsx
  • 表头:设备号(一列)
  • 前端“下载模板”按钮直接下载静态文件。
  • 后端只校验上传文件的表头、行数和内容,不读取或生成前端模板文件。

模板字段发生变化时,前后端必须在同一发布批次升级;旧模板仍可能被用户保存在本地,因此后端错误需要明确指出缺失或未知表头。

2. 上传并提交

POST /api/admin/devices/batch-assign-shop
Content-Type: multipart/form-data

字段:
  shop_id:  123   (必填,分配给哪个代理)
  file: <Excel文件>
POST /api/admin/devices/batch-assign-series
Content-Type: multipart/form-data

字段:
  series_id: 45   (必填,分配给哪个套餐系列)
  file: <Excel文件>

两个接口内部创建同一类任务表记录,但分别写入 operation_type=assign_shop|assign_series。请求中不接受另一个目标字段,避免前端通过隐藏参数把两个业务动作合并。

处理逻辑Asynq 异步)

  1. API 将上传文件保存到对象存储,把 source_file_key 写入任务Worker 从对象存储下载并解析 Excel
  2. 对设备号去重后分批查询 tb_device(按 virtual_noimei),禁止逐行查询
  3. assign_shop 仅按状态/归属条件批量更新 shop_idassign_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}

响应:

{
  "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-shopPOST /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

需求09C端支付方式限制配置化

状态:待评审 依赖:系统配置


背景

代码已经写好但被注释,注释原因是微信支付参数未申请下来,临时注释。

注释位置:

  • internal/handler/app/client_wallet.go(钱包充值入口)
  • internal/service/client_order/service.go(订单支付入口)

两处均有注释:// 第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)


业务规则

资产类型 允许的第三方支付 禁止的第三方支付
IoT 卡 支付宝、钱包 微信
设备 微信、钱包 支付宝

钱包支付对所有资产类型均允许。

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. 系统配置初始化(已在系统配置文档中定义)

// 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

// 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. 错误信息

用户端错误提示(友好文案):

// 根据资产类型给出具体提示
switch assetType {
case model.AssetTypeIotCard:
    return errors.New(errors.CodeForbidden, "卡资产仅支持支付宝或余额支付")
case model.AssetTypeDevice:
    return errors.New(errors.CodeForbidden, "设备仅支持微信或余额支付")
}

前端对接

C端支付页面

前端不维护另一份固定规则。资产初始化/详情接口返回后端已经计算好的:

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

需求10Gateway 手动卡限速

状态:待评审 已确认边界:本期只提供手动设置/取消限速Gateway 最终对象始终是卡,统一使用 cardNo


一、范围

本期提供一个统一的后台能力:对一张实际联网卡设置或取消限速。

  • 单卡资产:使用 IotCard.ICCID 作为 cardNo
  • 设备资产:查询 tb_device_sim_binding.is_current=true 的当前绑定卡,再使用该卡 ICCID。
  • speed_kbps > 0 表示设置限速;speed_kbps = 0 表示取消限速。
  • 内部业务单位固定为 kbps,前端也按 kbps 输入和展示。

本期不做:

  • 套餐限速字段或套餐编辑页限速配置。
  • 套餐激活、到期、续费、停机、切卡后的自动限速或自动取消。
  • 设备 IMEI 限速。
  • “Gateway 当前实际限速”查询展示。上游未提供查询接口时,页面只能展示最近一次本系统操作记录。

取消限速仍调用同一个 Gateway 方法。speed_kbps=0 是本系统语义Gateway 所需的取消报文仅在 Infrastructure 适配器中转换,不散落在 Handler、Service 或前端。


二、流程

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。


三、应用端口与数据

// 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 的幂等重试:

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 是“设为目标状态”的幂等操作。


四、接口与前端

POST /api/admin/assets/{identifier}/speed-limit
{
  "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.goNewDefaultRegistry() 统一注册所有场景
  • Asynq Worker 根据任务里的 scene 字段,从 Registry 取对应 DataSource 执行
  • 前端轮询任务状态后下载
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.goNewDefaultRegistry() 中注册,并更新 IsSupportedScene()
  4. 扩展统一 CreateExportTaskRequest.Scene 的允许值,通过 POST /api/admin/export-tasks 创建任务
  5. 在字段目录中注册稳定字段 key、中文表头、默认选择和所需导出权限

统一创建请求扩展为:

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_transactionpackagerefundexchangeagent_rechargeexpiring_asset。DTO description 和 pkg/constants/ 必须同步维护。


EXPD-001~003IoT卡导出字段新增

修改文件internal/exporter/iot_card_scene.go

Headers() 末尾追加三列,Fetch()Select 追加字段,iotCardExportRow 追加字段:

// 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_accountcreator 字段关联 tb_account.id,取 username
交易 ID id
关联业务订单号 reference_id 对应的单号JOIN 对应表)
交易渠道/支付方式 metadatapayment_method 字段

6.8.3:套餐列表导出

新增场景scene=package

支持现有套餐列表筛选条件。

导出字段(按实际列举的 25 个稳定字段 Key

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

导出字段(去掉“退款到账方式”,审批信息使用动态摘要,不固定具体节点):

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

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

去掉"支付通道"字段(需求文档中明确去掉),保留其他字段:

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 + fieldsPOST /api/admin/export-tasks
  4. 返回 task_id 后,前端轮询 GET /api/admin/export-tasks/{id} 直到终态。
  5. status=3 时下载 download_url;失败时展示任务错误摘要,禁止自动重复创建任务。

(与现有导出体系完全一致,复用现有前端导出 Hook


导出字段权限

需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。

评审结论:本迭代必须实现角色级导出字段配置,不作为后续预留。

该要求涉及真流量、成本价、身份材料等敏感数据,不能以“本期先全量导出”代替。字段权限必须由后端强制执行,前端复选框只负责展示。

1. 字段目录

每个场景在代码中注册稳定字段 key

// ExportFieldDefinition 导出字段定义
type ExportFieldDefinition struct {
    Key             string
    Header          string
    DefaultSelected bool
    Required        bool
}

示例:

scene=package
package_code       套餐编码       默认选择
package_name       套餐名称       默认选择
real_data_mb       真流量额度     敏感字段
virtual_data_mb    虚流量额度     默认选择
cost_price         成本价         敏感字段

表头中文可以调整,但 scene + field_key 一经发布不得随意改名,否则历史角色配置会失效。

2. 角色字段授权表

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

GET /api/admin/export-fields?scene=package

返回当前账号可选择的字段:

{
  "scene": "package",
  "fields": [
    {
      "key": "package_code",
      "header": "套餐编码",
      "default_selected": true,
      "required": true
    }
  ]
}

角色管理:

GET /api/admin/roles/{role_id}/export-fields
PUT /api/admin/roles/{role_id}/export-fields

更新请求按场景提交字段 key 列表,后端校验字段存在并记录审计日志。

4. 创建任务时的权限快照

创建任务时计算:

resolved_fields = requested_fields ∩ role_allowed_fields ∩ scene_supported_fields
  • 必选字段由后端自动补齐。
  • 交集为空时拒绝创建任务。
  • resolved_fields 和对应 resolved_headers 写入任务的 query_jsonWorker 不重新根据后来变化的角色权限扩大字段。
  • 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)不可被新购
  • 下架套餐可以续费(已在使用该套餐的客户)
  • 续费仅支持客户自己购买(不允许代理代购下架套餐给新客户)
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

// 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
}

续费资格判断:查当前资产是否有该套餐的有效历史使用记录,并确认当前登录主体就是资产所有人。已失效、已退款或仅创建未生效的记录不能作为续费资格:

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_codetb_agent_application,不修改 tb_shop 发展人字段和提现材料字段,不注册代理申请相关 API不发布 agent_application_approval,也不建设对应前端页面。

通用审批流本期只接入退款和平台员工线下充值。需求 16 后续重新立项时,可以复用本期审批流、站内消息、对象存储和 Outbox 能力但必须重新评审其数据模型、H5 安全、开店幂等、提现材料及工时。


需求18多人审批APR-001~009

依赖

基于 审批流基础设施 和 站内消息。

APR-009企微审批对接= Phase 2。

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

默认流程与可配置边界

  • 本迭代可以预置“业务审核 → 财务审核”两个节点,但这只是初始流程定义,不是引擎固定规则。
  • 每个节点可配置 roleuser 审批人来源,以及 anyall 完成方式。
  • 节点可配置是否要求操作密码;仅触发该节点完成的审批人输入,不能按“财务节点”等名称写死。
  • 角色审批在节点激活时解析当前启用账号,并将候选审批人快照到任务审批人表。
  • 流程定义发布后不可修改;调整节点或审批人配置时创建新版本。
  • 已发起实例始终使用发起时保存的流程定义快照。

业务表与审批流的关联

充值单和退款单接入审批流,各自新增 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

支付方式粒度

评审结论:支付方式按整批统一设计:

  • 页面选择 offlineagent_walletExcel 不再重复填写支付方式。
  • 混合支付拆成两个批次,避免一份凭证对应多种支付语义。
  • Excel 不包含支付方式列,后端拒绝同一批次混合支付。

业务流程

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。

数据库变更

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 模板

模板由前端项目随版本发布,后端不提供下载接口。按已确认的“整批统一支付方式”,模板字段为:

资产类型 | 资产标识 | 套餐编码 | 套餐名称

套餐名称用于人工核对,实际匹配以稳定的 套餐编码 为准。后端必须校验表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。

上传并提交

POST /api/admin/bulk-purchases
Content-Type: multipart/form-data

shop_id: 123
payment_method: offline | agent_wallet
voucher_keys: ["key1","key2"]
file: <Excel文件>

offlinevoucher_keys 必填,agent_wallet 时忽略该字段。

查询任务状态

GET /api/admin/bulk-purchases/{task_id}

分页查询任务明细

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 关联。审批通过后,代理钱包退款自动回退到原扣款代理主钱包;微信、支付宝和线下退款由财务人工完成。业务表增加独立处理状态:

processing_status0=待处理 1=处理中 2=已完成 3=处理失败

status=2 表示审批结论已通过,processing_status 表示实际退款动作是否完成。接口和前端必须同时展示两者。

数据库变更

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且不超过申请退款金额和订单实收金额并写入退款单和审批操作日志。会签需要指定金额决策人时流程定义增加其专属最终节点禁止第一位会签人预先锁定金额。

流程

sequenceDiagram
    actor Applicant as 提交人
    actor Approver as 审批人
    participant Refund as Refund Application
    participant Approval as Approval Application
    participant DB as PostgreSQL
    participant Worker as AgentWalletRefundHandler
    actor Finance as 财务人员

    Applicant->>Refund: POST /api/admin/refunds
    Refund->>DB: 同事务创建退款单(status=1)
    Refund->>Approval: StartProcess(refund, refund_id)
    Approval->>DB: 创建实例、首任务、审批人、Outbox
    Refund->>DB: 回写 approval_instance_id
    Approver->>Approval: 按 task_id 审批,决策节点可提交实际退款金额
    Approval->>DB: 提交 ProcessApproved/Rejected/Returned
    alt 代理钱包支付且审批通过
        DB-->>Worker: Outbox + Asynq 至少一次投递
        Worker->>DB: claim processing_status=1status=2
        Worker->>DB: 幂等回退原扣款代理主钱包并写资金流水
        Worker->>DB: processing_status=2
    else 非代理钱包支付且审批通过
        Refund->>DB: status=2, processing_status=0待人工退款
        Finance->>Refund: POST manual-complete
        Refund->>DB: 条件更新处理状态并记录确认信息
    else 审批拒绝
        Worker->>DB: status 从 1 更新为 3
    else 退回修改
        Worker->>DB: status 从 1 更新为 4
    end

AgentWalletRefundHandler 仅处理代理钱包订单,使用 refund:{refund_id} 作为业务幂等键,并读取审批事务已经持久化的 approved_refund_amount。它必须按原扣款资金流水定位原代理主钱包,余额、版本、钱包退款流水和处理状态在同一事务更新。处理失败时单独更新 processing_status=3 和错误摘要后返回可重试错误;不得回滚已经完成的审批实例,也不得重复回退。

处理器通过条件更新领取任务:processing_status IN (0,3),或状态为处理中但 processing_started_at 已超过约定租约。重复消费者看到未过期的处理中状态时不重复执行;进程在副作用完成后崩溃时,下一次重试依靠业务幂等键恢复并补写成功状态。

本期不调用第三方退款 API也不增加商户退款号、渠道退款号或渠道结果字段。个人/客户资产钱包退款不属于本期自动回退范围,现有对应分支必须在实施时隔离或拒绝进入本流程。

人工退款确认接口:

POST /api/admin/refunds/{id}/manual-complete

请求包含 request_id、可选 remark 和最多 5 个完成凭证。后端仅允许具备财务确认权限的账号对 status=2 AND processing_status IN (0,3) 的非代理钱包退款操作;实际金额沿用审批金额,不允许在确认时再次改价。确认记录操作人、时间、备注和凭证后将处理状态置为已完成。

退回后重新提交

POST /api/admin/refunds/{id}/resubmit
  → 校验 status=4
  → 请求体可修改 actual_received_amount、requested_refund_amount、refund_voucher_key、refund_reason
  → 在同一事务新建 ProcessInstance
  → 更新 approval_instance_idstatus 回到 1待审批
  → processing_status 重置为 0清空本次处理错误和人工确认记录
  → 旧审批实例保留为历史记录

复用当前真实路由 POST /api/admin/refunds/{id}/resubmit,不新增单独 PUT。重提命令沿用现有 ResubmitRefundRequest 字段范围;禁止修改订单 ID、资产快照、提交人或已形成的历史审批记录。

API 响应与前端

退款列表和详情增加:

{
  "approval_instance_id": 1001,
  "approval_source": "workflow",
  "approval_status": 2,
  "approval_status_name": "已通过",
  "current_node_name": "",
  "processing_status": 0,
  "processing_status_name": "待人工退款",
  "processing_error": ""
}

前端展示规则:

审批状态 处理状态 展示
审批中 待处理 待审批 + 当前节点
已通过 + 代理钱包 处理中 审批已通过,代理钱包回退处理中
已通过 + 非代理钱包 待处理 审批已通过,待人工退款;财务可确认完成
已通过 已完成 退款已完成
已通过 + 代理钱包 处理失败 系统重试中;管理员可查看错误摘要
已拒绝 待处理 已拒绝 + 原因
已退回 待处理 已退回,可编辑并重新提交

列表页不直接放固定审批按钮。点击进入详情后,根据审批接口返回的 available_actions 渲染通过、驳回和退回操作。

决策节点根据 action_form 展示“实际退款金额”输入,默认等于申请金额。审批通过后的非代理钱包退款展示“确认人工退款”入口,仅具备财务确认权限时显示;完成凭证与审批附件分区展示。前端只负责元/分转换和基础格式校验,金额上限以后端在审批事务中的校验为准。

停机发布后,现有按退款业务单 ID 直接通过、驳回或退回的路由不再注册;所有退款审批动作统一操作 task_id,避免绕过审批人快照、并发控制和操作日志。

维护窗口内需要为 status=1 AND approval_instance_id IS NULL 的存量退款单执行幂等回填,从 refund_approval 首节点创建流程实例和任务;历史终态退款不伪造流程实例。


需求21充值审核流程

充值单现有状态

tb_agent_recharge_record1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款

代码中已经存在 6=已驳回,不能改写其含义。员工线下充值走审批流时,在现有状态基础上追加 7=已退回

-- 现有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 已收口为“待处理”,两者不要共用中文状态名称常量。

流程

代理自行充值(不走审批)

flowchart LR
    A[代理提交充值申请] --> B[系统生成收款码]
    B --> C[代理扫码支付]
    C --> D[支付回调幂等入账]

员工线下代充值(走审批)

现有 offline-pay 的全局操作密码校验必须保留。通用审批详情在最后一个审批节点返回 operation_password 动作字段;审批动作适配器调用现有 OperationPasswordService 校验通过后才允许流程完成。密码只在内存中参与本次校验,不落库、不写审批日志、不进入 Outbox。

sequenceDiagram
    actor Staff as 平台员工
    actor Approver as 审批人
    participant Recharge as Recharge Application
    participant Approval as Approval Application
    participant DB as PostgreSQL
    participant Worker as RechargeApprovalHandler

    Staff->>Recharge: POST /api/admin/agent-recharges(payment_method=offline)
    Recharge->>DB: 同事务创建充值单(status=1)
    Recharge->>Approval: StartProcess(recharge, recharge_id)
    Approval->>DB: 创建实例、首任务、审批人、Outbox
    Recharge->>DB: 回写 approval_instance_id
    Approver->>Approval: 按 task_id 审批
    DB-->>Worker: 投递流程结果事件
    alt 审批通过
        Worker->>DB: status 从 1 更新为 2processing_status=1
        Worker->>DB: 幂等增加钱包余额并写流水
        Worker->>DB: status 从 2 更新为 3processing_status=2
    else 审批拒绝
        Worker->>DB: status 从 1 更新为 6写 rejection_reason
    else 退回修改
        Worker->>DB: status 从 1 更新为 7写 return_reason
    end

充值接口独立返回审批状态和业务处理状态。RechargeApprovalHandler 使用 recharge:{recharge_no} 作为幂等键;钱包余额、版本、充值单和交易流水必须在同一事务更新。

充值处理同样使用 processing_started_at 作为可恢复租约。重复事件不能再次增加余额;若钱包流水已经存在而充值单状态未完成,重试只补齐充值单状态。

退回后重新提交

POST /api/admin/agent-recharges/{id}/resubmit
  → 校验 status=7
  → 请求体可修改 amount、payment_voucher_key、remark
  → 在同一事务新建 ProcessInstance
  → 更新 approval_instance_idstatus 回到 1待支付/待审批)
  → processing_status 重置为 0清空处理错误和 return_reason
  → 旧审批实例保留为历史记录

新增 resubmit 路由时沿用现有 /api/admin/agent-recharges 资源名,不另建 /agent-recharge-records 路径。店铺、支付方式和提交人不可修改;编辑与新流程创建必须同事务完成。

停机切换

现有 POST /api/admin/agent-recharges/{id}/offline-payPOST /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)
  • 扣款、冻结、解冻、充值和调额都必须维护同一钱包不变量。
  • 存在欠款或冻结金额导致可用金额不足时,禁止降低额度或关闭信用。
  • 金额统一使用分,禁止浮点数入库。
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_creditcredit_limit
  • 关闭开关时额度必须为 0。
  • 打开开关时额度必须大于 0。
  • 修改额度前必须校验修改后的可用金额不为负,而不是只判断 balance >= 0

三、数据库变更

信用额度属于钱包,不在 tb_shoptb_agent_wallet 各保存一份,避免双写失真。创建/编辑代理接口可以接收信用参数,但最终权威数据写入代理主钱包。

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 不能替代迁移前核对。历史钱包默认关闭信用,行为不变。


四、领域模型

// 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 修改代理信用额度

Handler
  → ChangeShopCreditUseCase
      → 校验操作人和代理数据权限
      → 加载代理主钱包
      → 调用 wallet.ChangeCredit()
      → 按 version 条件更新钱包
      → 写操作日志和信用变更流水

条件更新示例:

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 钱包扣款

所有现有主钱包扣款语句必须从:

balance - frozen_balance >= amount

统一改为:

balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount

扣款、版本递增和钱包流水必须在同一事务。禁止只修改 GetAvailableBalance() 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。

5.3 受影响用例

  • 后台订单和批量订购的代理钱包支付。
  • C端/代理端使用代理主钱包的订单支付。
  • 钱包冻结与解冻。
  • 退款回充和员工线下代充值。
  • 资金概况、钱包详情和导出 Query。

实施时只迁移这些被信用额度触碰的完整资金用例,不主动改造佣金钱包和资产钱包。


六、查询方案

资金概况、钱包详情、列表和导出使用 Query 直接读取:

balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END AS available_balance

响应统一增加:

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 创建代理

现有:

POST /api/admin/shops

新增参数:

EnableCredit bool  `json:"enable_credit" description:"是否开启信用额度"`
CreditLimit  int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"`

Application 创建店铺和主钱包时,将信用配置写入钱包;任何一步失败整笔事务回滚。

7.2 修改信用额度

PUT /api/admin/shops/{id}/credit-limit
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 创建/编辑代理

信用额度
[开关] 允许使用信用额度
授信上限 [金额输入,单位元]
  • 开关关闭时清空输入并提交 credit_limit=0
  • 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。
  • 编辑代理基础资料不自动覆盖信用额度;信用调整使用独立权限和独立接口。

8.2 资金概况和详情

账面余额:-¥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 自然日计算的剩余天数在 015 天时,该资产进入临期状态。

资产有可接续的排队主套餐时不进入临期提醒因为服务不会在当前套餐到期后中断需求06仍会单独展示排队套餐接续后的“预计最后到期时间”。已过期资产不属于临期。

查询与通知职责

  • 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。
  • 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。
  • tb_expiry_push_record 仅用于防止同一资产、接收人、渠道、阈值重复通知。

颜色规则Version 2

剩余天数 颜色
≤ 15天 粉红色
≤ 7天 紫色
≤ 3天 黄色

各端提醒规则

场景 提醒方式 触发节点
后台管理 列表加临期天数列 + 高亮详情加临期字段≤15天高亮 实时计算
企业客户 企微推送Phase 2后台每日生成临期列表 15天/7天/3天节点
代理端 首页展示临期卡/设备数量;列表高亮 实时计算
C端公众号 套餐到期提醒模块≤15天展示 实时展示
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 记录已发送或已创建的通知:

-- 临期推送记录(防重)
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

响应新增字段:

type IotCardListItem struct {
    // ...原有字段...
    DaysUntilExpiry *int  `json:"days_until_expiry" description:"当前套餐剩余天数无生效套餐为null"`
    IsExpiring      bool  `json:"is_expiring" description:"是否临期≤15天且无排队套餐"`
}

计算方式(在 SQL 层计算,避免 N+1

-- 列表查询时 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。设备查询复用同一规则,不能另写一套日期计算。

资产列表筛选条件新增
type IotCardListRequest struct {
    // ...原有字段...
    ExpiringWithinDays *int `query:"expiring_within_days" description:"临期筛选(值=15表示查剩余≤15天"`
}
资产详情接口

后台资产详情走 GET /api/admin/assets/resolve/:identifier,响应 DTO 为 AssetResolveResponseinternal/model/dto/asset_dto.go)。

新增字段:

// AssetResolveResponse 追加
DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数无生效套餐或有排队套餐时为null"`
IsExpiring      bool `json:"is_expiring" description:"是否临期≤15天且无排队套餐"`

2. 新增临期列表接口(独立页面)

GET /api/admin/expiring-assets

查询参数:

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"`
}

响应:

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. 每日定时任务(仅通知)

// 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
}

定时任务每日执行一次即可,页面不参与轮询。漏跑恢复后,对每个当前仍在 015 天范围内的资产,仅补发一个“当前最近且尚未发送”的阈值:例如第 15 天漏跑、剩余 14 天时补发 15 天通知;剩余 6 天时补发 7 天通知,不补发多条过期阈值。


导出功能(临期列表)

使用统一导出任务:

POST /api/admin/export-tasks
scene=expiring_asset

导出字段:

字段 说明
资产标识 ICCID/设备号
资产类型 卡/设备
店铺名称
套餐名称
套餐到期时间
剩余天数
资产状态
已用流量(MB)
剩余流量(MB)

C端接口变更

公众号首页

现有接口(GET /api/c/v1/asset/info,通过 query param 传资产标识)的响应 DTO AssetInfoResponseinternal/model/dto/client_asset_dto.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`

代理端首页

在首页数据接口新增字段(需要确认代理端首页接口):

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;不做套餐字段和自动限速规则。

  1. 资产信息详情字段新增:资产信息页面卡信息/设备信息板块将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。
  2. 换货管理:
    编号 需求
    EXC-001 修正换货列表旧资产标识和新资产标识显示混乱问题。
    EXC-002 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。
    EXC-003 旧资产查询支持 ICCID、接入号、虚拟号。
    EXC-004 新资产查询支持 ICCID、接入号、虚拟号。
    13.列表字段新增:
    编号 模块
    ------- ------------
    COL-001 退款管理列表
    COL-002 代理充值列表
    COL-003 换号管理列表
  3. 导出功能:

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. 套餐相关: 套餐下架后,正在使用该套餐的客户仍可续费。,下架套餐续费仅支持客户自己购买。 ,下架套餐不可被新购买。
  2. 代理分销码与佣金提现

迭代范围变更2026-07-14需求 16 整体移出 7 月迭代,后续独立立项和评审。以下内容仅保留原始需求记录,本期不开发、不迁移、不发布。

员工可作为代理发展人进行标识。(在系统中如何体现?)

编号 需求
DST-001 新建代理时自动建立分销归属关系。
DST-002 员工可作为代理发展人进行标识。(在系统中如何体现?)
DST-003 佣金提现前,代理必须签署合同。(必填)
DST-004 佣金提现需上传营业执照。(可选)
DST-005 佣金提现需上传法人身份证。(必填)
DST-006 佣金提现可选上传门头照。(可选)
DST-007 佣金提现需上传发票,且公司主体需与合同一致。(可选)

当前结论:原技术方案不再属于 7 月迭代范围。后续重新立项时再评审分销关系、代理申请审批、开店幂等和提现材料。

  1. 不同渠道信用额度,钱包支持信用额度支付
    编号 需求
    BPO-009 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。
    BPO-010 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。
    BPO-011 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。
    BPO-012 授权额度代理可显示负数余额。平台用户也可显示负数余额。

评审结论:信用额度只属于代理主钱包。平台员工是操作主体而不是结算主体,不建立员工钱包、不配置员工信用额度,也不展示员工负余额;角色仅控制谁可以查看或调整代理额度。

  1. 系统原先对于审核都是单人审核,现在希望增加多人审批以及相关设置
    编号 需求
    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. 审批通过或驳回后通知申请人。

评审结论:微信、支付宝和线下退款由财务在系统外人工完成后确认;本期不接入第三方自动退款。代理钱包支付的退款仅自动回退原扣款代理主钱包,客户资产钱包不在本期自动回退范围。

  1. 充值审核流程 代理自己充值:
  2. 代理在系统提交充值申请。
  3. 系统展示收款二维码。
  4. 代理扫码支付。

员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)

  1. 充值单进入多级审核流程。
  2. 提交人部门领导先审批。
  3. 财务在上一审批人通过后收到待办提醒并审批。
  4. 审批通过后通知申请人。
  5. 审批驳回后通知申请人,并展示驳回原因。

技术口径说明:审批通过只表示审批结论成立,不表示退款已经到账或充值已经入账。退款、充值分别维护业务处理状态并支持异步重试。此次采用停机发布,旧退款业务单审批接口和线下充值 offline-pay/reject 不保留兼容窗口。

  1. 套餐临期提醒
  2. 系统每日计算卡/设备套餐剩余有效期。
  3. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。
  4. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。
  5. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。
  6. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒并显示立即续费按钮。
  7. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。

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天时显示黄色