6 Commits

Author SHA1 Message Date
1f2fe4a61a fix(设备实名): 无当前卡时按绑定卡实名回退并初始化单槽当前卡
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 6m26s
- 设备资产解析外层 real_name_status:存在当前使用卡时沿用该卡状态,无任何当前卡时任一有效绑定卡已实名即视为已实名
- 手工绑定与设备导入:设备最大槽位为 1 且有效绑定唯一时,在同一事务内将该绑定初始化为当前使用卡;多卡设备不推断
- 同步 asset-device、personal-customer 主 Spec;归档变更并附存量核对清单(75 条单槽唯一待修复绑定,其中 16 条已实名)
2026-09-18 17:48:17 +08:00
30c6e5bb78 线上的lxp账号被删除了,重新新建了账号
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m51s
2026-09-11 17:47:43 +08:00
3b4afd287e 修复换货迁移后 after_order 实名门槛误判
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m55s
换货迁移只迁移套餐权益不迁移订单,已支付订单仍挂在旧资产上,
导致新资产 HasValidRechargeOrPaidOrder 查不到支付事实,返回 1189。
现增加套餐权益兜底:当前世代存在未退款套餐权益且其关联订单已支付,
即视为满足充值/购买条件。
2026-09-09 17:41:26 +08:00
999acc128e 完成支付购包自动复机变更任务
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m54s
2026-09-09 10:04:23 +08:00
f5f03d1803 修复支付购包后自动复机 2026-09-09 10:01:42 +08:00
3c371ab88a 修复已用完套餐展示
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m51s
2026-09-08 17:19:30 +08:00
234 changed files with 2434 additions and 19757 deletions

View File

@@ -3,7 +3,7 @@ name: 构建并部署到测试环境(无 SSH
on:
push:
branches:
- Iteration/8-11
- main
env:
REGISTRY: registry.boss160.cn
@@ -28,7 +28,7 @@ jobs:
- name: 设置镜像标签
id: tag
run: |
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
echo "tag=latest" >> $GITHUB_OUTPUT
- name: 登录 Docker Registry
run: |
@@ -37,25 +37,28 @@ 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: 部署到测试环境(仅八月迭代分支)
if: github.ref == 'refs/heads/Iteration/8-11'
- name: 部署到测试环境(仅 main 分支)
if: github.ref == 'refs/heads/main'
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,7 +84,6 @@ openspec validate --all
```
启动、隔离数据库重置、smoke 与日志读取见 [`README.md`](README.md)。迁移使用 `scripts/migrate.sh` 和显式 `DB_*` 参数;生产迁移仅按生产运行说明由维护者手工执行。
- 测试验证涉及迁移、Redis、部署或集成 Smoke 时MUST 读取 [`docs/engineering/工程约束.md`](docs/engineering/工程约束.md) 的 ENG-TEST-001维护者指定的测试环境是唯一验证面。
## 渐进披露

32
CONTEXT.md Normal file
View File

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

View File

@@ -32,9 +32,7 @@ RUN GOOS=linux GOARCH=amd64 go build \
./cmd/api
# 下载 golang-migrate 工具(使用 GOPROXY 加速)
# 固定 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
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# ================================
# 阶段 2: 运行阶段

View File

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

View File

@@ -29,9 +29,6 @@ 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.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil)
handlers.WithdrawalQualification = admin.NewWithdrawalQualificationHandler(nil, nil, 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,9 +38,6 @@ func generateAdminDocs(outputPath string) error {
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)
handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil)
handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil)
handlers.WithdrawalQualification = admin.NewWithdrawalQualificationHandler(nil, nil, 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,9 +18,6 @@ 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"
distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
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"
@@ -379,12 +376,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)
// 员工代收款退款冲销与建账共用同一审计 Writer接入点仅在企微退款成功事务内。
refundService.SetEmployeeCollectionRefundOffset(
employeecollectionApp.NewRefundOffsetService(auditWriter),
)
if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil {
appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err))
}
@@ -398,23 +390,8 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
decisionDispatcher := approvalApp.NewDecisionDispatcher(
approvalInfra.NewDecisionDeliveryStore(runtime.db),
map[string]approvalApp.BusinessDecisionHandler{
constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(
runtime.db, walletPosting, runtime.workerResult.Services.RechargeAudit,
employeecollectionApp.NewBillCreationService(auditWriter),
),
constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(runtime.db, walletPosting, runtime.workerResult.Services.RechargeAudit),
constants.ApprovalBusinessTypeRefund: refundService,
constants.ApprovalBusinessTypeEmployeeCollection: employeecollectionApp.NewApprovalDecisionHandler(
runtime.db, auditWriter,
),
constants.ApprovalBusinessTypeAgentDistribution: distributionwithdrawalApp.NewDistributionApprovalHandler(
runtime.db, auditWriter, shopInfra.NewSubordinateCache(runtime.redisClient),
),
constants.ApprovalBusinessTypeWithdrawalQualification: distributionwithdrawalApp.NewQualificationApprovalHandler(
runtime.db, auditWriter,
),
constants.ApprovalBusinessTypeCommissionWithdrawal: distributionwithdrawalApp.NewWithdrawalApprovalHandler(
runtime.db, auditWriter,
),
},
owner,
appLogger,
@@ -471,7 +448,6 @@ 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:${IMAGE_TAG:-latest}
image: registry.boss160.cn/junhong/cmp-fiber-api: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:${IMAGE_TAG:-latest}
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
container_name: junhong-cmp-worker
restart: unless-stopped
environment:

View File

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

View File

@@ -4,27 +4,13 @@
- OwnerIoT Gateway 适配维护人
- 实现:`internal/gateway/`
- 核验日期2026-09-11
- 证据:`internal/gateway/client.go``crypto.go``card_status.go``flow_card.go``device.go``payment_voucher.go`
- 核验日期2026-08-07
- 证据:`internal/gateway/client.go``crypto.go``card_status.go``flow_card.go``device.go`
## 当前实际使用范围
Gateway 是运营商流量卡、实名、停复机、限速和设备信息的统一封装入口。具体路径、请求字段和响应字段以同目录详细协议与 `internal/gateway/*.go` 的实际调用交集为准;文档中出现但代码未调用的接口不视为系统能力。
付款凭证识别(`POST /ai/ocr/extract-payment`,入参 `image_base64`)由 `internal/gateway/payment_voucher.go` 封装,当前唯一调用方是代理线下预存款申请的「交易流水号表单预填」。该能力只消费响应中的 `order_number`(作为交易流水号预填值);`amount``remark``payment_method``payee``payment_time` 不进入本系统响应、不预填、不落库,识别结果不是资金事实。核验证据:`internal/gateway/payment_voucher.go` 的类型定义只对外暴露支付单号,`internal/application/agentrecharge/payment_voucher_ocr.go` 只返回该字段,接口响应 DTO 仅含 `external_transaction_no`
付款凭证识别刻意不走 `doRequest` / `doRequestWithResponse`:前者在 Info 级别打印加密前完整请求体、后者在 Info 级别打印完整原始响应,会把凭证图片内容与识别原始结果写进日志。该能力改用 `Client.doRequestWithoutPayloadLog`,仅记录路径、耗时与结果字节数摘要;既有能力的请求与日志语义保持不变(`internal/gateway/client.go``executeWithRetry``logPayload` 分支)。
### 付款凭证识别的已知限制
- **长号码可能不完整**:对位数较多的转账单号,该接口可能只返回前若干位,实测存在识别值与凭证图片所示号码不一致的情况(位数少于凭证所示)。连续多次识别同一凭证所得长度与内容稳定,属上游侧确定性截断,而非本系统侧裁剪;预填值**必须**由提交人对照凭证人工核对,系统以人工确认值为准。
- **单号缺失即失败**:响应未给出单号时,本系统按识别失败返回明确失败(`CodeGatewayInvalidResp`,中文提示),不返回空值。
- **字段类型会漂移**:响应 `data``amount` 为 JSON 数值而非字符串。本系统只解码 `order_number`,不声明其余字段,故不受类型漂移影响;新增消费字段前必须重新核对上游类型。
- **解析失败不回显原文**:响应解码失败时只返回固定中文提示,不携带底层解析错误,避免第三方库的错误消息把识别原始结果带进日志与错误上下文。
- **单次识别只接受单个附件键**图片由后端读取对象存储后编码Gateway 凭证不下发前端;识别结果不落库、不构成资金事实。
本条限制的核验方式(可复现、不依赖样本取值):对同一图片凭证**连续三次**调用该识别接口,比较三次返回值的**位数与内容是否一致**——一致说明是上游确定性行为而非随机抖动;再将该位数与凭证图片所示号码的位数(用等长掩码计数,只比位数)对照,得出是否缺位。判定责任方时看本系统的解码路径 `internal/gateway/payment_voucher.go`:它只对返回值做 `strings.TrimSpace`,无截断、无按长度裁剪、无正则截取,因此位数差异只能来自上游。识别结果不落库,复核该接口的返回值需重新发起识别调用,不能从业务表反查。
## 配置、认证与报文
配置键为 `gateway.base_url``gateway.app_id``gateway.app_secret``gateway.timeout`。业务参数先包装为 `{"params": ...}`,使用 AppSecret 做 AES-128-ECB 加密;外层请求含 `appId``data``sign``timestamp`,签名使用 MD5。HTTP 方法统一为 POST内容类型为 `application/json;charset=utf-8`。HTTP 200 且 Gateway `code=200` 才算成功,`data` 再按具体能力解码。

View File

@@ -33,7 +33,7 @@
| AUG26-014 | 导出与统一时间筛选 | PRD-08-014、PRD-08-020 |
| AUG26-015 | 报表管理 | PRD-08-016报表原编号重复 |
| AUG26-016 | 优先轮询通道 | PRD-08-019 |
| AUG26-017 | 代理自充收款方式 | PRD-08-021、PRD-08-013预存款审批字段 |
| AUG26-017 | 代理自充收款方式 | PRD-08-021 |
## 1. 已确认的领域语言

View File

@@ -1,114 +0,0 @@
# 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

@@ -199,8 +199,7 @@
"requirements": [
"agent-funds-commission::充值边界",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位",
"agent-funds-commission::线下预存款审批收款方式、其他凭证与交易流水号"
"order-payment-wallet::金额单位"
],
"classification": "behavior"
},
@@ -211,8 +210,7 @@
"requirements": [
"agent-funds-commission::充值边界",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位",
"agent-funds-commission::代理在线充值可用支付方式按允许范围与可用商户池判定"
"order-payment-wallet::金额单位"
],
"classification": "behavior"
},
@@ -223,8 +221,7 @@
"requirements": [
"agent-funds-commission::充值边界",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位",
"agent-funds-commission::线下预存款审批收款方式、其他凭证与交易流水号"
"order-payment-wallet::金额单位"
],
"classification": "behavior"
},
@@ -333,12 +330,7 @@
"capability": "asset-device",
"requirements": [
"asset-device::资产业务状态",
"identity-access::数据范围拒绝",
"package-lifecycle::资产套餐层级投影",
"package-lifecycle::套餐历史整组筛选与分页",
"package-lifecycle::套餐历史稳定排序",
"package-lifecycle::物理缺失与不可展示关系区分",
"package-lifecycle::套餐历史读取范围与兼容边界"
"identity-access::数据范围拒绝"
],
"classification": "behavior"
},
@@ -398,8 +390,7 @@
"capability": "operations-audit",
"requirements": [
"identity-access::数据范围拒绝",
"operations-audit::审计时间线",
"operations-audit::审计调查留存边界连续"
"operations-audit::审计时间线"
],
"classification": "behavior"
},
@@ -479,8 +470,7 @@
"capability": "operations-audit",
"requirements": [
"identity-access::数据范围拒绝",
"operations-audit::审计时间线",
"operations-audit::审计调查留存边界连续"
"operations-audit::审计时间线"
],
"classification": "behavior"
},
@@ -1366,12 +1356,7 @@
"entry": "GET /api/c/v1/asset/package-history",
"capability": "personal-customer",
"requirements": [
"personal-customer::资产归属查询",
"package-lifecycle::资产套餐层级投影",
"package-lifecycle::套餐历史整组筛选与分页",
"package-lifecycle::套餐历史稳定排序",
"package-lifecycle::物理缺失与不可展示关系区分",
"package-lifecycle::套餐历史读取范围与兼容边界"
"personal-customer::资产归属查询"
],
"classification": "behavior"
},
@@ -1529,8 +1514,7 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
"agent-open-api::开放接口认证"
],
"classification": "behavior"
},
@@ -1540,8 +1524,7 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
"agent-open-api::开放接口认证"
],
"classification": "behavior"
},
@@ -1551,8 +1534,7 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
"agent-open-api::开放接口认证"
],
"classification": "behavior"
},
@@ -1562,8 +1544,7 @@
"capability": "agent-open-api",
"requirements": [
"agent-open-api::开放接口数据范围",
"agent-open-api::开放接口认证",
"agent-open-api::代理开放接口查询不触发可靠卡观测"
"agent-open-api::开放接口认证"
],
"classification": "behavior"
},
@@ -1770,10 +1751,7 @@
"agent-funds-commission::充值边界",
"external-integration::富友调用超时兼容行为",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位",
"external-integration::富友主扫统一下单与订单查询",
"merchant-payment-routing::新支付商户快照与历史兼容",
"agent-funds-commission::线下预存款审批收款方式、其他凭证与交易流水号"
"order-payment-wallet::金额单位"
],
"classification": "behavior"
},
@@ -1784,8 +1762,7 @@
"requirements": [
"agent-funds-commission::充值边界",
"identity-access::数据范围拒绝",
"order-payment-wallet::金额单位",
"employee-collection-bill::员工代收款账单来源、金额与建账判据"
"order-payment-wallet::金额单位"
],
"classification": "behavior"
},
@@ -2191,8 +2168,7 @@
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁",
"package-lifecycle::套餐使用记录价格快照",
"employee-collection-bill::员工代收款账单来源、金额与建账判据"
"package-lifecycle::套餐使用记录价格快照"
],
"classification": "behavior"
},
@@ -2328,10 +2304,7 @@
"capability": "order-refund-exchange",
"requirements": [
"identity-access::数据范围拒绝",
"order-refund-exchange::订单、退款与换货状态门禁",
"refund-approval::零金额退款审批通过",
"refund-approval::零金额退款不产生资金回款",
"refund-approval::非法退款金额仍被拒绝"
"order-refund-exchange::订单、退款与换货状态门禁"
],
"classification": "behavior"
},
@@ -2702,8 +2675,7 @@
"capability": "order-payment-wallet",
"requirements": [
"external-integration::富友调用超时兼容行为",
"order-payment-wallet::钱包并发",
"merchant-payment-routing::新支付商户快照与历史兼容"
"order-payment-wallet::钱包并发"
],
"classification": "behavior"
},
@@ -2715,8 +2687,7 @@
"agent-funds-commission::充值边界",
"external-integration::富友调用超时兼容行为",
"order-payment-wallet::金额单位",
"order-payment-wallet::钱包并发",
"merchant-payment-routing::新支付商户快照与历史兼容"
"order-payment-wallet::钱包并发"
],
"classification": "behavior"
},
@@ -2788,8 +2759,7 @@
"entry": "POST /api/callback/wecom/approval/{application_id}",
"capability": "external-integration",
"requirements": [
"external-integration::企微回调与补偿",
"employee-collection-bill::核销申请审批、重提与幂等"
"external-integration::企微回调与补偿"
],
"classification": "behavior"
},
@@ -3243,20 +3213,14 @@
"entry_type": "async",
"entry": "constants.OutboxEventTypeApprovalSubmissionRequested",
"capability": "infrastructure",
"requirements": [
"employee-collection-bill::核销申请审批、重提与幂等"
],
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.OutboxEventTypeApprovalTerminalDecision",
"capability": "infrastructure",
"requirements": [
"employee-collection-bill::员工代收款账单来源、金额与建账判据",
"employee-collection-bill::核销申请审批、重提与幂等",
"employee-collection-bill::收款方式字典、退款联动与可追溯性"
],
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
@@ -3319,9 +3283,14 @@
"entry_type": "async",
"entry": "constants.TaskTypeAuditDailyArchive",
"capability": "infrastructure",
"requirements": [
"operations-audit::审计归档与日留存受控执行"
],
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeAuditMonthlyRetention",
"capability": "infrastructure",
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
@@ -3430,10 +3399,14 @@
"entry_type": "async",
"entry": "constants.TaskTypeIntegrationDailyArchive",
"capability": "infrastructure",
"requirements": [
"external-integration::外部交互日志归档与留存受控执行",
"external-integration::外部交互日志逐日物理留存"
],
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
"entry_type": "async",
"entry": "constants.TaskTypeIntegrationMonthlyFinalize",
"capability": "infrastructure",
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
@@ -3493,11 +3466,7 @@
"entry_type": "async",
"entry": "constants.TaskTypePackageQueueActivation",
"capability": "infrastructure",
"requirements": [
"package-queue-activation::当前主套餐过期后自动激活下一个",
"package-queue-activation::孤儿待生效套餐必须公平恢复",
"package-queue-activation::Asynq 激活结果必须准确且可重试"
],
"requirements": [],
"classification": "route_index_or_infrastructure"
},
{
@@ -3505,12 +3474,7 @@
"entry": "constants.TaskTypePollingCardStatus",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态",
"external-integration::高频轮询外部交互日志保留边界",
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
"polling-operations::手工轮询任务状态"
],
"classification": "behavior"
},
@@ -3519,12 +3483,7 @@
"entry": "constants.TaskTypePollingCarddata",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态",
"external-integration::高频轮询外部交互日志保留边界",
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
"polling-operations::手工轮询任务状态"
],
"classification": "behavior"
},
@@ -3533,9 +3492,7 @@
"entry": "constants.TaskTypePollingPackage",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态",
"polling-load-control::轮询全局并发与背压",
"polling-operations::停复机遵循实际生效实名策略"
"polling-operations::手工轮询任务状态"
],
"classification": "behavior"
},
@@ -3544,8 +3501,7 @@
"entry": "constants.TaskTypePollingProtect",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态",
"polling-load-control::轮询全局并发与背压"
"polling-operations::手工轮询任务状态"
],
"classification": "behavior"
},
@@ -3554,12 +3510,7 @@
"entry": "constants.TaskTypePollingRealname",
"capability": "polling-operations",
"requirements": [
"polling-operations::手工轮询任务状态",
"external-integration::高频轮询外部交互日志保留边界",
"operations-audit::轮询审计事实保留边界",
"polling-load-control::轮询全局并发与背压",
"polling-load-control::正常无变化观测的低写入处理",
"polling-load-control::业务变化与轮询异常仍可追溯"
"polling-operations::手工轮询任务状态"
],
"classification": "behavior"
},
@@ -3569,8 +3520,7 @@
"capability": "external-integration",
"requirements": [
"external-integration::企微回调与补偿",
"external-integration::外部调用重试边界",
"employee-collection-bill::核销申请审批、重提与幂等"
"external-integration::外部调用重试边界"
],
"classification": "behavior"
},
@@ -3579,8 +3529,7 @@
"entry": "constants.TaskTypeWeComApprovalSync",
"capability": "external-integration",
"requirements": [
"external-integration::企微审批状态",
"employee-collection-bill::核销申请审批、重提与幂等"
"external-integration::企微审批状态"
],
"classification": "behavior"
},
@@ -3611,311 +3560,5 @@
"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"
},
{
"entry_type": "cli",
"entry": "scripts/context-health.sh",
"capability": "system-operations",
"requirements": [
"system-operations::上下文健康检查证据链完整"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/payment-merchants",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/payment-merchants",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/payment-merchants/{id}",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/payment-merchants/{id}",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "DELETE /api/admin/payment-merchants/{id}",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/wechat-authorizations",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/wechat-authorizations/current",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户与微信授权配置管理"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/payment-merchant-pools",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/payment-merchant-pools",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/payment-merchant-pools/{id}",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/payment-merchant-pools/{id}",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/payment-merchant-pools/{id}/enable",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/payment-merchant-pools/{id}/disable",
"capability": "merchant-payment-routing",
"requirements": [
"merchant-payment-routing::商户池唯一性与轮询配置"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-payment-methods",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::收款方式字典、退款联动与可追溯性"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/employee-collection-payment-methods",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::收款方式字典、退款联动与可追溯性"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/employee-collection-payment-methods/{id}",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::收款方式字典、退款联动与可追溯性",
"agent-funds-commission::线下收款方式字典对代理充值引用的可见性"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "DELETE /api/admin/employee-collection-payment-methods/{id}",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::收款方式字典、退款联动与可追溯性",
"agent-funds-commission::线下收款方式字典对代理充值引用的可见性"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-bills",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-bills/statistics",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-bills/{id}",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/employee-collection-bills/{id}/close",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/employee-collection-applications",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::外部付款核销申请与分摊",
"employee-collection-bill::核销申请审批、重提与幂等"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-applications",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::外部付款核销申请与分摊",
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/employee-collection-applications/{id}",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::外部付款核销申请与分摊",
"employee-collection-bill::账单余额、状态与关闭"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/employee-collection-applications/{id}",
"capability": "employee-collection-bill",
"requirements": [
"employee-collection-bill::核销申请审批、重提与幂等"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "GET /api/admin/agent-self-recharge-payment-methods",
"capability": "agent-funds-commission",
"requirements": [
"agent-funds-commission::代理在线充值可用支付方式按允许范围与可用商户池判定",
"agent-funds-commission::代理自充允许方式配置可审计"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "PUT /api/admin/agent-self-recharge-payment-methods",
"capability": "agent-funds-commission",
"requirements": [
"agent-funds-commission::代理自充允许方式配置可审计"
],
"classification": "behavior"
},
{
"entry_type": "http",
"entry": "POST /api/admin/agent-recharges/payment-voucher-ocr",
"capability": "agent-funds-commission",
"requirements": [
"agent-funds-commission::付款凭证识别仅作交易流水号预填"
],
"classification": "behavior"
}
]

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,6 @@ import (
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
@@ -21,17 +20,11 @@ type ApprovalDecisionHandler struct {
db *gorm.DB
posting *walletapp.PostingService
audit RechargeAuditWriter
billCreation *employeecollectionapp.BillCreationService
}
// NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。
func NewApprovalDecisionHandler(
db *gorm.DB,
posting *walletapp.PostingService,
audit RechargeAuditWriter,
billCreation *employeecollectionapp.BillCreationService,
) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit, billCreation: billCreation}
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService, audit RechargeAuditWriter) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit}
}
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。
@@ -39,9 +32,6 @@ func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.
if h == nil || h.db == nil || h.posting == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置")
}
if h.billCreation == nil {
return errors.New(errors.CodeInternalError, "员工代收款建账能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效")
}
@@ -109,10 +99,6 @@ func (h *ApprovalDecisionHandler) applyApproved(
if err != nil {
return err
}
// 员工代收款建账:锚点为“平台账号发起的线下充值入账成功”,按来源唯一键 recharge:{id} 幂等。
if _, err := h.billCreation.CreateFromRechargeInTx(ctx, tx, record); err != nil {
return err
}
if record.Status == constants.RechargeStatusCompleted && posting.AlreadyApplied {
return nil
}

View File

@@ -9,7 +9,6 @@ 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"
@@ -82,7 +81,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.PaidAt.IsZero() {
if command.PaymentNo == "" || command.ConfigID == 0 || command.PaidAt.IsZero() {
return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
}
@@ -92,9 +91,6 @@ 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
@@ -118,10 +114,6 @@ 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})
@@ -135,7 +127,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: payment.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
ParentEventID: command.ParentEventID,
}
@@ -194,8 +186,6 @@ 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,
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),

View File

@@ -24,11 +24,6 @@ type CreateOfflineCommand struct {
RechargeNo string
Amount int64
PaymentVoucherKeys []string
OtherVoucherKeys []string
// OfflinePaymentMethodID 是提交人选择的线下收款方式字典项 ID。
OfflinePaymentMethodID uint
// ExternalTransactionNo 是人工确认后的交易流水号,独立于在线渠道第三方交易号。
ExternalTransactionNo string
Remark string
}
@@ -83,20 +78,8 @@ func (s *OfflineCreationService) TriggerHistorical(ctx context.Context, recordID
SubmitterAccountID: record.UserID, SubmitterUserType: account.UserType, ShopID: record.ShopID,
RechargeNo: record.RechargeNo, Amount: record.Amount,
PaymentVoucherKeys: []string(record.PaymentVoucherKey), Remark: record.Remark,
OtherVoucherKeys: []string(record.OtherVoucherKeys),
}
if record.ExternalTransactionNo != nil {
command.ExternalTransactionNo = *record.ExternalTransactionNo
}
// 补发审批使用历史记录已冻结的收款方式快照,不回查当前字典,避免历史材料被字典变更改写。
var frozenCode, frozenName string
if record.OfflinePaymentMethodCode != nil {
frozenCode = *record.OfflinePaymentMethodCode
}
if record.OfflinePaymentMethodName != nil {
frozenName = *record.OfflinePaymentMethodName
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, frozenCode, frozenName)
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
if err != nil {
return nil, err
}
@@ -171,31 +154,20 @@ func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOffl
if err != nil {
return nil, err
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
if err != nil {
return nil, err
}
paymentChannel := constants.RechargeMethodOffline
externalTransactionNo := strings.TrimSpace(command.ExternalTransactionNo)
var record *model.AgentRechargeRecord
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentMethod, err := loadEnabledOfflinePaymentMethod(ctx, tx, command.OfflinePaymentMethodID)
if err != nil {
return err
}
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName, paymentMethod.Code, paymentMethod.Name)
if err != nil {
return err
}
record = &model.AgentRechargeRecord{
record := &model.AgentRechargeRecord{
UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID,
RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount,
PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel,
PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark),
ExternalTransactionNo: &externalTransactionNo,
OfflinePaymentMethodID: &paymentMethod.ID,
OfflinePaymentMethodCode: &paymentMethod.Code,
OfflinePaymentMethodName: &paymentMethod.Name,
OtherVoucherKeys: model.StringJSONBArray(command.OtherVoucherKeys),
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
var approvalStatus int
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败")
}
@@ -246,71 +218,17 @@ func validateCreateOfflineCommand(command CreateOfflineCommand) error {
if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount {
return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
}
if command.OfflinePaymentMethodID == 0 {
return errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 {
return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证")
}
if err := validateRechargeTransactionNo(command.ExternalTransactionNo); err != nil {
return err
for _, key := range command.PaymentVoucherKeys {
if strings.TrimSpace(key) == "" {
return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空")
}
if err := validateVoucherKeys(command.PaymentVoucherKeys, 1, constants.AgentRechargePaymentVoucherMaxCount, "线下充值必须上传 1 至 5 个支付凭证"); err != nil {
return err
}
return validateVoucherKeys(command.OtherVoucherKeys, 0, constants.AgentRechargeOtherVoucherMaxCount, "线下充值其他凭证最多 5 个")
}
// validateRechargeTransactionNo 校验交易流水号必填且不超过长度上限;不参与去重与幂等判定。
func validateRechargeTransactionNo(value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return errors.New(errors.CodeInvalidParam, "线下充值必须填写交易流水号")
}
if len([]rune(trimmed)) > constants.AgentRechargeExternalTransactionNoMaxLength {
return errors.New(errors.CodeInvalidParam, "交易流水号长度超出限制")
}
return nil
}
// validateVoucherKeys 校验凭证对象键数量与内容minCount 为 0 时允许为空。
func validateVoucherKeys(keys []string, minCount, maxCount int, message string) error {
if len(keys) < minCount || len(keys) > maxCount {
return errors.New(errors.CodeInvalidParam, message)
}
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能为空")
}
if len([]rune(trimmed)) > constants.AgentRechargeVoucherKeyMaxLength {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键长度超出限制")
}
if _, exists := seen[trimmed]; exists {
return errors.New(errors.CodeInvalidParam, "线下充值凭证对象键不能重复")
}
seen[trimmed] = struct{}{}
}
return nil
}
// loadEnabledOfflinePaymentMethod 读取启用的线下收款方式字典项;不存在或已停用一律拒绝。
// 仅校验存在性与启停,不做编码或名称的二次改写,快照以字典当前值为准。
func loadEnabledOfflinePaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
if id == 0 {
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
}
var paymentMethod model.EmployeeCollectionPaymentMethod
if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
}
return &paymentMethod, nil
}
func (s *OfflineCreationService) loadHistoricalFacts(
ctx context.Context, record *model.AgentRechargeRecord,
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
@@ -373,7 +291,7 @@ func (s *OfflineCreationService) loadCreationFacts(
return &account, &shop, &wallet, nil
}
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName, paymentMethodCode, paymentMethodName string) ([]byte, []byte, error) {
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": command.SubmitterAccountID, "account_name": submitterName,
"user_type": command.SubmitterUserType,
@@ -391,10 +309,6 @@ func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopN
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
constants.ApprovalFieldSubmitterName: submitterName,
constants.ApprovalFieldOfflinePaymentMethod: paymentMethodName,
constants.ApprovalFieldOfflinePaymentMethodCode: paymentMethodCode,
constants.ApprovalFieldExternalTransactionNo: strings.TrimSpace(command.ExternalTransactionNo),
constants.ApprovalFieldOtherVoucherKey: command.OtherVoucherKeys,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败")

View File

@@ -11,7 +11,6 @@ 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"
@@ -48,27 +47,20 @@ type AvailablePaymentMethodsResult struct {
// OnlineCreationService 创建代理在线扫码充值单。
type OnlineCreationService struct {
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
audit PaymentAuditWriter
policy *OnlinePaymentMethodPolicy
}
// SetPaymentMethodPolicy 注入代理在线自充允许范围策略
func (s *OnlineCreationService) SetPaymentMethodPolicy(policy *OnlinePaymentMethodPolicy) {
s.policy = policy
}
// 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}
// 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}
}
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil || s.policy == nil {
if s == nil || s.db == 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)
@@ -87,19 +79,14 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInternalError, err, "生成在线充值请求指纹失败")
}
// 幂等回放先于允许范围门禁:同一 request_id 的重试属于既有单,不是新单,
// 不因允许范围变更被拒绝;允许范围只拦截会真正新建充值单与支付单的路径。
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
return replay, err
}
if err := s.policy.IsAllowed(ctx, command.PaymentMethod); err != nil {
return nil, err
}
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
account, shop, wallet, config, adapter, err := s.loadCreationFacts(ctx, command)
if err != nil {
return nil, err
}
result, config, adapter, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet)
result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config)
if err != nil {
if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found {
return replay, replayErr
@@ -138,56 +125,26 @@ 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,
}
if s == nil || s.db == nil || s.runtime == nil {
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
if userType != constants.UserTypeAgent {
return result, apperrors.New(apperrors.CodeForbidden, "代理账号可以查询在线支付方式")
}
if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform {
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
}
if s.policy == nil {
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围策略未配置")
}
allowed, err := s.policy.AllowedMethods(ctx)
if err != nil {
return result, err
}
for _, method := range allowed {
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
}
@@ -195,29 +152,40 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
func (s *OnlineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOnlineCommand,
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, 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, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
}
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
return nil, nil, 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, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
return nil, nil, 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, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
}
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
}
return &account, &shop, &wallet, nil
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
}
func (s *OnlineCreationService) createLocalFacts(
@@ -227,39 +195,15 @@ func (s *OnlineCreationService) createLocalFacts(
account *model.Account,
shop *model.Shop,
wallet *model.AgentWallet,
) (*CreateOnlineResult, *model.WechatConfig, OnlinePaymentPort, error) {
config *model.WechatConfig,
) (*CreateOnlineResult, error) {
rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405"))
if err != nil {
return nil, nil, nil, err
return nil, err
}
paymentNo, err := newBusinessNo("PAY", fmt.Sprintf("%d", time.Now().UnixMilli()))
if err != nil {
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)
return nil, err
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
@@ -267,18 +211,20 @@ func (s *OnlineCreationService) createLocalFacts(
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
record = &model.AgentRechargeRecord{
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,
PaymentConfigID: &config.ID, Status: constants.RechargeStatusPending,
RequestID: &requestID, RequestFingerprint: &fingerprint,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
payment = &model.Payment{
payment := &model.Payment{
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: command.PaymentMethod, Amount: command.Amount,
Status: model.PaymentRecordStatusPending, ExpireAt: &expireAt,
PaymentMethod: command.PaymentMethod, MerchantIdentity: paymentMerchantIdentity(command.PaymentMethod, config),
Amount: command.Amount, Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID, ExpireAt: &expireAt,
}
merchantpayment.FreezeRoute(payment, route)
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(record).Error; err != nil {
return err
}
@@ -292,13 +238,9 @@ func (s *OnlineCreationService) createLocalFacts(
})
})
if err != nil {
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) {
return nil, nil, nil, err
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
}
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
}
return &CreateOnlineResult{Recharge: record, Payment: payment}, config, adapter, nil
return &CreateOnlineResult{Recharge: record, Payment: payment}, nil
}
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {

View File

@@ -1,58 +0,0 @@
package agentrecharge
import (
"context"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// OnlinePaymentMethodConfigReader 提供代理在线自充允许范围的严格读取能力。
type OnlinePaymentMethodConfigReader interface {
GetStrict(ctx context.Context, key string) (string, error)
}
// OnlinePaymentMethodPolicy 将受控配置值映射为对外可见的线上支付方式集合。
type OnlinePaymentMethodPolicy struct {
reader OnlinePaymentMethodConfigReader
}
// NewOnlinePaymentMethodPolicy 创建代理在线自充允许范围策略。
func NewOnlinePaymentMethodPolicy(reader OnlinePaymentMethodConfigReader) *OnlinePaymentMethodPolicy {
return &OnlinePaymentMethodPolicy{reader: reader}
}
// AllowedMethods 严格读取允许范围;配置缺失使用注册默认值,非法值失败关闭。
func (p *OnlinePaymentMethodPolicy) AllowedMethods(ctx context.Context) ([]string, error) {
if p == nil || p.reader == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围未配置")
}
value, err := p.reader.GetStrict(ctx, constants.SystemConfigAgentSelfRechargeAllowedMethods)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "读取代理在线充值允许范围失败")
}
switch value {
case constants.AgentSelfRechargeAllowedWechatOnly:
return []string{constants.RechargeMethodWechat}, nil
case constants.AgentSelfRechargeAllowedAlipayOnly:
return []string{constants.RechargeMethodAlipay}, nil
case constants.AgentSelfRechargeAllowedBoth:
return []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay}, nil
default:
return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "代理在线充值允许范围值非法")
}
}
// IsAllowed 判断业务支付方式是否在当前受控允许范围内。
func (p *OnlinePaymentMethodPolicy) IsAllowed(ctx context.Context, method string) error {
methods, err := p.AllowedMethods(ctx)
if err != nil {
return err
}
for _, allowed := range methods {
if allowed == method {
return nil
}
}
return apperrors.New(apperrors.CodeNoPaymentConfig, "当前支付方式不在代理在线充值允许范围内")
}

View File

@@ -1,109 +0,0 @@
package agentrecharge
import (
"context"
"encoding/base64"
"io"
"net/http"
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
// PaymentVoucherObjectStore 提供付款凭证附件的元数据与内容读取能力。
type PaymentVoucherObjectStore interface {
Stat(ctx context.Context, key string) (*storage.ObjectMetadata, error)
Download(ctx context.Context, key string) (io.ReadCloser, error)
}
// PaymentVoucherRecognizer 是付款凭证识别的外部能力接缝,只暴露支付单号。
type PaymentVoucherRecognizer interface {
ExtractPaymentVoucherOrderNumber(ctx context.Context, imageBase64 string) (string, error)
}
// PaymentVoucherRecognitionResult 是识别结果中本系统消费的唯一字段。
type PaymentVoucherRecognitionResult struct {
// ExternalTransactionNo 是识别出的支付单号,仅作交易流水号表单预填值。
ExternalTransactionNo string
}
// PaymentVoucherOCRService 按附件对象键识别付款凭证,只返回交易流水号预填值。
// 识别不创建申请、不写入任何资金事实字段;其余识别字段一律不返回、不落库。
//
// ENG-AUDIT-001 事实决定:识别调用不产生状态变更、不涉及资金与权限,因此
// 不写 Audit Event、Domain Ledger、Integration Log 与 Outbox调用记录由 Access Log 与
// Gateway 客户端的路径级日志承载,识别载荷与原始结果不进入任何一类事实。
type PaymentVoucherOCRService struct {
objects PaymentVoucherObjectStore
recognizer PaymentVoucherRecognizer
}
// NewPaymentVoucherOCRService 创建付款凭证识别用例。
func NewPaymentVoucherOCRService(objects PaymentVoucherObjectStore, recognizer PaymentVoucherRecognizer) *PaymentVoucherOCRService {
return &PaymentVoucherOCRService{objects: objects, recognizer: recognizer}
}
// Recognize 校验附件为图片后调用识别能力,只返回交易流水号预填值。
// 非图片、对象不存在、内容为空或识别失败都返回明确失败,不阻断人工填写。
func (s *PaymentVoucherOCRService) Recognize(ctx context.Context, objectKey string) (*PaymentVoucherRecognitionResult, error) {
if s == nil || s.objects == nil || s.recognizer == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "付款凭证识别能力未配置")
}
key := strings.TrimSpace(objectKey)
if key == "" || len([]rune(key)) > constants.AgentRechargeVoucherKeyMaxLength {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象键无效")
}
metadata, err := s.objects.Stat(ctx, key)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "付款凭证对象不存在或不可读")
}
if metadata == nil || metadata.Size <= 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象内容为空")
}
if metadata.Size > constants.AgentRechargeVoucherMaxBytes {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证图片超过允许大小")
}
reader, err := s.objects.Download(ctx, key)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "读取付款凭证对象失败")
}
defer func() { _ = reader.Close() }()
content, err := io.ReadAll(io.LimitReader(reader, constants.AgentRechargeVoucherMaxBytes+1))
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInvalidParam, err, "读取付款凭证内容失败")
}
if int64(len(content)) > constants.AgentRechargeVoucherMaxBytes {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证图片超过允许大小")
}
if len(content) == 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证对象内容为空")
}
if !isPaymentVoucherImage(metadata.ContentType, content) {
return nil, apperrors.New(apperrors.CodeInvalidParam, "付款凭证必须是图片文件")
}
// base64 编码只存在于本次调用内存中,禁止写入日志、审计或错误信息。
orderNumber, err := s.recognizer.ExtractPaymentVoucherOrderNumber(ctx, base64.StdEncoding.EncodeToString(content))
if err != nil {
return nil, err
}
if strings.TrimSpace(orderNumber) == "" {
return nil, apperrors.New(apperrors.CodeGatewayInvalidResp, "未从付款凭证中识别出交易流水号")
}
return &PaymentVoucherRecognitionResult{ExternalTransactionNo: strings.TrimSpace(orderNumber)}, nil
}
// isPaymentVoucherImage 校验对象声明的类型为图片,并用内容嗅探拦截被改名的非图片文件。
// 嗅探结果为空或 application/octet-stream 表示未知容器(如 webp交由识别服务判定
// 明确识别为其他类型的PDF、压缩包、文本等直接拒绝。
func isPaymentVoucherImage(declaredContentType string, content []byte) bool {
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(declaredContentType)), "image/") {
return false
}
sniffed := strings.ToLower(strings.TrimSpace(http.DetectContentType(content)))
if sniffed == "" || sniffed == "application/octet-stream" || strings.HasPrefix(sniffed, "image/") {
return true
}
return false
}

View File

@@ -6,7 +6,6 @@ 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"
@@ -15,7 +14,6 @@ import (
// RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。
type RecoverOnlinePaymentService struct {
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
@@ -25,13 +23,13 @@ type RecoverOnlinePaymentService struct {
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
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}
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}
}
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
if s == nil || s.db == 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()
@@ -54,13 +52,7 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er
for index := range payments {
payment := &payments[index]
recharge := recharges[payment.OrderID]
config, configErr := s.recoveryConfig(ctx, payment, configs)
if configErr != nil {
if firstErr == nil {
firstErr = configErr
}
continue
}
config := recoveryConfig(payment, configs)
if recharge == nil || config == nil {
if firstErr == nil {
firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置")
@@ -149,7 +141,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].MerchantID == nil && payments[index].PaymentConfigID != nil {
if payments[index].PaymentConfigID != nil {
configIDs = append(configIDs, *payments[index].PaymentConfigID)
}
}
@@ -159,10 +151,8 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
}
var configRows []model.WechatConfig
if len(configIDs) > 0 {
// 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, "批量查询代理充值历史配置失败")
if err := s.db.WithContext(ctx).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))
@@ -176,27 +166,11 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
return recharges, configs, 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)
}
func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig {
if payment.PaymentConfigID == nil {
return nil, nil
return nil
}
return configs[*payment.PaymentConfigID], nil
return configs[*payment.PaymentConfigID]
}
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error {

View File

@@ -1,155 +0,0 @@
// Package distributionwithdrawal 收口代理分销注册、提现资料资格与提现企业微信终审的用例。
// 三者都以审批尝试/资料版本/注册记录主键作为通用审批业务标识,终态消费幂等且可重放。
package distributionwithdrawal
import (
"context"
stderrors "errors"
"strconv"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// VerificationCodeVerifier 是公开扫码注册复用的短信验证码校验接缝。
// 校验成功即消费验证码,同一验证码不可二次使用。
type VerificationCodeVerifier interface {
VerifyCode(ctx context.Context, phone string, code string) error
}
// AuditChange 描述分销注册、提现资格与提现审批事实的实际变化。
// 日志与审计不得记录密码、完整证件号、完整手机号或附件内容。
type AuditChange struct {
// EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。
EventID string
// ActionCode 是已注册的审计动作码。
ActionCode string
// Summary 是给人工阅读的中文摘要。
Summary string
// CorrelationID 是来源业务链路标识。
CorrelationID string
// Registration 是本次动作后的扫码注册记录事实。
Registration *model.AgentDistributionRegistration
// ParentShop 是扫码注册使用的上级店铺。
ParentShop *model.Shop
// Shop 是本次动作所属或引用的店铺。
Shop *model.Shop
// CreatedShop 是注册审批通过时新建的店铺。
// CreatedShop.DistributionCode 是本次为新店铺生成的随机码;
// AppliedDistributionCode 是注册时使用的上级店铺码快照,二者必须区分,不得混用。
CreatedShop *model.Shop
// AppliedDistributionCode 是注册提交时使用的上级店铺分销码快照。
AppliedDistributionCode string
// Qualification 是本次动作后的提现资料资格版本。
Qualification *model.WithdrawalQualification
// Withdrawal 是本次动作后的提现申请事实。
Withdrawal *model.CommissionWithdrawalRequest
// Attempt 是本次动作对应的提现审批尝试记录。
Attempt *model.CommissionWithdrawalRequestAttempt
// Wallet 是本次动作影响的佣金钱包。
Wallet *model.AgentWallet
// Transaction 是本次动作产生的钱包流水。
Transaction *model.AgentWalletTransaction
// BeforeData 与 AfterData 是脱敏前后的字段快照。
BeforeData map[string]any
AfterData map[string]any
// Result 是审计结果,空值按成功处理。
Result string
// ErrorCode 与 ErrorSummary 是失败或拒绝审计的稳定错误信息。
ErrorCode string
ErrorSummary string
}
// AuditWriter 在业务事务内追加统一 Audit Event。
type AuditWriter interface {
WriteDistributionWithdrawal(ctx context.Context, tx *gorm.DB, change AuditChange) error
}
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
func RecordFailure(ctx context.Context, db *gorm.DB, writer AuditWriter, change AuditChange, businessErr error) {
if writer == nil || db == nil || businessErr == nil {
return
}
appErr := changeError(businessErr)
change.Result = constants.AuditResultFailed
switch appErr.Code {
case errors.CodeForbidden, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeConflict,
errors.CodeInvalidStatus, errors.CodeInsufficientBalance, errors.CodeShopLevelExceeded:
change.Result = constants.AuditResultDenied
}
if change.ErrorCode == "" {
change.ErrorCode = strconv.Itoa(appErr.Code)
}
if change.ErrorSummary == "" {
change.ErrorSummary = appErr.Message
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return writer.WriteDistributionWithdrawal(ctx, tx, change)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(
change.ActionCode, "", "", change.CorrelationID, change.ErrorCode, err,
)
}
}
// changeError 归一化底层错误为稳定 AppError避免失败审计泄露底层文本。
func changeError(err error) *errors.AppError {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
return appErr
}
return errors.New(errors.CodeInternalError, "分销注册或提现审批操作失败")
}
// approvalSnapshots 生成通用审批的提交人快照与业务表单快照。
func approvalSnapshots(accountID uint, accountName string, business map[string]any) ([]byte, []byte, error) {
submitter, err := marshalJSON(map[string]any{
"account_id": accountID, "account_name": accountName,
})
if err != nil {
return nil, nil, err
}
request, err := marshalJSON(business)
if err != nil {
return nil, nil, err
}
return submitter, request, nil
}
// marshalJSON 使用 sonic 序列化业务快照,禁止写入密码、完整证件号或附件内容。
func marshalJSON(value any) ([]byte, error) {
payload, err := sonic.Marshal(value)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化审批业务快照失败")
}
return payload, nil
}
// createApprovalInTx 在业务事务内创建通用审批实例并返回引用。
func createApprovalInTx(
ctx context.Context,
tx *gorm.DB,
port approvalapp.Port,
preparation approvalapp.Preparation,
businessType string,
businessID uint,
submitterAccountID uint,
submitterSnapshot []byte,
requestSnapshot []byte,
correlationID string,
) (approvalapp.Reference, error) {
if port == nil {
return approvalapp.Reference{}, errors.New(errors.CodeServiceUnavailable, "审批能力尚未配置")
}
return port.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: businessType, BusinessID: businessID,
SubmitterAccountID: submitterAccountID, SubmitterSnapshot: submitterSnapshot,
RequestSnapshot: requestSnapshot, CorrelationID: correlationID,
})
}

View File

@@ -1,463 +0,0 @@
package distributionwithdrawal
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// QualificationResult 返回已落库的资料版本与审批实例引用。
type QualificationResult struct {
QualificationID uint
Status int
ApprovalInstanceID uint
ApprovalStatus int
}
// QualificationService 受理提现资料资格的提交、替换、作废与停用失效。
// 资格事实按版本不可变保存;替换合同或法人身份证即新增版本并在同一事务内失效旧有效版本。
type QualificationService struct {
db *gorm.DB
approval approvalapp.Port
audit AuditWriter
}
// NewQualificationService 创建提现资料资格用例。
func NewQualificationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *QualificationService {
return &QualificationService{db: db, approval: approval, audit: audit}
}
// Submit 提交或替换本人代理店铺的提现资料资格。
// 已有待审批版本时拒绝;已有效版本在合同或法人身份证未变化时拒绝重复提交。
func (s *QualificationService) Submit(
ctx context.Context,
shopID uint,
input distributiondomain.QualificationInput,
) (*QualificationResult, error) {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
}
if err := ensureOwnAgentShop(ctx, shopID); err != nil {
// 越权提交资格属关键拒绝,必须留痕:以目标店铺为主要资源记录拒绝事实。
RecordFailure(ctx, s.db, s.audit, AuditChange{
// 不手工构造 EventID本条是失败/拒绝事实,同一店铺可被拒绝多次,
// 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。
// 由审计 Writer 生成唯一 evt_<uuid>(与既有 recordRefundFailure 的做法一致)。
ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected,
Summary: "提交提现资料资格被拒绝:越权或非本人店铺",
Shop: failureShopResolved(ctx, s.db, shopID, nil),
}, err)
return nil, err
}
normalized, err := distributiondomain.ValidateQualificationInput(input)
if err != nil {
return nil, err
}
operatorID := middleware.GetUserIDFromContext(ctx)
submitter, err := resolveShopPrimaryAccount(ctx, s.db, shopID)
if err != nil {
return nil, err
}
correlationID := "withdrawal_qualification:" + uuid.NewString()
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeWithdrawalQualification,
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
})
if err != nil {
return nil, err
}
shop, err := loadShop(ctx, s.db, shopID)
if err != nil {
return nil, err
}
version := &model.WithdrawalQualification{
ShopID: shopID, SubjectType: normalized.SubjectType, SubjectCode: normalized.SubjectCode,
LegalPersonIDCard: normalized.LegalPersonIDCard, ContractFileKey: normalized.ContractFileKey,
IDCardFrontFileKey: normalized.IDCardFrontFileKey, IDCardBackFileKey: normalized.IDCardBackFileKey,
BusinessLicenseFileKey: normalized.BusinessLicenseFileKey, ShopFrontFileKey: normalized.ShopFrontFileKey,
InvoiceFileKey: normalized.InvoiceFileKey, InvoiceTitle: normalized.InvoiceTitle,
InvoiceSubjectCode: normalized.InvoiceSubjectCode,
Status: constants.WithdrawalQualificationStatusPending,
Creator: operatorID, Updater: operatorID,
}
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
qualificationApprovalForm(version, shop))
if err != nil {
return nil, err
}
result := &QualificationResult{}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
replaced, err := invalidateReplacedVersion(ctx, tx, shopID, normalized, operatorID)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Create(version).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现资料资格版本失败")
}
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
constants.ApprovalBusinessTypeWithdrawalQualification, version.ID, submitter.ID,
submitterSnapshot, requestSnapshot, correlationID)
if err != nil {
return err
}
if err := attachQualificationInstance(ctx, tx, version, reference.InstanceID); err != nil {
return err
}
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":submit",
ActionCode: constants.AuditActionWithdrawalQualificationSubmitted,
Summary: qualificationSubmitSummary(replaced),
CorrelationID: correlationID, Qualification: version, Shop: shop,
AfterData: qualificationAuditSnapshot(version),
}); err != nil {
return err
}
result.QualificationID = version.ID
result.Status = version.Status
result.ApprovalInstanceID = reference.InstanceID
result.ApprovalStatus = reference.Status
return nil
})
if err != nil {
// 提交在创建资料版本前被拒绝(存在待审批版本或参数非法),此时没有资料版本可作主要资源,
// 以店铺为主要资源记录拒绝事实。
RecordFailure(ctx, s.db, s.audit, AuditChange{
// 不手工构造 EventID本条是失败/拒绝事实,同一店铺可被拒绝多次,
// 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。
// 由审计 Writer 生成唯一 evt_<uuid>(与既有 recordRefundFailure 的做法一致)。
ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected,
Summary: "提交提现资料资格被拒绝", CorrelationID: correlationID,
Shop: shop,
}, err)
return nil, err
}
return result, nil
}
// Void 由超级管理员填写原因后作废有效提现资料资格。
// 原因必填;已失效或非有效版本返回稳定冲突错误。
func (s *QualificationService) Void(ctx context.Context, id uint, reason string) error {
if s == nil || s.db == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
reason = strings.TrimSpace(reason)
if reason == "" {
businessErr := errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因")
// 关键拒绝必须留痕:作废原因必填是权限相关拒绝,按超管作废动作记录拒绝事实。
RecordFailure(ctx, s.db, s.audit, AuditChange{
// 不手工构造 EventID该拒绝与「作废成功」是同一实体的两次不同发生
// 手工 ID 会让随后的成功作废审计被 event_id 唯一约束吞掉,造成审计与事实相反。
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
Summary: "作废提现资料资格被拒绝:未填写原因",
Qualification: &model.WithdrawalQualification{ID: id},
}, businessErr)
return businessErr
}
operatorID := middleware.GetUserIDFromContext(ctx)
var version model.WithdrawalQualification
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&version, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "提现资料资格不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败")
}
if version.Status != constants.WithdrawalQualificationStatusApproved {
return errors.New(errors.CodeConflict, "仅有效提现资料资格可作废")
}
before := qualificationAuditSnapshot(&version)
if err := invalidateVersion(ctx, tx, &version, reason, operatorID); err != nil {
return err
}
shop, err := loadShop(ctx, tx, version.ShopID)
if err != nil {
return err
}
return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":void",
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
Summary: "超级管理员作废提现资料资格", Qualification: &version, Shop: shop,
BeforeData: before, AfterData: qualificationAuditSnapshot(&version),
})
})
if err != nil {
// 失败审计必须可追溯且恰好有一个主要资源:带上目标资料版本(至少含 ID
RecordFailure(ctx, s.db, s.audit, AuditChange{
ActionCode: constants.AuditActionWithdrawalQualificationVoided,
Summary: "作废提现资料资格失败",
Qualification: &model.WithdrawalQualification{ID: id},
}, err)
return err
}
return nil
}
// InvalidateByShopDisable 在店铺停用事务内使该店铺全部有效资格失效。
// 历史版本与审批结果保留;由调用方保证与店铺停用处于同一事务。
func (s *QualificationService) InvalidateByShopDisable(
ctx context.Context,
tx *gorm.DB,
shopID uint,
reason string,
) error {
if tx == nil || shopID == 0 {
return nil
}
var versions []model.WithdrawalQualification
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
Order("id ASC").Find(&versions).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询待失效提现资料资格失败")
}
if len(versions) == 0 {
return nil
}
now := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
Updates(map[string]any{
"status": constants.WithdrawalQualificationStatusInvalidated,
"invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格失败")
}
if result.RowsAffected == 0 {
return nil
}
shop, err := loadShop(ctx, tx, shopID)
if err != nil {
return err
}
first := versions[0]
first.Status = constants.WithdrawalQualificationStatusInvalidated
first.InvalidReason = reason
first.InvalidatedAt = &now
summary := "代理店铺停用,全部有效提现资料资格失效"
if strings.Contains(reason, "删除") {
summary = "代理店铺已删除,全部有效提现资料资格失效"
}
// 不手工构造 EventID同一店铺可先停用失效、后删除失效属同一实体的两次不同发生
// 手工 ID 会让第二次失效审计被吞掉。
return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
ActionCode: constants.AuditActionWithdrawalQualificationInvalidated,
Summary: summary, Qualification: &first, Shop: shop,
AfterData: map[string]any{
"shop_id": shopID, "invalidated_count": result.RowsAffected,
"status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason,
},
})
}
// invalidateReplacedVersion 在替换合同或法人身份证时失效旧有效版本。
// 返回被失效的版本;没有需失效的版本时返回 nil。
func invalidateReplacedVersion(
ctx context.Context,
tx *gorm.DB,
shopID uint,
input distributiondomain.QualificationInput,
operatorID uint,
) (*model.WithdrawalQualification, error) {
var pending int64
if err := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusPending).
Count(&pending).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待审批提现资料资格失败")
}
if pending > 0 {
return nil, errors.New(errors.CodeConflict, "已存在待审批的提现资料资格,请等待审批结果")
}
var current model.WithdrawalQualification
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
First(&current).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定有效提现资料资格失败")
}
if !qualificationRequiresApproval(&current, input) {
return nil, errors.New(errors.CodeConflict, "提现资料资格已生效,合同与法人身份证未变化")
}
if err := invalidateVersion(ctx, tx, &current, "代理替换合同或法人身份证资料", operatorID); err != nil {
return nil, err
}
return &current, nil
}
// qualificationRequiresApproval 判断本次提交是否改变了合同或法人身份证事实。
func qualificationRequiresApproval(
current *model.WithdrawalQualification,
input distributiondomain.QualificationInput,
) bool {
return current.ContractFileKey != input.ContractFileKey ||
current.IDCardFrontFileKey != input.IDCardFrontFileKey ||
current.IDCardBackFileKey != input.IDCardBackFileKey ||
current.SubjectCode != input.SubjectCode ||
current.LegalPersonIDCard != input.LegalPersonIDCard
}
// invalidateVersion 条件更新单个资料版本为已失效。
func invalidateVersion(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
reason string,
operatorID uint,
) error {
now := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("id = ? AND status = ?", version.ID, version.Status).
Updates(map[string]any{
"status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason,
"invalidated_at": now, "invalidated_by": operatorID, "updater": operatorID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格版本失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
}
version.Status = constants.WithdrawalQualificationStatusInvalidated
version.InvalidReason = reason
version.InvalidatedAt = &now
version.InvalidatedBy = operatorID
return nil
}
// attachQualificationInstance 回写资料版本关联的审批实例,写入一次后不可修改。
func attachQualificationInstance(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
instanceID uint,
) error {
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("id = ? AND approval_instance_id IS NULL", version.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现资料资格审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现资料资格审批实例关联已变化")
}
version.ApprovalInstanceID = &instanceID
return nil
}
// qualificationApprovalForm 生成企业微信审批表单业务快照。
// 证件号按脱敏值写入,附件只写入对象存储 Key 引用,不写入附件内容。
func qualificationApprovalForm(version *model.WithdrawalQualification, shop *model.Shop) map[string]any {
shopName := ""
if shop != nil {
shopName = shop.ShopName
}
return map[string]any{
constants.ApprovalFieldQualificationShopID: version.ShopID,
constants.ApprovalFieldQualificationShopName: shopName,
constants.ApprovalFieldQualificationSubjectType: constants.GetWithdrawalQualificationSubjectTypeName(version.SubjectType),
constants.ApprovalFieldQualificationSubjectCodeMasked: distributiondomain.MaskSubjectCode(version.SubjectCode),
constants.ApprovalFieldQualificationLegalPersonMasked: distributiondomain.MaskSubjectCode(version.LegalPersonIDCard),
constants.ApprovalFieldQualificationContractKey: version.ContractFileKey,
constants.ApprovalFieldQualificationIDCardFrontKey: version.IDCardFrontFileKey,
constants.ApprovalFieldQualificationIDCardBackKey: version.IDCardBackFileKey,
constants.ApprovalFieldQualificationBusinessLicenseKey: version.BusinessLicenseFileKey,
constants.ApprovalFieldQualificationShopFrontKey: version.ShopFrontFileKey,
constants.ApprovalFieldQualificationInvoiceKey: version.InvoiceFileKey,
constants.ApprovalFieldQualificationInvoiceTitle: version.InvoiceTitle,
}
}
// qualificationAuditSnapshot 生成资料版本审计快照,证件号按脱敏值记录,不含附件内容。
func qualificationAuditSnapshot(version *model.WithdrawalQualification) map[string]any {
instanceID := uint(0)
if version.ApprovalInstanceID != nil {
instanceID = *version.ApprovalInstanceID
}
return map[string]any{
"id": version.ID, "shop_id": version.ShopID, "subject_type": version.SubjectType,
"subject_code_masked": distributiondomain.MaskSubjectCode(version.SubjectCode),
"status": version.Status, "approval_instance_id": instanceID,
"invalid_reason": version.InvalidReason,
"attachment_count": 3 + boolToInt(version.BusinessLicenseFileKey != "") +
boolToInt(version.ShopFrontFileKey != "") + boolToInt(version.InvoiceFileKey != ""),
}
}
// qualificationSubmitSummary 区分首次提交与替换提交的审计摘要。
func qualificationSubmitSummary(replaced *model.WithdrawalQualification) string {
if replaced != nil {
return "替换合同或法人身份证资料,旧有效提现资料资格已失效"
}
return "提交提现资料资格"
}
// ensureOwnAgentShop 校验当前账号为代理身份且目标即本人店铺。
func ensureOwnAgentShop(ctx context.Context, shopID uint) error {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "仅代理商用户可提交提现资料资格")
}
if shopID == 0 || shopID != middleware.GetShopIDFromContext(ctx) {
return errors.New(errors.CodeForbidden, "仅可为本人店铺提交提现资料资格")
}
return nil
}
// resolveShopPrimaryAccount 解析店铺启用的主账号,作为审批发起主体。
func resolveShopPrimaryAccount(ctx context.Context, db *gorm.DB, shopID uint) (*model.Account, error) {
var account model.Account
if err := db.WithContext(ctx).
Where("shop_id = ? AND status = ? AND is_primary = TRUE", shopID, constants.StatusEnabled).
First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidStatus, "店铺缺少启用的主账号,无法提交审批")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主账号失败")
}
return &account, nil
}
// loadShopOrNil 读取店铺事实;店铺已软删除或不存在时返回 nil供终态收敛使用。
func loadShopOrNil(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop {
shop, err := loadShop(ctx, db, shopID)
if err != nil {
return nil
}
return shop
}
// loadShop 读取店铺事实,未找到返回稳定不存在错误。
func loadShop(ctx context.Context, db *gorm.DB, shopID uint) (*model.Shop, error) {
var shop model.Shop
if err := db.WithContext(ctx).First(&shop, shopID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "店铺不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败")
}
return &shop, nil
}
// boolToInt 将布尔值转换为 0/1用于审计计数。
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}

View File

@@ -1,195 +0,0 @@
package distributionwithdrawal
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// QualificationApprovalHandler 将渠道无关企业微信终态应用到提现资料资格版本。
// 通过才使版本生效;驳回只标记该版本,不影响其他版本已记录的审批结果。
type QualificationApprovalHandler struct {
db *gorm.DB
audit AuditWriter
}
// NewQualificationApprovalHandler 创建提现资料资格审批终态消费者。
func NewQualificationApprovalHandler(db *gorm.DB, audit AuditWriter) *QualificationApprovalHandler {
return &QualificationApprovalHandler{db: db, audit: audit}
}
// Handle 幂等消费标准审批终态。
// 业务标识为资料版本主键;先锁定版本并校验审批实例一致,再以条件更新推进状态。
func (h *QualificationApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "提现资料资格审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeWithdrawalQualification ||
event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "提现资料资格审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
})
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var version model.WithdrawalQualification
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&version, event.BusinessID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "提现资料资格版本不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败")
}
if version.ApprovalInstanceID == nil || *version.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "提现资料资格版本关联的审批实例不一致")
}
if version.Status != constants.WithdrawalQualificationStatusPending {
// 已是终态(含被替换或作废):重复或乱序回调不再改变事实。
return nil
}
// 店铺可能已被软删除:终态必须仍能收敛,不得把「店铺不存在」当成致命错误,
// 否则该版本永久卡在待审批且终态事件永久重投。审计的店铺资源此时允许为空。
shop := loadShopOrNil(ctx, tx, version.ShopID)
before := qualificationAuditSnapshot(&version)
switch event.Decision {
case constants.ApprovalDecisionApproved:
return h.applyApproved(ctx, tx, &version, shop, before, event)
case constants.ApprovalDecisionRejected,
constants.ApprovalDecisionCancelled,
constants.ApprovalDecisionDeleted,
constants.ApprovalDecisionRevokedAfterApproved:
return h.applyRejected(ctx, tx, &version, shop, before, event)
default:
return errors.New(errors.CodeInvalidParam, "不支持的提现资料资格审批终态")
}
})
}
// applyApproved 使资料版本生效。
// 代理已提交替换版本时该版本已被失效,条件更新不再命中,不会覆盖更新版本。
func (h *QualificationApprovalHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
shop *model.Shop,
before map[string]any,
event approvalapp.TerminalDecisionEvent,
) error {
now := time.Now().UTC()
if qualificationShopDisabled(ctx, tx, version.ShopID) {
// 店铺停用或不存在时资格必须失效:若停留在待审批,则「有待审批版本」门禁会让该店铺
// 永远无法获得有效资格(作废仅接受有效版本),因此就地收敛为已失效终态并写审计。
return h.invalidateForDisabledShop(ctx, tx, version, before, event, now)
}
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalQualificationStatusApproved,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "使提现资料资格版本生效失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
}
version.Status = constants.WithdrawalQualificationStatusApproved
version.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":approved",
ActionCode: constants.AuditActionWithdrawalQualificationApproved,
Summary: "企业微信通过提现资料资格,版本已生效",
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
})
}
// invalidateForDisabledShop 在店铺停用或不存在时把待审批资料版本收敛为已失效。
// 与代理停用联动失效语义一致invalidated_by=0 表示系统联动),使该店铺可重新提交资格。
func (h *QualificationApprovalHandler) invalidateForDisabledShop(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
before map[string]any,
event approvalapp.TerminalDecisionEvent,
now time.Time,
) error {
reason := "代理店铺已停用,资格自动失效"
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalQualificationStatusInvalidated,
"invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效停用店铺的提现资料资格失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
}
version.Status = constants.WithdrawalQualificationStatusInvalidated
version.InvalidReason = reason
version.InvalidatedAt = &now
version.InvalidatedBy = 0
version.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":disabled",
ActionCode: constants.AuditActionWithdrawalQualificationInvalidated,
Summary: "企业微信通过时店铺已停用,提现资料资格直接失效",
CorrelationID: event.CorrelationID, Qualification: version,
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
})
}
// qualificationShopDisabled 判断资料版本所属店铺是否已停用或不存在。
func qualificationShopDisabled(ctx context.Context, tx *gorm.DB, shopID uint) bool {
var enabled int64
if err := tx.WithContext(ctx).Model(&model.Shop{}).
Where("id = ? AND status = ?", shopID, constants.ShopStatusEnabled).
Count(&enabled).Error; err != nil {
return true
}
return enabled == 0
}
// applyRejected 标记资料版本已驳回,不影响其他版本已记录的审批结果。
func (h *QualificationApprovalHandler) applyRejected(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
shop *model.Shop,
before map[string]any,
event approvalapp.TerminalDecisionEvent,
) error {
now := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}).
Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalQualificationStatusRejected,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现资料资格版本已驳回失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化")
}
version.Status = constants.WithdrawalQualificationStatusRejected
version.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":rejected",
ActionCode: constants.AuditActionWithdrawalQualificationRejected,
Summary: "企业微信未通过提现资料资格",
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
})
}

View File

@@ -1,240 +0,0 @@
package distributionwithdrawal
import (
"context"
"strconv"
"strings"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RegistrationResult 返回已落库的待审批注册记录与审批实例引用。
type RegistrationResult struct {
RegistrationID uint
Status int
ApprovalInstanceID uint
ApprovalStatus int
}
// RegistrationService 受理公开扫码注册。
// 只创建待审批注册记录与审批实例,不创建店铺、账号、钱包或上下级归属。
type RegistrationService struct {
db *gorm.DB
verifier VerificationCodeVerifier
approval approvalapp.Port
audit AuditWriter
}
// NewRegistrationService 创建公开扫码注册用例。
func NewRegistrationService(
db *gorm.DB,
verifier VerificationCodeVerifier,
approval approvalapp.Port,
audit AuditWriter,
) *RegistrationService {
return &RegistrationService{db: db, verifier: verifier, approval: approval, audit: audit}
}
// Register 创建待审批注册记录。
// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”,且不落库。
// 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。
func (s *RegistrationService) Register(
ctx context.Context,
input distributiondomain.RegistrationInput,
code string,
) (*RegistrationResult, error) {
if s == nil || s.db == nil || s.verifier == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
}
normalized, err := distributiondomain.ValidateRegistrationInput(input)
if err != nil {
return nil, err
}
code = strings.TrimSpace(code)
if code == "" {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
}
var parent *model.Shop
if err := s.db.WithContext(ctx).
Where("distribution_code = ? AND status = ?", normalized.DistributionCode, constants.ShopStatusEnabled).
First(&parent).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
}
// 验证码校验成功即消费;无效或已消费与无效分销码返回同一对外结果。
if err := s.verifier.VerifyCode(ctx, normalized.Phone, code); err != nil {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID)
if err != nil {
return nil, err
}
correlationID := "agent_distribution:registration:" + uuid.NewString()
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeAgentDistribution,
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
})
if err != nil {
return nil, err
}
registration := &model.AgentDistributionRegistration{
DistributionCode: normalized.DistributionCode, ParentShopID: parent.ID,
Phone: normalized.Phone, PasswordHash: string(passwordHash),
ShopName: normalized.ShopName, ShopCode: normalized.ShopCode, Username: normalized.Username,
ContactName: normalized.ContactName, Province: normalized.Province,
City: normalized.City, District: normalized.District, Address: normalized.Address,
Status: constants.AgentDistributionRegistrationStatusPending,
}
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
registrationApprovalForm(normalized, parent))
if err != nil {
return nil, err
}
result := &RegistrationResult{}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(registration).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建待审批注册记录失败")
}
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
constants.ApprovalBusinessTypeAgentDistribution, registration.ID, submitter.ID,
submitterSnapshot, requestSnapshot, correlationID)
if err != nil {
return err
}
if err := attachRegistrationInstance(ctx, tx, registration, reference.InstanceID); err != nil {
return err
}
// 公开注册提交不写审计:该链路在 personal.go 的 Use() 之前注册,不经任何认证中间件,
// 因而没有可信的 actor/sourceAppend 会以「审计操作者或入口不符合动作注册规则」失败)。
// tasks 1.7 只要求分销码生成、注册通过/驳回与资格相关审计,提交动作不在其列,
// 故移除该非必需审计而不是伪造操作者身份。
result.RegistrationID = registration.ID
result.Status = registration.Status
result.ApprovalInstanceID = reference.InstanceID
result.ApprovalStatus = reference.Status
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// resolveRegistrationSubmitter 解析扫码注册的审批发起身份。
// 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体;
// 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。
func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID uint) (*model.Account, error) {
var account model.Account
if err := db.WithContext(ctx).
Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled).
First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败")
}
return &account, nil
}
// registrationApprovalForm 生成企业微信审批表单业务快照。
// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop) map[string]any {
return map[string]any{
constants.ApprovalFieldDistributionCode: distributiondomain.MaskDistributionCode(input.DistributionCode),
constants.ApprovalFieldDistributionParentShopID: parent.ID,
constants.ApprovalFieldDistributionParentShopName: parent.ShopName,
constants.ApprovalFieldDistributionShopName: input.ShopName,
constants.ApprovalFieldDistributionShopCode: input.ShopCode,
constants.ApprovalFieldDistributionUsername: input.Username,
constants.ApprovalFieldDistributionPhoneMasked: distributiondomain.MaskPhone(input.Phone),
constants.ApprovalFieldDistributionContactName: input.ContactName,
constants.ApprovalFieldDistributionRegion: strings.TrimSpace(
input.Province + input.City + input.District + input.Address),
}
}
// attachRegistrationInstance 回写注册记录关联的审批实例,写入一次后不可修改。
func attachRegistrationInstance(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
instanceID uint,
) error {
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
Where("id = ? AND approval_instance_id IS NULL", registration.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联扫码注册审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "扫码注册审批实例关联已变化")
}
registration.ApprovalInstanceID = &instanceID
return nil
}
// registrationAuditSnapshot 生成注册记录审计快照,手机号按脱敏值记录,不含密码哈希。
func registrationAuditSnapshot(registration *model.AgentDistributionRegistration) map[string]any {
instanceID := uint(0)
if registration.ApprovalInstanceID != nil {
instanceID = *registration.ApprovalInstanceID
}
return map[string]any{
"id": registration.ID, "parent_shop_id": registration.ParentShopID,
"distribution_code_masked": distributiondomain.MaskDistributionCode(registration.DistributionCode),
"phone_masked": distributiondomain.MaskPhone(registration.Phone),
"username": registration.Username, "shop_code": registration.ShopCode,
"status": registration.Status, "approval_instance_id": instanceID,
}
}
// uintText 将无符号整数转换为审计标识与键的十进制文本。
func uintText(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
// intText 将整数转换为审计标识与键的十进制文本。
func intText(value int) string {
return strconv.Itoa(value)
}
// composeAuditEventID 拼接审计事件标识,并约束在审计列宽内。
func composeAuditEventID(parts ...string) (string, error) {
eventID := strings.Join(parts, ":")
if len(eventID) > 128 {
return "", errors.New(errors.CodeInternalError, "审计事件标识超出长度限制")
}
return eventID, nil
}
// lockRegistrationForUpdate 以行锁读取注册记录,未找到返回稳定不存在错误。
func lockRegistrationForUpdate(
ctx context.Context,
tx *gorm.DB,
id uint,
) (*model.AgentDistributionRegistration, error) {
var registration model.AgentDistributionRegistration
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&registration, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "扫码注册记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定扫码注册记录失败")
}
return &registration, nil
}

View File

@@ -1,279 +0,0 @@
package distributionwithdrawal
import (
"context"
"time"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SubordinateCacheInvalidator 在审批通过事务提交后清理上级店铺下级集合缓存。
type SubordinateCacheInvalidator interface {
InvalidateSubordinateCache(ctx context.Context, shopID uint)
}
// DistributionApprovalHandler 将渠道无关企业微信终态应用到代理扫码注册记录。
// 通过才在单一事务内创建启用店铺、代理主账号、所需钱包、上级层级与业务员快照;
// 驳回只标记注册记录,不创建任何实体;重复或乱序回调不重复创建账号、层级或钱包。
type DistributionApprovalHandler struct {
db *gorm.DB
audit AuditWriter
cache SubordinateCacheInvalidator
}
// NewDistributionApprovalHandler 创建代理分销注册审批终态消费者。
func NewDistributionApprovalHandler(
db *gorm.DB,
audit AuditWriter,
cache SubordinateCacheInvalidator,
) *DistributionApprovalHandler {
return &DistributionApprovalHandler{db: db, audit: audit, cache: cache}
}
// Handle 幂等消费标准审批终态。
// 业务标识为待审批注册记录主键;先锁定注册记录并校验审批实例一致,再按条件更新推进状态。
func (h *DistributionApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "代理分销注册审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeAgentDistribution ||
event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理分销注册审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
})
parentShopID := uint(0)
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
registration, err := lockRegistrationForUpdate(ctx, tx, event.BusinessID)
if err != nil {
return err
}
if registration.ApprovalInstanceID == nil || *registration.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "扫码注册记录关联的审批实例不一致")
}
if registration.Status != constants.AgentDistributionRegistrationStatusPending {
// 已是终态:重复或乱序回调不再改变事实。
return nil
}
switch event.Decision {
case constants.ApprovalDecisionApproved:
parentShopID = registration.ParentShopID
return h.applyApproved(ctx, tx, registration, event)
case constants.ApprovalDecisionRejected,
constants.ApprovalDecisionCancelled,
constants.ApprovalDecisionDeleted:
return h.applyRejected(ctx, tx, registration, event)
case constants.ApprovalDecisionRevokedAfterApproved:
// 注册记录无已建立的对外资金事实;通过后撤销按驳回处理并保留渠道决策痕迹。
return h.applyRejected(ctx, tx, registration, event)
default:
return errors.New(errors.CodeInvalidParam, "不支持的代理分销注册审批终态")
}
})
if err != nil {
return err
}
if parentShopID != 0 && h.cache != nil {
// 缓存清理必须在事务提交后执行,避免回滚后缓存与库内事实不一致。
h.cache.InvalidateSubordinateCache(ctx, parentShopID)
}
return nil
}
// applyApproved 在同一事务内建立店铺、账号、钱包、层级与业务员快照。
// 上级店铺必须仍然存在且启用;手机号或用户名已被并发注册占用时整体回滚,不留半套实体。
func (h *DistributionApprovalHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
event approvalapp.TerminalDecisionEvent,
) error {
parent, err := loadEnabledParentShop(ctx, tx, registration.ParentShopID)
if err != nil {
return err
}
level := parent.Level + 1
if level > constants.ShopMaxLevel {
return errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
}
role, err := loadEnabledCustomerRole(ctx, tx)
if err != nil {
return err
}
shop := &model.Shop{
ShopName: registration.ShopName, ShopCode: registration.ShopCode,
ParentID: &parent.ID, Level: level,
ContactName: registration.ContactName, Province: registration.Province,
City: registration.City, District: registration.District, Address: registration.Address,
Status: constants.ShopStatusEnabled,
}
shop.BusinessOwnerAccountID = parent.BusinessOwnerAccountID
shop.Creator = registration.ID
shop.Updater = registration.ID
// 新店铺生成自己的分销码:注册记录上的分销码是上级店铺快照,复用会与父店铺同码并命中唯一索引。
if err := shopapp.CreateShopWithDistributionCode(ctx, tx, shop); err != nil {
return err
}
account := &model.Account{
Username: registration.Username, Phone: registration.Phone,
Password: registration.PasswordHash, UserType: constants.UserTypeAgent,
ShopID: &shop.ID, Status: constants.StatusEnabled, IsPrimary: true,
}
account.Creator = registration.ID
account.Updater = registration.ID
if err := tx.WithContext(ctx).Create(account).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建扫码注册代理账号失败")
}
if err := tx.WithContext(ctx).Create(&model.AccountRole{
AccountID: account.ID, RoleID: role.ID, Status: constants.StatusEnabled,
Creator: registration.ID, Updater: registration.ID,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "为扫码注册代理账号分配角色失败")
}
if err := tx.WithContext(ctx).Create(&model.ShopRole{
ShopID: shop.ID, RoleID: role.ID, Status: constants.StatusEnabled,
Creator: registration.ID, Updater: registration.ID,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "设置扫码注册店铺默认角色失败")
}
if err := tx.WithContext(ctx).Create([]*model.AgentWallet{
{
ShopID: shop.ID, WalletType: constants.AgentWalletTypeMain,
CreditEnabled: role.DefaultCreditEnabled, CreditLimit: role.DefaultCreditLimit,
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
},
{
ShopID: shop.ID, WalletType: constants.AgentWalletTypeCommission,
CreditEnabled: false, CreditLimit: 0,
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
},
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "初始化扫码注册店铺钱包失败")
}
if err := markRegistrationApproved(ctx, tx, registration); err != nil {
return err
}
// 建店与业务员归属的访问审计不在本用例职责内:该动作面向后台账号入口,
// 由审批消费任务触发的建店无法提供其要求的操作者/数据范围投影,
// 强行写入会以「账号权限或组织审计操作者不完整」失败并中止事务。
// 新建店铺已作为 CreatedShop 资源记录在本用例的分销审计中。
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "agent-distribution:" + uintText(registration.ID) + ":approved",
ActionCode: constants.AuditActionAgentDistributionRegistrationApproved,
Summary: "企业微信通过扫码注册,已创建店铺与代理账号",
CorrelationID: event.CorrelationID, Registration: registration,
ParentShop: parent, CreatedShop: shop,
AppliedDistributionCode: registration.DistributionCode,
AfterData: registrationAuditSnapshot(registration),
})
}
// applyRejected 只标记注册记录终态,不创建店铺、账号、钱包或层级。
func (h *DistributionApprovalHandler) applyRejected(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
event approvalapp.TerminalDecisionEvent,
) error {
now := time.Now().UTC()
reason := rejectionReason(event.Decision)
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending).
Updates(map[string]any{
"status": constants.AgentDistributionRegistrationStatusRejected,
"reject_reason": reason, "decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记扫码注册记录已驳回失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "扫码注册记录状态已变化")
}
before := registration.Status
registration.Status = constants.AgentDistributionRegistrationStatusRejected
registration.RejectReason = reason
registration.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "agent-distribution:" + uintText(registration.ID) + ":rejected",
ActionCode: constants.AuditActionAgentDistributionRegistrationRejected,
Summary: "企业微信未通过扫码注册,未创建任何实体",
CorrelationID: event.CorrelationID, Registration: registration,
BeforeData: map[string]any{"status": before},
AfterData: registrationAuditSnapshot(registration),
})
}
// markRegistrationApproved 以待审批状态条件更新标记注册记录已通过,重复回调不重复推进。
func markRegistrationApproved(
ctx context.Context,
tx *gorm.DB,
registration *model.AgentDistributionRegistration,
) error {
now := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}).
Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending).
Updates(map[string]any{
"status": constants.AgentDistributionRegistrationStatusApproved,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记扫码注册记录已通过失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "扫码注册记录状态已变化")
}
registration.Status = constants.AgentDistributionRegistrationStatusApproved
registration.DecidedAt = &now
return nil
}
// rejectionReason 把渠道决策映射为可查询的中文驳回原因。
func rejectionReason(decision string) string {
switch decision {
case constants.ApprovalDecisionCancelled:
return "企业微信审批已撤销"
case constants.ApprovalDecisionDeleted:
return "企业微信审批已删除"
case constants.ApprovalDecisionRevokedAfterApproved:
return "企业微信审批通过后撤销"
default:
return "企业微信审批已驳回"
}
}
// loadEnabledParentShop 校验分销码所属店铺仍存在且启用。
func loadEnabledParentShop(ctx context.Context, tx *gorm.DB, shopID uint) (*model.Shop, error) {
var parent model.Shop
if err := tx.WithContext(ctx).First(&parent, shopID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "上级店铺不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺失败")
}
if parent.Status != constants.ShopStatusEnabled {
return nil, errors.New(errors.CodeInvalidStatus, "上级店铺已停用,不允许注册下级")
}
return &parent, nil
}
// loadEnabledCustomerRole 读取启用的客户角色,用于新建代理店铺的默认角色与信用额度。
func loadEnabledCustomerRole(ctx context.Context, tx *gorm.DB) (*model.Role, error) {
var role model.Role
if err := tx.WithContext(ctx).
Where("role_type = ? AND status = ?", constants.RoleTypeCustomer, constants.StatusEnabled).
Order("id ASC").First(&role).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidStatus, "缺少启用的客户角色,无法创建代理店铺")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询启用客户角色失败")
}
return &role, nil
}

View File

@@ -1,830 +0,0 @@
package distributionwithdrawal
import (
"context"
"crypto/rand"
"math/big"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// WithdrawalPolicy 是提现申请使用的当前配置快照,由调用方从提现配置读取。
type WithdrawalPolicy struct {
MinAmount int64
FeeRate int64
DailyWithdrawalLimit int
}
// WithdrawalInput 是提现申请或重提的规范化输入。
type WithdrawalInput struct {
Amount int64
WithdrawalMethod string
AccountName string
AccountNumber string
InvoiceKeys []string
}
// WithdrawalResult 返回已原子保存的提现申请与审批尝试记录。
type WithdrawalResult struct {
RequestID uint
WithdrawalNo string
AttemptID uint
AttemptNo int
Amount int64
Fee int64
FeeRate int64
ActualAmount int64
Status int
ApprovalInstanceID uint
ApprovalStatus int
CreatedAt time.Time
}
// WithdrawalService 创建与重提提现申请。
// 申请、审批尝试记录、审批实例与佣金钱包冻结在同一事务完成;
// 余额不足、资格无效或非本人代理时不创建申请、审批实例或任何冻结。
type WithdrawalService struct {
db *gorm.DB
approval approvalapp.Port
audit AuditWriter
}
// NewWithdrawalService 创建提现申请用例。
func NewWithdrawalService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *WithdrawalService {
return &WithdrawalService{db: db, approval: approval, audit: audit}
}
// Create 为本人代理店铺创建提现申请。
func (s *WithdrawalService) Create(
ctx context.Context,
shopID uint,
policy WithdrawalPolicy,
input WithdrawalInput,
) (*WithdrawalResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
if err := ensureOwnAgentShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
}
return s.submit(ctx, shopID, policy, nil, input)
}
// Resubmit 由本人代理修改金额、收款信息与本次发票后重提已被企业微信驳回的提现申请。
// 事务内先释放旧未结算尝试的冻结,再按新金额冻结;历史快照与审批结果不被覆盖。
func (s *WithdrawalService) Resubmit(
ctx context.Context,
requestID uint,
policy WithdrawalPolicy,
input WithdrawalInput,
) (*WithdrawalResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
if requestID == 0 {
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
}
return s.submit(ctx, 0, policy, &requestID, input)
}
// ensureReady 校验依赖完整,缺失时失败关闭,避免绕过企业微信终审。
func (s *WithdrawalService) ensureReady() error {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "提现申请能力尚未配置")
}
return nil
}
// submit 在同一事务内完成资格校验、钱包加锁冻结、写申请与审批尝试记录、创建审批实例。
func (s *WithdrawalService) submit(
ctx context.Context,
shopID uint,
policy WithdrawalPolicy,
resubmitRequestID *uint,
input WithdrawalInput,
) (*WithdrawalResult, error) {
submitter, err := currentSubmitter(ctx)
if err != nil {
return nil, err
}
if input.Amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "提现金额必须大于 0")
}
if policy.MinAmount > 0 && input.Amount < policy.MinAmount {
return nil, errors.New(errors.CodeInvalidParam, "提现金额低于当前最低提现额度")
}
correlationID := "commission_withdrawal:" + uuid.NewString()
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeCommissionWithdrawal,
SubmitterAccountID: submitter.ID, CorrelationID: correlationID,
})
if err != nil {
return nil, err
}
result := &WithdrawalResult{}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var request *model.CommissionWithdrawalRequest
if resubmitRequestID != nil {
request, err = lockWithdrawalRequest(ctx, tx, *resubmitRequestID)
if err != nil {
return err
}
if request.ShopID != submitter.ShopID {
return errors.New(errors.CodeNotFound, "提现申请不存在")
}
if request.Status != constants.WithdrawalStatusRejected {
return errors.New(errors.CodeConflict, "仅已被驳回的提现申请可重提")
}
if request.ApprovalInstanceID == nil {
return errors.New(errors.CodeConflict, "存量提现申请不支持企业微信重提")
}
// 重提先释放旧未结算尝试的冻结,避免产生第二笔冻结。
if _, err := releaseUnsettledAttemptsForRequest(ctx, tx, request.ID); err != nil {
return err
}
shopID = request.ShopID
}
if err := ensureOwnAgentShopForShopID(ctx, submitter, shopID); err != nil {
return err
}
qualification, err := loadValidQualification(ctx, tx, shopID)
if err != nil {
return err
}
if err := validateWithdrawalInvoice(qualification, input.InvoiceKeys); err != nil {
return err
}
if err := ensureDailyWithdrawalLimit(ctx, tx, shopID, policy.DailyWithdrawalLimit, resubmitRequestID == nil); err != nil {
return err
}
wallet, err := lockCommissionWallet(ctx, tx, shopID)
if err != nil {
return err
}
fee := input.Amount * policy.FeeRate / 10000
actualAmount := input.Amount - fee
if err := freezeCommissionBalance(ctx, tx, wallet, input.Amount); err != nil {
return err
}
accountInfo, err := marshalJSON(map[string]string{
"account_name": input.AccountName, "account_number": input.AccountNumber,
})
if err != nil {
return err
}
invoiceKeys, err := marshalJSON(normalizeInvoiceKeys(input.InvoiceKeys))
if err != nil {
return err
}
if request == nil {
request = &model.CommissionWithdrawalRequest{
WithdrawalNo: generateWithdrawalNo(),
ShopID: shopID,
AgentID: submitter.ID,
ApplicantID: submitter.ID,
Amount: input.Amount,
FeeRate: policy.FeeRate,
Fee: fee,
ActualAmount: actualAmount,
WithdrawalMethod: input.WithdrawalMethod,
AccountInfo: accountInfo,
Status: constants.WithdrawalStatusPending,
}
request.Creator = submitter.ID
request.Updater = submitter.ID
if err := tx.WithContext(ctx).Create(request).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现申请失败")
}
} else {
if err := updateWithdrawalRequestForResubmit(ctx, tx, request, input, policy, fee, actualAmount, accountInfo); err != nil {
return err
}
}
attemptNo, err := nextWithdrawalAttemptNo(ctx, tx, request.ID)
if err != nil {
return err
}
attempt := &model.CommissionWithdrawalRequestAttempt{
RequestID: request.ID, AttemptNo: attemptNo,
Amount: input.Amount, Fee: fee, FeeRate: policy.FeeRate, ActualAmount: actualAmount,
WithdrawalMethod: input.WithdrawalMethod, AccountInfo: accountInfo,
InvoiceKeys: invoiceKeys, SubmittedByAccountID: submitter.ID,
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现审批尝试记录失败")
}
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
withdrawalApprovalForm(request, attempt, shopName(ctx, tx, shopID)))
if err != nil {
return err
}
reference, err := createApprovalInTx(ctx, tx, s.approval, preparation,
constants.ApprovalBusinessTypeCommissionWithdrawal, attempt.ID, submitter.ID,
submitterSnapshot, requestSnapshot, correlationID)
if err != nil {
return err
}
if err := attachWithdrawalAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateWithdrawalLatest(ctx, tx, request, attempt, reference.InstanceID); err != nil {
return err
}
transaction, err := recordWithdrawalFreezeTransaction(ctx, tx, wallet, request, submitter.ID, input.Amount)
if err != nil {
return err
}
eventID, err := composeAuditEventID(
"commission-withdrawal", uintText(request.ID), "attempt", intText(attempt.AttemptNo), "submit")
if err != nil {
return err
}
if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: eventID, ActionCode: constants.AuditActionCommissionWithdrawalAttemptSubmitted,
Summary: withdrawalSubmitSummary(resubmitRequestID != nil), CorrelationID: correlationID,
Withdrawal: request, Attempt: attempt, Wallet: wallet, Transaction: transaction,
AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
}); err != nil {
return err
}
result.RequestID = request.ID
result.WithdrawalNo = request.WithdrawalNo
result.AttemptID = attempt.ID
result.AttemptNo = attempt.AttemptNo
result.Amount = attempt.Amount
result.Fee = attempt.Fee
result.FeeRate = attempt.FeeRate
result.ActualAmount = attempt.ActualAmount
result.Status = request.Status
result.ApprovalInstanceID = reference.InstanceID
result.ApprovalStatus = reference.Status
result.CreatedAt = request.CreatedAt
return nil
})
if err != nil {
// 失败审计必须可追溯带上店铺shopID 在手上),使审计恰好有一个主要资源。
RecordFailure(ctx, s.db, s.audit, AuditChange{
// 不手工构造 EventID同一店铺的提现可被拒绝多次手工 ID 会让后续拒绝被
// event_id 唯一约束吞掉;由审计 Writer 生成唯一 evt_<uuid>。
ActionCode: constants.AuditActionCommissionWithdrawalAttemptRejected,
Summary: "提交提现申请被拒绝", CorrelationID: correlationID,
Shop: failureShopResolved(ctx, s.db, shopID, resubmitRequestID),
}, err)
return nil, err
}
return result, nil
}
// failureShopResolved 为失败审计解析店铺引用:优先用入参 shopID
// Resubmit 场景下 shopID 为空则从提现申请行回查店铺,保证拒绝事实有可追溯的店铺主资源。
func failureShopResolved(ctx context.Context, db *gorm.DB, shopID uint, requestID *uint) *model.Shop {
if shopID == 0 && requestID != nil {
var request model.CommissionWithdrawalRequest
if err := db.WithContext(ctx).Select("id", "shop_id").First(&request, *requestID).Error; err == nil {
shopID = request.ShopID
}
}
return failureShop(ctx, db, shopID)
}
// failureShop 为失败审计解析店铺引用;店铺查询失败时退回仅含 ID 的最小引用,
// 保证拒绝事实仍有可追溯的店铺主资源。
func failureShop(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop {
if shopID == 0 {
return nil
}
if shop := loadShopOrNil(ctx, db, shopID); shop != nil {
return shop
}
// gorm.Model 的 ID 是提升字段,无法在复合字面量中设置,这里显式赋值。
minimal := &model.Shop{}
minimal.ID = shopID
return minimal
}
// withdrawalSubmitter 是发起提现的真实操作者。
type withdrawalSubmitter struct {
ID uint
Username string
ShopID uint
}
// currentSubmitter 从上下文取当前代理账号与其店铺,未认证时拒绝。
func currentSubmitter(ctx context.Context) (withdrawalSubmitter, error) {
accountID := middleware.GetUserIDFromContext(ctx)
if accountID == 0 {
return withdrawalSubmitter{}, errors.New(errors.CodeUnauthorized, "未授权访问")
}
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return withdrawalSubmitter{}, errors.New(errors.CodeForbidden, "代理账号缺少店铺信息")
}
return withdrawalSubmitter{
ID: accountID, Username: middleware.GetUsernameFromContext(ctx), ShopID: shopID,
}, nil
}
// ensureOwnAgentShopForShopID 复核代理身份与店铺归属,越权与不存在返回同一结果。
func ensureOwnAgentShopForShopID(ctx context.Context, submitter withdrawalSubmitter, shopID uint) error {
if shopID == 0 || shopID != submitter.ShopID {
return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现")
}
return nil
}
// loadValidQualification 读取当前有效的提现资料资格;缺失或已失效时拒绝提现申请。
func loadValidQualification(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (*model.WithdrawalQualification, error) {
var qualification model.WithdrawalQualification
err := tx.WithContext(ctx).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
First(&qualification).Error
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询有效提现资料资格失败")
}
var shop model.Shop
if err := tx.WithContext(ctx).Select("id", "status").First(&shop, shopID).Error; err != nil {
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
}
if shop.Status != constants.ShopStatusEnabled {
return nil, errors.New(errors.CodeInvalidStatus, "店铺已停用,提现资料资格已失效")
}
return &qualification, nil
}
// validateWithdrawalInvoice 校验申请级发票仅在企业主体且已登记发票资料时提交。
func validateWithdrawalInvoice(qualification *model.WithdrawalQualification, invoiceKeys []string) error {
keys := normalizeInvoiceKeys(invoiceKeys)
if len(keys) == 0 {
return nil
}
if qualification.SubjectType != constants.WithdrawalQualificationSubjectTypeEnterprise {
return errors.New(errors.CodeInvalidParam, "发票仅企业主体可提交")
}
if qualification.InvoiceSubjectCode == "" || qualification.InvoiceTitle == "" {
return errors.New(errors.CodeInvalidParam, "有效提现资料资格未登记发票资料")
}
return nil
}
// normalizeInvoiceKeys 归一化发票对象键列表,去除空串。
func normalizeInvoiceKeys(keys []string) []string {
result := make([]string, 0, len(keys))
for _, key := range keys {
if trimmed := strings.TrimSpace(key); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// ensureDailyWithdrawalLimit 校验当日提现次数上限;重提不占用新的当日次数。
func ensureDailyWithdrawalLimit(
ctx context.Context,
tx *gorm.DB,
shopID uint,
limit int,
countNewRequest bool,
) error {
if !countNewRequest || limit <= 0 {
return nil
}
today := time.Now().Format("2006-01-02")
var todayCount int64
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("shop_id = ? AND created_at >= ? AND created_at <= ?", shopID, today+" 00:00:00", today+" 23:59:59").
Count(&todayCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询当日提现次数失败")
}
if int(todayCount) >= limit {
return errors.New(errors.CodeInvalidParam, "今日提现次数已达上限")
}
return nil
}
// lockWithdrawalRequest 以行锁读取提现申请,未找到返回稳定不存在错误。
func lockWithdrawalRequest(
ctx context.Context,
tx *gorm.DB,
id uint,
) (*model.CommissionWithdrawalRequest, error) {
var request model.CommissionWithdrawalRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&request, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定提现申请失败")
}
return &request, nil
}
// lockCommissionWallet 以行锁读取店铺佣金钱包。
func lockCommissionWallet(ctx context.Context, tx *gorm.DB, shopID uint) (*model.AgentWallet, error) {
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeCommission).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺佣金钱包失败")
}
return &wallet, nil
}
// freezeCommissionBalance 以条件更新冻结可提现余额,影响行数不为 1 时判定余额不足。
func freezeCommissionBalance(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
amount int64,
) error {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND balance - frozen_balance >= ?",
wallet.ID, constants.AgentWalletTypeCommission, amount).
Updates(map[string]any{
"frozen_balance": gorm.Expr("frozen_balance + ?", amount),
"updated_at": time.Now(),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "冻结可提现余额失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeInsufficientBalance, "可提现余额不足或并发冲突,请稍后重试")
}
wallet.FrozenBalance += amount
return nil
}
// releaseCommissionBalance 以条件更新释放冻结余额并返回释放是否发生。
// 释放金额取尝试记录事实,重复释放不会重复调整余额。
func releaseCommissionBalance(
ctx context.Context,
tx *gorm.DB,
walletID uint,
amount int64,
) (bool, error) {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?",
walletID, constants.AgentWalletTypeCommission, amount).
Updates(map[string]any{
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
"updated_at": time.Now(),
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "释放冻结余额失败")
}
return result.RowsAffected == 1, nil
}
// nextWithdrawalAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。
func nextWithdrawalAttemptNo(ctx context.Context, tx *gorm.DB, requestID uint) (int, error) {
var row struct {
MaxAttemptNo int
}
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
Where("request_id = ?", requestID).Scan(&row).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试序号失败")
}
if row.MaxAttemptNo >= constants.WithdrawalAttemptMaxCount {
return 0, errors.New(errors.CodeConflict, "提现重提次数已达上限,请联系平台处理")
}
return row.MaxAttemptNo + 1, nil
}
// updateWithdrawalRequestForResubmit 以已驳回状态条件更新申请为最新尝试的镜像。
func updateWithdrawalRequestForResubmit(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
input WithdrawalInput,
policy WithdrawalPolicy,
fee int64,
actualAmount int64,
accountInfo []byte,
) error {
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusRejected).
Updates(map[string]any{
"amount": input.Amount, "fee": fee, "fee_rate": policy.FeeRate, "actual_amount": actualAmount,
"withdrawal_method": input.WithdrawalMethod, "account_info": accountInfo,
"status": constants.WithdrawalStatusPending, "processed_at": nil,
"reject_reason": "", "updater": request.ApplicantID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新提现申请重提内容失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请状态已变化,请刷新后重试")
}
request.Amount = input.Amount
request.Fee = fee
request.FeeRate = policy.FeeRate
request.ActualAmount = actualAmount
request.WithdrawalMethod = input.WithdrawalMethod
request.AccountInfo = accountInfo
request.Status = constants.WithdrawalStatusPending
request.ProcessedAt = nil
request.RejectReason = ""
return nil
}
// attachWithdrawalAttemptInstance 回写尝试记录关联的审批实例,写入一次后不可修改。
func attachWithdrawalAttemptInstance(
ctx context.Context,
tx *gorm.DB,
attempt *model.CommissionWithdrawalRequestAttempt,
instanceID uint,
) error {
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
Where("id = ? AND approval_instance_id IS NULL", attempt.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现审批实例关联已变化")
}
attempt.ApprovalInstanceID = &instanceID
return nil
}
// updateWithdrawalLatest 回填申请的最新尝试与审批实例引用,仅用于列表投影。
func updateWithdrawalLatest(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
instanceID uint,
) error {
updates := map[string]any{
"latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID,
}
if request.ApprovalInstanceID == nil {
// 首次接入企业微信审批时记录稳定门禁标识,本地人工终审据此拒绝。
updates["approval_instance_id"] = instanceID
}
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ?", request.ID).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回填提现申请最新审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请最新审批实例回填已变化")
}
request.LatestAttemptID = attempt.ID
request.LatestApprovalInstanceID = instanceID
if request.ApprovalInstanceID == nil {
request.ApprovalInstanceID = &instanceID
}
return nil
}
// recordWithdrawalFreezeTransaction 写入提现冻结钱包流水,金额为负且状态为处理中。
func recordWithdrawalFreezeTransaction(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
request *model.CommissionWithdrawalRequest,
operatorID uint,
amount int64,
) (*model.AgentWalletTransaction, error) {
remark := "提现冻结,单号:" + request.WithdrawalNo
refType := constants.ReferenceTypeWithdrawal
refID := request.ID
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: operatorID,
TransactionType: constants.AgentTransactionTypeWithdrawal,
Amount: -amount,
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - amount,
Status: constants.TransactionStatusProcessing,
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
Creator: operatorID, ShopIDTag: request.ShopID,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现冻结钱包流水失败")
}
return transaction, nil
}
// shopName 读取店铺名称用于审批表单展示,缺失时留空。
func shopName(ctx context.Context, db *gorm.DB, shopID uint) string {
var shop model.Shop
if err := db.WithContext(ctx).Select("id", "shop_name").First(&shop, shopID).Error; err != nil {
return ""
}
return shop.ShopName
}
// withdrawalApprovalForm 生成企业微信审批表单业务快照。
// 收款账号按原值写入供审批人核验,其他敏感内容不写入。
func withdrawalApprovalForm(
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
shopNameValue string,
) map[string]any {
accountName, accountNumber := decodeAccountInfo(attempt.AccountInfo)
return map[string]any{
constants.ApprovalFieldWithdrawalNo: request.WithdrawalNo,
constants.ApprovalFieldWithdrawalAttemptNo: attempt.AttemptNo,
constants.ApprovalFieldWithdrawalShopID: request.ShopID,
constants.ApprovalFieldWithdrawalShopName: shopNameValue,
constants.ApprovalFieldWithdrawalAmount: formatAmountYuan(attempt.Amount),
constants.ApprovalFieldWithdrawalAmountCent: attempt.Amount,
constants.ApprovalFieldWithdrawalFee: formatAmountYuan(attempt.Fee),
constants.ApprovalFieldWithdrawalActualAmount: formatAmountYuan(attempt.ActualAmount),
constants.ApprovalFieldWithdrawalMethod: attempt.WithdrawalMethod,
constants.ApprovalFieldWithdrawalAccountName: accountName,
constants.ApprovalFieldWithdrawalAccountNumber: accountNumber,
constants.ApprovalFieldWithdrawalInvoiceKey: decodeInvoiceKeys(attempt.InvoiceKeys),
}
}
// decodeAccountInfo 解析收款账户信息快照,解析失败时留空。
func decodeAccountInfo(payload []byte) (string, string) {
var info map[string]string
if err := sonic.Unmarshal(payload, &info); err != nil {
return "", ""
}
return info["account_name"], info["account_number"]
}
// decodeInvoiceKeys 解析发票对象键列表,解析失败时返回空列表。
func decodeInvoiceKeys(payload []byte) []string {
var keys []string
if err := sonic.Unmarshal(payload, &keys); err != nil {
return []string{}
}
return keys
}
// withdrawalAuditSnapshot 生成提现审计快照,不含收款账号与发票内容。
func withdrawalAuditSnapshot(
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
wallet *model.AgentWallet,
) map[string]any {
snapshot := map[string]any{
"id": request.ID, "withdrawal_no": request.WithdrawalNo, "shop_id": request.ShopID,
"amount": attempt.Amount, "fee": attempt.Fee, "fee_rate": attempt.FeeRate,
"actual_amount": attempt.ActualAmount, "withdrawal_method": attempt.WithdrawalMethod,
"status": request.Status, "attempt_id": attempt.ID, "attempt_no": attempt.AttemptNo,
"latest_approval_instance_id": request.LatestApprovalInstanceID,
"anomaly_flag": request.AnomalyFlag,
"invoice_count": len(decodeInvoiceKeys(attempt.InvoiceKeys)),
}
if wallet != nil {
snapshot["wallet_id"] = wallet.ID
snapshot["wallet_frozen_balance"] = wallet.FrozenBalance
}
return snapshot
}
// withdrawalSubmitSummary 区分首次提交与重提的审计摘要。
func withdrawalSubmitSummary(resubmit bool) string {
if resubmit {
return "重提佣金提现申请,已释放旧未结算冻结"
}
return "提交佣金提现申请并冻结可提现余额"
}
// ReleaseUnsettledAttemptsInTx 幂等释放指定店铺全部未结算的提现审批尝试冻结。
// 释放金额取 try.amount 事实,释放完成写入 released_at已释放的尝试不会被重复释放。
// 供后续佣金回溯在扣减佣金余额前先释放冻结,返回本次实际释放金额合计。
func (s *WithdrawalService) ReleaseUnsettledAttemptsInTx(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (int64, error) {
if s == nil || s.db == nil || tx == nil || shopID == 0 {
return 0, errors.New(errors.CodeInvalidParam, "提现冻结释放参数无效")
}
return releaseUnsettledAttempts(ctx, tx, shopID)
}
// releaseUnsettledAttemptsForRequest 在重提事务内释放指定申请的全部未结算尝试冻结。
func releaseUnsettledAttemptsForRequest(
ctx context.Context,
tx *gorm.DB,
requestID uint,
) (int64, error) {
return releaseUnsettledForRequests(ctx, tx, []uint{requestID})
}
// releaseUnsettledAttempts 释放指定店铺范围内未结算的提现审批尝试冻结。
// 全局加锁顺序固定为「申请 → 尝试 → 钱包」:本函数先锁申请行,再交由释放原语锁尝试行。
func releaseUnsettledAttempts(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) (int64, error) {
var requestIDs []uint
if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("shop_id = ?", shopID).Order("id ASC").Pluck("id", &requestIDs).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺提现申请失败")
}
return releaseUnsettledForRequests(ctx, tx, requestIDs)
}
// releaseUnsettledForRequests 通过共享释放原语释放尝试冻结并解冻钱包。
// 释放金额一律取尝试记录事实;原语的 released_at 条件更新保证重复调用不重复释放。
func releaseUnsettledForRequests(
ctx context.Context,
tx *gorm.DB,
requestIDs []uint,
) (int64, error) {
if len(requestIDs) == 0 {
return 0, nil
}
requests := make(map[uint]*model.CommissionWithdrawalRequest, len(requestIDs))
for _, requestID := range requestIDs {
request, err := lockWithdrawalRequest(ctx, tx, requestID)
if err != nil {
return 0, err
}
requests[requestID] = request
}
now := time.Now().UTC()
amounts, err := postgres.ReleaseUnsettledForRequestsInTx(ctx, tx, requestIDs, now)
if err != nil {
return 0, err
}
total := int64(0)
for _, requestID := range requestIDs {
amount, exists := amounts[requestID]
if !exists || amount == 0 {
continue
}
request := requests[requestID]
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
if err != nil {
return 0, err
}
ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, amount)
if err != nil {
return 0, err
}
if !ok {
return 0, errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对")
}
total += amount
}
return total, nil
}
// generateWithdrawalNo 生成提现单号格式W + 时间戳 + 随机数。
func generateWithdrawalNo() string {
return "W" + time.Now().Format("20060102150405") + randomDigits(6)
}
// randomDigits 生成指定位数的数字随机串,用于提现单号。
func randomDigits(length int) string {
const digits = "0123456789"
buf := make([]byte, 0, length)
limit := big.NewInt(int64(len(digits)))
for range length {
value, err := rand.Int(rand.Reader, limit)
if err != nil {
return strings.Repeat("0", length)
}
buf = append(buf, digits[value.Int64()])
}
return string(buf)
}
// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。
func formatAmountYuan(amount int64) string {
negative := amount < 0
if negative {
amount = -amount
}
value := distributiondomain.FormatCentYuan(amount)
if negative {
return "-" + value
}
return value
}

View File

@@ -1,365 +0,0 @@
package distributionwithdrawal
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WithdrawalApprovalHandler 将渠道无关企业微信终态应用到提现申请。
// 通过时仅一次从冻结余额扣减并保持 WithdrawalStatusApproved=2同时写入到账时间
// 驳回、撤销与删除仅一次释放本次尝试的冻结余额并记录释放时间;
// 通过后撤销不回滚、不重新冻结、不自动重提,只写入正交异常标记与原因。
type WithdrawalApprovalHandler struct {
db *gorm.DB
audit AuditWriter
}
// NewWithdrawalApprovalHandler 创建佣金提现审批终态消费者。
func NewWithdrawalApprovalHandler(db *gorm.DB, audit AuditWriter) *WithdrawalApprovalHandler {
return &WithdrawalApprovalHandler{db: db, audit: audit}
}
// Handle 幂等消费标准审批终态。
// 业务标识为提现审批尝试记录主键;先锁定尝试记录并校验审批实例一致,再按条件更新推进状态。
func (h *WithdrawalApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "佣金提现审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeCommissionWithdrawal ||
event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "佣金提现审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
})
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 全库统一加锁顺序:申请行 → 尝试行 → 钱包行(钱包永远最后)。
// 因此先用不加锁读取得 request_id再按序加锁避免与退款回扣路径形成死锁环。
var lookup model.CommissionWithdrawalRequestAttempt
if err := tx.WithContext(ctx).Select("id", "request_id").
First(&lookup, event.BusinessID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "提现审批尝试记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试记录失败")
}
request, err := lockWithdrawalRequest(ctx, tx, lookup.RequestID)
if err != nil {
return err
}
var attempt model.CommissionWithdrawalRequestAttempt
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&attempt, event.BusinessID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "提现审批尝试记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现审批尝试记录失败")
}
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "提现审批尝试记录关联的审批实例不一致")
}
if attempt.RequestID != request.ID {
return errors.New(errors.CodeConflict, "提现审批尝试记录归属已变化")
}
if request.LatestAttemptID != attempt.ID {
// 已被更新尝试取代的历史尝试终态不再改变申请事实。
return nil
}
switch event.Decision {
case constants.ApprovalDecisionApproved:
return h.applyApproved(ctx, tx, request, &attempt, event)
case constants.ApprovalDecisionRejected,
constants.ApprovalDecisionCancelled,
constants.ApprovalDecisionDeleted:
return h.applyClosed(ctx, tx, request, &attempt, event)
case constants.ApprovalDecisionRevokedAfterApproved:
return h.applyRevoked(ctx, tx, request, &attempt, event)
default:
return errors.New(errors.CodeInvalidParam, "不支持的提现申请审批终态")
}
})
}
// applyApproved 仅一次从冻结余额扣减,保持已通过状态并写入到账时间。
// 幂等守卫为「申请仍待审核 + paid_at 为空 + 尝试未释放」的条件更新且影响行数为 1。
func (h *WithdrawalApprovalHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
if err != nil {
return err
}
now := time.Now().UTC()
if attempt.ReleasedAt != nil {
// 已结算的尝试不再扣减,避免重复扣款。
return nil
}
if wallet.FrozenBalance < attempt.Amount {
return errors.New(errors.CodeConflict, "冻结余额不足以完成提现扣减,请人工核对")
}
// 通过即视为已到账:先以 released_at IS NULL 条件更新标记本次冻结已结算,保证重复回调不重复扣减。
settled, err := markAttemptReleased(ctx, tx, attempt, now)
if err != nil {
return err
}
if !settled {
return nil
}
// 通过时保持状态 2 并写入到账时间,禁止使用已到账状态值 4。
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ? AND paid_at IS NULL", request.ID, constants.WithdrawalStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalStatusApproved,
"paid_at": now,
"processed_at": now,
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现申请已通过失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请状态已变化")
}
if err := deductFrozenBalance(ctx, tx, wallet, attempt.Amount); err != nil {
return err
}
transaction, err := recordWithdrawalDeductTransaction(ctx, tx, wallet, request, attempt)
if err != nil {
return err
}
attempt.ReleasedAt = &now
before := map[string]any{"status": constants.WithdrawalStatusPending, "paid_at": nil, "frozen_balance": wallet.FrozenBalance + attempt.Amount}
request.Status = constants.WithdrawalStatusApproved
request.PaidAt = &now
request.ProcessedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":approved",
ActionCode: constants.AuditActionCommissionWithdrawalAttemptApproved,
Summary: "企业微信通过佣金提现,已从冻结余额扣减并记录到账时间",
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
Wallet: wallet, Transaction: transaction,
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
})
}
// applyClosed 处理最终驳回、撤销与删除:仅一次释放本次尝试冻结并记录释放时间。
// 幂等守卫为「尝试已结算时间仍为空」的条件更新且影响行数为 1。
func (h *WithdrawalApprovalHandler) applyClosed(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
if attempt.ReleasedAt != nil {
return nil
}
wallet, err := lockCommissionWallet(ctx, tx, request.ShopID)
if err != nil {
return err
}
now := time.Now().UTC()
released, err := markAttemptReleased(ctx, tx, attempt, now)
if err != nil {
return err
}
if !released {
return nil
}
ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, attempt.Amount)
if err != nil {
return err
}
if !ok {
return errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对")
}
reason := rejectionReason(event.Decision)
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalStatusRejected,
"processed_at": now,
"reject_reason": reason,
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现申请已驳回失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请状态已变化")
}
frozenBefore := wallet.FrozenBalance + attempt.Amount
wallet.FrozenBalance = frozenBefore - attempt.Amount
transaction, err := recordWithdrawalReleaseTransaction(ctx, tx, wallet, request, attempt, frozenBefore)
if err != nil {
return err
}
attempt.ReleasedAt = &now
before := map[string]any{"status": constants.WithdrawalStatusPending, "frozen_balance": frozenBefore}
request.Status = constants.WithdrawalStatusRejected
request.ProcessedAt = &now
request.RejectReason = reason
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":closed",
ActionCode: constants.AuditActionCommissionWithdrawalAttemptClosed,
Summary: "企业微信未通过佣金提现,已释放本次尝试冻结余额",
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
Wallet: wallet, Transaction: transaction,
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet),
})
}
// applyRevoked 处理通过后撤销。
// 已通过:不回滚已到账金额、不重新冻结、不自动重提,只写正交异常标记与原因。
// 仍在待审核(渠道乱序投递):按驳回同等处理,释放本次尝试冻结并转驳回状态。
func (h *WithdrawalApprovalHandler) applyRevoked(
ctx context.Context,
tx *gorm.DB,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
if request.Status == constants.WithdrawalStatusPending {
return h.applyClosed(ctx, tx, request, attempt, event)
}
if request.Status != constants.WithdrawalStatusApproved {
// 已驳回等终态不再改变事实。
return nil
}
if request.AnomalyFlag == constants.WithdrawalAnomalyFlagRevokedAfterApproved {
return nil
}
now := time.Now().UTC()
reason := "企业微信通过后撤销:" + rejectionReason(event.Decision)
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ? AND anomaly_flag = ?",
request.ID, constants.WithdrawalStatusApproved, constants.WithdrawalAnomalyFlagNone).
Updates(map[string]any{
"anomaly_flag": constants.WithdrawalAnomalyFlagRevokedAfterApproved,
"anomaly_reason": reason, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入提现异常标记失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请异常标记已变化")
}
before := map[string]any{
"status": request.Status, "anomaly_flag": constants.WithdrawalAnomalyFlagNone,
"paid_at": request.PaidAt, "amount": attempt.Amount,
}
request.AnomalyFlag = constants.WithdrawalAnomalyFlagRevokedAfterApproved
request.AnomalyReason = reason
request.UpdatedAt = now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":anomaly",
ActionCode: constants.AuditActionCommissionWithdrawalAnomalyFlagged,
Summary: "企业微信通过后撤销,已到账金额不回滚、不重新冻结,仅写入异常标记",
CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt,
BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, nil),
})
}
// markAttemptReleased 以未释放条件更新写入尝试释放时间,返回是否本次完成释放。
func markAttemptReleased(
ctx context.Context,
tx *gorm.DB,
attempt *model.CommissionWithdrawalRequestAttempt,
now time.Time,
) (bool, error) {
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}).
Where("id = ? AND released_at IS NULL", attempt.ID).
Update("released_at", now)
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "写入提现尝试释放时间失败")
}
return result.RowsAffected == 1, nil
}
// deductFrozenBalance 以冻结余额充足条件更新同时扣减余额与冻结余额。
func deductFrozenBalance(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, amount int64) error {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND frozen_balance >= ?",
wallet.ID, constants.AgentWalletTypeCommission, amount).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", amount),
"frozen_balance": gorm.Expr("frozen_balance - ?", amount),
"updated_at": time.Now(),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "从冻结余额扣减提现金额失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "冻结余额不足或已被并发处理")
}
wallet.Balance -= amount
wallet.FrozenBalance -= amount
return nil
}
// recordWithdrawalDeductTransaction 写入通过时的钱包流水,余额与冻结余额同时减少。
func recordWithdrawalDeductTransaction(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
) (*model.AgentWalletTransaction, error) {
remark := "企业微信终审通过,提现到账,单号:" + request.WithdrawalNo
refType := constants.ReferenceTypeWithdrawal
refID := request.ID
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: request.ApplicantID,
TransactionType: constants.AgentTransactionTypeWithdrawal,
Amount: -attempt.Amount,
BalanceBefore: wallet.Balance + attempt.Amount, BalanceAfter: wallet.Balance,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
Creator: request.ApplicantID, ShopIDTag: request.ShopID,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现到账钱包流水失败")
}
return transaction, nil
}
// recordWithdrawalReleaseTransaction 写入驳回时的钱包流水,仅冻结余额减少。
func recordWithdrawalReleaseTransaction(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
request *model.CommissionWithdrawalRequest,
attempt *model.CommissionWithdrawalRequestAttempt,
frozenBefore int64,
) (*model.AgentWalletTransaction, error) {
remark := "企业微信未通过,释放提现冻结,单号:" + request.WithdrawalNo
refType := constants.ReferenceTypeWithdrawal
refID := request.ID
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: request.ApplicantID,
TransactionType: constants.AgentTransactionTypeRefund,
Amount: attempt.Amount,
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType, ReferenceID: &refID, Remark: &remark,
Creator: request.ApplicantID, ShopIDTag: request.ShopID,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现释放钱包流水失败")
}
_ = frozenBefore
return transaction, nil
}

View File

@@ -1,671 +0,0 @@
package employeecollection
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ApplicationAllocationCommand 描述核销申请中单张账单的本次分摊。
type ApplicationAllocationCommand struct {
BillID uint
Amount int64
}
// SubmitApplicationCommand 描述创建或重提核销申请的稳定输入。
type SubmitApplicationCommand struct {
PaymentMethodID uint
PaidAmount int64
PayerName string
PaidAt time.Time
ExternalTransactionNo string
PaymentVoucherKeys []string
Remark string
ActingReason string
Allocations []ApplicationAllocationCommand
}
// ApplicationSubmitResult 返回已原子保存的申请、审批尝试记录与分摊。
type ApplicationSubmitResult struct {
Application *model.EmployeeCollectionApplication
Attempt *model.EmployeeCollectionApplicationAttempt
Allocations []*model.EmployeeCollectionApplicationAllocation
Bills []*model.EmployeeCollectionBill
InstanceID uint
InstanceStatus int
}
// ApplicationService 创建与重提核销申请。
// 申请、审批尝试记录、审批实例与账单预占在同一事务完成;任一校验失败都不留下半成品事实。
type ApplicationService struct {
db *gorm.DB
approval approvalapp.Port
audit ApplicationAuditWriter
}
// NewApplicationService 创建核销申请用例。
func NewApplicationService(db *gorm.DB, approval approvalapp.Port, audit ApplicationAuditWriter) *ApplicationService {
return &ApplicationService{db: db, approval: approval, audit: audit}
}
// Create 为本人可见账单创建核销申请;超级管理员可为账单欠款人代办并必须填写代办原因。
func (s *ApplicationService) Create(ctx context.Context, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
caller, err := currentApplicationCaller(ctx)
if err != nil {
return nil, err
}
return s.submit(ctx, caller, 0, command)
}
// Resubmit 修改并重提已驳回的核销申请,新增审批尝试记录与新的企业微信审批实例。
func (s *ApplicationService) Resubmit(ctx context.Context, applicationID uint, command SubmitApplicationCommand) (*ApplicationSubmitResult, error) {
if err := s.ensureReady(); err != nil {
return nil, err
}
caller, err := currentApplicationCaller(ctx)
if err != nil {
return nil, err
}
if applicationID == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
return s.submit(ctx, caller, applicationID, command)
}
// ensureReady 校验用例依赖完整,缺失时失败关闭,避免绕过企业微信终审。
func (s *ApplicationService) ensureReady() error {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "核销申请能力尚未配置")
}
return nil
}
// applicationCaller 是发起核销申请的真实操作者。
type applicationCaller struct {
AccountID uint
AccountName string
IsAdmin bool
}
// currentApplicationCaller 从上下文取当前操作者,未认证时拒绝。
func currentApplicationCaller(ctx context.Context) (applicationCaller, error) {
accountID := middleware.GetUserIDFromContext(ctx)
if accountID == 0 {
return applicationCaller{}, errors.New(errors.CodeUnauthorized)
}
return applicationCaller{
AccountID: accountID,
AccountName: middleware.GetUsernameFromContext(ctx),
IsAdmin: middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin,
}, nil
}
// submit 在同一事务内完成校验、加锁、写申请、写审批尝试记录、创建审批实例与账单预占。
// 加锁次序全仓统一为「申请行 → 账单行ID 升序)」,与审批终态消费者保持一致,避免死锁。
func (s *ApplicationService) submit(
ctx context.Context,
caller applicationCaller,
applicationID uint,
command SubmitApplicationCommand,
) (*ApplicationSubmitResult, error) {
if command.PaymentMethodID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "核销申请必须选择线下收款方式")
}
allocationCommands, err := sortedAllocationCommands(command.Allocations)
if err != nil {
return nil, err
}
correlationID := "employee_collection:application:" + uuid.NewString()
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypeEmployeeCollection,
SubmitterAccountID: caller.AccountID, CorrelationID: correlationID,
})
if err != nil {
return nil, err
}
var result *ApplicationSubmitResult
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existing *model.EmployeeCollectionApplication
if applicationID != 0 {
existing, err = lockApplication(ctx, tx, applicationID)
if err != nil {
return err
}
if err := employeecollectiondomain.ValidateApplicationResubmit(existing.Status); err != nil {
return err
}
if existing.ApplicantAccountID != caller.AccountID && !caller.IsAdmin {
return errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
}
bills, err := lockBillsInAscendingOrder(ctx, tx, allocationCommands)
if err != nil {
return err
}
paymentMethod, err := loadEnabledPaymentMethod(ctx, tx, command.PaymentMethodID)
if err != nil {
return err
}
applicantAccountID, err := resolveApplicantAccountID(caller, existing, bills)
if err != nil {
return err
}
acting := applicantAccountID != caller.AccountID
normalized, err := employeecollectiondomain.NormalizeApplicationInput(employeecollectiondomain.ApplicationInput{
PaidAmount: command.PaidAmount, PayerName: command.PayerName, PaidAt: command.PaidAt,
ExternalTransactionNo: command.ExternalTransactionNo, Remark: command.Remark,
ActingReason: command.ActingReason, PaymentVoucherKeys: command.PaymentVoucherKeys,
}, acting)
if err != nil {
return err
}
candidates := make([]employeecollectiondomain.AllocationCandidate, 0, len(allocationCommands))
for _, item := range allocationCommands {
bill := bills[item.BillID]
candidates = append(candidates, employeecollectiondomain.AllocationCandidate{
BillID: item.BillID, BillStatus: bill.Status, Amount: item.Amount,
Available: billAmounts(bill).Available(),
})
}
if err := employeecollectiondomain.ValidateAllocations(normalized.PaidAmount, candidates); err != nil {
return err
}
application, beforeData, err := prepareApplication(
ctx, tx, caller, existing, applicantAccountID, paymentMethod, normalized)
if err != nil {
return err
}
attemptNo, err := nextAttemptNo(ctx, tx, application.ID)
if err != nil {
return err
}
attempt, err := buildAttempt(ctx, tx, application.ID, attemptNo, caller, paymentMethod, normalized, bills, allocationCommands)
if err != nil {
return err
}
submitterSnapshot, requestSnapshot, err := approvalSnapshots(application.ID, caller, paymentMethod, normalized, bills, allocationCommands)
if err != nil {
return err
}
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeEmployeeCollection,
BusinessID: attempt.ID, SubmitterAccountID: caller.AccountID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
CorrelationID: correlationID,
})
if err != nil {
return err
}
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
return err
}
if err := updateApplicationLatest(ctx, tx, application, attempt, reference.InstanceID); err != nil {
return err
}
allocations, reservedBills, err := createAllocations(ctx, tx, application.ID, attempt.ID, caller, bills, allocationCommands)
if err != nil {
return err
}
submitEventID, err := composeAuditEventID(
"employee_collection", "application", uintText(application.ID), "attempt", intText(attempt.AttemptNo), "submit")
if err != nil {
return err
}
if err := s.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: submitEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationSubmitted,
Summary: submitSummary(existing != nil),
Application: application, Attempt: attempt, Allocations: allocations, Bills: reservedBills,
BeforeData: beforeData, AfterData: applicationAuditSnapshot(application),
CorrelationID: correlationID,
}); err != nil {
return err
}
result = &ApplicationSubmitResult{
Application: application, Attempt: attempt, Allocations: allocations,
Bills: reservedBills, InstanceID: reference.InstanceID, InstanceStatus: reference.Status,
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// submitSummary 区分首次提交与重提的审计摘要。
func submitSummary(resubmit bool) string {
if resubmit {
return "重提员工代收款核销申请"
}
return "提交员工代收款核销申请"
}
// sortedAllocationCommands 校验分摊入参基本形态并按账单 ID 升序返回,保证锁序唯一。
func sortedAllocationCommands(items []ApplicationAllocationCommand) ([]ApplicationAllocationCommand, error) {
if len(items) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "核销申请至少需要一个账单分摊")
}
if len(items) > constants.EmployeeCollectionAllocationMaxCount {
return nil, errors.New(errors.CodeInvalidParam, "核销申请账单分摊数量超出限制")
}
seen := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.BillID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "账单分摊缺少目标账单")
}
if item.Amount <= 0 {
return nil, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if _, exists := seen[item.BillID]; exists {
return nil, errors.New(errors.CodeInvalidParam, "同一账单不能重复分摊")
}
seen[item.BillID] = struct{}{}
}
sorted := append([]ApplicationAllocationCommand(nil), items...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].BillID < sorted[j].BillID })
return sorted, nil
}
// lockApplication 以行锁读取核销申请,未找到返回稳定不存在错误。
func lockApplication(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionApplication, error) {
var application model.EmployeeCollectionApplication
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&application, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定核销申请失败")
}
return &application, nil
}
// lockBillsInAscendingOrder 按账单 ID 升序逐行加锁并返回账单事实。
// 单条 `WHERE id IN (...) ORDER BY id FOR UPDATE` 在 PostgreSQL 中先取行加锁再排序,
// 无法保证加锁次序;因此对每个账单各发一条只锁一行的语句,由调用方保证 ID 升序且不重复。
func lockBillsInAscendingOrder(
ctx context.Context,
tx *gorm.DB,
items []ApplicationAllocationCommand,
) (map[uint]*model.EmployeeCollectionBill, error) {
bills := make(map[uint]*model.EmployeeCollectionBill, len(items))
for _, item := range items {
var bill model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&bill, item.BillID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定员工代收款账单失败")
}
bills[item.BillID] = &bill
}
return bills, nil
}
// loadEnabledPaymentMethod 读取启用中的线下收款方式字典项作为冻结来源。
func loadEnabledPaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
var paymentMethod model.EmployeeCollectionPaymentMethod
if err := tx.WithContext(ctx).First(&paymentMethod, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
if paymentMethod.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
}
return &paymentMethod, nil
}
// resolveApplicantAccountID 依据所选账单确定申请人。
// 新建:非超级管理员只能选择本人欠款账单;超级管理员代办时全部账单必须属于同一欠款人。
// 重提:被选账单必须仍属于原申请人,申请人的其他越权访问与不存在返回同一错误。
func resolveApplicantAccountID(
caller applicationCaller,
existing *model.EmployeeCollectionApplication,
bills map[uint]*model.EmployeeCollectionBill,
) (uint, error) {
if existing != nil {
applicant := existing.ApplicantAccountID
for _, bill := range bills {
if bill.DebtorAccountID != applicant {
return 0, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
}
return applicant, nil
}
applicant := uint(0)
for _, bill := range bills {
if !caller.IsAdmin && bill.DebtorAccountID != caller.AccountID {
return 0, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
if applicant == 0 {
applicant = bill.DebtorAccountID
continue
}
if applicant != bill.DebtorAccountID {
return 0, errors.New(errors.CodeInvalidParam, "代办核销申请时全部账单必须属于同一欠款人")
}
}
return applicant, nil
}
// billAmounts 将账单持久化事实映射为领域金额事实。
func billAmounts(bill *model.EmployeeCollectionBill) employeecollectiondomain.BillAmounts {
return employeecollectiondomain.BillAmounts{
Receivable: bill.ReceivableAmount, Received: bill.ReceivedAmount, Reserved: bill.ReservedAmount,
Closed: bill.Status == constants.EmployeeCollectionBillStatusClosed,
}
}
// nextAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。
func nextAttemptNo(ctx context.Context, tx *gorm.DB, applicationID uint) (int, error) {
var row struct {
MaxAttemptNo int
}
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAttempt{}).
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
Where("application_id = ?", applicationID).Scan(&row).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试序号失败")
}
return row.MaxAttemptNo + 1, nil
}
// prepareApplication 新建或就地更新核销申请,返回申请事实与变更前快照。
// 重提使用 expected-status 条件更新,状态已变化时返回冲突。
func prepareApplication(
ctx context.Context,
tx *gorm.DB,
caller applicationCaller,
existing *model.EmployeeCollectionApplication,
applicantAccountID uint,
paymentMethod *model.EmployeeCollectionPaymentMethod,
normalized employeecollectiondomain.NormalizedApplicationInput,
) (*model.EmployeeCollectionApplication, map[string]any, error) {
actingOperatorID := uint(0)
if applicantAccountID != caller.AccountID {
actingOperatorID = caller.AccountID
}
if existing == nil {
application := &model.EmployeeCollectionApplication{
ApplicantAccountID: applicantAccountID, ActingOperatorID: actingOperatorID,
ActingReason: normalized.ActingReason,
PaymentMethodID: paymentMethod.ID, PaymentMethodCode: paymentMethod.Code,
PaymentMethodName: paymentMethod.Name, PaidAmount: normalized.PaidAmount,
PayerName: normalized.PayerName, PaidAt: normalized.PaidAt,
ExternalTransactionNo: normalized.ExternalTransactionNo,
PaymentVoucherKeys: model.StringJSONBArray(normalized.PaymentVoucherKeys),
Remark: normalized.Remark,
Status: constants.EmployeeCollectionApplicationStatusPending,
Creator: caller.AccountID, Updater: caller.AccountID,
}
if err := tx.WithContext(ctx).Create(application).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "创建核销申请失败")
}
return application, nil, nil
}
beforeData := applicationAuditSnapshot(existing)
expectedStatus := existing.Status
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", existing.ID, expectedStatus).
Updates(map[string]any{
"acting_operator_id": actingOperatorID,
"acting_reason": normalized.ActingReason,
"payment_method_id": paymentMethod.ID,
"payment_method_code": paymentMethod.Code,
"payment_method_name": paymentMethod.Name,
"paid_amount": normalized.PaidAmount,
"payer_name": normalized.PayerName,
"paid_at": normalized.PaidAt,
"external_transaction_no": normalized.ExternalTransactionNo,
"payment_voucher_keys": model.StringJSONBArray(normalized.PaymentVoucherKeys),
"remark": normalized.Remark,
"status": constants.EmployeeCollectionApplicationStatusPending,
"decided_at": nil,
"terminal_reason": "",
"updater": caller.AccountID,
})
if result.Error != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请失败")
}
if result.RowsAffected != 1 {
return nil, nil, errors.New(errors.CodeConflict, "核销申请状态已变化,请刷新后重试")
}
existing.ActingOperatorID = actingOperatorID
existing.ActingReason = normalized.ActingReason
existing.PaymentMethodID = paymentMethod.ID
existing.PaymentMethodCode = paymentMethod.Code
existing.PaymentMethodName = paymentMethod.Name
existing.PaidAmount = normalized.PaidAmount
existing.PayerName = normalized.PayerName
existing.PaidAt = normalized.PaidAt
existing.ExternalTransactionNo = normalized.ExternalTransactionNo
existing.PaymentVoucherKeys = model.StringJSONBArray(normalized.PaymentVoucherKeys)
existing.Remark = normalized.Remark
existing.Status = constants.EmployeeCollectionApplicationStatusPending
existing.DecidedAt = nil
existing.TerminalReason = ""
existing.Updater = caller.AccountID
return existing, beforeData, nil
}
// buildAttempt 新增一条不可变审批尝试记录,冻结当次收款方式、外部付款、附件与账单分摊快照。
func buildAttempt(
ctx context.Context,
tx *gorm.DB,
applicationID uint,
attemptNo int,
caller applicationCaller,
paymentMethod *model.EmployeeCollectionPaymentMethod,
normalized employeecollectiondomain.NormalizedApplicationInput,
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) (*model.EmployeeCollectionApplicationAttempt, error) {
snapshot, err := allocationSnapshot(bills, items)
if err != nil {
return nil, err
}
attempt := &model.EmployeeCollectionApplicationAttempt{
ApplicationID: applicationID, AttemptNo: attemptNo,
PaymentMethodID: paymentMethod.ID, PaymentMethodCode: paymentMethod.Code, PaymentMethodName: paymentMethod.Name,
PaidAmount: normalized.PaidAmount, PayerName: normalized.PayerName, PaidAt: normalized.PaidAt,
ExternalTransactionNo: normalized.ExternalTransactionNo,
PaymentVoucherKeys: model.StringJSONBArray(normalized.PaymentVoucherKeys),
Remark: normalized.Remark, SubmittedByAccountID: caller.AccountID,
ActingReason: normalized.ActingReason, AllocationSnapshot: snapshot,
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建核销审批尝试记录失败")
}
return attempt, nil
}
// allocationSnapshot 生成账单分摊快照,只保存账单摘要与金额,不含付款凭证内容。
func allocationSnapshot(
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) ([]byte, error) {
entries := make([]map[string]any, 0, len(items))
for _, item := range items {
bill := bills[item.BillID]
entries = append(entries, map[string]any{
"bill_id": bill.ID, "source_type": bill.SourceType, "source_no": bill.SourceNo,
"bill_status": bill.Status, "receivable_amount": bill.ReceivableAmount,
"received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount,
"amount": item.Amount,
})
}
payload, err := sonic.Marshal(entries)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化账单分摊快照失败")
}
return payload, nil
}
// approvalSnapshots 生成通用审批的提交人快照与企业微信表单业务快照。
// 表单快照必须包含审批人核验所需的付款信息,因此保留经人工确认的完整外部流水号。
func approvalSnapshots(
applicationID uint,
caller applicationCaller,
paymentMethod *model.EmployeeCollectionPaymentMethod,
normalized employeecollectiondomain.NormalizedApplicationInput,
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) ([]byte, []byte, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": caller.AccountID, "account_name": caller.AccountName,
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码核销申请提交人快照失败")
}
requestSnapshot, err := sonic.Marshal(map[string]any{
constants.ApprovalFieldCollectionApplicationID: applicationID,
constants.ApprovalFieldCollectionPaymentMethod: paymentMethod.Name,
constants.ApprovalFieldCollectionPaidAmount: formatAmountYuan(normalized.PaidAmount),
constants.ApprovalFieldCollectionPaidAmountCent: normalized.PaidAmount,
constants.ApprovalFieldCollectionPayerName: normalized.PayerName,
constants.ApprovalFieldCollectionPaidAt: normalized.PaidAt.Format(time.RFC3339),
constants.ApprovalFieldCollectionExternalTransactionNo: normalized.ExternalTransactionNo,
constants.ApprovalFieldPaymentVoucherKey: normalized.PaymentVoucherKeys,
constants.ApprovalFieldRemark: normalized.Remark,
constants.ApprovalFieldSubmitterID: caller.AccountID,
constants.ApprovalFieldSubmitterName: caller.AccountName,
constants.ApprovalFieldCollectionBillCount: len(items),
constants.ApprovalFieldCollectionBillSummary: allocationSummary(bills, items),
})
if err != nil {
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码核销审批业务快照失败")
}
return submitterSnapshot, requestSnapshot, nil
}
// allocationSummary 生成给审批人阅读的账单分摊摘要。
func allocationSummary(bills map[uint]*model.EmployeeCollectionBill, items []ApplicationAllocationCommand) string {
parts := make([]string, 0, len(items))
for _, item := range items {
bill := bills[item.BillID]
parts = append(parts, fmt.Sprintf("账单%d%s应收%s 本次分摊%s",
bill.ID, bill.SourceNo, formatAmountYuan(bill.ReceivableAmount), formatAmountYuan(item.Amount)))
}
return strings.Join(parts, "")
}
// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。
func formatAmountYuan(amount int64) string {
return fmt.Sprintf("%d.%02d", amount/100, amount%100)
}
// attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。
func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.EmployeeCollectionApplicationAttempt, instanceID uint) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAttempt{}).
Where("id = ? AND approval_instance_id IS NULL", attempt.ID).
Update("approval_instance_id", instanceID)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联核销审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销审批实例关联已变化")
}
attempt.ApprovalInstanceID = &instanceID
return nil
}
// updateApplicationLatest 更新申请的最新审批尝试与审批实例引用,仅用于展示。
func updateApplicationLatest(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
instanceID uint,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ?", application.ID).
Updates(map[string]any{
"latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请最新审批实例失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请最新审批实例更新已变化")
}
application.LatestAttemptID = attempt.ID
application.LatestApprovalInstanceID = instanceID
return nil
}
// createAllocations 写入分摊行并按账单 ID 升序预占余额。
// 预占使用条件更新并要求 RowsAffected 为 1避免并发申请超额占用同一账单。
func createAllocations(
ctx context.Context,
tx *gorm.DB,
applicationID uint,
attemptID uint,
caller applicationCaller,
bills map[uint]*model.EmployeeCollectionBill,
items []ApplicationAllocationCommand,
) ([]*model.EmployeeCollectionApplicationAllocation, []*model.EmployeeCollectionBill, error) {
allocations := make([]*model.EmployeeCollectionApplicationAllocation, 0, len(items))
reservedBills := make([]*model.EmployeeCollectionBill, 0, len(items))
for _, item := range items {
bill := bills[item.BillID]
allocation := &model.EmployeeCollectionApplicationAllocation{
ApplicationID: applicationID, AttemptID: attemptID, BillID: bill.ID,
Amount: item.Amount, Status: constants.EmployeeCollectionAllocationStatusPending,
Creator: caller.AccountID, Updater: caller.AccountID,
}
if err := tx.WithContext(ctx).Create(allocation).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "写入核销分摊失败")
}
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND reserved_amount + ? <= receivable_amount - received_amount", bill.ID, item.Amount).
Updates(map[string]any{
"reserved_amount": gorm.Expr("reserved_amount + ?", item.Amount),
"updater": caller.AccountID,
})
if result.Error != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "预占账单可核销余额失败")
}
if result.RowsAffected != 1 {
return nil, nil, errors.New(errors.CodeEmployeeCollectionAllocationExceeded)
}
bill.ReservedAmount += item.Amount
allocations = append(allocations, allocation)
reservedBills = append(reservedBills, bill)
}
return allocations, reservedBills, nil
}
// applicationAuditSnapshot 生成申请审计快照,外部交易流水号按脱敏值记录。
func applicationAuditSnapshot(application *model.EmployeeCollectionApplication) map[string]any {
return map[string]any{
"id": application.ID, "applicant_account_id": application.ApplicantAccountID,
"acting_operator_id": application.ActingOperatorID,
"payment_method_id": application.PaymentMethodID, "payment_method_code": application.PaymentMethodCode,
"paid_amount": application.PaidAmount, "payer_name": application.PayerName,
"external_transaction_no_masked": employeecollectiondomain.MaskExternalTransactionNo(application.ExternalTransactionNo),
"voucher_count": len(application.PaymentVoucherKeys),
"status": application.Status,
"latest_attempt_id": application.LatestAttemptID,
"latest_approval_instance_id": application.LatestApprovalInstanceID,
}
}

View File

@@ -1,37 +0,0 @@
package employeecollection
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// ApplicationAudit 描述核销申请、审批尝试记录与受影响账单的事实变化。
type ApplicationAudit struct {
// EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。
EventID string
// ActionCode 是已注册的核销申请审计动作码。
ActionCode string
// Summary 是给人工阅读的中文摘要。
Summary string
// Application 是本次动作后的核销申请事实。
Application *model.EmployeeCollectionApplication
// Attempt 是本次动作对应的审批尝试记录。
Attempt *model.EmployeeCollectionApplicationAttempt
// Allocations 是本次动作涉及的分摊事实。
Allocations []*model.EmployeeCollectionApplicationAllocation
// Bills 是本次动作影响的员工代收款账单事实。
Bills []*model.EmployeeCollectionBill
// BeforeData 与 AfterData 是脱敏前后的字段快照,不得包含付款凭证内容。
BeforeData map[string]any
AfterData map[string]any
// CorrelationID 是申请链路标识。
CorrelationID string
}
// ApplicationAuditWriter 在员工代收款核销事务内追加统一 Audit Event。
type ApplicationAuditWriter interface {
WriteEmployeeCollectionApplication(ctx context.Context, tx *gorm.DB, change ApplicationAudit) error
}

View File

@@ -1,501 +0,0 @@
package employeecollection
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApprovalDecisionHandler 将渠道无关企业微信审批终态应用到员工代收款核销申请。
// 通过才增加账单已核销金额,驳回才释放预占;重复、乱序或延迟回调都不重复入账。
type ApprovalDecisionHandler struct {
db *gorm.DB
audit ApplicationAuditWriter
}
// NewApprovalDecisionHandler 创建员工代收款核销审批终态消费者。
func NewApprovalDecisionHandler(db *gorm.DB, audit ApplicationAuditWriter) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, audit: audit}
}
// Handle 幂等消费标准审批终态。
// 业务标识为审批尝试记录主键:先锁定尝试记录并校验审批实例一致,再按申请与账单 ID 升序加锁。
func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "员工代收款核销审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeEmployeeCollection || event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "员工代收款核销审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var attempt model.EmployeeCollectionApplicationAttempt
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&attempt, event.BusinessID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "核销审批尝试记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定核销审批尝试记录失败")
}
if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "核销审批尝试记录关联的审批实例不一致")
}
application, err := lockApplication(ctx, tx, attempt.ApplicationID)
if err != nil {
return err
}
if application.LatestAttemptID != attempt.ID {
// 已被更新尝试取代的历史尝试终态不再改变申请事实。
return nil
}
switch event.Decision {
case constants.ApprovalDecisionApproved:
return h.applyApproved(ctx, tx, application, &attempt, event)
case constants.ApprovalDecisionRejected:
return h.applyClosed(ctx, tx, application, &attempt, event,
constants.EmployeeCollectionApplicationStatusRejected, "企业微信审批已驳回")
case constants.ApprovalDecisionCancelled:
return h.applyClosed(ctx, tx, application, &attempt, event,
constants.EmployeeCollectionApplicationStatusRevoked, "企业微信审批已撤销")
case constants.ApprovalDecisionDeleted:
return h.applyClosed(ctx, tx, application, &attempt, event,
constants.EmployeeCollectionApplicationStatusRevoked, "企业微信审批已删除")
case constants.ApprovalDecisionRevokedAfterApproved:
return h.applyRevoked(ctx, tx, application, &attempt, event)
default:
return errors.New(errors.CodeInvalidParam, "不支持的核销申请审批终态")
}
})
}
// applyApproved 将本次尝试的全部预占分摊转入已核销并重算账单状态。
// 仅当申请仍处于审批中时推进,重复或乱序回调不重复增加已核销金额。
func (h *ApprovalDecisionHandler) applyApproved(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
if application.Status != constants.EmployeeCollectionApplicationStatusPending {
return nil
}
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
if err != nil {
return err
}
if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil {
return err
}
now := time.Now().UTC()
for _, allocation := range allocations {
if err := approveAllocation(ctx, tx, allocation, now); err != nil {
return err
}
if err := settleBillReservation(ctx, tx, allocation); err != nil {
return err
}
}
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending).
Updates(map[string]any{
"status": constants.EmployeeCollectionApplicationStatusApproved,
"decided_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请已通过失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请状态已变化")
}
before := application.Status
application.Status = constants.EmployeeCollectionApplicationStatusApproved
application.DecidedAt = &now
application.Updater = 0
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
if err != nil {
return err
}
approvedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "approved")
if err != nil {
return err
}
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: approvedEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationApproved,
Summary: "企业微信审批通过,核销分摊转入已核销",
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
BeforeData: map[string]any{"status": before},
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
})
}
// applyClosed 处理最终驳回与渠道撤销、删除:释放全部预占并把申请置为对应终态。
func (h *ApprovalDecisionHandler) applyClosed(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
event approvalapp.TerminalDecisionEvent,
targetStatus int,
reason string,
) error {
if application.Status != constants.EmployeeCollectionApplicationStatusPending {
return nil
}
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
if err != nil {
return err
}
if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil {
return err
}
now := time.Now().UTC()
for _, allocation := range allocations {
if err := releaseAllocation(ctx, tx, allocation, now); err != nil {
return err
}
if err := releaseBillReservation(ctx, tx, allocation); err != nil {
return err
}
}
terminalReason := ""
if targetStatus == constants.EmployeeCollectionApplicationStatusRevoked {
terminalReason = reason
}
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending).
Updates(map[string]any{
"status": targetStatus, "decided_at": now,
"terminal_reason": terminalReason, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新核销申请终态失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请状态已变化")
}
before := application.Status
application.Status = targetStatus
application.DecidedAt = &now
application.TerminalReason = terminalReason
application.Updater = 0
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
if err != nil {
return err
}
closedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, decisionEventSuffix(event.Decision))
if err != nil {
return err
}
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: closedEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationRejected,
Summary: "企业微信审批未通过,核销申请预占已释放",
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
BeforeData: map[string]any{"status": before},
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
})
}
// applyRevoked 处理通过后撤销。
// 申请已通过:不回滚已核销金额,只转异常终态并禁止自动重提。
// 申请仍在审批中(渠道乱序投递):释放全部审批中预占并转异常终态,避免预占永久占用账单。
func (h *ApprovalDecisionHandler) applyRevoked(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
switch application.Status {
case constants.EmployeeCollectionApplicationStatusApproved:
return h.revokeApproved(ctx, tx, application, attempt, event)
case constants.EmployeeCollectionApplicationStatusPending:
return h.revokePending(ctx, tx, application, attempt, event)
default:
// 已驳回、已撤销等终态不再改变事实。
return nil
}
}
// revokeApproved 在已通过态撤销:保留已核销金额,仅转异常终态。
func (h *ApprovalDecisionHandler) revokeApproved(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
now := time.Now().UTC()
terminalReason := "企业微信通过后撤销"
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusApproved).
Updates(map[string]any{
"status": constants.EmployeeCollectionApplicationStatusRevoked,
"decided_at": now, "terminal_reason": terminalReason, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请通过后撤销失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请状态已变化")
}
before := application.Status
application.Status = constants.EmployeeCollectionApplicationStatusRevoked
application.DecidedAt = &now
application.TerminalReason = terminalReason
application.Updater = 0
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
if err != nil {
return err
}
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
if err != nil {
return err
}
revokedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "revoked")
if err != nil {
return err
}
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: revokedEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationRevoked,
Summary: "企业微信通过后撤销,已核销金额不回滚",
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
BeforeData: map[string]any{"status": before},
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
})
}
// revokePending 在审批中态撤销:释放全部审批中预占并转异常终态。
func (h *ApprovalDecisionHandler) revokePending(
ctx context.Context,
tx *gorm.DB,
application *model.EmployeeCollectionApplication,
attempt *model.EmployeeCollectionApplicationAttempt,
event approvalapp.TerminalDecisionEvent,
) error {
allocations, err := loadAttemptAllocations(ctx, tx, attempt.ID)
if err != nil {
return err
}
if _, err := lockBillsInAscendingOrder(ctx, tx, allocationCommandsOf(allocations)); err != nil {
return err
}
now := time.Now().UTC()
for _, allocation := range allocations {
if err := releaseAllocation(ctx, tx, allocation, now); err != nil {
return err
}
if err := releaseBillReservation(ctx, tx, allocation); err != nil {
return err
}
}
terminalReason := "企业微信通过后在本地审批中状态被撤销"
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("id = ? AND status = ?", application.ID, constants.EmployeeCollectionApplicationStatusPending).
Updates(map[string]any{
"status": constants.EmployeeCollectionApplicationStatusRevoked,
"decided_at": now, "terminal_reason": terminalReason, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销申请撤销失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销申请状态已变化")
}
before := application.Status
application.Status = constants.EmployeeCollectionApplicationStatusRevoked
application.DecidedAt = &now
application.TerminalReason = terminalReason
application.Updater = 0
bills, err := reloadBills(ctx, tx, allocationCommandsOf(allocations))
if err != nil {
return err
}
revokedEventID, err := decisionEventID(application.ID, attempt.AttemptNo, "revoked")
if err != nil {
return err
}
return h.audit.WriteEmployeeCollectionApplication(ctx, tx, ApplicationAudit{
EventID: revokedEventID,
ActionCode: constants.AuditActionEmployeeCollectionApplicationRevoked,
Summary: "企业微信通过后撤销,申请仍在审批中,已释放预占",
Application: application, Attempt: attempt, Allocations: allocations, Bills: bills,
BeforeData: map[string]any{"status": before},
AfterData: applicationAuditSnapshot(application), CorrelationID: event.CorrelationID,
})
}
// loadAttemptAllocations 按账单 ID 升序读取本次尝试的分摊,保证后续加锁与写入次序唯一。
func loadAttemptAllocations(ctx context.Context, tx *gorm.DB, attemptID uint) ([]*model.EmployeeCollectionApplicationAllocation, error) {
var allocations []model.EmployeeCollectionApplicationAllocation
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("attempt_id = ?", attemptID).Order("bill_id ASC, id ASC").Find(&allocations).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试分摊失败")
}
if len(allocations) == 0 {
return nil, errors.New(errors.CodeConflict, "核销审批尝试记录缺少分摊事实")
}
result := make([]*model.EmployeeCollectionApplicationAllocation, 0, len(allocations))
for index := range allocations {
result = append(result, &allocations[index])
}
return result, nil
}
// allocationCommandsOf 提取分摊涉及的账单与金额,用于复用升序加锁函数。
func allocationCommandsOf(allocations []*model.EmployeeCollectionApplicationAllocation) []ApplicationAllocationCommand {
items := make([]ApplicationAllocationCommand, 0, len(allocations))
for _, allocation := range allocations {
items = append(items, ApplicationAllocationCommand{BillID: allocation.BillID, Amount: allocation.Amount})
}
return items
}
// approveAllocation 把分摊从审批中预占条件更新为已通过;重复处理时返回冲突。
func approveAllocation(
ctx context.Context,
tx *gorm.DB,
allocation *model.EmployeeCollectionApplicationAllocation,
now time.Time,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}).
Where("id = ? AND status = ?", allocation.ID, constants.EmployeeCollectionAllocationStatusPending).
Updates(map[string]any{
"status": constants.EmployeeCollectionAllocationStatusApproved, "released_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记核销分摊已通过失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销分摊状态已变化")
}
allocation.Status = constants.EmployeeCollectionAllocationStatusApproved
allocation.ReleasedAt = &now
return nil
}
// releaseAllocation 把分摊从审批中预占条件更新为已释放;重复处理时返回冲突。
func releaseAllocation(
ctx context.Context,
tx *gorm.DB,
allocation *model.EmployeeCollectionApplicationAllocation,
now time.Time,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}).
Where("id = ? AND status = ?", allocation.ID, constants.EmployeeCollectionAllocationStatusPending).
Updates(map[string]any{
"status": constants.EmployeeCollectionAllocationStatusReleased, "released_at": now, "updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放核销分摊预占失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "核销分摊状态已变化")
}
allocation.Status = constants.EmployeeCollectionAllocationStatusReleased
allocation.ReleasedAt = &now
return nil
}
// settleBillReservation 将账单预占转为已核销并重算账单状态。
// 条件更新要求账单预占不小于分摊金额,并检查 RowsAffected避免并发下重复入账。
func settleBillReservation(
ctx context.Context,
tx *gorm.DB,
allocation *model.EmployeeCollectionApplicationAllocation,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND reserved_amount >= ?", allocation.BillID, allocation.Amount).
Updates(map[string]any{
"received_amount": gorm.Expr("received_amount + ?", allocation.Amount),
"reserved_amount": gorm.Expr("reserved_amount - ?", allocation.Amount),
"status": gorm.Expr(
"CASE WHEN received_amount + ? >= receivable_amount THEN ?::smallint WHEN received_amount + ? > 0 THEN ?::smallint ELSE ?::smallint END",
allocation.Amount, constants.EmployeeCollectionBillStatusSettled,
allocation.Amount, constants.EmployeeCollectionBillStatusPartial,
constants.EmployeeCollectionBillStatusPending,
),
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "账单预占转入已核销失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "账单预占已变化,核销未入账")
}
return nil
}
// releaseBillReservation 释放账单预占金额,条件更新并检查 RowsAffected。
func releaseBillReservation(
ctx context.Context,
tx *gorm.DB,
allocation *model.EmployeeCollectionApplicationAllocation,
) error {
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND reserved_amount >= ?", allocation.BillID, allocation.Amount).
Updates(map[string]any{
"reserved_amount": gorm.Expr("reserved_amount - ?", allocation.Amount),
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放账单预占失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "账单预占已变化")
}
return nil
}
// reloadBills 重新读取受影响账单,保证审计快照反映终态金额。
// 账单行已在同一事务内持有排他锁,这里只做一次按 ID 升序的普通读取。
func reloadBills(
ctx context.Context,
tx *gorm.DB,
items []ApplicationAllocationCommand,
) ([]*model.EmployeeCollectionBill, error) {
billIDs := make([]uint, 0, len(items))
for _, item := range items {
billIDs = append(billIDs, item.BillID)
}
var bills []model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Where("id IN ?", billIDs).Order("id ASC").Find(&bills).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询受影响员工代收款账单失败")
}
result := make([]*model.EmployeeCollectionBill, 0, len(bills))
for index := range bills {
result = append(result, &bills[index])
}
return result, nil
}
// decisionEventID 生成审批终态审计事件的稳定标识,并约束在审计列宽内。
func decisionEventID(applicationID uint, attemptNo int, suffix string) (string, error) {
return composeAuditEventID(
"employee_collection", "application", uintText(applicationID), "attempt", intText(attemptNo), suffix)
}
// decisionEventSuffix 把渠道决策映射为审计事件后缀。
func decisionEventSuffix(decision string) string {
switch decision {
case constants.ApprovalDecisionRejected:
return "rejected"
case constants.ApprovalDecisionCancelled:
return "cancelled"
case constants.ApprovalDecisionDeleted:
return "deleted"
default:
return "closed"
}
}

View File

@@ -1,31 +0,0 @@
package employeecollection
import (
"strconv"
"strings"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// auditEventMaxLength 是统一审计事件标识的列宽上限,与 tb_audit_event.event_id 保持一致。
const auditEventMaxLength = 64
// composeAuditEventID 以冒号连接业务标识片段,生成确定性的审计事件标识。
// 超出审计列宽时返回稳定错误,避免写入时分段截断或事务被数据库拒绝。
func composeAuditEventID(parts ...string) (string, error) {
eventID := strings.Join(parts, ":")
if len(eventID) > auditEventMaxLength {
return "", errors.New(errors.CodeInternalError, "审计事件标识超出长度限制")
}
return eventID, nil
}
// uintText 将主键转为审计标识片段。
func uintText(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
// intText 将序号转为审计标识片段。
func intText(value int) string {
return strconv.Itoa(value)
}

View File

@@ -1,118 +0,0 @@
package employeecollection
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// BillCloseService 关闭员工代收款账单。
// 仅超级管理员可关闭,且只允许关闭仍待核销或部分核销、且不存在审批中分摊的账单。
type BillCloseService struct {
db *gorm.DB
audit BillAuditWriter
}
// NewBillCloseService 创建账单关闭事务脚本。
func NewBillCloseService(db *gorm.DB, audit BillAuditWriter) *BillCloseService {
return &BillCloseService{db: db, audit: audit}
}
// Close 关闭账单:作废未核销余额、保留已核销金额,并在同一事务内写关闭审计。
// 并发关闭通过行锁加 expected-status 条件更新兜底,状态已变化时返回冲突。
func (s *BillCloseService) Close(ctx context.Context, id uint, reason string) (*model.EmployeeCollectionBill, error) {
operatorID, err := requireSuperAdmin(ctx)
if err != nil {
return nil, err
}
if s == nil || s.db == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "账单关闭能力尚未配置")
}
if id == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
closeReason := strings.TrimSpace(reason)
var closed *model.EmployeeCollectionBill
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
bill, err := lockBill(ctx, tx, id)
if err != nil {
return err
}
var pendingAllocations int64
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplicationAllocation{}).
Where("bill_id = ? AND status = ?", id, constants.EmployeeCollectionAllocationStatusPending).
Count(&pendingAllocations).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "统计账单审批中分摊失败")
}
if err := employeecollectiondomain.ValidateBillClose(employeecollectiondomain.BillCloseInput{
Status: bill.Status, PendingAllocations: pendingAllocations, Reason: closeReason,
}); err != nil {
return err
}
before := *bill
closedAt := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND status = ?", bill.ID, bill.Status).
Updates(map[string]any{
"status": constants.EmployeeCollectionBillStatusClosed,
"closed_reason": closeReason,
"closed_at": closedAt,
"updater": operatorID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭员工代收款账单失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "账单状态已变化,请刷新后重试")
}
bill.Status = constants.EmployeeCollectionBillStatusClosed
bill.ClosedReason = closeReason
bill.ClosedAt = &closedAt
bill.Updater = operatorID
closeEventID, err := composeAuditEventID("employee_collection", "bill", uintText(bill.ID), "close")
if err != nil {
return err
}
if err := s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{
EventID: closeEventID,
ActionCode: constants.AuditActionEmployeeCollectionBillClosed, Summary: "关闭员工代收款账单",
Bill: bill,
BeforeData: map[string]any{
"status": before.Status, "closed_reason": before.ClosedReason,
},
AfterData: map[string]any{
"status": bill.Status, "closed_reason": bill.ClosedReason,
},
CorrelationID: bill.SourceNo,
}); err != nil {
return err
}
closed = bill
return nil
})
if err != nil {
return nil, err
}
return closed, nil
}
// lockBill 以行锁读取账单,未找到返回稳定不存在错误。
func lockBill(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionBill, error) {
var bill model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&bill, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定员工代收款账单失败")
}
return &bill, nil
}

View File

@@ -1,233 +0,0 @@
package employeecollection
import (
"context"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// BillAudit 描述员工代收款账单事实的实际变化。
type BillAudit struct {
// EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。
EventID string
// ActionCode 是已注册的账单审计动作码。
ActionCode string
// Summary 是给人工阅读的中文摘要。
Summary string
// Bill 是本次动作后的账单事实。
Bill *model.EmployeeCollectionBill
// BeforeData 与 AfterData 是脱敏前后的字段快照。
BeforeData map[string]any
AfterData map[string]any
// CorrelationID 是来源业务链路标识。
CorrelationID string
}
// BillAuditWriter 在员工代收款业务事务内追加统一 Audit Event。
type BillAuditWriter interface {
WriteEmployeeCollectionBill(ctx context.Context, tx *gorm.DB, change BillAudit) error
}
// BillCreationService 在来源成功事务内按来源唯一键幂等创建员工代收款账单。
// 建账只由来源成功事务携带的来源主键触发,不存在扫描历史订单或充值补建的路径。
type BillCreationService struct {
audit BillAuditWriter
}
// NewBillCreationService 创建员工代收款建账用例。
func NewBillCreationService(audit BillAuditWriter) *BillCreationService {
return &BillCreationService{audit: audit}
}
// CreateFromOrderInTx 在后台线下套餐订单激活事务内建账。
// 判据见 employeecollectiondomain.ShouldCreateBillForOrder不满足判据时返回 (nil, nil)。
// 重复订单事务、重放或重试都命中 source_key 唯一约束并返回既有账单,不使订单事务失败。
func (s *BillCreationService) CreateFromOrderInTx(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
hasGiftPackage bool,
) (*model.EmployeeCollectionBill, error) {
if s == nil || tx == nil || s.audit == nil {
return nil, errors.New(errors.CodeInternalError, "员工代收款建账用例未完整配置")
}
if order == nil || order.ID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "员工代收款建账缺少来源订单")
}
if !employeecollectiondomain.ShouldCreateBillForOrder(
employeecollectiondomain.OrderBillSubjectFromOrder(order, hasGiftPackage)) {
return nil, nil
}
if order.OperatorAccountID == nil || *order.OperatorAccountID == 0 {
return nil, errors.New(errors.CodeInternalError, "线下套餐订单缺少欠款人账号")
}
debtorAccountID := *order.OperatorAccountID
debtorSnapshot, err := marshalSnapshot(map[string]any{
"account_id": debtorAccountID, "account_name": order.OperatorAccountName,
"account_type": order.OperatorAccountType,
})
if err != nil {
return nil, err
}
// shop_id 与 seller_shop_id 同时写入:店铺筛选统一读 shop_idseller_shop_id 保留兼容口径。
customerSnapshot, err := marshalSnapshot(map[string]any{
"buyer_type": order.BuyerType, "buyer_id": order.BuyerID,
"buyer_nickname": order.BuyerNickname,
"shop_id": order.SellerShopID, "seller_shop_id": order.SellerShopID,
"asset_identifier": order.AssetIdentifier,
})
if err != nil {
return nil, err
}
sourceKey := employeecollectiondomain.OrderSourceKey(order.ID)
bill := &model.EmployeeCollectionBill{
SourceType: constants.EmployeeCollectionSourceTypeOrder,
SourceID: order.ID,
SourceKey: sourceKey,
SourceNo: order.OrderNo,
DebtorAccountID: debtorAccountID,
DebtorSnapshot: debtorSnapshot,
CustomerSnapshot: customerSnapshot,
ReceivableAmount: *order.ActualPaidAmount,
Status: constants.EmployeeCollectionBillStatusPending,
Creator: debtorAccountID,
Updater: debtorAccountID,
}
return s.persistInTx(ctx, tx, bill,
[]string{"employee_collection", "bill", "order", uintText(order.ID), "create"},
"后台线下套餐订单创建员工代收款账单", order.OrderNo)
}
// CreateFromRechargeInTx 在代理线下充值入账事务内建账。
// 判据见 employeecollectiondomain.ShouldCreateBillForRecharge欠款人为发起充值的后台账号。
// 覆盖企业微信终审通过入账与后台人工确认入账两条入口,重复入账不重复建账。
func (s *BillCreationService) CreateFromRechargeInTx(
ctx context.Context,
tx *gorm.DB,
record *model.AgentRechargeRecord,
) (*model.EmployeeCollectionBill, error) {
if s == nil || tx == nil || s.audit == nil {
return nil, errors.New(errors.CodeInternalError, "员工代收款建账用例未完整配置")
}
if record == nil || record.ID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "员工代收款建账缺少来源充值记录")
}
if !employeecollectiondomain.ShouldCreateBillForRecharge(employeecollectiondomain.RechargeBillSubject{
PaymentMethod: record.PaymentMethod, Amount: record.Amount,
}) {
return nil, nil
}
if record.UserID == 0 {
return nil, errors.New(errors.CodeInternalError, "线下充值记录缺少发起账号")
}
debtorName, err := rechargeAccountName(ctx, tx, record.UserID)
if err != nil {
return nil, err
}
debtorSnapshot, err := marshalSnapshot(map[string]any{
"account_id": record.UserID, "account_name": debtorName,
"account_type": model.OperatorAccountTypePlatform,
})
if err != nil {
return nil, err
}
customerSnapshot, err := marshalSnapshot(map[string]any{
"shop_id": record.ShopID, "agent_wallet_id": record.AgentWalletID,
"payment_method": record.PaymentMethod, "recharge_no": record.RechargeNo,
})
if err != nil {
return nil, err
}
sourceKey := employeecollectiondomain.RechargeSourceKey(record.ID)
bill := &model.EmployeeCollectionBill{
SourceType: constants.EmployeeCollectionSourceTypeRecharge,
SourceID: record.ID,
SourceKey: sourceKey,
SourceNo: record.RechargeNo,
DebtorAccountID: record.UserID,
DebtorSnapshot: debtorSnapshot,
CustomerSnapshot: customerSnapshot,
ReceivableAmount: record.Amount,
Status: constants.EmployeeCollectionBillStatusPending,
Creator: record.UserID,
Updater: record.UserID,
}
return s.persistInTx(ctx, tx, bill,
[]string{"employee_collection", "bill", "recharge", uintText(record.ID), "create"},
"代理线下充值入账创建员工代收款账单", record.RechargeNo)
}
// persistInTx 以来源唯一键幂等写入账单:已存在同一来源账单时返回既有事实且不重复审计。
func (s *BillCreationService) persistInTx(
ctx context.Context,
tx *gorm.DB,
bill *model.EmployeeCollectionBill,
eventIDParts []string,
summary string,
correlationID string,
) (*model.EmployeeCollectionBill, error) {
eventID, err := composeAuditEventID(eventIDParts...)
if err != nil {
return nil, err
}
result := tx.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source_key"}},
DoNothing: true,
}).Create(bill)
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "创建员工代收款账单失败")
}
if result.RowsAffected == 0 {
var existing model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Where("source_key = ?", bill.SourceKey).First(&existing).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取既有员工代收款账单失败")
}
return &existing, nil
}
if err := s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{
EventID: eventID, ActionCode: constants.AuditActionEmployeeCollectionBillCreated, Summary: summary,
Bill: bill, AfterData: billAuditSnapshot(bill), CorrelationID: correlationID,
}); err != nil {
return nil, err
}
return bill, nil
}
// marshalSnapshot 将只读业务快照序列化为 jsonb快照不得包含付款凭证或外部交易敏感内容。
func marshalSnapshot(snapshot map[string]any) ([]byte, error) {
payload, err := sonic.Marshal(snapshot)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化员工代收款账单快照失败")
}
return payload, nil
}
// rechargeAccountName 读取充值发起账号名称用于欠款人快照;账号已被删除时留空名称。
func rechargeAccountName(ctx context.Context, tx *gorm.DB, accountID uint) (string, error) {
var account model.Account
if err := tx.WithContext(ctx).Unscoped().First(&account, accountID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", nil
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询线下充值发起账号失败")
}
return account.Username, nil
}
// billAuditSnapshot 生成账单审计快照,只包含 ID、来源、金额与状态不含凭证内容。
func billAuditSnapshot(bill *model.EmployeeCollectionBill) map[string]any {
return map[string]any{
"id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID,
"source_key": bill.SourceKey, "source_no": bill.SourceNo,
"debtor_account_id": bill.DebtorAccountID, "receivable_amount": bill.ReceivableAmount,
"received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount,
"status": bill.Status,
}
}

View File

@@ -1,346 +0,0 @@
// Package employeecollection 收口员工代收款账单、核销申请与线下收款方式字典的写用例。
// 写用例在事务内保存业务事实与审计事实,读取由 internal/query 提供。
package employeecollection
import (
"context"
stdErrors "errors"
"strconv"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"gorm.io/gorm/clause"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"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"
)
// AuditWriter 接收员工代收款用例在业务事务内产生的配置审计事实。
type AuditWriter interface {
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit systemconfigapp.ChangeAudit) error
}
// PaymentMethodService 维护线下收款方式字典。
// 已启用的字典项由其稳定编码对外,被核销申请引用后只可停用,不允许物理删除或改编码。
type PaymentMethodService struct {
db *gorm.DB
audit AuditWriter
}
// NewPaymentMethodService 创建线下收款方式字典事务脚本。
func NewPaymentMethodService(db *gorm.DB, audit AuditWriter) *PaymentMethodService {
return &PaymentMethodService{db: db, audit: audit}
}
// Create 创建线下收款方式,并在同一事务内写入配置审计。
func (s *PaymentMethodService) Create(
ctx context.Context,
request dto.CreateEmployeeCollectionPaymentMethodRequest,
) (*dto.EmployeeCollectionPaymentMethodResponse, error) {
operatorID, err := requireSuperAdmin(ctx)
if err != nil {
return nil, err
}
if s == nil || s.db == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
}
status := constants.EmployeeCollectionPaymentMethodStatusDisabled
if request.Enabled != nil && *request.Enabled {
status = constants.EmployeeCollectionPaymentMethodStatusEnabled
}
var sortOrder int64
if request.Sort != nil {
sortOrder = *request.Sort
}
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
Code: request.Code, Name: request.Name, SortOrder: sortOrder, Status: status, Remark: request.Remark,
})
if err != nil {
return nil, err
}
paymentMethod := &model.EmployeeCollectionPaymentMethod{
Code: normalized.Code, Name: normalized.Name, SortOrder: normalized.SortOrder,
Status: normalized.Status, Remark: normalized.Remark,
BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, 0); err != nil {
return err
}
if err := tx.WithContext(ctx).Create(paymentMethod).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建线下收款方式失败")
}
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodCreate,
Description: "创建线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
AfterData: paymentMethodAuditSnapshot(paymentMethod), Result: constants.AuditResultSuccess,
})
})
if err != nil {
return nil, mapPaymentMethodCodeConflict(err)
}
return toPaymentMethodResponse(paymentMethod), nil
}
// Update 修改线下收款方式的名称、排序、启停与备注,并在未被引用时允许修改稳定编码。
func (s *PaymentMethodService) Update(
ctx context.Context,
id uint,
request dto.UpdateEmployeeCollectionPaymentMethodRequest,
) (*dto.EmployeeCollectionPaymentMethodResponse, error) {
operatorID, err := requireSuperAdmin(ctx)
if err != nil {
return nil, err
}
if s == nil || s.db == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
}
if id == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
var updated *model.EmployeeCollectionPaymentMethod
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentMethod, err := lockPaymentMethod(ctx, tx, id)
if err != nil {
return err
}
before := *paymentMethod
beforeData := paymentMethodAuditSnapshot(&before)
if request.Code != nil {
code := *request.Code
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
Code: code, Name: paymentMethod.Name, SortOrder: paymentMethod.SortOrder,
Status: paymentMethod.Status, Remark: paymentMethod.Remark,
})
if err != nil {
return err
}
if normalized.Code != paymentMethod.Code {
referenced, err := countPaymentMethodReferences(ctx, tx, id)
if err != nil {
return err
}
if referenced > 0 {
return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced,
"线下收款方式已被核销申请或代理充值申请引用,不能修改稳定编码")
}
if err := ensurePaymentMethodCodeAvailable(ctx, tx, normalized.Code, id); err != nil {
return err
}
}
paymentMethod.Code = normalized.Code
}
if request.Name != nil {
paymentMethod.Name = *request.Name
}
if request.Sort != nil {
paymentMethod.SortOrder = *request.Sort
}
if request.Enabled != nil {
if *request.Enabled {
paymentMethod.Status = constants.EmployeeCollectionPaymentMethodStatusEnabled
} else {
paymentMethod.Status = constants.EmployeeCollectionPaymentMethodStatusDisabled
}
}
if request.Remark != nil {
paymentMethod.Remark = *request.Remark
}
normalized, err := employeecollectiondomain.NormalizePaymentMethodInput(employeecollectiondomain.PaymentMethodInput{
Code: paymentMethod.Code, Name: paymentMethod.Name, SortOrder: paymentMethod.SortOrder,
Status: paymentMethod.Status, Remark: paymentMethod.Remark,
})
if err != nil {
return err
}
paymentMethod.Code = normalized.Code
paymentMethod.Name = normalized.Name
paymentMethod.SortOrder = normalized.SortOrder
paymentMethod.Status = normalized.Status
paymentMethod.Remark = normalized.Remark
paymentMethod.Updater = operatorID
if err := tx.WithContext(ctx).Save(paymentMethod).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新线下收款方式失败")
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodUpdate,
Description: "更新线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
BeforeData: beforeData, AfterData: paymentMethodAuditSnapshot(paymentMethod),
Result: constants.AuditResultSuccess,
}); err != nil {
return err
}
updated = paymentMethod
return nil
})
if err != nil {
return nil, mapPaymentMethodCodeConflict(err)
}
return toPaymentMethodResponse(updated), nil
}
// Delete 物理删除未被任何核销申请引用的线下收款方式,并写入配置审计。
// 已被引用的字典项只允许停用,保证历史申请继续显示冻结名称。
func (s *PaymentMethodService) Delete(ctx context.Context, id uint) error {
operatorID, err := requireSuperAdmin(ctx)
if err != nil {
return err
}
if s == nil || s.db == nil || s.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "线下收款方式维护能力尚未配置")
}
if id == 0 {
return errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentMethod, err := lockPaymentMethod(ctx, tx, id)
if err != nil {
return err
}
referenced, err := countPaymentMethodReferences(ctx, tx, id)
if err != nil {
return err
}
if referenced > 0 {
return errors.New(errors.CodeEmployeeCollectionPaymentMethodReferenced)
}
beforeData := paymentMethodAuditSnapshot(paymentMethod)
paymentMethod.Updater = operatorID
if err := tx.WithContext(ctx).Save(paymentMethod).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新线下收款方式失败")
}
if err := tx.WithContext(ctx).Delete(paymentMethod).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "删除线下收款方式失败")
}
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationEmployeeCollectionPaymentMethodDelete,
Description: "删除线下收款方式", ConfigKey: paymentMethodAuditConfigKey(paymentMethod.ID),
Module: constants.EmployeeCollectionAuditModule, ResourceID: paymentMethodAuditResourceID(paymentMethod.ID),
DisplayName: paymentMethod.Name, Identity: paymentMethodAuditIdentity(paymentMethod),
BeforeData: beforeData, Result: constants.AuditResultSuccess,
})
})
}
// requireSuperAdmin 校验当前调用者是超级管理员,并返回其账号 ID。
// 字典维护不对外开放,未授权一律返回同一禁止访问错误。
func requireSuperAdmin(ctx context.Context) (uint, error) {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return 0, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return 0, errors.New(errors.CodeUnauthorized)
}
return operatorID, nil
}
// lockPaymentMethod 以行锁读取线下收款方式,未找到返回稳定不存在错误。
func lockPaymentMethod(ctx context.Context, tx *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
var paymentMethod model.EmployeeCollectionPaymentMethod
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
First(&paymentMethod, id).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
return &paymentMethod, nil
}
// ensurePaymentMethodCodeAvailable 校验稳定编码在未删除记录中唯一excludeID 用于更新自身。
func ensurePaymentMethodCodeAvailable(ctx context.Context, tx *gorm.DB, code string, excludeID uint) error {
query := tx.WithContext(ctx).Model(&model.EmployeeCollectionPaymentMethod{}).Where("code = ?", code)
if excludeID != 0 {
query = query.Where("id <> ?", excludeID)
}
var count int64
if err := query.Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验线下收款方式编码失败")
}
if count > 0 {
return errors.New(errors.CodeEmployeeCollectionPaymentMethodCodeExists)
}
return nil
}
// countPaymentMethodReferences 统计引用该收款方式的核销申请与代理充值申请数量。
// 两类引用任一存在即禁止物理删除与改码,历史快照由各自记录冻结。
func countPaymentMethodReferences(ctx context.Context, tx *gorm.DB, id uint) (int64, error) {
var applicationCount int64
if err := tx.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}).
Where("payment_method_id = ?", id).Count(&applicationCount).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计线下收款方式引用失败")
}
if applicationCount > 0 {
return applicationCount, nil
}
var rechargeCount int64
if err := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("offline_payment_method_id = ?", id).Count(&rechargeCount).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计代理充值线下收款方式引用失败")
}
return rechargeCount, nil
}
// mapPaymentMethodCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误。
// 并发创建或改码时唯一索引是最终裁决,避免把约束冲突暴露成内部错误。
func mapPaymentMethodCodeConflict(err error) error {
var pgErr *pgconn.PgError
if stdErrors.As(err, &pgErr) && pgErr.Code == "23505" {
return errors.New(errors.CodeEmployeeCollectionPaymentMethodCodeExists)
}
return err
}
// toPaymentMethodResponse 将字典项投影为对外响应。
func toPaymentMethodResponse(paymentMethod *model.EmployeeCollectionPaymentMethod) *dto.EmployeeCollectionPaymentMethodResponse {
if paymentMethod == nil {
return nil
}
return &dto.EmployeeCollectionPaymentMethodResponse{
ID: paymentMethod.ID, Code: paymentMethod.Code, Name: paymentMethod.Name,
Enabled: paymentMethod.Status == constants.EmployeeCollectionPaymentMethodStatusEnabled,
Sort: paymentMethod.SortOrder, Remark: paymentMethod.Remark,
CreatedAt: paymentMethod.CreatedAt, UpdatedAt: paymentMethod.UpdatedAt,
}
}
// paymentMethodAuditConfigKey 生成字典项的审计配置键。
func paymentMethodAuditConfigKey(id uint) string {
return constants.EmployeeCollectionAuditConfigKeyPrefix + "." + strconv.FormatUint(uint64(id), 10)
}
// paymentMethodAuditResourceID 生成字典项审计资源标识。
func paymentMethodAuditResourceID(id uint) *string {
value := strconv.FormatUint(uint64(id), 10)
return &value
}
// paymentMethodAuditIdentity 生成字典项审计身份快照,不含任何凭证内容。
func paymentMethodAuditIdentity(paymentMethod *model.EmployeeCollectionPaymentMethod) map[string]any {
return map[string]any{
"id": paymentMethod.ID, "code": paymentMethod.Code, "name": paymentMethod.Name,
"status": paymentMethod.Status, "sort": paymentMethod.SortOrder,
}
}
// paymentMethodAuditSnapshot 生成字典项审计前后值快照,不含任何凭证内容。
func paymentMethodAuditSnapshot(paymentMethod *model.EmployeeCollectionPaymentMethod) map[string]any {
return map[string]any{
"id": paymentMethod.ID, "code": paymentMethod.Code, "name": paymentMethod.Name,
"status": paymentMethod.Status, "sort": paymentMethod.SortOrder, "remark": paymentMethod.Remark,
}
}

View File

@@ -1,161 +0,0 @@
package employeecollection
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RefundOffsetSource 是来源订单退款成功的事实快照。
type RefundOffsetSource struct {
// RefundID 表示本次退款申请 ID。
RefundID uint
// OrderID 表示退款关联的来源订单 ID。
OrderID uint
// RefundAmount 表示本次退款成功金额(分),与退款入账使用的金额为同一实参。
RefundAmount int64
}
// RefundOffsetService 在既有退款成功事务内冲销或提示员工代收款账单。
// 只处理来源为后台线下套餐订单的账单,其他订单直接跳过,不阻断退款链路。
type RefundOffsetService struct {
audit BillAuditWriter
}
// NewRefundOffsetService 创建退款冲销用例。
func NewRefundOffsetService(audit BillAuditWriter) *RefundOffsetService {
return &RefundOffsetService{audit: audit}
}
// ApplyInTx 在既有退款成功事务内按来源唯一键 order:{id} 查找账单并幂等写入冲销事实。
// 同一退款对同一账单至多一条关联:重复投递时关联写入影响 0 行,不再冲减、不再写审计、
// 也不依赖退款事务的 changed 标志。
func (s *RefundOffsetService) ApplyInTx(ctx context.Context, tx *gorm.DB, source RefundOffsetSource) error {
if s == nil || tx == nil || s.audit == nil {
return errors.New(errors.CodeInternalError, "员工代收款退款冲销能力未配置")
}
if source.RefundID == 0 || source.OrderID == 0 || source.RefundAmount <= 0 {
return errors.New(errors.CodeInvalidParam, "员工代收款退款冲销参数无效")
}
var bill model.EmployeeCollectionBill
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("source_key = ?", employeecollectiondomain.OrderSourceKey(source.OrderID)).
First(&bill).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// 来源订单未产生员工代收款账单,跳过而不阻断退款。
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定来源订单员工代收款账单失败")
}
decision, err := employeecollectiondomain.DecideRefundOffset(billAmounts(&bill), source.RefundAmount)
if err != nil {
return err
}
record := &model.EmployeeCollectionBillRefund{
BillID: bill.ID, RefundID: source.RefundID, SourceOrderID: source.OrderID,
RefundAmount: source.RefundAmount, BillReceivableAmount: bill.ReceivableAmount,
Outcome: decision.Outcome, ReducedAmount: decision.ReducedAmount,
}
result := tx.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "bill_id"}, {Name: "refund_id"}},
DoNothing: true,
}).Create(record)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入员工代收款退款冲销关联失败")
}
if result.RowsAffected == 0 {
// 同一退款已冲销过同一账单,保留既有事实。
return nil
}
before := bill
if err := applyRefundOutcome(ctx, tx, &bill, decision); err != nil {
return err
}
offsetEventID, err := composeAuditEventID(
"employee_collection", "bill", "order", uintText(source.OrderID), "refund", uintText(source.RefundID))
if err != nil {
return err
}
return s.audit.WriteEmployeeCollectionBill(ctx, tx, BillAudit{
EventID: offsetEventID,
ActionCode: constants.AuditActionEmployeeCollectionBillRefundOffseted,
Summary: constants.GetEmployeeCollectionRefundOutcomeName(decision.Outcome),
Bill: &bill,
BeforeData: map[string]any{
"receivable_amount": before.ReceivableAmount, "received_amount": before.ReceivedAmount,
"reserved_amount": before.ReservedAmount, "status": before.Status,
},
AfterData: map[string]any{
"receivable_amount": bill.ReceivableAmount, "received_amount": bill.ReceivedAmount,
"reserved_amount": bill.ReservedAmount, "status": bill.Status,
"refund_id": source.RefundID, "refund_amount": source.RefundAmount, "outcome": decision.Outcome,
},
CorrelationID: bill.SourceNo,
})
}
// applyRefundOutcome 按判定结果修改账单:全额退款关闭、部分冲减应收,提示结果不修改金额与状态。
// 关闭与冲减都使用 expected-status 条件更新并检查 RowsAffected避免并发覆盖。
func applyRefundOutcome(
ctx context.Context,
tx *gorm.DB,
bill *model.EmployeeCollectionBill,
decision employeecollectiondomain.RefundOffsetDecision,
) error {
expectedStatus := bill.Status
switch decision.Outcome {
case constants.EmployeeCollectionRefundOutcomeHintOnly:
return nil
case constants.EmployeeCollectionRefundOutcomeClosedFull:
closedAt := time.Now().UTC()
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND status = ?", bill.ID, expectedStatus).
Updates(map[string]any{
"status": constants.EmployeeCollectionBillStatusClosed,
"closed_reason": "来源订单全额退款",
"closed_at": closedAt,
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭来源订单全额退款账单失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "员工代收款账单状态已变化,退款冲销未完成")
}
bill.Status = constants.EmployeeCollectionBillStatusClosed
bill.ClosedReason = "来源订单全额退款"
bill.ClosedAt = &closedAt
return nil
case constants.EmployeeCollectionRefundOutcomeReduced:
amounts, err := billAmounts(bill).ReduceReceivable(decision.ReducedAmount)
if err != nil {
return err
}
nextStatus := amounts.DerivedStatus()
result := tx.WithContext(ctx).Model(&model.EmployeeCollectionBill{}).
Where("id = ? AND status = ?", bill.ID, expectedStatus).
Updates(map[string]any{
"receivable_amount": amounts.Receivable,
"status": nextStatus,
"updater": 0,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "冲减来源订单退款账单应收失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "员工代收款账单状态已变化,退款冲减未完成")
}
bill.ReceivableAmount = amounts.Receivable
bill.Status = nextStatus
return nil
default:
return errors.New(errors.CodeInternalError, "不支持的退款冲销处理结果")
}
}

View File

@@ -1,721 +0,0 @@
// 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

@@ -1,386 +0,0 @@
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

@@ -10,7 +10,6 @@ import (
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"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"
@@ -113,9 +112,8 @@ func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest
}
shop.Creator = operatorID
shop.Updater = operatorID
// 分销码在创建时随机生成且唯一;冲突时重新生成并重试,不提供人工指定或编辑入口。
if err := CreateShopWithDistributionCode(ctx, tx, shop); err != nil {
return nil, err
if err := tx.Create(shop).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建店铺失败")
}
account := &model.Account{
@@ -203,8 +201,6 @@ func (s *CreateService) fail(ctx context.Context, request *dto.CreateShopRequest
func shopCreationData(shop *model.Shop) map[string]any {
data := shopProfileData(shop)
data["shop_code"] = shop.ShopCode
// 分销码是本 Change 新增的建店事实,按脱敏值记录,口径与审批建店路径一致。
data["distribution_code_masked"] = distributiondomain.MaskDistributionCode(shop.DistributionCode)
data["parent_id"] = shop.ParentID
data["level"] = shop.Level
return data
@@ -289,8 +285,7 @@ func recordExists(tx *gorm.DB, target any, query string, value any) (bool, error
func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse {
return &dto.ShopResponse{
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode,
DistributionCode: shop.DistributionCode, ParentID: shop.ParentID,
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID,
BusinessOwnerAccountID: shop.BusinessOwnerAccountID,
ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName,
ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City,

View File

@@ -1,95 +0,0 @@
package shop
import (
"context"
stderrors "errors"
"reflect"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// distributionCodeConstraint 是分销码条件唯一索引名,用于识别唯一冲突并重试。
const distributionCodeConstraint = "uk_shop_distribution_code"
// distributionCodeSavepoint 是分销码冲突重试使用的保存点名称。
const distributionCodeSavepoint = "shop_distribution_code_retry"
// CreateShopWithDistributionCode 在事务内为新店铺生成全局唯一随机分销码并创建店铺。
//
// 每次尝试都重新生成随机码Create 命中分销码唯一约束时重新生成并重试,
// 最多 constants.ShopDistributionCodeMaxAttempts 次。其他唯一冲突(店铺编号等)不重试,
// 直接返回数据库错误。
//
// 重试依赖真实保存点:每条 Create 包在 GORM 的嵌套事务中执行,冲突时 GORM 自动
// 回滚到内部保存点外层事务因此仍可用PostgreSQL 唯一冲突会中止整个事务,
// 不回滚到保存点则后续语句必然 25P02重试不可能生效。这里刻意不使用裸
// SavePoint/RollbackToGORM 的嵌套事务会自行处理 PrepareStmt 下的连接池切换。
func CreateShopWithDistributionCode(ctx context.Context, tx *gorm.DB, shop *model.Shop) error {
if tx == nil || shop == nil {
return errors.New(errors.CodeInvalidParam, "创建店铺参数无效")
}
// 失败关闭:必须在调用方的事务句柄内执行,否则嵌套事务会自行开启并提交一个新事务,
// 破坏调用方的原子性(建店事务与注册审批通过事务均满足该前提)。
if !inTransaction(tx) {
return errors.New(errors.CodeInvalidStatus, "创建店铺必须传入事务句柄")
}
for range constants.ShopDistributionCodeMaxAttempts {
code, err := distributiondomain.GenerateDistributionCode()
if err != nil {
return err
}
if occupied, err := distributionCodeOccupied(ctx, tx, code); err != nil {
return err
} else if occupied {
// 预检命中直接换码,避免把可预期的冲突交给数据库。
continue
}
shop.DistributionCode = code
shop.ID = 0
createErr := tx.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
return inner.Create(shop).Error
})
if createErr == nil {
return nil
}
if !isDistributionCodeConflict(createErr) {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建店铺失败")
}
// 分销码冲突GORM 已回滚到内部保存点,外层事务仍可继续,换码重试。
}
return errors.New(errors.CodeConflict, "生成分销码冲突,请重试")
}
// inTransaction 判断句柄是否为已开启的事务,与 GORM 自身识别嵌套事务的方式一致。
func inTransaction(tx *gorm.DB) bool {
if tx == nil || tx.Statement == nil {
return false
}
committer, ok := tx.Statement.ConnPool.(gorm.TxCommitter)
return ok && committer != nil && !reflect.ValueOf(committer).IsNil()
}
// distributionCodeOccupied 预检分销码是否已被未删除店铺占用。
func distributionCodeOccupied(ctx context.Context, tx *gorm.DB, code string) (bool, error) {
var count int64
if err := tx.WithContext(ctx).Model(&model.Shop{}).
Where("distribution_code = ?", code).Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "校验分销码唯一性失败")
}
return count > 0, nil
}
// isDistributionCodeConflict 判断错误是否为分销码条件唯一索引冲突。
func isDistributionCodeConflict(err error) bool {
var pgErr *pgconn.PgError
if !stderrors.As(err, &pgErr) {
return false
}
return pgErr.Code == "23505" && pgErr.ConstraintName == distributionCodeConstraint
}

View File

@@ -18,19 +18,6 @@ import (
type UpdateService struct {
db *gorm.DB
audit accessauditapp.Writer
qualificationInvalidator WithdrawalQualificationInvalidator
}
// WithdrawalQualificationInvalidator 在店铺停用事务内联动失效提现资料资格。
// 接口定义在应用层,具体实现由装配注入,避免应用层依赖下游用例包。
type WithdrawalQualificationInvalidator interface {
InvalidateByShopDisable(ctx context.Context, tx *gorm.DB, shopID uint, reason string) error
}
// SetWithdrawalQualificationInvalidator 注入店铺停用联动的提现资料资格失效接缝。
// 未注入时停用不联动,用于不依赖该能力的旧装配路径。
func (s *UpdateService) SetWithdrawalQualificationInvalidator(invalidator WithdrawalQualificationInvalidator) {
s.qualificationInvalidator = invalidator
}
// NewUpdateService 创建店铺更新事务脚本。
@@ -94,8 +81,6 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
shop.Address = request.Address
shop.Status = request.Status
shop.Updater = operatorID
// 分销码创建后不可修改Save 写全列,这里显式保留加锁读取到的原值。
shop.DistributionCode = before.DistributionCode
if err := tx.Save(&shop).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新店铺失败")
}
@@ -125,14 +110,6 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
if err := s.writeStateAudits(ctx, tx, &before, &shop, parentShop, operatorID); err != nil {
return err
}
// 店铺停用必须使该店铺全部有效提现资料资格失效,且与停用同事务提交。
if before.Status != constants.ShopStatusDisabled && shop.Status == constants.ShopStatusDisabled &&
s.qualificationInvalidator != nil {
if err := s.qualificationInvalidator.InvalidateByShopDisable(
ctx, tx, shop.ID, "代理店铺已停用,提现资料资格自动失效"); err != nil {
return err
}
}
return nil
})
if err != nil {

View File

@@ -317,10 +317,6 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
{Code: constants.ApprovalFieldRemark, Name: "备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "员工提交线下代充值时填写的备注"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
{Code: constants.ApprovalFieldOfflinePaymentMethod, Name: "线下收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值使用的线下收款方式名称快照"},
{Code: constants.ApprovalFieldOfflinePaymentMethodCode, Name: "线下收款方式编码", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次充值使用的线下收款方式稳定编码快照"},
{Code: constants.ApprovalFieldExternalTransactionNo, Name: "交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "人工确认的第三方交易流水号,用于审批人核验;与在线渠道交易号无关"},
{Code: constants.ApprovalFieldOtherVoucherKey, Name: "其他凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的其他凭证列表"},
}, true
case constants.ApprovalBusinessTypeRefund:
return []dto.WeComBusinessFieldResponse{
@@ -337,70 +333,6 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeEmployeeCollection:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldCollectionApplicationID, Name: "核销申请 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "员工代收款核销申请的系统 ID"},
{Code: constants.ApprovalFieldCollectionPaymentMethod, Name: "线下收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款使用的线下收款方式名称快照"},
{Code: constants.ApprovalFieldCollectionPaidAmount, Name: "付款金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的人工确认付款金额"},
{Code: constants.ApprovalFieldCollectionPaidAmountCent, Name: "付款金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的人工确认付款金额整数"},
{Code: constants.ApprovalFieldCollectionPayerName, Name: "付款方", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款的付款方名称"},
{Code: constants.ApprovalFieldCollectionPaidAt, Name: "付款时间", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次外部付款时间RFC3339 格式"},
{Code: constants.ApprovalFieldCollectionExternalTransactionNo, Name: "外部交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "人工确认的第三方交易流水号,用于审批人核验"},
{Code: constants.ApprovalFieldPaymentVoucherKey, Name: "付款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的付款凭证列表"},
{Code: constants.ApprovalFieldRemark, Name: "备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "申请人填写的核销备注"},
{Code: constants.ApprovalFieldCollectionBillCount, Name: "分摊账单数量", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次核销分摊的账单数量"},
{Code: constants.ApprovalFieldCollectionBillSummary, Name: "账单分摊摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次各账单应收金额与分摊金额摘要"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeAgentDistribution:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldDistributionCode, Name: "分销码", ValueType: constants.ApprovalFieldValueTypeString, Description: "注册使用的上级店铺分销码脱敏值,仅用于审批人核对来源,不代表新建店铺的码"},
{Code: constants.ApprovalFieldDistributionParentShopID, Name: "上级店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "分销码所属上级店铺的系统 ID"},
{Code: constants.ApprovalFieldDistributionParentShopName, Name: "上级店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "分销码所属上级店铺名称快照"},
{Code: constants.ApprovalFieldDistributionShopName, Name: "申请店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺名称快照"},
{Code: constants.ApprovalFieldDistributionShopCode, Name: "申请店铺编号", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺编号快照,通过时按既有唯一约束校验"},
{Code: constants.ApprovalFieldDistributionUsername, Name: "代理账号用户名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的代理主账号用户名快照"},
{Code: constants.ApprovalFieldDistributionPhoneMasked, Name: "注册手机号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的注册手机号,禁止写入完整手机号"},
{Code: constants.ApprovalFieldDistributionContactName, Name: "联系人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册填写的联系人姓名"},
{Code: constants.ApprovalFieldDistributionRegion, Name: "注册地址摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "省市区与详细地址拼接的注册地址摘要"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeWithdrawalQualification:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldQualificationShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "提现资料资格所属代理店铺的系统 ID"},
{Code: constants.ApprovalFieldQualificationShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "提现资料资格所属代理店铺名称快照"},
{Code: constants.ApprovalFieldQualificationSubjectType, Name: "签约主体类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "签约主体类型中文名:企业或个人"},
{Code: constants.ApprovalFieldQualificationSubjectCodeMasked, Name: "签约主体代码", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的统一社会信用代码或身份证号,禁止写入完整证件号"},
{Code: constants.ApprovalFieldQualificationLegalPersonMasked, Name: "法人身份证号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的法人身份证号,禁止写入完整证件号"},
{Code: constants.ApprovalFieldQualificationContractKey, Name: "合同附件", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "合同对象存储 Key 列表(单个对象)"},
{Code: constants.ApprovalFieldQualificationIDCardFrontKey, Name: "法人身份证正面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证正面对象存储 Key 列表(单个对象)"},
{Code: constants.ApprovalFieldQualificationIDCardBackKey, Name: "法人身份证反面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证反面对象存储 Key 列表(单个对象)"},
{Code: constants.ApprovalFieldQualificationBusinessLicenseKey, Name: "营业执照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "营业执照对象存储 Key 列表(单个对象,可选)"},
{Code: constants.ApprovalFieldQualificationShopFrontKey, Name: "门头照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "门头照对象存储 Key 列表(单个对象,可选)"},
{Code: constants.ApprovalFieldQualificationInvoiceKey, Name: "发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "发票对象存储 Key 列表(单个对象,仅企业可选)"},
{Code: constants.ApprovalFieldQualificationInvoiceTitle, Name: "发票抬头", ValueType: constants.ApprovalFieldValueTypeString, Description: "发票抬头,仅企业填写且必须与合同主体一致"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypeCommissionWithdrawal:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldWithdrawalNo, Name: "提现单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提现申请单号"},
{Code: constants.ApprovalFieldWithdrawalAttemptNo, Name: "提交次序", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次为第几次提交,重提时递增"},
{Code: constants.ApprovalFieldWithdrawalShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "发起提现的代理店铺系统 ID"},
{Code: constants.ApprovalFieldWithdrawalShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "发起提现的代理店铺名称快照"},
{Code: constants.ApprovalFieldWithdrawalAmount, Name: "提现金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的提现金额"},
{Code: constants.ApprovalFieldWithdrawalAmountCent, Name: "提现金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的提现金额整数"},
{Code: constants.ApprovalFieldWithdrawalFee, Name: "手续费", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的本次手续费"},
{Code: constants.ApprovalFieldWithdrawalActualAmount, Name: "实际到账金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的实际到账金额"},
{Code: constants.ApprovalFieldWithdrawalMethod, Name: "收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款方式名称快照"},
{Code: constants.ApprovalFieldWithdrawalAccountName, Name: "收款人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款人姓名"},
{Code: constants.ApprovalFieldWithdrawalAccountNumber, Name: "收款账号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款账号,供审批人核验打款"},
{Code: constants.ApprovalFieldWithdrawalInvoiceKey, Name: "申请级发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "本次申请级发票对象存储 Key 列表,无发票时为空数组"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
default:
return nil, false
}
@@ -421,36 +353,14 @@ func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComCon
}
func validApprovalBusinessType(businessType string) bool {
switch businessType {
case constants.ApprovalBusinessTypeRefund,
constants.ApprovalBusinessTypeOfflineRecharge,
constants.ApprovalBusinessTypeEmployeeCollection,
constants.ApprovalBusinessTypeAgentDistribution,
constants.ApprovalBusinessTypeWithdrawalQualification,
constants.ApprovalBusinessTypeCommissionWithdrawal:
return true
default:
return false
}
return businessType == constants.ApprovalBusinessTypeRefund || businessType == constants.ApprovalBusinessTypeOfflineRecharge
}
func approvalBusinessTypeName(businessType string) string {
switch businessType {
case constants.ApprovalBusinessTypeRefund:
if businessType == constants.ApprovalBusinessTypeRefund {
return "退款审批"
case constants.ApprovalBusinessTypeOfflineRecharge:
return "员工线下代充值审批"
case constants.ApprovalBusinessTypeEmployeeCollection:
return "员工代收款核销审批"
case constants.ApprovalBusinessTypeAgentDistribution:
return "代理扫码分销注册审批"
case constants.ApprovalBusinessTypeWithdrawalQualification:
return "提现资料资格审批"
case constants.ApprovalBusinessTypeCommissionWithdrawal:
return "佣金提现终审"
default:
return "未知审批业务类型"
}
return "员工线下代充值审批"
}
func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any {

View File

@@ -1,9 +1,6 @@
package bootstrap
import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
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"
@@ -24,8 +21,6 @@ import (
agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit"
distributionwithdrawalQuery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
employeecollectionQuery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
@@ -91,6 +86,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
personalCustomerOpenIDStore,
personalCustomerStore,
personalCustomerPhoneStore,
svc.WechatConfig,
svc.Order,
packageSeriesStore,
shopSeriesAllocationStore,
@@ -112,13 +108,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
systemConfigCache = systemConfigInfra.NewRedisCache(deps.Redis)
}
systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts)
if svc.AgentRechargeOnline != nil {
svc.AgentRechargeOnline.SetPaymentMethodPolicy(agentrechargeApp.NewOnlinePaymentMethodPolicy(systemConfigReader))
}
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 {
@@ -181,7 +173,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, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
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.SetPaymentMethodPolicy(paymentMethodPolicy)
handler.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
return handler
@@ -204,10 +196,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Shop: func() *admin.ShopHandler {
handler := admin.NewShopHandler(svc.Shop, validate)
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
updateService := shopApp.NewUpdateService(deps.DB, svc.AccessAudit)
// 店铺停用必须联动失效提现资料资格,接入点在同一停用事务内。
updateService.SetWithdrawalQualificationInvalidator(svc.WithdrawalQualification)
handler.SetUpdateService(updateService)
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB, svc.AccessAudit))
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB, svc.AccessAudit))
return handler
@@ -215,15 +204,10 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: func() *admin.ShopCommissionHandler {
handler := admin.NewShopCommissionHandler(svc.ShopCommission, validate)
handler := admin.NewShopCommissionHandler(svc.ShopCommission)
handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB))
handler.SetWithdrawalQuery(distributionwithdrawalQuery.NewQuery(deps.DB))
return handler
}(),
WithdrawalQualification: admin.NewWithdrawalQualificationHandler(
svc.WithdrawalQualification, distributionwithdrawalQuery.NewQuery(deps.DB), validate,
),
AgentDistribution: app.NewAgentDistributionHandler(svc.DistributionRegistration, validate),
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate),
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
@@ -301,24 +285,10 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
return h
}(),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
PaymentMerchant: admin.NewPaymentMerchantHandler(merchantPaymentApp.NewManagementService(deps.DB, systemConfigAudit)),
EmployeeCollection: func() *admin.EmployeeCollectionHandler {
handler := admin.NewEmployeeCollectionHandler(
employeecollectionApp.NewPaymentMethodService(deps.DB, svc.AccessAudit),
employeecollectionApp.NewBillCloseService(deps.DB, svc.AccessAudit),
employeecollectionApp.NewApplicationService(deps.DB, svc.Approval, svc.AccessAudit),
)
handler.SetPaymentMethodQuery(employeecollectionQuery.NewPaymentMethodQuery(deps.DB))
handler.SetBillQuery(employeecollectionQuery.NewBillQuery(deps.DB))
handler.SetApplicationQuery(employeecollectionQuery.NewApplicationQuery(deps.DB))
return handler
}(),
AgentRecharge: func() *admin.AgentRechargeHandler {
handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate)
handler.SetOnlineCreationService(svc.AgentRechargeOnline)
handler.SetPaymentStatusQuery(agentRechargeQuery.NewPaymentStatusQuery(deps.DB))
handler.SetPaymentVoucherOCRService(svc.AgentRechargeVoucherOCR)
handler.SetSystemConfigUpdateService(systemConfigUpdate)
return handler
}(),
Refund: admin.NewRefundHandler(svc.Refund),

View File

@@ -15,7 +15,6 @@ func registerPaymentMethodConfigDefinitions(registry *systemconfig.Registry, log
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigPaymentAllowedCard, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "卡资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue},
{Key: constants.SystemConfigPaymentAllowedDevice, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "设备资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue},
{Key: constants.SystemConfigAgentSelfRechargeAllowedMethods, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeString, DefaultValue: constants.AgentSelfRechargeAllowedBoth, Description: "代理在线自充允许的支付方式范围", Control: "payment_methods", EnumValues: []string{constants.AgentSelfRechargeAllowedWechatOnly, constants.AgentSelfRechargeAllowedAlipayOnly, constants.AgentSelfRechargeAllowedBoth}},
}
for _, definition := range definitions {
if existing, exists := registry.Get(definition.Key); exists {

View File

@@ -8,10 +8,7 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
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"
@@ -90,9 +87,6 @@ type services struct {
Shop *shopSvc.Service
Auth *authSvc.Service
ShopCommission *shopCommissionSvc.Service
DistributionRegistration *distributionwithdrawalApp.RegistrationService
WithdrawalQualification *distributionwithdrawalApp.QualificationService
WithdrawalApproval *distributionwithdrawalApp.WithdrawalService
CommissionWithdrawal *commissionWithdrawalSvc.Service
CommissionWithdrawalSetting *commissionWithdrawalSettingSvc.Service
CommissionCalculation *commissionCalculationSvc.Service
@@ -133,7 +127,6 @@ type services struct {
AgentRecharge *agentRechargeSvc.Service
AgentRechargeOnline *agentrechargeApp.OnlineCreationService
AgentRechargePaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService
AgentRechargeVoucherOCR *agentrechargeApp.PaymentVoucherOCRService
PackageActivation *packageSvc.ActivationService
Refund *refundSvc.Service
TrafficQuery *trafficSvc.QueryService
@@ -271,9 +264,6 @@ func initServices(s *stores, deps *Dependencies) *services {
packageSeriesService := packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package)
packageSeriesService.SetAccessAudit(deps.DB, auditWriter)
orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone)
// 员工代收款建账用例在订单、充值入账与退款冲销的事务内复用同一实例。
employeeCollectionBillCreation := employeecollectionApp.NewBillCreationService(auditWriter)
orderService.SetEmployeeCollectionBillCreation(employeeCollectionBillCreation)
orderService.SetResumeCallback(stopResumeService)
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
@@ -297,7 +287,6 @@ 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),
@@ -308,11 +297,6 @@ func initServices(s *stores, deps *Dependencies) *services {
paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()),
auditWriter,
)
// 付款凭证识别需要对象存储与 Gateway任一缺失时不装配接口统一返回能力未配置。
var agentRechargeVoucherOCR *agentrechargeApp.PaymentVoucherOCRService
if deps.StorageService != nil && deps.GatewayClient != nil {
agentRechargeVoucherOCR = agentrechargeApp.NewPaymentVoucherOCRService(deps.StorageService.Provider(), deps.GatewayClient)
}
refundService := refundSvc.New(
deps.DB,
s.RefundRequest,
@@ -330,7 +314,6 @@ 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())))
@@ -366,10 +349,6 @@ func initServices(s *stores, deps *Dependencies) *services {
agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService, auditWriter),
)
agentRechargeService.SetRechargeAudit(auditWriter)
agentRechargeService.SetEmployeeCollectionBillCreation(employeeCollectionBillCreation)
refundService.SetEmployeeCollectionRefundOffset(
employeecollectionApp.NewRefundOffsetService(auditWriter),
)
refundService.SetRefundApprovalCreationService(
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService, auditWriter),
)
@@ -381,18 +360,6 @@ func initServices(s *stores, deps *Dependencies) *services {
shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
commissionWithdrawal := commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest)
commissionWithdrawal.SetAuditWriter(auditWriter)
// 代理分销注册、提现资格与提现终审共用同一审计 Writer 与通用审批创建接缝。
distributionRegistration := distributionwithdrawalApp.NewRegistrationService(
deps.DB, deps.VerificationService, approvalCreationService, auditWriter,
)
withdrawalQualification := distributionwithdrawalApp.NewQualificationService(
deps.DB, approvalCreationService, auditWriter,
)
withdrawalApproval := distributionwithdrawalApp.NewWithdrawalService(
deps.DB, approvalCreationService, auditWriter,
)
shopCommission.SetWithdrawalApprovalService(withdrawalApproval)
shopService.SetWithdrawalQualificationInvalidator(withdrawalQualification)
commissionCalculation := commissionCalculationSvc.New(
deps.DB,
s.CommissionRecord,
@@ -447,9 +414,6 @@ func initServices(s *stores, deps *Dependencies) *services {
Shop: shopService,
Auth: authService,
ShopCommission: shopCommission,
DistributionRegistration: distributionRegistration,
WithdrawalQualification: withdrawalQualification,
WithdrawalApproval: withdrawalApproval,
CommissionWithdrawal: commissionWithdrawal,
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
CommissionCalculation: commissionCalculation,
@@ -490,7 +454,6 @@ func initServices(s *stores, deps *Dependencies) *services {
AgentRecharge: agentRechargeService,
AgentRechargeOnline: agentRechargeOnline,
AgentRechargePaymentConfirm: agentRechargePaymentConfirm,
AgentRechargeVoucherOCR: agentRechargeVoucherOCR,
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword,

View File

@@ -29,8 +29,6 @@ type Handlers struct {
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
ShopCommission *admin.ShopCommissionHandler
WithdrawalQualification *admin.WithdrawalQualificationHandler
AgentDistribution *app.AgentDistributionHandler
CommissionWithdrawal *admin.CommissionWithdrawalHandler
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
Enterprise *admin.EnterpriseHandler
@@ -70,8 +68,6 @@ type Handlers struct {
AssetLifecycle *admin.AssetLifecycleHandler
AssetWallet *admin.AssetWalletHandler
WechatConfig *admin.WechatConfigHandler
PaymentMerchant *admin.PaymentMerchantHandler
EmployeeCollection *admin.EmployeeCollectionHandler
AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler

View File

@@ -32,9 +32,6 @@ type PaymentConfirmationFacts struct {
PaymentConfigID uint
RechargePaymentConfigID uint
ConfirmedConfigID uint
FrozenMerchant bool
FrozenMerchantPaymentMethod string
FrozenMerchantProviderType string
MerchantIdentity string
ConfirmedMerchantIdentity string
PaymentAmount int64
@@ -63,20 +60,7 @@ func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
identity := strings.TrimSpace(facts.MerchantIdentity)
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 ||
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

@@ -1,188 +0,0 @@
// Package distribution 收口代理分销注册、提现资格与提现审批的领域不变量。
// 本包不依赖 Fiber、GORM、Redis、Asynq 或具体第三方 SDK。
package distribution
import (
"crypto/rand"
"encoding/hex"
"strconv"
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
const (
distributionCodeBytes = 16
phoneMaskedKeepPrefix = 3
phoneMaskedKeepSuffix = 4
codeMaskedKeepPrefix = 4
codeMaskedKeepSuffix = 4
)
// GenerateDistributionCode 生成 32 位十六进制随机分销码。
// 唯一性由数据库条件唯一索引兜底,调用方在冲突时重新生成。
func GenerateDistributionCode() (string, error) {
buf := make([]byte, distributionCodeBytes)
if _, err := rand.Read(buf); err != nil {
return "", errors.Wrap(errors.CodeInternalError, err, "生成分销码失败")
}
return hex.EncodeToString(buf), nil
}
// ValidateRegistrationInput 规范化并校验扫码注册输入。
// 手机号、用户名、店铺编号与店铺名称由公开接口必填;密码长度沿用账号体系既有下限。
func ValidateRegistrationInput(input RegistrationInput) (RegistrationInput, error) {
input.DistributionCode = strings.TrimSpace(input.DistributionCode)
input.Phone = strings.TrimSpace(input.Phone)
input.Username = strings.TrimSpace(input.Username)
input.ShopName = strings.TrimSpace(input.ShopName)
input.ShopCode = strings.TrimSpace(input.ShopCode)
input.ContactName = strings.TrimSpace(input.ContactName)
input.Province = strings.TrimSpace(input.Province)
input.City = strings.TrimSpace(input.City)
input.District = strings.TrimSpace(input.District)
input.Address = strings.TrimSpace(input.Address)
if input.DistributionCode == "" || input.Phone == "" || input.Username == "" ||
input.ShopName == "" || input.ShopCode == "" {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不可用")
}
if len(input.Phone) != 11 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确")
}
if len(input.Username) < 3 || len(input.Username) > 50 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名长度必须为 3 至 50 个字符")
}
if len(input.Password) < 6 || len(input.Password) > 64 {
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "密码长度必须为 6 至 64 个字符")
}
return input, nil
}
// ValidateQualificationInput 规范化并校验提现资料资格输入。
// 企业主体必须填写统一社会信用代码,个人主体必须填写法人身份证号;
// 发票仅企业可选,且抬头与统一社会信用代码必须与签约主体一致。
func ValidateQualificationInput(input QualificationInput) (QualificationInput, error) {
input.SubjectCode = strings.TrimSpace(input.SubjectCode)
input.LegalPersonIDCard = strings.TrimSpace(input.LegalPersonIDCard)
input.ContractFileKey = strings.TrimSpace(input.ContractFileKey)
input.IDCardFrontFileKey = strings.TrimSpace(input.IDCardFrontFileKey)
input.IDCardBackFileKey = strings.TrimSpace(input.IDCardBackFileKey)
input.BusinessLicenseFileKey = strings.TrimSpace(input.BusinessLicenseFileKey)
input.ShopFrontFileKey = strings.TrimSpace(input.ShopFrontFileKey)
input.InvoiceFileKey = strings.TrimSpace(input.InvoiceFileKey)
input.InvoiceTitle = strings.TrimSpace(input.InvoiceTitle)
input.InvoiceSubjectCode = strings.TrimSpace(input.InvoiceSubjectCode)
switch input.SubjectType {
case constants.WithdrawalQualificationSubjectTypeEnterprise:
if input.SubjectCode == "" || input.LegalPersonIDCard == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "企业主体必须填写统一社会信用代码与法人身份证号")
}
case constants.WithdrawalQualificationSubjectTypePersonal:
if input.LegalPersonIDCard == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "个人主体必须填写法人身份证号")
}
if input.SubjectCode == "" {
// 个人主体的签约主体代码即法人身份证号。
input.SubjectCode = input.LegalPersonIDCard
}
default:
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "签约主体类型无效")
}
if input.SubjectCode != input.LegalPersonIDCard && input.SubjectType == constants.WithdrawalQualificationSubjectTypePersonal {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "个人主体的签约主体代码必须与法人身份证号一致")
}
if input.ContractFileKey == "" || input.IDCardFrontFileKey == "" || input.IDCardBackFileKey == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "合同与法人身份证正反面附件必须填写")
}
if input.SubjectType == constants.WithdrawalQualificationSubjectTypePersonal &&
(input.InvoiceFileKey != "" || input.InvoiceTitle != "" || input.InvoiceSubjectCode != "") {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "发票资料仅企业主体可提交")
}
if input.InvoiceFileKey != "" {
if input.InvoiceTitle == "" || input.InvoiceSubjectCode == "" {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "提交发票时必须填写抬头与统一社会信用代码")
}
if input.InvoiceSubjectCode != input.SubjectCode {
return QualificationInput{}, errors.New(errors.CodeInvalidParam, "发票统一社会信用代码必须与合同主体一致")
}
}
// 附件上限由结构保证:资格只有合同、法人身份证正反面、营业执照、门头照、发票共 6 个
// 单对象键字段,天然不超过企业微信单张审批单 6 个附件上限,无需运行时计数校验。
return input, nil
}
// MaskPhone 生成脱敏手机号,仅保留前 3 位与后 4 位。
// 日志与审计不得记录完整手机号。
func MaskPhone(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < phoneMaskedKeepPrefix+phoneMaskedKeepSuffix {
return ""
}
return phone[:phoneMaskedKeepPrefix] + "****" + phone[len(phone)-phoneMaskedKeepSuffix:]
}
// MaskSubjectCode 生成脱敏证件号或统一社会信用代码,仅保留前 4 位与后 4 位。
// 日志与审计不得记录完整证件号。
func MaskSubjectCode(code string) string {
code = strings.TrimSpace(code)
if len(code) < codeMaskedKeepPrefix+codeMaskedKeepSuffix {
return ""
}
return code[:codeMaskedKeepPrefix] + "**********" + code[len(code)-codeMaskedKeepSuffix:]
}
// MaskDistributionCode 生成脱敏分销码,仅保留首尾片段。
// 分销码是可枚举的公开入口标识,日志与审计只记录脱敏值。
func MaskDistributionCode(code string) string {
code = strings.TrimSpace(code)
if len(code) < codeMaskedKeepPrefix+codeMaskedKeepSuffix {
return ""
}
return code[:codeMaskedKeepPrefix] + "****" + code[len(code)-codeMaskedKeepSuffix:]
}
// FormatCentYuan 将分金额格式化为两位小数的元字符串,仅用于审批表单与展示。
func FormatCentYuan(amount int64) string {
return strconv.FormatInt(amount/100, 10) + "." +
pad2(strconv.FormatInt(amount%100, 10))
}
// pad2 将 0 至 99 的十进制文本左补零到两位。
func pad2(value string) string {
if len(value) >= 2 {
return value
}
return "0" + value
}
// RegistrationInput 是公开扫码注册的规范化输入。
type RegistrationInput struct {
DistributionCode string
Phone string
Username string
Password string
ShopName string
ShopCode string
ContactName string
Province string
City string
District string
Address string
}
// QualificationInput 是提现资料资格的规范化输入。
type QualificationInput struct {
SubjectType string
SubjectCode string
LegalPersonIDCard string
ContractFileKey string
IDCardFrontFileKey string
IDCardBackFileKey string
BusinessLicenseFileKey string
ShopFrontFileKey string
InvoiceFileKey string
InvoiceTitle string
InvoiceSubjectCode string
}

View File

@@ -1,58 +0,0 @@
package employeecollection
import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AllocationCandidate 是一笔待校验的核销申请账单分摊候选。
type AllocationCandidate struct {
// BillID 表示目标账单ID。
BillID uint
// BillStatus 表示目标账单当前持久化状态。
BillStatus int
// Amount 表示本次分摊金额(分)。
Amount int64
// Available 表示目标账单当前可核销余额(分),已扣除已通过分摊与其他审批中预占。
Available int64
}
// ValidateAllocations 校验核销申请的账单分摊集合。
// 规则:付款金额为正、分摊数量在允许区间、账单不重复、账单已关闭时拒绝、
// 单笔分摊为正且不超过该账单可核销余额、分摊总额不超过本次付款金额。
func ValidateAllocations(paidAmount int64, candidates []AllocationCandidate) error {
if paidAmount <= 0 {
return errors.New(errors.CodeInvalidParam, "付款金额必须大于零")
}
if len(candidates) == 0 {
return errors.New(errors.CodeInvalidParam, "核销申请至少需要一个账单分摊")
}
if len(candidates) > constants.EmployeeCollectionAllocationMaxCount {
return errors.New(errors.CodeInvalidParam, "核销申请账单分摊数量超出限制")
}
seen := make(map[uint]struct{}, len(candidates))
var total int64
for _, candidate := range candidates {
if candidate.BillID == 0 {
return errors.New(errors.CodeInvalidParam, "账单分摊缺少目标账单")
}
if _, exists := seen[candidate.BillID]; exists {
return errors.New(errors.CodeInvalidParam, "同一账单不能重复分摊")
}
seen[candidate.BillID] = struct{}{}
if candidate.BillStatus == constants.EmployeeCollectionBillStatusClosed {
return errors.New(errors.CodeEmployeeCollectionBillClosed)
}
if candidate.Amount <= 0 {
return errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if candidate.Amount > candidate.Available {
return errors.New(errors.CodeEmployeeCollectionAllocationExceeded)
}
total += candidate.Amount
}
if total > paidAmount {
return errors.New(errors.CodeEmployeeCollectionPaidAmountExceeded)
}
return nil
}

View File

@@ -1,110 +0,0 @@
package employeecollection
import (
"strings"
"time"
"unicode/utf8"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ApplicationInput 是核销申请提交的领域输入;账单分摊由 ValidateAllocations 单独校验。
type ApplicationInput struct {
// PaidAmount 表示人工确认的付款金额(分)。
PaidAmount int64
// PayerName 表示付款方名称。
PayerName string
// PaidAt 表示付款时间。
PaidAt time.Time
// ExternalTransactionNo 表示人工确认的外部交易流水号。
ExternalTransactionNo string
// Remark 表示申请备注。
Remark string
// ActingReason 表示代办原因,仅代办提交时必填。
ActingReason string
// PaymentVoucherKeys 表示支付凭证对象存储键列表。
PaymentVoucherKeys []string
}
// NormalizedApplicationInput 是通过校验并去空格后的核销申请事实。
type NormalizedApplicationInput struct {
PaidAmount int64
PayerName string
PaidAt time.Time
ExternalTransactionNo string
Remark string
ActingReason string
PaymentVoucherKeys []string
}
// NormalizeApplicationInput 校验并规范化核销申请输入。
// acting 表示本次是否由超级管理员为他人代办:代办必须填写原因,本人办理不得填写原因。
func NormalizeApplicationInput(input ApplicationInput, acting bool) (NormalizedApplicationInput, error) {
if input.PaidAmount <= 0 {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款金额必须大于零")
}
if input.PaidAt.IsZero() {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款时间必填")
}
payerName := strings.TrimSpace(input.PayerName)
if payerName == "" {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款方名称必填")
}
if utf8.RuneCountInString(payerName) > constants.EmployeeCollectionPayerNameMaxLength {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "付款方名称长度超出限制")
}
externalTransactionNo := strings.TrimSpace(input.ExternalTransactionNo)
if externalTransactionNo == "" {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "外部交易流水号必填")
}
if utf8.RuneCountInString(externalTransactionNo) > constants.EmployeeCollectionExternalTransactionNoMaxLength {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "外部交易流水号长度超出限制")
}
remark := strings.TrimSpace(input.Remark)
if utf8.RuneCountInString(remark) > constants.EmployeeCollectionRemarkMaxLength {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "核销申请备注长度超出限制")
}
actingReason := strings.TrimSpace(input.ActingReason)
if utf8.RuneCountInString(actingReason) > constants.EmployeeCollectionRemarkMaxLength {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "代办原因长度超出限制")
}
if acting && actingReason == "" {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "超级管理员代办核销申请必须填写代办原因")
}
if !acting && actingReason != "" {
return NormalizedApplicationInput{}, errors.New(errors.CodeInvalidParam, "本人办理核销申请不能填写代办原因")
}
vouchers, err := NormalizePaymentVouchers(input.PaymentVoucherKeys)
if err != nil {
return NormalizedApplicationInput{}, err
}
return NormalizedApplicationInput{
PaidAmount: input.PaidAmount, PayerName: payerName,
PaidAt: input.PaidAt.UTC(), ExternalTransactionNo: externalTransactionNo,
Remark: remark, ActingReason: actingReason, PaymentVoucherKeys: vouchers,
}, nil
}
// ValidateApplicationResubmit 校验申请当前状态允许修改并重提。
// 只有企业微信最终驳回的申请可以修改重提;已通过、审批中与异常终态一律拒绝。
func ValidateApplicationResubmit(status int) error {
if status == constants.EmployeeCollectionApplicationStatusRejected {
return nil
}
return errors.New(errors.CodeEmployeeCollectionApplicationStatusInvalid)
}
// MaskExternalTransactionNo 生成外部交易流水号的脱敏展示,用于审计与日志,不保留完整流水。
// 长度不超过 8 时整体掩码,否则保留首尾各 4 位。
func MaskExternalTransactionNo(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
runes := []rune(trimmed)
if len(runes) <= 8 {
return "****"
}
return string(runes[:4]) + "****" + string(runes[len(runes)-4:])
}

View File

@@ -1,51 +0,0 @@
package employeecollection
import (
"strings"
"github.com/bytedance/sonic"
)
// ExtractApprovalOpinion 从通用审批实例的终态决策快照中提取审批意见文本。
// 快照来自渠道审批详情(`info` 对象),审批意见位于 `comments[].comment_content`
// 兼容 `content` 与 `text` 两种等价键。取最后一条非空意见作为最终审批意见。
// 无法解析或没有意见时返回空字符串:意见缺失不影响申请与账单事实。
func ExtractApprovalOpinion(snapshot []byte) string {
if len(snapshot) == 0 {
return ""
}
var payload map[string]any
if err := sonic.Unmarshal(snapshot, &payload); err != nil {
return ""
}
opinion := ""
if raw, ok := payload["comments"].([]any); ok {
for _, item := range raw {
comment, ok := item.(map[string]any)
if !ok {
continue
}
if content := commentText(comment); content != "" {
opinion = content
}
}
}
if opinion != "" {
return opinion
}
return commentText(payload)
}
// commentText 按优先顺序读取审批意见文本。
func commentText(container map[string]any) string {
for _, key := range []string{"comment_content", "content", "text"} {
value, ok := container[key].(string)
if !ok {
continue
}
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View File

@@ -1,173 +0,0 @@
// Package employeecollection 收口员工代收款账单的金额、状态与预占不变量。
// 只依赖标准库、领域常量和稳定错误,不依赖传输、持久化或外部 SDK。
package employeecollection
import (
"strings"
"unicode/utf8"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// BillAmounts 描述一张员工代收款账单的应收、已核销、审批中预占与关闭事实。
type BillAmounts struct {
// Receivable 表示应收金额(分),来源成功事务判定后不允许为负。
Receivable int64
// Received 表示企业微信最终通过后累计的已核销金额(分)。
Received int64
// Reserved 表示审批中分摊预占的金额(分)。
Reserved int64
// Closed 表示账单是否已关闭;已关闭账单的可核销余额为 0。
Closed bool
}
// NewBillAmounts 依据来源应收金额构造初始账单金额事实。
// 应收金额必须大于零,避免零元账单立即成为已核销。
func NewBillAmounts(receivable int64) (BillAmounts, error) {
amounts := BillAmounts{Receivable: receivable}
if err := amounts.Validate(); err != nil {
return BillAmounts{}, err
}
return amounts, nil
}
// Validate 校验账单金额不变量:应收为正,已核销与预占非负且合计不超过应收。
func (a BillAmounts) Validate() error {
if a.Receivable <= 0 {
return errors.New(errors.CodeInvalidParam, "账单应收金额必须大于零")
}
if a.Received < 0 || a.Reserved < 0 {
return errors.New(errors.CodeInvalidParam, "账单已核销与预占金额不能为负")
}
if a.Received+a.Reserved > a.Receivable {
return errors.New(errors.CodeInvalidParam, "账单已核销与预占金额合计不能超过应收金额")
}
return nil
}
// Available 返回账单当前可被新分摊占用的金额;已关闭账单始终返回 0。
func (a BillAmounts) Available() int64 {
if a.Closed {
return 0
}
available := a.Receivable - a.Received - a.Reserved
if available < 0 {
return 0
}
return available
}
// DerivedStatus 依据金额推导未关闭账单的核销状态。
// 调用方必须自行区分已关闭账单,关闭状态不可由金额推导。
func (a BillAmounts) DerivedStatus() int {
switch {
case a.Received <= 0:
return constants.EmployeeCollectionBillStatusPending
case a.Received >= a.Receivable:
return constants.EmployeeCollectionBillStatusSettled
default:
return constants.EmployeeCollectionBillStatusPartial
}
}
// Reserve 在审批中预占指定金额,返回预占后的新金额事实。
// 分摊金额必须大于零且不超过当前可核销余额。
func (a BillAmounts) Reserve(amount int64) (BillAmounts, error) {
if amount <= 0 {
return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if err := a.ensureSettleable(); err != nil {
return BillAmounts{}, err
}
if amount > a.Available() {
return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationExceeded)
}
a.Reserved += amount
return a, nil
}
// Release 释放指定金额的审批中预占,返回释放后的新金额事实。
func (a BillAmounts) Release(amount int64) (BillAmounts, error) {
if amount <= 0 {
return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if amount > a.Reserved {
return BillAmounts{}, errors.New(errors.CodeInternalError, "释放的预占金额超过账单当前预占")
}
a.Reserved -= amount
return a, nil
}
// Approve 将指定金额从审批中预占转入已核销,返回通过后的新金额事实。
func (a BillAmounts) Approve(amount int64) (BillAmounts, error) {
if amount <= 0 {
return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionAllocationAmountInvalid)
}
if amount > a.Reserved {
return BillAmounts{}, errors.New(errors.CodeInternalError, "通过的分摊金额超过账单当前预占")
}
a.Reserved -= amount
a.Received += amount
return a, nil
}
// ReduceReceivable 按来源订单退款金额冲减应收,仅在账单不存在任何已通过或审批中分摊时允许。
func (a BillAmounts) ReduceReceivable(amount int64) (BillAmounts, error) {
if amount <= 0 {
return BillAmounts{}, errors.New(errors.CodeInvalidParam, "冲减金额必须大于零")
}
if a.Received > 0 || a.Reserved > 0 {
return BillAmounts{}, errors.New(errors.CodeEmployeeCollectionBillNotSettleable, "账单存在分摊,不能冲减应收")
}
if amount >= a.Receivable {
return BillAmounts{}, errors.New(errors.CodeInvalidParam, "冲减金额必须小于账单应收金额")
}
a.Receivable -= amount
return a, nil
}
// ensureSettleable 校验账单允许产生新的审批中分摊。
func (a BillAmounts) ensureSettleable() error {
if a.Closed {
return errors.New(errors.CodeEmployeeCollectionBillClosed)
}
if a.DerivedStatus() == constants.EmployeeCollectionBillStatusSettled {
return errors.New(errors.CodeEmployeeCollectionBillNotSettleable)
}
return nil
}
// BillCloseInput 描述关闭一张账单前的事实。
type BillCloseInput struct {
// Status 表示账单当前持久化状态。
Status int
// PendingAllocations 表示账单上仍处于审批中(预占)的分摊数量。
PendingAllocations int64
// Reason 表示关闭原因,必填。
Reason string
}
// ValidateBillClose 校验关闭账单的前置条件。
// 已关闭账单返回账单已关闭,已核销账单不允许关闭,存在审批中分摊时拒绝关闭,关闭原因必填。
func ValidateBillClose(input BillCloseInput) error {
if input.Status == constants.EmployeeCollectionBillStatusClosed {
return errors.New(errors.CodeEmployeeCollectionBillClosed)
}
if input.Status == constants.EmployeeCollectionBillStatusSettled {
return errors.New(errors.CodeEmployeeCollectionBillNotSettleable, "已核销账单没有未核销余额,不能关闭")
}
if input.Status != constants.EmployeeCollectionBillStatusPending &&
input.Status != constants.EmployeeCollectionBillStatusPartial {
return errors.New(errors.CodeConflict, "账单当前状态不允许关闭")
}
if input.PendingAllocations > 0 {
return errors.New(errors.CodeEmployeeCollectionApplicationPending)
}
if trimmed := strings.TrimSpace(input.Reason); trimmed == "" {
return errors.New(errors.CodeInvalidParam, "关闭原因必填")
} else if utf8.RuneCountInString(trimmed) > constants.EmployeeCollectionRemarkMaxLength {
return errors.New(errors.CodeInvalidParam, "关闭原因长度超出限制")
}
return nil
}

View File

@@ -1,56 +0,0 @@
package employeecollection
import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// OrderBillSubject 是判定后台线下套餐订单是否建账所需的来源事实。
// 四个条件必须同时成立,判定只由 ShouldCreateBillForOrder 一处实现。
type OrderBillSubject struct {
// PaymentMethod 表示订单支付方式快照。
PaymentMethod string
// OperatorAccountType 表示实际操作账号类型快照。
OperatorAccountType string
// ActualPaidAmount 表示订单实际支付金额(分),空表示来源未产生实收金额。
ActualPaidAmount *int64
// HasGiftPackage 表示订单是否包含赠送套餐。
HasGiftPackage bool
}
// OrderBillSubjectFromOrder 从已冻结的订单事实提取建账判据输入。
// 建账与创建时付款凭证放宽必须使用同一份输入,避免出现两套口径。
func OrderBillSubjectFromOrder(order *model.Order, hasGiftPackage bool) OrderBillSubject {
if order == nil {
return OrderBillSubject{}
}
return OrderBillSubject{
PaymentMethod: order.PaymentMethod,
OperatorAccountType: order.OperatorAccountType,
ActualPaidAmount: order.ActualPaidAmount,
HasGiftPackage: hasGiftPackage,
}
}
// ShouldCreateBillForOrder 判定后台线下套餐订单是否触发员工代收款建账。
// 判据:支付方式为线下、实际操作账号为平台账号、订单不含赠送套餐、实收金额大于零。
func ShouldCreateBillForOrder(subject OrderBillSubject) bool {
return subject.PaymentMethod == model.PaymentMethodOffline &&
subject.OperatorAccountType == model.OperatorAccountTypePlatform &&
!subject.HasGiftPackage &&
subject.ActualPaidAmount != nil && *subject.ActualPaidAmount > 0
}
// RechargeBillSubject 是判定代理线下充值入账是否建账所需的来源事实。
type RechargeBillSubject struct {
// PaymentMethod 表示充值记录支付方式。
PaymentMethod string
// Amount 表示充值记录金额(分)。
Amount int64
}
// ShouldCreateBillForRecharge 判定代理线下充值入账是否触发员工代收款建账。
// 判据:支付方式为线下且入账金额大于零;零金额无法形成正的应收金额。
func ShouldCreateBillForRecharge(subject RechargeBillSubject) bool {
return subject.PaymentMethod == constants.RechargeMethodOffline && subject.Amount > 0
}

View File

@@ -1,69 +0,0 @@
package employeecollection
import (
"strings"
"unicode"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PaymentMethodInput 是线下收款方式字典写入的领域输入。
type PaymentMethodInput struct {
// Code 表示稳定编码,创建后仅可在未被引用时修改。
Code string
// Name 表示收款方式名称。
Name string
// SortOrder 表示排序值,必须非负。
SortOrder int64
// Status 表示启停状态,取值见 constants.EmployeeCollectionPaymentMethodStatus*。
Status int
// Remark 表示备注。
Remark string
}
// NormalizedPaymentMethodInput 是通过校验并去空格后的字典写入事实。
type NormalizedPaymentMethodInput struct {
Code string
Name string
SortOrder int64
Status int
Remark string
}
// NormalizePaymentMethodInput 校验并规范化线下收款方式字典写入输入。
// 规则:编码 1 至 64 字符且不含空白或控制字符、名称 1 至 100 字符、
// 排序值非负、状态仅允许启用或停用、备注不超过 500 字符。
func NormalizePaymentMethodInput(input PaymentMethodInput) (NormalizedPaymentMethodInput, error) {
code := strings.TrimSpace(input.Code)
if code == "" {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式编码必填")
}
if len([]rune(code)) > constants.EmployeeCollectionCodeMaxLength {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式编码长度超出限制")
}
if strings.IndexFunc(code, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式编码不能包含空白或控制字符")
}
name := strings.TrimSpace(input.Name)
if name == "" {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式名称必填")
}
if len([]rune(name)) > constants.EmployeeCollectionNameMaxLength {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式名称长度超出限制")
}
if input.SortOrder < 0 {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式排序值不能为负")
}
if input.Status != constants.EmployeeCollectionPaymentMethodStatusDisabled &&
input.Status != constants.EmployeeCollectionPaymentMethodStatusEnabled {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式状态仅支持停用或启用")
}
remark := strings.TrimSpace(input.Remark)
if len([]rune(remark)) > constants.EmployeeCollectionRemarkMaxLength {
return NormalizedPaymentMethodInput{}, errors.New(errors.CodeInvalidParam, "收款方式备注长度超出限制")
}
return NormalizedPaymentMethodInput{
Code: code, Name: name, SortOrder: input.SortOrder, Status: input.Status, Remark: remark,
}, nil
}

View File

@@ -1,37 +0,0 @@
package employeecollection
import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RefundOffsetDecision 是来源订单退款成功对账单的处理判定结果。
type RefundOffsetDecision struct {
// Outcome 取值见 constants.EmployeeCollectionRefundOutcome*。
Outcome string
// ReducedAmount 是本次实际冲减的应收金额(分),仅 reduced 时大于零。
ReducedAmount int64
}
// DecideRefundOffset 判定来源订单本次退款成功金额对账单的处理方式。
// 判定顺序:已关闭账单与存在已通过或审批中分摊的账单只写退款关联提示,
// 其余按退款成功金额与账单应收比较,等于或超过应收时关闭账单,小于应收时按退款金额冲减。
// 已通过分摊体现为 received_amount > 0审批中分摊体现为 reserved_amount > 0
// 这两个金额只由本能力的条件更新维护,因此与「存在已通过或审批中分摊」等价。
func DecideRefundOffset(bill BillAmounts, refundAmount int64) (RefundOffsetDecision, error) {
if refundAmount <= 0 {
return RefundOffsetDecision{}, errors.New(errors.CodeInvalidParam, "退款成功金额必须大于零")
}
if err := bill.Validate(); err != nil {
return RefundOffsetDecision{}, err
}
if bill.Closed || bill.Received > 0 || bill.Reserved > 0 {
return RefundOffsetDecision{Outcome: constants.EmployeeCollectionRefundOutcomeHintOnly}, nil
}
if refundAmount >= bill.Receivable {
return RefundOffsetDecision{Outcome: constants.EmployeeCollectionRefundOutcomeClosedFull}, nil
}
return RefundOffsetDecision{
Outcome: constants.EmployeeCollectionRefundOutcomeReduced, ReducedAmount: refundAmount,
}, nil
}

View File

@@ -1,14 +0,0 @@
package employeecollection
import "strconv"
// OrderSourceKey 返回后台线下套餐订单来源的账单唯一键。
// 该键是 tb_employee_collection_bill.source_key 的持久化契约,同一来源至多一张账单。
func OrderSourceKey(orderID uint) string {
return "order:" + strconv.FormatUint(uint64(orderID), 10)
}
// RechargeSourceKey 返回代理线下充值来源的账单唯一键。
func RechargeSourceKey(rechargeID uint) string {
return "recharge:" + strconv.FormatUint(uint64(rechargeID), 10)
}

View File

@@ -1,35 +0,0 @@
package employeecollection
import (
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// NormalizePaymentVouchers 校验并规范化支付凭证对象键列表。
// 规则:数量必须在 1 至 5 个之间、每个键去空格后非空且不超过长度上限、不允许重复。
// 只接受对象存储键引用,不接受内联内容,避免敏感付款材料进入业务事实。
func NormalizePaymentVouchers(keys []string) ([]string, error) {
if len(keys) < constants.EmployeeCollectionVoucherMinCount ||
len(keys) > constants.EmployeeCollectionVoucherMaxCount {
return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid)
}
normalized := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid)
}
if len([]rune(trimmed)) > constants.EmployeeCollectionVoucherKeyMaxLength {
return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid)
}
if _, exists := seen[trimmed]; exists {
return nil, errors.New(errors.CodeEmployeeCollectionVoucherInvalid)
}
seen[trimmed] = struct{}{}
normalized = append(normalized, trimmed)
}
return normalized, nil
}

View File

@@ -99,6 +99,8 @@ func (c *Client) WithRetry(maxRetries int) *Client {
// 流程:包装参数 → 序列化 → 加密 → 签名 → HTTP POST带重试→ 解析响应 → 检查业务状态码
// params: 请求参数结构体,内部自动包装为 {"params": <JSON>} 格式
func (c *Client) doRequest(ctx context.Context, path string, params interface{}) (json.RawMessage, error) {
startTime := time.Now()
// 将参数包装为 {"params": ...} 格式后序列化
wrapper := requestWrapper{Params: params}
dataBytes, err := sonic.Marshal(wrapper)
@@ -113,28 +115,6 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
return nil, err
}
return c.executeWithRetry(ctx, path, encryptedData, true)
}
// doRequestWithoutPayloadLog 执行 Gateway 请求,但不记录请求体与响应体。
// 仅用于载荷含敏感内容(如付款凭证图片)的能力;成功与失败都只记录路径、耗时与结果摘要。
func (c *Client) doRequestWithoutPayloadLog(ctx context.Context, path string, params interface{}) (json.RawMessage, error) {
dataBytes, err := sonic.Marshal(requestWrapper{Params: params})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")
}
encryptedData, err := aesEncrypt(dataBytes, c.appSecret)
if err != nil {
return nil, err
}
return c.executeWithRetry(ctx, path, encryptedData, false)
}
// executeWithRetry 按现有重试语义发送一次已加密请求。
// logPayload 为 false 时不记录响应体,只记录路径、耗时与结果字节数摘要。
func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData string, logPayload bool) (json.RawMessage, error) {
startTime := time.Now()
// 带重试的 HTTP 请求
var lastErr error
observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver)
@@ -180,19 +160,11 @@ func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData strin
// 成功
duration := time.Since(startTime)
if logPayload {
c.logger.Debug("Gateway 请求成功",
zap.String("path", path),
zap.Duration("duration", duration),
zap.Any("result", result),
)
} else {
c.logger.Debug("Gateway 请求成功",
zap.String("path", path),
zap.Duration("duration", duration),
zap.Int("result_bytes", len(result)),
)
}
return result, nil
}

View File

@@ -1,60 +0,0 @@
// Package gateway 提供付款凭证识别能力,仅用于交易流水号表单预填。
// 识别结果不是资金事实,也不代表系统已完成任何资金动作。
package gateway
import (
"context"
"reflect"
"strings"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// paymentVoucherRecognitionPath 是付款凭证识别接口路径。
const paymentVoucherRecognitionPath = "/ai/ocr/extract-payment"
// PaymentVoucherExtractionRequest 是付款凭证识别请求,只传图片内容。
type PaymentVoucherExtractionRequest struct {
// ImageBase64 是付款凭证图片的 base64 编码内容。
ImageBase64 string `json:"image_base64"`
}
// paymentVoucherExtraction 只解码本系统消费的支付单号。
// 识别响应还含 amount数值、payee、payment_method、payment_time 与 remark
// 这些字段刻意不声明、不解码:既不进入响应、不预填、不落库,也不被本进程持有,
// 同时避免上游字段类型漂移(实测 amount 为 JSON 数值)导致整条响应解析失败。
type paymentVoucherExtraction struct {
OrderNumber string `json:"order_number"`
}
// ExtractPaymentVoucherOrderNumber 识别付款凭证图片并只返回识别出的支付单号。
// 该能力刻意不使用记录完整请求体与响应体的泛型入口,日志只含路径、耗时与结果摘要;
// 返回空字符串表示识别服务未给出支付单号,由调用方决定失败口径。
func (c *Client) ExtractPaymentVoucherOrderNumber(ctx context.Context, imageBase64 string) (string, error) {
if strings.TrimSpace(imageBase64) == "" {
return "", errors.New(errors.CodeInvalidParam, "付款凭证图片内容不能为空")
}
data, err := c.doRequestWithoutPayloadLog(ctx, paymentVoucherRecognitionPath, PaymentVoucherExtractionRequest{
ImageBase64: imageBase64,
})
if err != nil {
return "", err
}
var extraction paymentVoucherExtraction
if err := sonic.Unmarshal(data, &extraction); err != nil {
// 刻意不记录 err.Error()sonic 的类型错误消息会内嵌响应 JSON 原文片段,
// 一旦进入日志或错误上下文就等于记录识别原始结果(金额、单号等)。
// 只记录可诊断且非敏感的摘要:路径、响应字节数与错误类型名,
// 足以区分语法错、类型错与空响应,又不携带任何载荷内容。
c.logger.Warn("付款凭证识别响应解析失败",
zap.String("path", paymentVoucherRecognitionPath),
zap.Int("result_bytes", len(data)),
zap.String("err_kind", reflect.TypeOf(err).String()),
)
return "", errors.New(errors.CodeGatewayInvalidResp, "解析付款凭证识别结果失败")
}
return strings.TrimSpace(extraction.OrderNumber), nil
}

View File

@@ -4,14 +4,12 @@ import (
"bytes"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
agentrechargeapp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
agentrechargequery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
@@ -26,8 +24,6 @@ type AgentRechargeHandler struct {
service *agentRechargeSvc.Service
online *agentrechargeapp.OnlineCreationService
status *agentrechargequery.PaymentStatusQuery
ocr *agentrechargeapp.PaymentVoucherOCRService
config *systemconfigapp.UpdateService
validator *validator.Validate
}
@@ -41,16 +37,6 @@ func (h *AgentRechargeHandler) SetPaymentStatusQuery(query *agentrechargequery.P
h.status = query
}
// SetPaymentVoucherOCRService 注入付款凭证识别用例。
func (h *AgentRechargeHandler) SetPaymentVoucherOCRService(service *agentrechargeapp.PaymentVoucherOCRService) {
h.ocr = service
}
// SetSystemConfigUpdateService 注入受控系统配置写服务,用于代理自充允许范围修改。
func (h *AgentRechargeHandler) SetSystemConfigUpdateService(service *systemconfigapp.UpdateService) {
h.config = service
}
// NewAgentRechargeHandler 创建代理预充值 Handler
func NewAgentRechargeHandler(service *agentRechargeSvc.Service, validator *validator.Validate) *AgentRechargeHandler {
return &AgentRechargeHandler{service: service, validator: validator}
@@ -87,9 +73,8 @@ func (h *AgentRechargeHandler) createOnline(c *fiber.Ctx, req dto.CreateAgentRec
if h.online == nil {
return errors.New(errors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || len(req.OtherVoucherKey) > 0 ||
req.OfflinePaymentMethodID != 0 || strings.TrimSpace(req.ExternalTransactionNo) != "" || strings.TrimSpace(req.Remark) != "" {
return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、收款方式、交易流水号、支付凭证或运营备注")
if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || strings.TrimSpace(req.Remark) != "" {
return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、支付凭证或运营备注")
}
result, err := h.online.Execute(c.UserContext(), agentrechargeapp.CreateOnlineCommand{
AccountID: middleware.GetUserIDFromContext(c.UserContext()), UserType: middleware.GetUserTypeFromContext(c.UserContext()),
@@ -123,69 +108,6 @@ func (h *AgentRechargeHandler) PaymentMethods(c *fiber.Ctx) error {
})
}
// SelfRechargePaymentMethods 查询代理自充实际可用支付方式。
// GET /api/admin/agent-self-recharge-payment-methods
// 响应只含交集结果,不含允许范围、商户身份或凭证。
func (h *AgentRechargeHandler) SelfRechargePaymentMethods(c *fiber.Ctx) error {
return h.PaymentMethods(c)
}
// UpdateSelfRechargePaymentMethods 修改代理在线自充允许范围。
// PUT /api/admin/agent-self-recharge-payment-methods
// 复用受控系统配置写服务,获得超级管理员限定、咨询锁串行与前后值审计。
func (h *AgentRechargeHandler) UpdateSelfRechargePaymentMethods(c *fiber.Ctx) error {
var req dto.AgentSelfRechargePaymentMethodsUpdateRequest
decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body()))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.config == nil {
return errors.New(errors.CodeServiceUnavailable, "代理自充允许范围维护能力未配置")
}
item, err := h.config.Execute(c.UserContext(), constants.SystemConfigAgentSelfRechargeAllowedMethods,
dto.UpdateSystemConfigRequest{Value: req.AllowedMethods})
if err != nil {
return err
}
updatedAt := ""
if item.UpdatedAt != nil {
updatedAt = item.UpdatedAt.UTC().Format(time.RFC3339)
}
return response.Success(c, &dto.AgentSelfRechargePaymentMethodsUpdateResponse{
AllowedMethods: item.Value, AllowedMethodsName: constants.GetAgentSelfRechargeAllowedMethodsName(item.Value),
UpdatedAt: updatedAt,
})
}
// PaymentVoucherOCR 识别付款凭证,只返回交易流水号预填值。
// POST /api/admin/agent-recharges/payment-voucher-ocr
// 识别失败返回明确失败,不影响提交人人工填写交易流水号后创建申请。
func (h *AgentRechargeHandler) PaymentVoucherOCR(c *fiber.Ctx) error {
var req dto.AgentRechargePaymentVoucherOCRRequest
decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body()))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.ocr == nil {
return errors.New(errors.CodeServiceUnavailable, "付款凭证识别能力未配置")
}
result, err := h.ocr.Recognize(c.UserContext(), req.PaymentVoucherKey)
if err != nil {
return err
}
return response.Success(c, &dto.AgentRechargePaymentVoucherOCRResponse{
ExternalTransactionNo: result.ExternalTransactionNo,
})
}
// List 查询代理充值订单列表
// GET /api/admin/agent-recharges
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {

View File

@@ -1,272 +0,0 @@
package admin
import (
"strings"
"time"
"github.com/gofiber/fiber/v2"
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
employeecollectionquery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// EmployeeCollectionHandler 处理员工代收款账单、核销申请与线下收款方式字典请求。
// 边界只做绑定、路径参数校验与统一响应,状态、金额与权限不变量由应用用例判断。
type EmployeeCollectionHandler struct {
paymentMethod *employeecollectionapp.PaymentMethodService
paymentMethodQuery *employeecollectionquery.PaymentMethodQuery
billClose *employeecollectionapp.BillCloseService
billQuery *employeecollectionquery.BillQuery
application *employeecollectionapp.ApplicationService
applicationQuery *employeecollectionquery.ApplicationQuery
}
// NewEmployeeCollectionHandler 创建员工代收款处理器。
func NewEmployeeCollectionHandler(
service *employeecollectionapp.PaymentMethodService,
billClose *employeecollectionapp.BillCloseService,
application *employeecollectionapp.ApplicationService,
) *EmployeeCollectionHandler {
return &EmployeeCollectionHandler{paymentMethod: service, billClose: billClose, application: application}
}
// SetApplicationQuery 注入核销申请只读投影。
func (h *EmployeeCollectionHandler) SetApplicationQuery(query *employeecollectionquery.ApplicationQuery) {
h.applicationQuery = query
}
// SetPaymentMethodQuery 注入线下收款方式字典只读投影。
func (h *EmployeeCollectionHandler) SetPaymentMethodQuery(query *employeecollectionquery.PaymentMethodQuery) {
h.paymentMethodQuery = query
}
// SetBillQuery 注入员工代收款账单只读投影。
func (h *EmployeeCollectionHandler) SetBillQuery(query *employeecollectionquery.BillQuery) {
h.billQuery = query
}
// CreatePaymentMethod 创建线下收款方式。
// POST /api/admin/employee-collection-payment-methods
func (h *EmployeeCollectionHandler) CreatePaymentMethod(c *fiber.Ctx) error {
var request dto.CreateEmployeeCollectionPaymentMethodRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.paymentMethod.Create(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdatePaymentMethod 更新线下收款方式,支持修改名称、排序、启停、备注与未被引用时的稳定编码。
// PUT /api/admin/employee-collection-payment-methods/:id
func (h *EmployeeCollectionHandler) UpdatePaymentMethod(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var request dto.UpdateEmployeeCollectionPaymentMethodRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.paymentMethod.Update(c.UserContext(), id, request)
if err != nil {
return err
}
return response.Success(c, result)
}
// DeletePaymentMethod 删除未被核销申请引用的线下收款方式。
// DELETE /api/admin/employee-collection-payment-methods/:id
func (h *EmployeeCollectionHandler) DeletePaymentMethod(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
if err := h.paymentMethod.Delete(c.UserContext(), id); err != nil {
return err
}
return response.Success(c, nil)
}
// ListPaymentMethods 分页查询线下收款方式。
// GET /api/admin/employee-collection-payment-methods
func (h *EmployeeCollectionHandler) ListPaymentMethods(c *fiber.Ctx) error {
var request dto.EmployeeCollectionPaymentMethodListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.paymentMethodQuery.List(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// ListBills 分页查询员工代收款账单。
// GET /api/admin/employee-collection-bills
func (h *EmployeeCollectionHandler) ListBills(c *fiber.Ctx) error {
var request dto.EmployeeCollectionBillListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.billQuery.List(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// StatisticsBills 汇总员工代收款账单金额与待处理数量。
// GET /api/admin/employee-collection-bills/statistics
func (h *EmployeeCollectionHandler) StatisticsBills(c *fiber.Ctx) error {
var request dto.EmployeeCollectionBillStatisticsRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.billQuery.Statistics(c.UserContext(), request)
if err != nil {
return err
}
return response.Success(c, result)
}
// GetBill 查询员工代收款账单详情。
// GET /api/admin/employee-collection-bills/:id
func (h *EmployeeCollectionHandler) GetBill(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.billQuery.Detail(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// CloseBill 关闭员工代收款账单,仅超级管理员可操作。
// POST /api/admin/employee-collection-bills/:id/close
func (h *EmployeeCollectionHandler) CloseBill(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var request dto.CloseEmployeeCollectionBillRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
bill, err := h.billClose.Close(c.UserContext(), id, request.Reason)
if err != nil {
return err
}
result, err := employeecollectionquery.ProjectBill(bill)
if err != nil {
return err
}
return response.Success(c, result)
}
// CreateApplication 为本人可见账单创建核销申请。
// POST /api/admin/employee-collection-applications
func (h *EmployeeCollectionHandler) CreateApplication(c *fiber.Ctx) error {
request, err := bindApplicationRequest(c)
if err != nil {
return err
}
result, err := h.application.Create(c.UserContext(), request)
if err != nil {
return err
}
projected, err := employeecollectionquery.ProjectApplicationSubmit(employeecollectionquery.ApplicationSubmitProjection{
Application: result.Application, Attempt: result.Attempt, Allocations: result.Allocations,
Bills: result.Bills, InstanceID: result.InstanceID, InstanceStatus: result.InstanceStatus,
})
if err != nil {
return err
}
return response.Success(c, projected)
}
// ResubmitApplication 修改并重提已驳回的核销申请。
// PUT /api/admin/employee-collection-applications/:id
func (h *EmployeeCollectionHandler) ResubmitApplication(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
request, err := bindApplicationRequest(c)
if err != nil {
return err
}
result, err := h.application.Resubmit(c.UserContext(), id, request)
if err != nil {
return err
}
projected, err := employeecollectionquery.ProjectApplicationSubmit(employeecollectionquery.ApplicationSubmitProjection{
Application: result.Application, Attempt: result.Attempt, Allocations: result.Allocations,
Bills: result.Bills, InstanceID: result.InstanceID, InstanceStatus: result.InstanceStatus,
})
if err != nil {
return err
}
return response.Success(c, projected)
}
// ListApplications 分页查询核销申请。
// GET /api/admin/employee-collection-applications
func (h *EmployeeCollectionHandler) ListApplications(c *fiber.Ctx) error {
var request dto.EmployeeCollectionApplicationListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.applicationQuery.List(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// GetApplication 查询核销申请详情,含分摊与全部审批尝试历史。
// GET /api/admin/employee-collection-applications/:id
func (h *EmployeeCollectionHandler) GetApplication(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.applicationQuery.Detail(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// bindApplicationRequest 绑定并转换创建或重提核销申请请求。
// 付款时间按带时区的 RFC3339 解析;其余边界与业务校验由应用用例统一判断。
func bindApplicationRequest(c *fiber.Ctx) (employeecollectionapp.SubmitApplicationCommand, error) {
var request dto.SubmitEmployeeCollectionApplicationRequest
if err := c.BodyParser(&request); err != nil {
return employeecollectionapp.SubmitApplicationCommand{}, errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
paidAt, err := time.Parse(time.RFC3339, strings.TrimSpace(request.PaidAt))
if err != nil {
return employeecollectionapp.SubmitApplicationCommand{}, errors.New(errors.CodeInvalidParam, "付款时间必须为带时区的 RFC3339 格式")
}
allocations := make([]employeecollectionapp.ApplicationAllocationCommand, 0, len(request.Allocations))
for _, item := range request.Allocations {
allocations = append(allocations, employeecollectionapp.ApplicationAllocationCommand{
BillID: item.BillID, Amount: item.Amount,
})
}
return employeecollectionapp.SubmitApplicationCommand{
PaymentMethodID: request.PaymentMethodID, PaidAmount: request.PaidAmount,
PayerName: request.PayerName, PaidAt: paidAt,
ExternalTransactionNo: request.ExternalTransactionNo,
PaymentVoucherKeys: request.PaymentVoucherKeys, Remark: request.Remark,
ActingReason: request.ActingReason, Allocations: allocations,
}, nil
}

View File

@@ -1,218 +0,0 @@
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

@@ -3,15 +3,12 @@ package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
distributionquery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
shopCommissionService "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
@@ -19,21 +16,11 @@ import (
type ShopCommissionHandler struct {
service *shopCommissionService.Service
fundSummaryQuery *shopQuery.FundSummaryQuery
withdrawalQuery *distributionquery.Query
validator *validator.Validate
}
// NewShopCommissionHandler 创建代理商资金管理 Handler
func NewShopCommissionHandler(
service *shopCommissionService.Service,
validate *validator.Validate,
) *ShopCommissionHandler {
return &ShopCommissionHandler{service: service, validator: validate}
}
// SetWithdrawalQuery 注入提现申请详情查询。
func (h *ShopCommissionHandler) SetWithdrawalQuery(query *distributionquery.Query) {
h.withdrawalQuery = query
func NewShopCommissionHandler(service *shopCommissionService.Service) *ShopCommissionHandler {
return &ShopCommissionHandler{service: service}
}
// SetFundSummaryQuery 注入代理商资金概况 Query。
@@ -188,62 +175,6 @@ func (h *ShopCommissionHandler) CreateWithdrawal(c *fiber.Ctx) error {
return response.Success(c, result)
}
// ResubmitWithdrawal 重提被驳回的提现申请
// PUT /api/admin/shops/:shop_id/withdrawal-requests/:id
func (h *ShopCommissionHandler) ResubmitWithdrawal(c *fiber.Ctx) error {
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
requestID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || requestID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的提现申请 ID")
}
var req dto.ResubmitWithdrawalReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "提现重提校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "提现重提参数不合法")
}
result, err := h.service.ResubmitWithdrawalRequest(c.UserContext(), uint(shopID), uint(requestID), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// WithdrawalDetail 提现申请详情
// GET /api/admin/shops/:shop_id/withdrawal-requests/:id
// 仅返回当前账号数据范围内的申请;超出范围与不存在返回同一结果。
func (h *ShopCommissionHandler) WithdrawalDetail(c *fiber.Ctx) error {
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
requestID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || requestID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的提现申请 ID")
}
if h.withdrawalQuery == nil {
return errors.New(errors.CodeInternalError, "提现详情查询能力未配置")
}
if err := middleware.CanManageShop(c.UserContext(), uint(shopID)); err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
detail, err := h.withdrawalQuery.WithdrawalDetail(c.UserContext(), uint(requestID))
if err != nil {
return err
}
if detail.ShopID != uint(shopID) {
return errors.New(errors.CodeNotFound, "提现申请不存在")
}
return response.Success(c, detail)
}
// ListMainWalletTransactions 预充值钱包流水列表
// GET /api/admin/shops/:shop_id/main-wallet/transactions
func (h *ShopCommissionHandler) ListMainWalletTransactions(c *fiber.Ctx) error {

View File

@@ -1,129 +0,0 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
distributionquery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
"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/response"
)
// WithdrawalQualificationHandler 提现资料资格后台 Handler。
type WithdrawalQualificationHandler struct {
service *distributionapp.QualificationService
query *distributionquery.Query
validator *validator.Validate
}
// NewWithdrawalQualificationHandler 创建提现资料资格后台 Handler。
func NewWithdrawalQualificationHandler(
service *distributionapp.QualificationService,
query *distributionquery.Query,
validate *validator.Validate,
) *WithdrawalQualificationHandler {
return &WithdrawalQualificationHandler{service: service, query: query, validator: validate}
}
// SubmitWithdrawalQualification 提交或替换提现资料资格
// POST /api/admin/shops/:shop_id/withdrawal-qualifications
// 仅本人代理店铺;替换合同或法人身份证时同一事务新增版本并使旧有效版本失效。
func (h *WithdrawalQualificationHandler) SubmitWithdrawalQualification(c *fiber.Ctx) error {
if h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
}
var req dto.SubmitWithdrawalQualificationReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "提现资料资格参数不合法")
}
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
result, err := h.service.Submit(c.UserContext(), uint(shopID), distributiondomain.QualificationInput{
SubjectType: req.SubjectType,
SubjectCode: req.SubjectCode,
LegalPersonIDCard: req.LegalPersonIDCard,
ContractFileKey: req.ContractFileKey,
IDCardFrontFileKey: req.IDCardFrontFileKey,
IDCardBackFileKey: req.IDCardBackFileKey,
BusinessLicenseFileKey: req.BusinessLicenseFileKey,
ShopFrontFileKey: req.ShopFrontFileKey,
InvoiceFileKey: req.InvoiceFileKey,
InvoiceTitle: req.InvoiceTitle,
InvoiceSubjectCode: req.InvoiceSubjectCode,
})
if err != nil {
return err
}
return response.Success(c, &dto.SubmitWithdrawalQualificationResp{
ID: result.QualificationID,
Status: result.Status,
StatusName: constants.GetWithdrawalQualificationStatusName(result.Status),
})
}
// VoidWithdrawalQualification 超级管理员作废有效提现资料资格
// POST /api/admin/withdrawal-qualifications/:id/void
func (h *WithdrawalQualificationHandler) VoidWithdrawalQualification(c *fiber.Ctx) error {
if h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的资格 ID")
}
var req dto.VoidWithdrawalQualificationReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因")
}
if err := h.service.Void(c.UserContext(), uint(id), req.Reason); err != nil {
return err
}
return response.Success(c, nil)
}
// ListWithdrawalQualifications 查询提现资料资格版本
// GET /api/admin/shops/:shop_id/withdrawal-qualifications
// 仅返回当前账号数据范围内的资料版本;证件号脱敏,附件只返回对象存储 Key。
func (h *WithdrawalQualificationHandler) ListWithdrawalQualifications(c *fiber.Ctx) error {
if h.query == nil {
return errors.New(errors.CodeInternalError, "提现资料资格查询能力未配置")
}
var req dto.WithdrawalQualificationListReq
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 路由路径必带 shop_id数据范围由 CanManageShop 在业务边界强制。
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
}
if err := middleware.CanManageShop(c.UserContext(), uint(shopID)); err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
result, err := h.query.ListQualifications(c.UserContext(), []uint{uint(shopID)}, &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}

View File

@@ -1,68 +0,0 @@
package app
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"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/response"
)
// AgentDistributionHandler 代理分销扫码注册公开 Handler。
type AgentDistributionHandler struct {
service *distributionapp.RegistrationService
validator *validator.Validate
}
// NewAgentDistributionHandler 创建代理分销扫码注册公开 Handler。
func NewAgentDistributionHandler(
service *distributionapp.RegistrationService,
validate *validator.Validate,
) *AgentDistributionHandler {
return &AgentDistributionHandler{service: service, validator: validate}
}
// RegisterAgentDistribution 提交代理扫码注册
// POST /api/c/v1/agent-distribution-registrations
// 无需认证、JWT、角色或权限只创建待审批注册记录不返回任何账号凭证。
// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”且不落库。
func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error {
if h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置")
}
var req dto.CreateAgentDistributionRegistrationReq
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "代理分销注册校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "注册参数不合法")
}
result, err := h.service.Register(c.UserContext(), distributiondomain.RegistrationInput{
DistributionCode: req.DistributionCode,
Phone: req.Phone,
Username: req.Username,
Password: req.Password,
ShopName: req.ShopName,
ShopCode: req.ShopCode,
ContactName: req.ContactName,
Province: req.Province,
City: req.City,
District: req.District,
Address: req.Address,
}, req.Code)
if err != nil {
return err
}
return response.Success(c, &dto.CreateAgentDistributionRegistrationResp{
ID: result.RegistrationID,
Status: result.Status,
StatusName: constants.GetAgentDistributionRegistrationStatusName(result.Status),
})
}

View File

@@ -11,7 +11,6 @@ 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"
@@ -463,7 +462,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
@@ -486,68 +485,55 @@ func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
return err
}
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,
})
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)
if err != nil {
return err
}
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, collectPackageHistoryUsages(history.Items))
if err != nil {
return err
}
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
list := make([]dto.AssetPackageResponse, 0, len(usages))
for _, usage := range usages {
pkg := packageMap[usage.PackageID]
metrics := usage.BuildTrafficMetrics()
packageName := usage.PackageName
packageType := ""
pkgName := usage.PackageName
pkgType := ""
if pkg != nil {
if packageName == "" {
packageName = pkg.PackageName
if pkgName == "" {
pkgName = pkg.PackageName
}
packageType = pkg.PackageType
pkgType = pkg.PackageType
}
item := &dto.ClientAssetPackageHistoryNode{
list = append(list, dto.AssetPackageResponse{
PackageUsageID: usage.ID,
PackageID: usage.PackageID,
PackageName: packageName,
PackageType: packageType,
PackageName: pkgName,
PackageType: pkgType,
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
@@ -562,17 +548,10 @@ func buildClientPackageHistoryNode(node *assetquery.PackageHistoryNode, packageM
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
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
}
// RefreshAsset B4 资产刷新

View File

@@ -8,7 +8,6 @@ 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"
@@ -17,7 +16,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"
@@ -42,7 +41,7 @@ type ClientWalletHandler struct {
paymentStore *postgres.PaymentStore
rechargeService *rechargeSvc.Service
openIDStore *postgres.PersonalCustomerOpenIDStore
merchantRuntime *merchantpayment.RuntimeLoader
wechatConfigService *wechatConfigSvc.Service
redis *redis.Client
logger *zap.Logger
db *gorm.DB
@@ -74,6 +73,7 @@ func NewClientWalletHandler(
paymentStore *postgres.PaymentStore,
rechargeService *rechargeSvc.Service,
openIDStore *postgres.PersonalCustomerOpenIDStore,
wechatConfigService *wechatConfigSvc.Service,
redisClient *redis.Client,
logger *zap.Logger,
db *gorm.DB,
@@ -89,7 +89,7 @@ func NewClientWalletHandler(
paymentStore: paymentStore,
rechargeService: rechargeService,
openIDStore: openIDStore,
merchantRuntime: merchantpayment.NewRuntimeLoader(db, redisClient),
wechatConfigService: wechatConfigService,
redis: redisClient,
logger: logger,
db: db,
@@ -321,11 +321,19 @@ 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, wallet, req)
return h.createAlipayRecharge(c, resolved, config, wallet, req)
case constants.RechargeMethodWechat:
return h.createWechatRecharge(c, resolved, wallet, req)
return h.createWechatRecharge(c, resolved, config, wallet, req)
default:
return errors.New(errors.CodePaymentMethodUnavailable)
}
@@ -335,13 +343,15 @@ 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 {
authorization, appID, err := h.loadWechatAuthorization(resolved.SkipPermissionCtx, req.AppType)
appID, err := pickAppIDByType(config, req.AppType)
if err != nil {
return err
}
openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID)
if err != nil {
return err
@@ -349,47 +359,9 @@ 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
@@ -408,15 +380,49 @@ 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,
@@ -433,11 +439,18 @@ 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,
@@ -446,6 +459,7 @@ 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,
@@ -457,22 +471,12 @@ func (h *ClientWalletHandler) createAlipayRecharge(
PaymentMethod: model.PaymentByAlipay,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
ExpireAt: &expireAt,
}
var config *model.WechatConfig
var expireAt time.Time
// WAP URL 是本地签名,不需要先调第三方,在事务内创建充值单和支付单
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, "创建充值订单失败")
}
@@ -501,6 +505,7 @@ 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)
@@ -733,37 +738,7 @@ func parseOptionalTime(value string) (*time.Time, error) {
return nil, fmt.Errorf("invalid time format")
}
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) {
func pickAppIDByType(config *model.WechatConfig, 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.GetAuthorizationConfig(c.UserContext())
wechatConfig, err := h.wechatConfigService.GetActiveConfig(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.GetAuthorizationConfig(c.UserContext())
wechatConfig, err := h.wechatConfigService.GetActiveConfig(c.UserContext())
if err != nil {
return err
}

View File

@@ -38,7 +38,6 @@ 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 {
@@ -99,7 +98,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))
@@ -145,13 +144,12 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
}
case model.ProviderTypeWechat:
paymentSvc, err := h.wechatConfigService.GetPaymentServiceForCallback(cfg)
if err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "构建微信 v3 回调验签实例失败")
if h.wechatPayment == nil {
return errors.New(errors.CodeWechatCallbackInvalid, "微信 v3 支付未初始化")
}
var httpReq http.Request
fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true)
_, err = paymentSvc.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
_, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
if result.TradeState != "SUCCESS" {
return h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID,
@@ -423,7 +421,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("支付宝回调:加载支付配置失败",
@@ -496,14 +494,13 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeWechatCallbackInvalid, "支付通道校验失败")
}
// 商户支付单已由 GetConfigForCallback 按 merchant_id 绑定当前商户凭证;
// payment_config_id 只属于历史双读路径,不能用于否定新商户回调。
if payment.MerchantID == nil && payment.PaymentConfigID == nil {
// 校验 payment_config_id 与当前配置一致;旧数据为空时兼容并记录 warn
if 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.MerchantID == nil && *payment.PaymentConfigID != cfg.ID {
} else if *payment.PaymentConfigID != cfg.ID {
h.logger.Error("支付宝回调payment_config_id 不匹配",
zap.String("out_trade_no", outTradeNo),
zap.Uint("payment_config_id", *payment.PaymentConfigID),
@@ -570,7 +567,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

@@ -115,63 +115,6 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str
"approval_instance_id": instanceID, "status": recharge.Status,
},
}, nil
case constants.ApprovalBusinessTypeEmployeeCollection:
var attempt model.EmployeeCollectionApplicationAttempt
if err := tx.WithContext(ctx).First(&attempt, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联核销审批尝试记录失败")
}
return ResourceInput{
Type: constants.AuditResourceEmployeeCollectionAttempt, ID: &id,
Key: id, DisplayName: "审批尝试 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "application_id": attempt.ApplicationID, "attempt_no": attempt.AttemptNo,
"paid_amount": attempt.PaidAmount, "approval_instance_id": instanceID,
},
}, nil
case constants.ApprovalBusinessTypeAgentDistribution:
var registration model.AgentDistributionRegistration
if err := tx.WithContext(ctx).First(&registration, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联扫码注册记录失败")
}
return ResourceInput{
Type: constants.AuditResourceAgentDistributionRegistration, ID: &id,
Key: id, DisplayName: "扫码注册记录 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": registration.ID, "parent_shop_id": registration.ParentShopID,
"status": registration.Status, "approval_instance_id": instanceID,
},
}, nil
case constants.ApprovalBusinessTypeWithdrawalQualification:
var qualification model.WithdrawalQualification
if err := tx.WithContext(ctx).First(&qualification, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现资料资格版本失败")
}
return ResourceInput{
Type: constants.AuditResourceWithdrawalQualification, ID: &id,
Key: id, DisplayName: "提现资料资格版本 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": qualification.ID, "shop_id": qualification.ShopID,
"subject_type": qualification.SubjectType, "status": qualification.Status,
"approval_instance_id": instanceID,
},
}, nil
case constants.ApprovalBusinessTypeCommissionWithdrawal:
var attempt model.CommissionWithdrawalRequestAttempt
if err := tx.WithContext(ctx).First(&attempt, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现审批尝试记录失败")
}
return ResourceInput{
Type: constants.AuditResourceCommissionWithdrawalAttempt, ID: &id,
Key: id, DisplayName: "提现审批尝试 " + id,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "request_id": attempt.RequestID, "attempt_no": attempt.AttemptNo,
"amount": attempt.Amount, "approval_instance_id": instanceID,
},
}, nil
default:
return ResourceInput{}, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源")
}

View File

@@ -1,243 +0,0 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteDistributionWithdrawal 将分销注册、提现资格与提现审批事实写入统一 Audit Event。
// 审计只记录脱敏手机号与证件号、附件数量与对象键引用,绝不记录密码、完整证件号或附件内容。
func (w *Writer) WriteDistributionWithdrawal(
ctx context.Context,
tx *gorm.DB,
change distributionapp.AuditChange,
) error {
resources := make([]ResourceInput, 0, 6)
if change.Registration != nil && change.Registration.ID != 0 {
resources = append(resources, registrationResource(change))
}
if change.ParentShop != nil && change.ParentShop.ID != 0 {
resource := ShopResource(change.ParentShop, constants.AuditResourceRelationReference,
constants.AuditResourceRoleDistributionParentShop)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
if change.Qualification != nil && change.Qualification.ID != 0 {
resources = append(resources, qualificationResource(change))
}
if change.Withdrawal != nil && change.Withdrawal.ID != 0 {
resources = append(resources, withdrawalResource(change))
}
if change.Attempt != nil && change.Attempt.ID != 0 {
resources = append(resources, withdrawalAttemptResource(change.Attempt))
}
if change.Shop != nil && change.Shop.ID != 0 {
resource := ShopResource(change.Shop, constants.AuditResourceRelationReference,
constants.AuditResourceRoleQualificationShop)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
if change.CreatedShop != nil && change.CreatedShop.ID != 0 {
shop := change.CreatedShop
resource := ShopResource(shop, constants.AuditResourceRelationAffected,
constants.AuditResourceRoleShopTarget)
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = change.Summary
// 新建店铺自己的分销码:注册记录上的码是上级码快照,这里必须记录本次生成的店铺码,
// 否则「分销码生成」在审批建店路径没有审计事实,与后台建店路径不对称。
if shop.DistributionCode != "" {
resource.IdentitySnapshot["distribution_code_masked"] =
distributiondomain.MaskDistributionCode(shop.DistributionCode)
}
resource.AfterData = map[string]any{
"shop_name": shop.ShopName, "shop_code": shop.ShopCode,
"distribution_code_masked": distributiondomain.MaskDistributionCode(shop.DistributionCode),
"applied_distribution_code_masked": distributiondomain.MaskDistributionCode(change.AppliedDistributionCode),
"status": shop.Status,
}
resources = append(resources, resource)
}
if change.Wallet != nil && change.Wallet.ID != 0 {
resource := agentWalletAuditResource(change.Wallet, constants.AuditResourceRelationAffected,
constants.AuditResourceRoleWithdrawalWallet)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
if change.Transaction != nil && change.Transaction.ID != 0 {
resource := agentWalletTransactionResource(change.Transaction)
resource.Relation = constants.AuditResourceRelationAffected
resource.Role = constants.AuditResourceRoleWithdrawalTransaction
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
if len(resources) == 0 {
return errors.New(errors.CodeInvalidParam, "分销或提现审计缺少可追溯资源")
}
// 规范化关系:审计事件必须且恰好有一个主要资源。
// 失败/拒绝审计在业务事务回滚后重放此时业务对象可能尚未落库ID 为 0而被跳过
// 或变更只带店铺上下文,因此这里统一收敛:优先保留业务主资源,
// 没有业务主资源时用店铺承载主资源,其余资源一律降为引用。
primaryIndex := -1
for index := range resources {
if resources[index].Relation == constants.AuditResourceRelationPrimary {
primaryIndex = index
break
}
}
if primaryIndex == -1 {
for index := range resources {
if resources[index].Type == constants.AuditResourceShop {
resources[index].Relation = constants.AuditResourceRelationPrimary
primaryIndex = index
break
}
}
}
if primaryIndex == -1 {
return errors.New(errors.CodeInvalidParam, "分销或提现审计缺少可追溯资源")
}
for index := range resources {
if index != primaryIndex && resources[index].Relation == constants.AuditResourceRelationPrimary {
resources[index].Relation = constants.AuditResourceRelationReference
}
}
result := change.Result
if result == "" {
result = constants.AuditResultSuccess
}
// 用 AppendAndGet 而非 Append分销注册、资格与提现终审的审计属于「要求成功必达」的事实
// 必须与业务事实同事务原子提交。Append 会吞掉错误,导致审计插入失败后事务被 PG 置为
// aborted最终只表现为难以定位的提交失败这里显式返回错误。
if _, err := w.AppendAndGet(ctx, tx, AppendInput{
EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary,
ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary,
CorrelationID: change.CorrelationID, Metadata: auditMetadata(change), Resources: resources,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入分销或提现审计失败")
}
return nil
}
// registrationResource 构造扫码注册记录审计资源,手机号与分销码只记录脱敏值。
func registrationResource(change distributionapp.AuditChange) ResourceInput {
registration := change.Registration
id := strconv.FormatUint(uint64(registration.ID), 10)
instanceID := uint(0)
if registration.ApprovalInstanceID != nil {
instanceID = *registration.ApprovalInstanceID
}
return ResourceInput{
Type: constants.AuditResourceAgentDistributionRegistration, ID: &id, Key: id,
DisplayName: "扫码注册记录 " + id,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleDistributionRegistration,
IdentitySnapshot: map[string]any{
"id": registration.ID, "parent_shop_id": registration.ParentShopID,
"distribution_code_masked": distributiondomain.MaskDistributionCode(registration.DistributionCode),
"phone_masked": distributiondomain.MaskPhone(registration.Phone),
"username": registration.Username, "shop_code": registration.ShopCode,
"status": registration.Status, "approval_instance_id": instanceID,
"reject_reason": registration.RejectReason,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}
}
// qualificationResource 构造提现资料资格版本审计资源,证件号只记录脱敏值。
func qualificationResource(change distributionapp.AuditChange) ResourceInput {
qualification := change.Qualification
id := strconv.FormatUint(uint64(qualification.ID), 10)
instanceID := uint(0)
if qualification.ApprovalInstanceID != nil {
instanceID = *qualification.ApprovalInstanceID
}
return ResourceInput{
Type: constants.AuditResourceWithdrawalQualification, ID: &id, Key: id,
DisplayName: "提现资料资格版本 " + id,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleQualificationTarget,
IdentitySnapshot: map[string]any{
"id": qualification.ID, "shop_id": qualification.ShopID,
"subject_type": qualification.SubjectType,
"subject_code_masked": distributiondomain.MaskSubjectCode(qualification.SubjectCode),
"status": qualification.Status, "approval_instance_id": instanceID,
"invalid_reason": qualification.InvalidReason,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}
}
// withdrawalResource 构造提现单审计资源,并附加本次审批尝试记录的引用资源。
func withdrawalResource(change distributionapp.AuditChange) ResourceInput {
withdrawal := change.Withdrawal
resource := CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary,
constants.AuditResourceRoleWithdrawalTarget, change.BeforeData, change.AfterData)
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = change.Summary
return resource
}
// withdrawalAttemptResource 构造提现审批尝试记录审计资源,用于审批实例关联追踪。
func withdrawalAttemptResource(attempt *model.CommissionWithdrawalRequestAttempt) ResourceInput {
id := strconv.FormatUint(uint64(attempt.ID), 10)
instanceID := uint(0)
if attempt.ApprovalInstanceID != nil {
instanceID = *attempt.ApprovalInstanceID
}
return ResourceInput{
Type: constants.AuditResourceCommissionWithdrawalAttempt, ID: &id, Key: id,
DisplayName: "提现审批尝试 " + id,
Relation: constants.AuditResourceRelationReference,
Role: constants.AuditResourceRoleWithdrawalAttempt,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "request_id": attempt.RequestID, "attempt_no": attempt.AttemptNo,
"amount": attempt.Amount, "fee": attempt.Fee, "actual_amount": attempt.ActualAmount,
"approval_instance_id": instanceID, "released_at": attempt.ReleasedAt,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}
// auditMetadata 提取审计元数据,避免与资源明细重复。
func auditMetadata(change distributionapp.AuditChange) map[string]any {
metadata := map[string]any{}
if change.Registration != nil {
metadata["registration_status"] = change.Registration.Status
}
if change.Qualification != nil {
metadata["qualification_status"] = change.Qualification.Status
metadata["qualification_subject_type"] = change.Qualification.SubjectType
}
if change.Attempt != nil {
metadata["attempt_no"] = change.Attempt.AttemptNo
metadata["amount"] = change.Attempt.Amount
metadata["fee"] = change.Attempt.Fee
metadata["actual_amount"] = change.Attempt.ActualAmount
}
if change.Withdrawal != nil {
metadata["withdrawal_status"] = change.Withdrawal.Status
metadata["anomaly_flag"] = change.Withdrawal.AnomalyFlag
}
if change.CreatedShop != nil {
metadata["created_shop_id"] = change.CreatedShop.ID
if change.CreatedShop.DistributionCode != "" {
metadata["distribution_code_masked"] =
distributiondomain.MaskDistributionCode(change.CreatedShop.DistributionCode)
}
}
if len(metadata) == 0 {
return nil
}
return metadata
}

View File

@@ -1,41 +0,0 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
employeecollection "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteEmployeeCollectionBill 将员工代收款账单事实变化写入统一 Audit Event。
// 操作者与入口来自调用方审计上下文:订单建账为后台账号入口,充值入账为 Worker 或渠道回调入口。
func (w *Writer) WriteEmployeeCollectionBill(ctx context.Context, tx *gorm.DB, change employeecollection.BillAudit) error {
if change.Bill == nil || change.Bill.ID == 0 || change.Bill.SourceKey == "" {
return errors.New(errors.CodeInvalidParam, "员工代收款账单审计资源不完整")
}
bill := change.Bill
resourceID := strconv.FormatUint(uint64(bill.ID), 10)
return w.Append(ctx, tx, AppendInput{
EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: change.CorrelationID,
Resources: []ResourceInput{{
Type: constants.AuditResourceEmployeeCollectionBill, ID: &resourceID,
Key: bill.SourceKey, DisplayName: bill.SourceNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCollectionBill,
IdentitySnapshot: map[string]any{
"id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID,
"source_key": bill.SourceKey, "source_no": bill.SourceNo,
"debtor_account_id": bill.DebtorAccountID, "receivable_amount": bill.ReceivableAmount,
"received_amount": bill.ReceivedAmount, "reserved_amount": bill.ReservedAmount,
"status": bill.Status,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}},
})
}

View File

@@ -1,85 +0,0 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
employeecollection "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteEmployeeCollectionApplication 将核销申请、审批尝试记录与受影响账单写入统一 Audit Event。
// 申请与尝试的冻结快照含付款凭证对象键,审计只记录数量与外部流水号脱敏值,不复制敏感付款内容。
func (w *Writer) WriteEmployeeCollectionApplication(ctx context.Context, tx *gorm.DB, change employeecollection.ApplicationAudit) error {
if change.Application == nil || change.Application.ID == 0 {
return errors.New(errors.CodeInvalidParam, "核销申请审计资源不完整")
}
application := change.Application
applicationID := strconv.FormatUint(uint64(application.ID), 10)
resources := []ResourceInput{{
Type: constants.AuditResourceEmployeeCollectionApplication, ID: &applicationID,
Key: applicationID, DisplayName: "核销申请 " + applicationID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCollectionApplication,
IdentitySnapshot: map[string]any{
"id": application.ID, "applicant_account_id": application.ApplicantAccountID,
"acting_operator_id": application.ActingOperatorID,
"payment_method_id": application.PaymentMethodID, "paid_amount": application.PaidAmount,
"status": application.Status,
"latest_approval_instance_id": application.LatestApprovalInstanceID,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}}
if change.Attempt != nil && change.Attempt.ID != 0 {
attempt := change.Attempt
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
instanceID := uint(0)
if attempt.ApprovalInstanceID != nil {
instanceID = *attempt.ApprovalInstanceID
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceEmployeeCollectionAttempt, ID: &attemptID,
Key: attemptID, DisplayName: "审批尝试 " + attemptID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCollectionAttempt,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "application_id": attempt.ApplicationID, "attempt_no": attempt.AttemptNo,
"paid_amount": attempt.PaidAmount, "approval_instance_id": instanceID,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
})
}
for _, bill := range change.Bills {
if bill == nil || bill.ID == 0 {
continue
}
billID := strconv.FormatUint(uint64(bill.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceEmployeeCollectionBill, ID: &billID,
Key: bill.SourceKey, DisplayName: bill.SourceNo,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleCollectionBillAffected,
IdentitySnapshot: map[string]any{
"id": bill.ID, "source_type": bill.SourceType, "source_id": bill.SourceID,
"source_key": bill.SourceKey, "debtor_account_id": bill.DebtorAccountID, "status": bill.Status,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
})
}
return w.Append(ctx, tx, AppendInput{
EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: change.CorrelationID,
Metadata: map[string]any{
"payment_method_code": application.PaymentMethodCode,
"payer_name": application.PayerName,
"external_transaction_no_masked": employeecollectiondomain.MaskExternalTransactionNo(
application.ExternalTransactionNo),
"voucher_count": len(application.PaymentVoucherKeys),
"bill_count": len(change.Bills),
},
Resources: resources,
})
}

View File

@@ -179,16 +179,6 @@ func NewRegistry() *Registry {
wecomDefaultCreatorSaved := connectionConfigAction(constants.AuditActionWeComDefaultCreatorSaved, "保存企业微信默认审批发起人", constants.AuditResourceWeComApplication, constants.AuditRiskHigh)
wecomMembersSynced := connectionConfigAction(constants.AuditActionWeComMembersSynced, "同步企业微信应用可见成员", constants.AuditResourceWeComApplication, constants.AuditRiskNormal)
wecomApprovalSceneSaved := connectionConfigAction(constants.AuditActionWeComApprovalSceneSaved, "保存企业微信审批场景配置", constants.AuditResourceWeComApprovalScene, constants.AuditRiskHigh)
employeeCollectionPaymentMethodCreated := connectionConfigAction(constants.AuditActionEmployeeCollectionPaymentMethodCreated, "创建线下收款方式", constants.AuditResourceEmployeeCollectionPaymentMethod, constants.AuditRiskNormal)
employeeCollectionPaymentMethodUpdated := connectionConfigAction(constants.AuditActionEmployeeCollectionPaymentMethodUpdated, "更新线下收款方式", constants.AuditResourceEmployeeCollectionPaymentMethod, constants.AuditRiskNormal)
employeeCollectionPaymentMethodDeleted := connectionConfigAction(constants.AuditActionEmployeeCollectionPaymentMethodDeleted, "删除线下收款方式", constants.AuditResourceEmployeeCollectionPaymentMethod, constants.AuditRiskHigh)
employeeCollectionBillCreated := employeeCollectionAction(constants.AuditActionEmployeeCollectionBillCreated, "创建员工代收款账单", constants.AuditResourceEmployeeCollectionBill)
employeeCollectionBillClosed := employeeCollectionAction(constants.AuditActionEmployeeCollectionBillClosed, "关闭员工代收款账单", constants.AuditResourceEmployeeCollectionBill)
employeeCollectionApplicationSubmitted := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationSubmitted, "提交员工代收款核销申请", constants.AuditResourceEmployeeCollectionApplication)
employeeCollectionApplicationApproved := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationApproved, "员工代收款核销申请企业微信通过", constants.AuditResourceEmployeeCollectionApplication)
employeeCollectionApplicationRejected := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationRejected, "员工代收款核销申请企业微信驳回或撤销", constants.AuditResourceEmployeeCollectionApplication)
employeeCollectionApplicationRevoked := employeeCollectionAction(constants.AuditActionEmployeeCollectionApplicationRevoked, "员工代收款核销申请通过后撤销", constants.AuditResourceEmployeeCollectionApplication)
employeeCollectionBillRefundOffseted := employeeCollectionAction(constants.AuditActionEmployeeCollectionBillRefundOffseted, "来源订单退款冲销员工代收款账单", constants.AuditResourceEmployeeCollectionBill)
outboxReplayed := outboxRecoveryAction(
constants.AuditActionOutboxReplayed,
"人工重放 Outbox 事件",
@@ -357,36 +347,6 @@ func NewRegistry() *Registry {
withdrawalRequested := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRequested, "提交佣金提现申请")
withdrawalApproved := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请")
withdrawalRejected := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请")
// 提现审批尝试的提交由代理后台入口触发account/admin_api
// 终态由 Outbox 消费任务触发system_task/worker因此必须使用含两类入口的 distributionAction
// 而不是仅允许后台账号入口的 commissionWithdrawalAction。
withdrawalAttemptSubmitted := distributionAction(
constants.AuditActionCommissionWithdrawalAttemptSubmitted, "提交佣金提现审批尝试", constants.AuditResourceCommissionWithdrawal)
withdrawalAttemptApproved := distributionAction(
constants.AuditActionCommissionWithdrawalAttemptApproved, "企业微信通过佣金提现", constants.AuditResourceCommissionWithdrawal)
withdrawalAttemptClosed := distributionAction(
constants.AuditActionCommissionWithdrawalAttemptClosed, "企业微信驳回或撤销佣金提现", constants.AuditResourceCommissionWithdrawal)
withdrawalAnomalyFlagged := distributionAction(
constants.AuditActionCommissionWithdrawalAnomalyFlagged, "企微通过后撤销提现异常标记", constants.AuditResourceCommissionWithdrawal)
distributionRegistrationApproved := distributionAction(
constants.AuditActionAgentDistributionRegistrationApproved, "企业微信通过代理扫码注册", constants.AuditResourceAgentDistributionRegistration)
distributionRegistrationRejected := distributionAction(
constants.AuditActionAgentDistributionRegistrationRejected, "企业微信驳回代理扫码注册", constants.AuditResourceAgentDistributionRegistration)
qualificationSubmitted := distributionAction(
constants.AuditActionWithdrawalQualificationSubmitted, "提交提现资料资格", constants.AuditResourceWithdrawalQualification)
qualificationApproved := distributionAction(
constants.AuditActionWithdrawalQualificationApproved, "提现资料资格企业微信通过", constants.AuditResourceWithdrawalQualification)
qualificationRejected := distributionAction(
constants.AuditActionWithdrawalQualificationRejected, "提现资料资格企业微信驳回", constants.AuditResourceWithdrawalQualification)
qualificationVoided := distributionAction(
constants.AuditActionWithdrawalQualificationVoided, "作废提现资料资格", constants.AuditResourceWithdrawalQualification)
qualificationInvalidated := distributionAction(
constants.AuditActionWithdrawalQualificationInvalidated, "代理停用联动失效提现资料资格", constants.AuditResourceWithdrawalQualification)
// 创建前拒绝:没有业务实体可作主要资源,以店铺为主要资源,保证拒绝事实可追溯且主资源唯一。
withdrawalAttemptRejected := distributionAction(
constants.AuditActionCommissionWithdrawalAttemptRejected, "提现提交被拒绝", constants.AuditResourceShop)
qualificationSubmitRejected := distributionAction(
constants.AuditActionWithdrawalQualificationSubmitRejected, "资格提交被拒绝", constants.AuditResourceShop)
return &Registry{
actionsByOperation: map[string]ActionDefinition{
constants.AuditOperationSystemConfigUpdate: systemConfigUpdated,
@@ -403,9 +363,6 @@ func NewRegistry() *Registry {
constants.AuditOperationWeComDefaultCreatorSave: wecomDefaultCreatorSaved,
constants.AuditOperationWeComMembersSync: wecomMembersSynced,
constants.AuditOperationWeComApprovalSceneSave: wecomApprovalSceneSaved,
constants.AuditOperationEmployeeCollectionPaymentMethodCreate: employeeCollectionPaymentMethodCreated,
constants.AuditOperationEmployeeCollectionPaymentMethodUpdate: employeeCollectionPaymentMethodUpdated,
constants.AuditOperationEmployeeCollectionPaymentMethodDelete: employeeCollectionPaymentMethodDeleted,
constants.AuditOperationOutboxReplay: outboxReplayed,
constants.AuditOperationOutboxReleaseExpiredLease: outboxExpiredLeaseReleased,
},
@@ -518,16 +475,6 @@ func NewRegistry() *Registry {
constants.AuditActionCarrierUpdated: carrierUpdated,
constants.AuditActionCarrierDeleted: carrierDeleted,
constants.AuditActionCarrierStatusUpdated: carrierStatusUpdated,
constants.AuditActionEmployeeCollectionPaymentMethodCreated: employeeCollectionPaymentMethodCreated,
constants.AuditActionEmployeeCollectionPaymentMethodUpdated: employeeCollectionPaymentMethodUpdated,
constants.AuditActionEmployeeCollectionPaymentMethodDeleted: employeeCollectionPaymentMethodDeleted,
constants.AuditActionEmployeeCollectionBillCreated: employeeCollectionBillCreated,
constants.AuditActionEmployeeCollectionBillClosed: employeeCollectionBillClosed,
constants.AuditActionEmployeeCollectionApplicationSubmitted: employeeCollectionApplicationSubmitted,
constants.AuditActionEmployeeCollectionApplicationApproved: employeeCollectionApplicationApproved,
constants.AuditActionEmployeeCollectionApplicationRejected: employeeCollectionApplicationRejected,
constants.AuditActionEmployeeCollectionApplicationRevoked: employeeCollectionApplicationRevoked,
constants.AuditActionEmployeeCollectionBillRefundOffseted: employeeCollectionBillRefundOffseted,
constants.AuditActionWeComApplicationSaved: wecomApplicationSaved,
constants.AuditActionWeComDefaultCreatorSaved: wecomDefaultCreatorSaved,
constants.AuditActionWeComMembersSynced: wecomMembersSynced,
@@ -641,19 +588,6 @@ func NewRegistry() *Registry {
constants.AuditActionCommissionWithdrawalRequested: withdrawalRequested,
constants.AuditActionCommissionWithdrawalApproved: withdrawalApproved,
constants.AuditActionCommissionWithdrawalRejected: withdrawalRejected,
constants.AuditActionCommissionWithdrawalAttemptSubmitted: withdrawalAttemptSubmitted,
constants.AuditActionCommissionWithdrawalAttemptApproved: withdrawalAttemptApproved,
constants.AuditActionCommissionWithdrawalAttemptClosed: withdrawalAttemptClosed,
constants.AuditActionCommissionWithdrawalAnomalyFlagged: withdrawalAnomalyFlagged,
constants.AuditActionAgentDistributionRegistrationApproved: distributionRegistrationApproved,
constants.AuditActionAgentDistributionRegistrationRejected: distributionRegistrationRejected,
constants.AuditActionWithdrawalQualificationSubmitted: qualificationSubmitted,
constants.AuditActionWithdrawalQualificationApproved: qualificationApproved,
constants.AuditActionWithdrawalQualificationRejected: qualificationRejected,
constants.AuditActionWithdrawalQualificationVoided: qualificationVoided,
constants.AuditActionWithdrawalQualificationInvalidated: qualificationInvalidated,
constants.AuditActionCommissionWithdrawalAttemptRejected: withdrawalAttemptRejected,
constants.AuditActionWithdrawalQualificationSubmitRejected: qualificationSubmitRejected,
},
resources: map[string]ResourceDefinition{
constants.AuditResourceAccount: {
@@ -680,25 +614,6 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceCarrier, Name: "运营商配置",
IdentityFields: []string{"id", "carrier_code", "carrier_name", "carrier_type", "status"},
},
constants.AuditResourceEmployeeCollectionPaymentMethod: {
Type: constants.AuditResourceEmployeeCollectionPaymentMethod, Name: "线下收款方式",
IdentityFields: []string{"id", "code", "name", "status", "sort"},
},
constants.AuditResourceEmployeeCollectionBill: {
Type: constants.AuditResourceEmployeeCollectionBill, Name: "员工代收款账单",
IdentityFields: []string{"id", "source_type", "source_id", "source_key", "debtor_account_id", "status"},
},
constants.AuditResourceEmployeeCollectionApplication: {
Type: constants.AuditResourceEmployeeCollectionApplication, Name: "员工代收款核销申请",
IdentityFields: []string{
"id", "applicant_account_id", "acting_operator_id", "payment_method_id",
"paid_amount", "status", "latest_approval_instance_id",
},
},
constants.AuditResourceEmployeeCollectionAttempt: {
Type: constants.AuditResourceEmployeeCollectionAttempt, Name: "员工代收款核销审批尝试记录",
IdentityFields: []string{"id", "application_id", "attempt_no", "paid_amount", "approval_instance_id"},
},
constants.AuditResourceWeComApprovalScene: {
Type: constants.AuditResourceWeComApprovalScene, Name: "企业微信审批场景配置",
IdentityFields: []string{"id", "business_type", "application_id", "template_id", "template_name", "status"},
@@ -881,18 +796,6 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceCommissionWithdrawal, Name: "佣金提现单",
IdentityFields: []string{"id", "withdrawal_no", "shop_id", "applicant_id", "amount", "fee", "fee_rate", "actual_amount", "withdrawal_method", "payment_type", "status", "processor_id", "processed_at", "paid_at"},
},
constants.AuditResourceCommissionWithdrawalAttempt: {
Type: constants.AuditResourceCommissionWithdrawalAttempt, Name: "提现审批尝试记录",
IdentityFields: []string{"id", "request_id", "attempt_no", "amount", "fee", "actual_amount", "approval_instance_id", "released_at"},
},
constants.AuditResourceAgentDistributionRegistration: {
Type: constants.AuditResourceAgentDistributionRegistration, Name: "代理扫码注册记录",
IdentityFields: []string{"id", "parent_shop_id", "distribution_code_masked", "phone_masked", "username", "shop_code", "status", "approval_instance_id", "reject_reason"},
},
constants.AuditResourceWithdrawalQualification: {
Type: constants.AuditResourceWithdrawalQualification, Name: "提现资料资格版本",
IdentityFields: []string{"id", "shop_id", "subject_type", "subject_code_masked", "status", "approval_instance_id", "invalid_reason"},
},
constants.AuditResourceWeComApplication: {
Type: constants.AuditResourceWeComApplication, Name: "企业微信应用配置",
IdentityFields: []string{"id", "corp_id", "agent_id", "name", "status", "credentials_configured"},
@@ -1154,9 +1057,6 @@ func commissionWithdrawalAction(code, name string) ActionDefinition {
RequireTransaction: true, DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult, constants.AuditSubjectDetail},
SubjectFields: []string{"amount", "fee", "actual_amount", "withdrawal_method", "payment_type", "status"},
// 同一动作还由退款佣金回扣的自动拒绝提现路径发出Worker 消费任务system_task/worker
// 因此必须同时允许该入口,否则该审计会以「操作者或入口不符合动作注册规则」被丢弃。
AllowedOrigins: []ActionOrigin{{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker}},
}
}
@@ -1327,40 +1227,6 @@ func connectionConfigAction(code, name, resourceType, risk string) ActionDefinit
}
}
// employeeCollectionAction 定义员工代收款账单与核销申请动作;建账发生在来源成功事务内,
// 订单来源由后台账号触发,充值入账与审批终态由 Outbox 消费、渠道回调或恢复任务触发。
func employeeCollectionAction(code, name, primaryResource string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: primaryResource, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
},
}
}
// distributionAction 定义分销注册、提现资格与提现终审动作;提交由后台账号或公开个人接口触发,
// 终态由 Outbox 消费、渠道回调或恢复任务触发,均要求与业务事实同事务。
func distributionAction(code, name, primaryResource string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: primaryResource, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
},
}
}
// Action 返回已注册动作定义。
func (r *Registry) Action(code string) (ActionDefinition, bool) {
if r == nil {

View File

@@ -1,29 +0,0 @@
// Package shop 提供店铺相关的无状态基础设施适配。
package shop
import (
"context"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// SubordinateCache 负责清理店铺下级集合缓存。
// 新下级只在审批通过事务提交后出现,必须清理上级缓存,否则缓存有效期内新下级不可见。
type SubordinateCache struct {
redis *redis.Client
}
// NewSubordinateCache 创建店铺下级集合缓存适配器。
func NewSubordinateCache(client *redis.Client) *SubordinateCache {
return &SubordinateCache{redis: client}
}
// InvalidateSubordinateCache 清理指定店铺的下级集合缓存;缓存未配置时安全跳过。
func (c *SubordinateCache) InvalidateSubordinateCache(ctx context.Context, shopID uint) {
if c == nil || c.redis == nil || shopID == 0 {
return
}
_ = c.redis.Del(ctx, constants.RedisShopSubordinatesKey(shopID)).Err()
}

View File

@@ -1,34 +0,0 @@
package model
import "time"
// AgentDistributionRegistration 是代理扫码注册的待审批记录。
// 审批通过前不创建店铺、账号、钱包或上下级归属;同一手机号驳回后再次扫码是新记录。
type AgentDistributionRegistration struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
DistributionCode string `gorm:"column:distribution_code;type:varchar(32);not null;comment:上级店铺分销码快照" json:"distribution_code"`
ParentShopID uint `gorm:"column:parent_shop_id;not null;comment:上级店铺ID" json:"parent_shop_id"`
Phone string `gorm:"column:phone;type:varchar(20);not null;comment:注册手机号" json:"phone"`
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;comment:bcrypt 密码哈希" json:"-"`
ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:申请店铺名称快照" json:"shop_name"`
ShopCode string `gorm:"column:shop_code;type:varchar(50);not null;comment:申请店铺编号快照" json:"shop_code"`
Username string `gorm:"column:username;type:varchar(50);not null;comment:申请代理主账号用户名快照" json:"username"`
ContactName string `gorm:"column:contact_name;type:varchar(50);not null;default:'';comment:联系人姓名快照" json:"contact_name"`
Province string `gorm:"column:province;type:varchar(50);not null;default:'';comment:省份快照" json:"province"`
City string `gorm:"column:city;type:varchar(50);not null;default:'';comment:城市快照" json:"city"`
District string `gorm:"column:district;type:varchar(50);not null;default:'';comment:区县快照" json:"district"`
Address string `gorm:"column:address;type:varchar(255);not null;default:'';comment:详细地址快照" json:"address"`
Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-待审批 1-已通过 2-已驳回" json:"status"`
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:关联的通用审批实例ID" json:"approval_instance_id,omitempty"`
RejectReason string `gorm:"column:reject_reason;type:varchar(500);not null;default:'';comment:企业微信驳回原因" json:"reject_reason"`
DecidedAt *time.Time `gorm:"column:decided_at;type:timestamptz;comment:审批终态到达时间" json:"decided_at,omitempty"`
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"`
Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 指定代理扫码注册记录表名。
func (AgentDistributionRegistration) TableName() string {
return "tb_agent_distribution_registration"
}

View File

@@ -83,11 +83,6 @@ type AgentRechargeRecord struct {
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"`
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
ExternalTransactionNo *string `gorm:"column:external_transaction_no;type:varchar(128);comment:线下充值交易流水号(人工确认,独立于在线渠道第三方交易号)" json:"external_transaction_no,omitempty"`
OfflinePaymentMethodID *uint `gorm:"column:offline_payment_method_id;index;comment:线下收款方式字典ID(关联tb_employee_collection_payment_method.id)" json:"offline_payment_method_id,omitempty"`
OfflinePaymentMethodCode *string `gorm:"column:offline_payment_method_code;type:varchar(64);comment:线下收款方式稳定编码快照" json:"offline_payment_method_code,omitempty"`
OfflinePaymentMethodName *string `gorm:"column:offline_payment_method_name;type:varchar(100);comment:线下收款方式名称快照" json:"offline_payment_method_name,omitempty"`
OtherVoucherKeys StringJSONBArray `gorm:"column:other_voucher_keys;type:jsonb;comment:其他凭证对象存储Key列表(可选最多5个)" json:"other_voucher_keys"`
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款 6-已驳回)" json:"status"`
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表线下支付时必填最多5个微信支付时为空" json:"payment_voucher_key"`

View File

@@ -1,149 +0,0 @@
package dto
// CreateAgentDistributionRegistrationReq 公开扫码注册请求(无需认证)。
type CreateAgentDistributionRegistrationReq struct {
DistributionCode string `json:"distribution_code" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"上级代理店铺的分销码"`
Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"注册手机号,必须已完成短信验证码校验"`
Code string `json:"code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"短信验证码,校验成功即消费"`
Password string `json:"password" validate:"required,min=6,max=64" required:"true" minLength:"6" maximum:"64" description:"代理主账号登录密码"`
Username string `json:"username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"代理主账号用户名"`
ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"申请店铺名称"`
ShopCode string `json:"shop_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"申请店铺编号"`
ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"`
Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"`
City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"`
District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"`
Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"`
}
// CreateAgentDistributionRegistrationResp 公开扫码注册响应。
// 仅返回注册记录标识与待审批状态,不返回任何账号凭证或审批实例明细。
type CreateAgentDistributionRegistrationResp struct {
ID uint `json:"id" description:"待审批注册记录ID"`
Status int `json:"status" description:"注册记录状态 (0:待审批, 1:已通过, 2:已驳回)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
}
// SubmitWithdrawalQualificationReq 提交或替换提现资料资格请求。
type SubmitWithdrawalQualificationReq struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"`
SubjectType string `json:"subject_type" validate:"required,oneof=enterprise personal" required:"true" enum:"enterprise,personal" description:"签约主体类型 (enterprise:企业, personal:个人)"`
SubjectCode string `json:"subject_code" validate:"omitempty,max=64" maxLength:"64" description:"签约主体代码:企业填统一社会信用代码;个人留空并按法人身份证号取值"`
LegalPersonIDCard string `json:"legal_person_id_card" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"法人身份证号"`
ContractFileKey string `json:"contract_file_key" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"合同附件对象存储 Key单个对象"`
IDCardFrontFileKey string `json:"id_card_front_file_key" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"法人身份证正面附件对象存储 Key单个对象"`
IDCardBackFileKey string `json:"id_card_back_file_key" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"法人身份证反面附件对象存储 Key单个对象"`
BusinessLicenseFileKey string `json:"business_license_file_key" validate:"omitempty,max=255" maxLength:"255" description:"营业执照附件对象存储 Key单个对象可选"`
ShopFrontFileKey string `json:"shop_front_file_key" validate:"omitempty,max=255" maxLength:"255" description:"门头照附件对象存储 Key单个对象可选"`
InvoiceFileKey string `json:"invoice_file_key" validate:"omitempty,max=255" maxLength:"255" description:"发票附件对象存储 Key单个对象仅企业可选"`
InvoiceTitle string `json:"invoice_title" validate:"omitempty,max=200" maxLength:"200" description:"发票抬头,仅企业填写,必须与合同主体一致"`
InvoiceSubjectCode string `json:"invoice_subject_code" validate:"omitempty,max=64" maxLength:"64" description:"发票统一社会信用代码,必须与签约主体代码一致"`
}
// SubmitWithdrawalQualificationResp 提现资料资格提交响应。
type SubmitWithdrawalQualificationResp struct {
ID uint `json:"id" description:"资料版本ID"`
Status int `json:"status" description:"资料版本状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
}
// VoidWithdrawalQualificationReq 超级管理员作废提现资料资格请求。
type VoidWithdrawalQualificationReq struct {
Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"作废原因,必填且可在资格详情查询"`
}
// VoidWithdrawalQualificationParams 作废提现资料资格路径与请求参数。
type VoidWithdrawalQualificationParams struct {
IDReq
VoidWithdrawalQualificationReq
}
// WithdrawalQualificationListReq 提现资料资格列表查询请求。
type WithdrawalQualificationListReq struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" required:"true" description:"店铺ID路径参数必填查询前校验当前账号对该店铺的数据范围"`
Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
Status *int `query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"资料版本状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"`
}
// WithdrawalQualificationItem 提现资料资格版本项。
// 证件号按脱敏值返回,附件仅返回对象存储 Key 引用。
type WithdrawalQualificationItem struct {
ID uint `json:"id" description:"资料版本ID"`
ShopID uint `json:"shop_id" description:"所属店铺ID"`
ShopName string `json:"shop_name" description:"所属店铺名称"`
SubjectType string `json:"subject_type" description:"签约主体类型 (enterprise:企业, personal:个人)"`
SubjectTypeName string `json:"subject_type_name" description:"签约主体类型名称(中文)"`
SubjectCodeMasked string `json:"subject_code_masked" description:"脱敏后的签约主体代码"`
LegalPersonIDCardMask string `json:"legal_person_id_card_masked" description:"脱敏后的法人身份证号"`
ContractFileKey string `json:"contract_file_key" description:"合同附件对象存储 Key"`
IDCardFrontFileKey string `json:"id_card_front_file_key" description:"法人身份证正面附件对象存储 Key"`
IDCardBackFileKey string `json:"id_card_back_file_key" description:"法人身份证反面附件对象存储 Key"`
BusinessLicenseFileKey string `json:"business_license_file_key,omitempty" description:"营业执照附件对象存储 Key"`
ShopFrontFileKey string `json:"shop_front_file_key,omitempty" description:"门头照附件对象存储 Key"`
InvoiceFileKey string `json:"invoice_file_key,omitempty" description:"发票附件对象存储 Key"`
InvoiceTitle string `json:"invoice_title,omitempty" description:"发票抬头"`
Status int `json:"status" description:"状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
InvalidReason string `json:"invalid_reason,omitempty" description:"失效或作废原因"`
InvalidatedAt string `json:"invalidated_at,omitempty" description:"失效时间"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"关联的通用审批实例ID0 表示尚未关联"`
ApprovalStatus int `json:"approval_status" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批状态名称(中文)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"最近更新时间"`
}
// WithdrawalQualificationPageResult 提现资料资格分页响应。
type WithdrawalQualificationPageResult struct {
Items []WithdrawalQualificationItem `json:"items" description:"资料版本列表(按版本从新到旧)"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
Size int `json:"size" description:"每页数量"`
}
// ResubmitWithdrawalReq 驳回后重提提现申请请求。
type ResubmitWithdrawalReq struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"`
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"提现申请ID"`
Amount int64 `json:"amount" validate:"required,min=1" required:"true" minimum:"1" description:"提现金额(分)"`
WithdrawalMethod string `json:"withdrawal_method" validate:"required,oneof=alipay" required:"true" enum:"alipay" description:"收款类型"`
AccountName string `json:"account_name" validate:"required,max=50" required:"true" maximum:"50" description:"收款人姓名"`
AccountNumber string `json:"account_number" validate:"required,max=100" required:"true" maximum:"100" description:"收款账号"`
InvoiceKeys []string `json:"invoice_keys" description:"本次申请级发票对象存储 Key 列表;仅企业主体有效资格可提交"`
}
// ShopWithdrawalRequestDetailReq 提现申请详情路径参数。
type ShopWithdrawalRequestDetailReq struct {
ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"`
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"提现申请ID"`
}
// WithdrawalRequestAttemptItem 提现审批尝试记录项。
type WithdrawalRequestAttemptItem struct {
ID uint `json:"id" description:"审批尝试记录ID"`
AttemptNo int `json:"attempt_no" description:"第几次提交,从 1 递增"`
Amount int64 `json:"amount" description:"本次提现金额(分)"`
Fee int64 `json:"fee" description:"本次手续费(分)"`
FeeRate int64 `json:"fee_rate" description:"本次手续费率基点100=1%"`
ActualAmount int64 `json:"actual_amount" description:"本次实际到账金额(分)"`
WithdrawalMethod string `json:"withdrawal_method" description:"本次收款方式"`
SubmittedByID uint `json:"submitted_by_account_id" description:"本次提交账号ID"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"本次尝试关联的通用审批实例ID0 表示尚未关联"`
ApprovalStatus int `json:"approval_status" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批状态名称(中文)"`
ReleasedAt string `json:"released_at,omitempty" description:"本次冻结释放时间,空表示冻结仍未结算"`
CreatedAt string `json:"created_at" description:"创建时间"`
}
// ShopWithdrawalRequestDetailResp 提现申请详情响应。
type ShopWithdrawalRequestDetailResp struct {
ShopWithdrawalRequestItem
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID0 表示尚未接入企业微信审批"`
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID0 表示尚未接入"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"首次接入企业微信审批的审批实例ID0 表示存量申请仍走本地人工终审"`
AnomalyFlag int `json:"anomaly_flag" description:"正交异常标记 (0:无异常, 1:通过后撤销)"`
AnomalyName string `json:"anomaly_name" description:"异常名称(中文)"`
AnomalyReason string `json:"anomaly_reason,omitempty" description:"异常原因,供人工处理"`
Attempts []WithdrawalRequestAttemptItem `json:"attempts" description:"审批尝试记录,按提交次序倒序;历史尝试与审批结果不被覆盖"`
}

View File

@@ -6,10 +6,7 @@ type CreateAgentRechargeRequest struct {
Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额范围1分~100万元"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay offline" required:"true" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账仅平台可用)"`
RequestID string `json:"request_id,omitempty" validate:"omitempty,max=64" maxLength:"64" description:"在线充值幂等请求标识,微信或支付宝支付时必填"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表payment_method=offline 时至少1个最多5个在线支付时禁止传入"`
OtherVoucherKey []string `json:"other_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"其他凭证对象存储Key列表线下代充可选最多5个在线支付时禁止传入"`
OfflinePaymentMethodID uint `json:"offline_payment_method_id" description:"线下收款方式字典IDpayment_method=offline 时必填,须取自启用中的线下收款方式字典)"`
ExternalTransactionNo string `json:"external_transaction_no" validate:"omitempty,max=128" maxLength:"128" description:"交易流水号payment_method=offline 时必填,由付款凭证识别预填并经人工确认;系统不做跨记录去重)"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表payment_method=offline 时至少1个最多5个微信支付时忽略"`
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
}
@@ -48,29 +45,6 @@ type AgentRechargePaymentStatusResponse struct {
CompletedAt *string `json:"completed_at" description:"钱包入账完成时间"`
}
// AgentSelfRechargePaymentMethodsUpdateRequest 修改代理在线自充允许范围请求。
// 枚举取值必须在 description 与 enum 标签两处与 pkg/constants 的 AgentSelfRechargeAllowed* 保持一致。
type AgentSelfRechargePaymentMethodsUpdateRequest struct {
AllowedMethods string `json:"allowed_methods" validate:"required,oneof=wechat_only alipay_only both" required:"true" enum:"wechat_only,alipay_only,both" description:"允许范围 (wechat_only:仅微信, alipay_only:仅支付宝, both:同时支持微信和支付宝)"`
}
// AgentSelfRechargePaymentMethodsUpdateResponse 修改代理在线自充允许范围响应。
type AgentSelfRechargePaymentMethodsUpdateResponse struct {
AllowedMethods string `json:"allowed_methods" description:"允许范围 (wechat_only:仅微信, alipay_only:仅支付宝, both:同时支持微信和支付宝)"`
AllowedMethodsName string `json:"allowed_methods_name" description:"允许范围名称(中文)"`
UpdatedAt string `json:"updated_at" description:"最近更新时间,带时区 RFC3339 格式"`
}
// AgentRechargePaymentVoucherOCRRequest 付款凭证识别请求。
type AgentRechargePaymentVoucherOCRRequest struct {
PaymentVoucherKey string `json:"payment_voucher_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"付款凭证对象存储Key必须指向已上传的图片类型附件"`
}
// AgentRechargePaymentVoucherOCRResponse 付款凭证识别响应,只返回交易流水号预填值。
type AgentRechargePaymentVoucherOCRResponse struct {
ExternalTransactionNo string `json:"external_transaction_no" description:"识别出的交易流水号预填值,必须经人工确认或更正后提交;金额、备注、付款人、支付方式与支付时间一律不返回"`
}
// AgentOfflinePayRequest 代理线下充值确认请求
type AgentOfflinePayRequest struct {
OperationPassword string `json:"operation_password" validate:"required" required:"true" description:"操作密码"`
@@ -95,13 +69,7 @@ type AgentRechargeResponse struct {
RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"`
PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"`
PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID线下充值为null"`
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号(在线渠道权威值,仅在线支付有值)"`
// ExternalTransactionNo 是线下充值人工确认的交易流水号,独立于在线渠道第三方交易号。
ExternalTransactionNo string `json:"external_transaction_no" description:"交易流水号(线下充值人工确认值,独立于在线渠道第三方交易号)"`
OfflinePaymentMethodID uint `json:"offline_payment_method_id,omitempty" description:"线下收款方式字典ID仅线下充值有值"`
OfflinePaymentMethodCode string `json:"offline_payment_method_code,omitempty" description:"线下收款方式稳定编码快照,仅线下充值有值"`
OfflinePaymentMethodName string `json:"offline_payment_method_name,omitempty" description:"线下收款方式名称快照,仅线下充值有值"`
OtherVoucherKey []string `json:"other_voucher_key" description:"其他凭证对象存储Key列表仅线下充值有值不返回凭证内容"`
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"`
PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表线下支付时存在最多5个"`
Remark string `json:"remark,omitempty" description:"运营备注"`
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`

View File

@@ -160,45 +160,12 @@ type AssetPackageResponse struct {
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// 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 后台资产套餐历史分页结果。
// AssetPackagesResult 套餐列表分页结果
type AssetPackagesResult struct {
Total int64 `json:"total" description:"筛选后的顶层关系组总数"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页顶层关系组数量"`
Items []*AssetPackageHistoryNode `json:"items" nullable:"false" description:"套餐历史层级列表"`
PageSize int `json:"page_size" description:"每页条数"`
Items []*AssetPackageResponse `json:"items" description:"套餐列表"`
}
// AssetResolveRequest 资产解析请求

View File

@@ -183,39 +183,12 @@ type AssetPackageHistoryRequest struct {
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// 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 资产套餐历史层级响应。
// AssetPackageHistoryResponse B3 资产套餐历史响应
type AssetPackageHistoryResponse struct {
List []*ClientAssetPackageHistoryNode `json:"items" nullable:"false" description:"套餐历史层级列表"`
Total int64 `json:"total" description:"筛选后的顶层关系组总数"`
List []AssetPackageResponse `json:"items" description:"套餐历史列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"size" description:"每页顶层关系组数量"`
PageSize int `json:"size" description:"每页数量"`
}
// ========================================

View File

@@ -1,305 +0,0 @@
package dto
import "time"
// CreateEmployeeCollectionPaymentMethodRequest 创建线下收款方式请求
// 枚举说明enabled 对应 constants.EmployeeCollectionPaymentMethodStatusEnabled/Disabled1 启用、0 停用)。
type CreateEmployeeCollectionPaymentMethodRequest struct {
Code string `json:"code" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"收款方式稳定编码1-64 字符,未删除记录中唯一,被核销申请引用后不可修改"`
Name string `json:"name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"收款方式名称1-100 字符"`
Sort *int64 `json:"sort" validate:"omitempty,min=0" minimum:"0" description:"排序值,非负整数,默认 0"`
Enabled *bool `json:"enabled" description:"是否启用,默认启用;停用后新核销申请不可选择该方式"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注,最多 500 字符"`
}
// UpdateEmployeeCollectionPaymentMethodRequest 更新线下收款方式请求
// 全部字段可选:仅传入的字段被修改;已启用即被核销申请引用的记录不允许修改稳定编码。
type UpdateEmployeeCollectionPaymentMethodRequest struct {
Code *string `json:"code" validate:"omitempty,min=1,max=64" minLength:"1" maxLength:"64" description:"收款方式稳定编码,仅未被核销申请引用时可修改"`
Name *string `json:"name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"收款方式名称"`
Sort *int64 `json:"sort" validate:"omitempty,min=0" minimum:"0" description:"排序值,非负整数"`
Enabled *bool `json:"enabled" description:"是否启用;停用后新核销申请不可选择该方式,历史申请仍展示冻结名称"`
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注,最多 500 字符"`
}
// EmployeeCollectionPaymentMethodListRequest 查询线下收款方式列表请求
type EmployeeCollectionPaymentMethodListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数默认20最大100"`
Enabled *bool `json:"enabled" query:"enabled" description:"按启用状态过滤;超级管理员可查询全部,其他后台账号仅返回启用项"`
Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"按稳定编码或名称模糊搜索,最多 100 字符"`
}
// EmployeeCollectionPaymentMethodResponse 线下收款方式响应
// 对外契约字段固定为 id/code/name/enabled其余字段为维护与展示补充。
type EmployeeCollectionPaymentMethodResponse struct {
ID uint `json:"id" description:"收款方式ID"`
Code string `json:"code" description:"收款方式稳定编码"`
Name string `json:"name" description:"收款方式名称"`
Enabled bool `json:"enabled" description:"是否启用;停用后新核销申请不可选择该方式"`
Sort int64 `json:"sort" description:"排序值"`
Remark string `json:"remark" description:"备注"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
// EmployeeCollectionPaymentMethodListResponse 线下收款方式列表响应
type EmployeeCollectionPaymentMethodListResponse struct {
List []*EmployeeCollectionPaymentMethodResponse `json:"items" description:"收款方式列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"size" description:"每页条数"`
}
// EmployeeCollectionBillListRequest 查询员工代收款账单列表请求。
// 枚举说明source_type 对应 constants.EmployeeCollectionSourceTypeOrder/Recharge
// status 对应 constants.EmployeeCollectionBillStatusPending/Partial/Settled/Closed。
type EmployeeCollectionBillListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数默认20最大100"`
SourceType *string `json:"source_type" query:"source_type" validate:"omitempty,oneof=order recharge" enum:"order,recharge" description:"按来源类型过滤 (order:后台线下套餐订单, recharge:代理线下充值)"`
SourceNo *string `json:"source_no" query:"source_no" validate:"omitempty,max=64" maxLength:"64" description:"按来源单号精确过滤"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按账单状态过滤 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
DebtorAccountID *uint `json:"debtor_account_id" query:"debtor_account_id" description:"按欠款人后台账号ID过滤非超级管理员固定为当前账号该参数被忽略"`
CustomerID *uint `json:"customer_id" query:"customer_id" description:"按来源客户或店铺ID过滤匹配来源订单买家或充值归属店铺"`
CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始YYYY-MM-DD含当日 00:00:00"`
CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止YYYY-MM-DD含当日 23:59:59"`
}
// EmployeeCollectionBillStatisticsRequest 查询员工代收款账单统计请求。
// 与账单列表使用同一批筛选字段与可见性范围,但不接受分页参数。
type EmployeeCollectionBillStatisticsRequest struct {
SourceType *string `json:"source_type" query:"source_type" validate:"omitempty,oneof=order recharge" enum:"order,recharge" description:"按来源类型过滤 (order:后台线下套餐订单, recharge:代理线下充值)"`
SourceNo *string `json:"source_no" query:"source_no" validate:"omitempty,max=64" maxLength:"64" description:"按来源单号精确过滤"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按账单状态过滤 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
DebtorAccountID *uint `json:"debtor_account_id" query:"debtor_account_id" description:"按欠款人后台账号ID过滤非超级管理员固定为当前账号该参数被忽略"`
CustomerID *uint `json:"customer_id" query:"customer_id" description:"按来源客户或店铺ID过滤匹配来源订单买家或充值归属店铺"`
CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始YYYY-MM-DD含当日 00:00:00"`
CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止YYYY-MM-DD含当日 23:59:59"`
}
// EmployeeCollectionBillResponse 员工代收款账单行响应。
type EmployeeCollectionBillResponse struct {
ID uint `json:"id" description:"账单ID"`
SourceType string `json:"source_type" description:"来源类型 (order:后台线下套餐订单, recharge:代理线下充值)"`
SourceTypeName string `json:"source_type_name" description:"来源类型中文名称"`
SourceID uint `json:"source_id" description:"来源业务主键ID"`
SourceNo string `json:"source_no" description:"来源单号"`
DebtorAccountID uint `json:"debtor_account_id" description:"欠款人后台账号ID"`
DebtorSnapshot map[string]any `json:"debtor_snapshot" description:"欠款人账号只读快照账号ID、名称、类型"`
CustomerSnapshot map[string]any `json:"customer_snapshot" description:"来源客户或店铺只读快照,不含付款凭证内容"`
ReceivableAmount int64 `json:"receivable_amount" description:"应收金额(分)"`
ReceivedAmount int64 `json:"received_amount" description:"已核销金额(分),仅企业微信最终通过的分摊计入"`
ReservedAmount int64 `json:"reserved_amount" description:"审批中预占金额(分)"`
RemainingAmount int64 `json:"remaining_amount" description:"剩余可核销金额(分),已关闭账单为 0"`
Status int `json:"status" description:"账单状态 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
StatusName string `json:"status_name" description:"账单状态中文名称"`
ApprovalPending bool `json:"approval_pending" description:"是否存在审批中分摊(预占大于零)"`
ClosedReason string `json:"closed_reason" description:"关闭原因,仅已关闭账单有值"`
ClosedAt *time.Time `json:"closed_at,omitempty" description:"关闭时间"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"`
}
// EmployeeCollectionBillListResponse 员工代收款账单列表响应。
type EmployeeCollectionBillListResponse struct {
List []*EmployeeCollectionBillResponse `json:"items" description:"账单列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"size" description:"每页条数"`
}
// EmployeeCollectionBillStatisticsResponse 员工代收款账单统计响应。
// 与账单列表使用同一筛选条件与可见性范围。
type EmployeeCollectionBillStatisticsResponse struct {
ReceivableTotal int64 `json:"receivable_total" description:"应收金额合计(分)"`
ReceivedTotal int64 `json:"received_total" description:"已核销金额合计(分)"`
UnsettledTotal int64 `json:"unsettled_total" description:"未核销金额合计(分),已关闭账单未核销余额按 0 计入"`
PendingBillCount int64 `json:"pending_bill_count" description:"待处理账单数,即待核销或部分核销账单数量"`
}
// EmployeeCollectionBillRefundResponse 账单退款冲销关联响应。
type EmployeeCollectionBillRefundResponse struct {
ID uint `json:"id" description:"冲销关联ID"`
RefundID uint `json:"refund_id" description:"退款申请ID"`
SourceOrderID uint `json:"source_order_id" description:"账单来源订单ID"`
RefundAmount int64 `json:"refund_amount" description:"本次退款成功金额(分)"`
BillReceivableAmount int64 `json:"bill_receivable_amount" description:"冲销前账单应收金额快照(分)"`
Outcome string `json:"outcome" description:"处理结果 (closed_full:来源订单全额退款关闭, reduced:按退款金额冲减应收, hint_only:仅记录退款关联提示)"`
OutcomeName string `json:"outcome_name" description:"处理结果中文名称"`
ReducedAmount int64 `json:"reduced_amount" description:"实际冲减应收金额(分)"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// EmployeeCollectionBillAllocationResponse 账单分摊响应。
type EmployeeCollectionBillAllocationResponse struct {
ID uint `json:"id" description:"分摊ID"`
ApplicationID uint `json:"application_id" description:"核销申请ID"`
ApplicationStatus int `json:"application_status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"`
ApplicationStatusName string `json:"application_status_name" description:"申请状态中文名称"`
AttemptID uint `json:"attempt_id" description:"所属审批尝试记录ID"`
Amount int64 `json:"amount" description:"本次分摊金额(分)"`
Status int `json:"status" description:"分摊状态 (0:审批中预占, 1:已通过, 2:已驳回或已释放)"`
StatusName string `json:"status_name" description:"分摊状态中文名称"`
ReleasedAt *time.Time `json:"released_at,omitempty" description:"预占释放时间,审批中为空"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// EmployeeCollectionBillAttemptResponse 申请审批尝试记录响应,材料为本次提交的冻结快照。
type EmployeeCollectionBillAttemptResponse struct {
ID uint `json:"id" description:"审批尝试记录ID同时是通用审批业务ID"`
AttemptNo int `json:"attempt_no" description:"第几次提交,从 1 递增"`
PaymentMethodID uint `json:"payment_method_id" description:"本次冻结的收款方式字典ID"`
PaymentMethodCode string `json:"payment_method_code" description:"本次冻结的收款方式稳定编码"`
PaymentMethodName string `json:"payment_method_name" description:"本次冻结的收款方式名称"`
PaidAmount int64 `json:"paid_amount" description:"本次冻结的付款金额(分)"`
PayerName string `json:"payer_name" description:"付款方名称"`
PaidAt time.Time `json:"paid_at" description:"付款时间"`
ExternalTransactionNo string `json:"external_transaction_no" description:"人工确认的外部交易流水号,申请人可原样核对与重用"`
PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表仅返回对象键引用"`
Remark string `json:"remark" description:"本次提交备注"`
SubmittedByAccountID uint `json:"submitted_by_account_id" description:"本次实际提交账号ID代办时为超级管理员"`
ActingReason string `json:"acting_reason" description:"本次代办原因,非代办为空"`
AllocationSnapshot []map[string]any `json:"allocation_snapshot" description:"本次冻结的账单分摊快照"`
ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"本次尝试关联的通用审批实例ID"`
ApprovalStatus *int `json:"approval_status,omitempty" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批实例状态中文名称"`
ApprovalOpinion string `json:"approval_opinion" description:"渠道审批意见文本,取自通用审批实例终态决策快照;无意见时为空"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// EmployeeCollectionBillApplicationResponse 账单关联的核销申请响应,含审批历史。
type EmployeeCollectionBillApplicationResponse struct {
ID uint `json:"id" description:"核销申请ID"`
ApplicantAccountID uint `json:"applicant_account_id" description:"申请人后台账号ID"`
ActingOperatorID uint `json:"acting_operator_id" description:"实际代办的超级管理员账号ID0 表示本人办理"`
ActingReason string `json:"acting_reason" description:"代办原因,非代办为空"`
PaymentMethodID uint `json:"payment_method_id" description:"线下收款方式字典ID"`
PaymentMethodCode string `json:"payment_method_code" description:"收款方式稳定编码快照"`
PaymentMethodName string `json:"payment_method_name" description:"收款方式名称快照"`
PaidAmount int64 `json:"paid_amount" description:"人工确认的付款金额(分)"`
PayerName string `json:"payer_name" description:"付款方名称"`
PaidAt time.Time `json:"paid_at" description:"付款时间"`
ExternalTransactionNo string `json:"external_transaction_no" description:"外部交易流水号"`
PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表仅返回对象键引用"`
Remark string `json:"remark" description:"申请备注"`
Status int `json:"status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"`
StatusName string `json:"status_name" description:"申请状态中文名称"`
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID仅用于展示"`
DecidedAt *time.Time `json:"decided_at,omitempty" description:"审批终态到达时间"`
TerminalReason string `json:"terminal_reason" description:"异常终态说明,如企业微信通过后撤销"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
Attempts []*EmployeeCollectionBillAttemptResponse `json:"attempts" description:"该申请的审批尝试记录,按提交顺序排列"`
}
// EmployeeCollectionBillDetailResponse 员工代收款账单详情响应。
type EmployeeCollectionBillDetailResponse struct {
Bill *EmployeeCollectionBillResponse `json:"bill" description:"账单事实"`
Refunds []*EmployeeCollectionBillRefundResponse `json:"refunds" description:"来源订单退款冲销关联"`
Allocations []*EmployeeCollectionBillAllocationResponse `json:"allocations" description:"该账单的核销分摊"`
Applications []*EmployeeCollectionBillApplicationResponse `json:"applications" description:"涉及该账单的核销申请与审批历史"`
}
// CloseEmployeeCollectionBillRequest 关闭员工代收款账单请求
type CloseEmployeeCollectionBillRequest struct {
Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"关闭原因,必填,最多 500 字符"`
}
// EmployeeCollectionApplicationAllocationRequest 核销申请中的单张账单分摊。
type EmployeeCollectionApplicationAllocationRequest struct {
BillID uint `json:"bill_id" validate:"required" required:"true" description:"目标员工代收款账单ID"`
Amount int64 `json:"amount" validate:"required,min=1" required:"true" minimum:"1" description:"本次分摊金额(分),必须大于零且不超过该账单可核销余额"`
}
// SubmitEmployeeCollectionApplicationRequest 创建或重提核销申请请求。
// 枚举说明allocations 由申请人提交,服务端按账单可核销余额与付款金额严格校验。
type SubmitEmployeeCollectionApplicationRequest struct {
PaymentMethodID uint `json:"payment_method_id" validate:"required" required:"true" description:"线下收款方式字典ID必须是启用中的字典项"`
PaidAmount int64 `json:"paid_amount" validate:"required,min=1" required:"true" minimum:"1" description:"人工确认的付款金额(分),必须大于零"`
PayerName string `json:"payer_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"付款方名称1-100 字符"`
PaidAt string `json:"paid_at" validate:"required" required:"true" description:"付款时间,带时区的 RFC3339 格式,如 2026-08-31T10:00:00+08:00"`
ExternalTransactionNo string `json:"external_transaction_no" validate:"required,min=1,max=128" required:"true" minLength:"1" maxLength:"128" description:"经人工确认的外部交易流水号OCR 结果必须人工更正后提交"`
PaymentVoucherKeys []string `json:"payment_voucher_keys" validate:"required,min=1,max=5,dive,min=1,max=512" required:"true" minItems:"1" maxItems:"5" description:"支付凭证对象存储Key列表1-5 个既有对象键引用"`
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"申请备注,最多 500 字符"`
ActingReason string `json:"acting_reason" validate:"omitempty,max=500" maxLength:"500" description:"超级管理员代办原因1-500 字符;代办时必填,本人办理不得填写"`
Allocations []EmployeeCollectionApplicationAllocationRequest `json:"allocations" validate:"required,min=1,max=50,dive" required:"true" minItems:"1" maxItems:"50" description:"账单分摊列表,至少一条;每张账单本次只能出现一次"`
}
// EmployeeCollectionApplicationListRequest 查询核销申请列表请求。
type EmployeeCollectionApplicationListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数默认20最大100"`
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"按申请状态过滤 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"`
ApplicantAccountID *uint `json:"applicant_account_id" query:"applicant_account_id" description:"按申请人账号ID过滤非超级管理员固定为当前账号该参数被忽略"`
PaymentMethodID *uint `json:"payment_method_id" query:"payment_method_id" description:"按线下收款方式字典ID过滤"`
CreatedFrom *string `json:"created_from" query:"created_from" description:"创建时间起始YYYY-MM-DD含当日 00:00:00"`
CreatedTo *string `json:"created_to" query:"created_to" description:"创建时间截止YYYY-MM-DD含当日 23:59:59"`
}
// EmployeeCollectionApplicationResponse 核销申请行响应。
type EmployeeCollectionApplicationResponse struct {
ID uint `json:"id" description:"核销申请ID"`
ApplicantAccountID uint `json:"applicant_account_id" description:"申请人后台账号ID"`
ActingOperatorID uint `json:"acting_operator_id" description:"实际代办的超级管理员账号ID0 表示本人办理"`
ActingReason string `json:"acting_reason" description:"代办原因,非代办为空"`
PaymentMethodID uint `json:"payment_method_id" description:"线下收款方式字典ID"`
PaymentMethodCode string `json:"payment_method_code" description:"收款方式稳定编码快照"`
PaymentMethodName string `json:"payment_method_name" description:"收款方式名称快照,字典改名不影响历史申请"`
PaidAmount int64 `json:"paid_amount" description:"人工确认的付款金额(分)"`
PayerName string `json:"payer_name" description:"付款方名称"`
PaidAt time.Time `json:"paid_at" description:"付款时间"`
ExternalTransactionNo string `json:"external_transaction_no" description:"人工确认的外部交易流水号,申请人可原样核对与重用"`
PaymentVoucherKeys []string `json:"payment_voucher_keys" description:"支付凭证对象存储Key列表仅返回对象键引用"`
Remark string `json:"remark" description:"申请备注"`
Status int `json:"status" description:"申请状态 (0:审批中, 1:已通过, 2:已驳回, 3:已撤销或已关闭)"`
StatusName string `json:"status_name" description:"申请状态中文名称"`
LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID仅用于展示"`
LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID仅用于展示"`
DecidedAt *time.Time `json:"decided_at,omitempty" description:"审批终态到达时间"`
TerminalReason string `json:"terminal_reason" description:"异常终态说明,如企业微信通过后撤销"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"`
}
// EmployeeCollectionApplicationListResponse 核销申请列表响应。
type EmployeeCollectionApplicationListResponse struct {
List []*EmployeeCollectionApplicationResponse `json:"items" description:"核销申请列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"size" description:"每页条数"`
}
// EmployeeCollectionApplicationAllocationResponse 核销申请分摊响应,含目标账单只读摘要。
type EmployeeCollectionApplicationAllocationResponse struct {
ID uint `json:"id" description:"分摊ID"`
BillID uint `json:"bill_id" description:"目标账单ID"`
Amount int64 `json:"amount" description:"本次分摊金额(分)"`
Status int `json:"status" description:"分摊状态 (0:审批中预占, 1:已通过, 2:已驳回或已释放)"`
StatusName string `json:"status_name" description:"分摊状态中文名称"`
ReleasedAt *time.Time `json:"released_at,omitempty" description:"预占释放时间,审批中为空"`
BillSourceType string `json:"bill_source_type" description:"账单来源类型 (order:后台线下套餐订单, recharge:代理线下充值)"`
BillSourceNo string `json:"bill_source_no" description:"账单来源单号"`
BillReceivableAmount int64 `json:"bill_receivable_amount" description:"账单应收金额(分)"`
BillReceivedAmount int64 `json:"bill_received_amount" description:"账单已核销金额(分)"`
BillReservedAmount int64 `json:"bill_reserved_amount" description:"账单审批中预占金额(分)"`
BillStatus int `json:"bill_status" description:"账单状态 (0:待核销, 1:部分核销, 2:已核销, 3:已关闭)"`
BillStatusName string `json:"bill_status_name" description:"账单状态中文名称"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// EmployeeCollectionApplicationSubmitResponse 创建或重提核销申请响应。
type EmployeeCollectionApplicationSubmitResponse struct {
Application *EmployeeCollectionApplicationResponse `json:"application" description:"核销申请事实"`
Attempt *EmployeeCollectionBillAttemptResponse `json:"attempt" description:"本次新增的审批尝试记录与冻结快照"`
Allocations []*EmployeeCollectionApplicationAllocationResponse `json:"allocations" description:"本次账单分摊与预占结果"`
ApprovalInstanceID uint `json:"approval_instance_id" description:"本次创建的企业微信通用审批实例ID"`
ApprovalStatus int `json:"approval_status" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"`
ApprovalStatusName string `json:"approval_status_name" description:"通用审批实例状态中文名称"`
}
// EmployeeCollectionApplicationDetailResponse 核销申请详情响应。
type EmployeeCollectionApplicationDetailResponse struct {
Application *EmployeeCollectionApplicationResponse `json:"application" description:"核销申请事实与最新审批引用"`
Allocations []*EmployeeCollectionApplicationAllocationResponse `json:"allocations" description:"该申请当前的账单分摊"`
Attempts []*EmployeeCollectionBillAttemptResponse `json:"attempts" description:"全部审批尝试记录,按提交顺序排列,历史材料不被覆盖"`
}

View File

@@ -15,7 +15,7 @@ type CreateAdminOrderRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(卡支持 ICCID设备支持 VirtualNo、IMEI 或 SN"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表最多5个通过/storage/upload-url上传图片后获得;线下订单需付款凭证,但实际收款金额大于 0 且由平台账号操作的非赠送线下订单会生成员工代收款账单,该场景凭证由核销申请环节提供,创建订单时可为空"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表payment_method=offline时至少1个最多5个通过/storage/upload-url上传图片后获得"`
}
type OrderListRequest struct {

View File

@@ -1,124 +0,0 @@
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

@@ -89,9 +89,6 @@ type ShopResponse struct {
ID uint `json:"id" description:"店铺ID"`
ShopName string `json:"shop_name" description:"店铺名称"`
ShopCode string `json:"shop_code" description:"店铺编号"`
// DistributionCode 是创建时生成的全局唯一随机分销码,创建后不可修改。
// 该字段为只读;它是该店铺作为上级被扫码注册的唯一入口标识,二维码只编码该码。
DistributionCode string `json:"distribution_code" description:"分销码(全局唯一、创建后不可修改的只读随机码;该店铺作为上级被扫码注册的唯一入口标识)"`
ParentID *uint `json:"parent_id,omitempty" description:"上级店铺ID"`
ParentShopName string `json:"parent_shop_name,omitempty" description:"上级店铺名称"`
BusinessOwnerAccountID *uint `json:"business_owner_account_id" description:"平台业务员账号IDnull 表示未归属"`

View File

@@ -23,7 +23,7 @@ type InspectWeComTemplateParams struct {
// WeComBusinessFieldListParams 查询企业微信审批场景可映射字段路径参数。
type WeComBusinessFieldListParams struct {
BusinessType string `path:"business_type" required:"true" enum:"refund_approval,offline_recharge_approval,employee_collection_approval,agent_distribution_approval,withdrawal_qualification_approval,commission_withdrawal_approval" description:"业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批, employee_collection_approval:员工代收款核销审批, agent_distribution_approval:代理扫码分销注册审批, withdrawal_qualification_approval:提现资料资格审批, commission_withdrawal_approval:佣金提现终审)"`
BusinessType string `path:"business_type" required:"true" description:"业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批)"`
}
// WeComBusinessFieldResponse 企业微信审批场景可映射业务字段响应。
@@ -67,7 +67,7 @@ type SaveWeComApprovalSceneRequest struct {
// SaveWeComApprovalSceneParams 保存企业微信审批场景路径与请求参数。
type SaveWeComApprovalSceneParams struct {
BusinessType string `path:"business_type" required:"true" enum:"refund_approval,offline_recharge_approval,employee_collection_approval,agent_distribution_approval,withdrawal_qualification_approval,commission_withdrawal_approval" description:"业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批, employee_collection_approval:员工代收款核销审批, agent_distribution_approval:代理扫码分销注册审批, withdrawal_qualification_approval:提现资料资格审批, commission_withdrawal_approval:佣金提现终审);可先调用 GET /api/admin/wecom/scenes/{business_type}/fields 查询允许映射的业务字段"`
BusinessType string `path:"business_type" required:"true" description:"业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批);可先调用 GET /api/admin/wecom/scenes/{business_type}/fields 查询允许映射的业务字段"`
SaveWeComApprovalSceneRequest
}

View File

@@ -1,150 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// EmployeeCollectionPaymentMethod 是线下收款方式字典项。
// 状态语义见 constants.EmployeeCollectionPaymentMethodStatus*;被业务引用后只可停用。
type EmployeeCollectionPaymentMethod struct {
gorm.Model
BaseModel `gorm:"embedded"`
Code string `gorm:"column:code;type:varchar(64);not null;comment:稳定编码,未删除记录中唯一" json:"code"`
Name string `gorm:"column:name;type:varchar(100);not null;comment:收款方式名称" json:"name"`
SortOrder int64 `gorm:"column:sort_order;type:bigint;not null;default:0;comment:排序值,从 0 递增" json:"sort_order"`
Status int `gorm:"column:status;type:smallint;not null;default:1;comment:状态 0-禁用 1-启用" json:"status"`
Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:备注" json:"remark"`
}
// TableName 指定线下收款方式字典表名。
func (EmployeeCollectionPaymentMethod) TableName() string {
return "tb_employee_collection_payment_method"
}
// EmployeeCollectionBill 是后台账号代客户经办业务形成的本地暂挂欠款。
// 由来源成功事务按来源唯一键 SourceKey 幂等创建,不存在历史扫描或手工建账路径。
type EmployeeCollectionBill struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
SourceType string `gorm:"column:source_type;type:varchar(20);not null;comment:来源类型 order/recharge" json:"source_type"`
SourceID uint `gorm:"column:source_id;not null;comment:来源业务主键ID" json:"source_id"`
SourceKey string `gorm:"column:source_key;type:varchar(64);not null;comment:来源唯一键 order:{id} / recharge:{id}" json:"source_key"`
SourceNo string `gorm:"column:source_no;type:varchar(64);not null;default:'';comment:来源单号快照" json:"source_no"`
DebtorAccountID uint `gorm:"column:debtor_account_id;not null;comment:欠款人后台账号ID" json:"debtor_account_id"`
DebtorSnapshot datatypes.JSON `gorm:"column:debtor_snapshot;type:jsonb;not null;comment:欠款人账号快照" json:"debtor_snapshot"`
CustomerSnapshot datatypes.JSON `gorm:"column:customer_snapshot;type:jsonb;not null;comment:来源客户或店铺只读快照" json:"customer_snapshot"`
ReceivableAmount int64 `gorm:"column:receivable_amount;type:bigint;not null;comment:应收金额(分)" json:"receivable_amount"`
ReceivedAmount int64 `gorm:"column:received_amount;type:bigint;not null;default:0;comment:已核销金额(分)" json:"received_amount"`
ReservedAmount int64 `gorm:"column:reserved_amount;type:bigint;not null;default:0;comment:审批中预占金额(分)" json:"reserved_amount"`
Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-待核销 1-部分核销 2-已核销 3-已关闭" json:"status"`
ClosedReason string `gorm:"column:closed_reason;type:varchar(500);not null;default:'';comment:关闭原因" json:"closed_reason"`
ClosedAt *time.Time `gorm:"column:closed_at;type:timestamptz;comment:关闭时间" json:"closed_at,omitempty"`
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"`
Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 指定员工代收款账单表名。
func (EmployeeCollectionBill) TableName() string {
return "tb_employee_collection_bill"
}
// EmployeeCollectionApplication 是一笔外部付款的核销申请只保存最新审批实例ID用于展示。
type EmployeeCollectionApplication struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ApplicantAccountID uint `gorm:"column:applicant_account_id;not null;comment:申请人后台账号ID" json:"applicant_account_id"`
ActingOperatorID uint `gorm:"column:acting_operator_id;not null;default:0;comment:实际代办的超级管理员账号ID0 表示本人办理" json:"acting_operator_id"`
ActingReason string `gorm:"column:acting_reason;type:varchar(500);not null;default:'';comment:代办原因" json:"acting_reason"`
PaymentMethodID uint `gorm:"column:payment_method_id;not null;comment:线下收款方式字典ID" json:"payment_method_id"`
PaymentMethodCode string `gorm:"column:payment_method_code;type:varchar(64);not null;comment:收款方式编码快照" json:"payment_method_code"`
PaymentMethodName string `gorm:"column:payment_method_name;type:varchar(100);not null;comment:收款方式名称快照" json:"payment_method_name"`
PaidAmount int64 `gorm:"column:paid_amount;type:bigint;not null;comment:人工确认付款金额(分)" json:"paid_amount"`
PayerName string `gorm:"column:payer_name;type:varchar(100);not null;default:'';comment:付款方名称" json:"payer_name"`
PaidAt time.Time `gorm:"column:paid_at;type:timestamptz;not null;comment:付款时间" json:"paid_at"`
ExternalTransactionNo string `gorm:"column:external_transaction_no;type:varchar(128);not null;default:'';comment:外部交易流水号" json:"external_transaction_no"`
PaymentVoucherKeys StringJSONBArray `gorm:"column:payment_voucher_keys;type:jsonb;not null;comment:支付凭证对象存储Key列表" json:"payment_voucher_keys"`
Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:申请备注" json:"remark"`
Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-审批中 1-已通过 2-已驳回 3-已撤销或已关闭" json:"status"`
LatestAttemptID uint `gorm:"column:latest_attempt_id;not null;default:0;comment:最新审批尝试记录ID" json:"latest_attempt_id"`
LatestApprovalInstanceID uint `gorm:"column:latest_approval_instance_id;not null;default:0;comment:最新通用审批实例ID" json:"latest_approval_instance_id"`
DecidedAt *time.Time `gorm:"column:decided_at;type:timestamptz;comment:审批终态到达时间" json:"decided_at,omitempty"`
TerminalReason string `gorm:"column:terminal_reason;type:varchar(500);not null;default:'';comment:异常终态说明" json:"terminal_reason"`
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"`
Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 指定核销申请表名。
func (EmployeeCollectionApplication) TableName() string {
return "tb_employee_collection_application"
}
// EmployeeCollectionApplicationAttempt 是同一申请每次提交或重提对应的不可变审批材料。
// 其主键同时作为通用审批实例的业务ID使每次提交各自持有独立审批实例。
type EmployeeCollectionApplicationAttempt struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ApplicationID uint `gorm:"column:application_id;not null;comment:所属核销申请ID" json:"application_id"`
AttemptNo int `gorm:"column:attempt_no;type:int;not null;comment:第几次提交,从 1 递增" json:"attempt_no"`
PaymentMethodID uint `gorm:"column:payment_method_id;not null;comment:本次冻结的收款方式字典ID" json:"payment_method_id"`
PaymentMethodCode string `gorm:"column:payment_method_code;type:varchar(64);not null;comment:本次冻结的收款方式编码" json:"payment_method_code"`
PaymentMethodName string `gorm:"column:payment_method_name;type:varchar(100);not null;comment:本次冻结的收款方式名称" json:"payment_method_name"`
PaidAmount int64 `gorm:"column:paid_amount;type:bigint;not null;comment:本次冻结的付款金额(分)" json:"paid_amount"`
PayerName string `gorm:"column:payer_name;type:varchar(100);not null;default:'';comment:本次冻结的付款方名称" json:"payer_name"`
PaidAt time.Time `gorm:"column:paid_at;type:timestamptz;not null;comment:本次冻结的付款时间" json:"paid_at"`
ExternalTransactionNo string `gorm:"column:external_transaction_no;type:varchar(128);not null;default:'';comment:本次冻结的外部交易流水号" json:"external_transaction_no"`
PaymentVoucherKeys StringJSONBArray `gorm:"column:payment_voucher_keys;type:jsonb;not null;comment:本次冻结的支付凭证对象键列表" json:"payment_voucher_keys"`
Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:本次冻结的备注" json:"remark"`
SubmittedByAccountID uint `gorm:"column:submitted_by_account_id;not null;comment:本次实际提交账号ID" json:"submitted_by_account_id"`
ActingReason string `gorm:"column:acting_reason;type:varchar(500);not null;default:'';comment:本次代办原因" json:"acting_reason"`
AllocationSnapshot datatypes.JSON `gorm:"column:allocation_snapshot;type:jsonb;not null;comment:本次冻结的账单分摊快照" json:"allocation_snapshot"`
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:本次尝试关联的通用审批实例ID" json:"approval_instance_id,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
}
// TableName 指定审批尝试记录表名。
func (EmployeeCollectionApplicationAttempt) TableName() string {
return "tb_employee_collection_application_attempt"
}
// EmployeeCollectionApplicationAllocation 是申请对单张账单的本次分摊。
// 审批中预占账单余额,终态释放预占或转入已核销金额。
type EmployeeCollectionApplicationAllocation struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ApplicationID uint `gorm:"column:application_id;not null;comment:所属核销申请ID" json:"application_id"`
AttemptID uint `gorm:"column:attempt_id;not null;comment:所属审批尝试记录ID" json:"attempt_id"`
BillID uint `gorm:"column:bill_id;not null;comment:目标账单ID" json:"bill_id"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:本次分摊金额(分)" json:"amount"`
Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-审批中预占 1-已通过 2-已驳回或已释放" json:"status"`
ReleasedAt *time.Time `gorm:"column:released_at;type:timestamptz;comment:预占释放时间" json:"released_at,omitempty"`
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"`
Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 指定申请分摊表名。
func (EmployeeCollectionApplicationAllocation) TableName() string {
return "tb_employee_collection_application_allocation"
}
// EmployeeCollectionBillRefund 是来源订单退款与账单之间的唯一冲销关联事实。
type EmployeeCollectionBillRefund struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
BillID uint `gorm:"column:bill_id;not null;comment:目标账单ID" json:"bill_id"`
RefundID uint `gorm:"column:refund_id;not null;comment:退款申请ID" json:"refund_id"`
SourceOrderID uint `gorm:"column:source_order_id;not null;comment:账单来源订单ID" json:"source_order_id"`
RefundAmount int64 `gorm:"column:refund_amount;type:bigint;not null;comment:本次退款成功金额(分)" json:"refund_amount"`
BillReceivableAmount int64 `gorm:"column:bill_receivable_amount;type:bigint;not null;comment:冲销前账单应收金额快照(分)" json:"bill_receivable_amount"`
Outcome string `gorm:"column:outcome;type:varchar(20);not null;comment:处理结果 closed_full/reduced/hint_only" json:"outcome"`
ReducedAmount int64 `gorm:"column:reduced_amount;type:bigint;not null;default:0;comment:实际冲减应收金额(分)" json:"reduced_amount"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
}
// TableName 指定退款冲销关联表名。
func (EmployeeCollectionBillRefund) TableName() string {
return "tb_employee_collection_bill_refund"
}

View File

@@ -29,13 +29,6 @@ type CommissionWithdrawalRequest struct {
PaidAt *time.Time `gorm:"column:paid_at;comment:到账时间" json:"paid_at"`
RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason"`
Remark string `gorm:"column:remark;type:text;comment:备注" json:"remark"`
// LatestAttemptID 与 LatestApprovalInstanceID 仅用于列表投影,不是历史事实来源。
LatestAttemptID uint `gorm:"column:latest_attempt_id;type:bigint;not null;default:0;comment:最新审批尝试记录ID" json:"latest_attempt_id"`
LatestApprovalInstanceID uint `gorm:"column:latest_approval_instance_id;type:bigint;not null;default:0;comment:最新通用审批实例ID" json:"latest_approval_instance_id"`
// ApprovalInstanceID 为空表示从未关联审批实例的存量申请,仍走既有本地人工终审。
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;type:bigint;comment:首次接入企业微信审批的审批实例ID" json:"approval_instance_id,omitempty"`
AnomalyFlag int `gorm:"column:anomaly_flag;type:smallint;not null;default:0;comment:正交异常标记 0-无异常 1-通过后撤销" json:"anomaly_flag"`
AnomalyReason string `gorm:"column:anomaly_reason;type:varchar(500);not null;default:'';comment:异常原因" json:"anomaly_reason"`
}
// TableName 指定表名
@@ -43,30 +36,6 @@ func (CommissionWithdrawalRequest) TableName() string {
return "tb_commission_withdrawal_request"
}
// CommissionWithdrawalRequestAttempt 是提现申请每次提交或重提对应的不可变审批材料与冻结事实。
// 冻结与释放金额一律取本记录事实released_at 是回溯可重放的幂等依据。
type CommissionWithdrawalRequestAttempt struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
RequestID uint `gorm:"column:request_id;type:bigint;not null;comment:所属提现申请ID" json:"request_id"`
AttemptNo int `gorm:"column:attempt_no;type:int;not null;comment:第几次提交,从 1 递增" json:"attempt_no"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:本次冻结提现金额(分)" json:"amount"`
Fee int64 `gorm:"column:fee;type:bigint;not null;default:0;comment:本次手续费(分)" json:"fee"`
FeeRate int64 `gorm:"column:fee_rate;type:bigint;not null;default:0;comment:本次手续费率(基点)" json:"fee_rate"`
ActualAmount int64 `gorm:"column:actual_amount;type:bigint;not null;default:0;comment:本次实际到账金额(分)" json:"actual_amount"`
WithdrawalMethod string `gorm:"column:withdrawal_method;type:varchar(20);not null;comment:本次收款方式快照" json:"withdrawal_method"`
AccountInfo datatypes.JSON `gorm:"column:account_info;type:jsonb;not null;comment:本次收款账户信息快照" json:"account_info"`
InvoiceKeys datatypes.JSON `gorm:"column:invoice_keys;type:jsonb;not null;default:'[]';comment:本次申请级发票对象存储Key列表" json:"invoice_keys"`
SubmittedByAccountID uint `gorm:"column:submitted_by_account_id;type:bigint;not null;comment:本次实际提交账号ID" json:"submitted_by_account_id"`
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;type:bigint;comment:本次尝试关联的通用审批实例ID" json:"approval_instance_id,omitempty"`
ReleasedAt *time.Time `gorm:"column:released_at;comment:本次冻结释放时间,空表示未结算" json:"released_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
}
// TableName 指定提现审批尝试记录表名。
func (CommissionWithdrawalRequestAttempt) TableName() string {
return "tb_commission_withdrawal_request_attempt"
}
// CommissionWithdrawalSetting 佣金提现设置模型
// 提现参数配置(最低金额、手续费率、到账时间等)
type CommissionWithdrawalSetting struct {

View File

@@ -75,8 +75,7 @@ type Order struct {
// 支付配置
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
// 线下支付凭证对象存储 file_key 列表最多5个线下支付订单必填
// 会生成员工代收款账单的线下订单除外,该场景凭证由核销申请环节提供)
// 线下支付凭证对象存储 file_key 列表最多5个线下支付订单必填
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:线下支付凭证对象存储file_key列表jsonb存储[]string" json:"payment_voucher_key"`
}

View File

@@ -14,14 +14,6 @@ type Payment struct {
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"`

View File

@@ -1,94 +0,0 @@
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

@@ -10,8 +10,6 @@ type Shop struct {
BaseModel `gorm:"embedded"`
ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:店铺名称" json:"shop_name"`
ShopCode string `gorm:"column:shop_code;type:varchar(50);uniqueIndex:idx_shop_code,where:deleted_at IS NULL;comment:店铺编号" json:"shop_code"`
// DistributionCode 是创建时生成的全局唯一随机分销码,创建后不可修改;不提供人工指定或编辑入口。
DistributionCode string `gorm:"column:distribution_code;type:varchar(32);not null;default:'';comment:全局唯一随机分销码,创建时生成且不可修改" json:"distribution_code"`
ParentID *uint `gorm:"column:parent_id;index;comment:上级店铺IDNULL表示一级代理" json:"parent_id,omitempty"`
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id;index:idx_shop_business_owner_account_id;comment:平台业务员账号ID" json:"business_owner_account_id,omitempty"`
Level int `gorm:"column:level;type:int;not null;default:1;comment:层级1-7" json:"level"`

View File

@@ -1,36 +0,0 @@
package model
import "time"
// WithdrawalQualification 是代理提现资料资格的不可变版本。
// 历史版本、附件引用与审批结果不被覆盖:替换合同或法人身份证即新增版本并使旧有效版本失效。
type WithdrawalQualification struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
ShopID uint `gorm:"column:shop_id;not null;comment:所属代理店铺ID" json:"shop_id"`
SubjectType string `gorm:"column:subject_type;type:varchar(20);not null;comment:签约主体类型 enterprise/personal" json:"subject_type"`
SubjectCode string `gorm:"column:subject_code;type:varchar(64);not null;comment:签约主体代码" json:"subject_code"`
LegalPersonIDCard string `gorm:"column:legal_person_id_card;type:varchar(64);not null;comment:法人身份证号" json:"legal_person_id_card"`
ContractFileKey string `gorm:"column:contract_file_key;type:varchar(255);not null;comment:合同附件对象存储Key" json:"contract_file_key"`
IDCardFrontFileKey string `gorm:"column:id_card_front_file_key;type:varchar(255);not null;comment:法人身份证正面附件对象存储Key" json:"id_card_front_file_key"`
IDCardBackFileKey string `gorm:"column:id_card_back_file_key;type:varchar(255);not null;comment:法人身份证反面附件对象存储Key" json:"id_card_back_file_key"`
BusinessLicenseFileKey string `gorm:"column:business_license_file_key;type:varchar(255);not null;default:'';comment:营业执照附件对象存储Key" json:"business_license_file_key"`
ShopFrontFileKey string `gorm:"column:shop_front_file_key;type:varchar(255);not null;default:'';comment:门头照附件对象存储Key" json:"shop_front_file_key"`
InvoiceFileKey string `gorm:"column:invoice_file_key;type:varchar(255);not null;default:'';comment:发票附件对象存储Key" json:"invoice_file_key"`
InvoiceTitle string `gorm:"column:invoice_title;type:varchar(200);not null;default:'';comment:发票抬头" json:"invoice_title"`
InvoiceSubjectCode string `gorm:"column:invoice_subject_code;type:varchar(64);not null;default:'';comment:发票统一社会信用代码" json:"invoice_subject_code"`
Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-待审批 1-已通过有效 2-已驳回 3-已失效或已作废" json:"status"`
InvalidReason string `gorm:"column:invalid_reason;type:varchar(500);not null;default:'';comment:失效原因" json:"invalid_reason"`
InvalidatedAt *time.Time `gorm:"column:invalidated_at;type:timestamptz;comment:失效时间" json:"invalidated_at,omitempty"`
InvalidatedBy uint `gorm:"column:invalidated_by;not null;default:0;comment:使资格失效的操作账号ID0 表示店铺停用联动" json:"invalidated_by"`
ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:关联的通用审批实例ID" json:"approval_instance_id,omitempty"`
DecidedAt *time.Time `gorm:"column:decided_at;type:timestamptz;comment:审批终态到达时间" json:"decided_at,omitempty"`
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"`
Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 指定提现资料资格版本表名。
func (WithdrawalQualification) TableName() string {
return "tb_withdrawal_qualification"
}

View File

@@ -1,304 +0,0 @@
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

@@ -1,279 +0,0 @@
// Package distributionwithdrawal 提供分销注册、提现资格与提现审批的只读投影查询。
// 查询不得修改任何状态;可见性由调用方在业务边界先行校验。
package distributionwithdrawal
import (
"context"
"encoding/json"
"gorm.io/gorm"
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
"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"
)
// Query 提供提现资料资格与提现申请详情的只读投影。
type Query struct {
db *gorm.DB
}
// NewQuery 创建分销与提现审批只读查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db}
}
// ListQualifications 按数据范围分页查询提现资料资格版本,证件号按脱敏值返回。
func (q *Query) ListQualifications(
ctx context.Context,
shopIDs []uint,
req *dto.WithdrawalQualificationListReq,
) (*dto.WithdrawalQualificationPageResult, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeInternalError, "提现资料资格查询能力未配置")
}
page := req.Page
if page <= 0 {
page = constants.DefaultPage
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
query := q.db.WithContext(ctx).Model(&model.WithdrawalQualification{})
if len(shopIDs) == 1 {
query = query.Where("shop_id = ?", shopIDs[0])
} else if len(shopIDs) > 1 {
query = query.Where("shop_id IN ?", shopIDs)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计提现资料资格失败")
}
var versions []model.WithdrawalQualification
if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).
Find(&versions).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现资料资格失败")
}
shopNames, err := q.shopNames(ctx, versions)
if err != nil {
return nil, err
}
instanceStatuses, err := q.approvalStatuses(ctx, constants.ApprovalBusinessTypeWithdrawalQualification, instanceIDsOfQualifications(versions))
if err != nil {
return nil, err
}
items := make([]dto.WithdrawalQualificationItem, 0, len(versions))
for index := range versions {
version := &versions[index]
instanceID := uint(0)
if version.ApprovalInstanceID != nil {
instanceID = *version.ApprovalInstanceID
}
item := dto.WithdrawalQualificationItem{
ID: version.ID, ShopID: version.ShopID, ShopName: shopNames[version.ShopID],
SubjectType: version.SubjectType,
SubjectTypeName: constants.GetWithdrawalQualificationSubjectTypeName(version.SubjectType),
SubjectCodeMasked: distributiondomain.MaskSubjectCode(version.SubjectCode),
LegalPersonIDCardMask: distributiondomain.MaskSubjectCode(version.LegalPersonIDCard),
ContractFileKey: version.ContractFileKey,
IDCardFrontFileKey: version.IDCardFrontFileKey,
IDCardBackFileKey: version.IDCardBackFileKey,
BusinessLicenseFileKey: version.BusinessLicenseFileKey,
ShopFrontFileKey: version.ShopFrontFileKey,
InvoiceFileKey: version.InvoiceFileKey, InvoiceTitle: version.InvoiceTitle,
Status: version.Status, StatusName: constants.GetWithdrawalQualificationStatusName(version.Status),
InvalidReason: version.InvalidReason, ApprovalInstanceID: instanceID,
CreatedAt: version.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: version.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if version.InvalidatedAt != nil {
item.InvalidatedAt = version.InvalidatedAt.Format("2006-01-02 15:04:05")
}
if status, ok := instanceStatuses[instanceID]; ok {
item.ApprovalStatus = status
item.ApprovalStatusName = constants.GetApprovalStatusName(status)
} else {
item.ApprovalStatusName = constants.GetApprovalStatusName(-1)
}
items = append(items, item)
}
return &dto.WithdrawalQualificationPageResult{
Items: items, Total: total, Page: page, Size: pageSize,
}, nil
}
// WithdrawalDetail 查询单笔提现申请详情与其全部审批尝试记录。
func (q *Query) WithdrawalDetail(
ctx context.Context,
requestID uint,
) (*dto.ShopWithdrawalRequestDetailResp, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeInternalError, "提现详情查询能力未配置")
}
var request model.CommissionWithdrawalRequest
if err := q.db.WithContext(ctx).First(&request, requestID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "提现申请不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现申请失败")
}
var attempts []model.CommissionWithdrawalRequestAttempt
if err := q.db.WithContext(ctx).
Where("request_id = ?", request.ID).Order("attempt_no DESC").Find(&attempts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试记录失败")
}
instanceStatuses, err := q.approvalStatuses(ctx, constants.ApprovalBusinessTypeCommissionWithdrawal, instanceIDsOfAttempts(attempts))
if err != nil {
return nil, err
}
shopNames, err := q.shopNames(ctx, []model.WithdrawalQualification{{ShopID: request.ShopID}})
if err != nil {
return nil, err
}
accountName, accountNumber := decodeAttemptAccount(request.AccountInfo)
processedAt := ""
if request.ProcessedAt != nil {
processedAt = request.ProcessedAt.Format("2006-01-02 15:04:05")
}
paidAt := ""
if request.PaidAt != nil {
paidAt = request.PaidAt.Format("2006-01-02 15:04:05")
}
detail := &dto.ShopWithdrawalRequestDetailResp{
ShopWithdrawalRequestItem: dto.ShopWithdrawalRequestItem{
ID: request.ID, WithdrawalNo: request.WithdrawalNo, Amount: request.Amount,
FeeRate: request.FeeRate, Fee: request.Fee, ActualAmount: request.ActualAmount,
Status: request.Status, StatusName: constants.GetWithdrawalStatusName(request.Status),
ShopID: request.ShopID, ShopName: shopNames[request.ShopID],
ApplicantID: request.ApplicantID, WithdrawalMethod: request.WithdrawalMethod,
PaymentType: request.PaymentType, AccountName: accountName, AccountNumber: accountNumber,
RejectReason: request.RejectReason, Remark: request.Remark,
CreatedAt: request.CreatedAt.Format("2006-01-02 15:04:05"),
ProcessedAt: processedAt, PaidAt: paidAt,
},
LatestAttemptID: request.LatestAttemptID,
LatestApprovalInstanceID: request.LatestApprovalInstanceID,
AnomalyFlag: request.AnomalyFlag,
AnomalyName: constants.GetWithdrawalAnomalyName(request.AnomalyFlag),
AnomalyReason: request.AnomalyReason,
Attempts: make([]dto.WithdrawalRequestAttemptItem, 0, len(attempts)),
}
if request.ApprovalInstanceID != nil {
detail.ApprovalInstanceID = *request.ApprovalInstanceID
}
for index := range attempts {
attempt := &attempts[index]
instanceID := uint(0)
if attempt.ApprovalInstanceID != nil {
instanceID = *attempt.ApprovalInstanceID
}
item := dto.WithdrawalRequestAttemptItem{
ID: attempt.ID, AttemptNo: attempt.AttemptNo, Amount: attempt.Amount,
Fee: attempt.Fee, FeeRate: attempt.FeeRate, ActualAmount: attempt.ActualAmount,
WithdrawalMethod: attempt.WithdrawalMethod, SubmittedByID: attempt.SubmittedByAccountID,
ApprovalInstanceID: instanceID,
CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"),
}
if status, ok := instanceStatuses[instanceID]; ok {
item.ApprovalStatus = status
item.ApprovalStatusName = constants.GetApprovalStatusName(status)
} else {
item.ApprovalStatusName = constants.GetApprovalStatusName(-1)
}
if attempt.ReleasedAt != nil {
item.ReleasedAt = attempt.ReleasedAt.Format("2006-01-02 15:04:05")
}
detail.Attempts = append(detail.Attempts, item)
}
return detail, nil
}
// shopNames 批量读取店铺名称,缺失店铺留空。
func (q *Query) shopNames(ctx context.Context, versions []model.WithdrawalQualification) (map[uint]string, error) {
shopIDs := make([]uint, 0, len(versions))
seen := make(map[uint]struct{}, len(versions))
for _, version := range versions {
if version.ShopID == 0 {
continue
}
if _, exists := seen[version.ShopID]; exists {
continue
}
seen[version.ShopID] = struct{}{}
shopIDs = append(shopIDs, version.ShopID)
}
names := make(map[uint]string, len(shopIDs))
if len(shopIDs) == 0 {
return names, nil
}
var shops []model.Shop
if err := q.db.WithContext(ctx).Select("id", "shop_name").Where("id IN ?", shopIDs).Find(&shops).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺名称失败")
}
for _, shop := range shops {
names[shop.ID] = shop.ShopName
}
return names, nil
}
// approvalStatuses 批量读取通用审批实例状态,缺失实例不进入结果。
func (q *Query) approvalStatuses(
ctx context.Context,
businessType string,
instanceIDs []uint,
) (map[uint]int, error) {
statuses := make(map[uint]int, len(instanceIDs))
if len(instanceIDs) == 0 {
return statuses, nil
}
var instances []model.ApprovalInstance
if err := q.db.WithContext(ctx).Select("id", "status").
Where("business_type = ? AND id IN ?", businessType, instanceIDs).
Find(&instances).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批实例状态失败")
}
for _, instance := range instances {
statuses[instance.ID] = instance.Status
}
return statuses, nil
}
// instanceIDsOfQualifications 提取资料版本关联的审批实例 ID。
func instanceIDsOfQualifications(versions []model.WithdrawalQualification) []uint {
ids := make([]uint, 0, len(versions))
for index := range versions {
if versions[index].ApprovalInstanceID != nil {
ids = append(ids, *versions[index].ApprovalInstanceID)
}
}
return ids
}
// instanceIDsOfAttempts 提取提现审批尝试关联的审批实例 ID。
func instanceIDsOfAttempts(attempts []model.CommissionWithdrawalRequestAttempt) []uint {
ids := make([]uint, 0, len(attempts))
for index := range attempts {
if attempts[index].ApprovalInstanceID != nil {
ids = append(ids, *attempts[index].ApprovalInstanceID)
}
}
return ids
}
// decodeAttemptAccount 解析收款账户快照,解析失败时留空。
func decodeAttemptAccount(payload []byte) (string, string) {
var info map[string]string
if err := json.Unmarshal(payload, &info); err != nil {
return "", ""
}
return info["account_name"], info["account_number"]
}

View File

@@ -1,308 +0,0 @@
package employeecollection
import (
"context"
"gorm.io/gorm"
"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"
)
// ApplicationQuery 查询员工代收款核销申请列表与详情。
// Query 只做权限范围、筛选、分页与 DTO 投影,不修改任何状态。
type ApplicationQuery struct {
db *gorm.DB
}
// NewApplicationQuery 创建核销申请查询。
func NewApplicationQuery(db *gorm.DB) *ApplicationQuery {
return &ApplicationQuery{db: db}
}
// List 分页查询当前可见范围内的核销申请。
func (q *ApplicationQuery) List(
ctx context.Context,
request dto.EmployeeCollectionApplicationListRequest,
) (*dto.EmployeeCollectionApplicationListResponse, error) {
query, err := q.applyApplicationScope(ctx, request)
if err != nil {
return nil, err
}
page, pageSize := normalizePage(request.Page, request.PageSize)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请总数失败")
}
var applications []model.EmployeeCollectionApplication
if err := query.Order("created_at DESC, id DESC").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&applications).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请列表失败")
}
list := make([]*dto.EmployeeCollectionApplicationResponse, 0, len(applications))
for index := range applications {
list = append(list, ProjectApplication(&applications[index]))
}
return &dto.EmployeeCollectionApplicationListResponse{
List: list, Total: total, Page: page, PageSize: pageSize,
}, nil
}
// Detail 返回当前可见范围内的核销申请详情,包含全部分摊与审批尝试历史。
// 不可见与不存在返回同一错误,不产生可枚举差异。
func (q *ApplicationQuery) Detail(ctx context.Context, id uint) (*dto.EmployeeCollectionApplicationDetailResponse, error) {
if q == nil || q.db == nil || id == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
scoped, err := applyApplicationVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}))
if err != nil {
return nil, err
}
var application model.EmployeeCollectionApplication
if err := scoped.Where("id = ?", id).First(&application).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionApplicationNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请详情失败")
}
allocations, err := q.applicationAllocations(ctx, application.ID)
if err != nil {
return nil, err
}
attempts, err := q.applicationAttemptList(ctx, application.ID)
if err != nil {
return nil, err
}
return &dto.EmployeeCollectionApplicationDetailResponse{
Application: ProjectApplication(&application), Allocations: allocations, Attempts: attempts,
}, nil
}
// applyApplicationScope 生成同时应用可见性与筛选条件的核销申请查询。
func (q *ApplicationQuery) applyApplicationScope(
ctx context.Context,
request dto.EmployeeCollectionApplicationListRequest,
) (*gorm.DB, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "核销申请查询尚未配置")
}
query, err := applyApplicationVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionApplication{}))
if err != nil {
return nil, err
}
if request.ApplicantAccountID != nil && *request.ApplicantAccountID > 0 {
query = query.Where("applicant_account_id = ?", *request.ApplicantAccountID)
}
if request.PaymentMethodID != nil && *request.PaymentMethodID > 0 {
query = query.Where("payment_method_id = ?", *request.PaymentMethodID)
}
if request.Status != nil {
switch *request.Status {
case constants.EmployeeCollectionApplicationStatusPending,
constants.EmployeeCollectionApplicationStatusApproved,
constants.EmployeeCollectionApplicationStatusRejected,
constants.EmployeeCollectionApplicationStatusRevoked:
query = query.Where("status = ?", *request.Status)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的核销申请状态")
}
}
if request.CreatedFrom != nil {
from, err := parseBillDate(*request.CreatedFrom)
if err != nil {
return nil, err
}
if from != nil {
query = query.Where("created_at >= ?", *from)
}
}
if request.CreatedTo != nil {
to, err := parseBillDate(*request.CreatedTo)
if err != nil {
return nil, err
}
if to != nil {
query = query.Where("created_at < ?", to.AddDate(0, 0, 1))
}
}
return query, nil
}
// applyApplicationVisibility 应用核销申请可见性:超级管理员见全部,其他账号仅见本人申请。
func applyApplicationVisibility(ctx context.Context, query *gorm.DB) (*gorm.DB, error) {
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin {
return query, nil
}
accountID := middleware.GetUserIDFromContext(ctx)
if accountID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
return query.Where("applicant_account_id = ?", accountID), nil
}
// applicationAllocations 读取申请的全部分摊并附带目标账单只读摘要。
func (q *ApplicationQuery) applicationAllocations(
ctx context.Context,
applicationID uint,
) ([]*dto.EmployeeCollectionApplicationAllocationResponse, error) {
var allocations []model.EmployeeCollectionApplicationAllocation
if err := q.db.WithContext(ctx).Where("application_id = ?", applicationID).
Order("bill_id ASC, id ASC").Find(&allocations).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销申请分摊失败")
}
billIDs := make([]uint, 0, len(allocations))
for index := range allocations {
billIDs = append(billIDs, allocations[index].BillID)
}
bills := make(map[uint]model.EmployeeCollectionBill, len(billIDs))
if len(billIDs) > 0 {
var loaded []model.EmployeeCollectionBill
if err := q.db.WithContext(ctx).Where("id IN ?", billIDs).Find(&loaded).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销分摊目标账单失败")
}
for index := range loaded {
bills[loaded[index].ID] = loaded[index]
}
}
result := make([]*dto.EmployeeCollectionApplicationAllocationResponse, 0, len(allocations))
for index := range allocations {
allocation := allocations[index]
bill := bills[allocation.BillID]
result = append(result, &dto.EmployeeCollectionApplicationAllocationResponse{
ID: allocation.ID, BillID: allocation.BillID, Amount: allocation.Amount,
Status: allocation.Status, StatusName: constants.GetEmployeeCollectionAllocationStatusName(allocation.Status),
ReleasedAt: allocation.ReleasedAt,
BillSourceType: bill.SourceType, BillSourceNo: bill.SourceNo,
BillReceivableAmount: bill.ReceivableAmount, BillReceivedAmount: bill.ReceivedAmount,
BillReservedAmount: bill.ReservedAmount,
BillStatus: bill.Status, BillStatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status),
CreatedAt: allocation.CreatedAt,
})
}
return result, nil
}
// applicationAttemptList 读取申请的全部审批尝试记录与审批实例状态。
func (q *ApplicationQuery) applicationAttemptList(
ctx context.Context,
applicationID uint,
) ([]*dto.EmployeeCollectionBillAttemptResponse, error) {
var attempts []model.EmployeeCollectionApplicationAttempt
if err := q.db.WithContext(ctx).Where("application_id = ?", applicationID).
Order("attempt_no ASC, id ASC").Find(&attempts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询核销审批尝试记录失败")
}
approvalStatus, approvalOpinions, err := approvalStatusOfAttempts(ctx, q.db, attempts)
if err != nil {
return nil, err
}
return attemptResponses(applicationID, attempts, approvalStatus, approvalOpinions)
}
// ProjectApplication 将核销申请事实投影为对外响应,附件只返回对象键引用。
func ProjectApplication(application *model.EmployeeCollectionApplication) *dto.EmployeeCollectionApplicationResponse {
if application == nil {
return nil
}
vouchers := []string(application.PaymentVoucherKeys)
if vouchers == nil {
vouchers = []string{}
}
return &dto.EmployeeCollectionApplicationResponse{
ID: application.ID, ApplicantAccountID: application.ApplicantAccountID,
ActingOperatorID: application.ActingOperatorID, ActingReason: application.ActingReason,
PaymentMethodID: application.PaymentMethodID, PaymentMethodCode: application.PaymentMethodCode,
PaymentMethodName: application.PaymentMethodName, PaidAmount: application.PaidAmount,
PayerName: application.PayerName, PaidAt: application.PaidAt,
ExternalTransactionNo: application.ExternalTransactionNo, PaymentVoucherKeys: vouchers,
Remark: application.Remark, Status: application.Status,
StatusName: constants.GetEmployeeCollectionApplicationStatusName(application.Status),
LatestAttemptID: application.LatestAttemptID,
LatestApprovalInstanceID: application.LatestApprovalInstanceID,
DecidedAt: application.DecidedAt, TerminalReason: application.TerminalReason,
CreatedAt: application.CreatedAt, UpdatedAt: application.UpdatedAt,
}
}
// ApplicationSubmitProjection 是创建或重提核销申请返回的投影输入。
type ApplicationSubmitProjection struct {
Application *model.EmployeeCollectionApplication
Attempt *model.EmployeeCollectionApplicationAttempt
Allocations []*model.EmployeeCollectionApplicationAllocation
Bills []*model.EmployeeCollectionBill
InstanceID uint
InstanceStatus int
}
// ProjectApplicationSubmit 组装创建或重提核销申请的响应。
// 附件只返回对象键引用,审批状态取本次创建的通用审批实例状态,不返回对象存储内容。
func ProjectApplicationSubmit(projection ApplicationSubmitProjection) (*dto.EmployeeCollectionApplicationSubmitResponse, error) {
allocations, err := projectSubmitAllocations(projection.Allocations, projection.Bills)
if err != nil {
return nil, err
}
var attempts []*dto.EmployeeCollectionBillAttemptResponse
if projection.Attempt != nil {
approvalStatus := map[uint]int{}
if projection.InstanceID > 0 {
approvalStatus[projection.InstanceID] = projection.InstanceStatus
}
attempts, err = attemptResponses(
projection.Attempt.ApplicationID,
[]model.EmployeeCollectionApplicationAttempt{*projection.Attempt},
approvalStatus, map[uint]string{},
)
if err != nil {
return nil, err
}
}
response := &dto.EmployeeCollectionApplicationSubmitResponse{
Application: ProjectApplication(projection.Application),
Allocations: allocations,
ApprovalInstanceID: projection.InstanceID,
ApprovalStatus: projection.InstanceStatus,
ApprovalStatusName: constants.GetApprovalStatusName(projection.InstanceStatus),
}
if len(attempts) > 0 {
response.Attempt = attempts[0]
}
return response, nil
}
// projectSubmitAllocations 将分摊与目标账单摘要投影为响应。
func projectSubmitAllocations(
allocations []*model.EmployeeCollectionApplicationAllocation,
bills []*model.EmployeeCollectionBill,
) ([]*dto.EmployeeCollectionApplicationAllocationResponse, error) {
billByID := make(map[uint]*model.EmployeeCollectionBill, len(bills))
for _, bill := range bills {
if bill != nil {
billByID[bill.ID] = bill
}
}
result := make([]*dto.EmployeeCollectionApplicationAllocationResponse, 0, len(allocations))
for _, allocation := range allocations {
if allocation == nil {
continue
}
bill := billByID[allocation.BillID]
if bill == nil {
return nil, errors.New(errors.CodeInternalError, "核销分摊缺少目标账单事实")
}
result = append(result, &dto.EmployeeCollectionApplicationAllocationResponse{
ID: allocation.ID, BillID: allocation.BillID, Amount: allocation.Amount,
Status: allocation.Status, StatusName: constants.GetEmployeeCollectionAllocationStatusName(allocation.Status),
ReleasedAt: allocation.ReleasedAt,
BillSourceType: bill.SourceType, BillSourceNo: bill.SourceNo,
BillReceivableAmount: bill.ReceivableAmount, BillReceivedAmount: bill.ReceivedAmount,
BillReservedAmount: bill.ReservedAmount,
BillStatus: bill.Status, BillStatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status),
CreatedAt: allocation.CreatedAt,
})
}
return result, nil
}

View File

@@ -1,497 +0,0 @@
package employeecollection
import (
"context"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
"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"
)
// billDateLayout 是账单筛选使用的自然日格式。
const billDateLayout = "2006-01-02"
// BillQuery 查询员工代收款账单列表、统计与详情。
// 列表与统计共用 applyBillScope 生成的同一个 *gorm.DB禁止两处各写一套筛选。
type BillQuery struct {
db *gorm.DB
}
// NewBillQuery 创建员工代收款账单查询。
func NewBillQuery(db *gorm.DB) *BillQuery {
return &BillQuery{db: db}
}
// List 分页查询当前可见范围内的员工代收款账单。
func (q *BillQuery) List(ctx context.Context, request dto.EmployeeCollectionBillListRequest) (*dto.EmployeeCollectionBillListResponse, error) {
query, err := q.applyBillScope(ctx, billFilterOfList(request))
if err != nil {
return nil, err
}
page, pageSize := normalizePage(request.Page, request.PageSize)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单总数失败")
}
var bills []model.EmployeeCollectionBill
if err := query.Order("created_at DESC, id DESC").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&bills).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单列表失败")
}
list := make([]*dto.EmployeeCollectionBillResponse, 0, len(bills))
for index := range bills {
item, err := ProjectBill(&bills[index])
if err != nil {
return nil, err
}
list = append(list, item)
}
return &dto.EmployeeCollectionBillListResponse{
List: list, Total: total, Page: page, PageSize: pageSize,
}, nil
}
// Statistics 汇总当前可见范围内的应收、已核销、未核销金额与待处理账单数。
// 使用与列表完全相同的可见性与筛选条件。
func (q *BillQuery) Statistics(
ctx context.Context,
request dto.EmployeeCollectionBillStatisticsRequest,
) (*dto.EmployeeCollectionBillStatisticsResponse, error) {
query, err := q.applyBillScope(ctx, billFilterOfStatistics(request))
if err != nil {
return nil, err
}
var row struct {
ReceivableTotal int64
ReceivedTotal int64
UnsettledTotal int64
PendingBillCount int64
}
if err := query.Select(
`COALESCE(SUM(receivable_amount), 0) AS receivable_total,
COALESCE(SUM(received_amount), 0) AS received_total,
COALESCE(SUM(CASE WHEN status = ? THEN 0 ELSE receivable_amount - received_amount END), 0) AS unsettled_total,
COALESCE(SUM(CASE WHEN status IN (?, ?) THEN 1 ELSE 0 END), 0) AS pending_bill_count`,
constants.EmployeeCollectionBillStatusClosed,
constants.EmployeeCollectionBillStatusPending,
constants.EmployeeCollectionBillStatusPartial,
).Scan(&row).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单统计失败")
}
return &dto.EmployeeCollectionBillStatisticsResponse{
ReceivableTotal: row.ReceivableTotal, ReceivedTotal: row.ReceivedTotal,
UnsettledTotal: row.UnsettledTotal, PendingBillCount: row.PendingBillCount,
}, nil
}
// Detail 返回当前可见范围内的账单详情,包含退款冲销、分摊与申请审批历史。
// 不可见与不存在返回同一错误,不产生可枚举差异。
func (q *BillQuery) Detail(ctx context.Context, id uint) (*dto.EmployeeCollectionBillDetailResponse, error) {
if q == nil || q.db == nil || id == 0 {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
scoped, err := applyBillVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionBill{}))
if err != nil {
return nil, err
}
var bill model.EmployeeCollectionBill
if err := scoped.Where("id = ?", id).First(&bill).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询员工代收款账单详情失败")
}
billResponse, err := ProjectBill(&bill)
if err != nil {
return nil, err
}
refunds, err := q.billRefunds(ctx, bill.ID)
if err != nil {
return nil, err
}
allocations, applicationIDs, err := q.billAllocations(ctx, bill.ID)
if err != nil {
return nil, err
}
applications, err := q.billApplications(ctx, applicationIDs)
if err != nil {
return nil, err
}
return &dto.EmployeeCollectionBillDetailResponse{
Bill: billResponse, Refunds: refunds, Allocations: allocations, Applications: applications,
}, nil
}
// applyBillScope 生成同时应用可见性与筛选条件的账单查询。
// 列表、统计与关闭后的可见性校验都必须走这一处,避免筛选或数据范围分叉。
func (q *BillQuery) applyBillScope(ctx context.Context, filter billFilter) (*gorm.DB, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "员工代收款账单查询尚未配置")
}
query, err := applyBillVisibility(ctx, q.db.WithContext(ctx).Model(&model.EmployeeCollectionBill{}))
if err != nil {
return nil, err
}
return applyBillFilters(query, filter)
}
// billFilter 账单列表与统计共用的筛选条件,屏蔽两种请求 DTO 的字段差异。
type billFilter struct {
SourceType *string
SourceNo *string
Status *int
DebtorAccountID *uint
CustomerID *uint
CreatedFrom *string
CreatedTo *string
}
// billFilterOfList 投影账单列表请求的筛选字段。
func billFilterOfList(request dto.EmployeeCollectionBillListRequest) billFilter {
return billFilter{
SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
DebtorAccountID: request.DebtorAccountID, CustomerID: request.CustomerID,
CreatedFrom: request.CreatedFrom, CreatedTo: request.CreatedTo,
}
}
// billFilterOfStatistics 投影账单统计请求的筛选字段。
func billFilterOfStatistics(request dto.EmployeeCollectionBillStatisticsRequest) billFilter {
return billFilter{
SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
DebtorAccountID: request.DebtorAccountID, CustomerID: request.CustomerID,
CreatedFrom: request.CreatedFrom, CreatedTo: request.CreatedTo,
}
}
// applyBillVisibility 应用账单可见性:超级管理员见全部,其他账号仅见本人欠款账单。
func applyBillVisibility(ctx context.Context, query *gorm.DB) (*gorm.DB, error) {
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin {
return query, nil
}
accountID := middleware.GetUserIDFromContext(ctx)
if accountID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
return query.Where("debtor_account_id = ?", accountID), nil
}
// applyBillFilters 应用账单列表与统计共用的筛选条件。
func applyBillFilters(query *gorm.DB, request billFilter) (*gorm.DB, error) {
if request.DebtorAccountID != nil && *request.DebtorAccountID > 0 {
query = query.Where("debtor_account_id = ?", *request.DebtorAccountID)
}
if request.SourceType != nil {
sourceType := strings.TrimSpace(*request.SourceType)
switch sourceType {
case "":
case constants.EmployeeCollectionSourceTypeOrder, constants.EmployeeCollectionSourceTypeRecharge:
query = query.Where("source_type = ?", sourceType)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的账单来源类型")
}
}
if request.SourceNo != nil {
if sourceNo := strings.TrimSpace(*request.SourceNo); sourceNo != "" {
query = query.Where("source_no = ?", sourceNo)
}
}
if request.Status != nil {
switch *request.Status {
case constants.EmployeeCollectionBillStatusPending, constants.EmployeeCollectionBillStatusPartial,
constants.EmployeeCollectionBillStatusSettled, constants.EmployeeCollectionBillStatusClosed:
query = query.Where("status = ?", *request.Status)
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的账单状态")
}
}
if request.CustomerID != nil && *request.CustomerID > 0 {
customerID := strconv.FormatUint(uint64(*request.CustomerID), 10)
query = query.Where("customer_snapshot->>'buyer_id' = ? OR customer_snapshot->>'shop_id' = ?", customerID, customerID)
}
if request.CreatedFrom != nil {
from, err := parseBillDate(*request.CreatedFrom)
if err != nil {
return nil, err
}
if from != nil {
query = query.Where("created_at >= ?", *from)
}
}
if request.CreatedTo != nil {
to, err := parseBillDate(*request.CreatedTo)
if err != nil {
return nil, err
}
if to != nil {
query = query.Where("created_at < ?", to.AddDate(0, 0, 1))
}
}
return query, nil
}
// parseBillDate 解析自然日筛选值;空值表示不筛选。
func parseBillDate(value string) (*time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
parsed, err := time.ParseInLocation(billDateLayout, trimmed, time.Local)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "账单筛选日期格式必须为 YYYY-MM-DD")
}
return &parsed, nil
}
// billRefunds 读取账单的来源订单退款冲销关联。
func (q *BillQuery) billRefunds(ctx context.Context, billID uint) ([]*dto.EmployeeCollectionBillRefundResponse, error) {
var refunds []model.EmployeeCollectionBillRefund
if err := q.db.WithContext(ctx).Where("bill_id = ?", billID).Order("id ASC").Find(&refunds).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单退款冲销关联失败")
}
result := make([]*dto.EmployeeCollectionBillRefundResponse, 0, len(refunds))
for index := range refunds {
item := refunds[index]
result = append(result, &dto.EmployeeCollectionBillRefundResponse{
ID: item.ID, RefundID: item.RefundID, SourceOrderID: item.SourceOrderID,
RefundAmount: item.RefundAmount, BillReceivableAmount: item.BillReceivableAmount,
Outcome: item.Outcome, OutcomeName: constants.GetEmployeeCollectionRefundOutcomeName(item.Outcome),
ReducedAmount: item.ReducedAmount, CreatedAt: item.CreatedAt,
})
}
return result, nil
}
// billAllocations 读取账单分摊,并返回涉及的去重申请 ID。
func (q *BillQuery) billAllocations(ctx context.Context, billID uint) ([]*dto.EmployeeCollectionBillAllocationResponse, []uint, error) {
var allocations []model.EmployeeCollectionApplicationAllocation
if err := q.db.WithContext(ctx).Where("bill_id = ?", billID).Order("id ASC").Find(&allocations).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单分摊失败")
}
applicationIDs := make([]uint, 0, len(allocations))
seen := make(map[uint]struct{}, len(allocations))
for index := range allocations {
if _, exists := seen[allocations[index].ApplicationID]; !exists {
seen[allocations[index].ApplicationID] = struct{}{}
applicationIDs = append(applicationIDs, allocations[index].ApplicationID)
}
}
applicationStatus := make(map[uint]int, len(applicationIDs))
if len(applicationIDs) > 0 {
var applications []model.EmployeeCollectionApplication
if err := q.db.WithContext(ctx).Select("id", "status").
Where("id IN ?", applicationIDs).Find(&applications).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单分摊所属申请失败")
}
for index := range applications {
applicationStatus[applications[index].ID] = applications[index].Status
}
}
result := make([]*dto.EmployeeCollectionBillAllocationResponse, 0, len(allocations))
for index := range allocations {
item := allocations[index]
status := applicationStatus[item.ApplicationID]
result = append(result, &dto.EmployeeCollectionBillAllocationResponse{
ID: item.ID, ApplicationID: item.ApplicationID,
ApplicationStatus: status, ApplicationStatusName: constants.GetEmployeeCollectionApplicationStatusName(status),
AttemptID: item.AttemptID, Amount: item.Amount,
Status: item.Status, StatusName: constants.GetEmployeeCollectionAllocationStatusName(item.Status),
ReleasedAt: item.ReleasedAt, CreatedAt: item.CreatedAt,
})
}
return result, applicationIDs, nil
}
// billApplications 读取涉及该账单的申请及其审批尝试历史。
func (q *BillQuery) billApplications(ctx context.Context, applicationIDs []uint) ([]*dto.EmployeeCollectionBillApplicationResponse, error) {
if len(applicationIDs) == 0 {
return []*dto.EmployeeCollectionBillApplicationResponse{}, nil
}
var applications []model.EmployeeCollectionApplication
if err := q.db.WithContext(ctx).Where("id IN ?", applicationIDs).Order("id ASC").Find(&applications).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单关联核销申请失败")
}
attemptsByApplication, approvalStatus, approvalOpinions, err := q.applicationAttempts(ctx, applicationIDs)
if err != nil {
return nil, err
}
result := make([]*dto.EmployeeCollectionBillApplicationResponse, 0, len(applications))
for index := range applications {
application := applications[index]
vouchers := []string(application.PaymentVoucherKeys)
if vouchers == nil {
vouchers = []string{}
}
attempts, err := attemptResponses(
application.ID, attemptsByApplication[application.ID], approvalStatus, approvalOpinions)
if err != nil {
return nil, err
}
result = append(result, &dto.EmployeeCollectionBillApplicationResponse{
ID: application.ID, ApplicantAccountID: application.ApplicantAccountID,
ActingOperatorID: application.ActingOperatorID, ActingReason: application.ActingReason,
PaymentMethodID: application.PaymentMethodID, PaymentMethodCode: application.PaymentMethodCode,
PaymentMethodName: application.PaymentMethodName, PaidAmount: application.PaidAmount,
PayerName: application.PayerName, PaidAt: application.PaidAt,
ExternalTransactionNo: application.ExternalTransactionNo, PaymentVoucherKeys: vouchers,
Remark: application.Remark, Status: application.Status,
StatusName: constants.GetEmployeeCollectionApplicationStatusName(application.Status),
LatestApprovalInstanceID: application.LatestApprovalInstanceID,
DecidedAt: application.DecidedAt, TerminalReason: application.TerminalReason,
CreatedAt: application.CreatedAt,
Attempts: attempts,
})
}
return result, nil
}
// applicationAttempts 读取申请的审批尝试记录、审批实例状态与审批意见。
func (q *BillQuery) applicationAttempts(
ctx context.Context,
applicationIDs []uint,
) (map[uint][]model.EmployeeCollectionApplicationAttempt, map[uint]int, map[uint]string, error) {
var attempts []model.EmployeeCollectionApplicationAttempt
if err := q.db.WithContext(ctx).Where("application_id IN ?", applicationIDs).
Order("application_id ASC, attempt_no ASC").Find(&attempts).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批尝试记录失败")
}
approvalStatus, approvalOpinions, err := approvalStatusOfAttempts(ctx, q.db, attempts)
if err != nil {
return nil, nil, nil, err
}
grouped := make(map[uint][]model.EmployeeCollectionApplicationAttempt, len(applicationIDs))
for index := range attempts {
grouped[attempts[index].ApplicationID] = append(grouped[attempts[index].ApplicationID], attempts[index])
}
return grouped, approvalStatus, approvalOpinions, nil
}
// approvalStatusOfAttempts 批量读取审批尝试记录关联的通用审批实例状态与终态审批意见。
func approvalStatusOfAttempts(
ctx context.Context,
db *gorm.DB,
attempts []model.EmployeeCollectionApplicationAttempt,
) (map[uint]int, map[uint]string, error) {
instanceIDs := make([]uint, 0, len(attempts))
for index := range attempts {
if attempts[index].ApprovalInstanceID != nil {
instanceIDs = append(instanceIDs, *attempts[index].ApprovalInstanceID)
}
}
approvalStatus := make(map[uint]int, len(instanceIDs))
approvalOpinions := make(map[uint]string, len(instanceIDs))
if len(instanceIDs) == 0 {
return approvalStatus, approvalOpinions, nil
}
columns := []string{"id", "status", "decision_snapshot"}
var instances []model.ApprovalInstance
if err := db.WithContext(ctx).Select(columns).
Where("id IN ?", instanceIDs).Find(&instances).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询审批实例状态失败")
}
for index := range instances {
approvalStatus[instances[index].ID] = instances[index].Status
if opinion := employeecollectiondomain.ExtractApprovalOpinion(instances[index].DecisionSnapshot); opinion != "" {
approvalOpinions[instances[index].ID] = opinion
}
}
return approvalStatus, approvalOpinions, nil
}
// attemptResponses 投影审批尝试记录,附件只返回对象键引用与审批实例引用。
func attemptResponses(
applicationID uint,
attempts []model.EmployeeCollectionApplicationAttempt,
approvalStatus map[uint]int,
approvalOpinions map[uint]string,
) ([]*dto.EmployeeCollectionBillAttemptResponse, error) {
result := make([]*dto.EmployeeCollectionBillAttemptResponse, 0, len(attempts))
for index := range attempts {
attempt := attempts[index]
if attempt.ApplicationID != applicationID {
continue
}
vouchers := []string(attempt.PaymentVoucherKeys)
if vouchers == nil {
vouchers = []string{}
}
snapshot := make([]map[string]any, 0)
if len(attempt.AllocationSnapshot) > 0 {
if err := sonic.Unmarshal(attempt.AllocationSnapshot, &snapshot); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析审批尝试分摊快照失败")
}
}
var status *int
statusName := ""
opinion := ""
if attempt.ApprovalInstanceID != nil {
if value, ok := approvalStatus[*attempt.ApprovalInstanceID]; ok {
status = &value
statusName = constants.GetApprovalStatusName(value)
}
opinion = approvalOpinions[*attempt.ApprovalInstanceID]
}
result = append(result, &dto.EmployeeCollectionBillAttemptResponse{
ID: attempt.ID, AttemptNo: attempt.AttemptNo,
PaymentMethodID: attempt.PaymentMethodID, PaymentMethodCode: attempt.PaymentMethodCode,
PaymentMethodName: attempt.PaymentMethodName, PaidAmount: attempt.PaidAmount,
PayerName: attempt.PayerName, PaidAt: attempt.PaidAt,
ExternalTransactionNo: attempt.ExternalTransactionNo, PaymentVoucherKeys: vouchers,
Remark: attempt.Remark, SubmittedByAccountID: attempt.SubmittedByAccountID,
ActingReason: attempt.ActingReason, AllocationSnapshot: snapshot,
ApprovalInstanceID: attempt.ApprovalInstanceID,
ApprovalStatus: status, ApprovalStatusName: statusName,
ApprovalOpinion: opinion, CreatedAt: attempt.CreatedAt,
})
}
return result, nil
}
// ProjectBill 将账单事实投影为对外响应;关闭用例与查询共用同一投影。
func ProjectBill(bill *model.EmployeeCollectionBill) (*dto.EmployeeCollectionBillResponse, error) {
if bill == nil {
return nil, errors.New(errors.CodeEmployeeCollectionBillNotFound)
}
debtorSnapshot := make(map[string]any)
if len(bill.DebtorSnapshot) > 0 {
if err := sonic.Unmarshal(bill.DebtorSnapshot, &debtorSnapshot); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析账单欠款人快照失败")
}
}
customerSnapshot := make(map[string]any)
if len(bill.CustomerSnapshot) > 0 {
if err := sonic.Unmarshal(bill.CustomerSnapshot, &customerSnapshot); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "解析账单客户快照失败")
}
}
remaining := int64(0)
if bill.Status != constants.EmployeeCollectionBillStatusClosed {
remaining = bill.ReceivableAmount - bill.ReceivedAmount - bill.ReservedAmount
if remaining < 0 {
remaining = 0
}
}
return &dto.EmployeeCollectionBillResponse{
ID: bill.ID, SourceType: bill.SourceType,
SourceTypeName: constants.GetEmployeeCollectionSourceTypeName(bill.SourceType),
SourceID: bill.SourceID, SourceNo: bill.SourceNo,
DebtorAccountID: bill.DebtorAccountID, DebtorSnapshot: debtorSnapshot,
CustomerSnapshot: customerSnapshot,
ReceivableAmount: bill.ReceivableAmount, ReceivedAmount: bill.ReceivedAmount,
ReservedAmount: bill.ReservedAmount, RemainingAmount: remaining,
Status: bill.Status, StatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status),
ApprovalPending: bill.ReservedAmount > 0,
ClosedReason: bill.ClosedReason, ClosedAt: bill.ClosedAt,
CreatedAt: bill.CreatedAt, UpdatedAt: bill.UpdatedAt,
}, nil
}

Some files were not shown because too many files have changed in this diff Show More