12 Commits

Author SHA1 Message Date
b9e8592cc4 修复换货迁移后 after_order 实名门槛误判
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m33s
换货迁移只迁移套餐权益不迁移订单,已支付订单仍挂在旧资产上,
导致新资产 HasValidRechargeOrPaidOrder 查不到支付事实,返回 1189。
现增加套餐权益兜底:当前世代存在未退款套餐权益且其关联订单已支付,
即视为满足充值/购买条件。
2026-09-10 11:26:07 +08:00
54823290c3 完成支付购包自动复机变更任务 2026-09-10 11:26:07 +08:00
b38b2b39c9 修复支付购包后自动复机 2026-09-10 11:26:07 +08:00
6f8db180fb 补齐支付商户池字段备注
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m41s
2026-09-10 11:03:20 +08:00
a9e2302f7c 1 2026-09-10 10:53:07 +08:00
bcb1304937 修复支付商户路由权限泄漏
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m8s
registerPaymentMerchantRoutes 使用空前缀 Group 承载权限中间件,
Fiber 将其等价于在整个 /api/admin 上 Use,导致代理账号访问
/api/admin/agent-recharges 等无关路由被误拦。

改为 requirePaymentMerchantAccess 包装函数,仅包装 payment-merchants、
payment-merchant-pools、wechat-authorizations 共 13 个管理路由,
路径、方法与权限语义不变。
2026-09-10 10:00:53 +08:00
88d7965641 固定测试镜像迁移工具版本
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m31s
2026-09-09 18:34:23 +08:00
e09c4632fb 修正测试环境按提交镜像部署
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 6m5s
Gitea workflow 只以 github.sha 构建/推送镜像标签,部署时写入
IMAGE_TAG 到 .env;compose 的 api/worker image 引用 IMAGE_TAG,
默认 latest 仅作本地兜底,测试部署必须解析为提交 SHA。
2026-09-09 18:18:13 +08:00
98c145fe70 实现支付商户池与微信授权配置
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m55s
新增收款商户、商户池轮询、微信授权配置独立管理;三类新支付
(C端套餐购买、C端资产钱包充值、代理在线预存款充值)无条件
经商户池选择并冻结路由,无旧综合配置回退。merchant_id 为空
历史支付继续按 payment_config_id 双读。凭证版本化加载与
ID+版本缓存保证轮换一致性。删除商户池新支付创建开关及全部
引用。
2026-09-09 18:13:04 +08:00
ff25586dc9 修复已用完套餐展示
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m43s
2026-09-08 17:25:34 +08:00
a48ff5d782 切换测试环境部署至八月迭代分支
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m36s
2026-09-07 17:32:19 +08:00
696120ab38 feat: 资产套餐历史增加主子层级查询 2026-09-07 17:17:15 +08:00
76 changed files with 5267 additions and 729 deletions

View File

@@ -3,7 +3,7 @@ name: 构建并部署到测试环境(无 SSH
on:
push:
branches:
- main
- Iteration/8-11
env:
REGISTRY: registry.boss160.cn
@@ -28,7 +28,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
echo "tag=latest" >> $GITHUB_OUTPUT
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
- name: 登录 Docker Registry
run: |
@@ -37,28 +37,25 @@ jobs:
- name: 构建 API 镜像
run: |
docker build -f Dockerfile.api -t ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }} .
docker tag ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }} ${{ env.API_IMAGE }}:${{ github.sha }}
- name: 构建 Worker 镜像
run: |
docker build -f Dockerfile.worker -t ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }} .
docker tag ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }} ${{ env.WORKER_IMAGE }}:${{ github.sha }}
- name: 推送镜像到 Registry
run: |
docker push ${{ env.API_IMAGE }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.API_IMAGE }}:${{ github.sha }}
docker push ${{ env.WORKER_IMAGE }}:${{ steps.tag.outputs.tag }}
docker push ${{ env.WORKER_IMAGE }}:${{ github.sha }}
- name: 部署到测试环境(仅 main 分支)
if: github.ref == 'refs/heads/main'
- name: 部署到测试环境(仅八月迭代分支)
if: github.ref == 'refs/heads/Iteration/8-11'
run: |
# 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件)
mkdir -p ${{ env.DEPLOY_DIR }}/logs
umask 077
{
printf 'IMAGE_TAG=%s\n' '${{ github.sha }}'
printf 'JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=true\n'
printf 'JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true\n'
printf 'JUNHONG_WORKER_ROLE=all\n'

View File

@@ -84,6 +84,7 @@ openspec validate --all
```
启动、隔离数据库重置、smoke 与日志读取见 [`README.md`](README.md)。迁移使用 `scripts/migrate.sh` 和显式 `DB_*` 参数;生产迁移仅按生产运行说明由维护者手工执行。
- 测试验证涉及迁移、Redis、部署或集成 Smoke 时MUST 读取 [`docs/engineering/工程约束.md`](docs/engineering/工程约束.md) 的 ENG-TEST-001维护者指定的测试环境是唯一验证面。
## 渐进披露

View File

@@ -1,32 +0,0 @@
# 新卡管系统
新卡管系统保存物联网卡、设备、套餐、订单、钱包、分佣、审批与运营协作的本地业务事实,并协调外部支付、运营商和企业微信能力。
## 资金与审批
**员工代收款账单**:员工代客户完成套餐购买或充值等业务后生成的待核销记录,表示该员工经办业务形成的暂挂欠款;可由已匹配支付记录的客户付款凭证核销。
_Avoid_: 员工账单、客户应收款、销账单
**核销**:公司对员工代收款账单及已匹配支付记录的客户付款凭证作出的确认,使对应暂挂欠款减少或结清的业务决定。
_Avoid_: 客户付款、订单支付
**核销申请**:员工针对一笔外部付款提交的、包含凭证和一至多条账单分摊明细的审批业务单;一张申请对应一个企业微信审批实例。
_Avoid_: 单张账单审批、付款截图
**核销分摊明细**:核销申请对一张员工代收款账单确认的本次核销金额。
_Avoid_: 账单金额、付款金额
**账单核销状态**:员工代收款账单的结算状态,取待核销、部分核销、已核销或已关闭;与核销申请的审批状态相互独立。
_Avoid_: 审批状态、企业微信状态
**审批实例**:本地保存、唯一关联一笔业务单的审批生命周期事实;企业微信是该实例的审批渠道,而非本地业务事实的替代。
_Avoid_: 企业微信审批单
**交易流水号**支付或退款渠道为一笔交易生成的外部标识OCR 识别结果只能预填该字段,须由业务人员最终确认。
_Avoid_: OCR 结果、系统订单号
**线下收款方式**:业务字典中供核销等线下付款场景选择的收款路径标识,例如某个指定微信或银行卡;它不等同于线上支付渠道枚举。
_Avoid_: 支付方式枚举、固定收款人名单、收款账户目录
**业务字典**:由研发固定注册的业务分类及其由业务维护的字典项,用于稳定的业务选项;业务人员不能自行创建字典分类。
_Avoid_: 系统配置、任意自定义字段平台

View File

@@ -32,7 +32,9 @@ RUN GOOS=linux GOARCH=amd64 go build \
./cmd/api
# 下载 golang-migrate 工具(使用 GOPROXY 加速)
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# 固定 v4.19.1:其 go.mod 仅要求 go 1.24.0,与基础镜像 Go 1.25.6 兼容;
# @latest如 v4.20.1)要求 Go >= 1.25.11,在 GOTOOLCHAIN=local 的 1.25.6 环境下构建失败
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
# ================================
# 阶段 2: 运行阶段

View File

@@ -719,6 +719,8 @@ KeyAuthToken 缺失
| `jwt.secret_key` | `JUNHONG_JWT_SECRET_KEY` |
| `logging.level` | `JUNHONG_LOGGING_LEVEL` |
三类新线上支付C 端套餐购买、C 端资产钱包充值、代理在线预存款充值)始终从对应启用商户池选择并冻结商户路由;商户池、成员缺失或停用时明确返回“暂无可用商户”,绝不回退旧综合支付配置。`merchant_id` 为空只代表留存期内的历史支付,其回调、查单和既有退款路径仍按 `payment_config_id` 双读,直到独立 Change 删除旧读取路径。
### 必填配置
以下配置项必须通过环境变量设置(无默认值或需要覆盖):

View File

@@ -29,6 +29,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)

View File

@@ -38,6 +38,7 @@ func generateAdminDocs(outputPath string) error {
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)

View File

@@ -18,6 +18,7 @@ import (
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
@@ -376,6 +377,7 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil),
)
refundService.SetNotificationOutbox(outbox.NewRepository())
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient))
refundService.SetLifecycleAudit(auditWriter)
if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil {
appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err))
@@ -448,6 +450,7 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
)
recovery := agentrechargeApp.NewRecoverOnlinePaymentService(
runtime.db,
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger),
paymentInfra.NewAlipayWapAdapter(integration, appLogger),
paymentInfra.NewFuiouScanAdapter(integration, appLogger),

View File

@@ -29,7 +29,7 @@
services:
api:
image: registry.boss160.cn/junhong/cmp-fiber-api:latest
image: registry.boss160.cn/junhong/cmp-fiber-api:${IMAGE_TAG:-latest}
container_name: junhong-cmp-api
restart: unless-stopped
ports:
@@ -102,7 +102,7 @@ services:
max-file: "3"
worker:
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
image: registry.boss160.cn/junhong/cmp-fiber-worker:${IMAGE_TAG:-latest}
container_name: junhong-cmp-worker
restart: unless-stopped
environment:

View File

@@ -74,6 +74,28 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
迁移失败时不启动新二进制;按失败迁移的事务状态决定处理,必要时恢复已确认可用的数据库备份。启动失败时覆盖回部署前备份的二进制,再恢复数据库备份(如迁移已改变数据库)。
### 商户池支付路由发布与回滚
本节是维护者操作清单不是已执行证据。迁移、生产发布、Redis 操作和富友真实渠道核验均由维护者执行;本轮未执行,不能以本地构建替代。
**前置条件**
1. 留存维护者指定测试环境或本地验证证据,且不得记录密钥或完整报文中的敏感凭证。富友仅沿用现有实现;未进行外部渠道实测不构成开发、测试部署、任务完成、归档或发布前置。
2. 确认本次商户池 Schema 迁移已完成可恢复备份及校验;停止服务后确认迁移锁影响、无长事务和可接受维护窗口。
3. 上传支持 `merchant_id`/`payment_config_id` 双读的 API 与 Worker 二进制。商户池新支付没有运行时开关。
**发布后检查**
1. 由维护者执行迁移并部署双读二进制。C 端套餐购买、C 端资产钱包充值、代理在线预存款充值的后续新支付立即经启用商户池创建并冻结 `merchant_id`、商户池与 `routing_epoch`
2. 无可用商户池、成员缺失或池停用必须稳定失败,不得回退旧综合支付配置或自动换商户;后台线下订单、后台钱包余额支付和员工线下代充值不经过商户池。
3. 检查首次成功唯一累计,以及回调/查单/退款 A 对 `merchant_id` 新单和 `payment_config_id` 历史单的双读分流应用、审计和集成日志不得包含凭证、私钥、Token、证书或完整敏感配置。
**回滚与记录**
1. 不存在关闭商户池新支付创建的运行时开关。故障只能在仍支持双读的二进制上前向修复,不得恢复旧综合支付配置创建。
2. 只要存在 `merchant_id` 非空支付、成功累计事实或新商户池配置,禁止部署不识别新路由的旧二进制,也禁止执行破坏这些事实的 down 迁移。
3. 维护者记录二进制版本、时间、目标 PostgreSQL/Redis 的脱敏标识、备份校验、验证结果与全部阻塞原因。
### 零金额退款发布后核验
发布本次退款审批变更后,维护者应先等待既有重试处理稳定事件 `approval:26:approved`;若重试已耗尽,按受控运维流程重放同一事件,不得直接修改退款、订单或钱包数据。随后核验:

View File

@@ -300,3 +300,16 @@
- **Owner**:基础设施负责人
- **最后验证日期**2026-08-07
- **更新触发条件**:新增配置或依赖升级
## ENG-TEST-001
- **状态**:生效
- **适用范围**Agent 执行的迁移、Redis、API/Worker、部署与集成 Smoke 验证。
- **规则**:维护者指定的测试 PostgreSQL `junhong_cmp_test`、Redis DB 6、`Iteration/8-11` 测试部署和 `cmp-test` 日志主机构成唯一测试验证面MUST 使用该环境不额外要求独立数据库、Redis DB 或 namespace。迁移从本地工作区以明确 `DB_*` 参数执行;测试 fixture 仅可创建、删除当前 Change 自己的记录MUST NOT 重置整个测试库。测试部署通过 Gitea 工作流完成SSH 仅用于日志、容器状态与受控 Smoke。
- **理由**:同一可控测试面避免每个 Change 重复索取环境,且保留可复现的迁移、缓存、并发和部署证据。
- **最小正例**:当前 Change 在 `junhong_cmp_test` 执行迁移 up/down/up清理自己的 fixture并在测试部署后读取 `cmp-test` 日志。
- **最小反例**:以未提供额外“隔离环境”为由暂停,或重置整个测试数据库。
- **机械检查/人工原因**记录显式目标、迁移命令、fixture 清理范围、Redis 操作与测试部署 SHA通过 API/Worker 日志和可观察状态核对。
- **例外条件**:真实支付渠道和外部审批系统不作自动验证;第三方协议变更以契约文档和维护者提供的证据为准。生产仍按生产运行说明由维护者执行。
- **Owner**:基础设施负责人
- **最后验证日期**2026-09-08
- **更新触发条件**测试库、Redis DB、部署分支、测试主机或验证授权变化

View File

@@ -9,7 +9,9 @@
## 当前实际使用范围
系统使用微信预下单 `POST <ApiURL>/wxPreCreate` 与支付通知。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);未发现退款、撤销或查单能力
系统当前使用微信预下单 `POST <ApiURL>/wxPreCreate` 与支付通知;代理在线充值恢复流程另有本地 `CommonQuery` 调用,用于主动查询支付订单状态。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);本地 `CommonQuery` 代码保持现有请求格式、签名算法、状态映射和恢复语义不变。该源码事实仅表示本地候选实现及后续双读配置来源改造接缝,不证明真实富友渠道契约,也不证明验签、状态解释或恢复核验已通过
真实核验仍需隔离富友商户、明确接口权限、合法订单样本和允许的网络条件;在这些条件完成前,不得将查单、验签、状态映射或恢复结果作为上线证据。本 Change 不新增富友退款能力。
## 配置、认证与传输

View File

@@ -0,0 +1,114 @@
# AUG26-013 实施与验证记录
## 当前完成范围
- 已完成并在 Change 任务中勾选1.1 至 4.2。3.1 至 4.2 的勾选适用下文“用户授权的完成判定”,不等同于所有运行时场景已经实际通过。
- 运行时全量验收未执行:没有在严格隔离环境完整覆盖 H5 会话、消费者联调及查询计数;历史已执行的有限只读验收及工程命令见下文。
- 未修改 Schema、迁移、套餐状态、金额、退款或外部支付审批流程。
## 已执行的脱敏命令与结果
| 命令 | 结果 |
| --- | --- |
| `gofmt -w internal/query/asset/package_history.go internal/service/asset/service.go internal/handler/app/client_asset.go internal/model/dto/asset_dto.go internal/model/dto/client_asset_dto.go` | 成功,无输出。 |
| `go build ./cmd/api ./cmd/worker` | 初次因废弃的 `sort` 导入失败,移除后以临时可写 Go 缓存重新执行成功。 |
| `go run cmd/gendocs/main.go` | 成功生成 `docs/admin-openapi.yaml`。 |
| `openspec validate add-asset-package-hierarchy --strict` | 成功:`Change 'add-asset-package-hierarchy' is valid`。 |
| `openspec doctor --json` | 成功root healthy`status: []`。 |
此前曾创建后删除一个仅测试纯函数的 `internal/query/asset/package_history_smoke_test.go` 并运行 `go test`。该行为不符合项目“自动化测试 N/A”的后续执行约束文件已删除结果不作为任务 3.x 的隔离环境入口验收证据。后续不再创建 `*_test.go` 或运行 `go test`
## 本轮局部兼容修复
- 独立审查确认:旧 H5 历史使用的 `AssetPackageResponse` 会无条件序列化零值 `order_id:0`;新的 `ClientAssetPackageHistoryNode` 曾遗漏该可观察字段。
- 已仅在 H5 历史专用 DTO 恢复 `OrderID uint json:"order_id"`,中文说明明确该接口不填充真实订单 ID零值仍输出为 `0`;后台 DTO、公共 DTO 和 H5 映射均未改动。
- 第二项审查结论:上述四个历史数组在生成 OpenAPI 中均误标 `nullable:true`,但运行时契约要求始终返回 `[]`。已仅为 `AssetPackageHistoryNode.Children``ClientAssetPackageHistoryNode.Children``AssetPackagesResult.Items``AssetPackageHistoryResponse.List` 添加项目生成器支持的 `nullable:"false"` tag未扩展生成器行为未手改 YAML。
- 独立审查最终结论为 Standards 0 项确认问题、Spec 2 项确认问题;以上两项均已按限定范围修复并由下述局部 smoke 覆盖。
### 本轮精确局部验证
| 命令 | 可观察结果 |
| --- | --- |
| `gofmt -w internal/model/dto/asset_dto.go internal/model/dto/client_asset_dto.go && go build ./cmd/api ./cmd/worker && go run cmd/gendocs/main.go` | 命令退出成功;`gofmt` 无输出Go 在构建时输出一次模块缓存 stat 写入权限诊断,但未使构建命令失败;生成器输出“成功在以下位置生成 OpenAPI 文档”。 |
| `go run asset_package_history_contract_smoke.go` | 输出 `history JSON arrays, H5 order compatibility, and four OpenAPI nonnullable arrays verified`;程序随后删除。它核对后台/H5 主子 `children` 与空 `items` 均为数组、普通主项 `master_usage_id:null`、H5 主子 `order_id:0` 且不出现订单号/退款/金额/生效条件字段,并解析生成 OpenAPI 确认四个数组字段为 `type: array` 且非 nullable同时确认公共 `DtoAssetPackageResponse` 未增加层级字段。 |
此为 DTOJSON生成文档的局部契约 smoke不触发真实 API、数据库或消费者联调不替代任务 3.x 或 4.1 的实际验收。
## 真实测试环境只读验收(本轮)
- 用户已明确 `.env.local` 指向测试环境;本轮按该事实执行,未访问生产环境。
- `source .env.local` 仅在子进程内完成且未回显值。脱敏核对显示数据库、Redis、JWT 及服务地址必需项均存在;数据库与 Redis 主机均为外部主机,仅以 SHA-256 前 12 位标记记录,未记录凭据、原始库名或地址。
- PostgreSQL 连接固定设置 `PGOPTIONS=-c default_transaction_read_only=on`;首个查询成功确认 `transaction_read_only=on`,并确认 `tb_package_usage``tb_package``tb_iot_card``tb_device``tb_personal_customer` 存在。未执行任何迁移、DDL、DML、事务写入或外部支付审批调用。
- 最小 API 首次以 README 所示的 `go run cmd/api/main.go` 启动,因 `undefined: generateOpenAPIDocs` 退出;改为 `go run ./cmd/api` 后监听 `127.0.0.1:18181` 成功。启动前只读确认有 4 个启用超级管理员,因此 `initDefaultAdmin` 只会走存在检查与跳过分支。进程的日志仅写入 `/tmp`,验收后已停止。
- 配置 Redis DB `7` 无既有后台或 H5 会话,复用会导致后台历史请求返回 `401/code=1003`。按照用户批准的临时替代入口,仅本地 API 进程覆盖 `JUNHONG_REDIS_DB=0`,复用该 DB 中已有的 15 个超级管理员会话;未调用登录、开发登录、刷新、登出,不创建 H5 会话、不写 Redis、不伪造 JWT。随后后台入口返回 `200/code=0`
### 脱敏 SQL 与 HTTP 结果
| 范围 | 只读 SQLHTTP 摘要 | 实际结果 |
| --- | --- | --- |
| 数据关系盘点 | 对未软删除 `tb_package_usage` 聚合 `master_usage_id`、状态、退款、父记录存在性与软删除商品 | 共 78 条主项、0 条子项;物理缺失主项、软删父项、软删商品、退款子项、待生效子项、失效/过期/用尽子项、非待生效且无生效时间子项均为 0。卡与设备均不存在任何主子关系组。 |
| 后台候选卡 | 对资产标识 SHA-256 前缀 `1f0b3a3a5ee3` 查询 usage`GET /api/admin/assets/<hash>/packages` 使用已有超级管理员 token | SQL 得到 3 条顶层主项,状态分布为生效中 1、失效 2`master_usage_id=NULL``page=1&page_size=100` 返回 `200/code=0``total=3`、3 项、全部 `children=[]`;返回 ID 的哈希序与 SQL `created_at DESC,id DESC` 完全一致。 |
| 后台分页 | 同一候选卡依次 GET `page=2&page_size=1``page=99&page_size=1` | 第 2 页恰为 SQL 的第二个顶层项;超末页返回 `items=[]` 且保留 `total=3`。 |
| 后台状态筛选 | 同一候选卡 GET `status=1` 与无数据的 `status=0` | `status=1` 返回 `total=1` 的完整顶层项;`status=0` 返回 `total=0/items=[]`。 |
| 载体边界 | 对设备标识 SHA-256 前缀 `483c5060f9a9` 查询 usage 并 GET 后台历史 | SQL 为 24 条设备 usage、0 子项、1 个世代HTTP 返回 `200/code=0``total=24``page_size=1`、首项无子项。卡与设备均在各自资产范围内响应,未见跨载体内容。 |
| 当前套餐兼容 | `GET /api/admin/assets/<card-hash>/current-package` | `200/code=0`;实际 JSON 不含 `children``master_usage_id`,未被历史层级 DTO 连带改变。修改套餐接口是写接口,受本轮只读限制未调用。 |
| 查询次数可观测性 | 只读检查 `pg_stat_statements` 扩展及关系 | 扩展和关系均不存在;无法在不改变数据库配置或添加日志的前提下取得本次 HTTP 的精确 SQL 调用计数。 |
### 先前真实数据覆盖结论(当时)
| 任务 | 结论 | 未完成的精确原因 |
| --- | --- | --- |
| 3.1 | 后台已部分验证;当时不勾选 | 实际数据只有多主/无子项,子项总数为 0没有多子项、子项超过页大小、异常独立项。H5 没有既有会话,不能执行该入口的真实 JSON 验收。 |
| 3.2 | 后台仅验证状态命中与无匹配;当时不勾选 | 没有任何主子组H5 无会话,故未验证子项命中、同成员状态+类型联合、类型筛选及软删除商品资格。 |
| 3.3 | 仅观测后台顶层生效中/失效历史仍可返回;当时不勾选 | 无子项,且所需过期、用尽、退款、三桶、空生效时间和并列子项排序测试数据均不存在。 |
| 3.4 | 当时不勾选 | 物理缺失、软删父项、跨资产父项、H5 跨世代、商品缺失与存在性查询失败样本均不存在;不得造数或人为制造读取错误。 |
| 3.5 | 已实际观察卡/设备后台范围和当前套餐 JSON当时不勾选 | 无多世代样本、无既有代理会话、无 H5 会话;金额隔离只能通过 H5 实际响应验证,不能以 DTO 或 Query 代替。修改套餐接口为写接口,未调用。 |
| 3.6 | 后台分页壳和顶层稳定排序已实际观察;当时不勾选 | H5 无会话,无法比较相同集合;`pg_stat_statements` 不可用,无法获得 usage存在性商品批量查询次数的真实计数无主子样本也不能验证子项顺序或 N+1 边界。 |
| 3.7 | 当时不勾选 | 仓库无可联调的后台或 H5 消费者工程;未取得外部消费者对层级、默认展开、总数、整组筛选、整体错误及 API消费者同步发布回滚的联调确认。 |
当时 `tasks.md` 的 3.1—3.7、4.1、4.2 均保持未勾选。未修改 Go 源码或 OpenAPI 源,故该轮未重复 `gofmt``go build``gendocs`、OpenSpec validate 或 doctor第 4.x 的既有记录见上文,不能替代缺失的 H5 与主子实际验收。
### H5 临时会话可行性复核(第二轮,仅代码追踪与只读 SQL
- `internal/service/client_auth/service.go:940-975``DevLogin` 在事务内调用 `findOrCreateCustomer``bindAsset`,不是仅写 Redis 的认证入口。
- 即使 OpenID 已存在,`findOrCreateCustomer` 也会在 `service.go:718-749` 读取客户后无条件执行 `customerStore.Update(ctx, customer)`746 行);昵称、头像为空时不会改变内存字段,但仍不能排除业务表 `UPDATE`。未命中时 799-823 行会创建个人客户和 OpenID 记录。
- `bindAsset` 转至 `customer_binding.Service.Bind``service.go:891-893`);已有有效绑定的 PCD/PCI 分支会在 `customer_binding/service.go:265-288` 或 313-341 行返回而不创建绑定,但这不能消除前述客户 `UPDATE`。未绑定时相应的 271-302 或 319-349 行会创建绑定、首次绑定可修改资产并写审计。
- 只读 SQL 盘点:`tb_personal_customer_openid``app_id='dev_test_app' AND open_id LIKE 'dev_test_%'` 为 0 条、0 个客户;有 usage 的资产中,卡为 7 个6 个已有有效 H5 绑定)、设备为 6 个6 个已有有效 H5 绑定),但这些绑定均不属于确定的 `dev_test` 客户;所有绑定资产的子项数及多 usage 世代数均为 0。
若需继续完成 H5 真实验收,仅接受以下任一前置:
1. 提供与候选资产有效绑定对应的既有、可只读复用的 H5 会话或凭据;会话获取路径不得创建客户、绑定或其他业务记录。
2. 维护者明确书面授权在测试环境写入可回滚的业务 fixture并明确 fixture 的创建、回滚负责人和范围fixture 至少覆盖 H5 客户绑定、主子多项/跨世代/异常关系、商品类型资格、授权及金额隔离。未经该授权不得造数或调用会写业务表的认证入口。
- 因而本测试库没有“已存在确定 dev_test 客户+目标资产已有有效绑定”的安全前置,且即使该前置存在,当前实现仍无法排除个人客户表 `UPDATE`。本轮不调用 `DevLogin`、不请求用户凭据、不伪造 JWTH5 真实 GET 继续保持未验收。
## 已核对的生成文档
`docs/admin-openapi.yaml` 已包含:
- 后台 `GET /api/admin/assets/{identifier}/packages` 的全部世代层级、顶层 total 与关系异常说明;
- H5 `GET /api/c/v1/asset/package-history` 的当前世代、同一成员联合筛选与关系异常说明;
- `DtoAssetPackageHistoryNode``DtoClientAssetPackageHistoryNode``children``expand_by_default``relationship_status``relationship_status_name` 字段;
-`DtoAssetPackageResponse` 和当前套餐/修改套餐路由仍存在。
- `.gitignore` 明确忽略 `docs/admin-openapi.yaml`,且该路径不在 Git 跟踪清单;它是本地生成产物而非提交源文件。
- 项目交付方式是 `go run cmd/gendocs/main.go`(等价 Make 目标 `docs`):生成器将路由注册结果写入该固定路径。本轮已重新生成;`DtoClientAssetPackageHistoryNode.order_id` 位于生成文件 4075—4078 行,四个非 nullable 数组位于后台节点 2652—2656 行、后台列表 2871—2875 行、H5 节点 4050—4054 行及 H5 列表 2753—2757 行。当前套餐与修改套餐路由仍以公共 `DtoAssetPackageResponse` 为输出(路由 55、64、73 行),其生成 schema 保持在 2777—2868 行。
后台 API 的有限真实 JSON 验收已见“真实测试环境只读验收”H5 会话及主子/异常/授权测试数据缺失,运行时全量验收未执行。
## 用户授权的完成判定
用户已明确将本 Change 的完成门槛改为“功能实现已覆盖即可勾选”不再要求严格隔离环境、H5 会话、消费者联调或查询次数实测。本节据此记录 3.1—4.2 的勾选依据;这些勾选**不表示**完整实际环境、H5 消费者联调或发布回滚已经通过,运行时全量验收未执行。
| 任务 | 勾选依据 | 未作出的运行时声明 |
| --- | --- | --- |
| 3.1 | `PackageHistoryQuery.List` 先完整读取资产/世代范围内未软删除 usage、建立主子组与异常独立项再计算顶层 `total` 并只切顶层页空结果与超末页返回空数组且保留真实总数。后台、H5 都接入该 Query。 | 未在完整严格隔离环境以多子项和超页数据实际请求两端。 |
| 3.2 | 同一 `matchesPackageHistoryUsage` 同时判定 `status` 和类型资格;组内任一成员命中即保留完整组。类型资格查询使用默认软删除范围,故已软删除商品不成为 H5 类型命中。 | 未以真实 H5 会话和对应 fixture 复现全部组合。 |
| 3.3 | 三桶排序由 `packageHistoryChildBucket``packageHistoryChildLess` 固定实现:非待生效且有生效时间、非待生效空生效时间、待生效;桶内 ID 升序顶层创建时间ID 降序,映射直接保留原状态、退款及关联字段。 | 未在实际数据中覆盖每种失效/退款及并列排序组合。 |
| 3.4 | 未解析主 ID 只批量最小存在性核对;物理不存在生成 `master_missing`,存在但不在可展示集合或核对失败返回统一读取错误。商品批量读取与 usage 关系判定分离,展示映射保留 usage 名称快照并允许商品缺失回退。 | 未人为制造物理缺失、软删除、跨资产/世代或数据库失败。 |
| 3.5 | Query 仅接受 carddevice 并按对应载体过滤后台不传世代、H5 传资产当前世代且先做有效绑定校验。后台历史 DTO 仅平台填充成本价H5 专用 DTO 未映射订单、退款、金额或生效条件;当前套餐和修改路由继续使用公共 DTO。 | 未完成代理、H5、跨世代和修改写接口的全量实际验收存量企业授权问题未声称修复。 |
| 3.6 | 两端复用同一层级 Query 与排序;后台保留 `items/total/page/page_size`H5 保留 `items/total/page/size`。usage 一次集合读取、未解析主 ID 去重后至多一次核对、类型资格和商品均按 ID 批量读取后台商品读取失败快照降级H5 返回读取失败。 | 未实测 SQL 查询计数,未以同一真实集合比对两端顺序。 |
| 3.7 | proposal 明确消费者须同步适配层级、默认展开、顶层总数、整组筛选与整体错误;两条受认证路由及其 OpenAPI 输出类型、说明均已表达该契约。design 记录 API消费者同步发布与共同回滚为原平铺契约且无 Schema写入数据回滚。 | 未取得真实后台或 H5 消费者联调、同步发布或回滚确认,不宣称展示验收完成。 |
| 4.1 | 本文已有 `gofmt``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go` 成功记录,以及 DTOJSON生成 OpenAPI 的局部 smoke有限后台实际 JSON 和当前套餐兼容观察也已记录。 | 本轮未重跑工程命令H5 实际 JSON 与两端完整运行时兼容未验收。 |
| 4.2 | 本文已有 `openspec validate add-asset-package-hierarchy --strict` 成功与 `openspec doctor --json` healthy`status: []` 记录;自动化测试按项目决策为 N/A未运行迁移或真实外部业务调用。 | 未把此前有限 smoke 表述为完整运行时验收。 |
因此3.1—4.2 的完成状态代表源码实现、既有局部 smoke 和已记录工程命令已覆盖用户授权的完成门槛;其余尚未执行的运行时场景保持如实记录。

View File

@@ -210,7 +210,8 @@
"requirements": [
"agent-funds-commission::充值边界",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位"
"order-payment-wallet::金额单位",
"agent-funds-commission::代理在线充值可用支付方式按支付配置判定"
],
"classification": "behavior"
},
@@ -330,7 +331,12 @@
"capability": "asset-device",
"requirements": [
"asset-device::资产业务状态",
"identity-access::数据范围拒绝"
"identity-access::数据范围拒绝",
"package-lifecycle::资产套餐层级投影",
"package-lifecycle::套餐历史整组筛选与分页",
"package-lifecycle::套餐历史稳定排序",
"package-lifecycle::物理缺失与不可展示关系区分",
"package-lifecycle::套餐历史读取范围与兼容边界"
],
"classification": "behavior"
},
@@ -390,7 +396,8 @@
"capability": "operations-audit",
"requirements": [
"identity-access::数据范围拒绝",
"operations-audit::审计时间线"
"operations-audit::审计时间线",
"operations-audit::审计调查留存边界连续"
],
"classification": "behavior"
},
@@ -470,7 +477,8 @@
"capability": "operations-audit",
"requirements": [
"identity-access::数据范围拒绝",
"operations-audit::审计时间线"
"operations-audit::审计时间线",
"operations-audit::审计调查留存边界连续"
],
"classification": "behavior"
},
@@ -1356,7 +1364,12 @@
"entry": "GET /api/c/v1/asset/package-history",
"capability": "personal-customer",
"requirements": [
"personal-customer::资产归属查询"
"personal-customer::资产归属查询",
"package-lifecycle::资产套餐层级投影",
"package-lifecycle::套餐历史整组筛选与分页",
"package-lifecycle::套餐历史稳定排序",
"package-lifecycle::物理缺失与不可展示关系区分",
"package-lifecycle::套餐历史读取范围与兼容边界"
],
"classification": "behavior"
},
@@ -1514,7 +1527,8 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证"
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
],
"classification": "behavior"
},
@@ -1524,7 +1538,8 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证"
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
],
"classification": "behavior"
},
@@ -1534,7 +1549,8 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证"
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
],
"classification": "behavior"
},
@@ -1544,7 +1560,8 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证"
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
],
"classification": "behavior"
},
@@ -1751,7 +1768,8 @@
"agent-funds-commission::充值边界",
"external-integration::富友调用超时兼容行为",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位"
"order-payment-wallet::金额单位",
"external-integration::富友主扫统一下单与订单查询"
],
"classification": "behavior"
},
@@ -2304,7 +2322,10 @@
"capability": "order-refund-exchange",
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁"
"order-refund-exchange::订单、退款与换货状态门禁",
"refund-approval::零金额退款审批通过",
"refund-approval::零金额退款不产生资金回款",
"refund-approval::非法退款金额仍被拒绝"
],
"classification": "behavior"
},
@@ -3283,14 +3304,9 @@
"entry_type": "async",
"entry": "constants.TaskTypeAuditDailyArchive",
"capability": "infrastructure",
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeAuditMonthlyRetention",
"capability": "infrastructure",
"requirements": [],
"requirements": [
"operations-audit::审计归档与日留存受控执行"
],
"classification": "route_index_or_infrastructure"
},
{
@@ -3399,14 +3415,10 @@
"entry_type": "async",
"entry": "constants.TaskTypeIntegrationDailyArchive",
"capability": "infrastructure",
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeIntegrationMonthlyFinalize",
"capability": "infrastructure",
"requirements": [],
"requirements": [
"external-integration::外部交互日志归档与留存受控执行",
"external-integration::外部交互日志逐日物理留存"
],
"classification": "route_index_or_infrastructure"
},
{
@@ -3466,7 +3478,11 @@
"entry_type": "async",
"entry": "constants.TaskTypePackageQueueActivation",
"capability": "infrastructure",
"requirements": [],
"requirements": [
"package-queue-activation::当前主套餐过期后自动激活下一个",
"package-queue-activation::孤儿待生效套餐必须公平恢复",
"package-queue-activation::Asynq 激活结果必须准确且可重试"
],
"classification": "route_index_or_infrastructure"
},
{
@@ -3474,7 +3490,12 @@
"entry": "constants.TaskTypePollingCardStatus",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态"
"polling-operations::手工轮询任务状态",
"external-integration::高频轮询外部交互日志保留边界",
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
],
"classification": "behavior"
},
@@ -3483,7 +3504,12 @@
"entry": "constants.TaskTypePollingCarddata",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态"
"polling-operations::手工轮询任务状态",
"external-integration::高频轮询外部交互日志保留边界",
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
],
"classification": "behavior"
},
@@ -3492,7 +3518,9 @@
"entry": "constants.TaskTypePollingPackage",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态"
"polling-operations::手工轮询任务状态",
"polling-load-control::轮询全局并发与背压",
"polling-operations::停复机遵循实际生效实名策略"
],
"classification": "behavior"
},
@@ -3501,7 +3529,8 @@
"entry": "constants.TaskTypePollingProtect",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态"
"polling-operations::手工轮询任务状态",
"polling-load-control::轮询全局并发与背压"
],
"classification": "behavior"
},
@@ -3510,7 +3539,12 @@
"entry": "constants.TaskTypePollingRealname",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态"
"polling-operations::手工轮询任务状态",
"external-integration::高频轮询外部交互日志保留边界",
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
],
"classification": "behavior"
},
@@ -3560,5 +3594,44 @@
"order-refund-exchange::历史待审批退款可主动接入企业微信审批"
],
"classification": "behavior"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeAuditDailyRetention",
"capability": "operations-audit",
"requirements": [
"external-integration::外部交互日志归档与留存受控执行",
"external-integration::外部交互日志逐日物理留存",
"operations-audit::审计归档与日留存受控执行",
"operations-audit::审计在线数据逐日物理留存",
"operations-audit::日留存独立运行日志"
],
"classification": "behavior"
},
{
"entry_type": "cli",
"entry": "scripts/migration/migrate_runtime.py",
"capability": "qicheng-migration-package-lifecycle-overrides",
"requirements": [
"qicheng-migration-package-lifecycle-overrides::迁移配置支持逐卡套餐生命周期覆盖",
"qicheng-migration-package-lifecycle-overrides::覆盖必须完整且可验证",
"qicheng-migration-package-lifecycle-overrides::未覆盖资产维持严格冲突阻断",
"qicheng-migration-package-usage::迁移套餐使用记录保存计时条款快照",
"qicheng-migration-package-usage::迁移套餐使用 SQL 满足快照完整性约束"
],
"classification": "behavior"
},
{
"entry_type": "cli",
"entry": "scripts/migration/import_shops.py",
"capability": "shop-bulk-import",
"requirements": [
"shop-bulk-import::导入输入必须在生成 SQL 前完成全量预检",
"shop-bulk-import::生成的 SQL 必须在写入前校验目标库状态",
"shop-bulk-import::批量导入 SQL 必须创建完整的店铺初始事实",
"shop-bulk-import::导入执行必须由维护者审核后以单事务手工确认",
"shop-bulk-import::导入结果必须可审核和重跑定位"
],
"classification": "behavior"
}
]

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -81,7 +82,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
command.MerchantIdentity = strings.TrimSpace(command.MerchantIdentity)
command.ThirdPartyTradeNo = strings.TrimSpace(command.ThirdPartyTradeNo)
if command.PaymentNo == "" || command.ConfigID == 0 || command.PaidAt.IsZero() {
if command.PaymentNo == "" || command.PaidAt.IsZero() {
return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
}
@@ -91,6 +92,9 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
if err != nil {
return err
}
if payment.MerchantID == nil && command.ConfigID == 0 {
return errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
}
alreadyConfirmed, err := domain.ValidatePaymentConfirmation(toDomainConfirmationFacts(payment, recharge, command))
if err != nil {
return err
@@ -114,6 +118,10 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
if paymentUpdate.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "代理充值支付单状态已变化")
}
if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, paidAt); err != nil {
return err
}
rechargeUpdate := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status IN ?", recharge.ID, []int{constants.RechargeStatusPending, constants.RechargeStatusClosed}).
Updates(map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt})
@@ -127,7 +135,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
EventID: "agent-recharge:" + strconv.FormatUint(uint64(recharge.ID), 10) + ":payment-confirmed",
RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, PaymentID: payment.ID, PaymentNo: payment.PaymentNo,
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaymentMethod: payment.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
ParentEventID: command.ParentEventID,
}
@@ -186,7 +194,9 @@ func toDomainConfirmationFacts(payment *model.Payment, recharge *model.AgentRech
OrderType: payment.OrderType, ExpectedOrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: payment.PaymentMethod, RechargePaymentMethod: recharge.PaymentMethod, RechargePaymentChannel: rechargeChannel,
PaymentConfigID: paymentConfigID, RechargePaymentConfigID: rechargeConfigID, ConfirmedConfigID: command.ConfigID,
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
FrozenMerchant: payment.MerchantID != nil, FrozenMerchantPaymentMethod: payment.MerchantPaymentMethodSnapshot,
FrozenMerchantProviderType: payment.MerchantProviderTypeSnapshot,
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
PaymentAmount: payment.Amount, RechargeAmount: recharge.Amount, ConfirmedAmount: command.Amount,
PaymentOrderID: payment.OrderID, RechargeID: recharge.ID, PaymentState: domain.PaymentState(payment.Status),
RechargeStatus: recharge.Status, StoredTradeNo: payment.ThirdPartyTradeNo, ConfirmedTradeNo: command.ThirdPartyTradeNo,

View File

@@ -11,6 +11,7 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -46,21 +47,22 @@ type AvailablePaymentMethodsResult struct {
// OnlineCreationService 创建代理在线扫码充值单。
type OnlineCreationService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
audit PaymentAuditWriter
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
audit PaymentAuditWriter
}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入三个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入运行时路由和三个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
}
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil {
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
@@ -82,11 +84,11 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
return replay, err
}
account, shop, wallet, config, adapter, err := s.loadCreationFacts(ctx, command)
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
if err != nil {
return nil, err
}
result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config)
result, config, adapter, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet)
if err != nil {
if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found {
return replay, replayErr
@@ -125,7 +127,7 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
return result, nil
}
// AvailablePaymentMethods 按固定顺序返回配置完整的在线支付方式。
// AvailablePaymentMethods 按固定顺序返回已有可用商户池且凭证完整的在线支付方式。
func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) {
result := AvailablePaymentMethodsResult{
Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount,
@@ -133,18 +135,38 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
if userType != constants.UserTypeAgent {
return result, apperrors.New(apperrors.CodeForbidden, "仅代理账号可以查询在线支付方式")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return result, nil
for _, method := range []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay} {
var merchants []model.PaymentMerchant
err := s.db.WithContext(ctx).
Model(&model.PaymentMerchant{}).
Joins("JOIN tb_payment_merchant_pool_member AS member ON member.merchant_id = tb_payment_merchant.id AND member.deleted_at IS NULL").
Joins("JOIN tb_payment_merchant_pool AS pool ON pool.id = member.pool_id AND pool.deleted_at IS NULL").
Where("pool.payment_method = ? AND pool.status = ? AND tb_payment_merchant.payment_method = ? AND tb_payment_merchant.status = ?", method, model.PaymentMerchantStatusEnabled, method, model.PaymentMerchantStatusEnabled).
Order("member.sort_order ASC").Find(&merchants).Error
if err != nil {
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询代理在线支付商户池失败")
}
for index := range merchants {
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
merchant := &merchants[index]
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
var authErr error
authorization, authErr = s.runtime.LoadAuthorization(ctx)
if authErr != nil {
continue
}
}
config, configErr := merchantpayment.MerchantConfig(merchant, authorization)
if configErr != nil {
continue
}
adapter := s.adapter(method, config)
if adapter != nil && adapter.Available(config) {
result.Methods = append(result.Methods, method)
break
}
}
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
if s.wechat.Available(&config) || s.fuiou.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
}
if s.alipay.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodAlipay)
}
return result, nil
}
@@ -152,40 +174,29 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
func (s *OnlineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOnlineCommand,
) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, error) {
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND user_type = ? AND status = ?", command.AccountID, constants.UserTypeAgent, constants.StatusEnabled).First(&account).Error; err != nil || account.ShopID == nil || *account.ShopID != command.CurrentShopID {
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
}
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.CurrentShopID, constants.StatusEnabled).First(&shop).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).Where("shop_id = ? AND wallet_type = ? AND status = ?", command.CurrentShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).First(&wallet).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
return nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
adapter := s.adapter(command.PaymentMethod, &config)
if adapter == nil || !adapter.Available(&config) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return &account, &shop, &wallet, &config, adapter, nil
return &account, &shop, &wallet, nil
}
func (s *OnlineCreationService) createLocalFacts(
@@ -195,36 +206,58 @@ func (s *OnlineCreationService) createLocalFacts(
account *model.Account,
shop *model.Shop,
wallet *model.AgentWallet,
config *model.WechatConfig,
) (*CreateOnlineResult, error) {
) (*CreateOnlineResult, *model.WechatConfig, OnlinePaymentPort, error) {
rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405"))
if err != nil {
return nil, err
return nil, nil, nil, err
}
paymentNo, err := newBusinessNo("PAY", fmt.Sprintf("%d", time.Now().UnixMilli()))
if err != nil {
return nil, err
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = model.DefaultAliPayExpireMinutes
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
record := &model.AgentRechargeRecord{
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
PaymentConfigID: &config.ID, Status: constants.RechargeStatusPending,
RequestID: &requestID, RequestFingerprint: &fingerprint,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
payment := &model.Payment{
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: command.PaymentMethod, MerchantIdentity: paymentMerchantIdentity(command.PaymentMethod, config),
Amount: command.Amount, Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID, ExpireAt: &expireAt,
return nil, nil, nil, err
}
var record *model.AgentRechargeRecord
var payment *model.Payment
var config *model.WechatConfig
var adapter OnlinePaymentPort
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, err := s.runtime.SelectForNewPaymentWithTx(ctx, tx, command.PaymentMethod, time.Now())
if err != nil {
return err
}
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
if route.Merchant.ProviderType == model.ProviderTypeWechat || route.Merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, err = s.runtime.LoadAuthorization(ctx)
if err != nil {
return err
}
}
config, err = merchantpayment.MerchantConfig(route.Merchant, authorization)
if err != nil {
return err
}
adapter = s.adapter(command.PaymentMethod, config)
if adapter == nil || !adapter.Available(config) {
return apperrors.New(apperrors.CodeNoPaymentConfig)
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = model.DefaultAliPayExpireMinutes
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
record = &model.AgentRechargeRecord{
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
Status: constants.RechargeStatusPending, RequestID: &requestID, RequestFingerprint: &fingerprint,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
payment = &model.Payment{
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: command.PaymentMethod, Amount: command.Amount,
Status: model.PaymentRecordStatusPending, ExpireAt: &expireAt,
}
merchantpayment.FreezeRoute(payment, route)
if err := tx.Create(record).Error; err != nil {
return err
}
@@ -238,9 +271,13 @@ func (s *OnlineCreationService) createLocalFacts(
})
})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) {
return nil, nil, nil, err
}
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
}
return &CreateOnlineResult{Recharge: record, Payment: payment}, nil
return &CreateOnlineResult{Recharge: record, Payment: payment}, config, adapter, nil
}
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {

View File

@@ -6,6 +6,7 @@ import (
"gorm.io/gorm"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -14,6 +15,7 @@ import (
// RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。
type RecoverOnlinePaymentService struct {
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
@@ -23,13 +25,13 @@ type RecoverOnlinePaymentService struct {
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
func NewRecoverOnlinePaymentService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
}
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
}
now := s.now().UTC()
@@ -52,7 +54,13 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er
for index := range payments {
payment := &payments[index]
recharge := recharges[payment.OrderID]
config := recoveryConfig(payment, configs)
config, configErr := s.recoveryConfig(ctx, payment, configs)
if configErr != nil {
if firstErr == nil {
firstErr = configErr
}
continue
}
if recharge == nil || config == nil {
if firstErr == nil {
firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置")
@@ -141,7 +149,7 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
configIDs := make([]uint, 0, len(payments))
for index := range payments {
rechargeIDs = append(rechargeIDs, payments[index].OrderID)
if payments[index].PaymentConfigID != nil {
if payments[index].MerchantID == nil && payments[index].PaymentConfigID != nil {
configIDs = append(configIDs, *payments[index].PaymentConfigID)
}
}
@@ -151,8 +159,10 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
}
var configRows []model.WechatConfig
if len(configIDs) > 0 {
if err := s.db.WithContext(ctx).Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值创建配置失败")
// merchant_id 为空仅为留存期内历史支付;独立 Change 删除旧路径前,
// 必须按其 payment_config_id 读取,包括已软删除的原始配置。
if err := s.db.WithContext(ctx).Unscoped().Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值历史配置失败")
}
}
recharges := make(map[uint]*model.AgentRechargeRecord, len(rechargeRows))
@@ -166,11 +176,27 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
return recharges, configs, nil
}
func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig {
if payment.PaymentConfigID == nil {
return nil
// recoveryConfig 对冻结商户支付单按当前凭证版本加载;历史支付单保持 payment_config_id 路径。
func (s *RecoverOnlinePaymentService) recoveryConfig(ctx context.Context, payment *model.Payment, configs map[uint]*model.WechatConfig) (*model.WechatConfig, error) {
if payment.MerchantID != nil {
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return nil, err
}
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, err = s.runtime.LoadAuthorization(ctx)
if err != nil {
return nil, err
}
}
return merchantpayment.MerchantConfig(merchant, authorization)
}
return configs[*payment.PaymentConfigID]
if payment.PaymentConfigID == nil {
return nil, nil
}
return configs[*payment.PaymentConfigID], nil
}
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error {

View File

@@ -0,0 +1,721 @@
// Package merchantpayment provides merchant pool payment routing use cases.
package merchantpayment
import (
"context"
"reflect"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/bytedance/sonic"
)
// ManagementService 负责商户、商户池与授权配置写入。
type ManagementService struct {
db *gorm.DB
audit systemconfigapp.AuditWriter
}
// NewManagementService 创建商户配置用例。
func NewManagementService(db *gorm.DB, audit systemconfigapp.AuditWriter) *ManagementService {
return &ManagementService{db: db, audit: audit}
}
func requireManager(ctx context.Context) error {
kind := middleware.GetUserTypeFromContext(ctx)
if kind != constants.UserTypeSuperAdmin && kind != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限访问支付商户配置")
}
return nil
}
func normalizePage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
return page, size
}
func validPaymentMethod(method string) bool {
return method == "wechat" || method == "alipay"
}
func validateMerchantConfiguration(paymentMethod, providerType, merchantIdentity string, credentials model.JSONB) error {
paymentMethod, providerType, merchantIdentity = strings.TrimSpace(paymentMethod), strings.TrimSpace(providerType), strings.TrimSpace(merchantIdentity)
if !validPaymentMethod(paymentMethod) || merchantIdentity == "" || len(credentials) == 0 {
return errors.New(errors.CodeInvalidParam, "支付商户配置不完整")
}
var config model.WechatConfig
raw, err := sonic.Marshal(credentials)
if err != nil || sonic.Unmarshal(raw, &config) != nil {
return errors.New(errors.CodeInvalidParam, "支付商户凭证格式无效")
}
switch paymentMethod {
case "wechat":
switch providerType {
case model.ProviderTypeWechat:
if config.WxMchID != merchantIdentity || strings.TrimSpace(config.WxAPIV3Key) == "" || strings.TrimSpace(config.WxCertContent) == "" || strings.TrimSpace(config.WxKeyContent) == "" || strings.TrimSpace(config.WxSerialNo) == "" || strings.TrimSpace(config.WxNotifyURL) == "" {
return errors.New(errors.CodeInvalidParam, "微信直连商户凭证不完整或身份不一致")
}
case model.ProviderTypeWechatV2:
if config.WxMchID != merchantIdentity || strings.TrimSpace(config.WxAPIV2Key) == "" || strings.TrimSpace(config.WxNotifyURL) == "" {
return errors.New(errors.CodeInvalidParam, "微信 v2 商户凭证不完整或身份不一致")
}
case model.ProviderTypeFuiou:
if config.FyMchntCd != merchantIdentity || strings.TrimSpace(config.FyInsCd) == "" || strings.TrimSpace(config.FyTermID) == "" || strings.TrimSpace(config.FyPrivateKey) == "" || strings.TrimSpace(config.FyPublicKey) == "" || strings.TrimSpace(config.FyAPIURL) == "" || strings.TrimSpace(config.FyNotifyURL) == "" {
return errors.New(errors.CodeInvalidParam, "富友商户凭证不完整或身份不一致")
}
default:
return errors.New(errors.CodeInvalidParam, "微信支付服务商类型无效")
}
case "alipay":
if providerType != "alipay" || config.AliAppID != merchantIdentity || strings.TrimSpace(config.AliPrivateKey) == "" || strings.TrimSpace(config.AliPublicKey) == "" || strings.TrimSpace(config.AliNotifyURL) == "" || strings.TrimSpace(config.AliReturnURL) == "" {
return errors.New(errors.CodeInvalidParam, "支付宝商户凭证不完整或身份不一致")
}
}
return nil
}
func validatePoolRequest(req dto.PaymentMerchantPoolRequest) error {
if !validPaymentMethod(strings.TrimSpace(req.PaymentMethod)) {
return errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
}
switch req.Strategy {
case model.PaymentMerchantStrategyAmount:
if req.ThresholdAmount == nil || *req.ThresholdAmount <= 0 || req.ThresholdCount != nil || req.StatisticCycle == nil || !validStatisticCycle(*req.StatisticCycle) || req.TimePeriodValue != nil || req.TimePeriodUnit != nil || req.TimePeriodStartedAt != nil {
return errors.New(errors.CodeInvalidParam, "金额轮询策略参数不完整")
}
case model.PaymentMerchantStrategyCount:
if req.ThresholdCount == nil || *req.ThresholdCount <= 0 || req.ThresholdAmount != nil || req.StatisticCycle == nil || !validStatisticCycle(*req.StatisticCycle) || req.TimePeriodValue != nil || req.TimePeriodUnit != nil || req.TimePeriodStartedAt != nil {
return errors.New(errors.CodeInvalidParam, "笔数轮询策略参数不完整")
}
case model.PaymentMerchantStrategyTime:
if req.TimePeriodValue == nil || *req.TimePeriodValue < 1 || req.TimePeriodUnit == nil || !validTimeUnit(*req.TimePeriodUnit) || req.TimePeriodStartedAt == nil || req.ThresholdAmount != nil || req.ThresholdCount != nil || req.StatisticCycle != nil {
return errors.New(errors.CodeInvalidParam, "时间轮询策略参数不完整")
}
default:
return errors.New(errors.CodeInvalidParam, "不支持的商户池轮询策略")
}
return nil
}
func validStatisticCycle(value string) bool {
return value == "round" || value == "day" || value == "month"
}
func validTimeUnit(value string) bool { return value == "minute" || value == "hour" || value == "day" }
func (s *ManagementService) writeAudit(ctx context.Context, tx *gorm.DB, operation, description, key, name string, id uint, identity, before, after map[string]any) error {
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "支付商户管理审计接缝未配置")
}
resourceID := strconv.FormatUint(uint64(id), 10)
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: key, Module: "payment_merchant", ResourceID: &resourceID, DisplayName: name,
Identity: identity, BeforeData: before, AfterData: after, Result: constants.AuditResultSuccess,
})
}
func merchantAuditIdentity(m *model.PaymentMerchant) map[string]any {
return map[string]any{"id": m.ID, "name": m.Name, "payment_method": m.PaymentMethod, "provider_type": m.ProviderType, "merchant_identity": m.MerchantIdentity, "status": m.Status, "credential_version": m.CredentialVersion}
}
func poolAuditIdentity(p *model.PaymentMerchantPool) map[string]any {
return map[string]any{"id": p.ID, "name": p.Name, "payment_method": p.PaymentMethod, "strategy": p.Strategy, "status": p.Status, "routing_epoch": p.RoutingEpoch}
}
func authorizationAuditIdentity(a *model.WechatAuthorization) map[string]any {
return map[string]any{"id": a.ID, "status": a.Status, "credential_version": a.CredentialVersion, "oa_app_id": a.OaAppID, "miniapp_app_id": a.MiniappAppID}
}
// CreateMerchant 创建独立管理的支付商户。
func (s *ManagementService) CreateMerchant(ctx context.Context, req dto.PaymentMerchantRequest) (*dto.PaymentMerchantResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if s == nil || s.db == nil || strings.TrimSpace(req.Name) == "" || len(req.Credentials) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "商户参数或凭证不完整")
}
if !validPaymentMethod(strings.TrimSpace(req.PaymentMethod)) {
return nil, errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
}
m := &model.PaymentMerchant{Name: strings.TrimSpace(req.Name), PaymentMethod: strings.TrimSpace(req.PaymentMethod), ProviderType: strings.TrimSpace(req.ProviderType), MerchantIdentity: strings.TrimSpace(req.MerchantIdentity), Credentials: req.Credentials, CredentialVersion: 1, Remark: strings.TrimSpace(req.Remark), BaseModel: model.BaseModel{Creator: middleware.GetUserIDFromContext(ctx), Updater: middleware.GetUserIDFromContext(ctx)}}
if req.Enabled {
m.Status = model.PaymentMerchantStatusEnabled
}
if m.MerchantIdentity == "" || m.ProviderType == "" {
return nil, errors.New(errors.CodeInvalidParam, "商户身份或服务商类型不能为空")
}
if err := validateMerchantConfiguration(m.PaymentMethod, m.ProviderType, m.MerchantIdentity, m.Credentials); err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(m).Error; err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigCreate, "创建支付商户", "payment_merchant:"+strconv.FormatUint(uint64(m.ID), 10), m.Name, m.ID, merchantAuditIdentity(m), nil, merchantAuditIdentity(m))
}); err != nil {
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付商户失败")
}
return merchantResponse(m), nil
}
// ListMerchants returns the privileged configuration projection.
// ListPools returns one page of merchant pools and their ordered members without per-pool member queries.
func (s *ManagementService) ListPools(ctx context.Context, req dto.PaymentMerchantPoolListRequest) ([]*dto.PaymentMerchantPoolResponse, int64, error) {
if err := requireManager(ctx); err != nil {
return nil, 0, err
}
page, size := normalizePage(req.Page, req.PageSize)
query := s.db.WithContext(ctx).Model(&model.PaymentMerchantPool{})
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计商户池失败")
}
var pools []model.PaymentMerchantPool
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&pools).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
}
poolIDs := make([]uint, 0, len(pools))
for index := range pools {
poolIDs = append(poolIDs, pools[index].ID)
}
membersByPool := make(map[uint][]uint, len(pools))
if len(poolIDs) > 0 {
var members []model.PaymentMerchantPoolMember
if err := s.db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
}
for index := range members {
member := &members[index]
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
}
}
result := make([]*dto.PaymentMerchantPoolResponse, 0, len(pools))
for index := range pools {
pool := &pools[index]
result = append(result, &dto.PaymentMerchantPoolResponse{ID: pool.ID, Name: pool.Name, PaymentMethod: pool.PaymentMethod, Enabled: pool.Status == model.PaymentMerchantStatusEnabled, Strategy: pool.Strategy, ThresholdAmount: pool.ThresholdAmount, ThresholdCount: pool.ThresholdCount, StatisticCycle: pool.StatisticCycle, TimePeriodValue: pool.TimePeriodValue, TimePeriodUnit: pool.TimePeriodUnit, TimePeriodStartedAt: pool.TimePeriodStartedAt, RoutingEpoch: pool.RoutingEpoch, MemberIDs: membersByPool[pool.ID], Remark: pool.Remark})
}
return result, total, nil
}
// GetPool 查询一个商户池及其有序成员。
func (s *ManagementService) GetPool(ctx context.Context, id uint) (*dto.PaymentMerchantPoolResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var pool model.PaymentMerchantPool
if err := s.db.WithContext(ctx).First(&pool, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
}
return poolResponse(ctx, s.db, &pool)
}
// ListMerchants 分页查询特权商户配置。
func (s *ManagementService) ListMerchants(ctx context.Context, req dto.PaymentMerchantListRequest) ([]*dto.PaymentMerchantResponse, int64, error) {
if err := requireManager(ctx); err != nil {
return nil, 0, err
}
page, size := normalizePage(req.Page, req.PageSize)
query := s.db.WithContext(ctx).Model(&model.PaymentMerchant{})
if req.PaymentMethod != nil {
query = query.Where("payment_method = ?", strings.TrimSpace(*req.PaymentMethod))
}
if req.Enabled != nil {
status := model.PaymentMerchantStatusDisabled
if *req.Enabled {
status = model.PaymentMerchantStatusEnabled
}
query = query.Where("status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
}
var rows []model.PaymentMerchant
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
}
result := make([]*dto.PaymentMerchantResponse, 0, len(rows))
for index := range rows {
result = append(result, merchantResponse(&rows[index]))
}
return result, total, nil
}
// GetMerchant 查询一个特权商户配置。
func (s *ManagementService) GetMerchant(ctx context.Context, id uint) (*dto.PaymentMerchantResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var m model.PaymentMerchant
if err := s.db.WithContext(ctx).First(&m, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
}
return merchantResponse(&m), nil
}
// UpdateMerchant 更新商户凭证和可变配置。
func (s *ManagementService) UpdateMerchant(ctx context.Context, id uint, req dto.PaymentMerchantUpdateRequest) (*dto.PaymentMerchantResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if req.PaymentMethod != nil && !validPaymentMethod(strings.TrimSpace(*req.PaymentMethod)) {
return nil, errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
}
var m model.PaymentMerchant
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&m, id).Error; err != nil {
return err
}
before := merchantAuditIdentity(&m)
previous := m
var refs int64
if err := tx.Model(&model.Payment{}).Where("merchant_id = ?", id).Count(&refs).Error; err != nil {
return err
}
if refs > 0 && ((req.PaymentMethod != nil && *req.PaymentMethod != m.PaymentMethod) || (req.ProviderType != nil && *req.ProviderType != m.ProviderType) || (req.MerchantIdentity != nil && *req.MerchantIdentity != m.MerchantIdentity)) {
return errors.New(errors.CodeConflict, "已被支付单引用,不能修改收款身份")
}
if req.Name != nil {
m.Name = strings.TrimSpace(*req.Name)
}
if req.PaymentMethod != nil {
m.PaymentMethod = strings.TrimSpace(*req.PaymentMethod)
}
if req.ProviderType != nil {
m.ProviderType = strings.TrimSpace(*req.ProviderType)
}
if req.MerchantIdentity != nil {
m.MerchantIdentity = strings.TrimSpace(*req.MerchantIdentity)
}
if req.Remark != nil {
m.Remark = strings.TrimSpace(*req.Remark)
}
if req.Enabled != nil {
m.Status = model.PaymentMerchantStatusDisabled
if *req.Enabled {
m.Status = model.PaymentMerchantStatusEnabled
}
}
if req.Credentials != nil && !reflect.DeepEqual(m.Credentials, *req.Credentials) {
m.Credentials = *req.Credentials
}
if previous.Name != m.Name || previous.PaymentMethod != m.PaymentMethod || previous.ProviderType != m.ProviderType || previous.MerchantIdentity != m.MerchantIdentity || previous.Status != m.Status || !reflect.DeepEqual(previous.Credentials, m.Credentials) {
m.CredentialVersion++
}
if err := validateMerchantConfiguration(m.PaymentMethod, m.ProviderType, m.MerchantIdentity, m.Credentials); err != nil {
return err
}
if err := tx.Save(&m).Error; err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigUpdate, "更新支付商户", "payment_merchant:"+strconv.FormatUint(uint64(m.ID), 10), m.Name, m.ID, merchantAuditIdentity(&m), before, merchantAuditIdentity(&m))
}); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
}
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新支付商户失败")
}
return merchantResponse(&m), nil
}
// DeleteMerchant 仅在未被引用且二次确认后删除商户。
func (s *ManagementService) DeleteMerchant(ctx context.Context, id uint, confirm bool) error {
if err := requireManager(ctx); err != nil {
return err
}
if !confirm {
return errors.New(errors.CodeInvalidParam, "删除商户必须二次确认")
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var refs, members int64
var merchant model.PaymentMerchant
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&merchant, id).Error; err != nil {
return err
}
before := merchantAuditIdentity(&merchant)
if err := tx.Model(&model.Payment{}).Where("merchant_id = ?", id).Count(&refs).Error; err != nil {
return err
}
if refs > 0 {
return errors.New(errors.CodeConflict, "已被支付单引用的商户不能删除")
}
if err := tx.Model(&model.PaymentMerchantPoolMember{}).Where("merchant_id = ?", id).Count(&members).Error; err != nil {
return err
}
if members > 0 {
return errors.New(errors.CodeConflict, "商户仍属于商户池")
}
r := tx.Delete(&model.PaymentMerchant{}, id)
if r.Error != nil {
return r.Error
}
if r.RowsAffected == 0 {
return errors.New(errors.CodeNotFound, "支付商户不存在")
}
if err := s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDelete, "删除支付商户", "payment_merchant:"+strconv.FormatUint(uint64(merchant.ID), 10), merchant.Name, merchant.ID, before, before, nil); err != nil {
return err
}
return nil
})
}
// SavePool 创建或更新商户池,并原子替换有序成员。
func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.PaymentMerchantPoolRequest) (*dto.PaymentMerchantPoolResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if err := validatePoolRequest(req); err != nil {
return nil, err
}
if len(req.MemberIDs) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "商户池至少需要一个商户")
}
var pool model.PaymentMerchantPool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
creating := id == 0
var previousMemberIDs []uint
if !creating {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pool, id).Error; err != nil {
return err
}
var previousMembers []model.PaymentMerchantPoolMember
if err := tx.Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&previousMembers).Error; err != nil {
return err
}
previousMemberIDs = make([]uint, 0, len(previousMembers))
for _, member := range previousMembers {
previousMemberIDs = append(previousMemberIDs, member.MerchantID)
}
} else {
pool.Creator = middleware.GetUserIDFromContext(ctx)
pool.RoutingEpoch = 1
}
before := poolAuditIdentity(&pool)
if err := validatePoolMembers(ctx, tx, req.PaymentMethod, req.MemberIDs); err != nil {
return err
}
if !creating && poolEpochChanged(&pool, &req, previousMemberIDs) {
pool.RoutingEpoch++
}
pool.Name, pool.PaymentMethod, pool.Strategy, pool.Remark = strings.TrimSpace(req.Name), strings.TrimSpace(req.PaymentMethod), strings.TrimSpace(req.Strategy), strings.TrimSpace(req.Remark)
pool.ThresholdAmount, pool.ThresholdCount, pool.StatisticCycle, pool.TimePeriodValue, pool.TimePeriodUnit, pool.TimePeriodStartedAt = req.ThresholdAmount, req.ThresholdCount, req.StatisticCycle, req.TimePeriodValue, req.TimePeriodUnit, req.TimePeriodStartedAt
if req.Enabled {
var others int64
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
return err
}
if others > 0 {
return errors.New(errors.CodeConflict, "该支付方式已有启用商户池")
}
}
pool.Status = model.PaymentMerchantStatusDisabled
if req.Enabled {
pool.Status = model.PaymentMerchantStatusEnabled
}
pool.Updater = middleware.GetUserIDFromContext(ctx)
if creating {
if err := tx.Create(&pool).Error; err != nil {
return err
}
} else if err := tx.Save(&pool).Error; err != nil {
return err
}
if err := tx.Where("pool_id = ?", pool.ID).Delete(&model.PaymentMerchantPoolMember{}).Error; err != nil {
return err
}
members := make([]model.PaymentMerchantPoolMember, 0, len(req.MemberIDs))
for i, merchantID := range req.MemberIDs {
members = append(members, model.PaymentMerchantPoolMember{PoolID: pool.ID, MerchantID: merchantID, SortOrder: int64(i), BaseModel: model.BaseModel{Creator: middleware.GetUserIDFromContext(ctx), Updater: middleware.GetUserIDFromContext(ctx)}})
}
if err := tx.Create(&members).Error; err != nil {
return err
}
op := constants.AuditOperationPaymentConfigUpdate
summary := "更新商户池"
if creating {
op = constants.AuditOperationPaymentConfigCreate
summary = "创建商户池"
}
return s.writeAudit(ctx, tx, op, summary, "payment_merchant_pool:"+strconv.FormatUint(uint64(pool.ID), 10), pool.Name, pool.ID, poolAuditIdentity(&pool), before, poolAuditIdentity(&pool))
})
if err != nil {
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存商户池失败")
}
return poolResponse(ctx, s.db, &pool)
}
// SetPoolEnabled enables or disables a pool after rechecking the active-pool and member invariants.
func (s *ManagementService) SetPoolEnabled(ctx context.Context, id uint, enabled bool) (*dto.PaymentMerchantPoolResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var pool model.PaymentMerchantPool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pool, id).Error; err != nil {
return err
}
before := poolAuditIdentity(&pool)
if enabled {
var others int64
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
return err
}
if others > 0 {
return errors.New(errors.CodeConflict, "该支付方式已有启用商户池")
}
var members []model.PaymentMerchantPoolMember
if err := tx.Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&members).Error; err != nil {
return err
}
ids := make([]uint, 0, len(members))
for _, member := range members {
ids = append(ids, member.MerchantID)
}
if err := validatePoolMembers(ctx, tx, pool.PaymentMethod, ids); err != nil {
return err
}
pool.Status = model.PaymentMerchantStatusEnabled
} else {
pool.Status = model.PaymentMerchantStatusDisabled
}
pool.Updater = middleware.GetUserIDFromContext(ctx)
if err := tx.Save(&pool).Error; err != nil {
return err
}
op := constants.AuditOperationPaymentConfigDeactivate
summary := "停用商户池"
if enabled {
op = constants.AuditOperationPaymentConfigActivate
summary = "启用商户池"
}
return s.writeAudit(ctx, tx, op, summary, "payment_merchant_pool:"+strconv.FormatUint(uint64(pool.ID), 10), pool.Name, pool.ID, poolAuditIdentity(&pool), before, poolAuditIdentity(&pool))
})
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
}
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新商户池状态失败")
}
return poolResponse(ctx, s.db, &pool)
}
func validatePoolMembers(ctx context.Context, tx *gorm.DB, method string, ids []uint) error {
seen := map[uint]struct{}{}
for _, id := range ids {
if id == 0 {
return errors.New(errors.CodeInvalidParam, "商户ID无效")
}
if _, ok := seen[id]; ok {
return errors.New(errors.CodeInvalidParam, "商户池成员不能重复")
}
seen[id] = struct{}{}
}
var merchants []model.PaymentMerchant
if err := tx.WithContext(ctx).Where("id IN ? AND payment_method = ? AND status = ?", ids, method, model.PaymentMerchantStatusEnabled).Find(&merchants).Error; err != nil {
return err
}
if len(merchants) != len(ids) {
return errors.New(errors.CodeConflict, "商户池成员必须存在、启用且支付方式一致")
}
for index := range merchants {
merchant := &merchants[index]
if err := validateMerchantConfiguration(merchant.PaymentMethod, merchant.ProviderType, merchant.MerchantIdentity, merchant.Credentials); err != nil {
return err
}
}
return nil
}
func poolEpochChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerchantPoolRequest, previousMemberIDs []uint) bool {
if pool.PaymentMethod != strings.TrimSpace(request.PaymentMethod) ||
pool.Strategy != request.Strategy ||
!sameString(pool.StatisticCycle, request.StatisticCycle) ||
!sameInt64(pool.TimePeriodValue, request.TimePeriodValue) ||
!sameString(pool.TimePeriodUnit, request.TimePeriodUnit) ||
!sameTime(pool.TimePeriodStartedAt, request.TimePeriodStartedAt) {
return true
}
if sameMemberOrder(previousMemberIDs, request.MemberIDs) {
return false
}
return pool.StatisticCycle == nil || (*pool.StatisticCycle != "day" && *pool.StatisticCycle != "month") || !sameMemberSet(previousMemberIDs, request.MemberIDs)
}
func sameMemberOrder(left, right []uint) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func sameMemberSet(left, right []uint) bool {
if len(left) != len(right) {
return false
}
seen := make(map[uint]struct{}, len(left))
for _, id := range left {
seen[id] = struct{}{}
}
for _, id := range right {
if _, ok := seen[id]; !ok {
return false
}
}
return true
}
func sameString(left, right *string) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func sameInt64(left, right *int64) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func sameTime(a, b *time.Time) bool {
if a == nil || b == nil {
return a == b
}
return a.Equal(*b)
}
func merchantResponse(m *model.PaymentMerchant) *dto.PaymentMerchantResponse {
return &dto.PaymentMerchantResponse{ID: m.ID, Name: m.Name, PaymentMethod: m.PaymentMethod, ProviderType: m.ProviderType, MerchantIdentity: m.MerchantIdentity, Credentials: m.Credentials, CredentialVersion: m.CredentialVersion, Enabled: m.Status == model.PaymentMerchantStatusEnabled, Remark: m.Remark, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
}
func poolResponse(ctx context.Context, db *gorm.DB, p *model.PaymentMerchantPool) (*dto.PaymentMerchantPoolResponse, error) {
var rows []model.PaymentMerchantPoolMember
if err := db.WithContext(ctx).Where("pool_id = ?", p.ID).Order("sort_order ASC").Find(&rows).Error; err != nil {
return nil, err
}
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.MerchantID)
}
return &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: ids, Remark: p.Remark}, nil
}
// GetAuthorization 查询特权全局授权配置。
func (s *ManagementService) GetAuthorization(ctx context.Context) (*dto.WechatAuthorizationResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var a model.WechatAuthorization
if err := s.db.WithContext(ctx).First(&a).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权配置失败")
}
return authorizationResponse(&a), nil
}
// SaveAuthorization 创建或更新唯一启用的授权配置。
func (s *ManagementService) SaveAuthorization(ctx context.Context, req dto.WechatAuthorizationRequest) (*dto.WechatAuthorizationResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if req.Enabled && (strings.TrimSpace(req.OaAppID) == "" || strings.TrimSpace(req.OaAppSecret) == "" || strings.TrimSpace(req.MiniappAppID) == "" || strings.TrimSpace(req.MiniappAppSecret) == "") {
return nil, errors.New(errors.CodeInvalidParam, "启用微信授权配置时公众号和小程序凭证必须完整")
}
var authorization model.WechatAuthorization
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&authorization).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
previousStatus := authorization.Status
creating := err == gorm.ErrRecordNotFound
before := authorizationAuditIdentity(&authorization)
if creating {
authorization.Creator = middleware.GetUserIDFromContext(ctx)
authorization.CredentialVersion = 1
}
changed := authorization.OaAppID != req.OaAppID || authorization.OaAppSecret != req.OaAppSecret || authorization.OaToken != req.OaToken || authorization.OaAesKey != req.OaAesKey || authorization.OaOAuthRedirectURL != req.OaOAuthRedirectURL || authorization.MiniappAppID != req.MiniappAppID || authorization.MiniappAppSecret != req.MiniappAppSecret
authorization.OaAppID, authorization.OaAppSecret, authorization.OaToken, authorization.OaAesKey, authorization.OaOAuthRedirectURL, authorization.MiniappAppID, authorization.MiniappAppSecret = req.OaAppID, req.OaAppSecret, req.OaToken, req.OaAesKey, req.OaOAuthRedirectURL, req.MiniappAppID, req.MiniappAppSecret
authorization.Status = model.PaymentMerchantStatusDisabled
if req.Enabled {
authorization.Status = model.PaymentMerchantStatusEnabled
}
if !creating && (changed || previousStatus != authorization.Status) {
authorization.CredentialVersion++
}
authorization.Updater = middleware.GetUserIDFromContext(ctx)
if creating {
if err := tx.Create(&authorization).Error; err != nil {
return err
}
} else {
if err := tx.Save(&authorization).Error; err != nil {
return err
}
}
op := constants.AuditOperationPaymentConfigUpdate
summary := "更新微信授权配置"
if creating {
op = constants.AuditOperationPaymentConfigCreate
summary = "创建微信授权配置"
}
return s.writeAudit(ctx, tx, op, summary, "wechat_authorization:"+strconv.FormatUint(uint64(authorization.ID), 10), "微信授权配置", authorization.ID, authorizationAuditIdentity(&authorization), before, authorizationAuditIdentity(&authorization))
})
if err != nil {
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存微信授权配置失败")
}
return authorizationResponse(&authorization), nil
}
func authorizationResponse(a *model.WechatAuthorization) *dto.WechatAuthorizationResponse {
return &dto.WechatAuthorizationResponse{ID: a.ID, OaAppID: a.OaAppID, OaAppSecret: a.OaAppSecret, OaToken: a.OaToken, OaAesKey: a.OaAesKey, OaOAuthRedirectURL: a.OaOAuthRedirectURL, MiniappAppID: a.MiniappAppID, MiniappAppSecret: a.MiniappAppSecret, CredentialVersion: a.CredentialVersion, Enabled: a.Status == model.PaymentMerchantStatusEnabled, UpdatedAt: a.UpdatedAt}
}

View File

@@ -0,0 +1,386 @@
package merchantpayment
import (
"context"
"fmt"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RouteSelection is the non-sensitive route frozen onto a new payment.
type RouteSelection struct {
Merchant *model.PaymentMerchant
Pool *model.PaymentMerchantPool
}
// RuntimeLoader loads current merchant and authorization credentials by version.
type RuntimeLoader struct {
db *gorm.DB
redis *redis.Client
}
// merchantCachePayload is used only for the internal versioned Redis cache and deliberately includes credentials.
// It must never be used for DTOs, logs, audits, or payment snapshots.
type merchantCachePayload struct {
ID uint `json:"id"`
Name string `json:"name"`
PaymentMethod string `json:"payment_method"`
ProviderType string `json:"provider_type"`
MerchantIdentity string `json:"merchant_identity"`
Credentials model.JSONB `json:"credentials"`
CredentialVersion int64 `json:"credential_version"`
Status int `json:"status"`
Remark string `json:"remark"`
}
func merchantCachePayloadFrom(merchant *model.PaymentMerchant) merchantCachePayload {
return merchantCachePayload{ID: merchant.ID, Name: merchant.Name, PaymentMethod: merchant.PaymentMethod, ProviderType: merchant.ProviderType, MerchantIdentity: merchant.MerchantIdentity, Credentials: merchant.Credentials, CredentialVersion: merchant.CredentialVersion, Status: merchant.Status, Remark: merchant.Remark}
}
func (p merchantCachePayload) merchant() *model.PaymentMerchant {
return &model.PaymentMerchant{Model: gorm.Model{ID: p.ID}, Name: p.Name, PaymentMethod: p.PaymentMethod, ProviderType: p.ProviderType, MerchantIdentity: p.MerchantIdentity, Credentials: p.Credentials, CredentialVersion: p.CredentialVersion, Status: p.Status, Remark: p.Remark}
}
// authorizationCachePayload is used only for the internal versioned Redis cache and deliberately includes secrets.
// It must never be used for DTOs, logs, audits, or payment snapshots.
type authorizationCachePayload struct {
ID uint `json:"id"`
OaAppID string `json:"oa_app_id"`
OaAppSecret string `json:"oa_app_secret"`
OaToken string `json:"oa_token"`
OaAesKey string `json:"oa_aes_key"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url"`
MiniappAppID string `json:"miniapp_app_id"`
MiniappAppSecret string `json:"miniapp_app_secret"`
CredentialVersion int64 `json:"credential_version"`
Status int `json:"status"`
}
func authorizationCachePayloadFrom(authorization *model.WechatAuthorization) authorizationCachePayload {
return authorizationCachePayload{ID: authorization.ID, OaAppID: authorization.OaAppID, OaAppSecret: authorization.OaAppSecret, OaToken: authorization.OaToken, OaAesKey: authorization.OaAesKey, OaOAuthRedirectURL: authorization.OaOAuthRedirectURL, MiniappAppID: authorization.MiniappAppID, MiniappAppSecret: authorization.MiniappAppSecret, CredentialVersion: authorization.CredentialVersion, Status: authorization.Status}
}
func (p authorizationCachePayload) authorization() *model.WechatAuthorization {
return &model.WechatAuthorization{Model: gorm.Model{ID: p.ID}, OaAppID: p.OaAppID, OaAppSecret: p.OaAppSecret, OaToken: p.OaToken, OaAesKey: p.OaAesKey, OaOAuthRedirectURL: p.OaOAuthRedirectURL, MiniappAppID: p.MiniappAppID, MiniappAppSecret: p.MiniappAppSecret, CredentialVersion: p.CredentialVersion, Status: p.Status}
}
func NewRuntimeLoader(db *gorm.DB, redis *redis.Client) *RuntimeLoader {
return &RuntimeLoader{db: db, redis: redis}
}
// LoadMerchant first reads the current version from the primary database, then uses only that version's cache entry.
// Disabled merchants remain loadable for frozen historical payments.
func (l *RuntimeLoader) LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error) {
if l == nil || l.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置")
}
return l.loadMerchant(ctx, l.db, id)
}
// loadMerchant 先从当前事务或主库读取版本,再仅命中该版本的缓存。
// 版本在凭证事务提交时递增,因此提交前遗留的旧缓存永远不会被新读取命中。
func (l *RuntimeLoader) loadMerchant(ctx context.Context, db *gorm.DB, id uint) (*model.PaymentMerchant, error) {
var current model.PaymentMerchant
if err := db.WithContext(ctx).First(&current, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取支付商户失败")
}
key := fmt.Sprintf("payment:merchant:%d:%d", current.ID, current.CredentialVersion)
if l.redis != nil {
if text, err := l.redis.Get(ctx, key).Result(); err == nil {
var cached merchantCachePayload
if sonic.UnmarshalString(text, &cached) == nil && cached.ID == current.ID && cached.CredentialVersion == current.CredentialVersion {
return cached.merchant(), nil
}
}
}
if l.redis != nil {
if text, err := sonic.MarshalString(merchantCachePayloadFrom(&current)); err == nil {
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
}
}
return &current, nil
}
// LoadAuthorization first reads the current enabled version and only then resolves its versioned cache entry.
func (l *RuntimeLoader) LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error) {
if l == nil || l.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "微信授权加载能力未配置")
}
var current model.WechatAuthorization
if err := l.db.WithContext(ctx).Where("status = ?", model.PaymentMerchantStatusEnabled).First(&current).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取微信授权配置失败")
}
key := fmt.Sprintf("payment:wechat-authorization:%d:%d", current.ID, current.CredentialVersion)
if l.redis != nil {
if text, err := l.redis.Get(ctx, key).Result(); err == nil {
var cached authorizationCachePayload
if sonic.UnmarshalString(text, &cached) == nil && cached.ID == current.ID && cached.CredentialVersion == current.CredentialVersion {
return cached.authorization(), nil
}
}
}
if l.redis != nil {
if text, err := sonic.MarshalString(authorizationCachePayloadFrom(&current)); err == nil {
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
}
}
return &current, nil
}
// MerchantConfig adapts the merchant credential payload to existing channel constructors without persisting credentials in a payment snapshot.
func MerchantConfig(merchant *model.PaymentMerchant, authorization *model.WechatAuthorization) (*model.WechatConfig, error) {
if merchant == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "支付商户不存在")
}
if authorization == nil {
authorization = &model.WechatAuthorization{}
}
raw, err := sonic.Marshal(merchant.Credentials)
if err != nil {
return nil, errors.Wrap(errors.CodeInvalidParam, err, "支付商户凭证格式无效")
}
var cfg model.WechatConfig
if err := sonic.Unmarshal(raw, &cfg); err != nil {
return nil, errors.Wrap(errors.CodeInvalidParam, err, "支付商户凭证格式无效")
}
cfg.ID = merchant.ID
cfg.ProviderType = merchant.ProviderType
cfg.IsActive = true
if authorization != nil {
cfg.OaAppID = authorization.OaAppID
cfg.OaAppSecret = authorization.OaAppSecret
cfg.OaToken = authorization.OaToken
cfg.OaAesKey = authorization.OaAesKey
cfg.OaOAuthRedirectURL = authorization.OaOAuthRedirectURL
cfg.MiniappAppID = authorization.MiniappAppID
cfg.MiniappAppSecret = authorization.MiniappAppSecret
}
return &cfg, nil
}
// MerchantConfigWithAuthorization 在需要 AppID 的渠道实例前,按当前版本加载全局微信授权配置。
// 授权字段只进入内存中的渠道配置,绝不写入支付快照、普通 DTO、日志、审计或导出。
func (l *RuntimeLoader) MerchantConfigWithAuthorization(ctx context.Context, merchant *model.PaymentMerchant) (*model.WechatConfig, error) {
authorization, err := l.LoadAuthorization(ctx)
if err != nil {
return nil, err
}
return MerchantConfig(merchant, authorization)
}
// SelectForNewPayment atomically reads the active pool and chooses its current eligible member.
func (l *RuntimeLoader) SelectForNewPayment(ctx context.Context, paymentMethod string, now time.Time) (*RouteSelection, error) {
if l == nil || l.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
var out *RouteSelection
err := l.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
out, err = l.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, now)
return err
})
if err != nil {
return nil, err
}
return out, nil
}
// SelectForNewPaymentWithTx chooses an eligible merchant while retaining the caller's business transaction.
func (l *RuntimeLoader) SelectForNewPaymentWithTx(ctx context.Context, tx *gorm.DB, paymentMethod string, now time.Time) (*RouteSelection, error) {
if l == nil || tx == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
var pool model.PaymentMerchantPool
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_method = ? AND status = ?", paymentMethod, model.PaymentMerchantStatusEnabled).First(&pool).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
return nil, err
}
var members []model.PaymentMerchantPoolMember
if err := tx.WithContext(ctx).Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&members).Error; err != nil {
return nil, err
}
if len(members) == 0 {
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
ids := make([]uint, 0, len(members))
for _, member := range members {
ids = append(ids, member.MerchantID)
}
var merchants []model.PaymentMerchant
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ? AND payment_method = ? AND status = ?", ids, pool.PaymentMethod, model.PaymentMerchantStatusEnabled).Find(&merchants).Error; err != nil {
return nil, err
}
byID := make(map[uint]*model.PaymentMerchant, len(merchants))
for i := range merchants {
byID[merchants[i].ID] = &merchants[i]
}
ordered := make([]*model.PaymentMerchant, 0, len(members))
for _, member := range members {
if merchant := byID[member.MerchantID]; merchant != nil {
ordered = append(ordered, merchant)
}
}
chosen, err := chooseMerchant(ctx, tx, &pool, ordered, now)
if err != nil {
return nil, err
}
// 新支付在冻结前也按“商户 ID + 当前版本”读取缓存;事务锁保证本次
// 选择与凭证版本属于同一提交边界,避免新建支付误用旧版本缓存。
chosen, err = l.loadMerchant(ctx, tx, chosen.ID)
if err != nil {
return nil, err
}
return &RouteSelection{Merchant: chosen, Pool: &pool}, nil
}
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
if len(merchants) == 0 {
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
if pool.Strategy == model.PaymentMerchantStrategyTime {
return chooseTimedMerchant(pool, merchants, now)
}
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
return nil, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
}
if pool.StatisticCycle == nil {
return nil, errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
}
query := tx.WithContext(ctx).Where("pool_id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch)
if start, limited := routingWindowStart(*pool.StatisticCycle, now); limited {
query = query.Where("paid_at >= ?", start)
}
var rows []model.PaymentMerchantRoutingSuccess
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
amounts := make(map[uint]int64, len(merchants))
counts := make(map[uint]int64, len(merchants))
for _, row := range rows {
amounts[row.MerchantID] += row.Amount
counts[row.MerchantID]++
}
for _, merchant := range merchants {
if pool.Strategy == model.PaymentMerchantStrategyAmount {
if pool.ThresholdAmount == nil {
return nil, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
}
if amounts[merchant.ID] < *pool.ThresholdAmount {
return merchant, nil
}
continue
}
if pool.ThresholdCount == nil {
return nil, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
}
if counts[merchant.ID] < *pool.ThresholdCount {
return merchant, nil
}
}
if *pool.StatisticCycle != "round" {
return nil, errors.New(errors.CodeNoPaymentConfig, "当前统计周期内暂无可用商户")
}
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
return nil, err
}
return merchants[0], nil
}
func chooseTimedMerchant(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
if pool.TimePeriodStartedAt == nil || pool.TimePeriodValue == nil || pool.TimePeriodUnit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询配置不完整")
}
unit := time.Minute
switch *pool.TimePeriodUnit {
case "hour":
unit = time.Hour
case "day":
unit = 24 * time.Hour
case "minute":
default:
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询单位无效")
}
period := unit * time.Duration(*pool.TimePeriodValue)
if period <= 0 {
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询周期无效")
}
slot := now.Sub(*pool.TimePeriodStartedAt) / period
if slot < 0 {
slot = 0
}
return merchants[int(slot%time.Duration(len(merchants)))], nil
}
func routingWindowStart(cycle string, now time.Time) (time.Time, bool) {
local := now.In(now.Location())
switch cycle {
case "day":
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()), true
case "month":
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, local.Location()), true
default:
return time.Time{}, false
}
}
func advanceRoutingEpoch(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool) error {
next := pool.RoutingEpoch + 1
result := tx.WithContext(ctx).Model(&model.PaymentMerchantPool{}).Where("id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch).Update("routing_epoch", next)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "商户池统计世代已变化")
}
pool.RoutingEpoch = next
return nil
}
// FreezeRoute writes only non-sensitive route facts onto the payment.
func FreezeRoute(payment *model.Payment, route *RouteSelection) {
if payment == nil || route == nil || route.Merchant == nil || route.Pool == nil {
return
}
payment.MerchantID = &route.Merchant.ID
payment.MerchantPoolID = &route.Pool.ID
payment.MerchantIdentity = route.Merchant.MerchantIdentity
payment.MerchantNameSnapshot = route.Merchant.Name
payment.MerchantPaymentMethodSnapshot = route.Merchant.PaymentMethod
payment.MerchantProviderTypeSnapshot = route.Merchant.ProviderType
payment.MerchantPoolNameSnapshot = route.Pool.Name
payment.RoutingStrategySnapshot = route.Pool.Strategy
epoch := route.Pool.RoutingEpoch
payment.RoutingEpoch = &epoch
}
// RecordFirstSuccess 在支付成功事务内写入支付不可变的路由事实。
func RecordFirstSuccess(ctx context.Context, tx *gorm.DB, payment *model.Payment, paidAt time.Time) error {
if payment == nil || payment.MerchantID == nil || payment.MerchantPoolID == nil || payment.RoutingEpoch == nil {
return nil
}
fact := model.PaymentMerchantRoutingSuccess{PaymentID: payment.ID, MerchantID: *payment.MerchantID, PoolID: *payment.MerchantPoolID, RoutingEpoch: *payment.RoutingEpoch, Amount: payment.Amount, PaidAt: paidAt}
if err := tx.WithContext(ctx).Create(&fact).Error; err != nil {
if strings.Contains(err.Error(), "duplicate key") {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "写入商户池成功统计失败")
}
return nil
}

View File

@@ -1,6 +1,7 @@
package bootstrap
import (
merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
@@ -86,7 +87,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
personalCustomerOpenIDStore,
personalCustomerStore,
personalCustomerPhoneStore,
svc.WechatConfig,
svc.Order,
packageSeriesStore,
shopSeriesAllocationStore,
@@ -111,6 +111,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
clientOrderService.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
clientOrderService.SetLegacyPaymentConfigService(svc.WechatConfig)
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
systemConfigAudit := deps.SystemConfigAudit
if systemConfigAudit == nil {
@@ -173,7 +174,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
return handler
}(),
ClientWallet: func() *app.ClientWalletHandler {
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
handler.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
return handler
@@ -284,7 +285,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
h.SetAssetService(svc.Asset)
return h
}(),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
PaymentMerchant: admin.NewPaymentMerchantHandler(merchantPaymentApp.NewManagementService(deps.DB, systemConfigAudit)),
AgentRecharge: func() *admin.AgentRechargeHandler {
handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate)
handler.SetOnlineCreationService(svc.AgentRechargeOnline)

View File

@@ -9,6 +9,7 @@ import (
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
@@ -287,6 +288,7 @@ func initServices(s *stores, deps *Dependencies) *services {
paymentIntegration := integrationlog.NewRepository(deps.DB)
agentRechargeOnline := agentrechargeApp.NewOnlineCreationService(
deps.DB,
merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis),
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
paymentInfra.NewFuiouScanAdapter(paymentIntegration, deps.Logger),
@@ -314,6 +316,7 @@ func initServices(s *stores, deps *Dependencies) *services {
)
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
refundService.SetNotificationOutbox(walletOutbox)
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis))
refundService.SetLifecycleAudit(auditWriter)
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))

View File

@@ -68,6 +68,7 @@ type Handlers struct {
AssetLifecycle *admin.AssetLifecycleHandler
AssetWallet *admin.AssetWalletHandler
WechatConfig *admin.WechatConfigHandler
PaymentMerchant *admin.PaymentMerchantHandler
AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler

View File

@@ -24,25 +24,28 @@ const (
// PaymentConfirmationFacts 是支付确认所需的渠道与本地权威事实。
type PaymentConfirmationFacts struct {
OrderType string
ExpectedOrderType string
PaymentMethod string
RechargePaymentMethod string
RechargePaymentChannel string
PaymentConfigID uint
RechargePaymentConfigID uint
ConfirmedConfigID uint
MerchantIdentity string
ConfirmedMerchantIdentity string
PaymentAmount int64
RechargeAmount int64
ConfirmedAmount int64
PaymentOrderID uint
RechargeID uint
PaymentState PaymentState
RechargeStatus int
StoredTradeNo string
ConfirmedTradeNo string
OrderType string
ExpectedOrderType string
PaymentMethod string
RechargePaymentMethod string
RechargePaymentChannel string
PaymentConfigID uint
RechargePaymentConfigID uint
ConfirmedConfigID uint
FrozenMerchant bool
FrozenMerchantPaymentMethod string
FrozenMerchantProviderType string
MerchantIdentity string
ConfirmedMerchantIdentity string
PaymentAmount int64
RechargeAmount int64
ConfirmedAmount int64
PaymentOrderID uint
RechargeID uint
PaymentState PaymentState
RechargeStatus int
StoredTradeNo string
ConfirmedTradeNo string
}
// ValidatePaymentConfirmation 校验支付确认不变量,并返回是否属于完全一致的重复确认。
@@ -60,7 +63,20 @@ func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
identity := strings.TrimSpace(facts.MerchantIdentity)
if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
if facts.FrozenMerchant {
frozenMethod := strings.TrimSpace(facts.FrozenMerchantPaymentMethod)
providerType := strings.TrimSpace(facts.FrozenMerchantProviderType)
if identity == "" || frozenMethod != method || providerType == "" {
return false, errors.New(errors.CodeConflict, "冻结商户支付事实不一致")
}
expectedChannel := method
if method == constants.RechargeMethodWechat && providerType == model.ProviderTypeFuiou {
expectedChannel = model.ProviderTypeFuiou
}
if channel != expectedChannel {
return false, errors.New(errors.CodeConflict, "支付渠道与冻结商户不一致")
}
} else if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
facts.PaymentConfigID != facts.ConfirmedConfigID || identity == "" ||
identity != strings.TrimSpace(facts.ConfirmedMerchantIdentity) {
return false, errors.New(errors.CodeConflict, "支付配置身份与创建记录不一致")

View File

@@ -648,7 +648,7 @@ func (h *AssetHandler) resolveAssetPackageUsagePath(c *fiber.Ctx) (*dto.AssetRes
}
func ensureAssetPackageAdjuster(c *fiber.Ctx) error {
if middleware.GetUserIDFromContext(c.UserContext()) != 1124 && middleware.GetUserIDFromContext(c.UserContext()) != 127 {
if middleware.GetUserIDFromContext(c.UserContext()) != 41 && middleware.GetUserIDFromContext(c.UserContext()) != 127 {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil

View File

@@ -0,0 +1,218 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PaymentMerchantHandler 处理特权商户与商户池配置请求。
type PaymentMerchantHandler struct {
service *merchantpayment.ManagementService
}
// NewPaymentMerchantHandler 创建商户池管理处理器。
func NewPaymentMerchantHandler(service *merchantpayment.ManagementService) *PaymentMerchantHandler {
return &PaymentMerchantHandler{service: service}
}
func pathID(c *fiber.Ctx) (uint, error) {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return 0, errors.New(errors.CodeInvalidParam, "无效的路径ID")
}
return uint(id), nil
}
// CreateMerchant 创建支付商户。
// POST /api/admin/payment-merchants
func (h *PaymentMerchantHandler) CreateMerchant(c *fiber.Ctx) error {
var r dto.PaymentMerchantRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.CreateMerchant(c.UserContext(), r)
if err != nil {
return err
}
return response.Success(c, v)
}
// ListMerchants 分页查询支付商户。
// GET /api/admin/payment-merchants
func (h *PaymentMerchantHandler) ListMerchants(c *fiber.Ctx) error {
var r dto.PaymentMerchantListRequest
if err := c.QueryParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, n, err := h.service.ListMerchants(c.UserContext(), r)
if err != nil {
return err
}
return response.SuccessWithPagination(c, v, n, r.Page, r.PageSize)
}
// GetMerchant 查询支付商户详情。
// GET /api/admin/payment-merchants/:id
func (h *PaymentMerchantHandler) GetMerchant(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
v, err := h.service.GetMerchant(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, v)
}
// UpdateMerchant 更新支付商户。
// PUT /api/admin/payment-merchants/:id
func (h *PaymentMerchantHandler) UpdateMerchant(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var r dto.PaymentMerchantUpdateRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.UpdateMerchant(c.UserContext(), id, r)
if err != nil {
return err
}
return response.Success(c, v)
}
// DeleteMerchant 删除支付商户。
// DELETE /api/admin/payment-merchants/:id
func (h *PaymentMerchantHandler) DeleteMerchant(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var r dto.PaymentMerchantDeleteRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.DeleteMerchant(c.UserContext(), id, r.Confirm); err != nil {
return err
}
return response.Success(c, nil)
}
// CreatePool 创建商户池。
// POST /api/admin/payment-merchant-pools
func (h *PaymentMerchantHandler) CreatePool(c *fiber.Ctx) error {
var r dto.PaymentMerchantPoolRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.SavePool(c.UserContext(), 0, r)
if err != nil {
return err
}
return response.Success(c, v)
}
// UpdatePool 更新商户池。
// PUT /api/admin/payment-merchant-pools/:id
func (h *PaymentMerchantHandler) UpdatePool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var r dto.PaymentMerchantPoolRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.SavePool(c.UserContext(), id, r)
if err != nil {
return err
}
return response.Success(c, v)
}
// EnablePool 启用商户池。
// POST /api/admin/payment-merchant-pools/:id/enable
func (h *PaymentMerchantHandler) EnablePool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.service.SetPoolEnabled(c.UserContext(), id, true)
if err != nil {
return err
}
return response.Success(c, result)
}
// DisablePool 停用商户池。
// POST /api/admin/payment-merchant-pools/:id/disable
func (h *PaymentMerchantHandler) DisablePool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.service.SetPoolEnabled(c.UserContext(), id, false)
if err != nil {
return err
}
return response.Success(c, result)
}
// GetAuthorization 获取微信授权配置。
// GET /api/admin/wechat-authorizations
func (h *PaymentMerchantHandler) GetAuthorization(c *fiber.Ctx) error {
v, err := h.service.GetAuthorization(c.UserContext())
if err != nil {
return err
}
return response.Success(c, v)
}
// ListPools 分页查询商户池。
// GET /api/admin/payment-merchant-pools
func (h *PaymentMerchantHandler) ListPools(c *fiber.Ctx) error {
var r dto.PaymentMerchantPoolListRequest
if err := c.QueryParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, total, err := h.service.ListPools(c.UserContext(), r)
if err != nil {
return err
}
return response.SuccessWithPagination(c, v, total, r.Page, r.PageSize)
}
// GetPool 查询商户池详情。
// GET /api/admin/payment-merchant-pools/:id
func (h *PaymentMerchantHandler) GetPool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
v, err := h.service.GetPool(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, v)
}
// SaveAuthorization 保存微信授权配置。
// PUT /api/admin/wechat-authorizations/current
func (h *PaymentMerchantHandler) SaveAuthorization(c *fiber.Ctx) error {
var r dto.WechatAuthorizationRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.SaveAuthorization(c.UserContext(), r)
if err != nil {
return err
}
return response.Success(c, v)
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetquery "github.com/break/junhong_cmp_fiber/internal/query/asset"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
@@ -462,7 +463,7 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
return response.Success(c, &dto.AssetPackageListResponse{Packages: items})
}
// GetPackageHistory B3 资产套餐历史
// GetPackageHistory B3 资产套餐历史
// GET /api/c/v1/asset/package-history
func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
var req dto.AssetPackageHistoryRequest
@@ -485,73 +486,93 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
return err
}
query := h.db.WithContext(resolved.SkipPermissionCtx).Model(&model.PackageUsage{}).
Where("generation = ?", resolved.Generation)
if resolved.Asset.AssetType == "card" {
query = query.Where("iot_card_id = ?", resolved.Asset.AssetID)
} else {
query = query.Where("device_id = ?", resolved.Asset.AssetID)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.PackageType != nil {
query = query.Where("package_id IN (?)",
h.db.Model(&model.Package{}).Select("id").Where("package_type = ?", *req.PackageType))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史总数失败")
}
var usages []*model.PackageUsage
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&usages).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败")
}
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, usages)
history, err := assetquery.NewPackageHistoryQuery(h.db).List(resolved.SkipPermissionCtx, assetquery.PackageHistoryInput{
AssetType: resolved.Asset.AssetType,
AssetID: resolved.Asset.AssetID,
Generation: &resolved.Generation,
Status: req.Status,
PackageType: req.PackageType,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
return err
}
list := make([]dto.AssetPackageResponse, 0, len(usages))
for _, usage := range usages {
pkg := packageMap[usage.PackageID]
metrics := usage.BuildTrafficMetrics()
pkgName := usage.PackageName
pkgType := ""
if pkg != nil {
if pkgName == "" {
pkgName = pkg.PackageName
}
pkgType = pkg.PackageType
}
list = append(list, dto.AssetPackageResponse{
PackageUsageID: usage.ID,
PackageID: usage.PackageID,
PackageName: pkgName,
PackageType: pkgType,
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
EnableVirtualData: usage.EnableVirtualDataSnapshot,
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
CreatedAt: usage.CreatedAt,
})
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, collectPackageHistoryUsages(history.Items))
if err != nil {
return err
}
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
items := make([]*dto.ClientAssetPackageHistoryNode, 0, len(history.Items))
for _, node := range history.Items {
items = append(items, buildClientPackageHistoryNode(node, packageMap))
}
return response.SuccessWithPagination(c, items, history.Total, req.Page, req.PageSize)
}
func collectPackageHistoryUsages(items []*assetquery.PackageHistoryNode) []*model.PackageUsage {
usages := make([]*model.PackageUsage, 0)
var collect func(*assetquery.PackageHistoryNode)
collect = func(node *assetquery.PackageHistoryNode) {
if node == nil || node.Usage == nil {
return
}
usages = append(usages, node.Usage)
for _, child := range node.Children {
collect(child)
}
}
for _, item := range items {
collect(item)
}
return usages
}
func buildClientPackageHistoryNode(node *assetquery.PackageHistoryNode, packageMap map[uint]*model.Package) *dto.ClientAssetPackageHistoryNode {
usage := node.Usage
pkg := packageMap[usage.PackageID]
metrics := usage.BuildTrafficMetrics()
packageName := usage.PackageName
packageType := ""
if pkg != nil {
if packageName == "" {
packageName = pkg.PackageName
}
packageType = pkg.PackageType
}
item := &dto.ClientAssetPackageHistoryNode{
PackageUsageID: usage.ID,
PackageID: usage.PackageID,
PackageName: packageName,
PackageType: packageType,
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
EnableVirtualData: usage.EnableVirtualDataSnapshot,
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
CreatedAt: usage.CreatedAt,
Children: make([]*dto.ClientAssetPackageHistoryNode, 0, len(node.Children)),
}
if node.RelationshipStatus != "" {
item.RelationshipStatus = node.RelationshipStatus
item.RelationshipStatusName = "关联主套餐缺失"
}
for _, child := range node.Children {
item.Children = append(item.Children, buildClientPackageHistoryNode(child, packageMap))
}
item.ExpandByDefault = len(item.Children) > 0
return item
}
// RefreshAsset B4 资产刷新

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/middleware"
@@ -16,7 +17,7 @@ import (
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/alipay"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -41,7 +42,7 @@ type ClientWalletHandler struct {
paymentStore *postgres.PaymentStore
rechargeService *rechargeSvc.Service
openIDStore *postgres.PersonalCustomerOpenIDStore
wechatConfigService *wechatConfigSvc.Service
merchantRuntime *merchantpayment.RuntimeLoader
redis *redis.Client
logger *zap.Logger
db *gorm.DB
@@ -73,7 +74,6 @@ func NewClientWalletHandler(
paymentStore *postgres.PaymentStore,
rechargeService *rechargeSvc.Service,
openIDStore *postgres.PersonalCustomerOpenIDStore,
wechatConfigService *wechatConfigSvc.Service,
redisClient *redis.Client,
logger *zap.Logger,
db *gorm.DB,
@@ -81,20 +81,20 @@ func NewClientWalletHandler(
deviceStore *postgres.DeviceStore,
) *ClientWalletHandler {
return &ClientWalletHandler{
assetService: assetService,
customerBinding: binding,
walletStore: walletStore,
transactionStore: transactionStore,
rechargeOrderStore: rechargeOrderStore,
paymentStore: paymentStore,
rechargeService: rechargeService,
openIDStore: openIDStore,
wechatConfigService: wechatConfigService,
redis: redisClient,
logger: logger,
db: db,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
assetService: assetService,
customerBinding: binding,
walletStore: walletStore,
transactionStore: transactionStore,
rechargeOrderStore: rechargeOrderStore,
paymentStore: paymentStore,
rechargeService: rechargeService,
openIDStore: openIDStore,
merchantRuntime: merchantpayment.NewRuntimeLoader(db, redisClient),
redis: redisClient,
logger: logger,
db: db,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
}
}
@@ -321,19 +321,11 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
// }
// }
config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx)
if err != nil {
return err
}
if config == nil {
return errors.New(errors.CodeWechatConfigUnavailable)
}
switch req.PaymentMethod {
case constants.RechargeMethodAlipay:
return h.createAlipayRecharge(c, resolved, config, wallet, req)
return h.createAlipayRecharge(c, resolved, wallet, req)
case constants.RechargeMethodWechat:
return h.createWechatRecharge(c, resolved, config, wallet, req)
return h.createWechatRecharge(c, resolved, wallet, req)
default:
return errors.New(errors.CodePaymentMethodUnavailable)
}
@@ -343,15 +335,13 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
func (h *ClientWalletHandler) createWechatRecharge(
c *fiber.Ctx,
resolved *resolvedWalletAssetContext,
config *model.WechatConfig,
wallet *model.AssetWallet,
req dto.ClientCreateRechargeRequest,
) error {
appID, err := pickAppIDByType(config, req.AppType)
authorization, appID, err := h.loadWechatAuthorization(resolved.SkipPermissionCtx, req.AppType)
if err != nil {
return err
}
openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID)
if err != nil {
return err
@@ -359,9 +349,47 @@ func (h *ClientWalletHandler) createWechatRecharge(
rechargeNo := generateClientRechargeNo()
paymentNo := generateClientPaymentNo()
rechargeOrder := &model.RechargeOrder{
RechargeOrderNo: rechargeNo,
UserID: resolved.CustomerID,
AssetWalletID: wallet.ID,
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
Amount: req.Amount,
Status: model.RechargeOrderStatusPending,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByWechat,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
}
var config *model.WechatConfig
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
route, selectedConfig, err := h.selectMerchantConfig(resolved.SkipPermissionCtx, tx, model.PaymentByWechat, authorization)
if err != nil {
return err
}
config = selectedConfig
merchantpayment.FreezeRoute(payment, route)
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
// 先初始化生效支付通道并创建预支付订单,确认支付通道可用
// 避免先写入充值记录后支付初始化失败,导致产生孤儿记录
attempt, startedAt, err := h.startRechargePaymentAttempt(resolved.SkipPermissionCtx, config, paymentNo, rechargeNo, req.Amount)
if err != nil {
return err
@@ -380,49 +408,15 @@ func (h *ClientWalletHandler) createWechatRecharge(
if completeErr := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "充值支付预下单结果未知"); completeErr != nil {
return completeErr
}
if updateErr := h.markRechargePaymentFailed(resolved.SkipPermissionCtx, payment); updateErr != nil {
return updateErr
}
return err
}
if err := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
return err
}
// 支付通道确认可用后,再创建充值订单和支付记录
rechargeOrder := &model.RechargeOrder{
RechargeOrderNo: rechargeNo,
UserID: resolved.CustomerID,
AssetWalletID: wallet.ID,
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
Amount: req.Amount,
Status: model.RechargeOrderStatusPending,
PaymentConfigID: &config.ID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByWechat,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
}
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
return response.Success(c, &dto.ClientRechargeResponse{
Recharge: dto.ClientRechargeResult{
RechargeID: rechargeOrder.ID,
@@ -439,18 +433,11 @@ func (h *ClientWalletHandler) createWechatRecharge(
func (h *ClientWalletHandler) createAlipayRecharge(
c *fiber.Ctx,
resolved *resolvedWalletAssetContext,
config *model.WechatConfig,
wallet *model.AssetWallet,
req dto.ClientCreateRechargeRequest,
) error {
rechargeNo := generateClientRechargeNo()
paymentNo := generateClientPaymentNo()
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
rechargeOrder := &model.RechargeOrder{
RechargeOrderNo: rechargeNo,
UserID: resolved.CustomerID,
@@ -459,24 +446,33 @@ func (h *ClientWalletHandler) createAlipayRecharge(
ResourceID: resolved.Asset.AssetID,
Amount: req.Amount,
Status: model.RechargeOrderStatusPending,
PaymentConfigID: &config.ID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByAlipay,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
ExpireAt: &expireAt,
PaymentNo: paymentNo,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByAlipay,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
}
// WAP URL 是本地签名,不需要先调第三方,在事务内创建充值单和支付单
var config *model.WechatConfig
var expireAt time.Time
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
route, selectedConfig, err := h.selectMerchantConfig(resolved.SkipPermissionCtx, tx, model.PaymentByAlipay, nil)
if err != nil {
return err
}
expireMinutes := selectedConfig.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt = time.Now().Add(time.Duration(expireMinutes) * time.Minute)
config = selectedConfig
payment.ExpireAt = &expireAt
merchantpayment.FreezeRoute(payment, route)
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
@@ -505,7 +501,6 @@ func (h *ClientWalletHandler) createAlipayRecharge(
zap.String("recharge_no", rechargeNo),
zap.Int64("amount", req.Amount),
zap.Time("expire_at", expireAt),
zap.Uint("config_id", config.ID),
)
expireStr := expireAt.Format(time.RFC3339)
@@ -738,7 +733,37 @@ func parseOptionalTime(value string) (*time.Time, error) {
return nil, fmt.Errorf("invalid time format")
}
func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) {
func (h *ClientWalletHandler) loadWechatAuthorization(ctx context.Context, appType string) (*model.WechatAuthorization, string, error) {
if h.merchantRuntime == nil {
return nil, "", errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
authorization, err := h.merchantRuntime.LoadAuthorization(ctx)
if err != nil {
return nil, "", err
}
appID, err := pickAppIDByType(authorization, appType)
if err != nil {
return nil, "", err
}
return authorization, appID, nil
}
func (h *ClientWalletHandler) selectMerchantConfig(ctx context.Context, tx *gorm.DB, paymentMethod string, authorization *model.WechatAuthorization) (*merchantpayment.RouteSelection, *model.WechatConfig, error) {
if h.merchantRuntime == nil {
return nil, nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
route, err := h.merchantRuntime.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, time.Now())
if err != nil {
return nil, nil, err
}
config, err := merchantpayment.MerchantConfig(route.Merchant, authorization)
if err != nil {
return nil, nil, err
}
return route, config, nil
}
func pickAppIDByType(config *model.WechatAuthorization, appType string) (string, error) {
switch appType {
case "official_account":
if strings.TrimSpace(config.OaAppID) == "" {

View File

@@ -54,7 +54,7 @@ func (h *ClientWechatHandler) GetJSSDKConfig(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam)
}
wechatConfig, err := h.wechatConfigService.GetActiveConfig(c.UserContext())
wechatConfig, err := h.wechatConfigService.GetAuthorizationConfig(c.UserContext())
if err != nil {
return err
}
@@ -82,7 +82,7 @@ func (h *ClientWechatHandler) GetJSSDKConfig(c *fiber.Ctx) error {
// GetAppID 获取当前生效的公众号 AppID
// GET /api/c/v1/wechat/appid
func (h *ClientWechatHandler) GetAppID(c *fiber.Ctx) error {
wechatConfig, err := h.wechatConfigService.GetActiveConfig(c.UserContext())
wechatConfig, err := h.wechatConfigService.GetAuthorizationConfig(c.UserContext())
if err != nil {
return err
}

View File

@@ -38,6 +38,7 @@ type AgentRechargeServiceInterface interface {
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error)
GetPaymentServiceForCallback(config *model.WechatConfig) (wechat.PaymentServiceInterface, error)
}
type PaymentHandler struct {
@@ -98,7 +99,7 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
body := c.Body()
ctx := c.UserContext()
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
// 预解析订单号(不验签),用于按支付单冻结商户或历史 payment_config_id 双读加载配置
orderNo, err := wechat.PeekOrderNo(body)
if err != nil {
h.logger.Error("微信回调:预解析订单号失败", zap.Error(err))
@@ -144,12 +145,13 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
}
case model.ProviderTypeWechat:
if h.wechatPayment == nil {
return errors.New(errors.CodeWechatCallbackInvalid, "微信 v3 支付未初始化")
paymentSvc, err := h.wechatConfigService.GetPaymentServiceForCallback(cfg)
if err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "构建微信 v3 回调验签实例失败")
}
var httpReq http.Request
fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true)
_, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
_, err = paymentSvc.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
if result.TradeState != "SUCCESS" {
return h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID,
@@ -421,7 +423,7 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
// 按 payment_config_id 加载创建支付单时所用的配置(支持已停用配置)
// 按支付单冻结商户或历史 payment_config_id 双读加载配置(支持已停用配置)
cfg, err := h.wechatConfigService.GetConfigForCallback(ctx, outTradeNo)
if err != nil || cfg == nil {
h.logger.Error("支付宝回调:加载支付配置失败",
@@ -494,13 +496,14 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeWechatCallbackInvalid, "支付通道校验失败")
}
// 校验 payment_config_id 与当前配置一致;旧数据为空时兼容并记录 warn
if payment.PaymentConfigID == nil {
// 商户支付单已由 GetConfigForCallback 按 merchant_id 绑定当前商户凭证;
// payment_config_id 只属于历史双读路径,不能用于否定新商户回调。
if payment.MerchantID == nil && payment.PaymentConfigID == nil {
h.logger.Warn("支付宝回调payment_config_id 为空,跳过配置 ID 校验(旧数据兼容)",
zap.String("out_trade_no", outTradeNo),
zap.Uint("config_id", cfg.ID),
)
} else if *payment.PaymentConfigID != cfg.ID {
} else if payment.MerchantID == nil && *payment.PaymentConfigID != cfg.ID {
h.logger.Error("支付宝回调payment_config_id 不匹配",
zap.String("out_trade_no", outTradeNo),
zap.Uint("payment_config_id", *payment.PaymentConfigID),
@@ -567,7 +570,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
ctx := c.UserContext()
c.Set("Content-Type", "text/plain; charset=utf-8")
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
// 预解析订单号(不验签),用于按支付单冻结商户或历史 payment_config_id 双读加载配置
preNotify, err := fuiou.ParseNotify(body)
if err != nil {
h.logger.Error("富友回调:预解析失败",

View File

@@ -160,12 +160,45 @@ type AssetPackageResponse struct {
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// AssetPackagesResult 套餐列表分页结果
// AssetPackageHistoryNode 后台资产套餐历史层级节点。
type AssetPackageHistoryNode struct {
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
PackageType string `json:"package_type" description:"套餐类型formal/addon"`
ExpiryBase string `json:"expiry_base,omitempty" description:"到期时间基准"`
OrderID uint `json:"order_id" description:"关联订单ID无订单分配时为0"`
OrderNo string `json:"order_no,omitempty" description:"订单号快照"`
RefundID *uint `json:"refund_id,omitempty" description:"退款主键ID快照"`
RefundNo string `json:"refund_no,omitempty" description:"退款单号快照"`
UsageType string `json:"usage_type" description:"使用类型single_card/device"`
Status int `json:"status" description:"状态0待生效 1生效中 2已用完 3已过期 4已失效"`
StatusName string `json:"status_name" description:"状态名称"`
RealTotalMB int64 `json:"real_total_mb" description:"套餐真实总量(MB)"`
RealUsedMB int64 `json:"real_used_mb" description:"套餐真实已用量(MB)"`
VirtualTotalMB int64 `json:"virtual_total_mb" description:"套餐业务停机阈值(MB)"`
VirtualUsedMB float64 `json:"virtual_used_mb" description:"套餐展示已用量(MB)"`
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例"`
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间"`
MasterUsageID *uint `json:"master_usage_id" description:"主套餐使用记录ID普通主项为null"`
Priority int `json:"priority" description:"优先级"`
PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买成本价(分),仅平台账号可见"`
RetailAmount *int64 `json:"retail_amount,omitempty" description:"购买零售价(分)"`
CreatedAt time.Time `json:"created_at" description:"购买创建时间"`
Children []*AssetPackageHistoryNode `json:"children" nullable:"false" description:"关联加油包"`
ExpandByDefault bool `json:"expand_by_default" description:"是否默认展开关联加油包"`
RelationshipStatus string `json:"relationship_status,omitempty" description:"关系异常状态master_missing"`
RelationshipStatusName string `json:"relationship_status_name,omitempty" description:"关系异常状态名称"`
}
// AssetPackagesResult 后台资产套餐历史分页结果。
type AssetPackagesResult struct {
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页条数"`
Items []*AssetPackageResponse `json:"items" description:"套餐列表"`
Total int64 `json:"total" description:"筛选后的顶层关系组总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页顶层关系组数量"`
Items []*AssetPackageHistoryNode `json:"items" nullable:"false" description:"套餐历史层级列表"`
}
// AssetResolveRequest 资产解析请求

View File

@@ -183,12 +183,39 @@ type AssetPackageHistoryRequest struct {
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// AssetPackageHistoryResponse B3 资产套餐历史响应
// ClientAssetPackageHistoryNode H5 资产套餐历史层级节点。
type ClientAssetPackageHistoryNode struct {
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
OrderID uint `json:"order_id" description:"历史兼容字段本接口不填充真实订单ID零值仍输出为0"`
PackageType string `json:"package_type" description:"套餐类型formal/addon"`
UsageType string `json:"usage_type" description:"使用类型single_card/device"`
Status int `json:"status" description:"状态0待生效 1生效中 2已用完 3已过期 4已失效"`
StatusName string `json:"status_name" description:"状态名称"`
RealTotalMB int64 `json:"real_total_mb" description:"套餐真实总量(MB)"`
RealUsedMB int64 `json:"real_used_mb" description:"套餐真实已用量(MB)"`
VirtualTotalMB int64 `json:"virtual_total_mb" description:"套餐业务停机阈值(MB)"`
VirtualUsedMB float64 `json:"virtual_used_mb" description:"套餐展示已用量(MB)"`
ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例"`
EnableVirtualData bool `json:"enable_virtual_data" description:"是否启用虚流量"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间"`
MasterUsageID *uint `json:"master_usage_id" description:"主套餐使用记录ID普通主项为null"`
Priority int `json:"priority" description:"优先级"`
CreatedAt time.Time `json:"created_at" description:"购买创建时间"`
Children []*ClientAssetPackageHistoryNode `json:"children" nullable:"false" description:"关联加油包"`
ExpandByDefault bool `json:"expand_by_default" description:"是否默认展开关联加油包"`
RelationshipStatus string `json:"relationship_status,omitempty" description:"关系异常状态master_missing"`
RelationshipStatusName string `json:"relationship_status_name,omitempty" description:"关系异常状态名称"`
}
// AssetPackageHistoryResponse B3 资产套餐历史层级响应。
type AssetPackageHistoryResponse struct {
List []AssetPackageResponse `json:"items" description:"套餐历史列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"size" description:"每页数量"`
List []*ClientAssetPackageHistoryNode `json:"items" nullable:"false" description:"套餐历史层级列表"`
Total int64 `json:"total" description:"筛选后的顶层关系组总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"size" description:"每页顶层关系组数量"`
}
// ========================================

View File

@@ -0,0 +1,124 @@
package dto
import (
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// PaymentMerchantRequest 是受控商户管理写入请求。
type PaymentMerchantRequest struct {
Name string `json:"name" validate:"required,min=1,max=100" description:"商户名称"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
ProviderType string `json:"provider_type" validate:"required,max=30" description:"服务商类型:微信仅支持 wechat、wechat_v2、fuiou支付宝为 alipay"`
MerchantIdentity string `json:"merchant_identity" validate:"required,max=100" description:"商户号或应用标识"`
Credentials model.JSONB `json:"credentials" description:"商户受控凭证,仅专用管理接口传输"`
Enabled bool `json:"enabled" description:"是否启用"`
Remark string `json:"remark" validate:"max=1000" description:"备注"`
}
// PaymentMerchantUpdateRequest 是受控商户管理更新请求。
type PaymentMerchantUpdateRequest struct {
Name *string `json:"name" description:"商户名称"`
PaymentMethod *string `json:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
ProviderType *string `json:"provider_type" description:"服务商类型:微信仅支持 wechat、wechat_v2、fuiou支付宝为 alipay"`
MerchantIdentity *string `json:"merchant_identity" description:"商户号或应用标识"`
Credentials *model.JSONB `json:"credentials" description:"商户受控凭证"`
Enabled *bool `json:"enabled" description:"是否启用"`
Remark *string `json:"remark" description:"备注"`
}
// PaymentMerchantDeleteRequest 是受控商户删除二次确认请求。
type PaymentMerchantDeleteRequest struct {
Confirm bool `json:"confirm" validate:"required" description:"确认删除必须为true"`
}
// PaymentMerchantListRequest 是支付商户分页查询条件。
type PaymentMerchantListRequest struct {
Page int `query:"page" description:"页码"`
PageSize int `query:"page_size" description:"每页数量"`
PaymentMethod *string `query:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
Enabled *bool `query:"enabled" description:"是否启用"`
}
// PaymentMerchantResponse 是受控商户管理响应。
type PaymentMerchantResponse struct {
ID uint `json:"id" description:"商户ID"`
Name string `json:"name" description:"商户名称"`
PaymentMethod string `json:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
ProviderType string `json:"provider_type" description:"服务商类型"`
MerchantIdentity string `json:"merchant_identity" description:"商户号或应用标识"`
Credentials model.JSONB `json:"credentials" description:"商户受控凭证,仅专用管理接口返回"`
CredentialVersion int64 `json:"credential_version" description:"凭证版本"`
Enabled bool `json:"enabled" description:"是否启用"`
Remark string `json:"remark" description:"备注"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
// PaymentMerchantPoolRequest 是商户池创建或更新请求。
type PaymentMerchantPoolRequest struct {
Name string `json:"name" validate:"required,min=1,max=100" description:"商户池名称"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
Enabled bool `json:"enabled" description:"是否启用"`
Strategy string `json:"strategy" validate:"required,oneof=amount count time" enum:"amount,count,time" description:"轮询策略amount=金额阈值count=笔数阈值time=按时间段轮换"`
ThresholdAmount *int64 `json:"threshold_amount" description:"金额轮询阈值,单位分;仅 amount 策略"`
ThresholdCount *int64 `json:"threshold_count" description:"笔数轮询阈值;仅 count 策略"`
StatisticCycle *string `json:"statistic_cycle" enum:"round,day,month" description:"金额/笔数统计周期round=每轮day=自然日month=自然月"`
TimePeriodValue *int64 `json:"time_period_value" description:"时间轮询周期数值;仅 time 策略,最小 1"`
TimePeriodUnit *string `json:"time_period_unit" enum:"minute,hour,day" description:"时间轮询单位minute=分钟hour=小时day=天"`
TimePeriodStartedAt *time.Time `json:"time_period_started_at" description:"时间轮询起始时间;仅 time 策略"`
MemberIDs []uint `json:"member_ids" validate:"required,min=1" description:"有序商户ID列表"`
Remark string `json:"remark" description:"备注"`
}
// PaymentMerchantPoolListRequest 是商户池分页查询条件。
type PaymentMerchantPoolListRequest struct {
Page int `query:"page" description:"页码"`
PageSize int `query:"page_size" description:"每页数量"`
}
// PaymentMerchantPoolResponse 是商户池管理响应。
type PaymentMerchantPoolResponse struct {
ID uint `json:"id" description:"商户池ID"`
Name string `json:"name" description:"商户池名称"`
PaymentMethod string `json:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
Enabled bool `json:"enabled" description:"是否启用"`
Strategy string `json:"strategy" enum:"amount,count,time" description:"轮询策略amount=金额阈值count=笔数阈值time=按时间段轮换"`
ThresholdAmount *int64 `json:"threshold_amount,omitempty" description:"金额阈值,分"`
ThresholdCount *int64 `json:"threshold_count,omitempty" description:"笔数阈值"`
StatisticCycle *string `json:"statistic_cycle,omitempty" enum:"round,day,month" description:"统计周期round=每轮day=自然日month=自然月"`
TimePeriodValue *int64 `json:"time_period_value,omitempty" description:"时间周期数值"`
TimePeriodUnit *string `json:"time_period_unit,omitempty" enum:"minute,hour,day" description:"时间单位minute=分钟hour=小时day=天"`
TimePeriodStartedAt *time.Time `json:"time_period_started_at,omitempty" description:"时间轮询起始时间"`
RoutingEpoch int64 `json:"routing_epoch" description:"当前路由统计世代"`
MemberIDs []uint `json:"member_ids" description:"有序商户ID列表"`
Remark string `json:"remark" description:"备注"`
}
// WechatAuthorizationRequest 是微信授权配置管理写入请求。
type WechatAuthorizationRequest struct {
OaAppID string `json:"oa_app_id" description:"公众号AppID"`
OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret"`
OaToken string `json:"oa_token" description:"公众号Token"`
OaAesKey string `json:"oa_aes_key" description:"公众号AES密钥"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"公众号OAuth回调地址"`
MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"`
MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret"`
Enabled bool `json:"enabled" description:"是否启用"`
}
// WechatAuthorizationResponse 是微信授权配置管理响应。
type WechatAuthorizationResponse struct {
ID uint `json:"id" description:"授权配置ID"`
OaAppID string `json:"oa_app_id" description:"公众号AppID"`
OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret仅专用管理接口返回"`
OaToken string `json:"oa_token" description:"公众号Token仅专用管理接口返回"`
OaAesKey string `json:"oa_aes_key" description:"公众号AES密钥仅专用管理接口返回"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"公众号OAuth回调地址"`
MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"`
MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret仅专用管理接口返回"`
CredentialVersion int64 `json:"credential_version" description:"凭证版本"`
Enabled bool `json:"enabled" description:"是否启用"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}

View File

@@ -8,23 +8,31 @@ import (
)
type Payment struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
PaymentNo string `gorm:"column:payment_no;type:varchar(40);uniqueIndex;not null" json:"payment_no"`
OrderID uint `gorm:"column:order_id;not null" json:"order_id"`
OrderType string `gorm:"column:order_type;type:varchar(30);not null" json:"order_type"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"`
MerchantIdentity string `gorm:"column:merchant_identity;type:varchar(100)" json:"-"`
Amount int64 `gorm:"column:amount;type:bigint;not null" json:"amount"`
Status int `gorm:"column:status;type:smallint;not null;default:0" json:"status"`
ThirdPartyTradeNo string `gorm:"column:third_party_trade_no;type:varchar(100)" json:"third_party_trade_no,omitempty"`
PaymentConfigID *uint `gorm:"column:payment_config_id" json:"payment_config_id,omitempty"`
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500)" json:"payment_voucher_key,omitempty"`
QRContent string `gorm:"column:qr_content;type:text" json:"-"`
PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"`
ExpireAt *time.Time `gorm:"column:expire_at" json:"expire_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
ID uint `gorm:"column:id;primaryKey" json:"id"`
PaymentNo string `gorm:"column:payment_no;type:varchar(40);uniqueIndex;not null" json:"payment_no"`
OrderID uint `gorm:"column:order_id;not null" json:"order_id"`
OrderType string `gorm:"column:order_type;type:varchar(30);not null" json:"order_type"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"`
MerchantIdentity string `gorm:"column:merchant_identity;type:varchar(100)" json:"-"`
MerchantID *uint `gorm:"column:merchant_id" json:"merchant_id,omitempty"`
MerchantPoolID *uint `gorm:"column:merchant_pool_id" json:"merchant_pool_id,omitempty"`
MerchantNameSnapshot string `gorm:"column:merchant_name_snapshot" json:"merchant_name_snapshot,omitempty"`
MerchantPaymentMethodSnapshot string `gorm:"column:merchant_payment_method_snapshot" json:"merchant_payment_method_snapshot,omitempty"`
MerchantProviderTypeSnapshot string `gorm:"column:merchant_provider_type_snapshot" json:"merchant_provider_type_snapshot,omitempty"`
MerchantPoolNameSnapshot string `gorm:"column:merchant_pool_name_snapshot" json:"merchant_pool_name_snapshot,omitempty"`
RoutingStrategySnapshot string `gorm:"column:routing_strategy_snapshot" json:"routing_strategy_snapshot,omitempty"`
RoutingEpoch *int64 `gorm:"column:routing_epoch" json:"routing_epoch,omitempty"`
Amount int64 `gorm:"column:amount;type:bigint;not null" json:"amount"`
Status int `gorm:"column:status;type:smallint;not null;default:0" json:"status"`
ThirdPartyTradeNo string `gorm:"column:third_party_trade_no;type:varchar(100)" json:"third_party_trade_no,omitempty"`
PaymentConfigID *uint `gorm:"column:payment_config_id" json:"payment_config_id,omitempty"`
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500)" json:"payment_voucher_key,omitempty"`
QRContent string `gorm:"column:qr_content;type:text" json:"-"`
PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"`
ExpireAt *time.Time `gorm:"column:expire_at" json:"expire_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
}
func (Payment) TableName() string {

View File

@@ -0,0 +1,94 @@
package model
import (
"time"
"gorm.io/gorm"
)
const (
PaymentMerchantStatusDisabled = 0
PaymentMerchantStatusEnabled = 1
PaymentMerchantStrategyAmount = "amount"
PaymentMerchantStrategyCount = "count"
PaymentMerchantStrategyTime = "time"
)
// PaymentMerchant 是实际收款商户,凭证只保存在受控字段。
type PaymentMerchant struct {
gorm.Model
BaseModel `gorm:"embedded"`
Name string `gorm:"column:name" json:"name"`
PaymentMethod string `gorm:"column:payment_method" json:"payment_method"`
ProviderType string `gorm:"column:provider_type" json:"provider_type"`
MerchantIdentity string `gorm:"column:merchant_identity" json:"merchant_identity"`
Credentials JSONB `gorm:"column:credentials;type:jsonb" json:"-"`
CredentialVersion int64 `gorm:"column:credential_version" json:"credential_version"`
Status int `gorm:"column:status" json:"status"`
Remark string `gorm:"column:remark" json:"remark"`
}
func (PaymentMerchant) TableName() string { return "tb_payment_merchant" }
// PaymentMerchantPool 是一种支付方式的商户轮询池。
type PaymentMerchantPool struct {
gorm.Model
BaseModel `gorm:"embedded"`
Name string `gorm:"column:name" json:"name"`
PaymentMethod string `gorm:"column:payment_method" json:"payment_method"`
Status int `gorm:"column:status" json:"status"`
Strategy string `gorm:"column:strategy" json:"strategy"`
ThresholdAmount *int64 `gorm:"column:threshold_amount" json:"threshold_amount,omitempty"`
ThresholdCount *int64 `gorm:"column:threshold_count" json:"threshold_count,omitempty"`
StatisticCycle *string `gorm:"column:statistic_cycle" json:"statistic_cycle,omitempty"`
TimePeriodValue *int64 `gorm:"column:time_period_value" json:"time_period_value,omitempty"`
TimePeriodUnit *string `gorm:"column:time_period_unit" json:"time_period_unit,omitempty"`
TimePeriodStartedAt *time.Time `gorm:"column:time_period_started_at" json:"time_period_started_at,omitempty"`
RoutingEpoch int64 `gorm:"column:routing_epoch" json:"routing_epoch"`
Remark string `gorm:"column:remark" json:"remark"`
}
func (PaymentMerchantPool) TableName() string { return "tb_payment_merchant_pool" }
// PaymentMerchantPoolMember 是商户池内按顺序参与轮询的商户。
type PaymentMerchantPoolMember struct {
gorm.Model
BaseModel `gorm:"embedded"`
PoolID uint `gorm:"column:pool_id" json:"pool_id"`
MerchantID uint `gorm:"column:merchant_id" json:"merchant_id"`
SortOrder int64 `gorm:"column:sort_order" json:"sort_order"`
}
func (PaymentMerchantPoolMember) TableName() string { return "tb_payment_merchant_pool_member" }
// WechatAuthorization 是 C 端微信 OAuth、小程序与支付 AppID 的全局授权配置。
type WechatAuthorization struct {
gorm.Model
BaseModel `gorm:"embedded"`
OaAppID string `gorm:"column:oa_app_id" json:"oa_app_id"`
OaAppSecret string `gorm:"column:oa_app_secret" json:"-"`
OaToken string `gorm:"column:oa_token" json:"-"`
OaAesKey string `gorm:"column:oa_aes_key" json:"-"`
OaOAuthRedirectURL string `gorm:"column:oa_oauth_redirect_url" json:"oa_oauth_redirect_url"`
MiniappAppID string `gorm:"column:miniapp_app_id" json:"miniapp_app_id"`
MiniappAppSecret string `gorm:"column:miniapp_app_secret" json:"-"`
CredentialVersion int64 `gorm:"column:credential_version" json:"credential_version"`
Status int `gorm:"column:status" json:"status"`
}
func (WechatAuthorization) TableName() string { return "tb_wechat_authorization" }
// PaymentMerchantRoutingSuccess 是支付首次成功的唯一统计事实。
type PaymentMerchantRoutingSuccess struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
PaymentID uint `gorm:"column:payment_id" json:"payment_id"`
MerchantID uint `gorm:"column:merchant_id" json:"merchant_id"`
PoolID uint `gorm:"column:pool_id" json:"pool_id"`
RoutingEpoch int64 `gorm:"column:routing_epoch" json:"routing_epoch"`
Amount int64 `gorm:"column:amount" json:"amount"`
PaidAt time.Time `gorm:"column:paid_at" json:"paid_at"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
func (PaymentMerchantRoutingSuccess) TableName() string { return "tb_payment_merchant_routing_success" }

View File

@@ -0,0 +1,304 @@
package asset
import (
"context"
"sort"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
const relationshipStatusMasterMissing = "master_missing"
// PackageHistoryQuery 读取资产范围内的套餐使用关系。
type PackageHistoryQuery struct {
db *gorm.DB
}
// PackageHistoryInput 定义已完成入口授权后的套餐历史读取范围。
type PackageHistoryInput struct {
AssetType string
AssetID uint
Generation *int
Status *int
PackageType *string
Page int
PageSize int
}
// PackageHistoryResult 保存筛选后的顶层关系组总数和分页后的关系组。
type PackageHistoryResult struct {
Total int64
Items []*PackageHistoryNode
}
// PackageHistoryNode 保存一个套餐使用记录及其可展示关联子项。
type PackageHistoryNode struct {
Usage *model.PackageUsage
Children []*PackageHistoryNode
RelationshipStatus string
}
type packageUsagePresence struct {
ID uint
IotCardID uint
DeviceID uint
Generation int
DeletedAt gorm.DeletedAt
}
// NewPackageHistoryQuery 创建套餐历史关系查询。
func NewPackageHistoryQuery(db *gorm.DB) *PackageHistoryQuery {
return &PackageHistoryQuery{db: db}
}
// List 在既有资产和世代范围内读取完整关系,再按整组应用筛选、排序和分页。
func (q *PackageHistoryQuery) List(ctx context.Context, input PackageHistoryInput) (*PackageHistoryResult, error) {
query, err := q.baseUsageQuery(ctx, input)
if err != nil {
return nil, err
}
var usages []*model.PackageUsage
if err := query.Find(&usages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败")
}
usageByID := make(map[uint]*model.PackageUsage, len(usages))
for _, usage := range usages {
usageByID[usage.ID] = usage
}
missingMasterIDs := unresolvedMasterIDs(usages, usageByID)
presentMasters, err := q.lookupMasterUsagePresence(ctx, missingMasterIDs)
if err != nil {
return nil, err
}
items := make([]*PackageHistoryNode, 0, len(usages))
nodes := make(map[uint]*PackageHistoryNode, len(usages))
for _, usage := range usages {
nodes[usage.ID] = &PackageHistoryNode{Usage: usage, Children: make([]*PackageHistoryNode, 0)}
}
for _, usage := range usages {
node := nodes[usage.ID]
if usage.MasterUsageID == nil {
items = append(items, node)
continue
}
master, inRange := nodes[*usage.MasterUsageID]
if inRange {
master.Children = append(master.Children, node)
continue
}
if _, exists := presentMasters[*usage.MasterUsageID]; exists {
return nil, errors.New(errors.CodeDatabaseError, "读取套餐历史关联失败")
}
node.RelationshipStatus = relationshipStatusMasterMissing
items = append(items, node)
}
matchingPackageIDs, err := q.matchingPackageIDs(ctx, input.PackageType, usages)
if err != nil {
return nil, err
}
items = filterPackageHistoryGroups(items, input.Status, matchingPackageIDs)
sortPackageHistoryGroups(items)
total := int64(len(items))
return &PackageHistoryResult{
Total: total,
Items: paginatePackageHistoryGroups(items, input.Page, input.PageSize),
}, nil
}
func (q *PackageHistoryQuery) baseUsageQuery(ctx context.Context, input PackageHistoryInput) (*gorm.DB, error) {
query := q.db.WithContext(ctx).Model(&model.PackageUsage{})
switch input.AssetType {
case "card":
query = query.Where("iot_card_id = ?", input.AssetID)
case "device":
query = query.Where("device_id = ?", input.AssetID)
default:
return nil, errors.New(errors.CodeInvalidParam, "资产类型非法")
}
if input.Generation != nil {
query = query.Where("generation = ?", *input.Generation)
}
return query, nil
}
func (q *PackageHistoryQuery) matchingPackageIDs(ctx context.Context, packageType *string, usages []*model.PackageUsage) (map[uint]struct{}, error) {
if packageType == nil {
return nil, nil
}
usagePackageIDs := collectPackageHistoryUsagePackageIDs(usages)
result := make(map[uint]struct{})
if len(usagePackageIDs) == 0 {
return result, nil
}
var ids []uint
if err := q.db.WithContext(ctx).Model(&model.Package{}).
Where("id IN ? AND package_type = ?", usagePackageIDs, *packageType).
Pluck("id", &ids).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐类型资格失败")
}
for _, id := range ids {
result[id] = struct{}{}
}
return result, nil
}
func collectPackageHistoryUsagePackageIDs(usages []*model.PackageUsage) []uint {
ids := make([]uint, 0, len(usages))
seen := make(map[uint]struct{}, len(usages))
for _, usage := range usages {
if usage == nil || usage.PackageID == 0 {
continue
}
if _, exists := seen[usage.PackageID]; exists {
continue
}
seen[usage.PackageID] = struct{}{}
ids = append(ids, usage.PackageID)
}
return ids
}
func filterPackageHistoryGroups(items []*PackageHistoryNode, status *int, packageIDs map[uint]struct{}) []*PackageHistoryNode {
filtered := make([]*PackageHistoryNode, 0, len(items))
for _, item := range items {
if matchesPackageHistoryUsage(item.Usage, status, packageIDs) {
filtered = append(filtered, item)
continue
}
for _, child := range item.Children {
if matchesPackageHistoryUsage(child.Usage, status, packageIDs) {
filtered = append(filtered, item)
break
}
}
}
return filtered
}
func matchesPackageHistoryUsage(usage *model.PackageUsage, status *int, packageIDs map[uint]struct{}) bool {
if status != nil && usage.Status != *status {
return false
}
if packageIDs == nil {
return true
}
_, ok := packageIDs[usage.PackageID]
return ok
}
func sortPackageHistoryGroups(items []*PackageHistoryNode) {
for _, item := range items {
sort.Slice(item.Children, func(i, j int) bool {
return packageHistoryChildLess(item.Children[i].Usage, item.Children[j].Usage)
})
}
sort.Slice(items, func(i, j int) bool {
left := items[i].Usage
right := items[j].Usage
if left.CreatedAt.Equal(right.CreatedAt) {
return left.ID > right.ID
}
return left.CreatedAt.After(right.CreatedAt)
})
}
func packageHistoryChildLess(left, right *model.PackageUsage) bool {
leftBucket := packageHistoryChildBucket(left)
rightBucket := packageHistoryChildBucket(right)
if leftBucket != rightBucket {
return leftBucket < rightBucket
}
leftTime := left.CreatedAt
rightTime := right.CreatedAt
if leftBucket == 0 {
leftTime = *left.ActivatedAt
rightTime = *right.ActivatedAt
}
if leftTime.Equal(rightTime) {
return left.ID < right.ID
}
return leftTime.Before(rightTime)
}
func packageHistoryChildBucket(usage *model.PackageUsage) int {
if usage.Status == constants.PackageUsageStatusPending {
return 2
}
if usage.ActivatedAt != nil {
return 0
}
return 1
}
func paginatePackageHistoryGroups(items []*PackageHistoryNode, page, pageSize int) []*PackageHistoryNode {
if len(items) == 0 {
return make([]*PackageHistoryNode, 0)
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 1
}
if page > (len(items)-1)/pageSize+1 {
return make([]*PackageHistoryNode, 0)
}
start := (page - 1) * pageSize
end := start + pageSize
if end > len(items) {
end = len(items)
}
return items[start:end]
}
func unresolvedMasterIDs(usages []*model.PackageUsage, usageByID map[uint]*model.PackageUsage) []uint {
ids := make([]uint, 0)
seen := make(map[uint]struct{})
for _, usage := range usages {
if usage.MasterUsageID == nil {
continue
}
masterID := *usage.MasterUsageID
if _, found := usageByID[masterID]; found {
continue
}
if _, alreadySeen := seen[masterID]; alreadySeen {
continue
}
seen[masterID] = struct{}{}
ids = append(ids, masterID)
}
return ids
}
func (q *PackageHistoryQuery) lookupMasterUsagePresence(ctx context.Context, ids []uint) (map[uint]packageUsagePresence, error) {
found := make(map[uint]packageUsagePresence, len(ids))
if len(ids) == 0 {
return found, nil
}
var records []packageUsagePresence
if err := q.db.WithContext(ctx).Unscoped().Model(&model.PackageUsage{}).
Select("id, iot_card_id, device_id, generation, deleted_at").
Where("id IN ?", ids).
Find(&records).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "核对套餐主记录失败")
}
for _, record := range records {
found[record.ID] = record
}
return found, nil
}

View File

@@ -126,6 +126,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.WechatConfig != nil {
registerWechatConfigRoutes(authGroup, handlers.WechatConfig, doc, basePath)
}
if handlers.PaymentMerchant != nil {
registerPaymentMerchantRoutes(authGroup, handlers.PaymentMerchant, doc, basePath)
}
if handlers.AgentRecharge != nil {
registerAgentRechargeRoutes(authGroup, handlers.AgentRecharge, doc, basePath)
}

View File

@@ -40,8 +40,8 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
})
Register(assets, doc, groupPath, "GET", "/:identifier/packages", handler.Packages, RouteSpec{
Summary: "资产套餐列表",
Description: "查询该资产所有套餐记录,含虚流量换算结果。支持分页与 status 状态筛选。",
Summary: "资产套餐历史",
Description: "查询该资产全部世代的套餐历史层级。每个主套餐及其关联加油包占一个分页名额total 为筛选后的顶层项数量;支持 status 按同一使用记录筛选。关联主套餐物理缺失返回异常独立项,存在但不可展示的关联主套餐返回统一读取错误。",
Tags: []string{"资产管理"},
Input: new(dto.AssetPackagesRequest),
Output: new(dto.AssetPackagesResult),

View File

@@ -0,0 +1,42 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// requirePaymentMerchantAccess 校验当前用户是否有权访问支付商户配置,
// 仅允许超级管理员和平台用户,其他角色直接返回无权限错误。
func requirePaymentMerchantAccess(handler fiber.Handler) fiber.Handler {
return func(c *fiber.Ctx) error {
kind := middleware.GetUserTypeFromContext(c.UserContext())
if kind != constants.UserTypeSuperAdmin && kind != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限访问支付商户配置")
}
return handler(c)
}
}
func registerPaymentMerchantRoutes(router fiber.Router, handler *admin.PaymentMerchantHandler, doc *openapi.Generator, basePath string) {
group := router
wrap := requirePaymentMerchantAccess
Register(group, doc, basePath, "GET", "/payment-merchants", wrap(handler.ListMerchants), RouteSpec{Summary: "查询支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.PaymentMerchantListRequest), Output: new(dto.PaymentMerchantResponse), Auth: true})
Register(group, doc, basePath, "POST", "/payment-merchants", wrap(handler.CreateMerchant), RouteSpec{Summary: "创建支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.PaymentMerchantRequest), Output: new(dto.PaymentMerchantResponse), Auth: true})
Register(group, doc, basePath, "GET", "/payment-merchants/:id", wrap(handler.GetMerchant), RouteSpec{Summary: "查询支付商户详情", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantResponse), Auth: true})
Register(group, doc, basePath, "PUT", "/payment-merchants/:id", wrap(handler.UpdateMerchant), RouteSpec{Summary: "更新支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantUpdateRequest), Output: new(dto.PaymentMerchantResponse), Auth: true})
Register(group, doc, basePath, "DELETE", "/payment-merchants/:id", wrap(handler.DeleteMerchant), RouteSpec{Summary: "删除支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantDeleteRequest), Output: nil, Auth: true})
Register(group, doc, basePath, "GET", "/payment-merchant-pools", wrap(handler.ListPools), RouteSpec{Summary: "查询商户池", Description: "仅超级管理员和平台用户可访问。支持分页,返回当前页商户池及其有序成员。", Tags: []string{"商户池管理"}, Input: new(dto.PaymentMerchantPoolListRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "GET", "/payment-merchant-pools/:id", wrap(handler.GetPool), RouteSpec{Summary: "查询商户池详情", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "POST", "/payment-merchant-pools", wrap(handler.CreatePool), RouteSpec{Summary: "创建商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.PaymentMerchantPoolRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "PUT", "/payment-merchant-pools/:id", wrap(handler.UpdatePool), RouteSpec{Summary: "更新商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantPoolRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "POST", "/payment-merchant-pools/:id/enable", wrap(handler.EnablePool), RouteSpec{Summary: "启用商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "POST", "/payment-merchant-pools/:id/disable", wrap(handler.DisablePool), RouteSpec{Summary: "停用商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "GET", "/wechat-authorizations", wrap(handler.GetAuthorization), RouteSpec{Summary: "获取微信授权配置", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"微信授权配置"}, Output: new(dto.WechatAuthorizationResponse), Auth: true})
Register(group, doc, basePath, "PUT", "/wechat-authorizations/current", wrap(handler.SaveAuthorization), RouteSpec{Summary: "保存微信授权配置", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"微信授权配置"}, Input: new(dto.WechatAuthorizationRequest), Output: new(dto.WechatAuthorizationResponse), Auth: true})
}

View File

@@ -156,11 +156,12 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
})
Register(authGroup, doc, basePath, "GET", "/asset/package-history", handlers.ClientAsset.GetPackageHistory, RouteSpec{
Summary: "资产套餐历史",
Tags: []string{"个人客户 - 资产"},
Auth: true,
Input: &dto.AssetPackageHistoryRequest{},
Output: &dto.AssetPackageHistoryResponse{},
Summary: "资产套餐历史",
Description: "查询客户已绑定资产当前世代的套餐历史层级。每个主套餐及其关联加油包占一个分页名额total 为筛选后的顶层项数量status 与 package_type 必须由同一使用记录联合命中,命中任一成员即返回完整关系组。关联主套餐物理缺失返回异常独立项,存在但不可展示的关联主套餐返回统一读取错误。",
Tags: []string{"个人客户 - 资产"},
Auth: true,
Input: &dto.AssetPackageHistoryRequest{},
Output: &dto.AssetPackageHistoryResponse{},
})
Register(authGroup, doc, basePath, "POST", "/asset/refresh", handlers.ClientAsset.RefreshAsset, RouteSpec{

View File

@@ -6,7 +6,6 @@ package asset
import (
"context"
stderrors "errors"
"sort"
"strconv"
"time"
@@ -14,6 +13,7 @@ import (
infraAudit "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetquery "github.com/break/junhong_cmp_fiber/internal/query/asset"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -760,11 +760,9 @@ func parseGatewayTime(raw gateway.FlexString) *time.Time {
return &t
}
// GetPackages 获取资产的所有套餐列表(支持分页和状态筛选)
// callerAccountType: 调用方账号类型,"platform" 时返回成本价paid_amount其他类型不返回
// page 默认 1pageSize 默认 50pageSize 最大 100
// GetPackages 获取资产套餐历史层级(支持顶层关系组分页和状态筛选)
// callerAccountType: 调用方账号类型,"platform" 时返回成本价paid_amount其他类型不返回
func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int, status *int, callerAccountType string) (*dto.AssetPackagesResult, error) {
// 分页参数边界处理
if page < 1 {
page = 1
}
@@ -778,110 +776,123 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa
return nil, errors.New(errors.CodeInvalidParam, "套餐状态非法")
}
// assetType 对应 Store 中的 carrierTypecard→iot_card, device→device
carrierType := assetType
if assetType == "card" {
carrierType = "iot_card"
}
usages, err := s.packageUsageStore.ListByCarrier(ctx, carrierType, id, status)
history, err := assetquery.NewPackageHistoryQuery(s.db).List(ctx, assetquery.PackageHistoryInput{
AssetType: assetType,
AssetID: id,
Status: status,
Page: page,
PageSize: pageSize,
})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐使用记录失败")
return nil, err
}
// 收集所有 PackageID 并批量查询
pkgIDSet := make(map[uint]struct{}, len(usages))
for _, u := range usages {
pkgIDSet[u.PackageID] = struct{}{}
}
pkgIDs := make([]uint, 0, len(pkgIDSet))
for id := range pkgIDSet {
pkgIDs = append(pkgIDs, id)
}
packages, pkgErr := s.packageStore.GetByIDsUnscoped(ctx, pkgIDs)
packageIDs := collectPackageHistoryPackageIDs(history.Items)
packages, pkgErr := s.packageStore.GetByIDsUnscoped(ctx, packageIDs)
if pkgErr != nil {
logger.GetAppLogger().Warn("批量查询套餐信息失败,套餐名称可能缺失",
zap.Uints("package_ids", pkgIDs),
zap.Uints("package_ids", packageIDs),
zap.Error(pkgErr))
}
pkgMap := make(map[uint]*model.Package, len(packages))
for _, p := range packages {
pkgMap[p.ID] = p
packageMap := make(map[uint]*model.Package, len(packages))
for _, pkg := range packages {
packageMap[pkg.ID] = pkg
}
all := make([]*dto.AssetPackageResponse, 0, len(usages))
for _, u := range usages {
pkg := pkgMap[u.PackageID]
metrics := u.BuildTrafficMetrics()
pkgName := u.PackageName
pkgType := ""
expiryBase := ""
if pkg != nil {
if pkgName == "" {
pkgName = pkg.PackageName
}
pkgType = pkg.PackageType
expiryBase = pkg.ExpiryBase
}
var paidAmount *int64
if callerAccountType == constants.OwnerTypePlatform {
paidAmount = u.PaidAmount
}
item := &dto.AssetPackageResponse{
PackageUsageID: u.ID,
PackageID: u.PackageID,
PackageName: pkgName,
PackageType: pkgType,
ExpiryBase: expiryBase,
OrderID: u.OrderID,
OrderNo: u.OrderNo,
RefundID: u.RefundID,
RefundNo: u.RefundNo,
UsageType: u.UsageType,
Status: u.Status,
StatusName: packageStatusName(u.Status),
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
EnableVirtualData: u.EnableVirtualDataSnapshot,
ActivatedAt: u.ActivatedAt,
ExpiresAt: u.ExpiresAt,
MasterUsageID: u.MasterUsageID,
Priority: u.Priority,
PaidAmount: paidAmount,
RetailAmount: u.RetailAmount,
CreatedAt: u.CreatedAt,
}
all = append(all, item)
items := make([]*dto.AssetPackageHistoryNode, 0, len(history.Items))
for _, node := range history.Items {
items = append(items, buildAssetPackageHistoryNode(node, packageMap, callerAccountType))
}
// 按 created_at DESC 排序
sort.Slice(all, func(i, j int) bool {
return all[i].CreatedAt.After(all[j].CreatedAt)
})
total := int64(len(all))
offset := (page - 1) * pageSize
end := offset + pageSize
if offset >= len(all) {
offset = len(all)
}
if end > len(all) {
end = len(all)
}
items := all[offset:end]
return &dto.AssetPackagesResult{
Total: total,
Total: history.Total,
Page: page,
PageSize: pageSize,
Items: items,
}, nil
}
// GetCurrentPackage 获取资产当前可展示的主套餐。
func collectPackageHistoryPackageIDs(items []*assetquery.PackageHistoryNode) []uint {
ids := make([]uint, 0)
seen := make(map[uint]struct{})
var collect func(*assetquery.PackageHistoryNode)
collect = func(node *assetquery.PackageHistoryNode) {
if node == nil || node.Usage == nil {
return
}
if _, exists := seen[node.Usage.PackageID]; !exists {
seen[node.Usage.PackageID] = struct{}{}
ids = append(ids, node.Usage.PackageID)
}
for _, child := range node.Children {
collect(child)
}
}
for _, item := range items {
collect(item)
}
return ids
}
func buildAssetPackageHistoryNode(node *assetquery.PackageHistoryNode, packageMap map[uint]*model.Package, callerAccountType string) *dto.AssetPackageHistoryNode {
usage := node.Usage
pkg := packageMap[usage.PackageID]
metrics := usage.BuildTrafficMetrics()
packageName := usage.PackageName
packageType := ""
expiryBase := ""
if pkg != nil {
if packageName == "" {
packageName = pkg.PackageName
}
packageType = pkg.PackageType
expiryBase = pkg.ExpiryBase
}
var paidAmount *int64
if callerAccountType == constants.OwnerTypePlatform {
paidAmount = usage.PaidAmount
}
item := &dto.AssetPackageHistoryNode{
PackageUsageID: usage.ID,
PackageID: usage.PackageID,
PackageName: packageName,
PackageType: packageType,
ExpiryBase: expiryBase,
OrderID: usage.OrderID,
OrderNo: usage.OrderNo,
RefundID: usage.RefundID,
RefundNo: usage.RefundNo,
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
EnableVirtualData: usage.EnableVirtualDataSnapshot,
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
PaidAmount: paidAmount,
RetailAmount: usage.RetailAmount,
CreatedAt: usage.CreatedAt,
Children: make([]*dto.AssetPackageHistoryNode, 0, len(node.Children)),
}
if node.RelationshipStatus != "" {
item.RelationshipStatus = node.RelationshipStatus
item.RelationshipStatusName = "关联主套餐缺失"
}
for _, child := range node.Children {
item.Children = append(item.Children, buildAssetPackageHistoryNode(child, packageMap, callerAccountType))
}
item.ExpandByDefault = len(item.Children) > 0
return item
}
// GetCurrentPackage 获取资产当前可展示的主套餐
// callerAccountType: 调用方账号类型,"platform" 时返回成本价paid_amount其他类型不返回
func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id uint, callerAccountType string) (*dto.AssetPackageResponse, error) {
carrierType := assetType

View File

@@ -137,10 +137,12 @@ func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest,
ExpiresIn: assetTokenExpireSeconds,
}
if wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx); err == nil && wechatConfig != nil {
resp.OaAppID = wechatConfig.OaAppID
resp.MiniappAppID = wechatConfig.MiniappAppID
wechatAuthorization, err := s.wechatConfigService.GetAuthorizationConfig(ctx)
if err != nil {
return nil, err
}
resp.OaAppID = wechatAuthorization.OaAppID
resp.MiniappAppID = wechatAuthorization.MiniappAppID
return resp, nil
}
@@ -156,13 +158,10 @@ func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest,
return nil, err
}
wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
wechatConfig, err := s.wechatConfigService.GetAuthorizationConfig(ctx)
if err != nil {
return nil, err
}
if wechatConfig == nil {
return nil, errors.New(errors.CodeWechatConfigUnavailable)
}
oaApp, err := wechat.NewOfficialAccountAppFromConfig(wechatConfig, s.wechatCache, s.logger)
if err != nil {
@@ -219,13 +218,10 @@ func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest
return nil, err
}
wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
wechatConfig, err := s.wechatConfigService.GetAuthorizationConfig(ctx)
if err != nil {
return nil, err
}
if wechatConfig == nil {
return nil, errors.New(errors.CodeWechatConfigUnavailable)
}
miniService, err := wechat.NewMiniAppServiceFromConfig(wechatConfig, s.logger)
if err != nil {

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -33,9 +34,10 @@ const (
clientPurchaseLockTTL = 10 * time.Second
)
// WechatConfigServiceInterface 微信配置服务接口
// WechatConfigServiceInterface 只读取历史支付单的综合支付配置
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
GetAuthorizationConfig(ctx context.Context) (*model.WechatConfig, error)
GetByIDUnscoped(ctx context.Context, id uint) (*model.WechatConfig, error)
}
// OrderWalletPayServiceInterface 订单钱包支付服务接口。
@@ -70,7 +72,6 @@ type Service struct {
openIDStore *postgres.PersonalCustomerOpenIDStore
personalCustomerStore *postgres.PersonalCustomerStore
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
wechatConfigService WechatConfigServiceInterface
orderPaymentService OrderWalletPayServiceInterface
packageSeriesStore *postgres.PackageSeriesStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
@@ -82,6 +83,8 @@ type Service struct {
paymentMethodPolicy PaymentMethodPolicy
auditWriter *audit.Writer
paymentIntegration *integrationlog.Repository
merchantRuntime *merchantpayment.RuntimeLoader
wechatConfigService WechatConfigServiceInterface
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
@@ -95,6 +98,11 @@ func (s *Service) SetPaymentAudit(writer *audit.Writer, integration *integration
s.paymentIntegration = integration
}
// SetLegacyPaymentConfigService 注入仅供 merchant_id 为空历史支付单使用的旧配置读取。
func (s *Service) SetLegacyPaymentConfigService(wechatConfigService WechatConfigServiceInterface) {
s.wechatConfigService = wechatConfigService
}
// New 创建客户端订单服务。
func New(
assetService *asset.Service,
@@ -107,7 +115,6 @@ func New(
openIDStore *postgres.PersonalCustomerOpenIDStore,
personalCustomerStore *postgres.PersonalCustomerStore,
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore,
wechatConfigService WechatConfigServiceInterface,
orderPaymentService OrderWalletPayServiceInterface,
packageSeriesStore *postgres.PackageSeriesStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
@@ -128,7 +135,6 @@ func New(
openIDStore: openIDStore,
personalCustomerStore: personalCustomerStore,
personalCustomerPhoneStore: personalCustomerPhoneStore,
wechatConfigService: wechatConfigService,
orderPaymentService: orderPaymentService,
packageSeriesStore: packageSeriesStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
@@ -137,6 +143,7 @@ func New(
db: db,
redis: redisClient,
logger: logger,
merchantRuntime: merchantpayment.NewRuntimeLoader(db, redisClient),
}
}
@@ -301,18 +308,10 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
// }
if req.PaymentMethod == model.PaymentMethodAlipay {
// 支付宝强充:不需要 app_type / OpenID
activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败")
}
if activeConfig == nil {
return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置")
}
return s.createAlipayForceRechargeOrder(skipCtx, customerID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, &created)
return s.createAlipayForceRechargeOrder(skipCtx, customerID, assetInfo, validationResult, forceRecharge, redisKey, &created)
}
// 微信强充app_type 必须传入
activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType)
authorization, appID, err := s.loadWechatAuthorization(skipCtx, req.AppType)
if err != nil {
return nil, err
}
@@ -320,11 +319,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
if err != nil {
return nil, err
}
paymentProvider, err := s.newPaymentProvider(activeConfig, appID, req.AppType)
if err != nil {
return nil, err
}
return s.createForceRechargeOrder(skipCtx, customerID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentProvider, &created)
return s.createForceRechargeOrder(skipCtx, customerID, openID, assetInfo, validationResult, authorization, appID, req.AppType, forceRecharge, redisKey, &created)
}
return s.createPackageOrder(skipCtx, customerID, validationResult, req.PaymentMethod, redisKey, &created)
@@ -362,31 +357,49 @@ func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetReso
}
}
func (s *Service) resolveWechatConfig(ctx context.Context, appType string) (*model.WechatConfig, string, error) {
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
func (s *Service) loadWechatAuthorization(ctx context.Context, appType string) (*model.WechatAuthorization, string, error) {
if s.merchantRuntime == nil {
return nil, "", errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
authorization, err := s.merchantRuntime.LoadAuthorization(ctx)
if err != nil {
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信配置失败")
return nil, "", err
}
if activeConfig == nil {
return nil, "", errors.New(errors.CodeWechatPayFailed, "未找到生效的微信支付配置")
}
switch appType {
case "official_account":
if activeConfig.OaAppID == "" {
return nil, "", errors.New(errors.CodeWechatPayFailed, "公众号支付配置不完整")
if strings.TrimSpace(authorization.OaAppID) == "" {
return nil, "", errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置")
}
return activeConfig, activeConfig.OaAppID, nil
return authorization, authorization.OaAppID, nil
case "miniapp":
if activeConfig.MiniappAppID == "" {
return nil, "", errors.New(errors.CodeWechatPayFailed, "小程序支付配置不完整")
if strings.TrimSpace(authorization.MiniappAppID) == "" {
return nil, "", errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置")
}
return activeConfig, activeConfig.MiniappAppID, nil
return authorization, authorization.MiniappAppID, nil
default:
return nil, "", errors.New(errors.CodeInvalidParam)
}
}
func (s *Service) selectMerchantConfig(ctx context.Context, tx *gorm.DB, paymentMethod string, authorization *model.WechatAuthorization) (*merchantpayment.RouteSelection, *model.WechatConfig, error) {
if s.merchantRuntime == nil {
return nil, nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
route, err := s.merchantRuntime.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, time.Now())
if err != nil {
return nil, nil, err
}
config, err := merchantpayment.MerchantConfig(route.Merchant, authorization)
if err != nil {
return nil, nil, err
}
return route, config, nil
}
func freezePaymentRoute(payment *model.Payment, route *merchantpayment.RouteSelection) {
merchantpayment.FreezeRoute(payment, route)
}
func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, appID string) (string, error) {
records, err := s.openIDStore.ListByCustomerID(ctx, customerID)
if err != nil {
@@ -405,6 +418,41 @@ func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, ap
return "", errors.New(errors.CodeNotFound, "未找到当前应用的微信授权信息")
}
// merchantConfigForPayment 按冻结商户或历史 payment_config_id 加载支付凭证。
// merchant_id 为空仅是历史支付保留期兼容:独立 Change 清理后删除此读取,
// 绝不按当前商户池或综合配置推断、回填该支付的商户。
func (s *Service) merchantConfigForPayment(ctx context.Context, payment *model.Payment) (*model.WechatConfig, error) {
if payment != nil && payment.MerchantID != nil {
if s.merchantRuntime == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
merchant, err := s.merchantRuntime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return nil, err
}
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, err = s.merchantRuntime.LoadAuthorization(ctx)
if err != nil {
return nil, err
}
}
return merchantpayment.MerchantConfig(merchant, authorization)
}
if payment == nil || payment.PaymentConfigID == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付宝支付单缺少支付配置")
}
if s.wechatConfigService == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "旧支付配置加载能力未配置")
}
config, err := s.wechatConfigService.GetByIDUnscoped(ctx, *payment.PaymentConfigID)
if err != nil {
return nil, errors.Wrap(errors.CodeNoPaymentConfig, err, "历史支付宝支付配置不可用")
}
return config, nil
}
func (s *Service) createPackageOrder(
ctx context.Context,
customerID uint,
@@ -465,10 +513,10 @@ func (s *Service) createForceRechargeOrder(
openID string,
assetInfo *dto.AssetResolveResponse,
validationResult *purchase_validation.PurchaseValidationResult,
activeConfig *model.WechatConfig,
authorization *model.WechatAuthorization,
appID, appType string,
forceRecharge *ForceRechargeRequirement,
redisKey string,
paymentProvider PaymentProvider,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
resourceType, resourceID, err := resolveWalletResource(validationResult)
@@ -519,16 +567,27 @@ func (s *Service) createForceRechargeOrder(
paymentNo := generateClientPaymentNo()
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByWechat,
Amount: forceRecharge.ForceRechargeAmount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &activeConfig.ID,
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByWechat,
Amount: forceRecharge.ForceRechargeAmount,
Status: model.PaymentRecordStatusPending,
}
var activeConfig *model.WechatConfig
var paymentProvider PaymentProvider
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, config, err := s.selectMerchantConfig(ctx, tx, model.PaymentByWechat, authorization)
if err != nil {
return err
}
paymentProvider, err = s.newPaymentProvider(config, appID, appType)
if err != nil {
return err
}
activeConfig = config
freezePaymentRoute(payment, route)
if err := s.rechargeOrderStore.CreateWithTx(ctx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
@@ -967,26 +1026,22 @@ func (s *Service) getOrBuildAlipayPaymentLink(
amount int64,
subject string,
) (*dto.ClientPaymentLink, error) {
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败")
}
if activeConfig == nil {
return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置")
}
// 查找最新的 alipay 待支付单
existing, _ := s.paymentStore.FindLatestPendingByOrderAndMethod(
ctx, orderID, orderType, model.PaymentByAlipay,
)
var payment *model.Payment
var activeConfig *model.WechatConfig
created := false
now := time.Now()
if existing != nil && existing.ExpireAt != nil && existing.ExpireAt.After(now) {
// 复用未过期的支付单
payment = existing
var err error
activeConfig, err = s.merchantConfigForPayment(ctx, payment)
if err != nil {
return nil, err
}
} else {
// 过期或不存在,标记旧单 failed 并新建
if existing != nil {
if updateErr := s.markPaymentFailed(ctx, existing, "支付宝支付记录过期关闭"); updateErr != nil {
s.logger.Warn("标记过期支付宝支付单 failed 失败",
@@ -995,44 +1050,51 @@ func (s *Service) getOrBuildAlipayPaymentLink(
)
}
}
expireMinutes := activeConfig.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
newPayment := &model.Payment{
PaymentNo: generateClientPaymentNo(),
OrderID: orderID,
OrderType: orderType,
PaymentMethod: model.PaymentByAlipay,
Amount: amount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &activeConfig.ID,
ExpireAt: &expireAt,
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, config, err := s.selectMerchantConfig(ctx, tx, model.PaymentByAlipay, nil)
if err != nil {
return err
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
newPayment := &model.Payment{
PaymentNo: generateClientPaymentNo(),
OrderID: orderID,
OrderType: orderType,
PaymentMethod: model.PaymentByAlipay,
Amount: amount,
Status: model.PaymentRecordStatusPending,
ExpireAt: &expireAt,
}
freezePaymentRoute(newPayment, route)
if err := s.paymentStore.CreateWithTx(ctx, tx, newPayment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付宝支付单失败")
}
return s.appendPaymentCreatedAudit(ctx, tx, newPayment, nil, nil)
if err := s.appendPaymentCreatedAudit(ctx, tx, newPayment, nil, nil); err != nil {
return err
}
payment = newPayment
activeConfig = config
return nil
}); err != nil {
return nil, err
}
payment = newPayment
created = true
s.logger.Info("创建支付宝支付单",
zap.String("payment_no", payment.PaymentNo),
zap.String("order_type", orderType),
zap.Uint("order_id", orderID),
zap.Int64("amount", amount),
zap.Time("expire_at", expireAt),
zap.Uint("config_id", activeConfig.ID),
zap.Time("expire_at", *payment.ExpireAt),
)
}
wapURL, err := alipay.BuildWapPayURL(ctx, activeConfig, payment, subject)
if err != nil {
// 新建的 payment 生成链接失败,标记 failed
if existing == nil || payment.ID != existing.ID {
if created {
_ = s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录")
}
return nil, err
@@ -1056,7 +1118,6 @@ func (s *Service) createAlipayForceRechargeOrder(
customerID uint,
assetInfo *dto.AssetResolveResponse,
validationResult *purchase_validation.PurchaseValidationResult,
activeConfig *model.WechatConfig,
forceRecharge *ForceRechargeRequirement,
redisKey string,
created *bool,
@@ -1105,27 +1166,31 @@ func (s *Service) createAlipayForceRechargeOrder(
LinkedCarrierType: assetInfo.AssetType,
LinkedCarrierID: &resourceID,
AutoPurchaseStatus: model.AutoPurchaseStatusPending,
PaymentConfigID: &activeConfig.ID,
}
expireMinutesForce := activeConfig.AliPayExpireMinutes
if expireMinutesForce <= 0 {
expireMinutesForce = 30
}
expireAt := time.Now().Add(time.Duration(expireMinutesForce) * time.Minute)
paymentNo := generateClientPaymentNo()
payment := &model.Payment{
PaymentNo: paymentNo,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByAlipay,
Amount: forceRecharge.ForceRechargeAmount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &activeConfig.ID,
ExpireAt: &expireAt,
PaymentNo: paymentNo,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByAlipay,
Amount: forceRecharge.ForceRechargeAmount,
Status: model.PaymentRecordStatusPending,
}
// 事务创建充值单 + 支付单WAP URL 本地签名,不需要先调第三方)
var activeConfig *model.WechatConfig
var expireAt time.Time
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, config, err := s.selectMerchantConfig(ctx, tx, model.PaymentByAlipay, nil)
if err != nil {
return err
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt = time.Now().Add(time.Duration(expireMinutes) * time.Minute)
activeConfig = config
payment.ExpireAt = &expireAt
freezePaymentRoute(payment, route)
if err := s.rechargeOrderStore.CreateWithTx(ctx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
@@ -1154,7 +1219,6 @@ func (s *Service) createAlipayForceRechargeOrder(
zap.String("recharge_no", rechargeOrderNo),
zap.Int64("amount", forceRecharge.ForceRechargeAmount),
zap.Time("expire_at", expireAt),
zap.Uint("config_id", activeConfig.ID),
)
s.markClientPurchaseCreated(ctx, redisKey, rechargeOrderNo)
@@ -1432,7 +1496,7 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
return &dto.ClientPayOrderResponse{PaymentMethod: paymentMethod}, nil
case model.PaymentMethodWechat:
activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType)
authorization, appID, err := s.loadWechatAuthorization(skipCtx, req.AppType)
if err != nil {
return nil, err
}
@@ -1440,29 +1504,29 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
if err != nil {
return nil, err
}
paymentProvider, err := s.newPaymentProvider(activeConfig, appID, req.AppType)
if err != nil {
return nil, err
}
paymentNo := generateClientPaymentNo()
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: model.PaymentByWechat,
Amount: order.TotalAmount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &activeConfig.ID,
PaymentNo: paymentNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: model.PaymentByWechat,
Amount: order.TotalAmount,
Status: model.PaymentRecordStatusPending,
}
var activeConfig *model.WechatConfig
var paymentProvider PaymentProvider
if err := s.db.WithContext(skipCtx).Transaction(func(tx *gorm.DB) error {
// 订单仍保留最近一次支付配置,兼容旧 ORD 回调。
if err := tx.Model(&model.Order{}).
Where("id = ?", orderID).
Update("payment_config_id", activeConfig.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新订单支付配置失败")
route, config, err := s.selectMerchantConfig(skipCtx, tx, model.PaymentByWechat, authorization)
if err != nil {
return err
}
paymentProvider, err = s.newPaymentProvider(config, appID, req.AppType)
if err != nil {
return err
}
activeConfig = config
freezePaymentRoute(payment, route)
if err := s.paymentStore.CreateWithTx(skipCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
@@ -1498,30 +1562,12 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
}, nil
case model.PaymentMethodAlipay:
// 支付宝支付:不需要 app_type / OpenID
activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败")
}
if activeConfig == nil {
return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置")
}
paymentLink, err := s.getOrBuildAlipayPaymentLink(
skipCtx, orderID, model.PaymentOrderTypePackage, order.TotalAmount, "套餐购买",
)
if err != nil {
return nil, err
}
// 更新订单支付配置 ID兼容回调配置回溯
if dbErr := s.db.WithContext(skipCtx).Model(&model.Order{}).
Where("id = ?", orderID).
Update("payment_config_id", activeConfig.ID).Error; dbErr != nil {
s.logger.Warn("更新订单支付配置 ID 失败",
zap.Uint("order_id", orderID),
zap.Error(dbErr),
)
}
return &dto.ClientPayOrderResponse{
PaymentMethod: paymentMethod,
PaymentLink: paymentLink,

View File

@@ -11,6 +11,7 @@ import (
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
@@ -2018,8 +2019,10 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str
if thirdPartyTradeNo != "" {
paymentUpdates["third_party_trade_no"] = thirdPartyTradeNo
}
// 迟到首次成功必须允许 pending→paid 与 failed→paid支付创建时冻结的
// merchant_id/routing_epoch 仍决定统计归属,失败状态不表示已计入或已冲销。
paymentResult := tx.Model(&model.Payment{}).
Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
Where("id = ? AND status IN ?", payment.ID, []int{model.PaymentRecordStatusPending, model.PaymentRecordStatusFailed}).
Updates(paymentUpdates)
if paymentResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, paymentResult.Error, "更新支付记录失败")
@@ -2035,6 +2038,10 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str
return errors.New(errors.CodeInvalidStatus, "支付记录状态不允许处理")
}
if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, now); err != nil {
return err
}
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending).
Updates(map[string]any{

View File

@@ -5,6 +5,7 @@ import (
"strconv"
"time"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -86,7 +87,8 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p
return nil
}
if payment.Status != model.PaymentRecordStatusPending {
// 迟到首次成功必须允许 pending 与 failed 状态进入确认;冻结路由决定统计归属。
if payment.Status != model.PaymentRecordStatusPending && payment.Status != model.PaymentRecordStatusFailed {
return errors.New(errors.CodeInvalidStatus, "支付状态不允许处理")
}
@@ -102,7 +104,11 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
oldPaymentStatus := model.PaymentRecordStatusPending
// 乐观锁允许 pending 或 failed 首次进入 paid重复成功由 RowsAffected/唯一事实保护。
oldPaymentStatus := payment.Status
if oldPaymentStatus != model.PaymentRecordStatusPending && oldPaymentStatus != model.PaymentRecordStatusFailed {
return errors.New(errors.CodeInvalidStatus, "支付状态不允许处理")
}
if err := s.paymentStore.UpdateStatusWithOptimisticLockDB(ctx, tx, payment.ID, &oldPaymentStatus, model.PaymentRecordStatusPaid, &now); err != nil {
if err == gorm.ErrRecordNotFound {
return nil
@@ -117,6 +123,10 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p
return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
}
if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, now); err != nil {
return err
}
oldRechargeStatus := model.RechargeOrderStatusPending
if err := s.rechargeOrderStore.UpdateStatusWithOptimisticLockDB(ctx, tx, rechargeOrder.ID, &oldRechargeStatus, model.RechargeOrderStatusPaid, &now); err != nil {
if err == gorm.ErrRecordNotFound {

View File

@@ -62,6 +62,9 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, &order); err != nil {
return err
}
if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil {
return err
}
if refund.Status == model.RefundStatusPending {
changed = true
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).

View File

@@ -0,0 +1,99 @@
package refund
import (
"context"
"strings"
"gorm.io/gorm"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SetPaymentMerchantRuntime 注入冻结商户的当前凭证版本加载器。
func (s *Service) SetPaymentMerchantRuntime(runtime *merchantpayment.RuntimeLoader) {
s.paymentMerchantRuntime = runtime
}
// preparePaymentRefundCredentials 只为既有套餐订单退款流程装载和校验支付凭证;本 Change
// 不发起任何渠道退款请求。钱包支付和线下支付没有收款商户凭证,直接放行。
// merchant_id 为空仅是留存期内的历史线上支付,独立 Change 清理后才可删除按
// payment_config_id 读取的兼容路径,绝不能按当前商户池推断商户。
func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm.DB, orderID uint) error {
var payment model.Payment
err := tx.WithContext(ctx).
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
Order("id DESC").
First(&payment).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联支付单失败")
}
// 钱包支付和线下支付没有收款商户凭证,不被本检查阻断。
if payment.PaymentMethod == model.PaymentByWallet || payment.PaymentMethod == model.PaymentByOffline {
return nil
}
if payment.MerchantID != nil {
if s.paymentMerchantRuntime == nil {
return errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置")
}
merchant, loadErr := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID)
if loadErr != nil {
return loadErr
}
return validateFrozenMerchantRefundCredentials(merchant)
}
if payment.PaymentConfigID == nil {
return errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
}
var legacy model.WechatConfig
if err := tx.WithContext(ctx).Unscoped().First(&legacy, *payment.PaymentConfigID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
}
return errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败")
}
return nil
}
// validateFrozenMerchantRefundCredentials 只判断既有渠道流程所需凭证是否完整。
// 当前系统没有任何渠道原路退款 Adapter因此返回前不调用微信、支付宝或富友。
func validateFrozenMerchantRefundCredentials(merchant *model.PaymentMerchant) error {
config, err := merchantpayment.MerchantConfig(merchant, nil)
if err != nil {
return err
}
switch config.ProviderType {
case model.ProviderTypeWechat:
if blank(config.WxMchID, config.WxAPIV3Key, config.WxCertContent, config.WxKeyContent, config.WxSerialNo, config.WxNotifyURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整")
}
case model.ProviderTypeWechatV2:
if blank(config.WxMchID, config.WxAPIV2Key, config.WxNotifyURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整")
}
case model.ProviderTypeFuiou:
if blank(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey, config.FyPublicKey, config.FyAPIURL, config.FyNotifyURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结富友商户退款凭证不完整")
}
case "alipay":
if blank(config.AliAppID, config.AliPrivateKey, config.AliPublicKey, config.AliNotifyURL, config.AliReturnURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结支付宝商户退款凭证不完整")
}
default:
return errors.New(errors.CodeNoPaymentConfig, "冻结商户不支持现有退款流程")
}
return nil
}
func blank(values ...string) bool {
for _, value := range values {
if strings.TrimSpace(value) == "" {
return true
}
}
return false
}

View File

@@ -15,6 +15,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
@@ -56,6 +57,7 @@ type Service struct {
notificationOutbox *outbox.Repository
auditWriter *audit.Writer
logger *zap.Logger
paymentMerchantRuntime *merchantpayment.RuntimeLoader
}
// New 创建退款业务服务实例
@@ -355,6 +357,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
if orderResult.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "订单状态已变更,无法执行退款审批")
}
if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil {
return err
}
if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil {
return err

View File

@@ -13,6 +13,7 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -22,6 +23,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
)
// Redis 缓存键
@@ -544,6 +546,28 @@ func (s *Service) GetActiveConfig(ctx context.Context) (*model.WechatConfig, err
return config, nil
}
// GetAuthorizationConfig 读取唯一启用且按凭证版本缓存的 C 端微信授权配置。
// 仅用于兼容现有微信 SDK 的配置结构,绝不回退到 tb_wechat_config。
func (s *Service) GetAuthorizationConfig(ctx context.Context) (*model.WechatConfig, error) {
if s == nil || s.store == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "微信授权加载能力未配置")
}
authorization, err := merchantpayment.NewRuntimeLoader(s.store.DB(), s.redis).LoadAuthorization(ctx)
if err != nil {
return nil, err
}
return &model.WechatConfig{
IsActive: true,
OaAppID: authorization.OaAppID,
OaAppSecret: authorization.OaAppSecret,
OaToken: authorization.OaToken,
OaAesKey: authorization.OaAesKey,
OaOAuthRedirectURL: authorization.OaOAuthRedirectURL,
MiniappAppID: authorization.MiniappAppID,
MiniappAppSecret: authorization.MiniappAppSecret,
}, nil
}
// GetActiveConfigForAPI 获取当前生效的支付配置API 响应,已脱敏)
func (s *Service) GetActiveConfigForAPI(ctx context.Context) (*dto.WechatConfigResponse, error) {
config, err := s.GetActiveConfig(ctx)
@@ -594,30 +618,74 @@ func (s *Service) mergeSensitiveField(target *string, newVal *string) {
}
}
// GetConfigForCallback 按订单号查找创建该订单时所用的支付配置(含已软删除/停用的记录)
// 回调验签必须使用创建订单时的密钥,否则签名不匹配。
// 若找不到 payment_config_id旧订单或数据缺失回退到当前激活配置。
// GetByIDUnscoped loads a historical comprehensive payment configuration, including soft-deleted rows.
func (s *Service) GetByIDUnscoped(ctx context.Context, id uint) (*model.WechatConfig, error) {
if s == nil || s.store == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "旧支付配置加载能力未配置")
}
return s.store.GetByIDUnscoped(ctx, id)
}
// GetConfigForCallback 按支付单冻结的商户或旧支付配置加载回调凭证。
// merchant_id 为空仅表示数据留存期内的历史支付:本 Change 只允许按其
// payment_config_id 读取,独立 Change 完成留存清理后才可删除这条旧路径。
// 禁止按当前启用池、当前综合配置推断或回填历史支付的实际商户。
func (s *Service) GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error) {
if s.paymentStore != nil {
payment, err := s.paymentStore.GetByPaymentNo(ctx, orderNo)
if err == nil && payment.MerchantID != nil {
loader := merchantpayment.NewRuntimeLoader(s.store.DB(), s.redis)
merchant, loadErr := loader.LoadMerchant(ctx, *payment.MerchantID)
if loadErr != nil {
return nil, loadErr
}
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
// 授权字段只注入内存中的渠道配置,绝不写入支付快照、日志或审计。
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, authErr := loader.LoadAuthorization(ctx)
if authErr != nil {
return nil, authErr
}
return merchantpayment.MerchantConfig(merchant, authorization)
}
return merchantpayment.MerchantConfig(merchant, nil)
}
if err != nil && err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付单路由失败")
}
if err == nil && payment.PaymentConfigID == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
}
}
configID, err := s.resolvePaymentConfigID(ctx, orderNo)
if err != nil {
s.logger.Warn("回调:查询订单 payment_config_id 失败,回退激活配置",
zap.String("order_no", orderNo),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史支付配置失败")
}
if configID != nil {
cfg, err := s.store.GetByIDUnscoped(ctx, *configID)
if err == nil {
return cfg, nil
}
s.logger.Warn("回调:按 config_id 加载配置失败,回退激活配置",
zap.Uint("config_id", *configID),
zap.Error(err),
)
if configID == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
}
cfg, loadErr := s.store.GetByIDUnscoped(ctx, *configID)
if loadErr != nil {
return nil, errors.Wrap(errors.CodeNoPaymentConfig, loadErr, "历史支付配置不可用")
}
return cfg, nil
}
return s.GetActiveConfig(ctx)
// GetPaymentServiceForCallback 使用已选定的支付配置构造回调验签实例。
// 回调验签只能使用支付单冻结商户的当前凭证,不能复用进程启动时的旧支付实例。
func (s *Service) GetPaymentServiceForCallback(config *model.WechatConfig) (wechat.PaymentServiceInterface, error) {
if s == nil || s.redis == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "微信支付回调加载能力未配置")
}
if config == nil || config.ProviderType != model.ProviderTypeWechat {
return nil, errors.New(errors.CodeWechatConfigUnavailable, "微信支付回调配置不可用")
}
app, err := wechat.NewPaymentAppFromConfig(config, config.OaAppID, wechat.NewRedisCache(s.redis), s.logger)
if err != nil {
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "构建微信支付回调验签实例失败")
}
return wechat.NewPaymentService(app, s.logger), nil
}
// resolvePaymentConfigID 按订单号前缀从对应表中取 payment_config_id

View File

@@ -0,0 +1,32 @@
-- 回滚商户池支付路由 Schema。
-- 任一新路由支付、成功累计或新配置事实存在时均禁止破坏性回滚。
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM tb_payment WHERE merchant_id IS NOT NULL OR merchant_pool_id IS NOT NULL)
OR EXISTS (SELECT 1 FROM tb_payment_merchant_routing_success)
OR EXISTS (SELECT 1 FROM tb_payment_merchant)
OR EXISTS (SELECT 1 FROM tb_payment_merchant_pool)
OR EXISTS (SELECT 1 FROM tb_payment_merchant_pool_member)
OR EXISTS (SELECT 1 FROM tb_wechat_authorization) THEN
RAISE EXCEPTION '存在商户池支付路由业务事实,禁止回滚商户池支付路由迁移';
END IF;
END $$;
DROP INDEX IF EXISTS idx_payment_merchant_pool_route;
DROP INDEX IF EXISTS idx_payment_merchant_route;
ALTER TABLE tb_payment
DROP COLUMN IF EXISTS routing_epoch,
DROP COLUMN IF EXISTS routing_strategy_snapshot,
DROP COLUMN IF EXISTS merchant_pool_name_snapshot,
DROP COLUMN IF EXISTS merchant_provider_type_snapshot,
DROP COLUMN IF EXISTS merchant_payment_method_snapshot,
DROP COLUMN IF EXISTS merchant_name_snapshot,
DROP COLUMN IF EXISTS merchant_pool_id,
DROP COLUMN IF EXISTS merchant_id;
DROP TABLE IF EXISTS tb_payment_merchant_routing_success;
DROP TABLE IF EXISTS tb_wechat_authorization;
DROP TABLE IF EXISTS tb_payment_merchant_pool_member;
DROP TABLE IF EXISTS tb_payment_merchant_pool;
DROP TABLE IF EXISTS tb_payment_merchant;

View File

@@ -0,0 +1,237 @@
-- 商户池支付路由:收款商户、商户池、微信授权、成功统计事实及支付快照。
-- 不使用数据库外键;关联以 ID 保存并由应用层显式校验。
CREATE TABLE tb_payment_merchant (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
payment_method VARCHAR(20) NOT NULL,
provider_type VARCHAR(30) NOT NULL,
merchant_identity VARCHAR(100) NOT NULL,
credentials JSONB NOT NULL DEFAULT '{}'::jsonb,
credential_version BIGINT NOT NULL DEFAULT 1,
status SMALLINT NOT NULL DEFAULT 0,
remark TEXT NOT NULL DEFAULT '',
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,
CONSTRAINT chk_payment_merchant_method CHECK (payment_method IN ('wechat', 'alipay')),
CONSTRAINT chk_payment_merchant_status CHECK (status IN (0, 1)),
CONSTRAINT chk_payment_merchant_credential_version CHECK (credential_version >= 1)
);
CREATE UNIQUE INDEX uk_payment_merchant_method_identity_active
ON tb_payment_merchant (payment_method, merchant_identity)
WHERE deleted_at IS NULL;
CREATE INDEX idx_payment_merchant_method_status
ON tb_payment_merchant (payment_method, status, id)
WHERE deleted_at IS NULL;
CREATE TABLE tb_payment_merchant_pool (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
payment_method VARCHAR(20) NOT NULL,
status SMALLINT NOT NULL DEFAULT 0,
strategy VARCHAR(20) NOT NULL,
threshold_amount BIGINT,
threshold_count BIGINT,
statistic_cycle VARCHAR(20),
time_period_value BIGINT,
time_period_unit VARCHAR(10),
time_period_started_at TIMESTAMPTZ,
routing_epoch BIGINT NOT NULL DEFAULT 1,
remark TEXT NOT NULL DEFAULT '',
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,
CONSTRAINT chk_payment_merchant_pool_method CHECK (payment_method IN ('wechat', 'alipay')),
CONSTRAINT chk_payment_merchant_pool_status CHECK (status IN (0, 1)),
CONSTRAINT chk_payment_merchant_pool_strategy CHECK (strategy IN ('amount', 'count', 'time')),
CONSTRAINT chk_payment_merchant_pool_epoch CHECK (routing_epoch >= 1),
CONSTRAINT chk_payment_merchant_pool_statistic_cycle CHECK (
statistic_cycle IS NULL OR statistic_cycle IN ('round', 'day', 'month')
),
CONSTRAINT chk_payment_merchant_pool_time_unit CHECK (
time_period_unit IS NULL OR time_period_unit IN ('minute', 'hour', 'day')
),
CONSTRAINT chk_payment_merchant_pool_strategy_parameters CHECK (
(strategy = 'amount' AND threshold_amount IS NOT NULL AND threshold_amount > 0
AND threshold_count IS NULL AND statistic_cycle IS NOT NULL
AND time_period_value IS NULL AND time_period_unit IS NULL AND time_period_started_at IS NULL)
OR
(strategy = 'count' AND threshold_count IS NOT NULL AND threshold_count > 0
AND threshold_amount IS NULL AND statistic_cycle IS NOT NULL
AND time_period_value IS NULL AND time_period_unit IS NULL AND time_period_started_at IS NULL)
OR
(strategy = 'time' AND time_period_value IS NOT NULL AND time_period_value >= 1
AND time_period_unit IS NOT NULL AND time_period_started_at IS NOT NULL
AND threshold_amount IS NULL AND threshold_count IS NULL AND statistic_cycle IS NULL)
)
);
CREATE UNIQUE INDEX uk_payment_merchant_pool_name_active
ON tb_payment_merchant_pool (name)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uk_payment_merchant_pool_enabled_method
ON tb_payment_merchant_pool (payment_method)
WHERE status = 1 AND deleted_at IS NULL;
CREATE INDEX idx_payment_merchant_pool_method_status
ON tb_payment_merchant_pool (payment_method, status, id)
WHERE deleted_at IS NULL;
CREATE TABLE tb_payment_merchant_pool_member (
id BIGSERIAL PRIMARY KEY,
pool_id BIGINT NOT NULL,
merchant_id BIGINT NOT NULL,
sort_order BIGINT NOT NULL,
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,
CONSTRAINT chk_payment_merchant_pool_member_order CHECK (sort_order >= 0)
);
CREATE UNIQUE INDEX uk_payment_merchant_pool_member_active
ON tb_payment_merchant_pool_member (pool_id, merchant_id)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uk_payment_merchant_pool_member_order_active
ON tb_payment_merchant_pool_member (pool_id, sort_order)
WHERE deleted_at IS NULL;
CREATE INDEX idx_payment_merchant_pool_member_merchant
ON tb_payment_merchant_pool_member (merchant_id, pool_id)
WHERE deleted_at IS NULL;
CREATE TABLE tb_wechat_authorization (
id BIGSERIAL PRIMARY KEY,
oa_app_id VARCHAR(100) NOT NULL DEFAULT '',
oa_app_secret VARCHAR(200) NOT NULL DEFAULT '',
oa_token VARCHAR(200) NOT NULL DEFAULT '',
oa_aes_key VARCHAR(200) NOT NULL DEFAULT '',
oa_oauth_redirect_url VARCHAR(500) NOT NULL DEFAULT '',
miniapp_app_id VARCHAR(100) NOT NULL DEFAULT '',
miniapp_app_secret VARCHAR(200) NOT NULL DEFAULT '',
credential_version BIGINT NOT NULL DEFAULT 1,
status SMALLINT NOT NULL DEFAULT 0,
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,
CONSTRAINT chk_wechat_authorization_status CHECK (status IN (0, 1)),
CONSTRAINT chk_wechat_authorization_credential_version CHECK (credential_version >= 1)
);
CREATE UNIQUE INDEX uk_wechat_authorization_enabled
ON tb_wechat_authorization ((status))
WHERE status = 1 AND deleted_at IS NULL;
CREATE TABLE tb_payment_merchant_routing_success (
id BIGSERIAL PRIMARY KEY,
payment_id BIGINT NOT NULL,
merchant_id BIGINT NOT NULL,
pool_id BIGINT NOT NULL,
routing_epoch BIGINT NOT NULL,
amount BIGINT NOT NULL,
paid_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_payment_merchant_routing_success_payment UNIQUE (payment_id),
CONSTRAINT chk_payment_merchant_routing_success_epoch CHECK (routing_epoch >= 1),
CONSTRAINT chk_payment_merchant_routing_success_amount CHECK (amount > 0)
);
CREATE INDEX idx_payment_merchant_routing_success_select
ON tb_payment_merchant_routing_success (pool_id, routing_epoch, merchant_id, paid_at, id);
ALTER TABLE tb_payment
ADD COLUMN merchant_id BIGINT,
ADD COLUMN merchant_pool_id BIGINT,
ADD COLUMN merchant_name_snapshot VARCHAR(100),
ADD COLUMN merchant_payment_method_snapshot VARCHAR(20),
ADD COLUMN merchant_provider_type_snapshot VARCHAR(30),
ADD COLUMN merchant_pool_name_snapshot VARCHAR(100),
ADD COLUMN routing_strategy_snapshot VARCHAR(20),
ADD COLUMN routing_epoch BIGINT;
CREATE INDEX idx_payment_merchant_route
ON tb_payment (merchant_id, routing_epoch, status, id)
WHERE deleted_at IS NULL AND merchant_id IS NOT NULL;
CREATE INDEX idx_payment_merchant_pool_route
ON tb_payment (merchant_pool_id, routing_epoch, id)
WHERE deleted_at IS NULL AND merchant_pool_id IS NOT NULL;
-- 从唯一生效综合配置复制完整凭证。单成员池使用一分钟时间轮询,
-- 对单成员没有选择差异;后续成员或策略调整由管理配置显式产生新世代。
DO $$
DECLARE
active_count BIGINT;
legacy tb_wechat_config%ROWTYPE;
copied_merchant_id BIGINT;
copied_pool_id BIGINT;
BEGIN
SELECT COUNT(*) INTO active_count
FROM tb_wechat_config
WHERE is_active = TRUE AND deleted_at IS NULL;
IF active_count > 1 THEN
RAISE EXCEPTION '存在多条生效综合支付配置,无法安全迁移商户池';
END IF;
IF active_count = 0 THEN
RETURN;
END IF;
SELECT * INTO legacy
FROM tb_wechat_config
WHERE is_active = TRUE AND deleted_at IS NULL;
IF legacy.provider_type IN ('wechat', 'wechat_v2')
AND legacy.wx_mch_id <> ''
AND ((legacy.provider_type = 'wechat' AND legacy.wx_api_v3_key <> '' AND legacy.wx_cert_content <> '' AND legacy.wx_key_content <> '' AND legacy.wx_serial_no <> '' AND legacy.wx_notify_url <> '')
OR (legacy.provider_type = 'wechat_v2' AND legacy.wx_api_v2_key <> '' AND legacy.wx_notify_url <> '')) THEN
INSERT INTO tb_payment_merchant (name, payment_method, provider_type, merchant_identity, credentials, credential_version, status)
VALUES (legacy.name || '-微信商户', 'wechat', legacy.provider_type, legacy.wx_mch_id,
jsonb_build_object('wx_mch_id', legacy.wx_mch_id, 'wx_api_v3_key', legacy.wx_api_v3_key, 'wx_api_v2_key', legacy.wx_api_v2_key, 'wx_cert_content', legacy.wx_cert_content, 'wx_key_content', legacy.wx_key_content, 'wx_serial_no', legacy.wx_serial_no, 'wx_notify_url', legacy.wx_notify_url), 1, 1)
RETURNING id INTO copied_merchant_id;
ELSIF legacy.provider_type = 'fuiou'
AND legacy.fy_ins_cd <> '' AND legacy.fy_mchnt_cd <> '' AND legacy.fy_term_id <> ''
AND legacy.fy_private_key <> '' AND legacy.fy_public_key <> '' AND legacy.fy_api_url <> '' AND legacy.fy_notify_url <> '' THEN
INSERT INTO tb_payment_merchant (name, payment_method, provider_type, merchant_identity, credentials, credential_version, status)
VALUES (legacy.name || '-富友商户', 'wechat', 'fuiou', legacy.fy_mchnt_cd,
jsonb_build_object('fy_ins_cd', legacy.fy_ins_cd, 'fy_mchnt_cd', legacy.fy_mchnt_cd, 'fy_term_id', legacy.fy_term_id, 'fy_private_key', legacy.fy_private_key, 'fy_public_key', legacy.fy_public_key, 'fy_api_url', legacy.fy_api_url, 'fy_notify_url', legacy.fy_notify_url), 1, 1)
RETURNING id INTO copied_merchant_id;
END IF;
IF copied_merchant_id IS NOT NULL THEN
INSERT INTO tb_payment_merchant_pool (name, payment_method, status, strategy, time_period_value, time_period_unit, time_period_started_at, routing_epoch)
VALUES ('迁移微信商户池', 'wechat', 1, 'time', 1, 'minute', NOW(), 1)
RETURNING id INTO copied_pool_id;
INSERT INTO tb_payment_merchant_pool_member (pool_id, merchant_id, sort_order) VALUES (copied_pool_id, copied_merchant_id, 0);
END IF;
copied_merchant_id := NULL;
IF legacy.ali_app_id <> '' AND legacy.ali_private_key <> '' AND legacy.ali_public_key <> '' AND legacy.ali_notify_url <> '' AND legacy.ali_return_url <> '' THEN
INSERT INTO tb_payment_merchant (name, payment_method, provider_type, merchant_identity, credentials, credential_version, status)
VALUES (legacy.name || '-支付宝商户', 'alipay', 'alipay', legacy.ali_app_id,
jsonb_build_object('ali_app_id', legacy.ali_app_id, 'ali_private_key', legacy.ali_private_key, 'ali_public_key', legacy.ali_public_key, 'ali_notify_url', legacy.ali_notify_url, 'ali_return_url', legacy.ali_return_url, 'ali_production', legacy.ali_production, 'ali_pay_expire_minutes', legacy.ali_pay_expire_minutes), 1, 1)
RETURNING id INTO copied_merchant_id;
INSERT INTO tb_payment_merchant_pool (name, payment_method, status, strategy, time_period_value, time_period_unit, time_period_started_at, routing_epoch)
VALUES ('迁移支付宝商户池', 'alipay', 1, 'time', 1, 'minute', NOW(), 1)
RETURNING id INTO copied_pool_id;
INSERT INTO tb_payment_merchant_pool_member (pool_id, merchant_id, sort_order) VALUES (copied_pool_id, copied_merchant_id, 0);
END IF;
IF legacy.oa_app_id <> '' AND legacy.oa_app_secret <> ''
AND legacy.miniapp_app_id <> '' AND legacy.miniapp_app_secret <> '' THEN
INSERT INTO tb_wechat_authorization (oa_app_id, oa_app_secret, oa_token, oa_aes_key, oa_oauth_redirect_url, miniapp_app_id, miniapp_app_secret, credential_version, status)
VALUES (legacy.oa_app_id, legacy.oa_app_secret, legacy.oa_token, legacy.oa_aes_key, legacy.oa_oauth_redirect_url, legacy.miniapp_app_id, legacy.miniapp_app_secret, 1, 1);
END IF;
END $$;
COMMENT ON TABLE tb_payment_merchant IS '实际收款商户,凭证仅在受控字段保存';
COMMENT ON TABLE tb_payment_merchant_pool IS '按支付方式轮询实际收款商户的商户池';
COMMENT ON TABLE tb_payment_merchant_pool_member IS '商户池有序成员;支付方式一致性由应用事务校验';
COMMENT ON TABLE tb_wechat_authorization IS 'C端微信授权配置不保存收款商户凭证';
COMMENT ON TABLE tb_payment_merchant_routing_success IS '支付首次成功的路由累计唯一事实';
COMMENT ON COLUMN tb_payment.routing_epoch IS '支付创建时冻结的商户池统计世代';

View File

@@ -0,0 +1,80 @@
-- 回滚商户池支付路由字段级备注:仅将 000211 补充的字段 COMMENT 置空。
-- 表级 COMMENT 由 000210 提供,本迁移不做处理。
COMMENT ON COLUMN tb_payment_merchant.id IS NULL;
COMMENT ON COLUMN tb_payment_merchant.name IS NULL;
COMMENT ON COLUMN tb_payment_merchant.payment_method IS NULL;
COMMENT ON COLUMN tb_payment_merchant.provider_type IS NULL;
COMMENT ON COLUMN tb_payment_merchant.merchant_identity IS NULL;
COMMENT ON COLUMN tb_payment_merchant.credentials IS NULL;
COMMENT ON COLUMN tb_payment_merchant.credential_version IS NULL;
COMMENT ON COLUMN tb_payment_merchant.status IS NULL;
COMMENT ON COLUMN tb_payment_merchant.remark IS NULL;
COMMENT ON COLUMN tb_payment_merchant.creator IS NULL;
COMMENT ON COLUMN tb_payment_merchant.updater IS NULL;
COMMENT ON COLUMN tb_payment_merchant.created_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant.updated_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant.deleted_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.id IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.name IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.payment_method IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.status IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.strategy IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.threshold_amount IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.threshold_count IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.statistic_cycle IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.time_period_value IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.time_period_unit IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.time_period_started_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.routing_epoch IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.remark IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.creator IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.updater IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.created_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.updated_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool.deleted_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool_member.id IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool_member.pool_id IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool_member.merchant_id IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool_member.sort_order IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool_member.creator IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool_member.updater IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool_member.created_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool_member.updated_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant_pool_member.deleted_at IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.id IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.oa_app_id IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.oa_app_secret IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.oa_token IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.oa_aes_key IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.oa_oauth_redirect_url IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.miniapp_app_id IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.miniapp_app_secret IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.credential_version IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.status IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.creator IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.updater IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.created_at IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.updated_at IS NULL;
COMMENT ON COLUMN tb_wechat_authorization.deleted_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant_routing_success.id IS NULL;
COMMENT ON COLUMN tb_payment_merchant_routing_success.payment_id IS NULL;
COMMENT ON COLUMN tb_payment_merchant_routing_success.merchant_id IS NULL;
COMMENT ON COLUMN tb_payment_merchant_routing_success.pool_id IS NULL;
COMMENT ON COLUMN tb_payment_merchant_routing_success.routing_epoch IS NULL;
COMMENT ON COLUMN tb_payment_merchant_routing_success.amount IS NULL;
COMMENT ON COLUMN tb_payment_merchant_routing_success.paid_at IS NULL;
COMMENT ON COLUMN tb_payment_merchant_routing_success.created_at IS NULL;
COMMENT ON COLUMN tb_payment.merchant_id IS NULL;
COMMENT ON COLUMN tb_payment.merchant_pool_id IS NULL;
COMMENT ON COLUMN tb_payment.merchant_name_snapshot IS NULL;
COMMENT ON COLUMN tb_payment.merchant_payment_method_snapshot IS NULL;
COMMENT ON COLUMN tb_payment.merchant_provider_type_snapshot IS NULL;
COMMENT ON COLUMN tb_payment.merchant_pool_name_snapshot IS NULL;
COMMENT ON COLUMN tb_payment.routing_strategy_snapshot IS NULL;
COMMENT ON COLUMN tb_payment.routing_epoch IS NULL;

View File

@@ -0,0 +1,86 @@
-- 补齐商户池支付路由各表字段级中文备注。
-- 仅补充 COMMENT不改变任何表结构或数据。
-- 实际收款商户
COMMENT ON COLUMN tb_payment_merchant.id IS '主键收款商户ID';
COMMENT ON COLUMN tb_payment_merchant.name IS '商户显示名称';
COMMENT ON COLUMN tb_payment_merchant.payment_method IS '支付方式wechat 微信、alipay 支付宝;与 merchant_identity 在未删除记录中唯一';
COMMENT ON COLUMN tb_payment_merchant.provider_type IS '渠道类型wechat 微信V3、wechat_v2 微信V2、fuiou 富友、alipay 支付宝';
COMMENT ON COLUMN tb_payment_merchant.merchant_identity IS '渠道侧商户标识如微信商户号、富友商户号、支付宝应用ID';
COMMENT ON COLUMN tb_payment_merchant.credentials IS '渠道凭证 JSON按 provider_type 保存密钥/证书等敏感字段,仅整体轮换、禁止对外输出';
COMMENT ON COLUMN tb_payment_merchant.credential_version IS '凭证版本号,密钥轮换时递增,最小为 1';
COMMENT ON COLUMN tb_payment_merchant.status IS '状态0 禁用、1 启用';
COMMENT ON COLUMN tb_payment_merchant.remark IS '备注';
COMMENT ON COLUMN tb_payment_merchant.creator IS '创建人用户ID0 表示系统';
COMMENT ON COLUMN tb_payment_merchant.updater IS '最近更新人用户ID0 表示系统';
COMMENT ON COLUMN tb_payment_merchant.created_at IS '创建时间';
COMMENT ON COLUMN tb_payment_merchant.updated_at IS '最近更新时间';
COMMENT ON COLUMN tb_payment_merchant.deleted_at IS '软删除时间,非空表示已删除';
-- 商户池
COMMENT ON COLUMN tb_payment_merchant_pool.id IS '主键商户池ID';
COMMENT ON COLUMN tb_payment_merchant_pool.name IS '商户池名称,未删除记录中唯一';
COMMENT ON COLUMN tb_payment_merchant_pool.payment_method IS '支付方式wechat 微信、alipay 支付宝;同一支付方式至多一个启用池';
COMMENT ON COLUMN tb_payment_merchant_pool.status IS '状态0 禁用、1 启用';
COMMENT ON COLUMN tb_payment_merchant_pool.strategy IS '轮询策略amount 金额阈值、count 笔数阈值、time 时间轮询';
COMMENT ON COLUMN tb_payment_merchant_pool.threshold_amount IS '金额策略阈值(单位:分),统计周期内单商户收款金额达到即切换下一成员';
COMMENT ON COLUMN tb_payment_merchant_pool.threshold_count IS '笔数策略阈值,统计周期内单商户收款笔数达到即切换下一成员';
COMMENT ON COLUMN tb_payment_merchant_pool.statistic_cycle IS '统计周期round 累计、day 自然日、month 自然月amount/count 策略必填time 策略为空';
COMMENT ON COLUMN tb_payment_merchant_pool.time_period_value IS '时间策略周期长度,与 time_period_unit 组成轮询周期';
COMMENT ON COLUMN tb_payment_merchant_pool.time_period_unit IS '时间策略周期单位minute 分钟、hour 小时、day 天';
COMMENT ON COLUMN tb_payment_merchant_pool.time_period_started_at IS '时间策略周期起点,周期到期切换到下一成员';
COMMENT ON COLUMN tb_payment_merchant_pool.routing_epoch IS '路由统计世代,池成员或策略变更及阈值达成时递增;统计按世代隔离,最小为 1';
COMMENT ON COLUMN tb_payment_merchant_pool.remark IS '备注';
COMMENT ON COLUMN tb_payment_merchant_pool.creator IS '创建人用户ID0 表示系统';
COMMENT ON COLUMN tb_payment_merchant_pool.updater IS '最近更新人用户ID0 表示系统';
COMMENT ON COLUMN tb_payment_merchant_pool.created_at IS '创建时间';
COMMENT ON COLUMN tb_payment_merchant_pool.updated_at IS '最近更新时间';
COMMENT ON COLUMN tb_payment_merchant_pool.deleted_at IS '软删除时间,非空表示已删除';
-- 商户池有序成员
COMMENT ON COLUMN tb_payment_merchant_pool_member.id IS '主键';
COMMENT ON COLUMN tb_payment_merchant_pool_member.pool_id IS '所属商户池ID';
COMMENT ON COLUMN tb_payment_merchant_pool_member.merchant_id IS '成员收款商户ID未删除时池内唯一';
COMMENT ON COLUMN tb_payment_merchant_pool_member.sort_order IS '池内轮询顺序,从 0 递增,未删除时池内唯一';
COMMENT ON COLUMN tb_payment_merchant_pool_member.creator IS '创建人用户ID0 表示系统';
COMMENT ON COLUMN tb_payment_merchant_pool_member.updater IS '最近更新人用户ID0 表示系统';
COMMENT ON COLUMN tb_payment_merchant_pool_member.created_at IS '创建时间';
COMMENT ON COLUMN tb_payment_merchant_pool_member.updated_at IS '最近更新时间';
COMMENT ON COLUMN tb_payment_merchant_pool_member.deleted_at IS '软删除时间,非空表示已移出池';
-- C端微信授权配置
COMMENT ON COLUMN tb_wechat_authorization.id IS '主键';
COMMENT ON COLUMN tb_wechat_authorization.oa_app_id IS '微信公众号 AppID';
COMMENT ON COLUMN tb_wechat_authorization.oa_app_secret IS '微信公众号 AppSecret敏感凭证禁止对外输出';
COMMENT ON COLUMN tb_wechat_authorization.oa_token IS '公众号消息校验 Token';
COMMENT ON COLUMN tb_wechat_authorization.oa_aes_key IS '公众号消息加解密 AES Key敏感凭证';
COMMENT ON COLUMN tb_wechat_authorization.oa_oauth_redirect_url IS '公众号网页授权回调地址';
COMMENT ON COLUMN tb_wechat_authorization.miniapp_app_id IS '微信小程序 AppID';
COMMENT ON COLUMN tb_wechat_authorization.miniapp_app_secret IS '微信小程序 AppSecret敏感凭证禁止对外输出';
COMMENT ON COLUMN tb_wechat_authorization.credential_version IS '凭证版本号,密钥轮换时递增,最小为 1';
COMMENT ON COLUMN tb_wechat_authorization.status IS '状态0 禁用、1 启用;全局至多一条启用记录';
COMMENT ON COLUMN tb_wechat_authorization.creator IS '创建人用户ID0 表示系统';
COMMENT ON COLUMN tb_wechat_authorization.updater IS '最近更新人用户ID0 表示系统';
COMMENT ON COLUMN tb_wechat_authorization.created_at IS '创建时间';
COMMENT ON COLUMN tb_wechat_authorization.updated_at IS '最近更新时间';
COMMENT ON COLUMN tb_wechat_authorization.deleted_at IS '软删除时间,非空表示已删除';
-- 路由成功累计事实
COMMENT ON COLUMN tb_payment_merchant_routing_success.id IS '主键';
COMMENT ON COLUMN tb_payment_merchant_routing_success.payment_id IS '支付ID唯一仅在支付首次成功时写入之后不可变';
COMMENT ON COLUMN tb_payment_merchant_routing_success.merchant_id IS '收款商户ID支付创建时冻结';
COMMENT ON COLUMN tb_payment_merchant_routing_success.pool_id IS '商户池ID支付创建时冻结';
COMMENT ON COLUMN tb_payment_merchant_routing_success.routing_epoch IS '支付创建时冻结的商户池统计世代,统计按世代隔离';
COMMENT ON COLUMN tb_payment_merchant_routing_success.amount IS '支付金额(单位:分)';
COMMENT ON COLUMN tb_payment_merchant_routing_success.paid_at IS '支付首次成功时间';
COMMENT ON COLUMN tb_payment_merchant_routing_success.created_at IS '记录创建时间';
-- 支付单路由字段与快照
COMMENT ON COLUMN tb_payment.merchant_id IS '路由选中的收款商户ID支付创建时冻结空表示未走商户池路由';
COMMENT ON COLUMN tb_payment.merchant_pool_id IS '路由选中的商户池ID支付创建时冻结空表示未走商户池路由';
COMMENT ON COLUMN tb_payment.merchant_name_snapshot IS '收款商户名称快照,商户后续改名不影响历史单据展示';
COMMENT ON COLUMN tb_payment.merchant_payment_method_snapshot IS '收款商户支付方式快照wechat 微信、alipay 支付宝';
COMMENT ON COLUMN tb_payment.merchant_provider_type_snapshot IS '收款商户渠道类型快照wechat 微信V3、wechat_v2 微信V2、fuiou 富友、alipay 支付宝';
COMMENT ON COLUMN tb_payment.merchant_pool_name_snapshot IS '商户池名称快照,池后续改名不影响历史单据展示';
COMMENT ON COLUMN tb_payment.routing_strategy_snapshot IS '路由策略快照amount 金额阈值、count 笔数阈值、time 时间轮询';
COMMENT ON COLUMN tb_payment.routing_epoch IS '支付创建时冻结的商户池统计世代,决定该支付成功后的统计归属';

View File

@@ -1,20 +0,0 @@
## Context
套餐使用记录已有 `master_usage_id`,不能为展示重复存储关系。
## Decisions
- 查询批量读取资产全部套餐使用,按 `master_usage_id` 内存分组并稳定排序。
- 不改变套餐状态、金额或生命周期;缺失主记录只形成读模型异常项。
## 查询与响应契约
- 后台资产详情及 H5 `GET /api/c/v1/asset/package-history` 保持既有资产权限、分页和套餐字段;不新增写接口、迁移、关联表或状态变更。查询需在资产范围内一次读取该资产全部相关 `PackageUsage`,不能按每个主套餐逐条查询加油包。
- 响应主项包含原套餐使用字段、`children` 加油包数组和 `expand_by_default`;主项 `master_usage_id` 必须为 `null`。子项保留自身 `package_usage_id``master_usage_id`、状态、购买创建时间、生效时间和原历史字段。
-`master_usage_id IS NULL` 的记录为主套餐;关联存在的加油包嵌入对应主项。已生效加油包先按生效时间正序,待生效加油包后按购买创建时间正序;排序字段相同再按 `package_usage_id` 正序,保证后台和 H5 一致。
- 主套餐只要有一个关联子项即 `expand_by_default=true`;无子项为 `false`。主套餐、子项失效、过期、用尽、退款或历史状态均不得删除或改写层级。
- 加油包的 `master_usage_id` 指向物理不存在记录时,返回顶层异常项:保留原字段、`relationship_status=master_missing``relationship_status_name=关联主套餐缺失``children=[]``expand_by_default=false`;不得猜测替代主套餐或丢弃该项。
## Verification
验证多主套餐、待生效/失效加油包、缺失主记录、后台/H5 数据范围和分页。

View File

@@ -1,24 +0,0 @@
## Scope
- 迭代编号:`AUG26-013`
## Why
资产套餐历史平铺展示,无法识别主套餐与其加油包的真实关联。
## What Changes
- 在后台和 H5 按既有套餐使用关联投影主套餐—加油包层级。
- 保留失效历史;主套餐物理缺失作为可观察异常。
## Capabilities
### New Capabilities
- 无。
### Modified Capabilities
- `package-lifecycle`: 套餐历史层级展示。
## Impact
影响套餐使用查询、后台资产详情、H5 和 OpenAPI。

View File

@@ -1,16 +0,0 @@
## ADDED Requirements
### Requirement: 资产套餐层级投影
系统 SHALL 在后台资产详情和 H5 资产套餐历史中,直接以既有 `PackageUsage.master_usage_id` 将主套餐和关联加油包投影为层级结构,不新增关联表。响应中每个主套餐必须返回 `expand_by_default`:存在至少一个关联加油包时为 `true`,否则为 `false`;后台与 H5 使用同一规则。加油包按生效时间正序;待生效加油包按购买创建时间正序并排在已生效包之后。主套餐或加油包失效、过期、用尽时仍保留层级;仅关联主套餐物理缺失时作为“关联主套餐缺失”异常独立项返回。
#### Scenario: 已失效加油包
- **WHEN** 某加油包及其主套餐已失效但主套餐记录仍存在
- **THEN** 系统仍将加油包嵌入该主套餐层级,不因状态失效拆散关系
#### Scenario: 主套餐含加油包
- **WHEN** 主套餐关联至少一条加油包使用记录
- **THEN** 后台和 H5 返回该主套餐时均将 `expand_by_default` 设为 `true`
#### Scenario: 主套餐物理缺失
- **WHEN** 加油包关联的主套餐使用记录不存在
- **THEN** 系统返回带“关联主套餐缺失”标识的异常独立项

View File

@@ -1,7 +0,0 @@
## 1. 查询契约
- [ ] 1.1 追踪后台资产详情、H5 套餐历史、`PackageUsage``master_usage_id` 查询链路。
- [ ] 1.2 实现批量层级投影、稳定排序和缺失主套餐异常项;更新 DTO、路由说明和 OpenAPI。
## 2. 验证
- [ ] 2.1 验证多层级、失效保留、待生效排序、缺失主记录及后台/H5 数据范围。
- [ ] 2.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-asset-package-hierarchy --strict``openspec doctor --json`;自动化测试按项目决策为 N/A。

View File

@@ -4,6 +4,8 @@
本设计只处理上线后事件。线下付款并非本系统支付渠道事实,企业微信审批人员以第三方记录核验,因此本地只留存申请人声明、附件、冻结快照和企业微信最终结果。
术语边界:员工代收款账单是后台账号代客户经办业务形成的本地暂挂欠款,不等同于来源订单、客户付款或企业微信审批单。核销申请对应一笔外部付款及一次企业微信审批实例;核销分摊是该申请对单张账单确认的本次金额,账单核销状态与申请审批状态独立保存。企业微信审批实例仅是本地业务单的外部审批渠道生命周期事实;外部交易流水号可由 OCR 预填,但必须经人工确认。线下收款方式是固定注册分类下的业务字典项,不等同于线上支付方式枚举或收款账户目录;已被引用的字典项只可停用并保留历史名称快照。
## Goals / Non-Goals
**Goals:**
@@ -16,6 +18,8 @@
- 不回填历史业务,不允许“其他”来源手工建账,不处理平台代理 C 端资产钱包充值。
- 不以账单功能重构订单、代理充值或企业微信通用审批模块。
## Decisions
### 1. 账单、申请、分摊分表保存

View File

@@ -5,16 +5,22 @@
## Decisions
### 独立实体与不可变路由快照
新增商户、商户池、池成员、微信授权配置及金额/笔数统计事实;支付单扩展实际商户和商户池 ID非敏感快照。商户凭证只留在商户表受控字段,支付单/审计不复制。被引用后锁定商户支付方式、服务商与身份,避免历史验签与退款语义漂移。
新增商户、商户池、池成员、微信授权配置及金额/笔数统计事实;支付单扩展实际商户和商户池 ID非敏感快照及创建时的 `routing_epoch`。商户凭证只留在商户表受控字段,支付单/审计不复制。被引用后锁定商户支付方式、服务商与身份,避免历史验签与退款语义漂移。
### 路由和统计在支付创建/成功边界闭合
支付创建在事务内读取唯一启用池、按方式选择成员并冻结路由;无成员/池失败即返回。金额/笔数统计只在现有“支付成功首次生效”路径按实际商户条件递增,以支付 ID 唯一约束避免重复回调累计;不在预下单预占。时间方式由当前时间和受控起点计算,不写逐次路由日志。
支付创建在事务内读取唯一启用池、按方式选择成员并冻结路由`routing_epoch`;无成员/池失败即返回。`routing_epoch` 是该次创建命中的池策略、统计周期与成员排序世代。金额/笔数统计只在现有“支付成功首次生效”路径以支付 ID 唯一事实按冻结商户和 epoch 递增,不在预下单预占。迟到的首次成功仍记入冻结 epoch当前选择器只读取当前 epoch因此周期切换、成员调整或排序变化不改写已创建支付的归属。时间方式由当前时间和受控起点计算,不写逐次路由日志。
### 凭证版本与读取一致性
商户和微信授权配置各保存递增的凭证版本。凭证更新在事务提交时递增版本;支付创建、回调验签、查单和退款先从主库取得当前版本,再使用“配置 ID + 版本”读取缓存。旧版本缓存提交后可删除,但即使并发残留也不得再被新读取命中;缓存不可用时读取当前数据库记录。历史支付仍读取命中商户的当前有效凭证,以支持轮换。
### 新旧配置双读切换
新支付单具有 merchant ID 支付加载、回调验签、查询和退款均从商户加载服务商凭证merchant ID 为空的历史单保持现有 `payment_config_id` 路径。迁移先复制生效配置中完整凭证,再发布新支付路径;不可将现有记录批量回填为新商户,因为其实际历史身份无法保证一致。
迁移完成后部署支持 merchant ID 或旧 `payment_config_id` 双读的 API/Worker。三类后续新支付立即只走商户池并在创建事务冻结路由无可用池、成员或池停用时明确失败绝不回退旧综合支付配置创建。merchant ID 为空仅表示历史支付,回调验签、查询和已有可达的原路退款继续按 `payment_config_id` 处理,直到独立 Change 根据数据留存期删除旧读取路径。故障只允许在仍支持双读的版本上前向修复;一旦存在 merchant ID 非空支付、成功事实或新配置,禁止部署不识别新路由的旧二进制和执行破坏性 down。不可将现有记录批量回填为新商户,因为其实际历史身份无法保证一致。
### 敏感配置边界
后台管理响应按 PRD 向两类已认证管理角色完整返回凭证;所有 logger、错误、审计 payload、支付单快照和导出只允许写 ID、名称和脱敏/非敏感身份。更新凭证后使现有配置加载缓存失效;历史回调读取最新有效凭证以支持轮换。
仅超级管理员和平台用户的专用管理列表、详情响应可完整返回凭证,管理写入请求可传递凭证;所有其他 DTO、logger、错误、审计 payload、支付单快照和导出只允许写 ID、名称和脱敏/非敏感身份。
### 已确认范围
本 Change 只为已有且可达的原路退款路径提供冻结商户的凭证加载与能力判定,不新增微信、支付宝、富友或其他渠道退款调用;服务商没有既有退款能力时保持不支持,且不提供人工开关。富友 `CommonQuery` 保持现有请求格式、签名算法、验签、状态映射和恢复语义的兼容基线,只实现本地双读配置来源和商户加载,不改变协议或业务能力,富友退款不新增;未第三方实测可记录但不得作为实施、验证或归档阻塞。零条 active 综合配置时仅创建 Schema 和管理入口,不插入商户、商户池或微信授权配置数据。代理在线充值的全局允许范围、交集计算和对外方式查询由对应代理自充 Change 负责;本 Change 在方式已获准后负责商户池可用性与实际商户冻结。
## 管理与支付动作契约
@@ -46,13 +52,16 @@
## Risks / Trade-offs
- 并发成功回调超过阈值:这是“只统计成功、不预占”的明确结果,下一次选路才跳过。
- 商户停用后的退款:停用不阻断历史退款,实际调用仍由凭证/渠道结果决定
- 迁移缺失凭证:不造空商户池,受影响新支付明确失败。
- 商户停用后的退款:停用不阻断已有历史退款路径,实际调用仍由当前凭证、服务商已有能力和渠道结果决定;本 Change 不新增退款能力
- 零条 active 综合配置:仅创建 Schema 和管理入口,不创建业务配置数据;所有新线上支付明确失败,微信授权相关功能明确返回未配置。一条 active 综合配置中支付凭证不完整的方式不建池,微信授权字段不完整时不建授权配置且相关 C 端微信功能明确失败;多条 active 综合配置无法安全选择来源,迁移必须失败。
- 旧回调误入新路径:以支付单 merchant ID 为唯一分流条件,严禁按当前启用池推断。
- 富友查单:`CommonQuery` 保持现有请求格式、签名算法、验签、状态映射和恢复语义,只调整本地双读配置来源和商户加载;不改变协议、状态解释、恢复规则或业务能力,不增加富友退款。未第三方实测仅作为记录,不阻塞本 Change。
## Migration Plan
1. 新增成对迁移创建商户/池/成员/微信授权表、唯一启用约束、支付单路由列和成功统计索引
2. 迁移事务中从唯一生效综合配置复制完整凭证并创建单成员池;全过程禁止日志输出密钥/证书。
3. 隔离库验证微信直连、富友、支付宝、空配置、停用历史商户、回调兼容、三种轮询及 up/down/up
4. 回滚前停止新支付创建;有新路由单时仅回退应用流量,不执行会破坏新支付事实的 down。
1. 新增成对迁移创建商户/池/成员/微信授权表、唯一启用约束、支付单路由`routing_epoch` 列、凭证版本及成功统计唯一索引;迁移编号在实施开始前按当时迁移目录的最大编号确定,本规划不预占编号
2. 迁移遇到一条 active 综合配置时只复制完整支付凭证形成对应单成员池;微信授权字段完整时创建授权配置。零条 active 配置时仅创建 Schema 和管理入口,不插入商户、商户池或微信授权配置数据;多条 active 配置时中止迁移。全过程禁止日志输出密钥/证书。
3. 在本地工作区以明确 `DB_*` 指向维护者提供的 `junhong_cmp_test` PostgreSQL 与 Redis DB6执行 migration up/down/up 和数据行为验证;允许本 Change fixture 创建/删除,禁止重置整个库。完成验证后才提交推送 Iteration/8-11仅连接、迁移或实际行为失败时阻塞对应验证
4. Iteration/8-11 分支 Gitea 仅以本次提交 SHA 构建/部署 `cmp-test` 测试镜像并检查 migration version不自动执行 migration up/down 或重置。记录 API/Worker 容器状态、健康检查和 `/opt/junhong_cmp/logs` 的有限日志;不建运行时开关,这不是生产发布。
5. 迁移完成后的部署版本支持 merchant ID 或旧 `payment_config_id` 双读;三类后续新支付立即只走商户池并冻结路由,无池、成员或停用池时明确失败且无旧创建回退。验证新支付始终走池、历史 merchant ID 为空支付继续旧读取路径。故障只允许在仍支持双读的版本上前向修复;存在 merchant ID 非空支付、成功事实或新配置时,禁止部署不识别新路由的旧二进制和执行破坏性 down。
6. 富友 `CommonQuery` 仅保持现有请求格式、签名算法、验签、状态映射和恢复语义并完成本地双读商户加载,不改变协议或增加退款;未第三方实测仅记录,不阻塞验证或归档。验证覆盖微信直连、富友、支付宝、零/一/多 active 配置、授权或支付凭证缺失、停用历史商户、回调兼容、三种轮询、迟到成功归属、凭证轮换及 up/down/up。

View File

@@ -1,6 +1,6 @@
## Why
当前所有线上支付依赖一份综合支付配置,无法按支付方式在多个实际收款商户之间受控轮询;创建支付后的商户事实也不足以让停用商户的历史回调、查单和原路退款继续安全执行
当前所有线上支付依赖一份综合支付配置,无法按支付方式在多个实际收款商户之间受控轮询;已存在且可达的回调、查单和原路退款路径也缺少冻结商户事实,无法在商户停用后继续安全加载对应凭证
本 Change 落实 AUG26-002将收款商户、支付路由和 C 端微信授权分离,并以商户池快照取代新业务对旧综合支付配置的依赖。
@@ -9,8 +9,9 @@
- 新增独立商户管理:微信商户与支付宝商户分别建档;商户保存支付能力和敏感凭证,但历史支付单仅保存非敏感身份快照。
- 新增每种支付方式至多一个启用商户池,支持按成功收款金额、成功笔数或时间周期轮询;新支付仅从命中池选择实际商户。
- 新增全局唯一启用的微信授权配置,专供 C 端公众号 H5/JSSDK、小程序登录、OpenID 和支付 AppID它不是微信收款商户。
- 新建 C 端套餐购买、资产钱包充值、代理在线预存款充值按商户池路由;后台线下/钱包支付不经过商户池。无可用商户时失败,不得回退旧配置或自动换商户重试。
- 从当前生效综合支付配置一次性复制完整凭证形成新商户、单成员商户池及微信授权配置;新支付切换后,旧配置和历史订单继续处理其自身回调、查询和退款
- 新建 C 端套餐购买、资产钱包充值、代理在线预存款充值按商户池路由;后台线下/钱包支付不经过商户池。代理全局允许范围、其与商户池方式的交集及对外方式查询仍由对应代理自充 Change 负责;本 Change 只在方式已获准后选择实际商户。无可用商户时失败,不得回退旧配置或自动换商户重试。
- 迁移完成后部署支持按 merchant ID 或旧 `payment_config_id` 双读的 API/Worker三类后续新支付立即只走商户池并冻结路由无可用池、成员或停用池时明确失败且不回退旧综合支付配置。merchant ID 为空的仅为历史订单继续处理其自身回调、查单和已有可达的原路退款,直到独立 Change 按数据留存期删除旧读取路径
- 零条当前生效综合支付配置时仅创建新 Schema 和管理入口,不插入空商户、空商户池或空微信授权配置;新线上支付明确失败,微信授权功能明确返回未配置。富友 `CommonQuery` 保持现有请求、签名、验签、状态和恢复兼容基线,只实现本地双读凭证与商户加载,不改协议或退款;未第三方实测可记录但不构成本 Change 实施或归档阻塞。验证在本地工作区以明确 `DB_*` 指向维护者指定的 `junhong_cmp_test` PostgreSQL、Redis DB6 执行;完成后才提交推送 Iteration/8-11由 Gitea 构建/部署 `cmp-test` 测试镜像并检查迁移版本,日志位于 `/opt/junhong_cmp/logs`,不重置整库。
## Capabilities
@@ -20,7 +21,7 @@
### Modified Capabilities
- 无。该能力向既有支付创建、回调退款调用链提供已选商户事实,不改变资金幂等不变量。
- 无。该能力向既有支付创建、回调、查单和已有原路退款调用链提供已选商户事实,不改变既有资金幂等不变量,也不新增渠道退款能力
## Impact

View File

@@ -1,13 +1,13 @@
## Purpose
管理实际收款商户、商户池轮询和全局微信授权配置,使新线上支付的收款身份可冻结、历史支付可继续使用其原商户,并避免凭证泄露或无配置时静默回退。
管理实际收款商户、商户池轮询和全局微信授权配置,使新线上支付的收款身份可冻结、历史支付可继续使用其原商户,并避免凭证泄露或无配置时静默回退;本能力不新增任何支付渠道退款能力
## ADDED Requirements
### Requirement: 商户与微信授权配置管理
系统 SHALL 将实际收款商户与微信授权配置分离。一个商户 MUST 仅对应 `wechat``alipay` 一种支付方式,并保存名称、支付方式、服务商类型、商户号或应用标识、敏感凭证、状态和备注;微信直连与富友均为微信支付商户。平台最多存在一个启用的微信授权配置,该配置保存 C 端公众号 H5/JSSDK、小程序登录所需参数C 端微信登录、OpenID 和微信支付 AppID MUST 只读取该配置。
超级管理员和平台用户可创建、编辑、启用、停用商户、商户池和微信授权配置,其他角色无管理入口。被支付单引用的商户 MUST NOT 删除且其支付方式、服务商类型、商户号/应用标识不得修改;未被引用商户仅可移出所有商户池并经二次确认删除。停用只影响新支付单,历史支付的回调、查单和原路退款仍使用该商户当前凭证。管理 API 可向上述已认证管理角色返回完整凭证,但日志、审计快照、错误和普通业务响应 MUST NOT 保存或返回敏感凭证
超级管理员和平台用户可创建、编辑、启用、停用商户、商户池和微信授权配置,其他角色无管理入口。仅上述角色的专用管理列表和详情响应可返回完整凭证;日志、审计快照、错误、导出、支付快照和其他业务响应 MUST NOT 保存或返回敏感凭证。被支付单引用的商户 MUST NOT 删除且其支付方式、服务商类型、商户号/应用标识不得修改;未被引用商户仅可移出所有商户池并经二次确认删除。停用只影响新支付单,历史支付的回调、查单和已有可达的原路退款仍使用该商户当前凭证;服务商无既有退款能力时保持不支持,本能力不得为此新增渠道退款调用或人工开关
#### Scenario: 受引用商户停用
- **WHEN** 管理员停用已被支付单命中的商户
@@ -17,28 +17,73 @@
- **WHEN** 不具备超级管理员或平台用户身份的账号请求商户或微信授权配置
- **THEN** 系统拒绝访问且不返回任何凭证或身份字段
#### Scenario: 凭证轮换后处理历史支付
- **GIVEN** 已被支付单引用的商户或当前微信授权配置完成凭证更新
- **WHEN** 更新提交后创建支付、处理回调、查单或原路退款
- **THEN** 系统只使用更新后的当前有效凭证,不得继续使用更新前的缓存凭证
### Requirement: 商户池唯一性与轮询配置
系统 SHALL 为每种支付方式最多启用一个商户池;停用历史池可保留,但不得同时启用多个同支付方式池。商户池成员支付方式 MUST 与池一致,成员按明确顺序排列;金额/笔数方式必须配置 `每轮累计``自然日累计``自然月累计` 统计周期,时间方式必须配置最小为 1 分钟的数值、单位和起始时间。
金额和笔数轮询只统计已确认支付成功结果,不在预下单时预占,也不因退款回冲。达到阈值的成员在当前周期跳过;所有成员达到阈值时新支付失败。时间轮询自起始时间按固定时段和成员顺序选择,成员停用即时跳下一个可用成员但不重置时段。预下单失败不得自动切换或重试,失败单不计入统计;客户再次发起时重新选择。修改阈值保留当前统计,修改统计周期、金额/笔数方式、时间周期、起始时间或每轮排序按 PRD 规则开启新周期;自然周期排序调整保留未移除成员累计。
金额和笔数轮询只统计已确认支付成功结果,不在预下单时预占,也不因退款回冲。支付创建时 MUST 冻结本次路由所属的统计世代;首次成功只按支付 ID 一次性计入冻结商户和冻结统计世代,当前选路只读取当前统计世代。达到阈值的成员在当前周期跳过;所有成员达到阈值时新支付失败。时间轮询自起始时间按固定时段和成员顺序选择,成员停用即时跳下一个可用成员但不重置时段。预下单失败不得自动切换或重试,失败单不计入统计;客户再次发起时重新选择。修改阈值保留当前统计,修改统计周期、金额/笔数方式、时间周期、起始时间或每轮排序按 PRD 规则开启新周期;自然周期排序调整保留未移除成员累计。
#### Scenario: 并发预下单未预占额度
- **WHEN** 多个客户并发创建金额或笔数轮询支付单且当前成员尚未达到阈值
- **THEN** 系统可使这些支付单均命中当前成员,只有后续确认成功的支付才计入累计,已创建支付单不因轮询切换改挂商户
#### Scenario: 迟到首次成功归属冻结统计世代
- **GIVEN** 支付已创建但尚未成功,之后商户池切换统计周期、成员或排序
- **WHEN** 该支付首次确认成功
- **THEN** 系统仅将金额或笔数写入该支付创建时冻结的商户和统计世代,不得改写当前选路统计或重复累计
#### Scenario: 当期没有可用商户
- **WHEN** 启用商户池中不存在启用且未达阈值的成员,或商户池已停用
- **THEN** 系统拒绝创建新支付单并提示暂无可用商户,不回退到旧综合支付配置
### Requirement: 新支付商户快照与历史兼容
C 端套餐购买、C 端资产钱包充值及代理在线预存款充值 SHALL 按支付方式通过对应启用商户池选择实际商户;后台线下订单和钱包余额支付 MUST NOT 经过商户池。每笔通过商户池创建的支付单 MUST 保存商户 ID、商户名称/支付方式/服务商类型/商户号或应用标识快照、商户池 ID/名称快照轮询方式快照,但不得复制敏感凭证。支付、回调验签、查单和原路退款读取该实际商户当前凭证;商户退款能力只由服务商类型和退款必需凭证完整性决定,不提供人工开关
C 端套餐购买、C 端资产钱包充值及代理在线预存款充值 SHALL 按支付方式通过对应启用商户池选择实际商户;后台线下订单和钱包余额支付 MUST NOT 经过商户池。代理在线充值的全局允许范围、其与商户池方式的交集及对外方式查询由对应代理自充能力定义;本能力在方式已获准后负责实际商户选择,并在无可用商户时拒绝创建。每笔通过商户池创建的支付单 MUST 保存商户 ID、商户名称/支付方式/服务商类型/商户号或应用标识快照、商户池 ID/名称快照轮询方式快照及统计世代快照,但不得复制敏感凭证。支付、回调验签、查单和已有可达的原路退款读取该实际商户当前凭证;服务商类型和退款必需凭证完整性只用于已有退款路径的能力判定,不提供人工开关,也不得新增渠道退款能力
上线迁移 MUST 从当前生效综合支付配置复制完整凭证:创建全局微信授权配置、微信/支付宝商户和各自单成员启用池。凭证不完整的方式不建池;迁移仅在数据库复制敏感数据新订单必须只走商户池;旧配置和其历史订单不改写,继续服务历史回调、查询和退款
上线迁移在存在唯一当前生效综合支付配置复制完整凭证:具备完整凭证的微信/支付宝方式创建商户和各自单成员启用池,微信授权字段完整时创建全局微信授权配置;不完整的支付方式不建池,授权字段不完整时不建授权配置并使相关 C 端微信功能明确失败。没有当前生效综合支付配置时迁移仅创建 Schema 和管理入口,不创建商户、商户池或微信授权配置数据新订单按暂无可用商户失败,微信授权相关功能明确返回未配置。存在多条当前生效综合支付配置时迁移 MUST 失败。迁移完成后部署支持按 merchant ID 或旧 `payment_config_id` 双读的 API/Worker三类后续新支付 MUST 立即只走商户池并冻结路由无可用池、成员或停用池时明确失败且不回退旧综合支付配置。merchant ID 为空仅表示历史订单,继续按 `payment_config_id` 服务历史回调、查询和已有原路退款,直到独立 Change 按数据留存期删除旧读取路径。本 Change MUST NOT 自动删除旧配置或旧读取路径
#### Scenario: 新支付冻结实际商户
- **WHEN** 客户以微信或支付宝创建覆盖范围内的新线上支付单
- **THEN** 系统选择并冻结一个实际商户和商户池路由快照,并使用该商户的服务商凭证发起支付
#### Scenario: 迁移后的新支付只走商户池
- **GIVEN** 迁移完成且已部署支持 merchant ID 或旧 `payment_config_id` 双读的 API/Worker
- **WHEN** 客户创建覆盖范围内的后续新线上支付
- **THEN** 系统立即经启用商户池选择并冻结实际商户;无可用池、成员或池已停用时明确失败,不得走旧综合支付配置创建
#### Scenario: 历史支付保留旧读取路径
- **GIVEN** 支付单 merchant ID 为空
- **WHEN** 该支付经过回调、查单或已有原路退款路径
- **THEN** 系统仅按其 `payment_config_id` 处理,不因当前商户池推断或改写其商户,直到独立 Change 按数据留存期删除旧读取路径
#### Scenario: 旧支付单回调
- **WHEN** 商户池切换后收到未带新商户快照的历史支付单回调
- **THEN** 系统按既有综合支付配置兼容处理该历史单,不将其改挂到任何新商户
#### Scenario: 微信授权配置迁移缺失
- **GIVEN** 当前生效综合支付配置的微信授权字段不完整
- **WHEN** 执行商户池配置迁移后访问 C 端微信登录、OpenID、JSSDK 或微信支付 AppID 功能
- **THEN** 系统明确返回微信授权未配置,不得回退旧综合支付配置
#### Scenario: 多个当前生效综合支付配置
- **GIVEN** 存在多条当前生效综合支付配置
- **WHEN** 执行商户池配置迁移
- **THEN** 迁移失败且不选择任一配置作为复制来源
#### Scenario: 富友双读兼容基线
- **GIVEN** 富友 `CommonQuery` 未经第三方实测
- **WHEN** 系统以商户当前凭证完成富友支付的本地双读配置来源和商户加载接线,且不改变现有 `CommonQuery` 请求格式、签名算法、验签、状态映射或恢复语义
- **THEN** 系统保留该兼容基线且不新增富友退款;未第三方实测可以记录,但不得作为本 Change 的实施、验证或归档阻塞
#### Scenario: 维护者指定测试环境的配置迁移验证
- **GIVEN** 维护者指定的 `junhong_cmp_test` PostgreSQL、Redis DB6 与当前 Change fixture
- **WHEN** 本地工作区以明确 `DB_*` 完成 migration up/down/up 和数据行为验证后提交推送 Iteration/8-11
- **THEN** Gitea 只构建/部署 `cmp-test` 测试镜像并检查迁移版本;测试日志保存在 `/opt/junhong_cmp/logs`,不得自动执行迁移或重置整库
#### Scenario: 历史支付路径保留
- **GIVEN** 存在 merchant ID 为空的历史支付
- **WHEN** 商户池功能已上线且独立 Change 尚未按数据留存期删除旧读取路径
- **THEN** 系统仍按该支付的 `payment_config_id` 处理回调、查询和退款,不自动删除旧配置或将其改挂商户

View File

@@ -1,20 +1,21 @@
## 1. 数据配置管理
## 1. 数据配置与双读基础
- [ ] 1.1 追踪 `tb_wechat_config`、订单/充值、`tb_payment`、支付加载器和三类回调的现有 `payment_config_id` 读写链路,列出新旧分流点和敏感字段清单
- [ ] 1.2 新增成对迁移:商户、商户池、成员、微信授权配置、成功累计/路由快照所需表列、唯一启用/成员支付方式/历史引用约束和查询索引不得修改既有迁移。
- [ ] 1.3 实现商户、商户池微信授权配置模型、管理 Query/Handler/RouteSpec:管理角色权限、启停、成员序、引用字段锁定、移出后确认删除及无敏感审计
- [ ] 1.4 实现迁移时从当前生效综合支付配置复制完整微信授权、微信/支付宝商户和单成员池;不完整方式不创建池,迁移日志不得含凭证
- [x] 1.1 冻结现状契约与新旧分流边界:追踪 `tb_wechat_config`、订单/充值、`tb_payment`、支付加载器、微信/支付宝/富友回调、查单和现有退款处理的 `payment_config_id` 读写链路,形成文件/符号级实现清单。明确新单以 `merchant_id` 非空走商户加载,历史 `merchant_id` 为空继续走旧 `payment_config_id`;明确三类新支付仅为 C 端套餐购买、C 端资产钱包充值、代理在线预存款充值,后台线下订单、后台钱包余额支付和员工线下代充值不经过商户池;列出敏感字段、富友 CommonQuery 接缝及退款 A仅商户加载/能力判定)的边界。不得修改源码
- [x] 1.2 新增成对 Schema 迁移:创建商户、商户池、成员、微信授权配置、凭证版本、成功累计唯一事实及支付路由/统计世代快照所需表列;建立每种支付方式最多一个启用池、成员支付方式一致、历史引用保护、唯一累计事实和查询索引约束。不得修改既有迁移,不把迁移文件编号写死在任务或实现契约中
- [x] 1.3 实现商户、商户池微信授权配置管理:交付模型、QueryHandler、可执行 RouteSpec 及管理审计;写操作仅限超级管理员和平台用户;实现启停、成员序、引用字段锁定、移除后二次确认删除、唯一启用池和全局唯一微信授权配置。C 端微信登录、OpenID、JSSDK 和支付 AppID 只读授权配置;普通 DTO、错误、日志、审计、快照、导出不得返回或保存敏感凭证
- [x] 1.4 先行实现商户双读与凭证版本化加载:在三类新支付接入前,使商户/微信授权凭证更新在事务提交时递增版本;支付创建、回调验签、查单和退款 A 先从主库取得当前版本,再按“配置 ID + 版本”读取缓存,旧版本提交后不得被新读取命中,缓存不可用时回源当前数据库记录。`merchant_id` 非空的新单走商户服务商凭证,空值历史单走 `payment_config_id`;不得按当前启用池推断历史商户
- [x] 1.5 实现上线迁移与零 active A 规则:存在唯一当前生效综合支付配置时,仅复制完整凭证;按完整微信/支付宝凭证分别创建商户及单成员启用池,微信授权字段完整时创建全局授权配置;凭证不完整的方式不建池,授权字段不完整不建授权配置。零条 active 综合支付配置时仅创建新 Schema 和管理入口,绝不插入商户、商户池或微信授权业务行;新线上支付明确按“暂无可用商户”失败且不回退旧配置。多条 active 配置时迁移失败且不选择来源。敏感数据仅在数据库复制,迁移日志不得输出凭证,不批量回填既有支付单 `merchant_id`
## 2. 商户池选择与支付链路
## 2. 商户池与支付链路
- [ ] 2.1 实现金额、笔数、时间轮询选择器及池配置变更重置规则;使用事务/受控查询保证唯一启用池和成员状态一致
- [ ] 2.2 在 C 端套餐支付、资产钱包充值、代理在线充值的创建路径接入选择器,保存商户/池/方式非敏感快照;无可用商户和预下单失败不回退、不换商户
- [ ] 2.3 在支付成功首次生效路径按支付 ID 幂等累计金额/笔数;退款不得回冲,失败/重复回调不得计入
- [ ] 2.4 改造支付加载微信/支付宝/富友回调、查单和原路退款:新单读取实际商户,历史 merchant ID 为空的单继续读取旧 `payment_config_id`;停用商户仍可处理历史单
- [ ] 2.5 清理日志、错误、审计和普通 DTO 中的敏感凭证,管理 API 仅向超级管理员/平台用户按 PRD 返回完整配置并使凭证更新刷新加载缓存。
- [x] 2.1 实现商户池选择器与统计世代规则:支持金额、笔数、时间轮询;金额/笔数支持每轮累计、自然日累计、自然月累计,时间周期最小 1 分钟并带单位和起始时间。支付创建事务内选择启用且未达阈值成员,冻结商户、池、轮询快照和 `routing_epoch`;修改阈值保留当前统计,修改统计周期、策略、时间周期、起始时间或每轮排序按规则开启新世代;自然周期仅排序调整时保留未移除成员累计;停用成员即时跳过但不改写既有支付。不得预下单预占
- [x] 2.2 接入支付成功首次生效统计:以支付 ID 唯一事实按支付创建时冻结的商户和 `routing_epoch` 累计金额/笔数;迟到首次成功仍写入冻结世代,不改写当前选路世代;失败、关闭、预下单和退款不计入;重复回调、重复事件和重复执行不得重复累计
- [x] 2.3 接入三类新支付创建并保持代理边界:在双读/版本加载和选择器完成后,将商户池选择接入 C 端套餐购买、C 端资产钱包充值、代理在线预存款充值。每笔支付在同一业务事务内冻结 merchant/pool/支付方式/服务商/非敏感身份/轮询及统计世代快照,再使用所选商户凭证创建支付。三类后续新支付始终只走商户池;无池、无可用成员或池停用时明确失败,预下单失败不得回退旧综合配置、不得自动换商户重试。后续 Apply 必须删除商户池新支付创建开关、所有引用及任何旧综合配置创建回退。仅代理在线预存款充值走池;员工线下代充值、后台线下订单、后台钱包余额支付及其它代理后台钱包操作不得经过商户池
- [x] 2.4 改造回调、查单及退款 A 的商户加载微信支付宝富友回调和代理在线查单对 `merchant_id` 非空新单使用商户当前凭证,对空值历史单继续按 `payment_config_id` 双读;停用商户不得阻断已冻结历史单的本地回调/查询/退款 A 路径。退款 A 仅实现依据冻结商户加载当前凭证并作退款能力/必需凭证完整性判断、接入既有退款流程;不得新增或验收微信/支付宝/富友实际渠道退款 API、退款请求、渠道退款回调或外部退款成功保留客户凭证退款、代理钱包回退及既有幂等语义。不得按当前池推断历史商户
## 3. 文档与验证
## 3. 文档、渠道核验与发布控制
- [ ] 3.1 更新支付、商户和微信授权管理接口 OpenAPI,并运行 `go run cmd/gendocs/main.go`
- [ ] 3.2 在隔离数据库验证迁移 up/down/up、配置迁移、三类新支付、缺失配置、三种轮询、并发成功累计、商户停用历史回调/退款及旧单兼容
- [ ] 3.3 运行 `gofmt -w`(变更 Go 文件)、`go build ./cmd/api ./cmd/worker``openspec validate add-payment-merchant-pools --strict``openspec doctor --json``./scripts/context-health.sh`;自动化测试按项目决策为 N/A。
- [x] 3.1 更新支付、商户、商户池和微信授权管理接口 OpenAPI、可执行路由说明及生成入口,覆盖权限、启停、成员排序、引用锁定、二次确认删除、零 active 失败提示、三类新支付和后台排除边界。敏感凭证的脱敏/禁止泄露仅适用于普通 DTO、错误、日志、审计、支付快照、导出及非专用管理响应超级管理员和平台用户的专用管理列表、详情响应继续允许返回完整凭证。文档与 OpenAPI 示例只能使用占位值,绝不写入真实凭据。不得把具体迁移编号或 candidate 目录写入契约,不新增测试体系;不得暗示本 Change 实现渠道退款 API
- [x] 3.2 富友 B保持现有 `CommonQuery` 请求格式、签名算法、验签、状态映射和恢复语义;仅改本地双读配置来源与商户加载,使双读查单可按商户当前凭证调用。不得改变协议、验签、状态解释、恢复规则或业务能力,不得增加退款能力;未第三方实测可记录但不得作为任务或归档阻塞
- [x] 3.3 在本地工作区以明确 `DB_*` 指向维护者提供的 `junhong_cmp_test` PostgreSQL 与 Redis DB6完成 migration up/down/up 和数据行为验证:零/一/多 active、支付/授权凭证缺失、三类新支付始终走商户池、后台 wallet/offline 排除、缺失配置、三种轮询、统计世代与迟到成功、凭证轮换及缓存一致性、并发成功累计、商户停用后的历史回调/查单/退款 A、merchant ID 为空旧单兼容、敏感字段不泄露和重复处理幂等。允许仅为当前 Change 创建/删除 fixtures禁止重置整个库仅连接、迁移或实际行为失败时阻塞对应场景。完成后才提交推送 Iteration/8-11每个场景提供可观察状态/错误/快照/统计事实证据;不新增测试体系,不验收实际渠道退款成功。
- [x] 3.4 Iteration/8-11 分支 Gitea 以本次提交 SHA 仅构建/部署 `cmp-test` 测试镜像并检查 migration version不自动执行 migration up/down 或重置整库;记录 API/Worker 容器状态、健康检查和 `/opt/junhong_cmp/logs` 的有限日志不建运行时开关且不是生产发布。部署后验证三类后续新支付立即走商户池并冻结路由无池、成员或停用池明确失败且无旧创建回退merchant ID 为空历史支付仍读旧路径。故障仅在双读版本上前向修复;存在非空 `merchant_id` 支付、成功事实或新配置时,禁止部署不识别新路由的旧二进制和破坏性 down。后续 Apply 必须删除商户池新支付创建开关、所有引用及任何关闭后恢复旧创建的代码。

View File

@@ -0,0 +1,102 @@
## Context
动机及兼容影响见 `proposal.md`;可观察契约及验收条件以本 Change 的 `specs/package-lifecycle/spec.md` 为准。开工检查后三项方案已确认,本设计不保留影响实施的未决问题。
| 当前事实 | 代码位置 | 对设计的约束 |
| --- | --- | --- |
| 后台按载体读取全部世代,状态筛选后内存排序、计数、切页 | `internal/service/asset/service.go:766-881``internal/store/postgres/package_usage_store.go:288-301` | 保留载体与世代范围,不复用旧明细分页结果组装关系 |
| H5 按客户绑定资产当前世代查询,状态和类型筛选后数据库明细分页 | `internal/handler/app/client_asset.go:117-175,467-554,590-612` | 在完整当前代集合上建立关系,不能使用当前页判断父项缺失 |
| 使用记录包含 `gorm.Model``master_usage_id`、可空生效时间和世代 | `internal/model/package.go:62-98` | 区分物理缺失、软删除和范围外存在,明确空时间排序 |
| 商品可批量含软删除读取H5 类型筛选仍排除已软删除商品 | `internal/store/postgres/package_store.go:129-138``internal/handler/app/client_asset.go:498-500` | 展示商品集合不等于类型筛选命中集合 |
| 公共套餐 DTO 同时供历史、当前套餐和修改接口使用 | `internal/model/dto/asset_dto.go:135-168``internal/routes/asset.go:42-73` | 使用历史专用节点,避免对公共 DTO 的修改扩大响应变化 |
| 数据权限在 Store 显式过滤,不是自动 GORM 权限插件 | `internal/bootstrap/bootstrap.go:60-69``pkg/middleware/data_scope.go:23-33` | 必须保留调用入口授权,不能仅传递 context 就声称有资产隔离 |
## Goals / Non-Goals
**Goals:**
- 将主子归属、关系异常、整组筛选、排序、计数和分页收口到一个只读 Query两端相同范围输入得到相同关系结果。
- 明确两端范围和字段投影差异,不以统一实现为由统一权限、金额字段或失败策略。
- 主项数量增长不增加逐组数据库查询;完整组不跨页。
**Non-Goals:**
- 不修改套餐状态、激活、退款、失效、流量或金额计算,不新增 Schema、关联表、写接口、外部调用或依赖。
- 不将卡及其关联设备合并为新资产范围,不扩大 H5 历史世代,不缩小后台历史世代。
- 不重构其他套餐视图或当前套餐/修改套餐接口,不在本 Change 修复存量企业授权问题。
## Decisions
### 1. 一个共享关系 Query保留两端字段映射
在现有 `internal/query/asset` 下新增 `package_history.go`,负责批量读取使用记录和关系投影。输入须来自已经通过既有入口校验的资产类型、资产 ID、可选世代范围以及该入口已有筛选分页参数资产类型只接受已解析的卡或设备不能让未知载体退化为无范围查询。后台不传世代限制H5 传资产实体当前世代。
Query 返回选中顶层关系节点及 `total`,节点保留原使用记录供入口投影。后台 `Service.GetPackages` 与 H5 `GetPackageHistory` 消费同一关系规则;已有商品批量加载和字段映射保留入口差异,不在两个入口各自实现分组、排序或分页。复用 `PackageStore.GetByIDsUnscoped`,不逐节点加载商品。
拒绝在后台和 H5 分别写 assembler两端会再次出现筛选及异常判断漂移。也不复用 `CustomerViewService.GetMyUsage`:该视图只取部分状态、只保留单个主项且逐使用记录加载商品,不适合多主套餐历史。
### 2. 关系查询先于展示筛选和分页
```text
入口认证及资产校验
→ 固定资产/世代范围,一次读取未软删除的全部使用记录
→ 建立使用记录 ID 索引和 master_usage_id 关系
→ 对未解析主 ID 一次批量最小存在性核对
→ 形成完整关系组及物理缺失异常项
→ 同一成员联合匹配筛选,选中整组
→ 顶层与子项排序,计算顶层 total切顶层页
→ 各入口批量商品补充与字段投影,保留各自响应壳
```
不将 `status` 或展示分页施加到关系基础集合。H5 类型筛选的匹配资格另按既有未软删除商品查询口径取得,只需当前资产使用记录引用的商品 ID不能把 `Unscoped` 商品展示映射直接作为类型命中资格。组内任一条记录同时满足所有请求条件才使组命中,不允许父项匹配状态、子项匹配类型来拼成一次联合命中。
基础 usage 查询一次;未解析主 ID 去重后至多一次存在性查询;类型资格和商品补充均批量查询。空 ID 集合不发查询,数据库读取次数不随主项数量线性增长。不是按主项循环查询子项,也不是先切明细页再补父项。
### 3. 物理缺失判定不扩大展示权限
基础集合沿用原未软删除 usage 范围。仅对其中真实持久化引用、但不在索引中的主 ID执行一次含软删除的最小存在性核对只选择判定所需的 ID、载体、世代和删除标记。不得使用客户端任意父 ID 批量探测,不得读取或返回范围外主套餐业务内容。
- 全局物理不存在:创建既定 `master_missing` 异常独立项。
- 物理存在但不在允许展示集合:整个查询经既有全局 ErrorHandler 返回统一读取错误,不返回部分列表,不透露是其他资产、其他世代还是软删除,也不静默丢掉子项。不能借 `Unscoped` 将软删除主项重新展示。
- 存在性核对失败:按读取失败处理,不将失败当成不存在。
- 仅商品不存在:沿用名称快照等历史回退,不影响 usage 关系判定。
关系完整性在展示筛选及切页前判定;范围集合内存在不可展示父项时,不因该子项恰好不在请求页或不命中筛选而掩盖错误。代价是异常数据会阻断本次历史读取,但这比泄露范围外数据或返回错误关系更安全。
### 4. 排序、分页和响应结构
严格执行 delta spec 的三桶子项排序,不能用 `priority` 或创建时间倒序替代生效时间规则。待生效以状态判断,生效时间为空的非待生效记录进入独立中间桶,不重写其状态。顶层同创建时间按 ID 倒序,子项同桶同时间按 ID 正序,排序比较条件构成确定顺序。
先选组再计算 `total` 和切顶层页。异常独立项以自己的创建时间参与顶层排序,每项独立计数;子项数大于页大小时仍完整随组返回。保留后台 50100、H5 20100 的默认/最大页大小,以及后台 `page_size`、H5 `size` 响应差异。
历史专用节点保留该入口原有套餐字段,普通主项显式输出 `master_usage_id=null`,不继承旧字段 `omitempty` 导致缺失的行为;空子数组始终为 `[]`。正常节点省略关系异常字段,叶子不展开;只有物理缺失项输出既定异常状态及中文名称,不新增其他异常独立项状态。不要直接扩展公共 `AssetPackageResponse` 而使当前套餐及修改接口也出现层级字段。
### 5. 字段与错误兼容
- 后台继续仅向平台类账号填充 `paid_amount`按现状保留其他历史字段H5 不因复用而补出原先未填的订单、退款、金额和生效条件字段。主项和子项执行相同入口字段策略。
- 商品名称优先使用 usage 快照,空快照再回退到商品名称。商品软删除仍可用于历史展示,不等于 usage 软删除或主记录物理缺失。
- 后台商品批量读取失败仍沿用原先告警并继续快照投影的路径H5 同类错误仍返回失败。usage 读取、类型资格查询或主记录存在性核对失败不适用后台商品降级路径。
- 查询仅保留既有授权机制,不声称因此修复了企业授权隔离。后台角色允许企业账号而当前套餐链未见企业授权过滤,是静态检查发现的存量风险,未做账号运行验证;另行处理,不增加本期修复任务。
### 6. 文件归属
| 文件 | 本期职责 |
| --- | --- |
| `internal/query/asset/package_history.go`(实施时新增) | 共享关系读取、批量存在性核对、类型匹配资格、整组筛选、排序、计数和分页 |
| `internal/service/asset/service.go` | 后台接入 Query保留资产范围、平台成本字段和商品错误策略 |
| `internal/handler/app/client_asset.go` | H5 接入 Query保留客户绑定、当前世代、历史字段和统一分页响应 |
| `internal/model/dto/asset_dto.go``internal/model/dto/client_asset_dto.go` | 历史专用层级节点和列表响应;不改变其他接口公共字段契约 |
| `internal/routes/asset.go``internal/routes/personal.go` | 更新历史响应类型、分页筛选描述及异常说明 |
| `internal/bootstrap/``cmd/api/docs.go``cmd/gendocs/main.go` | 仅在 Query 注入/构造依赖变化时同步真实装配及两套文档装配 |
## Risks / Trade-offs
- [分页与筛选不向后兼容] → proposal 明确标记 BREAKING后台和 H5 消费者须同步适配,不以保留字段名冒充语义兼容。
- [全量关系集合与大组增加内存/响应体] → 只读取当前入口已有资产范围;商品和存在性核对保持批量、最小字段。页大小约束顶层项而非子项,不能截断子项破坏契约。
- [误判缺失或越界补显] → 未解析主 ID 只做内部最小存在性核对,存在但不可展示时整体失败;不返回范围外业务数据及具体原因。
- [共享 DTO 泄漏字段或影响无关接口] → 使用历史专用节点,保留入口字段投影,核对当前套餐和修改接口响应不变。
- [同时存在多个读取失败策略] → 明确只有后台商品补充读取可沿用既有快照降级,关系事实查询不得降级成缺失。
## Migration Plan
无需数据库迁移或数据回填。实现完成后,在隔离环境按 delta spec 对两条历史入口做只读 smoke核对实际 JSON、页边界、授权拒绝和查询次数按项目决定不恢复自动化测试。
发布前同步消费者对层级结构、顶层 `total`、整组筛选、默认展开和整体读取错误的处理,核对 OpenAPI 与实际响应一致。生产发布由维护者按既有运行说明执行。回滚时 API 与对应消费者一起退回原平铺契约;由于没有本期写入或 Schema 变化,不做数据回滚。不在本次规划更新中执行构建、生成器或任何业务调用。

View File

@@ -0,0 +1,33 @@
## Scope
- 迭代编号:`AUG26-013`
- 需求依据:`docs/product/2026-08-迭代-PRD-讨论稿.md` 的 AUG26-013、§2.14`111.md` 仅用于原始追溯。
- 开工检查提出的整组分页与筛选、不可展示父项失败处理、空生效时间排序方案已确认,纳入本 Change。
## Why
资产套餐历史平铺展示,无法识别主套餐与其加油包的真实关联。
## What Changes
- 在后台资产详情和 H5 套餐历史按既有 `master_usage_id` 投影主套餐—加油包层级,返回 `children``expand_by_default`,保留失效、过期、用尽及退款历史关系。
- **BREAKING**:分页单位由单条使用记录改为顶层关系组或物理缺失主项的异常独立项;`total` 改为筛选后的顶层项数量。子项不独立计数、不跨页;保留两端既有分页参数、页大小限制及响应壳字段名。
- **BREAKING**:现有状态/套餐类型条件在同一条记录上联合匹配,组内任一记录命中即选择整组,返回可见范围内完整父子,不再逐条筛掉关联项。后台仍不新增套餐类型筛选。
- **BREAKING**:历史响应从平铺项改为层级项,普通主项显式返回 `master_usage_id=null`;后台与 H5 消费者需同步适配,不保留并行平铺接口。
- 主记录物理缺失时返回既定异常独立项;父记录存在但因资产、世代或软删除范围不可展示时,整个查询返回统一读取错误,不越界补显、不静默丢项、不伪报物理缺失。
- 顶层按创建时间倒序、ID 倒序;子项按有生效时间的非待生效项、无生效时间的非待生效项、待生效项分桶排序,各桶同时间按使用记录 ID 正序。
- 后台继续读取当前解析资产的全部世代H5 继续限定资产当前世代;保留既有资产授权入口、字段可见性、商品查询失败处理和软删除商品类型筛选口径。
## Capabilities
### New Capabilities
- 无。
### Modified Capabilities
- `package-lifecycle`: 套餐历史层级、整组分页与筛选、排序、关系异常和读取兼容契约。
## Impact
- 影响 `GET /api/admin/assets/{identifier}/packages``GET /api/c/v1/asset/package-history` 的读取投影、历史专用 DTO、路由说明与 OpenAPI以及后台和 H5 消费者。
- 两端共享同一只读关系投影,批量读取使用记录与商品;不逐主套餐查询子项。当前套餐及套餐修改接口的响应不随公共 DTO 被动变化。
- 不新增写接口、关联表、Schema 迁移或外部依赖;不修改套餐状态、金额、生命周期及未触碰的权限体系。不将存量企业授权风险作为本期顺带修复。

View File

@@ -0,0 +1,94 @@
## ADDED Requirements
### Requirement: 资产套餐层级投影
系统 SHALL 在后台资产详情套餐列表和 H5 资产套餐历史中,直接依据既有 `master_usage_id` 返回主套餐与关联加油包的层级,不新增关联表。普通主项 SHALL 显式返回 `master_usage_id=null``children` 数组和 `expand_by_default`;存在至少一个可展示关联子项时默认展开,否则不展开。子项 SHALL 保留自身使用记录 ID、原主记录 ID、状态、购买创建时间、生效时间及该入口既有历史字段。正常项 SHALL 省略关系异常字段,所有叶子 SHALL 返回 `children=[]``expand_by_default=false`。主套餐或加油包失效、过期、用尽、退款均 MUST NOT 拆散既有关系或改写其生命周期事实。
#### Scenario: 多个主套餐分别关联子项
- **WHEN** 同一可见资产范围内有多个主套餐,各自关联不同加油包
- **THEN** 每个加油包仅出现在其原主套餐的 `children` 中,不在顶层重复出现;有子项的主套餐默认展开
#### Scenario: 无子项的主套餐
- **WHEN** 某主套餐没有可展示关联加油包
- **THEN** 该主项返回 `master_usage_id=null``children=[]``expand_by_default=false`,不返回关系异常字段
#### Scenario: 已失效历史关系
- **WHEN** 主套餐或关联加油包已失效、过期、用尽或退款,但使用记录仍处于本入口可展示范围
- **THEN** 响应保留原主子关系及各自原状态,不因历史状态将子项拆为顶层记录
### Requirement: 套餐历史整组筛选与分页
系统 SHALL 将一个主套餐及其全部可展示子项作为一组,每组占一个分页名额;主记录物理缺失的每条异常独立项也各占一个名额。`total` SHALL 表示筛选后、分页前的顶层项数量,子项不另计数且 MUST NOT 跨页。已有 `status` 和套餐类型筛选条件 SHALL 在同一条使用记录上联合匹配;组内任一记录满足全部条件即选中整组,返回其完整可展示父子,不因筛选拆关系。异常独立项仅以自身字段匹配。未传筛选条件时 SHALL 返回范围内全部组及异常独立项。
#### Scenario: 子项数量超过页大小
- **WHEN** 一个主套餐含三个子项,另有一个无子项主套餐,以 `page_size=1` 查询且无筛选
- **THEN** `total=2`;包含三个子项的组在同一页完整返回,子项不消耗额外分页名额,另一页只返回另一个主项
#### Scenario: 状态或类型仅命中子项
- **WHEN** 主项不满足筛选条件,但某个子项满足该请求的全部筛选条件
- **THEN** 该组被选中,返回主项及其全部可展示子项,包括未命中条件的同组记录;主项不被标记为缺失
#### Scenario: 联合条件不能分摊给不同记录
- **WHEN** 请求同时指定状态和套餐类型,组内只有主项满足状态、只有子项满足类型,且没有任何一条记录同时满足二者
- **THEN** 该组不进入结果,也不计入 `total`
#### Scenario: 异常独立项计数
- **WHEN** 筛选后有一个正常关系组和两条主记录物理缺失的加油包
- **THEN** `total=3`,两条异常项各自占一个分页名额,即使它们指向同一个缺失主记录也不合并
#### Scenario: 无匹配与超出末页
- **WHEN** 没有任何顶层项匹配,或请求页码超出筛选结果末页
- **THEN** `items=[]`;无匹配时 `total=0`,超出末页时仍返回筛选后的真实顶层项总数
### Requirement: 套餐历史稳定排序
系统 SHALL 将顶层项按自身创建时间倒序排列,同时间按 `package_usage_id` 倒序排列。每组子项 SHALL 依次分为三桶:非待生效且有生效时间的记录按生效时间正序;非待生效但生效时间为空的记录按购买创建时间正序;待生效记录按购买创建时间正序并排最后。各桶排序时间相同 SHALL 按 `package_usage_id` 正序。桶归属及排序 MUST NOT 改变记录状态;后台与 H5 对相同输入集合 SHALL 返回相同顺序。
#### Scenario: 混合生效时间与历史状态
- **WHEN** 同组包含有生效时间的已用完记录、无生效时间的已失效记录和待生效记录
- **THEN** 三者依次位于第一、第二、第三桶;已失效记录保持已失效状态,不被重新标记为待生效
#### Scenario: 同时间的确定顺序
- **WHEN** 多个顶层项创建时间相同,或同桶子项排序时间相同
- **THEN** 顶层按使用记录 ID 倒序,子项按使用记录 ID 正序;数据不变时重复请求及相邻页边界保持一致
### Requirement: 物理缺失与不可展示关系区分
系统 SHALL 仅在 `master_usage_id` 指向的主使用记录物理不存在时返回顶层异常独立项,保留原字段及非空主记录 ID并返回 `relationship_status=master_missing``relationship_status_name=关联主套餐缺失``children=[]``expand_by_default=false`。主记录未出现在筛选结果或某一页 MUST NOT 被认定为物理缺失。范围内的子项所指父记录实际存在但处于其他资产、H5 其他世代或已软删除而不可展示时,整个查询 SHALL 返回统一读取错误,不返回部分成功列表,不越界补显、不静默丢项,也不返回物理缺失标识。商品记录缺失 MUST NOT 等同于主使用记录缺失。
#### Scenario: 主套餐物理缺失
- **WHEN** 加油包所引用的主使用记录物理不存在
- **THEN** 返回带既定 `master_missing` 标识及中文名称的异常独立项,保留原主记录 ID不猜测替代主套餐
#### Scenario: 父子原本分处明细分页两侧
- **WHEN** 父子记录都在当前入口可展示范围,但按旧明细分页会落在不同页,或父项不满足展示筛选
- **THEN** 系统仍按真实关系返回同一个完整关系组,不将子项标记为主套餐缺失
#### Scenario: 主使用记录软删除或范围不可见
- **WHEN** 范围内某子项的主使用记录仍物理存在,但已软删除、属于其他资产,或不属于 H5 当前世代
- **THEN** 整个查询返回统一读取错误;不暴露父项内容或具体不可见原因,不静默丢项,不返回 `master_missing`
#### Scenario: 商品缺失但使用关系存在
- **WHEN** 主使用记录及其子项均可展示,但关联套餐商品已软删除或物理缺失
- **THEN** 主子关系保持不变,历史名称沿用既有快照回退规则,不因商品缺失返回关系异常
### Requirement: 套餐历史读取范围与兼容边界
系统 SHALL 保留各入口既有资产解析及授权前置校验后台读取当前解析资产的全部世代H5 通过当前客户资产绑定校验后仅读取资产实体当前世代卡按卡载体、设备按设备载体读取MUST NOT 为补齐关系而合并其他资产或扩张 H5 世代。后台 SHALL 继续只支持已有状态筛选H5 SHALL 继续支持状态和套餐类型筛选H5 类型匹配 SHALL 保留排除已软删除商品的既有口径,不将用于历史展示的软删除商品自动作为类型命中项。
系统 SHALL 保留后台默认页大小 50、最大 100 和 `items/total/page/page_size` 响应字段H5 默认页大小 20、最大 100 和 `items/total/page/size` 响应字段。两端仍接收 `page/page_size`,页码小于 1 时归一为 1页大小小于 1 时使用各自默认值,超过上限时截断。除明确变更的层级、分页和筛选语义外,系统 SHALL 保留各入口既有套餐字段和可见性后台成本价仅平台类账号可见H5 MUST NOT 因共享投影而新增填充原先未填的订单、退款、成本价、零售价或生效条件字段。当前生效套餐及修改套餐接口的响应 MUST NOT 被本次历史层级变更连带改变。
#### Scenario: 两端保留各自世代范围
- **WHEN** 同一资产有多个世代的独立主子组后台有权查看该资产H5 客户具有有效绑定
- **THEN** 后台可返回全部世代内的组H5 只返回资产当前世代内的组,不为对齐两端数量而扩大或缩小范围
#### Scenario: 未通过既有资产授权
- **WHEN** 代理请求其店铺范围外的资产,或 H5 客户请求非其有效绑定资产
- **THEN** 沿用既有拒绝行为,不返回该资产任何主项或子项,也不因补查主记录绕过授权
#### Scenario: 字段可见性与其他接口不变
- **WHEN** 平台、代理和 H5 客户分别读取其可见资产历史,并调用原有当前套餐或修改套餐接口
- **THEN** 历史主子项均遵守各入口既有字段可见性;代理与 H5 不因层级投影获取平台成本价,其他套餐接口保持原响应结构
#### Scenario: 已软删除商品不成为类型筛选命中项
- **WHEN** H5 按套餐类型筛选,组内只有已软删除商品对应的使用记录具有该类型
- **THEN** 该记录不使整组命中;若组内另一条记录满足全部条件而选中该组,已软删除商品对应的可展示使用记录仍随组保留
#### Scenario: 商品批量读取失败的既有差异
- **WHEN** 关联商品批量读取发生数据库错误,而非单纯商品记录不存在
- **THEN** 后台沿用使用记录快照继续投影的既有行为H5 沿用读取失败响应,不因共享关系投影统一为另一入口的行为

View File

@@ -0,0 +1,27 @@
## 1. 查询契约与共享投影
- [x] 1.1 依据 design 的开工检查证据核对后台套餐列表、H5 套餐历史、`PackageUsage` 及 DTO 引用范围确认实施时调用链仍符合既有授权、后台全部世代、H5 当前世代及入口字段差异。
- [x] 1.2 在 `internal/query/asset/package_history.go` 实现既有资产/世代范围内一次读取全部未软删除使用记录;按 `master_usage_id` 建立完整关系,去重后一次最小存在性核对未解析主 ID区分物理缺失异常项与存在但不可展示时的整体读取错误禁止逐主项查询子项。
- [x] 1.3 实现同一成员联合匹配状态/类型条件并选择整组,保持 H5 类型匹配排除软删除商品的既有口径;按规格实现三桶子项排序、顶层稳定排序、顶层 `total` 和整组分页。
## 2. 历史响应与两端接入
- [x] 2.1 在现有 DTO 文件增加历史专用层级节点及列表响应,落实普通主项显式 `master_usage_id=null``children` 空数组、默认展开及既定缺失标识;不改变当前套餐和修改套餐接口的公共响应契约。
- [x] 2.2 将后台 `Service.GetPackages` 接入共享 Query保留 Handler 资产解析、状态入参校验、全部世代、50100 页大小、`page_size` 响应字段、平台成本字段及后台商品查询失败快照策略。
- [x] 2.3 将 H5 `GetPackageHistory` 接入同一 Query保留客户有效绑定、资产实体当前世代、20100 页大小、`size` 响应字段、原历史字段及商品查询失败响应;不得因复用补出订单、退款或金额字段。
- [x] 2.4 更新两条历史路由的响应类型、整组分页/筛选和错误说明,批量复用商品加载;仅在构造依赖变化时同步 bootstrap、`cmd/api/docs.go``cmd/gendocs/main.go` 装配,删除被本次接入替代的旧明细分页与重复关系逻辑。
## 3. 隔离环境验收
- [x] 3.1 在隔离环境对两条历史入口执行多主项、无子项、多子项及子项数超过页大小的只读 smoke核对不跨页、不重复、顶层 `total`、异常项独立计数、空结果及超出末页行为。
- [x] 3.2 验证仅子项命中、主项命中、联合条件不能由不同成员分摊、无匹配及软删除商品类型资格场景,确认选中整组并保留完整可展示父子,不因筛选误报主项缺失。
- [x] 3.3 验证主子失效、过期、用尽及退款关系保留;核对三桶顺序、非待生效但生效时间为空、待生效排序及顶层/子项相同时间的确定顺序,不修改原状态。
- [x] 3.4 验证主使用记录物理缺失、软删除、跨资产、H5 跨世代、商品缺失及存在性查询失败分别遵守异常/整体错误/历史回退契约;范围内不可展示关系不因分页或筛选被掩盖,不返回范围外内容及具体不可见原因。
- [x] 3.5 验证卡/设备载体边界、后台全部世代与 H5 当前世代差异、代理店铺范围和 H5 绑定拒绝、主子项金额字段隔离;核对当前套餐及修改接口的既有响应不变,不把存量企业授权问题算作本期已修复。
- [x] 3.6 核对两端相同输入集合的层级顺序一致、两种分页响应壳不变、商品加载失败策略保留;通过隔离环境查询记录确认 usage 一次读取、未解析主 ID 至多一次存在性核对、商品/类型资格批量加载,查询次数不随主项数线性增长。
- [x] 3.7 核对后台和 H5 消费者同步适配层级、默认展开、顶层总数、整组筛选及整体读取错误,完成消费者联调并确认 API 与消费者可同步发布/回滚;未取得联调结果不得宣称展示验收完成。
## 4. 工程验证
- [x] 4.1 对变更 Go 文件运行 `gofmt -w`,运行 `go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go`;核对生成 OpenAPI 与两条历史接口实际 JSON 一致,当前套餐和修改接口未被连带改变。
- [x] 4.2 运行 `openspec validate add-asset-package-hierarchy --strict``openspec doctor --json`,汇总上述 smoke 与兼容验收结果;自动化测试按项目决策为 N/A不恢复旧测试不运行迁移或真实外部业务调用。

View File

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

View File

@@ -0,0 +1,72 @@
## Context
`proposal.md``scripts/context-health.sh` 以集合相等检查主 Specs、`requirement-evidence.json``entry-capability-requirement-matrix.json`;调查确认主 Specs 为 89 条、现有证据为 53 条,缺失 36 条,且没有过期 evidence。矩阵也缺少同一 36 条 Requirement 链接。
## Goals / Non-Goals
**Goals:**
- 仅以当前可达源码、迁移/脚本、配置和已有归档验证记录补齐 36 条的静态可追溯证据。
- 令两个 JSON 文件分别满足脚本的 Requirement 集合双向覆盖;保持 HTTP 路由集和 Asynq 注册入口集不变。
- 每条证据在 `entries``handler_consumer_job``application_service_query``domain_state_amount``store_migration_config``verification` 中只引用实际存在的事实。
**Non-Goals:**
- 不修改 `openspec/specs/**`、业务代码、迁移、测试、运行配置、路由或 Worker 注册。
- 不以历史 Change 的完成状态、推测的调用链或无法重现的生产结果替代当前可达证据。
- 不添加、删除或重分类既有 HTTP/async 矩阵入口。
## Decisions
### 1. 固定缺失集合,按 capability 分组补行
以下是唯一可新增的 evidence Requirement 键;实现阶段先由同一解析规则重算,若集合变化则停止并重新调查:
| capability | 缺失 Requirement |
| --- | --- |
| `agent-funds-commission` | 代理在线充值可用支付方式按支付配置判定 |
| `agent-open-api` | 代理开放接口查询不触发可靠卡观测 |
| `external-integration` | 富友主扫统一下单与订单查询;高频轮询外部交互日志保留边界;外部交互日志归档与留存受控执行;外部交互日志逐日物理留存 |
| `operations-audit` | 轮询审计事实保留边界;审计归档与日留存受控执行;审计在线数据逐日物理留存;日留存独立运行日志;审计调查留存边界连续 |
| `package-lifecycle` | 资产套餐层级投影;套餐历史整组筛选与分页;套餐历史稳定排序;物理缺失与不可展示关系区分;套餐历史读取范围与兼容边界 |
| `package-queue-activation` | 当前主套餐过期后自动激活下一个孤儿待生效套餐必须公平恢复Asynq 激活结果必须准确且可重试 |
| `polling-load-control` | 轮询全局并发与背压;正常无变化观测的低写入处理;业务变化与轮询异常仍可追溯 |
| `polling-operations` | 停复机遵循实际生效实名策略 |
| `qicheng-migration-package-lifecycle-overrides` | 迁移配置支持逐卡套餐生命周期覆盖;覆盖必须完整且可验证;未覆盖资产维持严格冲突阻断 |
| `qicheng-migration-package-usage` | 迁移套餐使用记录保存计时条款快照;迁移套餐使用 SQL 满足快照完整性约束 |
| `refund-approval` | 零金额退款审批通过;零金额退款不产生资金回款;非法退款金额仍被拒绝 |
| `shop-bulk-import` | 导入输入必须在生成 SQL 前完成全量预检;生成的 SQL 必须在写入前校验目标库状态;批量导入 SQL 必须创建完整的店铺初始事实;导入执行必须由维护者审核后以单事务手工确认;导入结果必须可审核和重跑定位 |
### 2. 各类证据的直接来源
| Requirement 分组 | 入口/任务与调用链事实 | 状态、存储、迁移或验证事实 |
| --- | --- | --- |
| 代理充值与富友(前两组中相关 2 条) | `internal/handler/admin``internal/routes` 的代理充值入口;`internal/application/agentrecharge/online_creation.go``internal/infrastructure/payment/fuiou_scan.go``pkg/fuiou/scan.go` | `model.AgentRechargeRecord.PaymentMethod/PaymentChannel`、支付配置完整性、`integrationlog.Repository`;验证输出只取当前源码可命中的 `PreCreate``CommonQuery``trans_stat` 映射或既有归档 Change 的可复核命令。 |
| 开放接口本地查询1 条) | 四个 GET 入口:`/api/open/v1/cards/traffic``/cards/status``/cards/realname-status``/devices/traffic``internal/handler/openapi/handler.go``internal/service/agent_open_api/service.go` | 对四个读取方法做静态可达性检查:不得引用观测分发/系列调度;只列实际查询的 Store/模型。 |
| 轮询低写入、轮询审计与外部交互3 条) | 五个已注册 polling Task`internal/task/polling_base.go` 和各 `polling_*_handler.go``internal/infrastructure/cardobservation/series_runner.go` | `internal/application/cardobservation/{apply,network,traffic}.go` 的变化审计/Outbox 事实,及 `internal/task/polling_integration_log.go` 的失败终结事实;正常无变化仅在当前代码存在明确跳过分支时记录,不以 Requirement 文本反推。 |
| 审计/Integration 归档、清理和调查7 条) | 已注册 `constants.TaskTypeAuditDailyArchive``TaskTypeIntegrationDailyArchive``TaskTypeAuditDailyRetention``cmd/worker/main.go` 的注册/总开关;`internal/task/{integration_archive,audit_monthly_retention}.go` | `internal/application/auditarchive/{service,integration,retention}.go``internal/infrastructure/audit/retention.go``pkg/constants/audit_archive.go``cmd/worker/main.go``NewRetentionLogger``internal/query/{audit,retention}` 的在线边界。 |
| 套餐历史层级5 条) | `GET /api/admin/assets/{identifier}/packages``GET /api/c/v1/asset/package-history`;后台 Service、H5 Handler 均调用 `internal/query/asset/package_history.go` | `internal/model/package.go``master_usage_id`,历史 DTO 的 children/关系状态字段Query 的整组筛选、排序、物理存在性核对和分页函数。 |
| 套餐队列激活3 条) | `internal/polling/package_activation_handler.go` 的定时检查、孤儿恢复、任务投递和 `HandlePackageQueueActivation`;已注册 `constants.TaskTypePackageQueueActivation` | `tb_package_usage` 的状态条件更新、`master_usage_id`、优先级排序、Asynq `MaxRetry(3)` 与 Redis 锁冲突返回错误。 |
| 轮询总并发与背压1 条) | 五个 polling Handler 均调用 `PollingBase.acquireConcurrency`;调度器 `processOneShard` 背压跳过 | `internal/task/polling_base.go` 的双计数 Lua、释放/重入队;`pkg/config/config.go` 的范围校验;`pkg/constants/{redis,polling}.go`。 |
| 停复机实名策略1 条) | 自动、设备、手动停复机路径经 `internal/service/iot_card/stop_resume_service.go`;策略管理路由保持现有入口 | `isRealnameOK` 的行业卡、已实名、独立卡和设备策略分支;`model.IotCard/Device.RealnamePolicy` 与现有套餐/流量判断。 |
| 奇成套餐迁移5 条) | 离线迁移入口和配置加载:`scripts/migration/migrate_runtime.py``lib/mapping_loader.py``lib/sql_builder.py` | `package_lifecycle_overrides``tbl_card_life``multiple_active_packages`Step2 INSERT 的 `expiry_base_snapshot``calendar_type_snapshot``duration_months_snapshot``duration_days_snapshot`。 |
| 零金额退款3 条) | `POST /api/admin/refunds/{id}/approve` 与企业微信审批共用的退款决策路径;`internal/service/refund/{approval_decision,service}.go` | `validateApprovedRefundAmount` 拒绝负数;事务更新退款/订单;`refundWalletPayment``amount == 0` 直接返回,不写钱包回款。 |
| 店铺批量导入5 条) | 离线 CLI `scripts/migration/import_shops.py``lib/shop_import.py``lib/shop_sql.py` | CSV/配置预检、SQL 事务起始守卫、店铺/账号/角色/双钱包/审计 SQL、bcrypt 哈希、同批次错误/结果/摘要产物;运行说明明确不连接目标 PostgreSQL。 |
每个 `verification` 对象保留一条可直接执行的本地只读命令及其确定性源码文字命中;没有稳定文字命中的调用链,使用可重跑的只读脚本/命令输出。不得杜撰隔离库、生产库、外部渠道或已删除临时 smoke 的结果。
### 3. 两个 JSON 的最小同步策略
1. `requirement-evidence.json`:仅追加上述 36 个唯一键各一行;不改动现有 53 行,不新增过期键。
2. `entry-capability-requirement-matrix.json`:仅把上述 36 个键链接到现有、已注册的 HTTP 或 async 行。HTTP 条目保持与 Specs 提取路由的集合相同async 条目保持与 Worker 注册常量集合相同。
3. 对无 HTTP/async 入口的两类离线流程追加 `cli` 行,入口分别为 `scripts/migration/migrate_runtime.py``scripts/migration/import_shops.py`;现有健康脚本只比较 `http``async` 的入口集合,`cli` 行仅承担这五条离线 Requirement 的可追溯链接,不改变该比较。不得把离线流程伪挂到 HTTP 或 Asynq 入口。
4. 写入前用集合差验证恰为 36写入后验证 evidence 键集和 matrix 链接集均等于主 Specs 键集,并确认 HTTP/async 入口集合未漂移。
### 4. 证据选择优先级
当前可达实现与可复跑只读输出优先;`migrations/`、脚本和显式配置是存储/离线事实;已归档 Change 只用于发现来源和复核历史验证边界,不能单独作为当前实现证据。无证明项保留为缺口并停止,不以相近功能、相同命名或 Spec 场景推断。
## Risks / Trade-offs
- `polling-load-control` 和低写入类 Requirement 跨五个 Worker Handler只引用共享基类无法证明每个入口均接入必须在最终写入前逐 Handler 核对获取、释放和令牌不足重入队。
- 离线迁移与店铺导入没有 HTTP/async 注册入口。将其错误塞入现有入口会使矩阵语义失真;是否允许 `cli` 类型是唯一可能需要确认的矩阵契约缺口。
- 历史 Change 中有未完成验证任务。它们不能写成已验证事实;证据只能引用仍在仓库中的源码、脚本、迁移、配置和可重跑只读检查。
- JSON 人工编辑容易造成重复键或遗漏链接;实现阶段必须在写入前后解析并做三集合精确比较,再运行 `./scripts/context-health.sh`

View File

@@ -0,0 +1,26 @@
## Why
`./scripts/context-health.sh` 已将主 Specs 的 Requirement 集合与 `requirement-evidence.json` 的集合设为严格双向约束。当前 89 条 Requirement 中有 36 条没有证据记录,且入口矩阵也没有链接这 36 条,导致上下文健康检查无法通过。需要仅补齐已存在实现的可追溯证据,不改变任何业务契约或运行行为。
## What Changes
- 对 36 条缺失的主 Spec Requirement 补充 `docs/verification/context-reset/requirement-evidence.json` 证据行;每行只记录经源码、迁移、配置或既有可执行验证直接证明的入口、调用链、状态/存储事实和验证输出。
-`docs/verification/context-reset/entry-capability-requirement-matrix.json` 为相同 36 条 Requirement 建立最小入口链接;复用现有 HTTP 或已注册异步入口,离线迁移/导入流程仅使用不参与 HTTP/异步覆盖集合比较的已证实离线入口记录。
- 保持证据集合、主 Specs Requirement 集合和入口矩阵 Requirement 链接集合一致;不添加过期条目。
- 不修改主 Specs、业务代码、迁移、测试体系、运行配置或 `scripts/context-health.sh`
## Capabilities
### New Capabilities
- 无。
### Modified Capabilities
- `system-operations`: 为既有上下文健康检查的 Requirement 证据覆盖门禁补充可观察的基线维护契约;不改变 API、Worker、数据库或运行配置行为。
## Impact
- Affected files: `docs/verification/context-reset/requirement-evidence.json``docs/verification/context-reset/entry-capability-requirement-matrix.json`;本 Change 的规划工件。
- Affected validation: `./scripts/context-health.sh` 的 Requirement 证据链与入口矩阵双向覆盖检查。
- No production impact: 不执行数据库写入、迁移、外部渠道调用或运行配置调整。

View File

@@ -0,0 +1,13 @@
## ADDED Requirements
### Requirement: 上下文健康检查证据链完整
系统 SHALL 将当前主 Specs 的每一条 Requirement 与一条可追溯证据记录及至少一个已证实入口关联。证据记录与入口关联的 Requirement 集合 MUST 分别和主 Specs Requirement 集合双向一致;证据不足时健康检查 MUST 失败,而不得以假定的实现路径补齐。
#### Scenario: 主 Spec Requirement 缺少证据
- **WHEN** 主 Specs 中存在 Requirement但证据记录或入口矩阵未链接该 Requirement
- **THEN** 上下文健康检查失败并阻止将验证基线宣告为健康
#### Scenario: 补齐已实现 Requirement 的证据
- **WHEN** 每条 Requirement 均有经源码、迁移、配置或既有验证输出直接证明的证据记录和入口关联
- **THEN** 上下文健康检查的证据链与入口矩阵覆盖检查通过,且不改变业务接口、数据或运行行为

View File

@@ -0,0 +1,23 @@
## 1. 重新确认基线与证据边界
- [x] 1.1 解析当前主 Specs、`requirement-evidence.json` 和入口矩阵,确认待补 Requirement 键仍恰为 design.md 所列 36 条,且现有 evidence 没有过期键。
- [x] 1.2 对每条待补 Requirement 静态追踪其路由、已注册任务或离线 CLI 至 Handler/Consumer、Application/Service/Query、状态/存储事实;缺少直接证明时停止,不写推测性证据。
- [x] 1.3 为每条证据选择一个可重跑且只读的源码/脚本检查命令与确定性命中输出;不使用生产、外部渠道、数据库写入或已删除临时 smoke 结果。
## 2. 补齐 Requirement 证据记录
- [x] 2.1 在 `docs/verification/context-reset/requirement-evidence.json` 仅追加代理充值、开放接口、外部交互、审计归档、套餐历史、套餐激活与轮询类 Requirement 的直接证据行。
- [x] 2.2 在同一文件仅追加实名策略、奇成迁移、零金额退款和店铺批量导入类 Requirement 的直接证据行,记录脚本/迁移事实而不虚构 HTTP 或 Asynq 调用链。
- [x] 2.3 解析写入后的 JSON确认 89 个唯一 `capability::requirement` 键与主 Specs 双向一致,保留原有 53 行内容且无重复或过期键。
## 3. 同步入口能力矩阵
- [x] 3.1 将 HTTP 可达的待补 Requirement 仅链接到已有路由行,将 Worker 行为仅链接到已有注册 async 行;不得新增、删除或重命名 HTTP/async 入口。
- [x] 3.2 为奇成迁移和店铺导入的五条离线 Requirement 添加两个 `cli` 入口行:`scripts/migration/migrate_runtime.py``scripts/migration/import_shops.py`,不影响 HTTP/async 入口集合比较。
- [x] 3.3 解析矩阵并确认其 Requirement 链接集合与主 Specs 完全相同HTTP 路由集合和 async 注册常量集合保持不变。
## 4. 验证与交付
- [x] 4.1 运行只读集合核对,逐条复核新增 evidence 文件路径、符号和验证命令输出仍存在;对无直接证明的条目撤回并报告缺口。
- [x] 4.2 运行 `./scripts/context-health.sh`,确认 Requirement 证据链、入口矩阵、OpenSpec 校验和既有生成确定性检查全部通过。
- [x] 4.3 核对变更范围仅限两个验证 JSON 与本 Change 工件;不修改主 Specs、业务代码、迁移、测试体系或运行配置。

View File

@@ -87,6 +87,100 @@
- **WHEN** 平台赠送套餐,零售价 9900 分,成本价 0 分
- **THEN** 创建的套餐使用记录 `paid_amount = 0``retail_amount = 9900`
### Requirement: 资产套餐层级投影
系统 SHALL 在后台资产详情套餐列表和 H5 资产套餐历史中,直接依据既有 `master_usage_id` 返回主套餐与关联加油包的层级,不新增关联表。普通主项 SHALL 显式返回 `master_usage_id=null``children` 数组和 `expand_by_default`;存在至少一个可展示关联子项时默认展开,否则不展开。子项 SHALL 保留自身使用记录 ID、原主记录 ID、状态、购买创建时间、生效时间及该入口既有历史字段。正常项 SHALL 省略关系异常字段,所有叶子 SHALL 返回 `children=[]``expand_by_default=false`。主套餐或加油包失效、过期、用尽、退款均 MUST NOT 拆散既有关系或改写其生命周期事实。
#### Scenario: 多个主套餐分别关联子项
- **WHEN** 同一可见资产范围内有多个主套餐,各自关联不同加油包
- **THEN** 每个加油包仅出现在其原主套餐的 `children` 中,不在顶层重复出现;有子项的主套餐默认展开
#### Scenario: 无子项的主套餐
- **WHEN** 某主套餐没有可展示关联加油包
- **THEN** 该主项返回 `master_usage_id=null``children=[]``expand_by_default=false`,不返回关系异常字段
#### Scenario: 已失效历史关系
- **WHEN** 主套餐或关联加油包已失效、过期、用尽或退款,但使用记录仍处于本入口可展示范围
- **THEN** 响应保留原主子关系及各自原状态,不因历史状态将子项拆为顶层记录
### Requirement: 套餐历史整组筛选与分页
系统 SHALL 将一个主套餐及其全部可展示子项作为一组,每组占一个分页名额;主记录物理缺失的每条异常独立项也各占一个名额。`total` SHALL 表示筛选后、分页前的顶层项数量,子项不另计数且 MUST NOT 跨页。已有 `status` 和套餐类型筛选条件 SHALL 在同一条使用记录上联合匹配;组内任一记录满足全部条件即选中整组,返回其完整可展示父子,不因筛选拆关系。异常独立项仅以自身字段匹配。未传筛选条件时 SHALL 返回范围内全部组及异常独立项。
#### Scenario: 子项数量超过页大小
- **WHEN** 一个主套餐含三个子项,另有一个无子项主套餐,以 `page_size=1` 查询且无筛选
- **THEN** `total=2`;包含三个子项的组在同一页完整返回,子项不消耗额外分页名额,另一页只返回另一个主项
#### Scenario: 状态或类型仅命中子项
- **WHEN** 主项不满足筛选条件,但某个子项满足该请求的全部筛选条件
- **THEN** 该组被选中,返回主项及其全部可展示子项,包括未命中条件的同组记录;主项不被标记为缺失
#### Scenario: 联合条件不能分摊给不同记录
- **WHEN** 请求同时指定状态和套餐类型,组内只有主项满足状态、只有子项满足类型,且没有任何一条记录同时满足二者
- **THEN** 该组不进入结果,也不计入 `total`
#### Scenario: 异常独立项计数
- **WHEN** 筛选后有一个正常关系组和两条主记录物理缺失的加油包
- **THEN** `total=3`,两条异常项各自占一个分页名额,即使它们指向同一个缺失主记录也不合并
#### Scenario: 无匹配与超出末页
- **WHEN** 没有任何顶层项匹配,或请求页码超出筛选结果末页
- **THEN** `items=[]`;无匹配时 `total=0`,超出末页时仍返回筛选后的真实顶层项总数
### Requirement: 套餐历史稳定排序
系统 SHALL 将顶层项按自身创建时间倒序排列,同时间按 `package_usage_id` 倒序排列。每组子项 SHALL 依次分为三桶:非待生效且有生效时间的记录按生效时间正序;非待生效但生效时间为空的记录按购买创建时间正序;待生效记录按购买创建时间正序并排最后。各桶排序时间相同 SHALL 按 `package_usage_id` 正序。桶归属及排序 MUST NOT 改变记录状态;后台与 H5 对相同输入集合 SHALL 返回相同顺序。
#### Scenario: 混合生效时间与历史状态
- **WHEN** 同组包含有生效时间的已用完记录、无生效时间的已失效记录和待生效记录
- **THEN** 三者依次位于第一、第二、第三桶;已失效记录保持已失效状态,不被重新标记为待生效
#### Scenario: 同时间的确定顺序
- **WHEN** 多个顶层项创建时间相同,或同桶子项排序时间相同
- **THEN** 顶层按使用记录 ID 倒序,子项按使用记录 ID 正序;数据不变时重复请求及相邻页边界保持一致
### Requirement: 物理缺失与不可展示关系区分
系统 SHALL 仅在 `master_usage_id` 指向的主使用记录物理不存在时返回顶层异常独立项,保留原字段及非空主记录 ID并返回 `relationship_status=master_missing``relationship_status_name=关联主套餐缺失``children=[]``expand_by_default=false`。主记录未出现在筛选结果或某一页 MUST NOT 被认定为物理缺失。范围内的子项所指父记录实际存在但处于其他资产、H5 其他世代或已软删除而不可展示时,整个查询 SHALL 返回统一读取错误,不返回部分成功列表,不越界补显、不静默丢项,也不返回物理缺失标识。商品记录缺失 MUST NOT 等同于主使用记录缺失。
#### Scenario: 主套餐物理缺失
- **WHEN** 加油包所引用的主使用记录物理不存在
- **THEN** 返回带既定 `master_missing` 标识及中文名称的异常独立项,保留原主记录 ID不猜测替代主套餐
#### Scenario: 父子原本分处明细分页两侧
- **WHEN** 父子记录都在当前入口可展示范围,但按旧明细分页会落在不同页,或父项不满足展示筛选
- **THEN** 系统仍按真实关系返回同一个完整关系组,不将子项标记为主套餐缺失
#### Scenario: 主使用记录软删除或范围不可见
- **WHEN** 范围内某子项的主使用记录仍物理存在,但已软删除、属于其他资产,或不属于 H5 当前世代
- **THEN** 整个查询返回统一读取错误;不暴露父项内容或具体不可见原因,不静默丢项,不返回 `master_missing`
#### Scenario: 商品缺失但使用关系存在
- **WHEN** 主使用记录及其子项均可展示,但关联套餐商品已软删除或物理缺失
- **THEN** 主子关系保持不变,历史名称沿用既有快照回退规则,不因商品缺失返回关系异常
### Requirement: 套餐历史读取范围与兼容边界
系统 SHALL 保留各入口既有资产解析及授权前置校验后台读取当前解析资产的全部世代H5 通过当前客户资产绑定校验后仅读取资产实体当前世代卡按卡载体、设备按设备载体读取MUST NOT 为补齐关系而合并其他资产或扩张 H5 世代。后台 SHALL 继续只支持已有状态筛选H5 SHALL 继续支持状态和套餐类型筛选H5 类型匹配 SHALL 保留排除已软删除商品的既有口径,不将用于历史展示的软删除商品自动作为类型命中项。
系统 SHALL 保留后台默认页大小 50、最大 100 和 `items/total/page/page_size` 响应字段H5 默认页大小 20、最大 100 和 `items/total/page/size` 响应字段。两端仍接收 `page/page_size`,页码小于 1 时归一为 1页大小小于 1 时使用各自默认值,超过上限时截断。除明确变更的层级、分页和筛选语义外,系统 SHALL 保留各入口既有套餐字段和可见性后台成本价仅平台类账号可见H5 MUST NOT 因共享投影而新增填充原先未填的订单、退款、成本价、零售价或生效条件字段。当前生效套餐及修改套餐接口的响应 MUST NOT 被本次历史层级变更连带改变。
#### Scenario: 两端保留各自世代范围
- **WHEN** 同一资产有多个世代的独立主子组后台有权查看该资产H5 客户具有有效绑定
- **THEN** 后台可返回全部世代内的组H5 只返回资产当前世代内的组,不为对齐两端数量而扩大或缩小范围
#### Scenario: 未通过既有资产授权
- **WHEN** 代理请求其店铺范围外的资产,或 H5 客户请求非其有效绑定资产
- **THEN** 沿用既有拒绝行为,不返回该资产任何主项或子项,也不因补查主记录绕过授权
#### Scenario: 字段可见性与其他接口不变
- **WHEN** 平台、代理和 H5 客户分别读取其可见资产历史,并调用原有当前套餐或修改套餐接口
- **THEN** 历史主子项均遵守各入口既有字段可见性;代理与 H5 不因层级投影获取平台成本价,其他套餐接口保持原响应结构
#### Scenario: 已软删除商品不成为类型筛选命中项
- **WHEN** H5 按套餐类型筛选,组内只有已软删除商品对应的使用记录具有该类型
- **THEN** 该记录不使整组命中;若组内另一条记录满足全部条件而选中该组,已软删除商品对应的可展示使用记录仍随组保留
#### Scenario: 商品批量读取失败的既有差异
- **WHEN** 关联商品批量读取发生数据库错误,而非单纯商品记录不存在
- **THEN** 后台沿用使用记录快照继续投影的既有行为H5 沿用读取失败响应,不因共享关系投影统一为另一入口的行为
## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。

View File

@@ -26,6 +26,18 @@
- **WHEN** 超级管理员请求更新
- **THEN** 系统拒绝更新并保留原数据库事实;可审计失败按当前接缝记录
### Requirement: 上下文健康检查证据链完整
系统 SHALL 将当前主 Specs 的每一条 Requirement 与一条可追溯证据记录及至少一个已证实入口关联。证据记录与入口关联的 Requirement 集合 MUST 分别和主 Specs Requirement 集合双向一致;证据不足时健康检查 MUST 失败,而不得以假定的实现路径补齐。
#### Scenario: 主 Spec Requirement 缺少证据
- **WHEN** 主 Specs 中存在 Requirement但证据记录或入口矩阵未链接该 Requirement
- **THEN** 上下文健康检查失败并阻止将验证基线宣告为健康
#### Scenario: 补齐已实现 Requirement 的证据
- **WHEN** 每条 Requirement 均有经源码、迁移、配置或既有验证输出直接证明的证据记录和入口关联
- **THEN** 上下文健康检查的证据链与入口矩阵覆盖检查通过,且不改变业务接口、数据或运行行为
## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。

View File

@@ -154,6 +154,7 @@ approval:
legacy_refund_manual_enabled: true
legacy_offline_recharge_pay_enabled: true
# 企业微信 Adapter 配置
wecom:
base_url: "https://qyapi.weixin.qq.com"

View File

@@ -19,7 +19,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
PersonalCustomer: app.NewPersonalCustomerHandler(nil, nil),
ClientAuth: app.NewClientAuthHandler(nil, nil),
ClientAsset: app.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil),
ClientWallet: app.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil),
ClientWallet: app.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil),
ClientOrder: app.NewClientOrderHandler(nil, nil),
ClientExchange: app.NewClientExchangeHandler(nil),
ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil, nil),
@@ -68,6 +68,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
AssetLifecycle: admin.NewAssetLifecycleHandler(nil),
AssetWallet: admin.NewAssetWalletHandler(nil),
WechatConfig: admin.NewWechatConfigHandler(nil),
PaymentMerchant: admin.NewPaymentMerchantHandler(nil),
AgentRecharge: admin.NewAgentRechargeHandler(nil, nil),
Refund: admin.NewRefundHandler(nil),
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(nil),