新增接口

This commit is contained in:
2026-08-18 16:15:46 +08:00
parent d256f6d176
commit 247d7d9f6e
20 changed files with 523 additions and 16178 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -209,9 +209,9 @@ default:
- 新增操作审计日志系统记录所有账号操作create/update/delete/assign_roles/remove_role - 新增操作审计日志系统记录所有账号操作create/update/delete/assign_roles/remove_role
**文档** **文档**
- [迁移指南](docs/account-management-refactor/迁移指南.md) - 前端接口迁移步骤 - 迁移指南 - 前端接口迁移步骤
- [功能总结](docs/account-management-refactor/功能总结.md) - 重构内容和安全提升 - 功能总结 - 重构内容和安全提升
- [API 文档](docs/account-management-refactor/API文档.md) - 详细接口说明 - API 文档 - 详细接口说明
--- ---
@@ -224,24 +224,24 @@ default:
- **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式包含错误码、消息、时间戳Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx和日志级别控制敏感信息自动脱敏保护 - **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式包含错误码、消息、时间戳Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx和日志级别控制敏感信息自动脱敏保护
- **数据持久化**GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力 - **数据持久化**GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力
- **异步任务处理**Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务 - **异步任务处理**Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务
- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `scene=device/iot_card``format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 [功能总结](docs/unified-export-task-system/功能总结.md)[验收记录](docs/unified-export-task-system/验收记录.md) - **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `scene=device/iot_card``format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 功能总结 与 验收记录
- **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 [功能总结](docs/add-asset-operation-audit-log/功能总结.md)、[接口回放示例](docs/add-asset-operation-audit-log/接口回放示例.md)[SQL 验收脚本](docs/add-asset-operation-audit-log/手工验收脚本.sql) - **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 功能总结、接口回放示例 与 SQL 验收脚本
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md)[权限检查使用指南](docs/permission-check-usage.md) - **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过详见 功能总结、使用指南 和 权限检查使用指南)
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md)[API 文档](docs/shop-management/API文档.md) - **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 使用指南 和 API 文档)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md)[菜单权限使用指南](docs/login-menu-button-response/使用指南.md) - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 API 文档、使用指南、架构说明 和 菜单权限使用指南
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)[架构说明](docs/auth-architecture.md) - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 API 文档、使用指南 和 架构说明
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户 - **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
- **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md) - **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 套餐与佣金业务模型
- **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md)[误发差价佣金修复说明](docs/agent-open-api/开放接口误发差价佣金修复说明.md) - **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 对接说明 与 误发差价佣金修复说明
- **批量同步**:卡状态、实名状态、流量使用情况 - **批量同步**:卡状态、实名状态、流量使用情况
- **轮询系统**IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md) - **轮询系统**IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 轮询系统文档
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/) - **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 套餐系统升级文档
- **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md)[最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md) - **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 功能总结 与 最终验收清单
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md) - **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 分佣逻辑正确与否验证
- **对象存储**S3 兼容的对象存储服务集成(联通云 OSS支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md)[前端接入指南](docs/object-storage/前端接入指南.md) - **对象存储**S3 兼容的对象存储服务集成(联通云 OSS支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 使用指南 和 前端接入指南
- **微信集成**:完整的微信公众号 OAuth 认证和微信支付功能JSAPI + H5使用 PowerWeChat v3 SDK支持个人客户微信授权登录、账号绑定、微信内支付和浏览器 H5 支付;支付回调自动验证签名和幂等性处理;详见 [使用指南](docs/wechat-integration/使用指南.md)[API 文档](docs/wechat-integration/API文档.md) - **微信集成**:完整的微信公众号 OAuth 认证和微信支付功能JSAPI + H5使用 PowerWeChat v3 SDK支持个人客户微信授权登录、账号绑定、微信内支付和浏览器 H5 支付;支付回调自动验证签名和幂等性处理;详见 使用指南 和 API 文档
- **C 端微信 AppID 获取接口**:新增免登录接口 `GET /api/c/v1/wechat/appid`,仅返回当前生效微信配置中的公众号 `app_id`,供前端在拉起微信授权或初始化微信能力前动态获取;详见 [功能总结](docs/client-wechat-appid/功能总结.md) - **C 端微信 AppID 获取接口**:新增免登录接口 `GET /api/c/v1/wechat/appid`,仅返回当前生效微信配置中的公众号 `app_id`,供前端在拉起微信授权或初始化微信能力前动态获取;详见 功能总结
- **订单超时自动取消**:待支付订单(微信/支付宝30 分钟超时自动取消,支持钱包余额解冻;使用 Asynq Scheduler 每分钟扫描,取代原有 time.Ticker 实现;同时将告警检查和数据清理迁移至 Asynq Scheduler 统一调度;详见 [功能总结](docs/order-expiration/功能总结.md) - **订单超时自动取消**:待支付订单(微信/支付宝30 分钟超时自动取消,支持钱包余额解冻;使用 Asynq Scheduler 每分钟扫描,取代原有 time.Ticker 实现;同时将告警检查和数据清理迁移至 Asynq Scheduler 统一调度;详见 功能总结
### 导出任务接口示例 ### 导出任务接口示例
@@ -330,8 +330,8 @@ tb_account (账号表 - 已修改)
``` ```
详细设计文档参见: 详细设计文档参见:
- [设计文档](openspec/changes/add-user-organization-model/design.md) - 设计文档
- [提案文档](openspec/changes/add-user-organization-model/proposal.md) - 提案文档
## 数据权限模型 ## 数据权限模型
@@ -369,8 +369,8 @@ allOrders, err := orderStore.List(ctx) // 查询所有订单(仅限特殊场
``` ```
详细说明参见: 详细说明参见:
- [数据权限清理总结](docs/remove-legacy-rbac-cleanup/清理总结.md) - 数据权限清理总结
- [RBAC 权限使用指南](docs/004-rbac-data-permission/使用指南.md) - RBAC 权限使用指南
## 快速开始 ## 快速开始
@@ -413,7 +413,7 @@ export JUNHONG_DEFAULT_ADMIN_PHONE="自定义手机号"
- 建议首次登录后立即修改默认密码 - 建议首次登录后立即修改默认密码
- 初始化日志记录在 `logs/app.log` - 初始化日志记录在 `logs/app.log`
详细设置和测试说明请参阅 [快速开始指南](specs/001-fiber-middleware-integration/quickstart.md) 详细设置和测试说明请参阅 快速开始指南。
## 项目结构 ## 项目结构
@@ -761,7 +761,7 @@ services:
### 完整环境变量列表 ### 完整环境变量列表
详见 [环境变量配置文档](docs/environment-variables.md) 详见 环境变量配置文档
## 测试 ## 测试
@@ -807,7 +807,7 @@ go test -v ./tests/integration/...
### 测试连接管理 ### 测试连接管理
测试使用全局单例连接池,性能提升 6-7 倍。详见 [测试连接管理规范](docs/testing/test-connection-guide.md) 测试使用全局单例连接池,性能提升 6-7 倍。详见 测试连接管理规范。
**标准写法**: **标准写法**:
```go ```go
@@ -863,9 +863,9 @@ Handler (HTTP) → Service (业务逻辑) → Store (数据访问) → Model (
- 在关键扩展点添加 TODO 标记 - 在关键扩展点添加 TODO 标记
**详细文档** **详细文档**
- [变更提案](openspec/changes/refactor-framework-cleanup/proposal.md) - 变更提案
- [设计文档](openspec/changes/refactor-framework-cleanup/design.md) - 设计文档
- [任务清单](openspec/changes/refactor-framework-cleanup/tasks.md) - 任务清单
## 开发规范 ## 开发规范
@@ -917,22 +917,22 @@ rdb.Set(ctx, key, status, time.Hour)
### 开发规范 ### 开发规范
- **[API 文档生成规范](docs/api-documentation-guide.md)**路由注册规范、DTO 规范、OpenAPI 文档生成流程 - **API 文档生成规范**路由注册规范、DTO 规范、OpenAPI 文档生成流程
- **[数据库验证规范](AGENTS.md#数据库验证规范)**:使用 PostgreSQL MCP 验证接口逻辑和业务数据的正确性 - **[数据库验证规范](AGENTS.md#数据库验证规范)**:使用 PostgreSQL MCP 验证接口逻辑和业务数据的正确性
- **[开发规范总览](AGENTS.md)**:完整的项目开发规范(必读) - **[开发规范总览](AGENTS.md)**:完整的项目开发规范(必读)
### 功能指南 ### 功能指南
- **[快速开始指南](specs/001-fiber-middleware-integration/quickstart.md)**:详细设置和测试说明 - **快速开始指南**:详细设置和测试说明
- **[限流指南](docs/rate-limiting.md)**:全面的限流配置和使用 - **限流指南**:全面的限流配置和使用
- **[错误处理使用指南](docs/003-error-handling/使用指南.md)**错误码参考、Handler 使用、客户端处理、最佳实践 - **错误处理使用指南**错误码参考、Handler 使用、客户端处理、最佳实践
- **[错误处理架构说明](docs/003-error-handling/架构说明.md)**:架构设计、性能优化、扩展性说明 - **错误处理架构说明**:架构设计、性能优化、扩展性说明
- **[订单操作者与产业链佣金语义修复](docs/feature-001-order-operator-and-chain-commission-semantics/功能总结.md)**:说明订单操作者账号化、佣金流程状态与业务结果拆分、以及手工验收口径 - **订单操作者与产业链佣金语义修复**:说明订单操作者账号化、佣金流程状态与业务结果拆分、以及手工验收口径
### 架构设计 ### 架构设计
- **[实现计划](specs/001-fiber-middleware-integration/plan.md)**:设计决策和架构 - **实现计划**:设计决策和架构
- **[数据模型](specs/001-fiber-middleware-integration/data-model.md)**:配置结构和 Redis 架构 - **数据模型**:配置结构和 Redis 架构
## 技术栈 ## 技术栈

View File

@@ -8,6 +8,7 @@ import (
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
@@ -46,6 +47,94 @@ func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port, audit Rec
return &OfflineCreationService{db: db, approval: approval, audit: audit} return &OfflineCreationService{db: db, approval: approval, audit: audit}
} }
// TriggerHistorical 为历史待审批线下代充值补发一次企业微信审批。
func (s *OfflineCreationService) TriggerHistorical(ctx context.Context, recordID uint) (*CreateOfflineResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || recordID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
}
var record model.AgentRechargeRecord
if err := s.db.WithContext(ctx).First(&record, recordID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值申请失败")
}
if record.PaymentMethod != constants.RechargeMethodOffline || record.Status != constants.RechargeStatusPending || record.ApprovalInstanceID != nil {
return nil, errors.New(errors.CodeConflict, "充值申请状态不允许补发审批")
}
account, shop, wallet, err := s.loadHistoricalFacts(ctx, &record)
if err != nil {
return nil, err
}
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, SubmitterAccountID: record.UserID,
CorrelationID: record.RechargeNo,
})
if err != nil {
return nil, err
}
command := CreateOfflineCommand{
SubmitterAccountID: record.UserID, SubmitterUserType: account.UserType, ShopID: record.ShopID,
RechargeNo: record.RechargeNo, Amount: record.Amount,
PaymentVoucherKeys: []string(record.PaymentVoucherKey), Remark: record.Remark,
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
if err != nil {
return nil, err
}
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current model.AgentRechargeRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, recordID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "充值记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定历史线下代充值申请失败")
}
if current.PaymentMethod != constants.RechargeMethodOffline || current.Status != constants.RechargeStatusPending || current.ApprovalInstanceID != nil {
return errors.New(errors.CodeConflict, "充值申请状态不允许补发审批")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeOfflineRecharge,
BusinessID: current.ID, SubmitterAccountID: current.UserID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RechargeNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND payment_method = ? AND status = ? AND approval_instance_id IS NULL", current.ID, constants.RechargeMethodOffline, constants.RechargeStatusPending).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联线下代充值审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "线下代充值审批实例关联已变化")
}
current.ApprovalInstanceID = &reference.InstanceID
record = current
approvalStatus = reference.Status
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败")
}
return s.audit.WriteAgentRecharge(ctx, tx, RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeCreated, Summary: "补发员工线下代充值审批",
Record: &current, Approval: &instance, Wallet: wallet,
AfterData: map[string]any{"status": current.Status, "approval_instance_id": current.ApprovalInstanceID},
})
})
if err != nil {
return nil, err
}
return &CreateOfflineResult{
Record: &record, ShopName: shop.ShopName, SubmitterName: account.Username, ApprovalStatus: approvalStatus,
}, nil
}
// Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。 // Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。
func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) { func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil { if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
@@ -140,6 +229,38 @@ func validateCreateOfflineCommand(command CreateOfflineCommand) error {
return nil return nil
} }
func (s *OfflineCreationService) loadHistoricalFacts(
ctx context.Context, record *model.AgentRechargeRecord,
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
if record == nil || record.UserID == 0 || record.ShopID == 0 || record.AgentWalletID == 0 {
return nil, nil, nil, errors.New(errors.CodeInvalidParam)
}
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", record.UserID, constants.StatusEnabled).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeForbidden, "原创建账号不可用")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值创建人失败")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ?", record.ShopID).First(&shop).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值目标店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).
Where("id = ? AND shop_id = ? AND wallet_type = ? AND status = ?", record.AgentWalletID, record.ShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil, nil, errors.New(errors.CodeWalletNotFound, "原充值主钱包不存在或不可用")
}
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史线下代充值主钱包失败")
}
return &account, &shop, &wallet, nil
}
func (s *OfflineCreationService) loadCreationFacts( func (s *OfflineCreationService) loadCreationFacts(
ctx context.Context, ctx context.Context,
command CreateOfflineCommand, command CreateOfflineCommand,

View File

@@ -8,6 +8,7 @@ import (
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
@@ -55,6 +56,99 @@ func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWrite
} }
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。 // Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
// TriggerHistorical 为历史待审批退款补发一次企业微信审批。
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
var refund model.RefundRequest
if err := s.db.WithContext(ctx).First(&refund, refundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史退款申请失败")
}
if refund.Status != model.RefundStatusPending || refund.ApprovalInstanceID != nil {
return nil, errors.New(errors.CodeConflict, "退款申请状态不允许补发审批")
}
account, err := s.loadSubmitter(ctx, refund.Creator)
if err != nil {
return nil, err
}
var order model.Order
if err := s.db.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "退款关联订单不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: refund.Creator,
CorrelationID: refund.RefundNo,
})
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := refundSnapshots(&refund, account)
if err != nil {
return nil, err
}
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current model.RefundRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, refundID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "退款申请不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定历史退款申请失败")
}
if current.Status != model.RefundStatusPending || current.ApprovalInstanceID != nil {
return errors.New(errors.CodeConflict, "退款申请状态不允许补发审批")
}
var currentOrder model.Order
if err := tx.WithContext(ctx).First(&currentOrder, current.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
BusinessID: current.ID, SubmitterAccountID: current.Creator,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: current.RefundNo,
})
if err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", current.ID, model.RefundStatusPending).
Update("approval_instance_id", reference.InstanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
}
current.ApprovalInstanceID = &reference.InstanceID
refund = current
order = currentOrder
approvalStatus = reference.Status
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: &current, Order: &currentOrder, Approval: &instance, Submitter: account,
})
})
if err != nil {
return nil, err
}
return &CreateResult{Refund: &refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
}
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) { func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil { if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")

View File

@@ -143,6 +143,20 @@ func (h *AgentRechargeHandler) Get(c *fiber.Ctx) error {
return response.Success(c, result) return response.Success(c, result)
} }
// TriggerApproval 主动补发历史线下代理充值审批。
// POST /api/admin/agent-recharges/:id/trigger-approval
func (h *AgentRechargeHandler) TriggerApproval(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的充值记录ID")
}
result, err := h.service.TriggerApproval(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// PaymentStatus 查询代理充值本地支付与到账状态。 // PaymentStatus 查询代理充值本地支付与到账状态。
// GET /api/admin/agent-recharges/:id/payment-status // GET /api/admin/agent-recharges/:id/payment-status
func (h *AgentRechargeHandler) PaymentStatus(c *fiber.Ctx) error { func (h *AgentRechargeHandler) PaymentStatus(c *fiber.Ctx) error {

View File

@@ -69,6 +69,20 @@ func (h *RefundHandler) GetByID(c *fiber.Ctx) error {
return response.Success(c, result) return response.Success(c, result)
} }
// TriggerApproval 主动补发历史退款审批
// POST /api/admin/refunds/:id/trigger-approval
func (h *RefundHandler) TriggerApproval(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的退款申请ID")
}
result, err := h.service.TriggerApproval(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// Approve 审批通过退款申请 // Approve 审批通过退款申请
// POST /api/admin/refunds/:id/approve // POST /api/admin/refunds/:id/approve
func (h *RefundHandler) Approve(c *fiber.Ctx) error { func (h *RefundHandler) Approve(c *fiber.Ctx) error {

View File

@@ -1,82 +0,0 @@
package audit
import (
"context"
"encoding/json"
"testing"
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func TestAppendFailureDoesNotReturnToBusiness(t *testing.T) {
writer := NewWriter(nil, nil)
input := AppendInput{ActionCode: "missing_action"}
before := auditfailure.SecondaryWriteFailureCount()
if err := writer.Append(context.Background(), nil, input); err != nil {
t.Fatalf("Append 返回审计失败: %v", err)
}
if err := writer.WriteAccessChange(context.Background(), nil, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPersonalCustomerAssetBound,
OperatorID: 1,
}); err != nil {
t.Fatalf("资源构造失败返回业务: %v", err)
}
if got := auditfailure.SecondaryWriteFailureCount(); got != before+2 {
t.Fatalf("二次失败记录次数 = %d, want %d", got, before+2)
}
if _, err := writer.AppendAndGet(context.Background(), nil, input); err == nil {
t.Fatal("AppendAndGet 未保留错误语义")
}
}
func TestPersonalCustomerAssetBoundProjectsOnlyPersonalResources(t *testing.T) {
action, ok := NewRegistry().Action(constants.AuditActionPersonalCustomerAssetBound)
if !ok {
t.Fatal("未注册个人客户资产绑定审计动作")
}
resources, err := accessResources(accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionPersonalCustomerAssetBound,
PersonalCustomer: &model.PersonalCustomer{Model: gorm.Model{ID: 1}, Nickname: "客户"},
PersonalDevices: []accessauditapp.PersonalCustomerDeviceChange{{
Binding: &model.PersonalCustomerDevice{Model: gorm.Model{ID: 2}, CustomerID: 1, VirtualNo: "DEVICE-1"},
}},
PersonalICCIDs: []accessauditapp.PersonalCustomerICCIDChange{{
Binding: &model.PersonalCustomerICCID{Model: gorm.Model{ID: 3}, CustomerID: 1, ICCID: "ICCID-1"},
}},
SubjectVisibility: constants.AuditSubjectDetail,
SubjectSummary: "绑定个人客户资产",
SubjectData: map[string]any{"asset_type": constants.AuditResourceIotCard, "asset_id": uint(9)},
}, action.PrimaryResource)
if err != nil {
t.Fatalf("构造绑定审计资源失败: %v", err)
}
projected, err := NewWriter(nil, nil).buildResources(resources, action)
if err != nil {
t.Fatalf("构造绑定审计投影失败: %v", err)
}
want := map[string]bool{
constants.AuditResourcePersonalCustomer: true,
constants.AuditResourcePersonalCustomerDevice: true,
constants.AuditResourcePersonalCustomerICCID: true,
}
for _, resource := range projected {
if resource.ResourceType == constants.AuditResourceIotCard || resource.ResourceType == constants.AuditResourceDevice {
t.Fatalf("绑定审计投影包含内部资源: %s", resource.ResourceType)
}
delete(want, resource.ResourceType)
if resource.ResourceType == constants.AuditResourcePersonalCustomer {
var subjectData map[string]any
if err := json.Unmarshal(resource.SubjectData, &subjectData); err != nil || resource.SubjectVisibility != constants.AuditSubjectDetail || subjectData["asset_type"] != constants.AuditResourceIotCard || subjectData["asset_id"] != float64(9) {
t.Fatalf("主个人客户主体投影不完整: %#v", resource)
}
}
}
for resourceType := range want {
t.Fatalf("绑定审计投影缺少合法资源: %s", resourceType)
}
}

View File

@@ -61,6 +61,14 @@ func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechar
Auth: true, Auth: true,
}) })
Register(group, doc, groupPath, "POST", "/:id/trigger-approval", handler.TriggerApproval, RouteSpec{
Summary: "补发历史线下代理充值审批",
Tags: []string{"代理预充值"},
Input: new(dto.IDReq),
Output: new(dto.AgentRechargeResponse),
Auth: true,
})
Register(group, doc, groupPath, "POST", "/:id/offline-pay", handler.OfflinePay, RouteSpec{ Register(group, doc, groupPath, "POST", "/:id/offline-pay", handler.OfflinePay, RouteSpec{
Summary: "确认线下充值", Summary: "确认线下充值",
Tags: []string{"代理预充值"}, Tags: []string{"代理预充值"},

View File

@@ -49,6 +49,14 @@ func registerRefundRoutes(router fiber.Router, handler *admin.RefundHandler, doc
Auth: true, Auth: true,
}) })
Register(refund, doc, groupPath, "POST", "/:id/trigger-approval", handler.TriggerApproval, RouteSpec{
Summary: "补发历史退款审批",
Tags: []string{"退款管理"},
Input: new(dto.RefundIDRequest),
Output: new(dto.RefundResponse),
Auth: true,
})
Register(refund, doc, groupPath, "POST", "/:id/approve", handler.Approve, RouteSpec{ Register(refund, doc, groupPath, "POST", "/:id/approve", handler.Approve, RouteSpec{
Summary: "审批通过退款申请", Summary: "审批通过退款申请",
Tags: []string{"退款管理"}, Tags: []string{"退款管理"},

View File

@@ -434,6 +434,27 @@ func (s *Service) appendCreditedAudit(ctx context.Context, tx *gorm.DB, record *
}) })
} }
// TriggerApproval 为历史线下代理充值主动补发企业微信审批。
func (s *Service) TriggerApproval(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) {
if s.offlineCreation == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
}
record, err := s.agentRechargeStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
}
result, err := s.offlineCreation.TriggerHistorical(ctx, record.ID)
if err != nil {
return nil, err
}
resp := toResponse(result.Record, result.ShopName)
resp.SubmitterName = result.SubmitterName
resp.ApprovalProvider = constants.IntegrationProviderWeCom
resp.ApprovalStatus = &result.ApprovalStatus
resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus)
return resp, nil
}
// GetByID 根据ID查询充值订单详情 // GetByID 根据ID查询充值订单详情
// GET /api/admin/agent-recharges/:id // GET /api/admin/agent-recharges/:id
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) { func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) {

View File

@@ -1,83 +0,0 @@
package customer_binding
import (
"context"
"database/sql"
"database/sql/driver"
"io"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func init() { sql.Register("customer_binding_audit_test", customerAuditDriver{}) }
type customerAuditDriver struct{}
func (customerAuditDriver) Open(string) (driver.Conn, error) { return customerAuditConn{}, nil }
type customerAuditConn struct{}
func (customerAuditConn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
func (customerAuditConn) Close() error { return nil }
func (customerAuditConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
func (customerAuditConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) {
return &customerAuditRows{}, nil
}
type customerAuditRows struct{ sent bool }
func (*customerAuditRows) Columns() []string { return []string{"id", "nickname"} }
func (r *customerAuditRows) Close() error { return nil }
func (r *customerAuditRows) Next(dest []driver.Value) error {
if r.sent {
return io.EOF
}
r.sent = true
dest[0], dest[1] = int64(7), "客户"
return nil
}
type captureAuditWriter struct{ change accessauditapp.ChangeAudit }
func (w *captureAuditWriter) WriteAccessChange(_ context.Context, _ *gorm.DB, change accessauditapp.ChangeAudit) error {
w.change = change
return nil
}
func TestWriteBindingAuditOmitsInternalAssets(t *testing.T) {
db, err := sql.Open("customer_binding_audit_test", "")
if err != nil {
t.Fatal(err)
}
defer db.Close()
tx, err := gorm.Open(postgres.New(postgres.Config{Conn: db}), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
writer := &captureAuditWriter{}
service := &Service{accessAudit: writer}
personalDevices := []accessauditapp.PersonalCustomerDeviceChange{{Binding: &model.PersonalCustomerDevice{Model: gorm.Model{ID: 2}, CustomerID: 7, VirtualNo: "DEVICE-1"}}}
personalICCIDs := []accessauditapp.PersonalCustomerICCIDChange{{Binding: &model.PersonalCustomerICCID{Model: gorm.Model{ID: 3}, CustomerID: 7, ICCID: "ICCID-1"}}}
cards := []accessauditapp.IotCardChange{{Card: &model.IotCard{Model: gorm.Model{ID: 9}}}}
devices := []accessauditapp.DeviceChange{{Device: &model.Device{Model: gorm.Model{ID: 10}}}}
if err := service.writeBindingAudit(context.Background(), tx, constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", 7, personalDevices, personalICCIDs, cards, devices); err != nil {
t.Fatalf("写入绑定审计失败: %v", err)
}
change := writer.change
if len(change.Cards) != 0 || len(change.Devices) != 0 {
t.Fatalf("绑定审计泄露内部资源: Cards=%d Devices=%d", len(change.Cards), len(change.Devices))
}
if change.PersonalCustomer == nil || change.PersonalCustomer.ID != 7 || len(change.PersonalDevices) != 1 || len(change.PersonalICCIDs) != 1 {
t.Fatalf("绑定审计未保留个人客户字段: %#v", change)
}
if change.SubjectData["asset_type"] != constants.AuditResourceIotCard || change.SubjectData["asset_id"] != uint(9) {
t.Fatalf("绑定审计未保留主体摘要: %#v", change.SubjectData)
}
}

View File

@@ -238,6 +238,27 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re
} }
// GetByID 根据 ID 查询退款申请详情 // GetByID 根据 ID 查询退款申请详情
// TriggerApproval 为历史退款申请主动补发企业微信审批。
func (s *Service) TriggerApproval(ctx context.Context, id uint) (*dto.RefundResponse, error) {
if s.refundApprovalCreation == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
result, err := s.refundApprovalCreation.TriggerHistorical(ctx, refund.ID)
if err != nil {
return nil, err
}
resp := buildRefundResponse(result.Refund)
resp.SubmitterName = result.SubmitterName
resp.ApprovalProvider = constants.IntegrationProviderWeCom
resp.ApprovalStatus = &result.ApprovalStatus
resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus)
return resp, nil
}
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, error) { func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, error) {
refund, err := s.refundStore.GetByID(ctx, id) refund, err := s.refundStore.GetByID(ctx, id)
if err != nil { if err != nil {

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-18

View File

@@ -0,0 +1,45 @@
## Context
历史记录在 `tb_refund_request``tb_agent_recharge_record` 中保持待审批但 `approval_instance_id` 为空。现有新建用例已经在单个事务中创建通用审批实例、企业微信上下文、审批提交 Outbox并回填该关联两个业务表的 `approval_instance_id` 均有唯一索引,通用审批实例还以业务类型和业务 ID 唯一。
## Goals / Non-Goals
**Goals:**
- 以最小增量复用现有审批创建和可靠提交链路补发历史记录。
- 以原创建账号构造企业微信发起人及审批快照。
- 使并发请求和重复请求均不会形成第二张审批单。
**Non-Goals:**
- 不批量扫描或自动补发历史记录。
- 不改变既有审批终态、企业微信提交重试或人工审批接口。
- 不新增迁移、重置既有审批关联,或为提交失败创建第二张审批单。
## Decisions
### 在各业务审批创建用例中增加历史记录发起入口
退款和线下代理充值分别新增面向既有记录的 Application 用例入口,复用各自已有的快照构造、提交人校验、通用审批 `Prepare`/`CreateInTx`、审计及 DTO 组装逻辑。Handler 只解析路径 ID 并调用服务Service 负责加载完整业务事实和调用 Application。
选择按业务保留两个小入口,而不引入跨退款/充值的通用“历史审批补发器”:二者的资格条件、快照和关联事实不同,现有两个创建用例已是最短复用边界。
### 以事务内条件更新和既有唯一约束保证一次性
发起前可在事务外执行审批渠道预检;事务内必须重新读取或条件更新业务记录,要求 `status=待审批 AND approval_instance_id IS NULL`,再创建通用审批及渠道上下文/Outbox并回填 `approval_instance_id`。任一环节失败回滚,不消耗发起资格;成功提交后,由业务表关联唯一索引和通用审批业务唯一索引共同拒绝并发的第二次创建。
不增加“已尝试”字段:用户确认以成功创建审批实例作为一次性边界,已有唯一关联就是持久化且可恢复的事实源。
### 发起人和授权语义
企业微信发起人固定为业务记录 `Creator`,不使用点击接口的账号;该账号不可用时失败关闭。接口沿用各自当前路由组的账号类型授权,不扩大既有退款或代理充值管理入口的访问范围。返回值沿用现有详情 DTO 的审批摘要字段,避免新增响应类型。
## Risks / Trade-offs
- [原创建账号已禁用或未绑定企业微信] → 不创建任何审批事实并返回错误;维护者修复账号/绑定后可再次操作。
- [两个请求同时发起] → 事务条件和数据库唯一约束确保仅一个提交成功,调用方对另一个请求按冲突处理。
- [提交 Outbox 后企微调用结果未知] → 沿用已有结果未知恢复流程,禁止通过本接口重建审批。
## Migration Plan
1. 发布 API 与 Worker 均包含该版本的应用代码,确保 Outbox 消费者已注册。
2. 维护者在生产环境按发布运行说明,通过列表筛选待审批历史记录后逐单调用新接口,并核对返回的审批摘要与审计/Outbox 事实。
3. 如需回滚,仅停止暴露新路由并回滚应用二进制;已成功创建的审批实例继续由既有 Worker 流程处理,不删除审批关联或重新发起。

View File

@@ -0,0 +1,28 @@
## Why
七月迭代上线前已创建且仍待审批的退款申请、员工线下代充值申请未关联通用审批实例,无法进入企业微信审批流。需要由管理员按单主动补发,同时避免同一业务重复创建审批单。
## What Changes
- 为待审批且尚未关联审批实例的历史退款申请新增主动发起企业微信审批接口。
- 为待审批、线下支付且尚未关联审批实例的历史代理充值申请新增主动发起企业微信审批接口。
- 主动发起时复用原业务创建人作为企业微信审批发起人;原创建人不可用或审批场景不可用时不创建审批实例。
- 在同一事务创建通用审批实例、企业微信上下文、提交 Outbox 并回填业务记录的 `approval_instance_id`,以该唯一关联保证成功创建后不可再次发起。
- 仅在业务保持待审批状态时允许主动发起;已关联审批实例、非线下充值或非待审批记录均拒绝。
## Capabilities
### New Capabilities
- 无。
### Modified Capabilities
- `order-refund-exchange`: 退款申请可对历史待审批且未关联审批实例的记录主动创建一次企业微信审批。
- `agent-funds-commission`: 历史待审批线下代理充值申请可主动创建一次企业微信审批。
## Impact
- 路由、退款与代理充值 Handler/Service以及审批创建 Application 用例。
- 新增两个后台 API 并同步 OpenAPI 文档生成入口。
- 复用现有通用审批、企业微信审批上下文、Outbox、审计和既有 `approval_instance_id` 唯一索引;不新增外部依赖或数据库表结构。

View File

@@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: 历史待审批线下代理充值可主动接入企业微信审批
系统 SHALL 提供 `POST /api/admin/agent-recharges/{id}/trigger-approval`,使具有既有代理充值管理访问权限的后台账号可为历史线下代理充值申请主动创建企业微信审批。系统 MUST 仅在线下充值记录处于待审批状态且 `approval_instance_id` 为空时创建审批;审批发起人 MUST 使用该充值记录的原创建账号。创建成功后,系统 MUST 原子保存唯一审批实例、审批提交请求及充值记录的审批实例关联,并返回更新后的充值申请审批摘要。
#### Scenario: 主动发起历史线下代理充值审批成功
- **GIVEN** 线下代理充值申请处于待审批状态、未关联审批实例,且其原创建账号和企业微信线下充值审批场景均可用
- **WHEN** 有既有代理充值管理访问权限的后台账号请求 `POST /api/admin/agent-recharges/{id}/trigger-approval`
- **THEN** 系统创建以原创建账号为发起人的唯一企业微信审批并返回审批摘要,后续由既有可靠提交流程提交至企业微信
#### Scenario: 在线、非待审批或已发起记录被拒绝
- **WHEN** 请求主动发起的充值记录不是线下充值、不是待审批状态或已关联审批实例
- **THEN** 系统返回状态冲突且不创建新的审批实例或提交请求
#### Scenario: 并发主动发起同一充值审批
- **WHEN** 两个请求同时为同一符合条件的线下代理充值申请主动发起审批
- **THEN** 系统至多创建一个审批实例和一个审批提交请求,未成功创建关联的请求返回冲突
#### Scenario: 原创建人或审批渠道不可用
- **WHEN** 充值申请原创建账号不可用,或企业微信线下充值审批场景不可用
- **THEN** 系统返回相应错误,充值申请保持未关联审批实例,修复条件后可再次发起

View File

@@ -0,0 +1,21 @@
## ADDED Requirements
### Requirement: 历史待审批退款可主动接入企业微信审批
系统 SHALL 提供 `POST /api/admin/refunds/{id}/trigger-approval`,使具有既有退款管理访问权限的后台账号可为历史退款申请主动创建企业微信审批。系统 MUST 仅在退款申请状态为待审批且 `approval_instance_id` 为空时创建审批;审批发起人 MUST 使用该退款申请的原创建账号。创建成功后,系统 MUST 原子保存唯一审批实例、审批提交请求及退款申请的审批实例关联,并返回更新后的退款申请审批摘要。
#### Scenario: 主动发起历史退款审批成功
- **GIVEN** 退款申请处于待审批状态、未关联审批实例,且其原创建账号和企业微信退款审批场景均可用
- **WHEN** 有既有退款管理访问权限的后台账号请求 `POST /api/admin/refunds/{id}/trigger-approval`
- **THEN** 系统创建以原创建账号为发起人的唯一企业微信审批并返回审批摘要,后续由既有可靠提交流程提交至企业微信
#### Scenario: 非待审批或已发起记录被拒绝
- **WHEN** 请求主动发起的退款申请不是待审批状态或已关联审批实例
- **THEN** 系统返回状态冲突且不创建新的审批实例或提交请求
#### Scenario: 并发主动发起同一退款审批
- **WHEN** 两个请求同时为同一符合条件的退款申请主动发起审批
- **THEN** 系统至多创建一个审批实例和一个审批提交请求,未成功创建关联的请求返回冲突
#### Scenario: 原创建人或审批渠道不可用
- **WHEN** 退款申请原创建账号不可用,或企业微信退款审批场景不可用
- **THEN** 系统返回相应错误,退款申请保持未关联审批实例,修复条件后可再次发起

View File

@@ -0,0 +1,16 @@
## 1. 审批补发用例
- [x] 1.1 在退款审批 Application 中实现历史待审批退款的主动发起:加载原创建人和订单事实、复用既有审批快照与 `Prepare`/`CreateInTx` 链路,并在同一事务内按待审批且未关联审批实例的条件回填关联和审计。
- [x] 1.2 在线下代理充值 Application 中实现历史待审批充值的主动发起校验线下支付、待审批和未关联审批实例加载原创建人、店铺和钱包事实并复用既有审批创建、快照、Outbox 与审计链路。
- [x] 1.3 在退款和代理充值 Service 中接入补发用例,复核既有路由权限与资源查询范围,向调用方返回包含审批摘要的既有 DTO将并发或已关联审批实例映射为状态冲突。
## 2. HTTP 入口与文档
- [x] 2.1 在退款 Handler 和路由注册 `POST /api/admin/refunds/{id}/trigger-approval`,完成路径 ID 绑定并交由 Service 处理。
- [x] 2.2 在代理充值 Handler 和路由注册 `POST /api/admin/agent-recharges/{id}/trigger-approval`,完成路径 ID 绑定并交由 Service 处理。
- [x] 2.3 同步 `cmd/api/docs.go``cmd/gendocs/main.go` 所依赖的路由元数据,确保两个接口及其响应模型生成到 OpenAPI 文档。
## 3. 验证
- [x] 3.1 以隔离环境或最小可运行验证覆盖:两个符合资格的历史记录各只创建一次审批实例和提交 Outbox非待审批、已关联、在线充值、原创建人/场景不可用及并发重复请求不创建第二实例。
- [ ] 3.2 执行 `gofmt -w`(变更的 Go 文件)、`go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate --all``./scripts/context-health.sh`

View File

@@ -97,13 +97,38 @@
- **WHEN** 当前账号请求资金概况列表但未提供 `shop_id` - **WHEN** 当前账号请求资金概况列表但未提供 `shop_id`
- **THEN** 系统继续按既有分页、数据范围、店铺名称和主账号用户名条件返回结果 - **THEN** 系统继续按既有分页、数据范围、店铺名称和主账号用户名条件返回结果
### Requirement: 历史待审批线下代理充值可主动接入企业微信审批
系统 SHALL 提供 `POST /api/admin/agent-recharges/{id}/trigger-approval`,使具有既有代理充值管理访问权限的后台账号可为历史线下代理充值申请主动创建企业微信审批。系统 MUST 仅在线下充值记录处于待审批状态且 `approval_instance_id` 为空时创建审批;审批发起人 MUST 使用该充值记录的原创建账号。创建成功后,系统 MUST 原子保存唯一审批实例、审批提交请求及充值记录的审批实例关联,并返回更新后的充值申请审批摘要。
#### Scenario: 主动发起历史线下代理充值审批成功
- **GIVEN** 线下代理充值申请处于待审批状态、未关联审批实例,且其原创建账号和企业微信线下充值审批场景均可用
- **WHEN** 有既有代理充值管理访问权限的后台账号请求 `POST /api/admin/agent-recharges/{id}/trigger-approval`
- **THEN** 系统创建以原创建账号为发起人的唯一企业微信审批并返回审批摘要,后续由既有可靠提交流程提交至企业微信
#### Scenario: 在线、非待审批或已发起记录被拒绝
- **WHEN** 请求主动发起的充值记录不是线下充值、不是待审批状态或已关联审批实例
- **THEN** 系统返回状态冲突且不创建新的审批实例或提交请求
#### Scenario: 并发主动发起同一充值审批
- **WHEN** 两个请求同时为同一符合条件的线下代理充值申请主动发起审批
- **THEN** 系统至多创建一个审批实例和一个审批提交请求,未成功创建关联的请求返回冲突
#### Scenario: 原创建人或审批渠道不可用
- **WHEN** 充值申请原创建账号不可用,或企业微信线下充值审批场景不可用
- **THEN** 系统返回相应错误,充值申请保持未关联审批实例,修复条件后可再次发起
## 可达操作索引 ## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。 本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。
### 代理预充值 ### 代理预充值
`GET /api/admin/agent-recharges`(查询代理充值订单列表);`POST /api/admin/agent-recharges`(创建代理充值订单);`GET /api/admin/agent-recharges/{id}`(查询代理充值订单详情);`POST /api/admin/agent-recharges/{id}/offline-pay`(确认线下充值);`GET /api/admin/agent-recharges/{id}/payment-status`(查询代理充值本地支付与到账状态);`POST /api/admin/agent-recharges/{id}/reject`(驳回代理充值订单);`GET /api/admin/agent-recharges/payment-methods`(查询代理在线充值可用支付方式)。 `GET /api/admin/agent-recharges`(查询代理充值订单列表);`POST /api/admin/agent-recharges`(创建代理充值订单);`GET /api/admin/agent-recharges/{id}`(查询代理充值订单详情);`POST /api/admin/agent-recharges/{id}/trigger-approval`(补发历史线下代理充值审批);`POST /api/admin/agent-recharges/{id}/offline-pay`(确认线下充值);`GET /api/admin/agent-recharges/{id}/payment-status`(查询代理充值本地支付与到账状态);`POST /api/admin/agent-recharges/{id}/reject`(驳回代理充值订单);`GET /api/admin/agent-recharges/payment-methods`(查询代理在线充值可用支付方式)。
### 代理商资金管理 ### 代理商资金管理

View File

@@ -38,6 +38,31 @@
- **WHEN** 该代理查询退款列表或退款申请详情 - **WHEN** 该代理查询退款列表或退款申请详情
- **THEN** 系统返回空列表或不存在,且不泄露任何退款申请 - **THEN** 系统返回空列表或不存在,且不泄露任何退款申请
### Requirement: 历史待审批退款可主动接入企业微信审批
系统 SHALL 提供 `POST /api/admin/refunds/{id}/trigger-approval`,使具有既有退款管理访问权限的后台账号可为历史退款申请主动创建企业微信审批。系统 MUST 仅在退款申请状态为待审批且 `approval_instance_id` 为空时创建审批;审批发起人 MUST 使用该退款申请的原创建账号。创建成功后,系统 MUST 原子保存唯一审批实例、审批提交请求及退款申请的审批实例关联,并返回更新后的退款申请审批摘要。
#### Scenario: 主动发起历史退款审批成功
- **GIVEN** 退款申请处于待审批状态、未关联审批实例,且其原创建账号和企业微信退款审批场景均可用
- **WHEN** 有既有退款管理访问权限的后台账号请求 `POST /api/admin/refunds/{id}/trigger-approval`
- **THEN** 系统创建以原创建账号为发起人的唯一企业微信审批并返回审批摘要,后续由既有可靠提交流程提交至企业微信
#### Scenario: 非待审批或已发起记录被拒绝
- **WHEN** 请求主动发起的退款申请不是待审批状态或已关联审批实例
- **THEN** 系统返回状态冲突且不创建新的审批实例或提交请求
#### Scenario: 并发主动发起同一退款审批
- **WHEN** 两个请求同时为同一符合条件的退款申请主动发起审批
- **THEN** 系统至多创建一个审批实例和一个审批提交请求,未成功创建关联的请求返回冲突
#### Scenario: 原创建人或审批渠道不可用
- **WHEN** 退款申请原创建账号不可用,或企业微信退款审批场景不可用
- **THEN** 系统返回相应错误,退款申请保持未关联审批实例,修复条件后可再次发起
## 可达操作索引 ## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。 本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。
@@ -48,7 +73,7 @@
### 退款管理 ### 退款管理
`GET /api/admin/refunds`(退款申请列表);`POST /api/admin/refunds`(创建退款申请);`GET /api/admin/refunds/{id}`(退款申请详情);`POST /api/admin/refunds/{id}/approve`(审批通过退款申请);`POST /api/admin/refunds/{id}/reject`(审批拒绝退款申请);`POST /api/admin/refunds/{id}/resubmit`(重新提交退款申请);`POST /api/admin/refunds/{id}/return`(退回退款申请)。 `GET /api/admin/refunds`(退款申请列表);`POST /api/admin/refunds`(创建退款申请);`GET /api/admin/refunds/{id}`(退款申请详情);`POST /api/admin/refunds/{id}/trigger-approval`(补发历史退款审批);`POST /api/admin/refunds/{id}/approve`(审批通过退款申请);`POST /api/admin/refunds/{id}/reject`(审批拒绝退款申请);`POST /api/admin/refunds/{id}/resubmit`(重新提交退款申请);`POST /api/admin/refunds/{id}/return`(退回退款申请)。
### 换货管理 ### 换货管理