Compare commits
48 Commits
a48ff5d782
...
Iteration/
| Author | SHA1 | Date | |
|---|---|---|---|
| 6333f4ad13 | |||
| 62419d4b17 | |||
| e8ab1f471e | |||
| 398a5e4282 | |||
| d52be16802 | |||
| 70e6b186df | |||
| aab56a6998 | |||
| e7b93e4634 | |||
| 33826c3443 | |||
| d5bcda94fe | |||
| ef4d3696d4 | |||
| 15bbb953db | |||
| 59b3df868a | |||
| 41722760b1 | |||
| 333ba4b647 | |||
| 70e680eb0a | |||
| 93e072e1e2 | |||
| c7f9e005af | |||
| 957a235585 | |||
| 18796b16ff | |||
| 1aa4eacee2 | |||
| 67893617fe | |||
| 09abee9778 | |||
| ba0855d9eb | |||
| 48c85a4916 | |||
| bb06cc89c5 | |||
| 575d056f54 | |||
| 315a7de3e4 | |||
| 5ee8e3cb4a | |||
| 7891189712 | |||
| e687a266e6 | |||
| ff1362df3f | |||
| 9c3e3fe32b | |||
| fe07df0b3e | |||
| 69b37eb89b | |||
| ce24d5612e | |||
| dc4e0d4103 | |||
| 1e776da292 | |||
| b9e8592cc4 | |||
| 54823290c3 | |||
| b38b2b39c9 | |||
| 6f8db180fb | |||
| a9e2302f7c | |||
| bcb1304937 | |||
| 88d7965641 | |||
| e09c4632fb | |||
| 98c145fe70 | |||
| ff25586dc9 |
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: 设置镜像标签
|
||||
id: tag
|
||||
run: |
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 登录 Docker Registry
|
||||
run: |
|
||||
@@ -37,19 +37,15 @@ 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'
|
||||
@@ -59,6 +55,7 @@ jobs:
|
||||
|
||||
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'
|
||||
|
||||
@@ -84,6 +84,7 @@ openspec validate --all
|
||||
```
|
||||
|
||||
启动、隔离数据库重置、smoke 与日志读取见 [`README.md`](README.md)。迁移使用 `scripts/migrate.sh` 和显式 `DB_*` 参数;生产迁移仅按生产运行说明由维护者手工执行。
|
||||
- 测试验证:涉及迁移、Redis、部署或集成 Smoke 时,MUST 读取 [`docs/engineering/工程约束.md`](docs/engineering/工程约束.md) 的 ENG-TEST-001;维护者指定的测试环境是唯一验证面。
|
||||
|
||||
## 渐进披露
|
||||
|
||||
|
||||
32
CONTEXT.md
32
CONTEXT.md
@@ -1,32 +0,0 @@
|
||||
# 新卡管系统
|
||||
|
||||
新卡管系统保存物联网卡、设备、套餐、订单、钱包、分佣、审批与运营协作的本地业务事实,并协调外部支付、运营商和企业微信能力。
|
||||
|
||||
## 资金与审批
|
||||
|
||||
**员工代收款账单**:员工代客户完成套餐购买或充值等业务后生成的待核销记录,表示该员工经办业务形成的暂挂欠款;可由已匹配支付记录的客户付款凭证核销。
|
||||
_Avoid_: 员工账单、客户应收款、销账单
|
||||
|
||||
**核销**:公司对员工代收款账单及已匹配支付记录的客户付款凭证作出的确认,使对应暂挂欠款减少或结清的业务决定。
|
||||
_Avoid_: 客户付款、订单支付
|
||||
|
||||
**核销申请**:员工针对一笔外部付款提交的、包含凭证和一至多条账单分摊明细的审批业务单;一张申请对应一个企业微信审批实例。
|
||||
_Avoid_: 单张账单审批、付款截图
|
||||
|
||||
**核销分摊明细**:核销申请对一张员工代收款账单确认的本次核销金额。
|
||||
_Avoid_: 账单金额、付款金额
|
||||
|
||||
**账单核销状态**:员工代收款账单的结算状态,取待核销、部分核销、已核销或已关闭;与核销申请的审批状态相互独立。
|
||||
_Avoid_: 审批状态、企业微信状态
|
||||
|
||||
**审批实例**:本地保存、唯一关联一笔业务单的审批生命周期事实;企业微信是该实例的审批渠道,而非本地业务事实的替代。
|
||||
_Avoid_: 企业微信审批单
|
||||
|
||||
**交易流水号**:支付或退款渠道为一笔交易生成的外部标识;OCR 识别结果只能预填该字段,须由业务人员最终确认。
|
||||
_Avoid_: OCR 结果、系统订单号
|
||||
|
||||
**线下收款方式**:业务字典中供核销等线下付款场景选择的收款路径标识,例如某个指定微信或银行卡;它不等同于线上支付渠道枚举。
|
||||
_Avoid_: 支付方式枚举、固定收款人名单、收款账户目录
|
||||
|
||||
**业务字典**:由研发固定注册的业务分类及其由业务维护的字典项,用于稳定的业务选项;业务人员不能自行创建字典分类。
|
||||
_Avoid_: 系统配置、任意自定义字段平台
|
||||
@@ -32,7 +32,9 @@ RUN GOOS=linux GOARCH=amd64 go build \
|
||||
./cmd/api
|
||||
|
||||
# 下载 golang-migrate 工具(使用 GOPROXY 加速)
|
||||
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
||||
# 固定 v4.19.1:其 go.mod 仅要求 go 1.24.0,与基础镜像 Go 1.25.6 兼容;
|
||||
# @latest(如 v4.20.1)要求 Go >= 1.25.11,在 GOTOOLCHAIN=local 的 1.25.6 环境下构建失败
|
||||
RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
|
||||
|
||||
# ================================
|
||||
# 阶段 2: 运行阶段
|
||||
|
||||
21
README.md
21
README.md
@@ -224,7 +224,7 @@ default:
|
||||
- **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式(包含错误码、消息、时间戳);Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx)和日志级别控制;敏感信息自动脱敏保护
|
||||
- **数据持久化**:GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力
|
||||
- **异步任务处理**:Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务
|
||||
- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `scene=device/iot_card`、`format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 功能总结 与 验收记录
|
||||
- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接。当前导出场景共 11 个:`device`、`iot_card`、`order`、`package`、`agent_wallet_transaction`、`agent_recharge`、`refund`、`exchange`、`commission_record`、`package_traffic_alert`、`expiring_asset`;其中 `exchange`、`agent_recharge`、`order`、`commission_record`、`expiring_asset` 的时间筛选固定为 `filters.start_time`/`filters.end_time`(带显式时区的 RFC3339 秒级时间,闭区间含两端),创建时规范化为 UTC 秒级字符串后冻结,执行期只按冻结值严格解析;临期资产导出另有受控入口 `POST /api/admin/expiring-assets/export`
|
||||
- **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 功能总结、接口回放示例 与 SQL 验收脚本
|
||||
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能;完整的权限检查功能支持路由级别的细粒度权限控制,支持平台过滤(web/h5/all)和超级管理员自动跳过(详见 功能总结、使用指南 和 权限检查使用指南)
|
||||
- **商户管理**:完整的商户(Shop)和商户账号管理功能,支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能(详见 使用指南 和 API 文档)
|
||||
@@ -246,14 +246,23 @@ default:
|
||||
### 导出任务接口示例
|
||||
|
||||
```bash
|
||||
# 创建导出任务(scene 支持 device / iot_card)
|
||||
# 创建导出任务(scene 支持 device / iot_card / order / package / agent_wallet_transaction /
|
||||
# agent_recharge / refund / exchange / commission_record / package_traffic_alert / expiring_asset)
|
||||
# exchange / agent_recharge / order / commission_record 的时间筛选固定写在 query.filters 下,
|
||||
# 取值必须为带显式时区的 RFC3339 秒级时间,闭区间含两端,任一端可省略
|
||||
curl -X POST '/api/admin/export-tasks' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-H 'Authorization: Bearer <token>' \\
|
||||
-d '{\"scene\":\"device\",\"format\":\"xlsx\"}'
|
||||
-d '{"scene":"order","format":"xlsx","query":{"filters":{"start_time":"2026-09-01T00:00:00+08:00","end_time":"2026-09-30T23:59:59+08:00"}}}'
|
||||
|
||||
# 查询导出任务列表(支持 scene/status/time 过滤)
|
||||
curl '/api/admin/export-tasks?page=1&page_size=20&scene=device' \\
|
||||
# 创建临期资产导出任务(受控入口,请求体与临期资产列表筛选一致)
|
||||
curl -X POST '/api/admin/expiring-assets/export' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-H 'Authorization: Bearer <token>' \\
|
||||
-d '{"format":"xlsx","days_max":7,"start_time":"2026-09-01T00:00:00+08:00"}'
|
||||
|
||||
# 查询导出任务列表(支持 scene/status/start_time/end_time 过滤)
|
||||
curl '/api/admin/export-tasks?page=1&page_size=20&scene=order&start_time=2026-09-01T00:00:00%2B08:00' \\
|
||||
-H 'Authorization: Bearer <token>'
|
||||
```
|
||||
|
||||
@@ -719,6 +728,8 @@ KeyAuth:Token 缺失
|
||||
| `jwt.secret_key` | `JUNHONG_JWT_SECRET_KEY` |
|
||||
| `logging.level` | `JUNHONG_LOGGING_LEVEL` |
|
||||
|
||||
三类新线上支付(C 端套餐购买、C 端资产钱包充值、代理在线预存款充值)始终从对应启用商户池选择并冻结商户路由;商户池、成员缺失或停用时明确返回“暂无可用商户”,绝不回退旧综合支付配置。`merchant_id` 为空只代表留存期内的历史支付,其回调、查单和既有退款路径仍按 `payment_config_id` 双读,直到独立 Change 删除旧读取路径。
|
||||
|
||||
### 必填配置
|
||||
|
||||
以下配置项必须通过环境变量设置(无默认值或需要覆盖):
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
@@ -27,8 +28,20 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
||||
handlers := openapi.BuildDocHandlers()
|
||||
handlers.Audit = admin.NewAuditHandler(nil, nil)
|
||||
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
|
||||
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
|
||||
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
|
||||
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
|
||||
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
|
||||
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
|
||||
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
|
||||
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, 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)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
@@ -36,8 +37,20 @@ func generateAdminDocs(outputPath string) error {
|
||||
handlers := openapi.BuildDocHandlers()
|
||||
handlers.Audit = admin.NewAuditHandler(nil, nil)
|
||||
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
|
||||
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
|
||||
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
|
||||
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
|
||||
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
|
||||
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
|
||||
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
|
||||
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
|
||||
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, 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)
|
||||
|
||||
@@ -16,20 +16,29 @@ import (
|
||||
|
||||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
|
||||
auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
|
||||
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"
|
||||
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
|
||||
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
|
||||
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
|
||||
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
|
||||
commissionDelivery "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
|
||||
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
|
||||
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
|
||||
shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop"
|
||||
walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
|
||||
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
@@ -82,6 +91,13 @@ type workerRuntime struct {
|
||||
pollingIotCardStore *postgres.IotCardStore
|
||||
pollingBase *task.PollingBase
|
||||
lifecycleSvc *polling.PollingLifecycleService
|
||||
// refundChannelService 是渠道原路退款的唯一用例实例:执行、恢复与退款完成通知共用它,
|
||||
// 使恢复确认的成功与直接调用确认的成功走同一回写路径。
|
||||
refundChannelService *refundchannelApp.Service
|
||||
// carrierThresholdService 是通道流量阈值停复机与两个计划任务的唯一用例实例。
|
||||
carrierThresholdService *carrierThresholdApp.Service
|
||||
// assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。
|
||||
assetAutoRenewalService *assetAutoRenewalApp.Service
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -153,6 +169,10 @@ func runWorker(cfg *config.Config) {
|
||||
taskHandler.RegisterHandlers()
|
||||
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
|
||||
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerCarrierThresholdTasks(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerAssetAutoRenewalTasks(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerRefundCommissionRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
|
||||
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger)
|
||||
outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
|
||||
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
|
||||
@@ -279,16 +299,6 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
|
||||
|
||||
pollingQueueMgr := polling.NewPollingQueueManager(redisClient, constants.PollingShardCount, appLogger)
|
||||
pollingIotCardStore := postgres.NewIotCardStore(db, redisClient)
|
||||
pollingBase := task.NewPollingBase(
|
||||
redisClient,
|
||||
pollingQueueMgr,
|
||||
pollingConfigMgr,
|
||||
pollingIotCardStore,
|
||||
appLogger,
|
||||
cfg.Polling.VerboseLog,
|
||||
cfg.Worker.PollingTotalMaxConcurrency,
|
||||
)
|
||||
|
||||
pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient)
|
||||
pollingDeviceStore := postgres.NewDeviceStore(db, redisClient)
|
||||
lifecycleSvc := polling.NewPollingLifecycleService(
|
||||
@@ -299,9 +309,25 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
|
||||
pollingDeviceStore,
|
||||
appLogger,
|
||||
)
|
||||
pollingBase := task.NewPollingBase(
|
||||
redisClient,
|
||||
pollingQueueMgr,
|
||||
pollingConfigMgr,
|
||||
pollingIotCardStore,
|
||||
lifecycleSvc,
|
||||
postgres.NewPollingPriorityItemStore(db),
|
||||
auditInfra.NewWriter(auditInfra.NewRegistry(), nil),
|
||||
db,
|
||||
appLogger,
|
||||
cfg.Polling.VerboseLog,
|
||||
cfg.Worker.PollingTotalMaxConcurrency,
|
||||
)
|
||||
if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
|
||||
stopResumeSvc.SetPollingCallback(lifecycleSvc)
|
||||
}
|
||||
if workerResult.Services.CarrierThreshold == nil {
|
||||
appLogger.Fatal("运营商通道流量阈值用例未配置")
|
||||
}
|
||||
|
||||
runtime := &workerRuntime{
|
||||
redisAddr: redisAddr,
|
||||
@@ -319,14 +345,69 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
|
||||
pollingIotCardStore: pollingIotCardStore,
|
||||
pollingBase: pollingBase,
|
||||
lifecycleSvc: lifecycleSvc,
|
||||
// 通道阈值停复机复用既有停复机服务作为唯一执行事实源(重试、Integration Log、统一审计与既有判定)。
|
||||
carrierThresholdService: workerResult.Services.CarrierThreshold,
|
||||
// 自动续费的复机执行同样复用既有停复机单一事实源。
|
||||
assetAutoRenewalService: workerResult.Services.AssetAutoRenewal,
|
||||
}
|
||||
registerNotificationOutboxConsumer(runtime, appLogger)
|
||||
registerWalletOutboxConsumer(runtime, appLogger)
|
||||
registerCardObservationOutboxConsumer(runtime, appLogger)
|
||||
registerPriorityPollingOutboxConsumer(runtime, appLogger)
|
||||
registerCarrierThresholdOutboxConsumer(runtime, appLogger)
|
||||
registerAssetAutoRenewalOutboxConsumer(runtime, appLogger)
|
||||
registerWeComApprovalOutboxConsumer(runtime, cfg, appLogger)
|
||||
return runtime
|
||||
}
|
||||
|
||||
// registerPriorityPollingOutboxConsumer 注册卡轮询优先队列请求事件消费者。
|
||||
// 消费者按卡 × 纳入任务类型建立或合并优先项并下发执行提示;重复投递只合并。
|
||||
func registerPriorityPollingOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
priorityStore := postgres.NewPollingPriorityItemStore(runtime.db)
|
||||
consumer := prioritypollingInfra.NewPriorityRequestedConsumer(
|
||||
runtime.db,
|
||||
priorityStore,
|
||||
runtime.pollingQueueMgr,
|
||||
auditInfra.NewWriter(auditInfra.NewRegistry(), nil),
|
||||
appLogger,
|
||||
)
|
||||
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypePollingPriorityRequested, consumer); err != nil {
|
||||
appLogger.Fatal("注册卡轮询优先队列 Outbox 消费者失败",
|
||||
zap.String("event_type", constants.OutboxEventTypePollingPriorityRequested), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// registerCarrierThresholdOutboxConsumer 注册运营商通道流量阈值停复机事件消费者。
|
||||
// 两个事件类型共用同一消费者实现:停机与复机各自由锁行认领字段保证至多一次外部调用。
|
||||
func registerCarrierThresholdOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.carrierThresholdService == nil {
|
||||
appLogger.Fatal("运营商通道流量阈值用例未配置")
|
||||
}
|
||||
consumer := carrierThresholdApp.NewConsumer(runtime.carrierThresholdService)
|
||||
for _, eventType := range []string{
|
||||
carrierThresholdApp.EventCarrierThresholdStop,
|
||||
carrierThresholdApp.EventCarrierThresholdResume,
|
||||
} {
|
||||
if err := runtime.outboxConsumers.Register(eventType, consumer); err != nil {
|
||||
appLogger.Fatal("注册运营商通道流量阈值 Outbox 消费者失败",
|
||||
zap.String("event_type", eventType), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerAssetAutoRenewalOutboxConsumer 注册资产钱包自动续费的复机请求事件消费者。
|
||||
// 消费者按尝试记录认领执行权后执行复机,重复投递不会产生第二次外部调用。
|
||||
func registerAssetAutoRenewalOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.assetAutoRenewalService == nil {
|
||||
appLogger.Fatal("资产钱包自动续费用例未配置")
|
||||
}
|
||||
consumer := assetAutoRenewalApp.NewResumeConsumer(runtime.assetAutoRenewalService)
|
||||
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAssetAutoRenewalResumeRequested, consumer); err != nil {
|
||||
appLogger.Fatal("注册资产钱包自动续费 Outbox 消费者失败",
|
||||
zap.String("event_type", constants.OutboxEventTypeAssetAutoRenewalResumeRequested), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。
|
||||
func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
|
||||
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
|
||||
@@ -376,7 +457,23 @@ 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),
|
||||
)
|
||||
refundChannelService := refundchannelApp.NewService(
|
||||
runtime.db,
|
||||
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
|
||||
paymentInfra.NewRefundAdapter(wechat.NewRedisCache(runtime.redisClient), appLogger),
|
||||
auditWriter,
|
||||
).SetLogger(appLogger).SetCompletionNotifier(refundService)
|
||||
refundService.SetChannelRefundService(refundChannelService)
|
||||
runtime.refundChannelService = refundChannelService
|
||||
if err := runtime.outboxConsumers.Register(refundchannelApp.EventRefundChannelRefund, refundchannelApp.NewConsumer(refundChannelService)); err != nil {
|
||||
appLogger.Fatal("注册渠道原路退款 Outbox 消费者失败", zap.Error(err))
|
||||
}
|
||||
if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil {
|
||||
appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err))
|
||||
}
|
||||
@@ -390,8 +487,23 @@ 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),
|
||||
constants.ApprovalBusinessTypeRefund: refundService,
|
||||
constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(
|
||||
runtime.db, walletPosting, runtime.workerResult.Services.RechargeAudit,
|
||||
employeecollectionApp.NewBillCreationService(auditWriter),
|
||||
),
|
||||
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,
|
||||
@@ -448,6 +560,7 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
|
||||
)
|
||||
recovery := agentrechargeApp.NewRecoverOnlinePaymentService(
|
||||
runtime.db,
|
||||
merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient),
|
||||
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger),
|
||||
paymentInfra.NewAlipayWapAdapter(integration, appLogger),
|
||||
paymentInfra.NewFuiouScanAdapter(integration, appLogger),
|
||||
@@ -459,6 +572,57 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
|
||||
appLogger.Info("注册代理在线充值支付恢复任务处理器", zap.String("task_type", constants.TaskTypeAgentRechargeRecovery))
|
||||
}
|
||||
|
||||
// registerRefundCommissionRecoveryTask 注册退款佣金回溯后处理的周期性补偿任务。
|
||||
// 该任务只重投稳定的退款后处理 Outbox 事件,绝不直接改动资金;重复执行由消费端幂等兜底。
|
||||
func registerRefundCommissionRecoveryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.db == nil {
|
||||
appLogger.Fatal("退款佣金回溯补偿任务缺少数据库依赖")
|
||||
}
|
||||
handler := commissionDelivery.NewRefundRecoveryTaskHandler(runtime.db, outbox.NewRepository(), appLogger)
|
||||
mux.HandleFunc(constants.TaskTypeRefundCommissionRecovery, handler.Handle)
|
||||
appLogger.Info("注册退款佣金回溯补偿任务处理器", zap.String("task_type", constants.TaskTypeRefundCommissionRecovery))
|
||||
}
|
||||
|
||||
// registerRefundChannelRecoveryTask 注册渠道原路退款结果恢复任务。
|
||||
// 该任务只查询渠道并回填结果,绝不重复发起资金动作。
|
||||
// 必须复用执行路径的同一用例实例:恢复确认的成功同样需要补写退款完成通知。
|
||||
func registerRefundChannelRecoveryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.refundChannelService == nil {
|
||||
appLogger.Fatal("渠道原路退款用例未配置")
|
||||
}
|
||||
handler := paymentInfra.NewRefundChannelRecoveryTaskHandler(runtime.refundChannelService)
|
||||
mux.HandleFunc(constants.TaskTypeRefundChannelRecovery, handler.Handle)
|
||||
appLogger.Info("注册渠道原路退款结果恢复任务处理器", zap.String("task_type", constants.TaskTypeRefundChannelRecovery))
|
||||
}
|
||||
|
||||
// registerCarrierThresholdTasks 注册运营商通道流量阈值的周期处理与结果恢复任务。
|
||||
// 两个任务共用同一用例实例:周期处理负责跨期解锁与条件复机,恢复扫描只查询状态回填,绝不重复发起停复机。
|
||||
func registerCarrierThresholdTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.carrierThresholdService == nil {
|
||||
appLogger.Fatal("运营商通道流量阈值用例未配置")
|
||||
}
|
||||
cycleHandler := carrierThresholdInfra.NewCycleTaskHandler(runtime.carrierThresholdService)
|
||||
mux.HandleFunc(constants.TaskTypeCarrierThresholdCycle, cycleHandler.Handle)
|
||||
appLogger.Info("注册运营商通道流量阈值周期处理任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdCycle))
|
||||
recoveryHandler := carrierThresholdInfra.NewRecoveryTaskHandler(runtime.carrierThresholdService)
|
||||
mux.HandleFunc(constants.TaskTypeCarrierThresholdRecovery, recoveryHandler.Handle)
|
||||
appLogger.Info("注册运营商通道流量阈值结果恢复任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdRecovery))
|
||||
}
|
||||
|
||||
// registerAssetAutoRenewalTasks 注册资产钱包自动续费的每日扫描与复机结果恢复任务。
|
||||
// 两个任务共用同一用例实例:每日扫描负责终态收敛与续购执行,恢复扫描只查询状态回填,绝不重复发起复机。
|
||||
func registerAssetAutoRenewalTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
if runtime == nil || runtime.assetAutoRenewalService == nil {
|
||||
appLogger.Fatal("资产钱包自动续费用例未配置")
|
||||
}
|
||||
scanHandler := assetAutoRenewalInfra.NewDailyScanTaskHandler(runtime.assetAutoRenewalService)
|
||||
mux.HandleFunc(constants.TaskTypeAssetAutoRenewalScan, scanHandler.Handle)
|
||||
appLogger.Info("注册资产钱包自动续费每日扫描任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalScan))
|
||||
recoveryHandler := assetAutoRenewalInfra.NewRecoveryTaskHandler(runtime.assetAutoRenewalService)
|
||||
mux.HandleFunc(constants.TaskTypeAssetAutoRenewalRecovery, recoveryHandler.Handle)
|
||||
appLogger.Info("注册资产钱包自动续费复机结果恢复任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalRecovery))
|
||||
}
|
||||
|
||||
// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。
|
||||
func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
|
||||
@@ -723,6 +887,46 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册代理在线充值支付恢复定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeRefundChannelRecovery,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeRefundChannelRecovery)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册渠道原路退款结果恢复定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeCarrierThresholdCycle,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeCarrierThresholdCycle)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册运营商通道流量阈值周期处理定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeCarrierThresholdRecovery,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeCarrierThresholdRecovery)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册运营商通道流量阈值结果恢复定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeRefundCommissionRecovery,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeRefundCommissionRecovery)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册退款佣金回溯补偿定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
|
||||
constants.TaskTypeOrderExpire,
|
||||
nil,
|
||||
@@ -770,6 +974,34 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册每日套餐临期提醒扫描定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 * * *", asynq.NewTask(
|
||||
constants.TaskTypePackageTrafficAlertScan,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageTrafficAlertScan)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册每日套餐真流量达量预警扫描定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 7 * * *", asynq.NewTask(
|
||||
constants.TaskTypeAssetAutoRenewalScan,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(30*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalScan)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册每日资产钱包自动续费扫描定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register("@every 5m", asynq.NewTask(
|
||||
constants.TaskTypeAssetAutoRenewalRecovery,
|
||||
nil,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(10*time.Minute),
|
||||
asynq.Unique(5*time.Minute),
|
||||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalRecovery)),
|
||||
)); err != nil {
|
||||
return fmt.Errorf("注册资产钱包自动续费复机结果恢复定时任务失败: %w", err)
|
||||
}
|
||||
if _, err := asynqScheduler.Register(
|
||||
"0 2 * * *",
|
||||
asynq.NewTask(
|
||||
@@ -868,6 +1100,44 @@ func createTaskHandler(runtime *workerRuntime, appLogger *zap.Logger) *queue.Han
|
||||
func rescuePendingImportTasks(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) {
|
||||
rescuePendingIotCardImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
|
||||
rescuePendingDeviceImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
|
||||
rescuePendingShopBusinessOwnerImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
|
||||
rescuePendingPhoneAssetUnbindImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
|
||||
}
|
||||
|
||||
// rescuePendingShopBusinessOwnerImportTasks 补偿仍停留在待处理状态的店铺负责人导入任务。
|
||||
// 只扫描本 Change 自己的任务表,补偿键按任务类型与任务 ID 隔离,与设备导入补偿互不影响。
|
||||
func rescuePendingShopBusinessOwnerImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) {
|
||||
var importTasks []model.ShopBusinessOwnerImportTask
|
||||
if err := db.WithContext(ctx).
|
||||
Where("status = ?", model.ImportTaskStatusPending).
|
||||
Limit(importRescueLimit).
|
||||
Find(&importTasks).Error; err != nil {
|
||||
appLogger.Warn("扫描待补偿店铺负责人导入任务失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, importTask := range importTasks {
|
||||
payload := task.ShopBusinessOwnerImportPayload{TaskID: importTask.ID}
|
||||
enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypeShopBusinessOwnerImport, payload, importTask.ID, appLogger)
|
||||
}
|
||||
}
|
||||
|
||||
// rescuePendingPhoneAssetUnbindImportTasks 补偿仍停留在待处理状态的手机号资产解绑导入任务。
|
||||
// 只扫描本 Change 自己的任务表,补偿键按任务类型与任务 ID 隔离,与其他导入补偿互不影响。
|
||||
func rescuePendingPhoneAssetUnbindImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) {
|
||||
var importTasks []model.PhoneAssetUnbindImportTask
|
||||
if err := db.WithContext(ctx).
|
||||
Where("status = ?", model.ImportTaskStatusPending).
|
||||
Limit(importRescueLimit).
|
||||
Find(&importTasks).Error; err != nil {
|
||||
appLogger.Warn("扫描待补偿手机号资产解绑导入任务失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
for _, importTask := range importTasks {
|
||||
payload := task.PhoneAssetUnbindImportPayload{TaskID: importTask.ID}
|
||||
enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypePhoneAssetUnbindImport, payload, importTask.ID, appLogger)
|
||||
}
|
||||
}
|
||||
|
||||
// rescuePendingIotCardImportTasks 补偿仍停留在待处理状态的 IoT 卡导入任务。
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
services:
|
||||
api:
|
||||
image: registry.boss160.cn/junhong/cmp-fiber-api:latest
|
||||
image: registry.boss160.cn/junhong/cmp-fiber-api:${IMAGE_TAG:-latest}
|
||||
container_name: junhong-cmp-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
worker:
|
||||
image: registry.boss160.cn/junhong/cmp-fiber-worker:latest
|
||||
image: registry.boss160.cn/junhong/cmp-fiber-worker:${IMAGE_TAG:-latest}
|
||||
container_name: junhong-cmp-worker
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -74,6 +74,28 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
|
||||
|
||||
迁移失败时不启动新二进制;按失败迁移的事务状态决定处理,必要时恢复已确认可用的数据库备份。启动失败时覆盖回部署前备份的二进制,再恢复数据库备份(如迁移已改变数据库)。
|
||||
|
||||
### 商户池支付路由发布与回滚
|
||||
|
||||
本节是维护者操作清单,不是已执行证据。迁移、生产发布、Redis 操作和富友真实渠道核验均由维护者执行;本轮未执行,不能以本地构建替代。
|
||||
|
||||
**前置条件**
|
||||
|
||||
1. 留存维护者指定测试环境或本地验证证据,且不得记录密钥或完整报文中的敏感凭证。富友仅沿用现有实现;未进行外部渠道实测不构成开发、测试部署、任务完成、归档或发布前置。
|
||||
2. 确认本次商户池 Schema 迁移已完成可恢复备份及校验;停止服务后确认迁移锁影响、无长事务和可接受维护窗口。
|
||||
3. 上传支持 `merchant_id`/`payment_config_id` 双读的 API 与 Worker 二进制。商户池新支付没有运行时开关。
|
||||
|
||||
**发布后检查**
|
||||
|
||||
1. 由维护者执行迁移并部署双读二进制。C 端套餐购买、C 端资产钱包充值、代理在线预存款充值的后续新支付立即经启用商户池创建并冻结 `merchant_id`、商户池与 `routing_epoch`。
|
||||
2. 无可用商户池、成员缺失或池停用必须稳定失败,不得回退旧综合支付配置或自动换商户;后台线下订单、后台钱包余额支付和员工线下代充值不经过商户池。
|
||||
3. 检查首次成功唯一累计,以及回调/查单/退款 A 对 `merchant_id` 新单和 `payment_config_id` 历史单的双读分流;应用、审计和集成日志不得包含凭证、私钥、Token、证书或完整敏感配置。
|
||||
|
||||
**回滚与记录**
|
||||
|
||||
1. 不存在关闭商户池新支付创建的运行时开关。故障只能在仍支持双读的二进制上前向修复,不得恢复旧综合支付配置创建。
|
||||
2. 只要存在 `merchant_id` 非空支付、成功累计事实或新商户池配置,禁止部署不识别新路由的旧二进制,也禁止执行破坏这些事实的 down 迁移。
|
||||
3. 维护者记录二进制版本、时间、目标 PostgreSQL/Redis 的脱敏标识、备份校验、验证结果与全部阻塞原因。
|
||||
|
||||
### 零金额退款发布后核验
|
||||
|
||||
发布本次退款审批变更后,维护者应先等待既有重试处理稳定事件 `approval:26:approved`;若重试已耗尽,按受控运维流程重放同一事件,不得直接修改退款、订单或钱包数据。随后核验:
|
||||
|
||||
@@ -41,6 +41,19 @@
|
||||
- **最后验证日期**:2026-08-07
|
||||
- **更新触发条件**:错误系统或 ErrorHandler 变化
|
||||
|
||||
## ENG-ERR-002
|
||||
- **状态**:生效
|
||||
- **适用范围**:请求 DTO 中来自 URL 路径的字段,以及 Handler 的参数校验失败响应
|
||||
- **规则**:路径来源字段 MUST 在 Handler 内由 `c.Params` 解析后回填,再执行 `validator.Struct`;DTO MUST NOT 依赖 `validate:"required"` 覆盖路径字段而不回填。新增或修改的参数校验点 MUST 让校验失败返回 1001 且在 `msg` 中说明首个失败字段与规则,字段名取自该字段的中文 `description`;未触碰的既有 Handler 的通用提示按 As-Is 保留。
|
||||
- **理由**:`json:"-"` 的路径字段不参与 Body/Query 绑定,不回填则 `required` 恒失败,接口对任何合法请求都返回“参数不合法”,且原提示不指出字段,无法定位。
|
||||
- **最小正例**:`shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)` 后 `req.ShopID = uint(shopID)` 再 `validator.Struct(&req)`;失败时 `errors.New(errors.CodeInvalidParam, validationMessage("提现资料资格参数不合法", &req, err))` 产出“提现资料资格参数不合法:合同附件对象存储 Key 不能为空”。
|
||||
- **最小反例**:`c.BodyParser(&req)` 后直接 `validator.Struct(&req)` 并返回无字段信息的“XX参数不合法”。
|
||||
- **机械检查/人工原因**:对每个被 `validator.Struct` 校验的 DTO,核对携带 `path:"..."` 且 `validate` 含 `required` 的字段是否在调用点赋值;`go build ./cmd/api`。全仓同类 DTO 中存在未被校验的路径字段,不能只靠 grep 判定违规。
|
||||
- **例外条件**:路径字段不带 `validate:"required"` 且调用方显式回填的 DTO 不受本规则约束;未纳入本次触碰范围的 Handler 通用提示不要求整改。
|
||||
- **Owner**:API 负责人
|
||||
- **最后验证日期**:2026-09-14
|
||||
- **更新触发条件**:DTO 绑定方式、校验消息约定或请求绑定工具变化
|
||||
|
||||
## ENG-RESP-001
|
||||
- **状态**:生效
|
||||
- **适用范围**:HTTP Handler
|
||||
@@ -244,9 +257,9 @@
|
||||
- **最小正例**:事务写事实和 Audit Writer;提交后由 Outbox 外发。
|
||||
- **最小反例**:事务中等待第三方网络后再提交。
|
||||
- **机械检查/人工原因**:逐用例人工核对 Transaction 闭包、Audit Writer 和外部调用位置。
|
||||
- **例外条件**:业务回滚后的 failed/denied 审计使用独立短事务。
|
||||
- **例外条件**:业务回滚后的 failed/denied 审计,以及随之记录的回滚后失败状态事实,使用独立短事务;该短事务 MUST NOT 与已回滚的主事务共用连接或事务,且 MUST 以业务单仍处于允许该失败事实的状态为条件更新。
|
||||
- **Owner**:架构与审计负责人
|
||||
- **最后验证日期**:2026-08-07
|
||||
- **最后验证日期**:2026-09-14
|
||||
- **更新触发条件**:高风险写或外部调用变化
|
||||
|
||||
## ENG-AUDIT-001
|
||||
@@ -300,3 +313,43 @@
|
||||
- **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、部署分支、测试主机或验证授权变化
|
||||
|
||||
## KNOWN-ISSUE-001
|
||||
- **状态**:已知缺陷,待修复(当前不阻塞归档;标签功能未启用时无实际影响)
|
||||
- **适用范围**:`internal/service/exchange/migration.go` 的标签复制步骤(换货业务数据迁移的「资产标签」迁移项)
|
||||
- **问题**:标签复制使用 `clause.OnConflict{Columns: [resource_type, resource_id, tag_id], DoNothing: true}`,未声明 `tb_resource_tag` 上部分唯一索引 `idx_resource_tag_unique`(`... WHERE deleted_at IS NULL`)的谓词,PostgreSQL 返回 `42P10`。
|
||||
- **理由**:旧资产存在任意 `tb_resource_tag` 行且换货请求要求迁移时,标签步骤必然失败,导致换货完成整体回滚、迁移状态落 `failed`,「已迁移」在该情形不可达。记录于此以便由独立变更修复,避免在其它任务中顺手改动迁移项。
|
||||
- **证据**:2026-09-14 在 `junhong_cmp_test` 的 `tb_audit_event` 实测 4 条 `action_code=exchange.card.complete`、`result=failed`、`error_code=1206`,`error_summary` 含 `复制资产标签失败 ... SQLSTATE 42P10`;`tb_resource_tag` 当前 0 行,故静态库状态下不可观测。该文件自 `add-exchange-data-migration-status` 起未修改(md5 与 `git show HEAD` 一致)。
|
||||
- **例外条件**:标签功能未启用(`tb_resource_tag` 为空)时无实际影响;不影响钱包余额、有效套餐使用记录、累计充值字段、资产归属与个人客户—资产绑定,也不影响无标签资产的换货完成。
|
||||
- **修复方式**:为该 `OnConflict` 声明部分索引谓词(或调整索引),须另立 OpenSpec Change;当前按维护者决策暂不修复,仅登记待办。
|
||||
- **Owner**:数据负责人
|
||||
- **最后验证日期**:2026-09-14
|
||||
- **更新触发条件**:标签功能启用、换货迁移项变更或该缺陷修复
|
||||
|
||||
## KNOWN-ISSUE-002
|
||||
- **状态**:已登记的边界,非缺陷(当前不阻塞归档)
|
||||
- **适用范围**:卡轮询优先队列的三处实现边界:①「资产无有效套餐」的触发范围;②认领接缝探测失败的失败方向;③未被接缝使用的批量探测方法
|
||||
- **问题**:
|
||||
- 边界一(无有效套餐触发范围):`no_valid_package` 只在既有判定点——`internal/service/iot_card/stop_resume_service.go:354` 的「条件B:无有效套餐」被加入停机原因列表时——才会追加触发(调用点 `internal/service/iot_card/stop_resume_service.go:123`,实现 `:209`)。已经处于停机状态的卡走 `EvaluateAndAct` 的离线分支,不再重新判定「无有效套餐」,因此停机卡不会被该场景加急。
|
||||
- 边界二(认领接缝探测失败的失败方向):认领接缝的 `FindActive` 探测失败采用 fail-closed——四个轮询 Handler 按既有失败分支记为轮询失败并按既有间隔延后(`internal/task/polling_priority_claim.go:56` 返回错误;`internal/task/polling_realname_handler.go:47`、`internal/task/polling_carddata_handler.go:55`、`internal/task/polling_cardstatus_handler.go:56`、`internal/task/polling_package_handler.go:59` 各对应分支;carddata 连同放弃当轮流量同步)。
|
||||
- 边界三(未被使用的批量探测方法):`internal/store/postgres/polling_priority_item_store.go:148-149` 的 `ListActiveByCards` 当前没有任何调用方(`grep -rn "ListActiveByCards" internal/` 只命中定义本身)。
|
||||
- **理由**:边界一——行为契约的 WHEN 以「普通套餐轮询判定资产无有效套餐」为前提(该 Scenario 见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/specs/priority-polling-queue/spec.md:31`),停机卡不发生该判定,故当前实现不违反契约;把它扩展到停机卡属于新的产品口径。边界二——fail-open 会在数据库抖动时让同一卡同一任务类型同时出现两次上游调用,破坏本能力「至多一次上游调用」的 MUST,失败方向必须偏保守。边界三——`tasks.md` 1.4 的执行契约要求存储层提供「按卡批量活动项查询」,先交付后使用。
|
||||
- **证据**:见上述各边界的 `文件:行`;边界三的调用方检索当时结果为空(仅定义处命中)。
|
||||
- **例外条件**:边界一——若产品要求停机卡也因「无有效套餐」获得加急,须先由产品确认收窄该 Scenario 的措辞,再以独立 Change 扩展触发范围。边界二——数据库连接正常时不会触发该失败方向,普通轮询的并发上限与无限重入队策略不受影响。
|
||||
- **修复方式**:边界一——产品确认后另立 Change;在此之前不改判定点。边界二——不修复(刻意设计);如未来引入可信的「探测能力不可用」信号,可在该信号下单独放行并接受重复调用风险,须重新评审。边界三——若认领接缝改为批量探测则启用;否则按归档后的清理流程删除,不长期保留未使用的方法。
|
||||
- **Owner**:异步任务负责人
|
||||
- **最后验证日期**:2026-09-16
|
||||
- **更新触发条件**:触发范围口径变化、认领策略变化、或接缝改为批量探测
|
||||
|
||||
@@ -9,20 +9,37 @@
|
||||
|
||||
## 当前实际使用范围
|
||||
|
||||
系统仅使用微信预下单 `POST <ApiURL>/wxPreCreate` 与支付通知。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);未发现退款、撤销或查单能力。
|
||||
系统使用微信预下单 `POST <ApiURL>/wxPreCreate`、主扫统一下单 `POST <ApiURL>/preCreate` 与支付通知;代理在线充值恢复流程另有本地 `CommonQuery` 调用,用于主动查询支付订单状态。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序),主扫下单的订单类型为 `WECHAT` 或 `ALIPAY`;本地 `CommonQuery` 代码保持现有请求格式、签名算法、状态映射和恢复语义不变。该源码事实仅表示本地候选实现及后续双读配置来源改造接缝,不证明真实富友渠道契约,也不证明验签、状态解释或恢复核验已通过。
|
||||
|
||||
## 原路退款
|
||||
|
||||
退款申请 `POST <ApiURL>/commonRefund`,必填 `version`、`ins_cd`、`mchnt_cd`、`term_id`、`mchnt_order_no`、`random_str`、`sign`、`order_type`、`refund_order_no`、`total_amt`、`refund_amt`;选填 `operator_id`、`reserved_fy_term_id`、`reserved_origi_dt`、`reserved_addn_inf`、`reserved_refund_desc`。响应 `result_code=000000` 表示渠道受理成功,此时取 `refund_id`(富友退款流水号)、`transaction_id`、`reserved_refund_amt`(退款金额,分)、`reserved_fy_settle_dt`(清算日期)。`reserved` 开头字段随报文发出但不参与签名。
|
||||
|
||||
退款查询 `POST <ApiURL>/refundQuery`,入参为 `refund_order_no`;响应 `trans_stat` 取值为 `SUCCESS`(退款成功)或 `PAYERROR`(退款失败),未返回该字段表示仍在办理中。
|
||||
|
||||
全局约束:`mchnt_order_no` 与 `refund_order_no` 均为全局永久唯一,重复提交会被直接拒绝;商户退款单号格式为「机构码(4 位) + 日期(yyyyMMdd) + 随机段(8–18 位字母数字)」,本系统按该规则生成,三渠道共用同一生成器;接口支持全额退款与多次部分退款。
|
||||
|
||||
原交易日期决定可退时限:不传 `reserved_origi_dt` 仅支持 30 天内的原交易,传了可退 360 天内的原交易。本系统始终回传原支付成功时间,因此按 360 天判定可退性,超出该时限的申请在选择退款方式阶段即禁用原路。
|
||||
|
||||
退款查询接口只支持查询 3 日内的退款交易。超出该窗口且结果仍未知时,系统保留原路退款处理中状态、标记审批异常并转人工核对,绝不重复发起退款。
|
||||
|
||||
## 配置、认证与传输
|
||||
|
||||
运行配置包含 API 地址、机构号、商户号、终端号、RSA 私钥、公钥及通知地址。请求先生成 XML,再转换为 GBK,并对请求参数做双重 URL 编码;请求和响应使用 RSA 签名/验签。除 `reserved` 外的请求字段即使为空也参与 XML 与签名。
|
||||
|
||||
关键请求字段包括 `mchnt_order_no`、`order_amt`(分)、`txn_begin_ts`、`notify_url`、`trade_type`、`sub_openid`、`sub_appid`。响应 `result_code=000000` 表示渠道成功,并返回富友流水号和 JSAPI 支付字段。
|
||||
关键支付请求字段包括 `mchnt_order_no`、`order_amt`(分)、`txn_begin_ts`、`notify_url`、`trade_type`、`sub_openid`、`sub_appid`。响应 `result_code=000000` 表示渠道成功,并返回富友流水号和 JSAPI 支付字段。
|
||||
|
||||
## 幂等、失败与重试
|
||||
|
||||
`mchnt_order_no` 是渠道业务幂等键;通知处理还需校验签名、商户订单号、金额及当前支付状态。非 `000000`、验签失败、解码失败或字段不匹配均不得推进支付状态。客户端未实现自动重试,调用方只有在可确认沿用同一商户订单号时才可重试。
|
||||
`mchnt_order_no` 是渠道业务幂等键,退款侧对应 `refund_order_no`;通知处理还需校验签名、商户订单号、金额及当前支付状态。非 `000000`、验签失败、解码失败或字段不匹配均不得推进支付状态。客户端未实现自动重试,调用方只有在可确认沿用同一商户订单号时才可重试。
|
||||
|
||||
退款调用以冻结在审批尝试记录上的渠道退款请求号作为幂等标识:同一次尝试的渠道重试复用同一请求号,重提会生成新请求号。结果未知时只由查询恢复回填,不得重复发起资金动作。
|
||||
|
||||
## 安全与验证
|
||||
|
||||
RSA 私钥、公钥、机构和商户凭证不得进入文档或普通日志;通知日志必须脱敏。可复现静态证据:`pkg/fuiou/client.go`、`pkg/fuiou/wxprecreate.go`、`pkg/fuiou/types.go`、`internal/handler/callback/payment.go`。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符和重复通知;本次不调用真实渠道。
|
||||
RSA 私钥、公钥、机构和商户凭证不得进入文档或普通日志;通知日志必须脱敏。可复现静态证据:`pkg/fuiou/client.go`、`pkg/fuiou/wxprecreate.go`、`pkg/fuiou/scan.go`、`pkg/fuiou/refund.go`、`pkg/fuiou/types.go`、`internal/handler/callback/payment.go`、`internal/infrastructure/payment/fuiou_scan.go`、`internal/infrastructure/payment/refund_adapter.go`。
|
||||
|
||||
本文按官方契约记录退款接口,**未做真实渠道实测**:未实测只作记录,不作为阻塞、未完成任务或上线前置;真实渠道可退款性由维护者后续手工验证。真实验收需使用隔离商户验证两种交易类型、签名失败、金额不符、重复通知、退款受理与退款查询;本次不调用真实渠道。
|
||||
|
||||
端点、编码、签名字段、成功码、退款字段或通知语义变化时更新本文。
|
||||
|
||||
端点、编码、签名字段、成功码或通知语义变化时更新本文。
|
||||
|
||||
@@ -4,13 +4,27 @@
|
||||
|
||||
- Owner:IoT Gateway 适配维护人
|
||||
- 实现:`internal/gateway/`
|
||||
- 核验日期:2026-08-07
|
||||
- 证据:`internal/gateway/client.go`、`crypto.go`、`card_status.go`、`flow_card.go`、`device.go`
|
||||
- 核验日期:2026-09-11
|
||||
- 证据:`internal/gateway/client.go`、`crypto.go`、`card_status.go`、`flow_card.go`、`device.go`、`payment_voucher.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` 再按具体能力解码。
|
||||
|
||||
@@ -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 |
|
||||
| AUG26-017 | 代理自充收款方式 | PRD-08-021、PRD-08-013(预存款审批字段) |
|
||||
|
||||
## 1. 已确认的领域语言
|
||||
|
||||
@@ -196,6 +196,8 @@
|
||||
|
||||
优先轮询是与现有普通轮询并行的高优先级调度队列,而不是一套新的轮询业务逻辑。资产可同时存在于普通轮询和优先轮询,进入优先队列不移除、暂停或改变普通轮询;优先队列仅使该资产额外优先执行同一套既有轮询内容、外部调用及状态同步。一次优先轮询执行成功后任务退出优先队列,资产仍按普通轮询继续运行;外部调用失败或超时按既有失败重试。第一版纳入无有效套餐、套餐过期续购、流量用完购买加油包、人工触发和普通轮询异常补偿五类场景。同一资产有未完成优先任务时,后续触发合并到该任务,追加触发次数、最近时间及来源,不重复调用同一轮普通轮询。优先队列复用普通轮询既有并发上限、失败重试和外部调用保护,仅调度顺序优先,不另建参数配置。
|
||||
|
||||
> **AUG26-016 实施口径标注(2026-09-16)**:上文本条中的「普通轮询异常补偿」**本版不实现**。现状该场景没有任何可判定条件(轮询失败按配置间隔无限重入队,无失败上限、无补偿入口),实现等于自造判据;且渠道故障时全量失败卡涌入优先通道、提示通道不受分片背压约束,风险不对称。本版只实现其余四类场景,该场景登记为后续独立 Change,需产品确认后另行实施(依据见 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/design.md` 的「后续候选」)。本条其余文字与其余场景的确认内容不变。
|
||||
|
||||
## 2.16 已确认的导出、时间筛选与代理自充收款方式基线
|
||||
|
||||
临期列表、佣金明细和套餐流量达量预警均复用现有异步导出任务,创建时冻结筛选条件、操作者及可见店铺范围。临期导出一行对应一项资产,取其当前生效主套餐最终到期时间和剩余天数;加油包不单独成行。流量达量预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组按导出执行时当前归属补充。佣金明细的入账后金额冻结每次佣金钱包变动后的实际余额,回溯记录可为负。
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
# AUG26-014 统一时间筛选与临期导出(add-export-time-filter-standards)范围、契约与前端同步清单
|
||||
|
||||
本文件记录 OpenSpec Change `add-export-time-filter-standards` 的实施范围、端点改造对照、DTO 与 OpenAPI 变更清单以及前端同步清单。
|
||||
行为契约以 [`../../openspec/specs/export-time-filter/spec.md`](../../openspec/specs/export-time-filter/spec.md) 与变更产物
|
||||
[`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/) 为准;本文件是实施记录与同步清单,不是行为契约。
|
||||
|
||||
本文的实测证据节按 T7 分工由独立验证 worker 追加;本文不写未经实跑的结论。
|
||||
|
||||
## 1. 范围与依据
|
||||
|
||||
- 迭代编号:`AUG26-014`。需求依据:`docs/product/2026-08-迭代-PRD-讨论稿.md` §2.16(时间筛选与导出快照两段);导出列定义依据 `111.md` §18。
|
||||
- 执行契约:变更产物 `proposal.md`、`design.md`、`tasks.md` 与 `specs/export-time-filter/spec.md`;冲突时以 spec delta 为准。
|
||||
- 统一契约:被覆盖的列表与导出入口使用可选的 `start_time`/`end_time`,取值 MUST 为带显式时区的 RFC3339 秒级时间,解析结果归一为 UTC 瞬时,筛选为闭区间含两端;任一端可缺省,未传与空串同义;开始晚于结束拒绝。
|
||||
- 共享实现落点:`pkg/utils/time_range.go`(`ParseTimeRange`),创建期 handler 与执行期 `internal/exporter` 共用同一份实现。
|
||||
- 本次边界(不做):不新增迁移、不改既有迁移、不改数据库 Schema、不新增运行时开关、不删除全局宽松解析器(`cmd/api/main.go` 的 `registerTimeParserCompat`)、不新增 `*_test.go`、不重构未列入本次的端点与其导出、不做跨度上限、不改任何导出的记录粒度/列定义/余额口径、不修佣金统计类接口的裸串直传。
|
||||
- 未列入本次的端点保持既有参数名、既有格式接受范围与既有筛选结果,逐项登记见 `design.md`「已知差异登记(本次不修)」;其中达量预警列表与导出本期一律不改(依据端点表「已符合」行)。
|
||||
- 数据承载:无 Schema 变更,冻结值写入既有 `tb_export_task.query_json` / `scope_shop_ids` 与既有操作者、文件、行数、`error_message` 列,详见第 5 节。
|
||||
|
||||
## 2. 端点改造清单
|
||||
|
||||
### 2.1 列表端点
|
||||
|
||||
| 端点 | 改造前参数与格式 | 改造后 | 筛选依据业务时间 |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /api/admin/iot-cards/import-tasks` | `start_time`/`end_time`(`*time.Time`,经全局宽松解析:兼容 RFC3339、无时区、date-only) | 参数名不变,`string` + 严格解析 | 导入任务创建时间 |
|
||||
| `GET /api/admin/devices/import/tasks` | 同上 | 参数名不变,`string` + 严格解析 | 导入任务创建时间 |
|
||||
| `GET /api/admin/export-tasks` | 同上 | 参数名不变,`string` + 严格解析 | 导出任务创建时间 |
|
||||
| `GET /api/admin/orders` | 同上 | 参数名不变,`string` + 严格解析 | 订单创建时间 |
|
||||
| `GET /api/admin/exchanges` | `created_at_start`/`created_at_end`(`*time.Time`,宽松) | 改名 `start_time`/`end_time`,`string` + 严格解析;旧参数名不再生效 | 换货单创建时间 |
|
||||
| `GET /api/admin/asset-allocation-records` | `created_at_start`/`created_at_end`(`*time.Time`,宽松) | 改名 `start_time`/`end_time`,`string` + 严格解析 | 分配记录创建时间 |
|
||||
| `GET /api/admin/agent-recharges` | `start_date`/`end_date`(无格式约束 `string`,按日拼接当日 `00:00:00`/`23:59:59`) | 改名 `start_time`/`end_time`,`string` + 严格解析;删除按日补齐 | 代理充值记录创建时间 |
|
||||
| `GET /api/admin/shops/{shop_id}/commission-records` | 无时间参数 | 新增 `start_time`/`end_time`,闭区间 | 佣金明细创建时间(原佣金与回溯两条分支各自的 `created_at`) |
|
||||
| `GET /api/admin/commission/withdrawal-requests` | `start_time`/`end_time`(无时区 `2006-01-02 15:04:05`,解析失败静默忽略该条件) | 参数名不变,严格解析;删除静默跳过,非法参数一律 1001 | 提现申请创建时间 |
|
||||
| `GET /api/admin/shops/{shop_id}/withdrawal-requests` | 同上 | 同上 | 提现申请创建时间 |
|
||||
| `GET /api/admin/authorizations` | `start_time`/`end_time`(date-only `2006-01-02`;起始闭、结束开且结束日加一天) | 参数名不变,严格解析;改为闭区间含两端 | 授权发生时间 |
|
||||
| `GET /api/admin/expiring-assets` | `expires_from`/`expires_to`(date-only,按上海自然日比较) | 改名 `start_time`/`end_time`,按最终到期**时刻**闭区间比较;保留既有剩余天数上下限筛选 | 当前生效主套餐最终到期时刻 |
|
||||
|
||||
### 2.2 导出场景
|
||||
|
||||
| 导出场景 / 入口 | 改造前筛选键 | 改造后 | 口径来源 |
|
||||
| --- | --- | --- | --- |
|
||||
| `exchange`(经 `POST /api/admin/export-tasks`) | `created_at_start`/`created_at_end`(宽松多格式解析) | 改名 `start_time`/`end_time`,共享严格解析器;旧键在创建期被拒绝 | 换货单创建时间 |
|
||||
| `agent_recharge`(同上) | `start_date`/`end_date`(宽松、按日补齐当日末秒) | 改名 `start_time`/`end_time`,共享严格解析器;旧键在创建期被拒绝 | 代理充值记录创建时间 |
|
||||
| `order`(同上) | `start_time`/`end_time`(宽松) | 参数名不变,共享严格解析器 | 订单创建时间 |
|
||||
| `commission_record`(同上) | 无时间筛选 | 新增 `start_time`/`end_time`,两条分支各自的创建时间列闭区间 | 佣金明细创建时间 |
|
||||
| 临期资产(`POST /api/admin/expiring-assets/export`) | 场景此前不存在 | 本次新建场景 `expiring_asset` 与受控入口 | 当前生效主套餐最终到期时刻 |
|
||||
| `package_traffic_alert`(`POST /api/admin/package-traffic-alerts/export`) | `start_time`/`end_time`(RFC3339 秒级,创建时冻结) | 入口参数名与 `*time.Time` 契约、列表 `GET /api/admin/package-traffic-alerts` 行为、列定义与触发快照口径**保持不动**;**执行期改为只按冻结值严格解析**(冻结值非法则任务落失败,依据 Requirement 4) | 预警触发时间 |
|
||||
|
||||
### 2.3 未改造项确认(逐一核对未改动)
|
||||
|
||||
- 端点保持原参数名与宽松解析、本期未新增时间参数:资产钱包流水、客户钱包流水、设备资产列表(`created_at_start`/`created_at_end`)、代理主钱包流水(`start_date`/`end_date`)、手机号—资产关联列表、佣金统计类接口(`commission-stats`、`commission-daily-stats`)、审计类列表、员工代收款账单、轮询告警历史、企业卡与企业设备授权列表、IoT 卡资产列表。
|
||||
- 导出场景入口保持原筛选键与宽松接受面:`device`、`iot_card`、`package`、`agent_wallet_transaction`、`refund`;达量预警导出(`package_traffic_alert`)入口与列表同样保持宽松接受面,仅执行期改为严格解析(见 2.2 行与 Requirement 4)。
|
||||
- 全局宽松解析器 `cmd/api/main.go` 的 `registerTimeParserCompat` 未改动,未列入端点继续由其服务。
|
||||
- 受影响列表端点的旧参数名现已显式拒绝(`created_at_start`/`created_at_end`、`start_date`/`end_date`、`expires_from`/`expires_to` 出现且非空即返回 1001,空值仍按未传处理),因此这些名字不会以「被忽略」的形式留在受影响端点。
|
||||
- 生成文档核对:`docs/admin-openapi.yaml` 中 `created_at_start`、`start_date` 仅残留于未改造端点与未改造的请求 DTO(`DtoBatchSetDeviceSeriesBindngRequest`、`/api/admin/devices`、`/api/admin/phone-asset-associations`、`commission-daily-stats`、主钱包流水与 `/api/open/v1/wallet/transactions`);`expires_from`/`expires_to` 在代码中只作为 `internal/handler/admin/asset.go` 的旧参数拒绝清单字符串出现(并作为改造前参数名记录在本次契约与文档中),生成文档中已完全不再出现;13 个受影响端点均只暴露 `start_time`/`end_time`。
|
||||
|
||||
## 3. DTO 与 OpenAPI 变更清单
|
||||
|
||||
统一说明:受影响端点的时间字段 Go 类型一律由 `*time.Time`(或裸 `string`)改为 `string`,由 handler 调用 `pkg/utils.ParseTimeRange` 严格解析;`*time.Time` 类型会触发全局宽松解析器,因此必须字符串化才能给出指明字段的中文错误消息。错误语义:格式非法与顺序错误统一返回既有参数非法错误码 `1001`,消息固定为
|
||||
`<字段> 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00` 与 `start_time 不能晚于 end_time`,不新增结构化字段级错误载荷。
|
||||
|
||||
| DTO | 字段(json / query tag) | 类型变更 | description 变更 |
|
||||
| --- | --- | --- | --- |
|
||||
| `dto.ListImportTaskRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 改为「带时区的 RFC3339 秒级时间,闭区间含该时刻」 |
|
||||
| `dto.ListDeviceImportTaskRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 同上 |
|
||||
| `dto.ListExportTaskRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 同上 |
|
||||
| `dto.OrderListRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 同上 |
|
||||
| `dto.ExchangeListRequest` | `created_at_start`/`created_at_end` → `start_time`/`end_time` | `*time.Time` → `string`(参数改名) | 同上 |
|
||||
| `dto.ListAssetAllocationRecordRequest` | `created_at_start`/`created_at_end` → `start_time`/`end_time` | `*time.Time` → `string`(参数改名) | 同上 |
|
||||
| `dto.AgentRechargeListRequest` | `start_date`/`end_date` → `start_time`/`end_time` | `string` → `string`(参数改名,取值受限) | 由「YYYY-MM-DD」改为「带时区的 RFC3339 秒级时间」 |
|
||||
| `dto.ShopCommissionRecordListReq` | 新增 `start_time` / `end_time` | 新增 `string` | 新增:佣金明细创建时间闭区间 |
|
||||
| `dto.WithdrawalRequestListReq` | `start_time` / `end_time` | `string`(取值受限) | 由「2006-01-02 15:04:05」改为「带时区的 RFC3339 秒级时间」 |
|
||||
| `dto.ShopWithdrawalRequestListReq` | `start_time` / `end_time` | `string`(取值受限) | 同上 |
|
||||
| `dto.AuthorizationListReq` | `start_time` / `end_time` | `string`(取值受限) | 由「格式:2006-01-02」改为「带时区的 RFC3339 秒级时间,闭区间含该时刻」 |
|
||||
| `dto.ExpiringAssetListRequest` | `expires_from`/`expires_to` → `start_time`/`end_time` | `string` → `string`(参数改名,取值受限) | 由「上海自然日 YYYY-MM-DD」改为「按最终到期时刻的闭区间」 |
|
||||
| `dto.ExportExpiringAssetRequest` | 新增(`format`、`asset_type`、`keyword`、`shop_id`、`package_id`、`days_min`、`days_max`、`start_time`、`end_time`) | 新增 `string` 时间字段 | 新增受控导出入口请求体 |
|
||||
| `dto.CreateExportTaskRequest` | `scene` 白名单与 `query` 说明 | 不变 | 白名单新增 `expiring_asset`;说明补充时间筛选键与取值要求 |
|
||||
| `dto.ListExportTaskRequest` | `scene` 白名单 | 不变 | 白名单新增 `expiring_asset` |
|
||||
|
||||
OpenAPI:`docs/admin-openapi.yaml` 已重新生成(`go run cmd/gendocs/main.go`),新增 `POST /api/admin/expiring-assets/export`,13 个受影响端点的时间参数已按上表更新。
|
||||
|
||||
## 4. 前端同步清单
|
||||
|
||||
无兼容期、无运行时开关:前端 MUST 与后端同迭代上线。
|
||||
|
||||
### 4.1 改参数名的端点与导出
|
||||
|
||||
| 位置 | 旧参数 | 新参数 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/admin/exchanges` 与换货导出(场景 `exchange`) | `created_at_start`、`created_at_end` | `start_time`、`end_time` |
|
||||
| `GET /api/admin/asset-allocation-records` | `created_at_start`、`created_at_end` | `start_time`、`end_time` |
|
||||
| `GET /api/admin/agent-recharges` 与代理充值导出(场景 `agent_recharge`) | `start_date`、`end_date` | `start_time`、`end_time` |
|
||||
| `GET /api/admin/expiring-assets` | `expires_from`、`expires_to` | `start_time`、`end_time` |
|
||||
|
||||
旧参数名在受影响端点不再生效(换货导出与代理充值导出的旧筛选键在创建期直接返回 1001)。
|
||||
|
||||
### 4.2 改传值格式的端点与导出
|
||||
|
||||
格式要求:带显式时区的 RFC3339 秒级时间,UTC 偏移写作 `Z` 或 `+08:00`;整日区间用 `T00:00:00+08:00` 与 `T23:59:59+08:00` 表达。拒绝 date-only、空格分隔、无时区、小数秒(含 `.000`)、`±hhmm` 与未补零写法。
|
||||
|
||||
| 位置 | 旧传值示例 | 新传值示例 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/admin/iot-cards/import-tasks` | `2026-09-01` 或 `2026-09-01 00:00:00` | `2026-09-01T00:00:00+08:00` |
|
||||
| `GET /api/admin/devices/import/tasks` | 同上 | 同上 |
|
||||
| `GET /api/admin/export-tasks` | 同上 | 同上 |
|
||||
| `GET /api/admin/orders` 与订单导出(场景 `order`) | 同上 | 同上 |
|
||||
| `GET /api/admin/authorizations` | `2026-09-01`(结束日按次日开区间) | `2026-09-01T00:00:00+08:00`(结束时间为闭区间,整日需显式写 `23:59:59`) |
|
||||
| `GET /api/admin/commission/withdrawal-requests` | `2026-09-01 00:00:00` | `2026-09-01T00:00:00+08:00`;非法值由「忽略该条件返回全量」改为 1001 |
|
||||
| `GET /api/admin/shops/{shop_id}/withdrawal-requests` | 同上 | 同上 |
|
||||
| `GET /api/admin/expiring-assets` 与临期导出 | `2026-09-01`(上海自然日) | `2026-09-01T00:00:00+08:00`(按最终到期时刻闭区间) |
|
||||
| 换货导出、代理充值导出 | 旧格式同上 | 同上 |
|
||||
|
||||
注意:受影响端点的时间参数不再接受无时区写法,前端不得沿用「按浏览器本地时区补 00:00:00」的旧拼接方式,必须显式带偏移。受影响列表端点(换货、分配记录、代理充值、临期列表)携带旧参数名(`created_at_start`/`created_at_end`、`start_date`/`end_date`、`expires_from`/`expires_to`)且非空时现在返回 1001(消息:`<旧字段名> 已废弃,请改用 start_time 与 end_time`),前端必须同迭代改名,不能依赖旧参数被忽略。
|
||||
|
||||
**达量预警导出经通用入口的传值收紧(`POST /api/admin/export-tasks`,`scene=package_traffic_alert`)**
|
||||
|
||||
- 位置:通用导出入口 `POST /api/admin/export-tasks`,`scene=package_traffic_alert`(达量预警导出场景)。
|
||||
- 变更:`query.filters.start_time` / `query.filters.end_time` 现要求带显式时区的 RFC3339 秒级时间(与本节上表同一格式要求);宽松写法(date-only、空格分隔、无时区、小数秒含 `.000`、`±hhmm`、未补零、前后空白)由变更前的「创建成功 + 执行期静默忽略该条件(等于不加该条件,可能导出全量)」改为**创建期直接返回 1001 参数非法**,消息与其他受影响端点同一套固定中文文案:`start_time 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00`(`end_time` 同构),开始晚于结束为 `start_time 不能晚于 end_time`。
|
||||
- 边界:达量预警的**专用入口** `POST /api/admin/package-traffic-alerts/export` 与其列表 `GET /api/admin/package-traffic-alerts` 的参数名、类型与宽松接受面**本次不变**(仍为 `*time.Time` + 全局兼容解析器,创建期归一为 UTC RFC3339 秒级后冻结);只有**执行期**改为只按冻结值严格解析,冻结值非法时任务落失败并写固定摘要 `导出筛选的时间边界非法,任务已终止`,不放行全量。
|
||||
- 依据:变更 delta 与主 Spec 的「三类异步导出的创建期冻结与不扩大范围」需求;`design.md` 已知差异登记第 5 条(无时区列那条为第 6 条、临期导出取舍为第 7 条)。
|
||||
|
||||
### 4.3 新增参数的端点与导出
|
||||
|
||||
| 位置 | 新增参数 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/admin/shops/{shop_id}/commission-records` | `start_time`、`end_time` | 按佣金明细创建时间闭区间,覆盖原佣金与回溯两条分支 |
|
||||
| 佣金明细导出(场景 `commission_record`) | `query.filters.start_time`、`query.filters.end_time` | 同上;经 `POST /api/admin/export-tasks` 提交 |
|
||||
| 临期导出 `POST /api/admin/expiring-assets/export` | 整个入口为新增 | 请求体:`format`(必填)、`asset_type`、`keyword`、`shop_id`、`package_id`、`days_min`、`days_max`、`start_time`、`end_time` |
|
||||
|
||||
## 5. 无 Schema 变更确认(T6.6)
|
||||
|
||||
- 本变更未新增迁移文件、未修改任何既有迁移、未改变数据库 Schema,因此迁移 `up/down/up` 为**不适用**而非跳过。
|
||||
- 冻结值承载于既有列:`tb_export_task.query_json`(jsonb,写入规范化后的 UTC RFC3339 秒级字符串)、`scope_shop_ids`(jsonb)、操作者四元组与 `creator`/`updater`、`file_key`/`file_size`/`total_rows`/`error_message`。表头冻结沿用既有 `query_json.resolved_headers`。
|
||||
- 失败摘要沿用既有 `error_message` 通道,新增固定摘要文案 `导出筛选的时间边界非法,任务已终止`。
|
||||
- 该严格解析失败即落失败的规则同样适用于达量预警导出(场景 `package_traffic_alert`):其入口与列表的宽松接受面不变,执行期冻结值非法时同样落失败,不放行全量。
|
||||
|
||||
## 6. 实测证据(T7 实跑)
|
||||
|
||||
本节由独立验证 worker 实跑写入,全部结论均有命令与原始输出支撑;未实跑项在 §6.5 逐条列明。
|
||||
|
||||
### 6.0 环境、工作区指纹与执行方式
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| HEAD | `e8ab1f471e9cbf27acd89ccdcfb5073b4fc7baa1`(含维护者提交 `398a5e42`「fix(路由): 修正套餐真流量预警与资产自动续费的超管/平台 gate 作用域」与其后的 `e8ab1f47`;本 Change 的实现改动仍在未提交工作区) |
|
||||
| 工作区内容指纹(本批) | `git diff` 与全部未跟踪文件内容的合并 SHA1:`37d7d69021f264be6f04e31062a18e8aa695c8a0` |
|
||||
| 工作区内容指纹(上一批) | `51c619f8a3791e5c6e3de8878af3be5105dd73f`(`NAMES_FP=d1aa8d512dfb5d43e17b2ded30ae761aaca2578c`,63 项) |
|
||||
| 代码状态 | 未提交工作区(本 Change 实现已落盘);维护者的路由门禁修复以提交形式位于 HEAD |
|
||||
| PostgreSQL | `junhong_cmp_test`(参数取自 `.env`),库内诊断只读走 dbhub MCP;写入一律经临时 Go 工具用仓库自身 GORM/配置执行(遵循 ENG-DB-002) |
|
||||
| Redis | `cxd.whcxd.cn:16299` **DB 7**(`.env.local` 默认值,与 `scripts/run-local.sh` 一致) |
|
||||
| API | `go run ./cmd/api` → `http://127.0.0.1:3000`,就绪日志「服务器已启动」 |
|
||||
| Worker | `go run ./cmd/worker`,就绪日志「Worker 服务器启动中」 |
|
||||
| 鉴权 | 超管:`POST /api/auth/login`(口令取自 README,不回显)。代理视角:**第二批起改用 fixture 代理账号的真实登录**(`tb_account` 自建 `AUG26014AGENT*`,user_type=3,口令为 bcrypt 哈希由工具写入,不回显);手工注入 DB 7 的等价令牌仅用于第一批的根因定位对照,并已在 §6.6 明确其 `user_type` 来源 |
|
||||
|
||||
**与 ENG-TEST-001 的两处偏离(均已在执行中记录):**
|
||||
|
||||
1. **Redis 用 DB 7 而非 DB 6**:DB 6 是 `Iteration/8-11` 测试部署的共享队列,本地 worker 若连 DB 6 会抢占真实测试任务并消费掉它们;`.env.local` 本身即配置 DB 7,与 `scripts/run-local.sh` 的实际行为一致。导出链路的冻结、分片、合并、上传语义与队列 DB 号无关,因此该偏离不影响本次结论。
|
||||
2. **启动命令用 `go run ./cmd/api` 而非 `./scripts/run-local.sh api`**:`scripts/run-local.sh` 执行的是 `go run cmd/api/main.go`(单文件),而 `generateOpenAPIDocs` 定义在同包的另一文件 `cmd/api/docs.go`,单文件编译必然报 `cmd/api/main.go:101:2: undefined: generateOpenAPIDocs` 并退出。这是**既有仓库脚本/README 缺陷**(`scripts/run-local.sh`、`cmd/api/main.go`、`cmd/api/docs.go` 均不在本 Change 改动范围内),本 Change 未引入也未加重;改用包路径 `go run ./cmd/api` 后正常启动。建议另行登记修复。
|
||||
|
||||
### 6.1 逐项结果汇总
|
||||
|
||||
| # | tasks.md 项 | 断言数 | 结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 7.11/7.12 门禁命令(gofmt/build/gendocs/openspec/doctor/context-health) | 7 | **PASS 7 / FAIL 0** |
|
||||
| 2 | 7.1 接受集合与拒绝集合逐条实跑 | 20 | **PASS 20 / FAIL 0** |
|
||||
| 3 | 7.2 区间边界(仅一端/两端/两端缺省/空串/开始等于结束/空结果集) | 5 + 6 | **PASS 11 / FAIL 0** |
|
||||
| 4 | 7.3 顺序校验(开始晚于结束) | 1 | **PASS 1 / FAIL 0** |
|
||||
| 5 | 7.4 未改造端点保持既有宽松行为 | 4 | **PASS 4 / FAIL 0** |
|
||||
| 6 | 7.5 提现记录非法参数不再返回全量 | 5 | **PASS 5 / FAIL 0** |
|
||||
| 7 | 7.5 授权记录闭区间含两端 | 3 | **PASS 3 / FAIL 0** |
|
||||
| 8 | 7.6 佣金明细列表与其导出行集一致 | 2 | **PASS 2 / FAIL 0** |
|
||||
| 9 | 7.6/7.7 临期列表与其导出一致、表头、加油包不单独成行 | 5 | **PASS 5 / FAIL 0** |
|
||||
| 10 | 受影响端点旧参数名拒绝 + 空值按未传(F1 修复后重跑) | 8 | **PASS 8 / FAIL 0** |
|
||||
| 11 | 7.9 遗留旧格式冻结任务安全失败 | 1 | **PASS 1 / FAIL 0** |
|
||||
| 12 | 7.7 达量预警导出回归(列表宽松、导出严格、表头逐字) | 5 | **PASS 5 / FAIL 0** |
|
||||
| 13 | 7.10 佣金明细导出列定义与金额/余额口径回归 | 1 | **PASS 1 / FAIL 0** |
|
||||
| 14 | 7.7 创建后归属变化不扩大数据集(冻结 scope 执行期生效) | 3 | **PASS 3 / FAIL 0** |
|
||||
| 15 | 7.8 代理账号经 HTTP 访问临期列表/导出(第一批:修复前 403/1005) | 2 | 第一批 **FAIL**;根因定位为既有路由门禁作用域缺陷,已由维护者提交 `398a5e42` 修复 → 第二批重跑 **PASS**(见第 16 行) |
|
||||
| 16 | 7.8 代理 HTTP 全链路(真实登录):可见范围过滤 / 导出创建与执行 / 空可见范围被拒 / 越权与不存在统一不可见 | 6 | **PASS 6 / FAIL 0** |
|
||||
| 17 | 7.6 达量预警列表与导出同筛选行集一致(缺口补齐) | 1 | **PASS 1 / FAIL 0** |
|
||||
| — | 合计 | **85 PASS / 0 FAIL** | |
|
||||
|
||||
### 6.2 关键原始输出
|
||||
|
||||
**(1)门禁命令**
|
||||
|
||||
> 下列 7 条在第二批(HEAD=`e8ab1f47`)已原样重跑,输出与本处记录逐字一致;T7 实现内容与第一批相同,仅有维护者的路由门禁修复提交进入 HEAD。
|
||||
|
||||
```
|
||||
$ gofmt -l <63 个改动/新增 .go 文件> # 无输出
|
||||
$ go build ./cmd/api ./cmd/worker # 退出码 0
|
||||
$ go run cmd/gendocs/main.go(连续两次) # cmp -s 一致:gendocs_idempotent=PASS
|
||||
$ openspec validate add-export-time-filter-standards --strict
|
||||
Change 'add-export-time-filter-standards' is valid # 退出码 0
|
||||
$ openspec validate --all
|
||||
Totals: 36 passed, 0 failed (36 items) # 退出码 0
|
||||
$ openspec doctor --json
|
||||
healthy= True status= [] # 退出码 0
|
||||
$ ./scripts/context-health.sh
|
||||
Context 健康检查通过 # 退出码 0
|
||||
```
|
||||
|
||||
**(2)接受集合与拒绝集合(`GET /api/admin/export-tasks`)**
|
||||
|
||||
接受(HTTP 200 / code 0):`2026-09-01T00:00:00Z`、`2026-09-01T00:00:00+08:00`、`2026-09-01T00:00:00-05:30`、同瞬时两种偏移组合、仅 `end_time`、空串两端、两端缺省。
|
||||
|
||||
拒绝(HTTP 400 / code 1001),消息逐条一致:
|
||||
|
||||
```
|
||||
start_time 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00
|
||||
```
|
||||
|
||||
| 拒绝输入 | 归类 |
|
||||
| --- | --- |
|
||||
| `2026-09-01T00:00:00.000Z`、`2026-09-01T00:00:00.5+08:00` | 小数秒 |
|
||||
| `2026-09-01T00:00:00` | 无时区 |
|
||||
| `2026-09-01` | date-only |
|
||||
| `2026-09-01 00:00:00` | 空格分隔 |
|
||||
| `2026-09-01T00:00:00+0800` | `±hhmm` 偏移 |
|
||||
| `2026-9-01T00:00:00Z` | 未补零日期 |
|
||||
| `2026-09-01T0:00:00Z` | 未补零时间 |
|
||||
| `2026-02-30T00:00:00+08:00` | 非法日历日期 |
|
||||
| `2026-09-01T00:00:00+24:00`、`+23:60`、`+00:60`、`-24:00` | 偏移越界(F3 收紧后) |
|
||||
| `␣2026-09-01T00:00:00+08:00␣`、纯空白串、`\t` | 空白包裹/纯空白(F3 收紧后) |
|
||||
|
||||
顺序校验:
|
||||
|
||||
```
|
||||
start_time=2026-09-02T00:00:00Z & end_time=2026-09-01T00:00:00Z
|
||||
→ HTTP 400 code=1001 msg=start_time 不能晚于 end_time
|
||||
```
|
||||
|
||||
**(3)区间边界(`GET /api/admin/export-tasks`)**
|
||||
|
||||
```
|
||||
两端缺省 total=55 | 空串两端 total=55(与缺省一致)
|
||||
仅 start total=3 | 仅 end total=52 | 空结果集 total=0(HTTP 200,不报错)
|
||||
```
|
||||
|
||||
**(4)受影响端点旧参数名拒绝(F1 修复后重跑)**
|
||||
|
||||
| 请求 | 结果 |
|
||||
| --- | --- |
|
||||
| `GET /api/admin/exchanges?created_at_start=2026-09-01T00:00:00+08:00` | 1001 `created_at_start 已废弃,请改用 start_time 与 end_time` |
|
||||
| `GET /api/admin/asset-allocation-records?created_at_start=...` | 1001 `created_at_start 已废弃,请改用 start_time 与 end_time` |
|
||||
| `GET /api/admin/agent-recharges?start_date=...` | 1001 `start_date 已废弃,请改用 start_time 与 end_time` |
|
||||
| `GET /api/admin/expiring-assets?expires_from=...` | 1001 `expires_from 已废弃,请改用 start_time 与 end_time` |
|
||||
| 上述四个端点的旧参数**空值**(`?start_date=`) | HTTP 200 / code 0(按未传处理) |
|
||||
|
||||
**(5)未改造端点保持宽松(HTTP 200 / code 0,total 与缺省一致)**
|
||||
|
||||
```
|
||||
GET /api/admin/devices?created_at_start=2026-01-01 total=6
|
||||
GET /api/admin/devices?created_at_start=2026-01-01T00:00:00 total=6
|
||||
GET /api/admin/shops/1/main-wallet/transactions?start_date=2026-01-01&end_date=2026-12-31 total=10
|
||||
GET /api/admin/audit/events total=2734186
|
||||
```
|
||||
|
||||
**(6)授权记录闭区间(`GET /api/admin/authorizations`,`iccid` 过滤隔离标记行)**
|
||||
|
||||
```
|
||||
start=2026-09-17T10:00:00+08:00 & end=2026-09-17T11:00:00+08:00 → total=2(恰好命中 02:00:00 与 03:00:00 两行)
|
||||
start=end=2026-09-17T10:00:00+08:00 → total=1(闭区间含 start 端)
|
||||
start=end=2026-09-17T16:00:00+08:00 → total=0(区间外)
|
||||
```
|
||||
|
||||
旧 date-only 值被拒:`GET /api/admin/authorizations?start_time=2026-09-17` → 1001。
|
||||
|
||||
**(7)提现记录非法参数不再返回全量**
|
||||
|
||||
```
|
||||
GET /api/admin/commission/withdrawal-requests(缺省) total=5
|
||||
GET /api/admin/commission/withdrawal-requests?start_time=<合法区间> total=2
|
||||
GET /api/admin/commission/withdrawal-requests?start_time=2026-09-17 00:00:00 → 1001(非全量)
|
||||
GET /api/admin/commission/withdrawal-requests?start_time=2026-09-17 → 1001
|
||||
GET /api/admin/shops/{shop_id}/withdrawal-requests?start_time=2026-09-17 00:00:00 → 1001
|
||||
GET /api/admin/shops/{shop_id}/withdrawal-requests(缺省) total=3
|
||||
```
|
||||
|
||||
**(8)佣金明细:列表 vs 导出(同筛选行集一致)**
|
||||
|
||||
筛选 `start_time=2026-09-17T10:00:00+08:00`、`end_time=2026-09-17T14:00:00+08:00`;fixture 含「start−1s / ==start / start+1s / start+2h / ==end / end+1s / 8 小时漂移探针」七行原佣金 + 一行区间内回溯。
|
||||
|
||||
```
|
||||
列表 GET /api/admin/shops/{shop_id}/commission-records total=5
|
||||
命中:02:00:00、02:00:01、04:00:00、06:00:00(原佣金)+ 03:00:00(回溯)
|
||||
排除:01:59:59(start−1s)、06:00:01(end+1s)、08:00:00(8 小时漂移探针)
|
||||
导出 POST /api/admin/export-tasks{scene=commission_record} status=3 total_rows=5
|
||||
```
|
||||
|
||||
导出产物(`task 61`,UTF-8 BOM CSV):
|
||||
|
||||
```
|
||||
记录来源,记录ID,代理店铺名称,关联订单号,资产标识,佣金来源,金额(元),是否可提现,状态,回溯后佣金余额(元),原佣金记录ID,来源退款单号,佣金入账时间,生成时间
|
||||
原佣金,463,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 06:00:00
|
||||
原佣金,462,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 04:00:00
|
||||
回溯明细,9,...,AUG26014ORD005,AUG26014CARD001,成本价差,-10.00,不可提现,回溯,40.00,460,AUG26014REF001,,2026-09-17 03:00:00
|
||||
原佣金,461,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 02:00:01
|
||||
原佣金,460,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 02:00:00
|
||||
```
|
||||
|
||||
结论:**列表与导出在同一筛选下行集完全一致(5 = 5),无 8 小时量级的边界漂移**(漂移探针行在两端行为一致均被排除);负数金额原样输出(`-10.00`)、入账后余额原样输出(`40.00`),表头 14 列与 `internal/exporter/commission_record_scene.go:53-59` 逐字一致。
|
||||
|
||||
**(9)临期资产:列表 vs 导出、表头、加油包**
|
||||
|
||||
fixture:3 个资产(2 个同一最终到期时刻、1 个第 15 天窗口)+ 1 个挂主套餐的加油包。
|
||||
|
||||
```
|
||||
列表 GET /api/admin/expiring-assets total=3
|
||||
AUG26014CARD001 days=4 final=2026-09-21T16:00:00+08:00
|
||||
AUG26014CARD002 days=4 final=2026-09-21T16:00:00+08:00
|
||||
AUG26014CARD003 days=14 final=2026-10-01T16:00:00+08:00
|
||||
闭区间 start=end=2026-09-21T16:00:00+08:00 → total=2(同一时刻的两个资产都返回)
|
||||
区间外 start=end=2026-09-22T16:00:00+08:00 → total=0
|
||||
同瞬时 Z 写法 start=end=2026-09-21T08:00:00Z → total=2
|
||||
导出 POST /api/admin/expiring-assets/export{days_min:0,days_max:15} status=3 total_rows=3
|
||||
```
|
||||
|
||||
导出产物(`task 62`):
|
||||
|
||||
```
|
||||
店铺,业务员,用户组,资产类型,设备类型,设备型号,资产标识,当前套餐,到期时间,剩余天数
|
||||
AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD001,AUG26014-临期套餐,2026-09-21 16:00:00,4
|
||||
AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD002,AUG26014-临期套餐,2026-09-21 16:00:00,4
|
||||
AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD003,AUG26014-临期套餐,2026-10-01 16:00:00,14
|
||||
```
|
||||
|
||||
结论:**表头 10 列与 `111.md` §18.1 逐列同序一致**;**加油包不单独成行**(4 条 `tb_package_usage` 行 → 3 条导出行);导出与列表同筛选下行集一致(3 = 3)。
|
||||
|
||||
**(10)遗留旧格式冻结任务安全失败(7.9)**
|
||||
|
||||
fixture:`tb_export_task` 插入 `status=1`、`scene=order`、`query_json={"filters":{"start_time":"2026-09-01","end_time":"2026-09-02"}}`(date-only 旧格式),经 `export:dispatch` 投递后由本地 worker 执行:
|
||||
|
||||
```
|
||||
status= 4(已失败)
|
||||
error_message= '导出筛选的时间边界非法,任务已终止' # 与 constants.ExportTaskInvalidTimeFilterMessage 逐字一致
|
||||
file_key=None total_rows=0 total_shards=0 failed_shards=0 download_url=None
|
||||
```
|
||||
|
||||
结论:**任务落失败、错误摘要为规定安全文案、未产出全量文件(无 file_key、无分片行)**。
|
||||
|
||||
**(11)达量预警回归(F2 修复后重跑)**
|
||||
|
||||
```
|
||||
列表 GET /api/admin/package-traffic-alerts 缺省 / RFC3339+08:00 / date-only / 空格分隔 → 均 HTTP 200 code=0(宽松契约不变)
|
||||
导出 POST /api/admin/export-tasks{scene=package_traffic_alert, filters={start_time,end_time 为带时区 RFC3339}}
|
||||
status=3 total_rows=0(测试库 tb_package_traffic_alert 当前 0 行)
|
||||
表头=资产类型,资产标识,对应标识符,卡标识,设备类型,设备型号,套餐名称,真流量已用量(MB),真流量额度(MB),比例(%),阈值快照(%),到期时间,剩余天数,触发时间,店铺,业务员,用户组,通知投递结果
|
||||
```
|
||||
|
||||
结论:**表头 18 列与改动前逐字一致**(`internal/exporter/package_traffic_alert_scene.go:50-56` 未变更,`git diff` 仅改时间筛选的执行期解析);记录粒度与触发快照口径未动;该场景无金额列,金额/余额口径回归由佣金明细导出覆盖。
|
||||
|
||||
**(12)创建后归属变化不扩大数据集(7.7 范围冻结)**
|
||||
|
||||
作为 §6.2 第 13 项(代理 HTTP 全链路)之外的执行期补充,本项以「按代理创建语义直接落入 `scope_shop_ids` 的冻结任务 + 真实 worker 执行」验证归属变化对已冻结任务的影响:
|
||||
|
||||
| 阶段 | 任务 | 冻结 scope | 资产位置 | 结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | `AUG26014-SCOPE-1`(id 65) | `[1,1527]` | 在 1527 | `total_rows=3` |
|
||||
| 2 | `AUG26014-SCOPE-1`(id 66,重置重跑) | `[1,1527]` | 已移出至 shop 2 | `total_rows=0` |
|
||||
| 对照 | 超管无范围任务(id 67) | 空(不限制) | 已移出至 shop 2 | `total_rows=3` |
|
||||
|
||||
结论:**执行期只按创建时冻结的 `scope_shop_ids` 取数;资产归属变化后行集缩小而非扩大**;对照组证明归属变更确实生效(超管看到 3 行)。
|
||||
|
||||
**(13)代理账号经 HTTP 全链路(第二批补充;真实 `POST /api/auth/login` 登录,非注入令牌)**
|
||||
|
||||
fixture:可见店铺 `1528`(`parent_id=1`)内 3 个临期资产 + 不可见店铺 `shop 2` 内 2 个临期资产(`AUG26014OUT001/002`);代理账号 `AUG26014AGENT`(`user_type=3`、`shop_id=1`)与本工具写入的 bcrypt 口令**真实登录**取得令牌。
|
||||
|
||||
```
|
||||
AGENT(有店铺) login ok user_type= 3 shop_id= 1
|
||||
AGENT(无店铺) login ok user_type= 3 shop_id= None
|
||||
```
|
||||
|
||||
1)列表只返回可见店铺:
|
||||
|
||||
```
|
||||
GET /api/admin/expiring-assets (代理令牌)http=200 code=0 total=3
|
||||
iot_card AUG26014CARD001 shop_id=1528 days=4
|
||||
iot_card AUG26014CARD002 shop_id=1528 days=4
|
||||
iot_card AUG26014CARD003 shop_id=1528 days=14
|
||||
summary={"card_count":3,"device_count":0,"total_count":3,"window_days":15}
|
||||
GET /api/admin/expiring-assets (超管对照)total=5
|
||||
identifiers=[CARD001, CARD002, OUT001, OUT002, CARD003]
|
||||
```
|
||||
|
||||
即:**代理看不到 `shop 2` 的 2 个范围外资产,超管能看到全部 5 个**(逐个资产标识与期望一致)。
|
||||
|
||||
2)代理创建并执行临期导出(行集 ⊆ 创建时冻结的可见店铺范围):
|
||||
|
||||
```
|
||||
POST /api/admin/expiring-assets/export {format:csv,days_min:0,days_max:15}
|
||||
http=200 code=0 → task_id=68 task_no=EXP-20260917-960000 status=1
|
||||
task 68 → status=3 total_rows=3(与代理列表 total=3 一致)
|
||||
库内冻结行长(dbhub 只读):
|
||||
id=68 scene=expiring_asset creator_user_id=683 creator_user_type=3 creator_shop_id=1
|
||||
scope_shop_ids=[1, 6, 1528] # 代理 1 号店的下级树快照
|
||||
query_json={"filters":{"days_max":15,"days_min":0},"resolved_headers":["店铺","业务员","用户组","资产类型","设备类型","设备型号","资产标识","当前套餐","到期时间","剩余天数"]}
|
||||
导出产物:3 行,资产标识为 CARD001/CARD002/CARD003(不含 OUT001/OUT002)
|
||||
```
|
||||
|
||||
3)空可见范围(代理无店铺)创建被拒:
|
||||
|
||||
```
|
||||
GET /api/admin/expiring-assets (shop_id=NULL 的 fixture 代理)http=200 code=0 total=0
|
||||
POST /api/admin/expiring-assets/export (同上) http=403 code=1005 msg=代理账号缺少店铺信息
|
||||
```
|
||||
|
||||
4)越权与不存在统一不可见(同一代理令牌):
|
||||
|
||||
```
|
||||
GET /api/admin/export-tasks/1 → http=403 code=1005 msg=无权限操作该资源或资源不存在
|
||||
GET /api/admin/export-tasks/999999 → http=403 code=1005 msg=无权限操作该资源或资源不存在
|
||||
一致: True
|
||||
```
|
||||
|
||||
**(14)达量预警列表 vs 导出同筛选行集一致(7.6 缺口补齐)**
|
||||
|
||||
fixture 直接写入 `tb_package_traffic_alert` 3 条快照(2 条 `triggered_at` 在 `[2026-09-01T00:00:00Z, 2026-09-30T23:59:59Z]` 内、1 条在 2026-10-05 窗外),同筛选分别调列表与导出:
|
||||
|
||||
```
|
||||
GET /api/admin/package-traffic-alerts?start_time=2026-09-01T00:00:00Z&end_time=2026-09-30T23:59:59Z total=2
|
||||
POST /api/admin/export-tasks{scene:package_traffic_alert, filters 同上} → task 69 status=3 total_rows=2
|
||||
(库内冻结行 69:scope_shop_ids=[],query_json.filters 已规范化为 UTC RFC3339)
|
||||
导出产物:
|
||||
资产类型,资产标识,对应标识符,卡标识,设备类型,设备型号,套餐名称,真流量已用量(MB),真流量额度(MB),比例(%),阈值快照(%),到期时间,剩余天数,触发时间,店铺,业务员,用户组,通知投递结果
|
||||
物联网卡,AUG26014CARD001,,,,,AUG26014-临期套餐,1500,2000,75.00,80.00,,,2026-09-20 20:00:00,AUG26014-临期验证店,<业务员账号名>,<用户组名>,待投递
|
||||
物联网卡,AUG26014CARD001,,,,,AUG26014-临期套餐,1500,2000,75.00,70.00,,,2026-09-15 08:00:00,AUG26014-临期验证店,<业务员账号名>,<用户组名>,待投递
|
||||
```
|
||||
|
||||
结论:**列表 total=2 == 导出 total_rows=2,窗外那条被两端一致排除**;触发快照列(用量/额度/比例/阈值)原样输出,未被执行期改动影响。
|
||||
|
||||
### 6.3 fixture 清单与清理结果
|
||||
|
||||
第一批(§6.2 第 1–12 项):
|
||||
|
||||
| 表 | 标记方式 | 造行数 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tb_shop` | `shop_name LIKE 'AUG26014%'` | 1 | 临期资产归属店铺(`parent_id=1`,供代理可见链路) |
|
||||
| `tb_iot_card` | `iccid LIKE 'AUG26014%'` | 3 | 临期资产 |
|
||||
| `tb_package_usage` | `package_name LIKE 'AUG26014%'` | 4(3 主套餐 + 1 加油包) | 最终到期推算 / 加油包不单独成行 |
|
||||
| `tb_order` | `order_no LIKE 'AUG26014%'` | 6 | 佣金明细关联订单号与 `(order_id,package_id)` 唯一索引避让 |
|
||||
| `tb_commission_record` | `remark LIKE 'AUG26014%'` | 7 | 佣金闭区间两端 + 1 秒内 + 4 小时内 + 8 小时漂移探针 |
|
||||
| `tb_commission_clawback_record` | `refund_no LIKE 'AUG26014%'` | 1 | 回溯分支负金额口径 |
|
||||
| `tb_enterprise_card_authorization` | `remark LIKE 'AUG26014%'` | 3 | 授权闭区间三态(等于 start / 等于 end / 区间外,整秒) |
|
||||
| `tb_commission_withdrawal_request` | `withdrawal_no LIKE 'AUG26014%'` | 3 | 提现闭区间三态(整秒) |
|
||||
| `tb_export_task` / `tb_export_shard_task` | `task_no LIKE 'AUG26014%'` | 遗留任务 1 + 冻结范围任务 1 | 7.9 遗留安全、范围冻结 |
|
||||
|
||||
第二批(§6.2 第 13–14 项):
|
||||
|
||||
| 表 | 标记方式 | 造行数 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tb_iot_card`(范围外) | `iccid LIKE 'AUG26014OUT%'`(`shop_id=2`) | 2 | 代理可见范围对照(不得出现在代理列表/导出) |
|
||||
| `tb_order`(范围外) | `order_no LIKE 'AUG26014OUTORD%'` | 2 | 同上(订单与套餐使用的外键上下文) |
|
||||
| `tb_package_usage`(范围外) | `package_name LIKE 'AUG26014-范围外套餐'` | 2 | 同上 |
|
||||
| `tb_account` | `username LIKE 'AUG26014%'` | 2 | fixture 代理账号:`AUG26014AGENT`(`shop_id=1`)、`AUG26014AGENTNOSHOP`(`shop_id=NULL`);真实登录用,口令 bcrypt 由工具写入且不回显 |
|
||||
| `tb_package_traffic_alert` | `notification_event_id LIKE 'AUG26014%'` | 3(2 条窗内 + 1 条窗外) | 达量预警列表与导出同筛选行集一致 |
|
||||
|
||||
**清理结果**(`cleanup` 按标记删除 + `cleanup-tasks` 删除本次经 HTTP 创建的无标记导出任务):
|
||||
|
||||
```
|
||||
CLEANUP_DONE
|
||||
COUNT tb_shop=0 COUNT tb_iot_card=0 COUNT tb_package_usage=0
|
||||
COUNT tb_order=0 COUNT tb_account=0 COUNT tb_package_traffic_alert=0
|
||||
COUNT tb_export_task=0(标记任务) COUNT tb_export_shard_task=0(标记分片)
|
||||
DELETED_TASKS=6 ids=58,59,61,62,64,67 # 第一批
|
||||
DELETED_TASKS=2 ids=68,69 # 第二批(代理导出、达量预警导出)
|
||||
```
|
||||
|
||||
清理后库内基线回到验证前状态(dbhub 只读复核):
|
||||
|
||||
```
|
||||
tb_export_task = 52 tb_export_shard_task = 48 id > 52 的导出任务 = 0
|
||||
AUG26014 标记:tb_shop / tb_iot_card / tb_package_usage / tb_order / tb_account /
|
||||
tb_package_traffic_alert / tb_export_task 计数全部 = 0
|
||||
```
|
||||
|
||||
### 6.4 证据与代码版本的对应
|
||||
|
||||
| 证据批次 | 时间 | HEAD / 指纹 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 预演(T1/T2/T3/T7 首轮) | 17:26–17:37 | `d52be16` / 修复前二进制 | 仅作预演记录;本文件引用的有效证据全部为修复后批次 |
|
||||
| 第一批(§6.2 第 1–12 项) | 17:39–17:47 | `d52be16` / `51c619f8…` | T7 实现尚未提交;当时 HEAD 尚不含维护者的路由门禁修复,故代理 HTTP 项为 403/1005 |
|
||||
| 第二批(§6.2 第 13–14 项、§6.1 第 15–17 行) | 18:0x–18:1x | `e8ab1f47`(含 `398a5e42`)/ `37d7d690…` | 仅多出维护者对 `internal/routes/{package_traffic_alert,asset_auto_renewal}.go` 的门禁作用域修复提交;T7 实现内容与第一批一致 |
|
||||
|
||||
### 6.5 未验证项(环境限制)
|
||||
|
||||
1. **「代理可见店铺为空列表」这一极端分支**:未单独构造。`GetSubordinateShopIDs` 恒包含账号自身店铺(`internal/store/postgres/shop_store.go:162-192` 以 `ids := []uint{shopID}` 起始),因此「非零 shop_id 且可见项为空」在正常数据下不可达;已用 `shop_id=NULL` 的 fixture 代理账号覆盖最近的等价分支(创建导出被拒 `403 / 1005 代理账号缺少店铺信息`,见 §6.2 第 13 项第 3 段)。
|
||||
2. **达量预警「真实扫描链路」产生的行级口径**:本批以 fixture 直接写入 `tb_package_traffic_alert` 快照行,验证了列表与导出同筛选行集一致(2=2)与快照列原样输出;未跑真实的每日扫描任务(需有效套餐 + 真流量累计 + 通知链路,代价高且与本 Change 无关)。
|
||||
3. **`tb_export_task` 中「旧格式冻结值」的历史存量**:清理前全库实测 0 条(`count(*) WHERE query_json::text ~ '[0-9]{4}-[0-9]{2}-[0-9]{2}[ T]'` = 0),7.9 因此用自建 fixture 覆盖。
|
||||
4. **迁移 `up/down/up`**:本变更无 Schema 变更(§5),按设计为「不适用」。
|
||||
|
||||
### 6.6 既有缺陷 D1:路由门禁作用域(已定位,已由维护者提交 `398a5e42` 修复)
|
||||
|
||||
**现象(第一批,HEAD 尚为 `d52be16`、不含修复)**:代理账号访问 `/api/admin/expiring-assets` 与新增的 `/api/admin/expiring-assets/export` 一律返回 403 / `1005 无权限操作该资源或资源不存在`,与路由注释「后台和代理共用」及路由描述「企业账号禁止调用」矛盾。原始输出:
|
||||
|
||||
```
|
||||
$ curl -s -H "Authorization: Bearer <agent-token>" 'http://127.0.0.1:3000/api/admin/expiring-assets?page=1&page_size=1'
|
||||
{"code":1005,"data":null,"msg":"无权限操作该资源或资源不存在"}
|
||||
|
||||
$ curl -s -X POST -H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
|
||||
-d '{"format":"csv"}' 'http://127.0.0.1:3000/api/admin/expiring-assets/export'
|
||||
{"code":1005,"data":null,"msg":"无权限操作该资源或资源不存在"}
|
||||
```
|
||||
|
||||
定位过程中的门禁键隔离实验(**注入令牌的 `user_type` 由验证者显式写入 Redis 值决定**,非账号真实类型):同一 `user_id=144` 以 `user_type=2` 注入 → 200,以 `user_type=3` 注入 → 1005;`user_id=148`(真实代理)以 `user_type=3` 注入 → 1005;平台 `user_id=149`(**平台账号,`user_type=2`,非代理**)以 `user_type=2` 注入 → 200。旁证:`user_type=3` 的令牌访问任一不存在的 `/api/admin/*` 路径同样 1005(超管/平台为 `1006 资源未找到`),说明拦截发生在其后的路由之前。
|
||||
|
||||
**根因(已由维护者提交证实)**:`internal/routes/package_traffic_alert.go` 与 `internal/routes/asset_auto_renewal.go` 原以 `router.Group("", gate)` 在管理端根组注册「仅超管/平台」门禁。Fiber 的 `Group(prefix, handlers...)` 会把该处理器注册为 `path=/api/admin` 的 USE 处理器并匹配**整个 `/api/admin` 前缀**,且按注册顺序先于其后注册的路由执行,因此 `internal/routes/admin.go` 中位于 `:107`(package-traffic-alert)与 `:110`(asset-auto-renewal)之后注册的全部后台接口(含 `:125` admin exchanges、`:149` `/assets/*`、`:150` `/expiring-assets` 与新增 `/expiring-assets/export`、`:165` agent-recharges,以及 `:122` admin orders)对代理/企业账号一律 403-1005;早于该点的 `:36/:52/:67/:71/:74/:83/:89/:101` 不受影响——与实测 200/1005 的分界完全吻合。
|
||||
|
||||
**修复(维护者提交 `398a5e42`,2026-09-17 17:49:33,不在本 Change 改动内)**:`git show 398a5e42` 将两处门禁改为挂在各自功能前缀组上(`asset_auto_renewal.go` → `router.Group("/asset-auto-renewal-config", …)`;`package_traffic_alert.go` → `router.Group("/package-traffic-alert-rules", gate)` 与 `router.Group("/package-traffic-alerts", gate)`),并在注释中写明「gate 必须挂在功能路径组上:Fiber 的组中间件按路径前缀生效,挂在空路径组上会落到 /api/admin 前缀」。该修复与本文件定位的机制一致。
|
||||
|
||||
**修复后复验(第二批,HEAD=`e8ab1f47`)**:代理账号经真实登录访问同一端点已可达,列表只返回可见店铺(3 行,不含范围外 2 个资产),导出创建并执行为 3 行,详见 §6.2 第 13 项;本 Change 的代理视角验收因此在本批全部通过。
|
||||
|
||||
**归类**:既有实现缺陷(**非本 Change 引入**),**已由维护者在 HEAD 修复**;本 Change 无需改动,亦未被该缺陷遗留影响。验证的第一批 2 个 FAIL(`§6.1` 第 15 行)为修复前版本的照实记录,已保留。
|
||||
|
||||
|
||||
## 7. 实现期发现(需 director 决策,未在本次改动)
|
||||
|
||||
`tb_export_task`、`tb_commission_record`、`tb_commission_withdrawal_request`、`tb_agent_recharge_record`、`tb_enterprise_card_authorization` 等列的时间列为 PostgreSQL `timestamp without time zone`,其中保存的是应用本地时区(部署容器 `TZ=Asia/Shanghai`)的**挂钟时间**;`tb_order`、`tb_exchange_order`、`tb_asset_allocation_record` 为 `timestamp with time zone`。测试库实测(dbhub 只读查询):
|
||||
|
||||
```sql
|
||||
SELECT o.created_at AS order_created_tz, c.created_at AS commission_created_naive, (c.created_at - o.created_at) AS delta
|
||||
FROM tb_commission_record c JOIN tb_order o ON o.id = c.order_id ORDER BY c.id DESC LIMIT 1;
|
||||
-- 2026-08-25 08:08:15.00218+00 | 2026-08-25 16:08:20.158216 | {"hours": 8, "seconds": 5, ...}
|
||||
```
|
||||
|
||||
即同一笔业务事实在无时区列上的挂钟时间比带时区列的快照早 8 小时。驱动在绑定 `time.Time` 到无时区列时会丢弃时区(仅保留挂钟字段),因此统一按「归一后的 UTC 瞬时」绑定后,无时区列上的有效边界为该瞬时的 UTC 挂钟值。
|
||||
|
||||
本次实现按 spec「解析结果 MUST 归一为 UTC 瞬时」与唯一合规先例(`internal/exporter/package_traffic_alert_scene.go` 的 `startTime.UTC()`)原样绑定,未引入额外的时区换算;列表与导出使用同一解析结果,因此同筛选下行集一致。无时区列上的「用户本地整日区间」与「UTC 挂钟边界」之间的差值属本变更范围外的既有事实。
|
||||
|
||||
director 决定:
|
||||
|
||||
- 该事实**已登记**为本变更产物的已知差异,见 [`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md) 第 6 条「无时区列的时间锚定(本次不改)」。
|
||||
- 本次**不改**:统一按 spec 的「归一为 UTC 瞬时」绑定,列表与导出同向同量、有效边界一致,已由本文件 §6.2 第 8 项(含 8 小时漂移探针)实测确认。
|
||||
- 若产品期望「按真实发生瞬时的上海自然日」筛选,属**另立 Change** 的范围(涉及 8 张无时区列的口径与历史数据)。
|
||||
|
||||
## 8. 相关文件
|
||||
|
||||
- 变更产物:[`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/)
|
||||
- 行为契约:[`../../openspec/specs/export-time-filter/spec.md`](../../openspec/specs/export-time-filter/spec.md)
|
||||
- 上下文证据:[`./context-reset/requirement-evidence.json`](./context-reset/requirement-evidence.json)、[`./context-reset/entry-capability-requirement-matrix.json`](./context-reset/entry-capability-requirement-matrix.json)
|
||||
- 工程门禁:[`../engineering/工程约束.md`](../engineering/工程约束.md)
|
||||
- 同类体例参考:[`./add-priority-polling-queue-verification.md`](./add-priority-polling-queue-verification.md)
|
||||
207
docs/verification/add-priority-polling-queue-verification.md
Normal file
207
docs/verification/add-priority-polling-queue-verification.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# AUG26-016 卡轮询优先队列(add-priority-polling-queue)实施与验证记录
|
||||
|
||||
本文件记录 OpenSpec Change `add-priority-polling-queue` 的实施范围、已实测证据与未验证清单。
|
||||
所有实测均在测试库 `junhong_cmp_test` 与**隔离 Redis DB 15** 上执行;未连接生产、未启动 Worker/API 进程、未调用运营商上游接口。
|
||||
|
||||
## 0. 结论概览
|
||||
|
||||
| 验证项 | 断言计数 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| 9.1 迁移 up/down/up + down 守卫 | 结构断言 4 组 + 探针 6 条 | 通过(含记账 `dirty=true` 的实测结论) |
|
||||
| 9.2–9.7 本机可安全验证部分(A–F + H5 批) | **94 PASS / 0 FAIL** | 通过 |
|
||||
| 接缝执行(认领/跳过/接管/范围校验,非导出接缝经真实 `Handle` 驱动) | **63 PASS / 0 FAIL** | 通过 |
|
||||
| 提示通道(优先键与手动键、FIFO、`RemoveFromAllQueues` 边界、分片队列快照) | **12 PASS / 0 FAIL** | 通过 |
|
||||
| 清理与零残留(PG 六类计数 + Redis 键 + 越界 DB 只读复核) | **20 PASS / 0 FAIL** | 通过 |
|
||||
| 成功路径 `Complete`、真并发、尝试上限第 3 次判定、package handler 接缝、HTTP 层、通道阈值持锁、三类生效触发点生产集成 | — | **未验证**(见第 4 节) |
|
||||
|
||||
## 1. 环境与命令
|
||||
|
||||
- 目标库:`junhong_cmp_test`(`cxd.whcxd.cn:16159`);迁移经 `scripts/migrate.sh`(读取 `.env` 的 `DB_*`)。
|
||||
- 隔离 Redis:`cxd.whcxd.cn:16299` **DB 15**(部署库为 DB 6、本地沙盒为 DB 7,二者对所有写入被显式拒绝)。
|
||||
- 环境装载:`set -a; . ./.env; . ./.env.local; set +a`(`DB_*` 只在 `.env`;Redis 参数只在 `.env.local`)。
|
||||
- 构建:`go build -o /tmp/pf ./cmd/priority-fixture-tmp`。
|
||||
- 只读诊断:dbhub MCP `mcp__postgres_execute_sql_main`(仅 SELECT);Redis 只读用 `redis-cli -n <db> EXISTS|DBSIZE|SCAN`。
|
||||
- 临时工具子命令:`setup|a-store|b-consumer|c-trigger|d-read|e-enqueue|f-audit|g-h5|seam-verify|hint-verify|purge-stale-audits|cleanup|count|insert-fixture|delete-fixture|count-rows|index-probe`。
|
||||
该工具带有安全栏:`DB_NAME` 必须为 `junhong_cmp_test`,所有自建行带 `AUG26016` 标记,清理只按本批 ID 集合或标记删除。
|
||||
- 日志:每场景独立带时间戳文件 `run<N>-<步骤>-<TS>.log`(不覆盖);本文只摘录关键原始行。
|
||||
|
||||
## 2. 已验证清单(含点时间与期望/实际)
|
||||
|
||||
### 2.1 任务 9.1:迁移 up / down / up 与 down 守卫(2026-09-16)
|
||||
|
||||
| 步骤 | 期望 | 实际(原文摘录) |
|
||||
| --- | --- | --- |
|
||||
| 迁移前版本 | 227 | `当前迁移版本: 227` |
|
||||
| `./scripts/migrate.sh up` | 应用到 228,exit=0 | `228/u add_polling_priority_queue (344.53875ms)`、`✓ 迁移操作完成` |
|
||||
| 结构 | 23 列全注释、5 索引(含活动项部分唯一索引)、9 CHECK、0 外键 | `column_count=23`、`commented_columns=23`、`index_count=5`、`c:9`+`p:1`、`fk_count=0` |
|
||||
| 活动项索引谓词 | 仅 `deleted_at IS NULL AND status IN ('pending','processing')` 占键位 | `CREATE UNIQUE INDEX uq_polling_priority_item_active ... WHERE ((deleted_at IS NULL) AND ((status)::text = ANY ((ARRAY['pending','processing'])::text[])))` |
|
||||
| 造 1 条活动 fixture 后 `./scripts/migrate.sh down` | 守卫拒绝回滚且**结构零损伤** | `pq: 存在卡轮询优先队列活动项,拒绝回滚以避免丢失在途加急事实与认领租约状态`、`down_exit=1`;随后 `table_regclass=tb_polling_priority_item`、`row_count=1`、`column_count=23`、`index_count=5` |
|
||||
| 守卫后的记账标记 | —(实测为工具行为) | `{"version":"227","dirty":true}` |
|
||||
| 记账修复 | `force <真实版本>` 只改一行、不动 DDL | `./scripts/migrate.sh force 228` → `{"version":"228","dirty":false}` |
|
||||
| 清 fixture 后 `down` | 表/索引/relation 全部消失,exit=0 | `228/d add_polling_priority_queue (221.332417ms)`;`{"table_regclass":null,"index_count":"0","relation_leftovers":"0"}` |
|
||||
| 再次 `up` | 回到 228 且结构完整 | `{"version":"228","dirty":false,"column_count":"23","index_count":"5","check_count":"9","fk_count":"0"}` |
|
||||
|
||||
**实测结论(`dirty=true` 的口径)**:golang-migrate 在运行某个迁移文件之前先写入目标版本并标 `dirty=true`,成功后写回 `dirty=false`;`down 228` 的目标版本是 227,守卫 `RAISE EXCEPTION` 后库被留在 `227, dirty=true`。
|
||||
这是**迁移工具的记账行为,不是本 Change 的实现缺陷**;恢复方式是 `./scripts/migrate.sh force <当前真实版本>`(本例 228),该命令只修一行记账、不触发 DDL 或数据变更。验证 down 守卫的正确预期应写成「守卫拒绝 + 结构零损伤 + 由维护者 `force` 回当前真实版本并确认 `dirty=false`」。
|
||||
|
||||
### 2.2 部分唯一索引语义与约束探针(2026-09-16)
|
||||
|
||||
```
|
||||
[成功] P1 插入活动行 A(id=1 card_id=6012 task_type=carddata status=pending)
|
||||
[报错] P2 重复活动行(同卡同任务类型) → SQLSTATE=23505 constraint=uq_polling_priority_item_active
|
||||
[成功] P3 软删 A 后插入活动行 B(id=3)→ 软删行不占用活动项键位
|
||||
[报错] P4 插入活动行 C(B 仍活动) → SQLSTATE=23505 constraint=uq_polling_priority_item_active
|
||||
[成功] P5 把 B 转终态(completed/success)后插入活动行 D(id=5)→ 终态行不占用活动项键位
|
||||
[报错] P6 终态与结果一致性(completed 但 result 为空) → SQLSTATE=23514 constraint=ck_polling_priority_item_terminal_result
|
||||
索引与约束探针全部符合预期 (exit=0)
|
||||
```
|
||||
|
||||
### 2.3 A–F 批次(Store / 消费者 / 触发分类 / 读侧 / 人工入队 / 审计)——94 PASS / 0 FAIL
|
||||
|
||||
时间:2026-09-17 09:46:49(+08),日志 `run5-<场景>-20260917T094649.log`(另有一轮同结果运行 `run-e2e-*-20260917T092447.log`)。
|
||||
|
||||
| 场景 | 断言 | 期望 → 实际(摘录) |
|
||||
| --- | --- | --- |
|
||||
| A Store 生命周期 | 20 PASS | `A1 首次认领命中…→ status=processing claimed_at=2026-09-17 09:24:49+08`;`A2 超租约(120s>90s)允许接管 → taken=true`;`A2 租约内(10s<90s)不允许接管 → taken=false`;`A3 两次 IncrementAttempt 后 attempt_count → 期望=2 实际=2`;`A3 MarkFailed(未累加尝试次数,等同 FailFinal 路径)→ attempt_count=0`;`A3 尝试上限路径 → attempt_count=1`;`A4 分页归一化 Page=0/PageSize=999 → total=29 returned=20` |
|
||||
| B 消费者 | 15 PASS | `B5 设备载体在事务内冻结的绑定卡数 → 3`;`B5 活动项行数(3 卡 × 4 任务类型) → 12`;`B6 重复投递只合并:行数不变、触发次数+1、最近触发时间刷新、触发类型集合去重`;`B7 自动(no_valid_package)+ 人工(manual_trigger)合并为同一行`;`B8 已终态后重复追加同一 event_id:outbox 仍只 1 行`;`B9 同一 event_id 重投不产生重复审计行(EventID 去重) → 1` |
|
||||
| C 触发分类与前置条件 | 13 PASS | `C10 → renewal_activated` / `purchase_activated`;`C11 存在 status=0 → HasPendingPackageUsage=true`;`C12 观测抑制为真 → 不写事件(0 行)`;`C12 无有效套餐 event_id ≤ 48 且主体与载体可区分 → prio:nvp:d:990016999:… len=31` |
|
||||
| D 读侧权限与范围 | 12 PASS | `D13 超管/平台 → total=18/18`;`D13 代理 → total=16`(平台卡与范围外卡各 1 行不可见);`D13 企业 → code=1005`;`D14 详情越权 vs 不存在 → 同一码与文案` |
|
||||
| E 人工入队用例 | 14 PASS | `E15 一次入队覆盖全部纳入类型 → created=4 merged=0`;`E16 24h 内再次入队 → created=0 merged=4`;`E17 原因缺失/超长 → CodeInvalidParam`;`E17 拒绝审计 → 5 条 manual_denied`;`E17 被拒绝的请求不产生优先项变更 → 仍 4 行活动项` |
|
||||
| F 审计来源匹配 | 6 PASS | `F polling_priority.enqueue/claim/manual_denied 注册 → primary=polling_priority_item,origins 含 account/admin_api 与 system_task/worker`;`F 消费者审计 Actor=system_task / Source=worker` |
|
||||
| count(残留计数) | 10 PASS | 卡/设备/绑定/套餐使用/优先项/审计/outbox/两类孤儿审计资源 **均 = 0**,且 `本批全部自建行已清理干净` |
|
||||
|
||||
### 2.4 H5 加固:事件通道拒绝 `manual_trigger`(2026-09-17 09:24:47)
|
||||
|
||||
`internal/infrastructure/prioritypolling/event.go` 的 `AppendPriorityRequested` 在资源类型校验后显式拒绝
|
||||
`trigger_type='manual_trigger'`(人工入队由 `internal/service/polling/priority_enqueue_service.go` 直写事实表,不经事件通道)。
|
||||
|
||||
```
|
||||
[PASS] H5-1 经事件通道投递 manual_trigger 返回参数错误 → code=1001 err=优先轮询事件不接受人工入队触发类型 manual_trigger,人工入队请调用人工入队用例
|
||||
[PASS] H5-3 该 event_id 在 tb_outbox_event 新增 0 行 → 期望=0 实际=0
|
||||
[PASS] H5-4 tb_outbox_event 总行数不变(拒绝发生在写入前) → 期望=19453 实际=19453
|
||||
```
|
||||
|
||||
### 2.5 接缝执行(seam-verify)——63 PASS / 0 FAIL
|
||||
|
||||
时间:2026-09-17 10:16:34(+08),隔离 Redis DB 15(打印 `db=15`),**未执行 `FlushDB`**。
|
||||
驱动方式:不启动 Worker/Asynq/HTTP,用 `task.NewPollingBase` + `asynq.NewTask` 直接调用
|
||||
`PollingRealnameHandler` / `PollingCarddataHandler` / `PollingCardStatusHandler` 的真实 `Handle`;
|
||||
`integration` 注入真实仓库(非 nil),使「零上游」由真实计数断言证明。
|
||||
每个任务类型(realname / carddata / card_status)依次断言 (a)–(e):
|
||||
|
||||
| 断言组 | 期望 → 实际(摘录) |
|
||||
| --- | --- |
|
||||
| 计数器真对照 | `写入 1 行 tb_integration_log 后按夹具卡统计 +1 → 期望=1 实际=1`;`对照行已删除:计数回到原值 → 期望=0 实际=0` |
|
||||
| (a) 无活动项 = 基线等价 | `Handle 正常返回 → err=<nil>`;`不产生优先项行 → 0`;`零上游调用 → 0`;`仍按既有路径重入队(分片 ZSET 命中) → key=polling:shard:0:queue:polling:realname member=11696` |
|
||||
| (b) pending = 本次领取 | `claim 审计 +1 → delta=1`;`status=failed claimed_at=2026-09-17 10:16:41+08 attempt=0 reason="流量查询能力未配置"`;`fail+dequeue 各 +1`;`零上游调用 → 0` |
|
||||
| (c) 租约内 = 跳过且零上游 | `状态/认领时间/尝试次数/失败原因全部不变 → processing,claimed_at 前后相同`;`claim=0 fail=0 dequeue=0`;`零上游调用 → 0`;`重新入队(ZSET 命中)` |
|
||||
| (d) 超租约 = 接管 | `claimed_at=2026-09-17 10:16:42+08`(距运行 <30s,已刷新);`claim delta=1`;`零上游调用 → 0` |
|
||||
| (e) 卡不在轮询范围 | `status=failed reason="卡已不在轮询范围内" attempt=0`;`零上游调用 → 0` |
|
||||
|
||||
收尾(同一进程内):
|
||||
|
||||
```
|
||||
[清理] 隔离 Redis 已删除本套件使用的 12 个键(未执行 FlushDB)
|
||||
[清理] 审计事实(按本批 ID 集合精确删除):事件=27,资源行=27
|
||||
[清理] 本功能动作码审计保留条数(非本批写入,未删除):0
|
||||
[清理] 优先项=0;Outbox(prio:)=0;套餐使用=0;绑定=3;卡=6;设备=1
|
||||
[PASS] 清理后计数 … 应为 0(9 条)+ 本批全部自建行已清理干净
|
||||
结果:全部断言通过
|
||||
```
|
||||
|
||||
### 2.6 提示通道(hint-verify)——12 PASS / 0 FAIL
|
||||
|
||||
时间:2026-09-17 10:16:34(+08),隔离 Redis DB 15、未 `FlushDB`、全程前后 `polling:shard:*` 快照 `{}`。
|
||||
|
||||
```
|
||||
[PASS] ① EnqueuePriority 只写优先提示键(LLEN) → 期望=3 实际=3
|
||||
[PASS] ① 手动触发键未被 EnqueuePriority 触碰(LLEN) → 期望=0 实际=0
|
||||
[PASS] ① EnqueueManual 只写手动触发键(LLEN) → 期望=1 实际=1
|
||||
[PASS] ① 优先提示键未被 EnqueueManual 触碰(LLEN) → 期望=3 实际=3
|
||||
[PASS] ② RPush 3 元素后 LPopCount 弹出顺序为先进先出 → 期望=[990016101 990016102 990016103] 实际=[990016101 990016102 990016103]
|
||||
[PASS] ③ 边界登记:RemoveFromAllQueues 不清理优先提示键(LLEN 保持 1)
|
||||
[PASS] ③ 边界登记:RemoveFromAllQueues 不清理手动触发键(LLEN 保持 1)
|
||||
[PASS] ④ 本子命令前后 polling:shard:* 键集合与成员完全一致(diff 为空) → before={} after={}
|
||||
[PASS] 收尾 删除本子命令使用的键数 → 2;两个键 EXISTS=0(未残留)
|
||||
```
|
||||
|
||||
### 2.7 清理与零残留(PG + Redis 只读复核,2026-09-17 10:17 起)
|
||||
|
||||
| 指标 | 运行前 | 运行后 |
|
||||
| --- | --- | --- |
|
||||
| `tb_polling_priority_item` | 0 | **0** |
|
||||
| 标记夹具卡 / 设备(`AUG26016%`) | 0 / 0 | **0 / 0** |
|
||||
| `polling_priority.%` 审计 | 0 | **0** |
|
||||
| `prio:` outbox | 0 | **0** |
|
||||
| 孤儿审计资源(`polling_priority_item` / `iot_card+polling_card`) | 0 / 0 | **0 / 0** |
|
||||
| `schema_migrations` | 228 / dirty=false | **228 / dirty=false** |
|
||||
| Redis DB 15 `polling:*` 键数 | 0 | **0** |
|
||||
| Redis DB 15 本套件 12 键 `EXISTS` | — | **0** |
|
||||
| Redis DB 6 夹具卡专属键(`polling:card:11696`、`traffic:sync:lock:card:11696`) | — | **0 / 0**(无本 Change 痕迹) |
|
||||
|
||||
库内全量孤儿审计资源另有 88 行,分组为 `employee_collection_bill=37`、`employee_collection_application=16`、
|
||||
`employee_collection_application_attempt=16`、`package_traffic_alert=16`、`package_traffic_alert_rule=3`——
|
||||
**全部属于其它能力**,本 Change 两类均为 0。
|
||||
|
||||
### 2.8 一次真实失败与其修复(如实记录)
|
||||
|
||||
首次接缝运行(2026-09-17 10:11:34,`run6-seam-verify-20260917T101134.log`)接缝断言 61/61 通过,
|
||||
但收尾残留检查 2 条 FAIL:`polling_priority.*` 审计残留 27 条(claim 9 / fail 9 / dequeue 9,
|
||||
`created_at` 2026-09-17T02:11:37Z–02:11:49Z)。
|
||||
根因是**验证工具自身的记账缺陷**:场景逐个任务类型收尾会删除优先项行,而审计清理依赖按优先项 ID 反查,删行后即漏删。
|
||||
修复(仅临时工具,未放宽任何断言):运行期采集优先项 ID 并在清理时作为额外 ID 集合传入;另加带时间窗护栏的
|
||||
`purge-stale-audits` 兜底子命令(必须显式给 `AUG26016_PURGE_SINCE`,窗口外的行一律拒绝删除并列出)。
|
||||
修复后重跑 63/63 全绿,27 条残留按兜底路径精确删除(`事件=27,资源行=27,删除后残留=0`)。
|
||||
|
||||
## 3. 工程门禁(收尾复跑,脚手架删除后;2026-09-17 10:26)
|
||||
|
||||
| 命令 | 原始结果 | 退出码 |
|
||||
| --- | --- | --- |
|
||||
| `gofmt -l .` | 仅列出 6 个**历史未格式化**文件:`internal/model/dto/package_dto.go`、`internal/model/order_package_invalidate_task.go`、`internal/model/personal_customer_device.go`、`internal/model/personal_customer_iccid.go`、`internal/model/personal_customer_phone.go`、`internal/query/h5popup/query.go`;本 Change 改动文件(`internal/infrastructure/prioritypolling/event.go`、`internal/task/*`、`internal/polling/*`、`internal/service/*`、`pkg/constants/*`、`migrations/000228_*` 等)**均未出现** → 无新增未格式化文件 | 0 |
|
||||
| `go build ./cmd/api ./cmd/worker` | 无输出(仅 Go 模块缓存 stat 写入诊断,不影响构建) | 0 |
|
||||
| `go vet ./...` | 无诊断输出 | 0 |
|
||||
| `go run cmd/gendocs/main.go`(连续两次) | 两次均输出「成功在以下位置生成 OpenAPI 文档」;`md5(docs/admin-openapi.yaml)` = `696dba47834f93faacba93c745353fdd`(两次一致;该文件被 `.gitignore` 忽略,是本地生成产物) | 0 / 0 |
|
||||
| `openspec validate add-priority-polling-queue --strict` | `Change 'add-priority-polling-queue' is valid` | 0 |
|
||||
| `openspec doctor --json` | `"root": {… "healthy": true, "status": []}`、`"status": []` | 0 |
|
||||
| `./scripts/context-health.sh` | `Context 健康检查通过` | 0 |
|
||||
|
||||
自动化测试按项目决策为 N/A:未创建任何 `*_test.go`(`context-health.sh` 亦校验仓库无 `*_test.go`)。
|
||||
|
||||
## 4. 未验证清单(明确未覆盖,不得据本文推断)
|
||||
|
||||
| 项 | 未验证原因 |
|
||||
| --- | --- |
|
||||
| 优先项成功路径 `Complete`(`processing → completed`) | 需真实上游调用成功;本轮所有执行都停在「能力未配置」失败分支,未制造成功上游响应 |
|
||||
| 真并发下的认领互斥 | 无并发执行环境;本轮以「租约内执行中 → 跳过且零上游」的单线程等价路径覆盖,未做同卡同类型真并发竞态 |
|
||||
| 尝试上限第 3 次判定 | 需稳定失败的真实上游;本轮止于 `FailFinal`(尝试次数不累加),未覆盖 `FailRetryable` 连续 3 次的收敛 |
|
||||
| `package` handler 的接缝 | 其 `Handle` 依赖 `t.ResultWriter().TaskID()`,本地 `asynq.Task` 会 panic,未驱动 |
|
||||
| HTTP 层权限矩阵 | 未启动 API;权限判定仅覆盖服务层与查询层(读侧范围、越权=不存在同一响应),未取得真实状态码/`msg` |
|
||||
| 9.7 通道阈值持锁边界 | 需 `tb_carrier_traffic_threshold_lock` 活动锁与真实停复机评估路径;本轮未造锁、未驱动停复机 |
|
||||
| 三类生效触发点的生产集成 | 新购/续购/加油包/排队顺延的触发分类与事件构造已单测(C 场景),但未经真实订单/支付/激活链路端到端验证 |
|
||||
| `queue_activated` / `addon_activated` 正向落库 `trigger_type` | C 场景只对 `purchase_activated`/`renewal_activated` 做了正向落库;排队顺延与加油包仅验证了事件构造 |
|
||||
| 调度器排空顺序与分片背压 | 需 Worker + 既有部署环境;本文只覆盖提示通道的生产/消费语义 |
|
||||
| 迁移 down 守卫的自动化 | 未引入自动化测试(项目决策 N/A),仅为手工实测记录 |
|
||||
|
||||
## 5. 边界声明(避免误读)
|
||||
|
||||
1. **`tb_integration_log` 全表计数会被在跑的测试部署推高**:观测窗口内该表由 5,905,167 增至 5,905,619(+452),
|
||||
来源是并发运行的既有测试部署(DB 6 心跳/并发/27 卡在跑),**不是本套件产生**;本套件在该表只插入并删除 1 行对照行(复查 = 0)。
|
||||
2. **「零上游调用」的判据是夹具卡维度**:`tb_integration_log WHERE resource_type='iot_card' AND resource_id='<夹具卡ID>'`,
|
||||
每条场景运行前后均为 0(15 条独立断言),并由 `counterControl` 对照证明该计数口径能发现写入。
|
||||
这**不等于**「整库无上游调用」——测试部署的正常上游调用一直在发生。
|
||||
3. **隔离 Redis DB 15 并非本套件独占**:其中仍有他人 7 个键(`auth:refresh:…`、`auth:user:127:tokens`、
|
||||
`auth:token:482e6fdc-…`、`asynq:queues`、`asynq:{export:*}×3`)。因此验证工具**默认不执行 `FlushDB`**,
|
||||
只按清单删除自己使用的键;DB 0/6/7 在所有写入路径上被显式拒绝(DB 6/7 仅做过 `EXISTS`/`DBSIZE`/`SCAN` 只读探测)。
|
||||
4. **成功路径与并发结论不得外推**:本轮所有执行都走「能力未配置」分支,因此「零上游」「状态收敛」的结论仅适用于该分支;
|
||||
成功路径 `Complete`、真并发互斥、尝试上限收敛见第 4 节未验证清单。
|
||||
|
||||
## 6. 收尾处置
|
||||
|
||||
- 验证脚手架 `cmd/priority-fixture-tmp/`(main.go / verify.go / seam.go / hint.go / purge.go)为一次性工具,
|
||||
验证完成后**整目录删除**(`rm -rf cmd/priority-fixture-tmp`,DELETED_EXIT=0)。
|
||||
删除确认:`ls cmd/` 只剩 `api/audit-coverage/audit-retention-simulate/foundation-check/gendocs/migration-finalize/worker`;
|
||||
`find . -name 'priority-fixture-tmp*' -not -path './.git/*'` 无输出(仓库根目录与全仓均无同名二进制);
|
||||
`git status --porcelain | grep -i fixture` 无输出(工作区已不含该目录)。
|
||||
- 测试库 `tb_polling_priority_item` 与本 Change 动作码审计、`prio:` outbox、标记夹具均已清零(见 2.7);
|
||||
本文引用的逐场景日志保存在 `/tmp/aug26-016/`(会话级临时目录,非仓库产物)。
|
||||
- 未提交、未 push。2026-09-17 归档:Change 目录移至 `openspec/changes/archive/2026-09-17-add-priority-polling-queue/`(含 `.openspec.yaml`),delta spec 已同步进主 Specs(新增 `openspec/specs/priority-polling-queue/spec.md`,并在 `openspec/specs/polling-operations/spec.md` 追加「优先轮询项与普通轮询的单次执行互斥」与三条优先队列路由);`tasks.md` 的 9.1–9.7 按用户口径统一勾选,第 4 节未验证清单不因勾选而改变。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -42,6 +43,7 @@ type ChangeAudit struct {
|
||||
PersonalOpenIDs []PersonalCustomerOpenIDChange
|
||||
PersonalDevices []PersonalCustomerDeviceChange
|
||||
PersonalICCIDs []PersonalCustomerICCIDChange
|
||||
PhoneAssociations []PhoneAssetAssociationChange
|
||||
Role *model.Role
|
||||
Roles []RoleChange
|
||||
Permissions []PermissionChange
|
||||
@@ -59,6 +61,24 @@ type PersonalCustomerPhoneChange struct {
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// PhoneAssetAssociationChange 保存手机号—资产关联资源的前后变化。
|
||||
// 手机号一律传入脱敏值:关联用例不得把完整手机号写入审计(ENG-LOG-001)。
|
||||
// 关联指向的资产以资产类型与资产 ID 声明,由 Writer 组装为参考资源。
|
||||
type PhoneAssetAssociationChange struct {
|
||||
AssociationID uint
|
||||
PhoneMasked string
|
||||
AssetType string
|
||||
AssetID uint
|
||||
AssetDisplayName string
|
||||
Status int
|
||||
Source string
|
||||
InvalidatedAt *time.Time
|
||||
InvalidationMethod string
|
||||
InvalidationReason string
|
||||
BeforeData map[string]any
|
||||
AfterData map[string]any
|
||||
}
|
||||
|
||||
// PersonalCustomerOpenIDChange 保存个人客户微信主体资源变化。
|
||||
type PersonalCustomerOpenIDChange struct {
|
||||
OpenID *model.PersonalCustomerOpenID
|
||||
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
@@ -17,14 +18,20 @@ import (
|
||||
|
||||
// ApprovalDecisionHandler 将渠道无关审批终态应用到员工线下代充值业务。
|
||||
type ApprovalDecisionHandler struct {
|
||||
db *gorm.DB
|
||||
posting *walletapp.PostingService
|
||||
audit RechargeAuditWriter
|
||||
db *gorm.DB
|
||||
posting *walletapp.PostingService
|
||||
audit RechargeAuditWriter
|
||||
billCreation *employeecollectionapp.BillCreationService
|
||||
}
|
||||
|
||||
// NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。
|
||||
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService, audit RechargeAuditWriter) *ApprovalDecisionHandler {
|
||||
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit}
|
||||
func NewApprovalDecisionHandler(
|
||||
db *gorm.DB,
|
||||
posting *walletapp.PostingService,
|
||||
audit RechargeAuditWriter,
|
||||
billCreation *employeecollectionapp.BillCreationService,
|
||||
) *ApprovalDecisionHandler {
|
||||
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit, billCreation: billCreation}
|
||||
}
|
||||
|
||||
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。
|
||||
@@ -32,6 +39,9 @@ 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, "员工线下代充值审批终态参数无效")
|
||||
}
|
||||
@@ -99,6 +109,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -81,7 +82,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
|
||||
command.MerchantIdentity = strings.TrimSpace(command.MerchantIdentity)
|
||||
command.ThirdPartyTradeNo = strings.TrimSpace(command.ThirdPartyTradeNo)
|
||||
if command.PaymentNo == "" || command.ConfigID == 0 || command.PaidAt.IsZero() {
|
||||
if command.PaymentNo == "" || command.PaidAt.IsZero() {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
|
||||
}
|
||||
|
||||
@@ -91,6 +92,9 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if payment.MerchantID == nil && command.ConfigID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
|
||||
}
|
||||
alreadyConfirmed, err := domain.ValidatePaymentConfirmation(toDomainConfirmationFacts(payment, recharge, command))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -114,6 +118,10 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
if paymentUpdate.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "代理充值支付单状态已变化")
|
||||
}
|
||||
|
||||
if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, paidAt); err != nil {
|
||||
return err
|
||||
}
|
||||
rechargeUpdate := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
|
||||
Where("id = ? AND status IN ?", recharge.ID, []int{constants.RechargeStatusPending, constants.RechargeStatusClosed}).
|
||||
Updates(map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt})
|
||||
@@ -127,7 +135,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
|
||||
EventID: "agent-recharge:" + strconv.FormatUint(uint64(recharge.ID), 10) + ":payment-confirmed",
|
||||
RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, PaymentID: payment.ID, PaymentNo: payment.PaymentNo,
|
||||
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
|
||||
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
|
||||
PaymentMethod: payment.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
|
||||
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
ParentEventID: command.ParentEventID,
|
||||
}
|
||||
@@ -186,7 +194,9 @@ func toDomainConfirmationFacts(payment *model.Payment, recharge *model.AgentRech
|
||||
OrderType: payment.OrderType, ExpectedOrderType: model.PaymentOrderTypeAgentRecharge,
|
||||
PaymentMethod: payment.PaymentMethod, RechargePaymentMethod: recharge.PaymentMethod, RechargePaymentChannel: rechargeChannel,
|
||||
PaymentConfigID: paymentConfigID, RechargePaymentConfigID: rechargeConfigID, ConfirmedConfigID: command.ConfigID,
|
||||
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
|
||||
FrozenMerchant: payment.MerchantID != nil, FrozenMerchantPaymentMethod: payment.MerchantPaymentMethodSnapshot,
|
||||
FrozenMerchantProviderType: payment.MerchantProviderTypeSnapshot,
|
||||
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
|
||||
PaymentAmount: payment.Amount, RechargeAmount: recharge.Amount, ConfirmedAmount: command.Amount,
|
||||
PaymentOrderID: payment.OrderID, RechargeID: recharge.ID, PaymentState: domain.PaymentState(payment.Status),
|
||||
RechargeStatus: recharge.Status, StoredTradeNo: payment.ThirdPartyTradeNo, ConfirmedTradeNo: command.ThirdPartyTradeNo,
|
||||
|
||||
@@ -24,7 +24,12 @@ type CreateOfflineCommand struct {
|
||||
RechargeNo string
|
||||
Amount int64
|
||||
PaymentVoucherKeys []string
|
||||
Remark string
|
||||
OtherVoucherKeys []string
|
||||
// OfflinePaymentMethodID 是提交人选择的线下收款方式字典项 ID。
|
||||
OfflinePaymentMethodID uint
|
||||
// ExternalTransactionNo 是人工确认后的交易流水号,独立于在线渠道第三方交易号。
|
||||
ExternalTransactionNo string
|
||||
Remark string
|
||||
}
|
||||
|
||||
// CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。
|
||||
@@ -78,8 +83,20 @@ 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),
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -154,20 +171,31 @@ 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
|
||||
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),
|
||||
Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
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{
|
||||
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,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败")
|
||||
}
|
||||
@@ -218,17 +246,71 @@ func validateCreateOfflineCommand(command CreateOfflineCommand) error {
|
||||
if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount {
|
||||
return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
|
||||
}
|
||||
if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证")
|
||||
if command.OfflinePaymentMethodID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值必须选择线下收款方式")
|
||||
}
|
||||
for _, key := range command.PaymentVoucherKeys {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空")
|
||||
}
|
||||
if err := validateRechargeTransactionNo(command.ExternalTransactionNo); err != nil {
|
||||
return err
|
||||
}
|
||||
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) {
|
||||
@@ -291,7 +373,7 @@ func (s *OfflineCreationService) loadCreationFacts(
|
||||
return &account, &shop, &wallet, nil
|
||||
}
|
||||
|
||||
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) {
|
||||
func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName, paymentMethodCode, paymentMethodName string) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": command.SubmitterAccountID, "account_name": submitterName,
|
||||
"user_type": command.SubmitterUserType,
|
||||
@@ -300,15 +382,19 @@ func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopN
|
||||
return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值提交人快照失败")
|
||||
}
|
||||
requestSnapshot, err := sonic.Marshal(map[string]any{
|
||||
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
|
||||
constants.ApprovalFieldShopID: command.ShopID,
|
||||
constants.ApprovalFieldShopName: shopName,
|
||||
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
|
||||
constants.ApprovalFieldAmountCent: command.Amount,
|
||||
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
|
||||
constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark),
|
||||
constants.ApprovalFieldSubmitterID: command.SubmitterAccountID,
|
||||
constants.ApprovalFieldSubmitterName: submitterName,
|
||||
constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo),
|
||||
constants.ApprovalFieldShopID: command.ShopID,
|
||||
constants.ApprovalFieldShopName: shopName,
|
||||
constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100),
|
||||
constants.ApprovalFieldAmountCent: command.Amount,
|
||||
constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys,
|
||||
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, "编码线下代充值审批业务快照失败")
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -46,21 +47,28 @@ type AvailablePaymentMethodsResult struct {
|
||||
|
||||
// OnlineCreationService 创建代理在线扫码充值单。
|
||||
type OnlineCreationService struct {
|
||||
db *gorm.DB
|
||||
wechat OnlinePaymentPort
|
||||
alipay OnlinePaymentPort
|
||||
fuiou OnlinePaymentPort
|
||||
audit PaymentAuditWriter
|
||||
db *gorm.DB
|
||||
runtime *merchantpayment.RuntimeLoader
|
||||
wechat OnlinePaymentPort
|
||||
alipay OnlinePaymentPort
|
||||
fuiou OnlinePaymentPort
|
||||
audit PaymentAuditWriter
|
||||
policy *OnlinePaymentMethodPolicy
|
||||
}
|
||||
|
||||
// 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}
|
||||
// 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}
|
||||
}
|
||||
|
||||
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
|
||||
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
|
||||
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil {
|
||||
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil || s.policy == nil {
|
||||
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
|
||||
}
|
||||
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
|
||||
@@ -79,14 +87,19 @@ 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
|
||||
}
|
||||
account, shop, wallet, config, adapter, err := s.loadCreationFacts(ctx, command)
|
||||
if err := s.policy.IsAllowed(ctx, command.PaymentMethod); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config)
|
||||
result, config, adapter, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet)
|
||||
if err != nil {
|
||||
if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
@@ -125,26 +138,56 @@ 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 userType != constants.UserTypeAgent {
|
||||
return result, apperrors.New(apperrors.CodeForbidden, "仅代理账号可以查询在线支付方式")
|
||||
if s == nil || s.db == nil || s.runtime == nil {
|
||||
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
|
||||
}
|
||||
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 userType != constants.UserTypeAgent && userType != constants.UserTypePlatform {
|
||||
return result, apperrors.New(apperrors.CodeForbidden, "仅代理或平台账号可以查询在线支付方式")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -152,40 +195,29 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
|
||||
func (s *OnlineCreationService) loadCreationFacts(
|
||||
ctx context.Context,
|
||||
command CreateOnlineCommand,
|
||||
) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, error) {
|
||||
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
|
||||
var account model.Account
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND user_type = ? AND status = ?", command.AccountID, constants.UserTypeAgent, constants.StatusEnabled).First(&account).Error; err != nil || account.ShopID == nil || *account.ShopID != command.CurrentShopID {
|
||||
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
|
||||
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
|
||||
}
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
|
||||
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.CurrentShopID, constants.StatusEnabled).First(&shop).Error; err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
|
||||
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
|
||||
}
|
||||
var wallet model.AgentWallet
|
||||
if err := s.db.WithContext(ctx).Where("shop_id = ? AND wallet_type = ? AND status = ?", command.CurrentShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).First(&wallet).Error; err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
|
||||
return nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
|
||||
}
|
||||
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
|
||||
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
|
||||
}
|
||||
var config model.WechatConfig
|
||||
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
|
||||
}
|
||||
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
|
||||
}
|
||||
adapter := s.adapter(command.PaymentMethod, &config)
|
||||
if adapter == nil || !adapter.Available(&config) {
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
|
||||
}
|
||||
return &account, &shop, &wallet, &config, adapter, nil
|
||||
return &account, &shop, &wallet, nil
|
||||
}
|
||||
|
||||
func (s *OnlineCreationService) createLocalFacts(
|
||||
@@ -195,36 +227,58 @@ func (s *OnlineCreationService) createLocalFacts(
|
||||
account *model.Account,
|
||||
shop *model.Shop,
|
||||
wallet *model.AgentWallet,
|
||||
config *model.WechatConfig,
|
||||
) (*CreateOnlineResult, error) {
|
||||
) (*CreateOnlineResult, *model.WechatConfig, OnlinePaymentPort, error) {
|
||||
rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
paymentNo, err := newBusinessNo("PAY", fmt.Sprintf("%d", time.Now().UnixMilli()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expireMinutes := config.AliPayExpireMinutes
|
||||
if expireMinutes <= 0 {
|
||||
expireMinutes = model.DefaultAliPayExpireMinutes
|
||||
}
|
||||
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
|
||||
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
|
||||
record := &model.AgentRechargeRecord{
|
||||
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
|
||||
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
|
||||
PaymentConfigID: &config.ID, Status: constants.RechargeStatusPending,
|
||||
RequestID: &requestID, RequestFingerprint: &fingerprint,
|
||||
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
payment := &model.Payment{
|
||||
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
|
||||
PaymentMethod: command.PaymentMethod, MerchantIdentity: paymentMerchantIdentity(command.PaymentMethod, config),
|
||||
Amount: command.Amount, Status: model.PaymentRecordStatusPending,
|
||||
PaymentConfigID: &config.ID, ExpireAt: &expireAt,
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var record *model.AgentRechargeRecord
|
||||
var payment *model.Payment
|
||||
var config *model.WechatConfig
|
||||
var adapter OnlinePaymentPort
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
route, err := s.runtime.SelectForNewPaymentWithTx(ctx, tx, command.PaymentMethod, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 仅微信直连(v3/v2)商户需要全局授权配置中的 AppID;富友商户不依赖该配置。
|
||||
var authorization *model.WechatAuthorization
|
||||
if route.Merchant.ProviderType == model.ProviderTypeWechat || route.Merchant.ProviderType == model.ProviderTypeWechatV2 {
|
||||
authorization, err = s.runtime.LoadAuthorization(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
config, err = merchantpayment.MerchantConfig(route.Merchant, authorization)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adapter = s.adapter(command.PaymentMethod, config)
|
||||
if adapter == nil || !adapter.Available(config) {
|
||||
return apperrors.New(apperrors.CodeNoPaymentConfig)
|
||||
}
|
||||
expireMinutes := config.AliPayExpireMinutes
|
||||
if expireMinutes <= 0 {
|
||||
expireMinutes = model.DefaultAliPayExpireMinutes
|
||||
}
|
||||
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
|
||||
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
|
||||
record = &model.AgentRechargeRecord{
|
||||
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
|
||||
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
|
||||
Status: constants.RechargeStatusPending, RequestID: &requestID, RequestFingerprint: &fingerprint,
|
||||
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
payment = &model.Payment{
|
||||
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
|
||||
PaymentMethod: command.PaymentMethod, Amount: command.Amount,
|
||||
Status: model.PaymentRecordStatusPending, ExpireAt: &expireAt,
|
||||
}
|
||||
merchantpayment.FreezeRoute(payment, route)
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -238,9 +292,13 @@ func (s *OnlineCreationService) createLocalFacts(
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
|
||||
var appErr *apperrors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
|
||||
}
|
||||
return &CreateOnlineResult{Recharge: record, Payment: payment}, nil
|
||||
return &CreateOnlineResult{Recharge: record, Payment: payment}, config, adapter, nil
|
||||
}
|
||||
|
||||
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {
|
||||
|
||||
58
internal/application/agentrecharge/online_payment_policy.go
Normal file
58
internal/application/agentrecharge/online_payment_policy.go
Normal file
@@ -0,0 +1,58 @@
|
||||
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, "当前支付方式不在代理在线充值允许范围内")
|
||||
}
|
||||
109
internal/application/agentrecharge/payment_voucher_ocr.go
Normal file
109
internal/application/agentrecharge/payment_voucher_ocr.go
Normal file
@@ -0,0 +1,109 @@
|
||||
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
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
// RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。
|
||||
type RecoverOnlinePaymentService struct {
|
||||
db *gorm.DB
|
||||
runtime *merchantpayment.RuntimeLoader
|
||||
wechat OnlinePaymentPort
|
||||
alipay OnlinePaymentPort
|
||||
fuiou OnlinePaymentPort
|
||||
@@ -23,13 +25,13 @@ type RecoverOnlinePaymentService struct {
|
||||
}
|
||||
|
||||
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
|
||||
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
|
||||
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
|
||||
func NewRecoverOnlinePaymentService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
|
||||
return &RecoverOnlinePaymentService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
|
||||
}
|
||||
|
||||
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
|
||||
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
|
||||
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
|
||||
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
|
||||
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
@@ -52,7 +54,13 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er
|
||||
for index := range payments {
|
||||
payment := &payments[index]
|
||||
recharge := recharges[payment.OrderID]
|
||||
config := recoveryConfig(payment, configs)
|
||||
config, configErr := s.recoveryConfig(ctx, payment, configs)
|
||||
if configErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = configErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if recharge == nil || config == nil {
|
||||
if firstErr == nil {
|
||||
firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置")
|
||||
@@ -141,7 +149,7 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
|
||||
configIDs := make([]uint, 0, len(payments))
|
||||
for index := range payments {
|
||||
rechargeIDs = append(rechargeIDs, payments[index].OrderID)
|
||||
if payments[index].PaymentConfigID != nil {
|
||||
if payments[index].MerchantID == nil && payments[index].PaymentConfigID != nil {
|
||||
configIDs = append(configIDs, *payments[index].PaymentConfigID)
|
||||
}
|
||||
}
|
||||
@@ -151,8 +159,10 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
|
||||
}
|
||||
var configRows []model.WechatConfig
|
||||
if len(configIDs) > 0 {
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值创建配置失败")
|
||||
// merchant_id 为空仅为留存期内历史支付;独立 Change 删除旧路径前,
|
||||
// 必须按其 payment_config_id 读取,包括已软删除的原始配置。
|
||||
if err := s.db.WithContext(ctx).Unscoped().Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值历史配置失败")
|
||||
}
|
||||
}
|
||||
recharges := make(map[uint]*model.AgentRechargeRecord, len(rechargeRows))
|
||||
@@ -166,11 +176,27 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
|
||||
return recharges, configs, nil
|
||||
}
|
||||
|
||||
func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig {
|
||||
if payment.PaymentConfigID == nil {
|
||||
return nil
|
||||
// recoveryConfig 对冻结商户支付单按当前凭证版本加载;历史支付单保持 payment_config_id 路径。
|
||||
func (s *RecoverOnlinePaymentService) recoveryConfig(ctx context.Context, payment *model.Payment, configs map[uint]*model.WechatConfig) (*model.WechatConfig, error) {
|
||||
if payment.MerchantID != nil {
|
||||
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 仅微信直连(v3/v2)商户需要全局授权配置中的 AppID;富友商户不依赖该配置。
|
||||
var authorization *model.WechatAuthorization
|
||||
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
|
||||
authorization, err = s.runtime.LoadAuthorization(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return merchantpayment.MerchantConfig(merchant, authorization)
|
||||
}
|
||||
return configs[*payment.PaymentConfigID]
|
||||
if payment.PaymentConfigID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return configs[*payment.PaymentConfigID], nil
|
||||
}
|
||||
|
||||
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error {
|
||||
|
||||
226
internal/application/assetautorenewal/config.go
Normal file
226
internal/application/assetautorenewal/config.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ConfigView 是自动续费配置的读取视图。
|
||||
type ConfigView struct {
|
||||
Enabled int `json:"enabled"`
|
||||
Scope string `json:"scope"`
|
||||
PackageIDs []uint `json:"package_ids"`
|
||||
DaysBeforeExpiry int `json:"days_before_expiry"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
Updater uint `json:"updater"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ConfigRequest 是保存自动续费配置的请求。
|
||||
type ConfigRequest struct {
|
||||
Enabled int `json:"enabled"`
|
||||
Scope string `json:"scope"`
|
||||
PackageIDs []uint `json:"package_ids"`
|
||||
DaysBeforeExpiry int `json:"days_before_expiry"`
|
||||
}
|
||||
|
||||
// GetConfig 读取唯一的自动续费配置;仅超级管理员与平台账号可见。
|
||||
func (s *Service) GetConfig(ctx context.Context) (*ConfigView, error) {
|
||||
if _, err := requirePlatformOperator(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config, err := s.configStore.Get(ctx)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "自动续费配置不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败")
|
||||
}
|
||||
return toConfigView(config), nil
|
||||
}
|
||||
|
||||
// SaveConfig 保存自动续费配置:单行事务锁串行化、事务内自增配置版本,并与审计同事务写入。
|
||||
//
|
||||
// 保存只影响后续扫描:已产生的尝试记录保留触发时的配置版本快照,不重算。
|
||||
func (s *Service) SaveConfig(ctx context.Context, request ConfigRequest) (*ConfigView, error) {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packageIDs, err := normalizeConfigRequest(&request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(packageIDs) > 0 {
|
||||
if err := s.validateSellableMainPackages(ctx, packageIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "自动续费配置审计接缝未配置")
|
||||
}
|
||||
saved := &model.AssetAutoRenewalConfig{}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
current, lockErr := s.configStore.LockInTx(ctx, tx)
|
||||
if lockErr != nil {
|
||||
if lockErr == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "自动续费配置不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定自动续费配置失败")
|
||||
}
|
||||
before := configSnapshot(current)
|
||||
saved.Enabled = request.Enabled
|
||||
saved.Scope = request.Scope
|
||||
saved.PackageIDs = model.UintJSONBArray(packageIDs)
|
||||
saved.DaysBeforeExpiry = request.DaysBeforeExpiry
|
||||
saved.ConfigVersion = current.ConfigVersion + 1
|
||||
saved.Creator = current.Creator
|
||||
saved.Updater = operatorID
|
||||
if saveErr := s.configStore.SaveInTx(ctx, tx, saved, operatorID); saveErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, saveErr, "保存自动续费配置失败")
|
||||
}
|
||||
if auditErr := s.auditWriter.WriteAssetAutoRenewalConfigChange(ctx, tx, audit.AssetAutoRenewalConfigAudit{
|
||||
OperatorID: operatorID,
|
||||
OperationType: constants.AuditOperationAssetAutoRenewalConfigUpdate,
|
||||
Description: "保存资产钱包自动续费配置",
|
||||
BeforeData: before,
|
||||
AfterData: configSnapshot(saved),
|
||||
RequestID: derefString(middleware.GetRequestIDFromContext(ctx)),
|
||||
CorrelationID: derefString(middleware.GetRequestIDFromContext(ctx)),
|
||||
}); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := &ConfigView{
|
||||
Enabled: saved.Enabled, Scope: saved.Scope, PackageIDs: packageIDs,
|
||||
DaysBeforeExpiry: saved.DaysBeforeExpiry, ConfigVersion: saved.ConfigVersion,
|
||||
Updater: saved.Updater, UpdatedAt: s.now(),
|
||||
}
|
||||
s.logger.Info("资产钱包自动续费配置已保存",
|
||||
zap.Int("enabled", view.Enabled), zap.String("scope", view.Scope),
|
||||
zap.Int("days_before_expiry", view.DaysBeforeExpiry), zap.Int64("config_version", view.ConfigVersion))
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// derefString 安全解引用可空字符串,供审计上下文可选字段复用。
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
// requirePlatformOperator 复核调用者仅限超级管理员与平台账号,并返回其账号 ID。
|
||||
//
|
||||
// 路由组已做粗粒度门禁,这里在业务边界再复核一次账号类型(ENG-AUTHZ-001):
|
||||
// 代理、企业与个人客户一律按「无权限或不存在」统一拒绝,不形成可枚举差异。
|
||||
func requirePlatformOperator(ctx context.Context) (uint, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// normalizeConfigRequest 归一化并校验保存请求,返回去重升序的指定套餐集合。
|
||||
func normalizeConfigRequest(request *ConfigRequest) ([]uint, error) {
|
||||
if request.Enabled != constants.AssetAutoRenewalConfigEnabledOff &&
|
||||
request.Enabled != constants.AssetAutoRenewalConfigEnabledOn {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自动续费总开关取值非法")
|
||||
}
|
||||
if request.Scope != constants.AssetAutoRenewalScopeAll && request.Scope != constants.AssetAutoRenewalScopeSpecified {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自动续费适用范围取值非法")
|
||||
}
|
||||
if request.DaysBeforeExpiry < constants.AssetAutoRenewalMinDaysBeforeExpiry ||
|
||||
request.DaysBeforeExpiry > constants.AssetAutoRenewalMaxDaysBeforeExpiry {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自动续费到期前天数必须在 1 至 90 之间")
|
||||
}
|
||||
seen := make(map[uint]struct{}, len(request.PackageIDs))
|
||||
packageIDs := make([]uint, 0, len(request.PackageIDs))
|
||||
for _, packageID := range request.PackageIDs {
|
||||
if packageID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "自动续费指定套餐包含无效 ID")
|
||||
}
|
||||
if _, exists := seen[packageID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[packageID] = struct{}{}
|
||||
packageIDs = append(packageIDs, packageID)
|
||||
}
|
||||
sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] })
|
||||
if request.Scope == constants.AssetAutoRenewalScopeSpecified && len(packageIDs) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "指定范围必须至少选择一个主套餐")
|
||||
}
|
||||
if request.Scope == constants.AssetAutoRenewalScopeAll {
|
||||
packageIDs = nil
|
||||
}
|
||||
return packageIDs, nil
|
||||
}
|
||||
|
||||
// validateSellableMainPackages 校验指定集合只能选择当前可售主套餐。
|
||||
//
|
||||
// 可售口径与购买校验的平台分支一致:套餐为正式套餐、全局启用且上架。
|
||||
// 运行时不因后来下架而拒绝(交由续费豁免判定),因此下架只在此处拦截配置保存。
|
||||
func (s *Service) validateSellableMainPackages(ctx context.Context, packageIDs []uint) error {
|
||||
packages, err := s.loadPackagesByIDs(ctx, packageIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, packageID := range packageIDs {
|
||||
pkg, exists := packages[packageID]
|
||||
if !exists {
|
||||
return errors.New(errors.CodeInvalidParam, "指定套餐不存在")
|
||||
}
|
||||
if pkg.PackageType != constants.PackageTypeFormal {
|
||||
return errors.New(errors.CodeInvalidParam, "指定范围只能选择主套餐")
|
||||
}
|
||||
if pkg.Status != constants.StatusEnabled {
|
||||
return errors.New(errors.CodeInvalidParam, "指定套餐已禁用")
|
||||
}
|
||||
if pkg.ShelfStatus != constants.ShelfStatusOn {
|
||||
return errors.New(errors.CodeInvalidParam, "指定套餐已下架")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// configSnapshot 生成配置前后值快照,字段口径固定,便于审计比对。
|
||||
func configSnapshot(config *model.AssetAutoRenewalConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"enabled": config.Enabled,
|
||||
"scope": config.Scope,
|
||||
"package_ids": []uint(config.PackageIDs),
|
||||
"days_before_expiry": config.DaysBeforeExpiry,
|
||||
"config_version": config.ConfigVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func toConfigView(config *model.AssetAutoRenewalConfig) *ConfigView {
|
||||
return &ConfigView{
|
||||
Enabled: config.Enabled,
|
||||
Scope: config.Scope,
|
||||
PackageIDs: []uint(config.PackageIDs),
|
||||
DaysBeforeExpiry: config.DaysBeforeExpiry,
|
||||
ConfigVersion: config.ConfigVersion,
|
||||
Updater: config.Updater,
|
||||
UpdatedAt: config.UpdatedAt,
|
||||
}
|
||||
}
|
||||
222
internal/application/assetautorenewal/event.go
Normal file
222
internal/application/assetautorenewal/event.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
)
|
||||
|
||||
// assetAutoRenewalResumePayloadVersion 是自动续费复机事件的载荷版本。
|
||||
const assetAutoRenewalResumePayloadVersion = 1
|
||||
|
||||
// resumePayload 是自动续费复机事件的载荷,只携带尝试与资产标识,消费者按尝试 ID 认领执行权。
|
||||
type resumePayload struct {
|
||||
AttemptID uint `json:"attempt_id"`
|
||||
AssetType string `json:"asset_type"`
|
||||
AssetID uint `json:"asset_id"`
|
||||
}
|
||||
|
||||
// AppendResumeRequested 在续费事务内幂等写入复机事件。
|
||||
//
|
||||
// 事件 ID 由尝试记录 ID 派生:续费成功与复机状态同事务写入,重复投递不会创建第二个事件
|
||||
// (ENG-OUTBOX-001)。调用方必须已确认可复机条件成立,本函数不做条件判定。
|
||||
func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, attempt *model.AssetAutoRenewalAttempt) error {
|
||||
if repository == nil {
|
||||
return gorm.ErrInvalidDB
|
||||
}
|
||||
if attempt == nil || attempt.ID == 0 {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
value := strconv.FormatUint(uint64(attempt.ID), 10)
|
||||
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: outboxid.Stable(constants.OutboxEventTypeAssetAutoRenewalResumeRequested+":", value),
|
||||
EventType: constants.OutboxEventTypeAssetAutoRenewalResumeRequested,
|
||||
PayloadVersion: assetAutoRenewalResumePayloadVersion,
|
||||
AggregateType: "asset_auto_renewal_attempt",
|
||||
AggregateID: value,
|
||||
ResourceType: attempt.AssetType,
|
||||
ResourceID: strconv.FormatUint(uint64(attempt.AssetID), 10),
|
||||
BusinessKey: constants.OutboxEventTypeAssetAutoRenewalResumeRequested + ":" + value,
|
||||
Payload: resumePayload{
|
||||
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ResumeConsumer 把自动续费复机事件转成一次复机动作。
|
||||
type ResumeConsumer struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewResumeConsumer 创建自动续费复机事件消费者。
|
||||
func NewResumeConsumer(service *Service) *ResumeConsumer {
|
||||
return &ResumeConsumer{service: service}
|
||||
}
|
||||
|
||||
// Consume 按尝试记录认领执行权后执行复机;重复投递由认领字段兜住,不会产生第二次外部调用。
|
||||
func (c *ResumeConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.service == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行能力未配置")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeAssetAutoRenewalResumeRequested {
|
||||
return outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
if envelope.PayloadVersion != assetAutoRenewalResumePayloadVersion {
|
||||
return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷版本不受支持"))
|
||||
}
|
||||
var payload resumePayload
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return outbox.Permanent(errors.Wrap(errors.CodeInvalidParam, err, "自动续费复机事件载荷格式错误"))
|
||||
}
|
||||
if payload.AttemptID == 0 {
|
||||
return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷不完整"))
|
||||
}
|
||||
// 消费者不经过计划任务入口,必须自带操作者与来源,否则失败审计会因审计上下文缺失被拒(fail-closed)。
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.OutboxEventTypeAssetAutoRenewalResumeRequested,
|
||||
ActorName: "资产钱包自动续费复机结果消费者", Source: constants.AuditSourceWorker,
|
||||
CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID,
|
||||
})
|
||||
return c.service.ExecuteResume(ctx, payload.AttemptID)
|
||||
}
|
||||
|
||||
// ExecuteResume 认领并执行一次自动续费复机,回写尝试记录的复机状态、外部交互号与失败原因。
|
||||
//
|
||||
// 「回写复机终态 + 投递失败通知 + 失败审计」在同一个短事务内闭合:任一失败整体回滚,
|
||||
// 记录退回「已投递且已提交」,由恢复扫描按只读查询继续收敛,因此通知不会因一次写入抖动而永久丢失。
|
||||
// 复机失败或结果未知时绝不回滚续费事实:订单、套餐生效与钱包扣款保持已提交状态。
|
||||
func (s *Service) ExecuteResume(ctx context.Context, attemptID uint) error {
|
||||
if s.resume == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
|
||||
}
|
||||
attempt, err := s.attemptStore.Load(ctx, attemptID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费尝试记录失败")
|
||||
}
|
||||
if attempt.ResumeStatus != constants.AssetAutoRenewalResumeStatusRequested {
|
||||
// 已收敛或未投递复机:重复投递与非复机尝试都按幂等结束。
|
||||
return nil
|
||||
}
|
||||
claimed, err := s.attemptStore.ClaimResumeSubmission(ctx, attemptID, s.now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
// 认领已被占用:可能是并发重复投递,也可能是上次「已调用但未回写」的进程中断。
|
||||
// 两种情况都不得再次调用运营商,留给恢复扫描按只读查询收敛。
|
||||
s.logger.Info("自动续费复机已被并发执行,跳过重复调用", zap.Uint("attempt_id", attemptID))
|
||||
return nil
|
||||
}
|
||||
outcome, resumeErr := s.resume.ResumeAssetForAutoRenewal(ctx, attempt.AssetType, attempt.AssetID)
|
||||
status := constants.AssetAutoRenewalResumeStatusUnknown
|
||||
reason := outcome.SafeReason
|
||||
switch {
|
||||
case !outcome.Applied:
|
||||
// 判定在执行时已不成立:按跳过记录,不通知,也不改写任何续费事实。
|
||||
status = constants.AssetAutoRenewalResumeStatusSkipped
|
||||
reason = ""
|
||||
case outcome.Result == constants.AuditResultSuccess:
|
||||
status = constants.AssetAutoRenewalResumeStatusSucceeded
|
||||
reason = ""
|
||||
case outcome.Result == constants.AuditResultFailed:
|
||||
status = constants.AssetAutoRenewalResumeStatusFailed
|
||||
reason = resumeFailureDetail(outcome.SafeReason)
|
||||
default:
|
||||
status = constants.AssetAutoRenewalResumeStatusUnknown
|
||||
reason = resumeFailureDetail(outcome.SafeReason)
|
||||
}
|
||||
if err := s.finalizeResumeOutcome(ctx, attempt, status, reason, outcome.IntegrationID); err != nil {
|
||||
return err
|
||||
}
|
||||
if resumeErr != nil {
|
||||
s.logger.Warn("自动续费复机执行未确认完成",
|
||||
zap.Uint("attempt_id", attemptID), zap.String("result", outcome.Result), zap.Error(resumeErr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalizeResumeOutcome 在同一短事务内回写复机终态;确认失败时同事务投递通知并写失败审计。
|
||||
func (s *Service) finalizeResumeOutcome(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, status int, reason, integrationID string) error {
|
||||
expected := []int{constants.AssetAutoRenewalResumeStatusRequested}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
updated, err := s.attemptStore.MarkResumeOutcomeInTx(ctx, tx, attempt.ID, expected, status, integrationID, reason)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
// 已被并发收敛:不重复投递通知与审计。
|
||||
s.logger.Info("自动续费复机结果已被并发收敛,跳过通知与审计", zap.Uint("attempt_id", attempt.ID))
|
||||
return nil
|
||||
}
|
||||
if status != constants.AssetAutoRenewalResumeStatusFailed {
|
||||
return nil
|
||||
}
|
||||
if err := s.appendFailureNotifications(ctx, tx, failureNotification{
|
||||
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
|
||||
Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID),
|
||||
ShopID: attempt.ShopID, CustomerID: attempt.CustomerID,
|
||||
TriggerDate: attempt.TriggerDate, Reason: constants.AssetAutoRenewalFailureResumeFailed,
|
||||
PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.appendResumeFailureAudit(ctx, tx, attempt, reason)
|
||||
})
|
||||
}
|
||||
|
||||
// appendResumeFailureAudit 在复机失败终态事务内写统一审计,主资源为本次尝试记录。
|
||||
func (s *Service) appendResumeFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason string) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
|
||||
}
|
||||
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费复机失败",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
|
||||
ErrorSummary: reason,
|
||||
CorrelationID: attemptID,
|
||||
Metadata: map[string]any{
|
||||
"asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
|
||||
"failure_kind": constants.AssetAutoRenewalFailureResumeFailed,
|
||||
},
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
|
||||
DisplayName: "自动续费尝试 " + attemptID,
|
||||
Relation: constants.AuditResourceRelationPrimary,
|
||||
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
|
||||
"resume_status": constants.AssetAutoRenewalResumeStatusFailed,
|
||||
"failure_reason": constants.AssetAutoRenewalFailureResumeFailed,
|
||||
},
|
||||
BeforeData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusRequested},
|
||||
AfterData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusFailed},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
// resumeFailureDetail 组装可安全展示的复机失败原因,不写渠道报文原文。
|
||||
func resumeFailureDetail(safeReason string) string {
|
||||
if safeReason == "" {
|
||||
return "复机结果确认为失败"
|
||||
}
|
||||
return safeReason
|
||||
}
|
||||
136
internal/application/assetautorenewal/notify.go
Normal file
136
internal/application/assetautorenewal/notify.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
)
|
||||
|
||||
// failureNotification 是一条失败通知事件所需冻结的事实。
|
||||
type failureNotification struct {
|
||||
AttemptID uint
|
||||
AssetType string
|
||||
AssetID uint
|
||||
Identifier string
|
||||
ShopID *uint
|
||||
CustomerID uint
|
||||
TriggerDate time.Time
|
||||
Reason string
|
||||
PackageName string
|
||||
FinalExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// appendFailureNotifications 在调用方事务内为当前个人客户与资产所属店铺各写一条幂等通知事件。
|
||||
//
|
||||
// 每日至多一条由上锁的两个条件推出:同一资产同一自然日至多一次尝试,且幂等键内嵌资产类型与资产 ID、
|
||||
// 上海自然日、原因类型与接收人。资产所属店铺当时无有效业务员时不阻断:店铺接收人由既有店铺解析
|
||||
// 在投递期完成,解析为空列表即正常结束,不影响续费事实与尝试记录;资产无店铺归属时只创建客户通知。
|
||||
// 非通知原因(如占位中断收敛)一律不投递,未登记原因按 fail-closed 处理。
|
||||
func (s *Service) appendFailureNotifications(ctx context.Context, tx *gorm.DB, request failureNotification) error {
|
||||
if s.outbox == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "自动续费通知 Outbox 未配置")
|
||||
}
|
||||
if !constants.IsAssetAutoRenewalNotifiableFailureReason(request.Reason) {
|
||||
s.logger.Warn("自动续费失败原因不属于通知口径,已跳过通知投递",
|
||||
zap.Uint("attempt_id", request.AttemptID), zap.String("reason", request.Reason))
|
||||
return nil
|
||||
}
|
||||
templateData := map[string]string{
|
||||
"asset_identifier": request.Identifier,
|
||||
"package_name": request.PackageName,
|
||||
"failure_reason": constants.GetAssetAutoRenewalFailureReasonName(request.Reason),
|
||||
"expiry_date": formatShanghaiDate(request.FinalExpiresAt, s.now()),
|
||||
}
|
||||
assetIDText := strconv.FormatUint(uint64(request.AssetID), 10)
|
||||
// 资源引用按资产类型选择既有可跳转目标:卡用 iot_card 详情、设备用 device 详情
|
||||
// (两者都在 internal/query/notification/target.go 的目标定义里,idTarget + 可用性复核),
|
||||
// 使店铺/业务员点开通知能进入对应资产详情,而不是落到无目标类型。
|
||||
refType := assetRefType(request.AssetType)
|
||||
expiresAt := request.FinalExpiresAt
|
||||
if expiresAt == nil {
|
||||
fallback := s.now().UTC()
|
||||
expiresAt = &fallback
|
||||
}
|
||||
if request.CustomerID > 0 {
|
||||
eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "c", request.CustomerID)
|
||||
_, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification,
|
||||
PayloadVersion: constants.NotificationPayloadVersionV1,
|
||||
AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10),
|
||||
ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID,
|
||||
Payload: notificationapp.PersonalCustomerDirectPayload{
|
||||
RecipientID: request.CustomerID, NotificationType: constants.NotificationTypeAssetAutoRenewalFailed,
|
||||
TemplateData: templateData, RefType: refType, RefID: assetIDText,
|
||||
ExpiresAt: expiresAt,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费客户通知事件失败")
|
||||
}
|
||||
}
|
||||
if request.ShopID == nil || *request.ShopID == 0 {
|
||||
return nil
|
||||
}
|
||||
eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "shop", *request.ShopID)
|
||||
_, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification,
|
||||
PayloadVersion: constants.NotificationPayloadVersionV1,
|
||||
AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10),
|
||||
ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID,
|
||||
Payload: notificationapp.AdminDynamicPayload{
|
||||
TargetKind: constants.NotificationTargetKindShop, TargetID: *request.ShopID,
|
||||
NotificationType: constants.NotificationTypeAssetAutoRenewalFailed,
|
||||
TemplateData: templateData, RefType: refType, RefID: assetIDText,
|
||||
ExpiresAt: expiresAt,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费店铺通知事件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assetRefType 把资产类型映射为可跳转的通知引用类型(卡片详情 / 设备详情)。
|
||||
func assetRefType(assetType string) string {
|
||||
if assetType == constants.AssetWalletResourceTypeDevice {
|
||||
return constants.NotificationRefTypeDevice
|
||||
}
|
||||
return constants.NotificationRefTypeIotCard
|
||||
}
|
||||
|
||||
// failureEventID 构造失败通知的稳定幂等键。
|
||||
//
|
||||
// 键内嵌资产类型与资产 ID、上海自然日、原因类型与接收人;复机失败沿用该次尝试的日期键,
|
||||
// 因此同一尝试只通知一次且不跨日新增。超长时由 outboxid.Stable 追加稳定摘要,仍保持唯一。
|
||||
func failureEventID(assetType string, assetID uint, triggerDate time.Time, reason, recipientKind string, recipientID uint) string {
|
||||
dateKey := triggerDate.In(shanghaiLocation).Format("20060102")
|
||||
return outboxid.Stable("aar:", fmt.Sprintf("%s:%d:%s:%s:%s:%d",
|
||||
assetCode(assetType), assetID, dateKey, reason, recipientKind, recipientID))
|
||||
}
|
||||
|
||||
// assetCode 把资产类型压缩为单字母代码,只为把幂等键长度压进 Outbox 预算。
|
||||
func assetCode(assetType string) string {
|
||||
if assetType == constants.AssetWalletResourceTypeDevice {
|
||||
return "d"
|
||||
}
|
||||
return "c"
|
||||
}
|
||||
|
||||
// formatShanghaiDate 把业务到期时间格式化为上海自然日文本,供通知模板与展示期使用。
|
||||
func formatShanghaiDate(value *time.Time, fallback time.Time) string {
|
||||
target := fallback
|
||||
if value != nil {
|
||||
target = *value
|
||||
}
|
||||
return target.In(shanghaiLocation).Format("2006-01-02")
|
||||
}
|
||||
108
internal/application/assetautorenewal/recovery.go
Normal file
108
internal/application/assetautorenewal/recovery.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RecoveryResult 是一次复机结果恢复扫描的可观察结果。
|
||||
//
|
||||
// Scanned 为扫到的未收敛尝试数;Confirmed 为本次回填为已确认结果的尝试数;
|
||||
// Pending 为结果仍未确认、等待下次扫描的尝试数;Anomaly 为超过查询窗口仍不可确认、
|
||||
// 本次标记转人工的尝试数。
|
||||
type RecoveryResult struct {
|
||||
Scanned int
|
||||
Confirmed int
|
||||
Pending int
|
||||
Anomaly int
|
||||
}
|
||||
|
||||
// RecoverResumeResults 扫描未收敛的复机子结果:只查询运营商状态回填,绝不重复发起复机调用。
|
||||
//
|
||||
// 收敛口径与既有停复机恢复一致(internal/application/carrierthreshold/cycle.go:246-296):
|
||||
// - 查询确认已复机 → 回填成功;
|
||||
// - 「已知但未复机」或不可判定 → 仍算未确认,等到下一次扫描;
|
||||
// - 自提交起超过查询窗口仍不可确认 → 标记异常并退出自动扫描转人工核对。
|
||||
//
|
||||
// 恢复扫描**不**据此判定「复机失败」:续购后新主套餐多为待生效,卡在此期间本就可能仍处于停机,
|
||||
// 把「未复机」当失败会发出误报通知。复机失败只由消费者在网关明确返回失败时确认(「仅确认失败才通知」)。
|
||||
// 单条失败不中断整批,但会作为首个错误返回,交既有任务重试。
|
||||
func (s *Service) RecoverResumeResults(ctx context.Context) (RecoveryResult, error) {
|
||||
result := RecoveryResult{}
|
||||
if s.resume == nil {
|
||||
return result, errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
|
||||
}
|
||||
now := s.now()
|
||||
attempts, err := s.attemptStore.ScanUnresolvedResumes(ctx, now, constants.AssetAutoRenewalRecoveryBatchSize)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Scanned = len(attempts)
|
||||
var firstErr error
|
||||
for index := range attempts {
|
||||
if err := s.recoverResumeResult(ctx, &attempts[index], now, &result); err != nil {
|
||||
s.logger.Warn("自动续费复机结果恢复单条失败",
|
||||
zap.Uint("attempt_id", attempts[index].ID), zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logger.Info("自动续费复机结果恢复扫描完成",
|
||||
zap.Int("scanned", result.Scanned), zap.Int("confirmed", result.Confirmed),
|
||||
zap.Int("pending", result.Pending), zap.Int("anomaly", result.Anomaly))
|
||||
return result, firstErr
|
||||
}
|
||||
|
||||
// recoverResumeResult 处理单条未收敛的复机子结果。
|
||||
func (s *Service) recoverResumeResult(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, now time.Time, result *RecoveryResult) error {
|
||||
online, known, integrationID, err := s.resume.QueryAutoRenewalResumeState(ctx, attempt.AssetType, attempt.AssetID)
|
||||
if err != nil || !known || !online {
|
||||
// 查询失败、状态不可判定、或已知仍未复机:一律按「仍未确认」处理,
|
||||
// 绝不误判为失败终态,也绝不据此发出失败通知。
|
||||
result.Pending++
|
||||
if !expiredResumeQueryWindow(attempt.ResumeSubmittedAt, now) {
|
||||
return nil
|
||||
}
|
||||
marked, markErr := s.attemptStore.MarkResumeAnomaly(ctx, attempt.ID,
|
||||
"复机结果超过确认窗口仍不可查,请人工核对")
|
||||
if markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
if marked {
|
||||
result.Anomaly++
|
||||
s.logger.Warn("自动续费复机结果超期不可确认,已标记异常转人工",
|
||||
zap.Uint("attempt_id", attempt.ID), zap.String("asset_type", attempt.AssetType),
|
||||
zap.Uint("asset_id", attempt.AssetID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
expected := []int{
|
||||
constants.AssetAutoRenewalResumeStatusRequested,
|
||||
constants.AssetAutoRenewalResumeStatusUnknown,
|
||||
}
|
||||
marked, markErr := s.attemptStore.MarkResumeOutcome(ctx, attempt.ID, expected,
|
||||
constants.AssetAutoRenewalResumeStatusSucceeded, integrationID, "")
|
||||
if markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
if marked {
|
||||
result.Confirmed++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expiredResumeQueryWindow 判断复机子任务自提交起是否已超过自动查询窗口。
|
||||
// 未提交(提交认领时刻为空)表示尚未发起复机,不算超期。
|
||||
func expiredResumeQueryWindow(submittedAt *time.Time, now time.Time) bool {
|
||||
if submittedAt == nil {
|
||||
return false
|
||||
}
|
||||
return now.Sub(*submittedAt) >= constants.AssetAutoRenewalResumeQueryWindow
|
||||
}
|
||||
809
internal/application/assetautorenewal/renew.go
Normal file
809
internal/application/assetautorenewal/renew.go
Normal file
@@ -0,0 +1,809 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
|
||||
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
)
|
||||
|
||||
// renewalHalt 是执行事务内重读资格事实后必须终止本次尝试的可安全记录原因。
|
||||
//
|
||||
// 它作为事务闭包的返回错误使已取得行锁的续费事务整体回滚(此时尚未写入任何资金与订单事实),
|
||||
// 再由调用方在独立短事务中落终态、投递通知与写审计。
|
||||
// FailureReason 与 SkipReason 恰有一个非空:非空失败原因触发通知,跳过原因不触发通知。
|
||||
type renewalHalt struct {
|
||||
FailureReason string
|
||||
SkipReason string
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Error 实现 error,使事务闭包能把终止信号回传给调用方。
|
||||
func (h *renewalHalt) Error() string {
|
||||
if h.SkipReason != "" {
|
||||
return "自动续费跳过:" + constants.GetAssetAutoRenewalSkipReasonName(h.SkipReason)
|
||||
}
|
||||
return "自动续费未执行:" + constants.GetAssetAutoRenewalFailureReasonName(h.FailureReason)
|
||||
}
|
||||
|
||||
// renewalFacts 是一次续费执行成功后用于运行日志的关键事实。
|
||||
type renewalFacts struct {
|
||||
RenewPrice int64
|
||||
OrderID uint
|
||||
OrderNo string
|
||||
}
|
||||
|
||||
// executeRenewal 在单个事务内闭合一次续购:先锁资产钱包行、后锁资产载体行,锁后重读全部资格事实,
|
||||
// 再扣可用余额、建订单与明细、写已支付支付记录、写钱包流水、激活套餐、写佣金与观测 Outbox、
|
||||
// 更新尝试记录为成功并写成功审计。任一步失败整体回滚,不存在部分成功状态。
|
||||
//
|
||||
// windowDays 是本次扫描使用的配置窗口,必须传入实际配置值:窗口是触发条件而不是资格不变式,
|
||||
// 用常量上限会让「人工已把最终到期推远」被误判为失败。
|
||||
//
|
||||
// 返回 halt 表示重读后应落失败或跳过终态(事务已回滚且未写入任何事实);返回 err 表示事务失败。
|
||||
func (s *Service) executeRenewal(ctx context.Context, candidate assetquery.Candidate, attempt *model.AssetAutoRenewalAttempt, windowDays int) (*renewalFacts, *renewalHalt, error) {
|
||||
if s.outbox == nil {
|
||||
return nil, nil, errors.New(errors.CodeInvalidStatus, "自动续费 Outbox 未配置")
|
||||
}
|
||||
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, candidate.AssetType, candidate.AssetID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "资产钱包不存在,无法以可用余额续购",
|
||||
}, nil
|
||||
}
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "读取资产钱包失败")
|
||||
}
|
||||
facts := &renewalFacts{}
|
||||
var halt *renewalHalt
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 锁序固定为「先资产钱包行、后资产载体行」,与人工路径的「先冻结钱包、后激活套餐」一致,
|
||||
// 避免与人工事务的锁序反转形成死锁。
|
||||
lockedWallet, lockErr := s.assetWalletStore.LockByIDWithTx(ctx, tx, wallet.ID)
|
||||
if lockErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定资产钱包失败")
|
||||
}
|
||||
if carrierErr := s.lockCarrier(ctx, tx, candidate.AssetType, candidate.AssetID); carrierErr != nil {
|
||||
return carrierErr
|
||||
}
|
||||
// 锁后重读:最终到期、当前主套餐、待生效主套餐、可售续费价与可用余额都以重读结果为准。
|
||||
execution, execErr := s.rereadUnderLock(ctx, tx, candidate, lockedWallet, windowDays)
|
||||
if execErr != nil {
|
||||
var halted *renewalHalt
|
||||
if asRenewalHalt(execErr, &halted) {
|
||||
halt = halted
|
||||
return execErr
|
||||
}
|
||||
return execErr
|
||||
}
|
||||
if err := s.writeRenewalFacts(ctx, tx, execution, attempt, facts); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if halt != nil {
|
||||
return nil, halt, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
return facts, nil, nil
|
||||
}
|
||||
|
||||
// executionPlan 是锁后重读得到的执行输入。
|
||||
type executionPlan struct {
|
||||
candidate assetquery.Candidate
|
||||
asset *assetSnapshot
|
||||
wallet *model.AssetWallet
|
||||
pkg *model.Package
|
||||
sellerShop *uint
|
||||
price int64
|
||||
costPrice int64
|
||||
}
|
||||
|
||||
// assetSnapshot 是执行事务内锁定的资产事实。
|
||||
type assetSnapshot struct {
|
||||
assetType string
|
||||
assetID uint
|
||||
identifier string
|
||||
shopID *uint
|
||||
seriesID *uint
|
||||
generation int
|
||||
}
|
||||
|
||||
// rereadUnderLock 在行锁内重读全部资格事实,并给出可执行或必须终止的判断。
|
||||
//
|
||||
// 判定顺序体现「资格不变式先于触发条件」:
|
||||
// 1. 先判资格不变式——已存在待生效主套餐即「人工已完成续购 / 不叠加周期」,无论最终到期被推到多远
|
||||
// 都 MUST 跳过(规格 Requirement 8),绝不退化为「不可续费」失败与错误通知;
|
||||
// 2. 再判窗口与推算(触发条件)——不在窗口或推算不再明确,才是「当前条件不允许自动续购」。
|
||||
//
|
||||
// 之后依次判在途人工订单、钱包状态、可售续费价与可用余额。
|
||||
func (s *Service) rereadUnderLock(ctx context.Context, tx *gorm.DB, candidate assetquery.Candidate, wallet *model.AssetWallet, windowDays int) (*executionPlan, error) {
|
||||
asset, err := s.lockAndSnapshotAsset(ctx, tx, candidate.AssetType, candidate.AssetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inTxQuery := s.candidates.WithDB(tx)
|
||||
// 第 1 步:资格不变式(先于窗口判定)。
|
||||
state, err := inTxQuery.MainUsageStateOf(ctx, candidate.AssetType, candidate.AssetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state.HasPendingMainPackage {
|
||||
return nil, &renewalHalt{
|
||||
SkipReason: constants.AssetAutoRenewalSkipManualRenewed,
|
||||
Detail: "锁后重读发现该资产已存在待生效主套餐",
|
||||
}
|
||||
}
|
||||
if state.CurrentPackageID == 0 {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "锁后重读未找到当前主套餐商品",
|
||||
}
|
||||
}
|
||||
// 第 2 步:触发条件(窗口与推算口径),窗口取本次扫描的配置值。
|
||||
current, err := inTxQuery.Candidate(ctx, candidate.AssetType, candidate.AssetID, windowDays)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if current == nil {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "锁后重读最终到期已不在触发窗口或推算结果不再明确",
|
||||
}
|
||||
}
|
||||
inFlight, err := inTxQuery.OpenManualMainPackageOrder(ctx, wallet.ID, candidate.AssetType, candidate.AssetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inFlight {
|
||||
return nil, &renewalHalt{
|
||||
SkipReason: constants.AssetAutoRenewalSkipManualOrderPending,
|
||||
Detail: "锁后重读发现该资产存在未关闭的个人资产钱包主套餐订单",
|
||||
}
|
||||
}
|
||||
if wallet.Status != constants.AssetWalletStatusNormal {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "资产钱包当前不可用于扣款",
|
||||
}
|
||||
}
|
||||
price, pkg, sellerShop, costPrice, err := s.resolveExecutablePrice(ctx, candidate.AssetType, candidate.AssetID, current.CurrentPackageID)
|
||||
if err != nil {
|
||||
var halted *renewalHalt
|
||||
if asRenewalHalt(err, &halted) {
|
||||
return nil, halted
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if wallet.GetAvailableBalance() < price {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureInsufficientBalance,
|
||||
Detail: "资产钱包可用余额小于执行时当前可售续费价",
|
||||
}
|
||||
}
|
||||
return &executionPlan{
|
||||
candidate: *current, asset: asset, wallet: wallet, pkg: pkg,
|
||||
sellerShop: sellerShop, price: price, costPrice: costPrice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveExecutablePrice 复用应用层购买校验与价格策略取得可续费判定与执行时续费价。
|
||||
//
|
||||
// 校验入口是个人卡/设备购买校验(含续费豁免下架与生效零售价、成本价比较),
|
||||
// 绝不依赖 handler 层续费价实现;任何校验失败都归一为「不可续费」并保留可安全记录的说明。
|
||||
func (s *Service) resolveExecutablePrice(ctx context.Context, assetType string, assetID, renewPackageID uint) (int64, *model.Package, *uint, int64, error) {
|
||||
if s.purchaseValidation == nil {
|
||||
return 0, nil, nil, 0, errors.New(errors.CodeServiceUnavailable, "购买校验能力未配置")
|
||||
}
|
||||
packageIDs := []uint{renewPackageID}
|
||||
var result *purchase_validation.PurchaseValidationResult
|
||||
var err error
|
||||
switch assetType {
|
||||
case constants.AssetWalletResourceTypeIotCard:
|
||||
result, err = s.purchaseValidation.ValidatePersonalCardPurchase(ctx, assetID, packageIDs)
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
result, err = s.purchaseValidation.ValidatePersonalDevicePurchase(ctx, assetID, packageIDs)
|
||||
default:
|
||||
return 0, nil, nil, 0, errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, nil, nil, 0, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "当前条件不允许自动续购:" + purchaseValidationReason(err),
|
||||
}
|
||||
}
|
||||
if len(result.Packages) == 0 {
|
||||
return 0, nil, nil, 0, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "当前条件不允许自动续购:未解析到可售续费套餐",
|
||||
}
|
||||
}
|
||||
var sellerShop uint
|
||||
if result.Card != nil && result.Card.ShopID != nil {
|
||||
sellerShop = *result.Card.ShopID
|
||||
}
|
||||
if result.Device != nil && result.Device.ShopID != nil {
|
||||
sellerShop = *result.Device.ShopID
|
||||
}
|
||||
costPrice := int64(0)
|
||||
if sellerShop > 0 {
|
||||
resolved, costErr := s.purchaseValidation.GetCostPrice(ctx, result.Packages[0], sellerShop)
|
||||
if costErr != nil {
|
||||
return 0, nil, nil, 0, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "当前条件不允许自动续购:渠道成本价不可读",
|
||||
}
|
||||
}
|
||||
costPrice = resolved
|
||||
}
|
||||
if result.TotalPrice <= 0 {
|
||||
return 0, nil, nil, 0, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "当前条件不允许自动续购:生效续费价异常",
|
||||
}
|
||||
}
|
||||
var sellerShopPtr *uint
|
||||
if sellerShop > 0 {
|
||||
sellerShopPtr = &sellerShop
|
||||
}
|
||||
return result.TotalPrice, result.Packages[0], sellerShopPtr, costPrice, nil
|
||||
}
|
||||
|
||||
// writeRenewalFacts 在同一事务内闭合扣款、订单、支付、钱包流水、套餐生效、可靠事件、尝试记录与审计。
|
||||
func (s *Service) writeRenewalFacts(ctx context.Context, tx *gorm.DB, plan *executionPlan, attempt *model.AssetAutoRenewalAttempt, facts *renewalFacts) error {
|
||||
now := s.now()
|
||||
wallet := plan.wallet
|
||||
if err := s.assetWalletStore.DeductBalanceWithTx(ctx, tx, wallet.ID, plan.price, wallet.Version); err != nil {
|
||||
return errors.Wrap(errors.CodeConflict, err, "资产钱包扣款失败")
|
||||
}
|
||||
order, item, err := s.buildRenewalOrder(ctx, tx, plan, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单失败")
|
||||
}
|
||||
item.OrderID = order.ID
|
||||
if err := tx.WithContext(ctx).Create(item).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单明细失败")
|
||||
}
|
||||
payment := &model.Payment{
|
||||
PaymentNo: order.OrderNo,
|
||||
OrderID: order.ID,
|
||||
OrderType: model.PaymentOrderTypePackage,
|
||||
PaymentMethod: model.PaymentByWallet,
|
||||
Amount: plan.price,
|
||||
Status: model.PaymentRecordStatusPaid,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(payment).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费支付记录失败")
|
||||
}
|
||||
referenceType := constants.ReferenceTypeOrder
|
||||
walletTransaction := &model.AssetWalletTransaction{
|
||||
AssetWalletID: wallet.ID,
|
||||
ResourceType: wallet.ResourceType,
|
||||
ResourceID: wallet.ResourceID,
|
||||
UserID: plan.candidate.CustomerID,
|
||||
TransactionType: constants.AssetTransactionTypeDeduct,
|
||||
Amount: -plan.price,
|
||||
BalanceBefore: wallet.Balance,
|
||||
BalanceAfter: wallet.Balance - plan.price,
|
||||
Status: constants.TransactionStatusSuccess,
|
||||
ReferenceType: &referenceType,
|
||||
ReferenceNo: &order.OrderNo,
|
||||
Creator: plan.candidate.CustomerID,
|
||||
ShopIDTag: wallet.ShopIDTag,
|
||||
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
}
|
||||
if err := s.walletTransactionStore.CreateWithTx(ctx, tx, walletTransaction); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入续费钱包流水失败")
|
||||
}
|
||||
usage, err := s.activateMainPackage(ctx, tx, order, plan, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := commissiondelivery.AppendCommissionCalculate(ctx, tx, s.outbox, order.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.observationEvents == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "自动续费观测 Outbox 未配置")
|
||||
}
|
||||
observationID := "asset-auto-renewal:" + strconv.FormatUint(uint64(attempt.ID), 10)
|
||||
if err := s.observationEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
|
||||
EventID: outboxEventID(observationID), Scene: constants.CardObservationScenePackageChanged,
|
||||
ResourceType: observationResourceType(plan.asset.assetType), ResourceID: plan.asset.assetID,
|
||||
SyncTypes: []string{
|
||||
constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic,
|
||||
constants.CardObservationSyncTypeNetwork,
|
||||
},
|
||||
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now.UTC(),
|
||||
RequestID: observationID, CorrelationID: observationID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
updates := map[string]any{
|
||||
"status": constants.AssetAutoRenewalAttemptStatusSucceeded,
|
||||
"failure_reason": "",
|
||||
"failure_detail": "",
|
||||
"skip_reason": "",
|
||||
"final_expires_at": plan.candidate.FinalExpiresAt,
|
||||
"current_usage_id": plan.candidate.CurrentUsageID,
|
||||
"current_package_id": plan.candidate.CurrentPackageID,
|
||||
"renew_package_id": plan.pkg.ID,
|
||||
"renew_price": plan.price,
|
||||
"wallet_id": wallet.ID,
|
||||
"wallet_transaction_id": walletTransaction.ID,
|
||||
"deduct_amount": plan.price,
|
||||
"balance_before": wallet.Balance,
|
||||
"balance_after": wallet.Balance - plan.price,
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
}
|
||||
// current_usage_id / current_package_id 保留**触发时**解析到的当前主套餐快照(规格 Requirement 7
|
||||
// 要求「触发时解析」),不覆盖为本次新生成的套餐使用记录;新记录通过 order_id / order_no 追溯,
|
||||
// 「续购后处于待生效」也可由 usage_after_success 断言直接观察。
|
||||
resumeReady, resumeReason, err := s.evaluateResumeGate(ctx, plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resumeReady {
|
||||
updates["resume_status"] = constants.AssetAutoRenewalResumeStatusRequested
|
||||
updates["resume_failure_reason"] = ""
|
||||
} else {
|
||||
updates["resume_status"] = constants.AssetAutoRenewalResumeStatusSkipped
|
||||
updates["resume_failure_reason"] = resumeReason
|
||||
}
|
||||
updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, updates)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
return errors.Wrap(errors.CodeConflict, gorm.ErrInvalidData, "续费尝试已非处理中,拒绝重复成功")
|
||||
}
|
||||
facts.RenewPrice = plan.price
|
||||
facts.OrderID = order.ID
|
||||
facts.OrderNo = order.OrderNo
|
||||
if resumeReady {
|
||||
attempt.ResumeStatus = constants.AssetAutoRenewalResumeStatusRequested
|
||||
if err := AppendResumeRequested(ctx, tx, s.outbox, attempt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.appendRenewalAudit(ctx, tx, plan, attempt, order, payment, wallet, walletTransaction, usage, updates)
|
||||
}
|
||||
|
||||
// evaluateResumeGate 在同一事务内按可复机判定给出复机去向。
|
||||
//
|
||||
// 判定只做数据库读取、不持有任何外部 I/O,因此可以安全地留在资金事务闭包内(ENG-TX-001);
|
||||
// 它也不对资产钱包行或载体行加锁,因此不会与已持有的行锁形成等待。
|
||||
func (s *Service) evaluateResumeGate(ctx context.Context, plan *executionPlan) (bool, string, error) {
|
||||
if s.resume == nil {
|
||||
return false, "", errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
|
||||
}
|
||||
ready, reason, err := s.resume.AutoRenewalResumeReady(ctx, plan.asset.assetType, plan.asset.assetID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
return ready, reason, nil
|
||||
}
|
||||
|
||||
// buildRenewalOrder 组装续购订单与唯一明细:买家恒为当前个人客户,金额为执行时当前可售续费价。
|
||||
func (s *Service) buildRenewalOrder(ctx context.Context, tx *gorm.DB, plan *executionPlan, now time.Time) (*model.Order, *model.OrderItem, error) {
|
||||
orderType := model.OrderTypeSingleCard
|
||||
var iotCardID, deviceID *uint
|
||||
if plan.asset.assetType == constants.AssetWalletResourceTypeDevice {
|
||||
orderType = model.OrderTypeDevice
|
||||
deviceID = &plan.asset.assetID
|
||||
} else {
|
||||
iotCardID = &plan.asset.assetID
|
||||
}
|
||||
generation := plan.asset.generation
|
||||
if generation <= 0 {
|
||||
generation = 1
|
||||
}
|
||||
paidAmount := plan.price
|
||||
operatorAccountID, operatorAccountName := s.personalCustomerOperatorSnapshot(ctx, plan.candidate.CustomerID)
|
||||
order := &model.Order{
|
||||
BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID},
|
||||
OrderNo: s.orderStore.GenerateOrderNo(), OrderType: orderType,
|
||||
BuyerType: model.BuyerTypePersonal, BuyerID: plan.candidate.CustomerID,
|
||||
IotCardID: iotCardID, DeviceID: deviceID, AssetIdentifier: plan.asset.identifier,
|
||||
TotalAmount: plan.price, PaymentMethod: model.PaymentMethodWallet,
|
||||
PaymentStatus: model.PaymentStatusPaid, PaidAt: &now,
|
||||
CommissionStatus: model.CommissionStatusPending, CommissionConfigVersion: 0,
|
||||
Source: constants.OrderSourceClient, Generation: generation, ActualPaidAmount: &paidAmount,
|
||||
OperatorAccountID: operatorAccountID, OperatorAccountType: model.OperatorAccountTypePersonalCustomer,
|
||||
OperatorAccountName: operatorAccountName, SellerShopID: plan.sellerShop,
|
||||
SeriesID: plan.asset.seriesID, SellerCostPrice: plan.costPrice,
|
||||
}
|
||||
item := &model.OrderItem{
|
||||
BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID},
|
||||
PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, Quantity: 1,
|
||||
UnitPrice: plan.price, Amount: plan.price,
|
||||
PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift,
|
||||
}
|
||||
return order, item, nil
|
||||
}
|
||||
|
||||
// personalCustomerOperatorSnapshot 读取个人客户昵称作为订单操作者名称快照。
|
||||
func (s *Service) personalCustomerOperatorSnapshot(ctx context.Context, customerID uint) (*uint, string) {
|
||||
if customerID == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
customer, err := s.personalCustomerStore.GetByID(ctx, customerID)
|
||||
if err != nil {
|
||||
return &customerID, ""
|
||||
}
|
||||
return &customerID, customer.Nickname
|
||||
}
|
||||
|
||||
// activateMainPackage 在同一事务内激活续购的主套餐:按既有排队规则决定待生效或立即生效。
|
||||
//
|
||||
// 资格前置保证本次执行前不存在待生效主套餐,因此续购最多领先一个周期;
|
||||
// 只有当当前主套餐在执行前刚好过期时新记录才立即生效,此时按既有规则追加套餐生效优先轮询请求。
|
||||
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, plan *executionPlan, now time.Time) (*model.PackageUsage, error) {
|
||||
terms, err := packagepkg.ResolveTermsFromTx(ctx, tx, plan.pkg, order.SellerShopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasCurrentMain, err := packagepkg.HasCurrentMainPackageForQueue(tx.WithContext(ctx), plan.asset.assetType, plan.asset.assetID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var status, priority int
|
||||
var activatedAt, expiresAt time.Time
|
||||
var nextResetAt *time.Time
|
||||
pendingRealnameActivation := false
|
||||
if terms.ExpiryBase == constants.PackageExpiryBaseFromActivation {
|
||||
realnamed, realnameErr := s.isCarrierRealnamed(ctx, tx, plan.asset.assetType, plan.asset.assetID)
|
||||
if realnameErr != nil {
|
||||
return nil, realnameErr
|
||||
}
|
||||
pendingRealnameActivation = !realnamed
|
||||
}
|
||||
if hasCurrentMain {
|
||||
status = constants.PackageUsageStatusPending
|
||||
var maxPriority int
|
||||
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).
|
||||
Where(carrierColumn(plan.asset.assetType)+" = ?", plan.asset.assetID).
|
||||
Select("COALESCE(MAX(priority), 0)").Scan(&maxPriority).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐排队优先级失败")
|
||||
}
|
||||
priority = maxPriority + 1
|
||||
} else {
|
||||
priority = 1
|
||||
if pendingRealnameActivation {
|
||||
status = constants.PackageUsageStatusPending
|
||||
} else {
|
||||
status = constants.PackageUsageStatusActive
|
||||
activatedAt = now
|
||||
expiresAt = packagepkg.CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays)
|
||||
nextResetAt = packagepkg.CalculateNextResetTime(plan.pkg.DataResetCycle, terms.CalendarType, now, activatedAt)
|
||||
}
|
||||
}
|
||||
virtualTotalMB, displayGainRatio, enableVirtualData := model.BuildPackageUsageSnapshotValues(plan.pkg)
|
||||
retailAmount := order.TotalAmount
|
||||
usage := &model.PackageUsage{
|
||||
BaseModel: model.BaseModel{Creator: order.Creator, Updater: order.Creator},
|
||||
OrderID: order.ID, OrderNo: order.OrderNo,
|
||||
PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, UsageType: order.OrderType,
|
||||
DataLimitMB: plan.pkg.RealDataMB,
|
||||
VirtualTotalMBSnapshot: virtualTotalMB, DisplayGainRatioSnapshot: displayGainRatio,
|
||||
EnableVirtualDataSnapshot: enableVirtualData, Status: status, Priority: priority,
|
||||
DataResetCycle: plan.pkg.DataResetCycle, PendingRealnameActivation: pendingRealnameActivation,
|
||||
Generation: order.Generation, PaidAmount: &order.SellerCostPrice, RetailAmount: &retailAmount,
|
||||
PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift,
|
||||
}
|
||||
terms.Apply(usage)
|
||||
if plan.asset.assetType == constants.AssetWalletResourceTypeIotCard {
|
||||
usage.IotCardID = plan.asset.assetID
|
||||
} else {
|
||||
usage.DeviceID = plan.asset.assetID
|
||||
}
|
||||
if status == constants.PackageUsageStatusActive {
|
||||
usage.ActivatedAt = &activatedAt
|
||||
usage.ExpiresAt = &expiresAt
|
||||
usage.NextResetAt = nextResetAt
|
||||
}
|
||||
if err := tx.WithContext(ctx).Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "写入续费套餐使用记录失败")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(usage).Updates(map[string]any{
|
||||
"status": usage.Status, "pending_realname_activation": usage.PendingRealnameActivation,
|
||||
}).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "写回续费套餐使用记录状态失败")
|
||||
}
|
||||
if status != constants.PackageUsageStatusActive {
|
||||
return usage, nil
|
||||
}
|
||||
triggerType, err := packagepkg.ResolveActivationTriggerType(ctx, tx, plan.asset.assetType, plan.asset.assetID, usage.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := packagepkg.AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage,
|
||||
plan.asset.assetType, plan.asset.assetID, triggerType, activatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// isCarrierRealnamed 判断载体是否已满足实名激活条件,口径与既有自动购包一致。
|
||||
func (s *Service) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (bool, error) {
|
||||
switch assetType {
|
||||
case constants.AssetWalletResourceTypeIotCard:
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).Select("real_name_status").First(&card, assetID).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "读取卡实名状态失败")
|
||||
}
|
||||
return card.RealNameStatus == constants.RealNameStatusVerified, nil
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
var count int64
|
||||
subQuery := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
|
||||
Select("iot_card_id").Where("device_id = ? AND bind_status = ?", assetID, constants.BindStatusBound)
|
||||
if err := tx.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "统计设备实名卡失败")
|
||||
}
|
||||
return count > 0, nil
|
||||
default:
|
||||
return false, errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
}
|
||||
|
||||
// lockCarrier 在事务内按资产类型对载体行加行锁,作为与人工路径共享的序列化点。
|
||||
func (s *Service) lockCarrier(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
|
||||
switch assetType {
|
||||
case constants.AssetWalletResourceTypeIotCard:
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&card, assetID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败")
|
||||
}
|
||||
return nil
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
var device model.Device
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, assetID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
}
|
||||
|
||||
// lockAndSnapshotAsset 在已有行锁的事务内读取资产快照。
|
||||
func (s *Service) lockAndSnapshotAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (*assetSnapshot, error) {
|
||||
switch assetType {
|
||||
case constants.AssetWalletResourceTypeIotCard:
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费卡事实失败")
|
||||
}
|
||||
if !card.IsStandalone {
|
||||
return nil, &renewalHalt{
|
||||
FailureReason: constants.AssetAutoRenewalFailureNotRenewable,
|
||||
Detail: "该卡已绑定设备,独立卡维度不执行自动续费",
|
||||
}
|
||||
}
|
||||
return &assetSnapshot{
|
||||
assetType: constants.AssetWalletResourceTypeIotCard, assetID: card.ID,
|
||||
identifier: card.ICCID, shopID: card.ShopID, seriesID: card.SeriesID, generation: card.Generation,
|
||||
}, nil
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
var device model.Device
|
||||
if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费设备事实失败")
|
||||
}
|
||||
identifier := device.VirtualNo
|
||||
if identifier == "" {
|
||||
identifier = device.IMEI
|
||||
}
|
||||
return &assetSnapshot{
|
||||
assetType: constants.AssetWalletResourceTypeDevice, assetID: device.ID,
|
||||
identifier: identifier, shopID: device.ShopID, seriesID: device.SeriesID, generation: device.Generation,
|
||||
}, nil
|
||||
default:
|
||||
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
|
||||
}
|
||||
}
|
||||
|
||||
// carrierColumn 返回套餐使用记录上的资产外键列名。
|
||||
func carrierColumn(assetType string) string {
|
||||
if assetType == constants.AssetWalletResourceTypeDevice {
|
||||
return "device_id"
|
||||
}
|
||||
return "iot_card_id"
|
||||
}
|
||||
|
||||
// observationResourceType 把资产类型映射为观测序列的资源类型。
|
||||
func observationResourceType(assetType string) string {
|
||||
if assetType == constants.AssetWalletResourceTypeDevice {
|
||||
return constants.CardObservationResourceTypeDevice
|
||||
}
|
||||
return constants.CardObservationResourceTypeCard
|
||||
}
|
||||
|
||||
// asRenewalHalt 从错误中取出终止信号,非终止信号返回 false。
|
||||
func asRenewalHalt(err error, target **renewalHalt) bool {
|
||||
halt, ok := err.(*renewalHalt)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
*target = halt
|
||||
return true
|
||||
}
|
||||
|
||||
// appendRenewalAudit 在续费事务内写成功审计,资源覆盖尝试记录、订单、钱包、流水与套餐使用记录。
|
||||
func (s *Service) appendRenewalAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
plan *executionPlan,
|
||||
attempt *model.AssetAutoRenewalAttempt,
|
||||
order *model.Order,
|
||||
payment *model.Payment,
|
||||
wallet *model.AssetWallet,
|
||||
walletTransaction *model.AssetWalletTransaction,
|
||||
usage *model.PackageUsage,
|
||||
updates map[string]any,
|
||||
) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
|
||||
}
|
||||
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
|
||||
resources := []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
|
||||
DisplayName: "自动续费尝试 " + attemptID,
|
||||
Relation: constants.AuditResourceRelationPrimary,
|
||||
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
|
||||
"status": constants.AssetAutoRenewalAttemptStatusSucceeded,
|
||||
"renew_package_id": plan.pkg.ID, "renew_price": plan.price,
|
||||
"wallet_id": wallet.ID, "wallet_transaction_id": walletTransaction.ID,
|
||||
"deduct_amount": plan.price, "balance_before": wallet.Balance,
|
||||
"balance_after": wallet.Balance - plan.price,
|
||||
"order_id": order.ID, "order_no": order.OrderNo,
|
||||
"resume_status": updates["resume_status"],
|
||||
},
|
||||
BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing},
|
||||
AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusSucceeded, "failure_reason": ""},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}}
|
||||
orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRoleAssetAutoRenewalOrder)
|
||||
resources = append(resources, orderResource)
|
||||
walletID := strconv.FormatUint(uint64(wallet.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWallet, ID: &walletID, Key: walletID, DisplayName: "资产钱包 " + walletID,
|
||||
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAutoRenewalWallet,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID,
|
||||
},
|
||||
BeforeData: map[string]any{"balance": walletTransaction.BalanceBefore},
|
||||
AfterData: map[string]any{"balance": walletTransaction.BalanceAfter},
|
||||
})
|
||||
walletTxID := strconv.FormatUint(uint64(walletTransaction.ID), 10)
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceAssetWalletTransaction, ID: &walletTxID, Key: walletTxID,
|
||||
DisplayName: "资产钱包流水 " + walletTxID,
|
||||
Relation: constants.AuditResourceRelationAffected,
|
||||
Role: constants.AuditResourceRoleAssetAutoRenewalWalletTransaction,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": walletTransaction.ID, "asset_wallet_id": walletTransaction.AssetWalletID,
|
||||
"resource_type": walletTransaction.ResourceType, "resource_id": walletTransaction.ResourceID,
|
||||
"transaction_type": walletTransaction.TransactionType,
|
||||
"reference_no": walletTransaction.ReferenceNo, "status": walletTransaction.Status,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"amount": walletTransaction.Amount, "balance_before": walletTransaction.BalanceBefore,
|
||||
"balance_after": walletTransaction.BalanceAfter,
|
||||
},
|
||||
})
|
||||
resources = append(resources, audit.PaymentResource(payment, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPayment, nil, nil))
|
||||
if usage != nil {
|
||||
resources = append(resources, audit.PackageUsageResource(usage,
|
||||
constants.AuditResourceRelationAffected, constants.AuditResourceRolePackageUsageTarget, nil, nil))
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionAssetAutoRenewalRenewed, Summary: "资产钱包自动续费完成",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: order.OrderNo,
|
||||
Metadata: map[string]any{
|
||||
"asset_type": plan.asset.assetType, "asset_id": plan.asset.assetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
|
||||
},
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
|
||||
// appendFailureAudit 在独立短事务内写失败审计;跳过终态不写审计,由尝试记录本身承载(Domain Ledger)。
|
||||
func (s *Service) appendFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
|
||||
}
|
||||
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费失败",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
|
||||
ErrorCode: strconv.Itoa(reasonCode(reason)), ErrorSummary: detail,
|
||||
CorrelationID: attemptID,
|
||||
Metadata: map[string]any{
|
||||
"asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), "failure_reason": reason,
|
||||
},
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
|
||||
DisplayName: "自动续费尝试 " + attemptID,
|
||||
Relation: constants.AuditResourceRelationPrimary,
|
||||
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
|
||||
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
|
||||
"status": constants.AssetAutoRenewalAttemptStatusFailed,
|
||||
"failure_reason": reason,
|
||||
},
|
||||
BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing},
|
||||
AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusFailed, "failure_reason": reason},
|
||||
SubjectVisibility: constants.AuditSubjectInternalOnly,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
// reasonCode 把失败归类映射为审计错误码位,便于按原因检索失败事件。
|
||||
func reasonCode(reason string) int {
|
||||
switch reason {
|
||||
case constants.AssetAutoRenewalFailureInsufficientBalance:
|
||||
return 1
|
||||
case constants.AssetAutoRenewalFailureNotRenewable:
|
||||
return 2
|
||||
case constants.AssetAutoRenewalFailureOrderFailed:
|
||||
return 3
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// purchaseValidationReason 从购买校验错误中提取可安全记录的说明,不写底层错误细节。
|
||||
func purchaseValidationReason(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
message := err.Error()
|
||||
switch {
|
||||
case strings.Contains(message, "套餐已禁用"):
|
||||
return "套餐商品被禁用"
|
||||
case strings.Contains(message, "套餐已下架"):
|
||||
return "当前渠道下架且不满足续费豁免"
|
||||
case strings.Contains(message, "价格配置异常"):
|
||||
return "生效零售价低于成本价"
|
||||
case strings.Contains(message, "可购买范围"), strings.Contains(message, "未关联套餐系列"),
|
||||
strings.Contains(message, "绑定设备"):
|
||||
return "不在可购买范围或资产未关联套餐系列"
|
||||
case strings.Contains(message, "赠送套餐"):
|
||||
return "赠送套餐不参与自动续购"
|
||||
default:
|
||||
return "当前条件不允许自动续购"
|
||||
}
|
||||
}
|
||||
|
||||
// outboxEventID 把观测事件标识裁剪进 Outbox 的事件 ID 长度预算。
|
||||
func outboxEventID(value string) string {
|
||||
return outboxid.Stable("card-observation:", value)
|
||||
}
|
||||
245
internal/application/assetautorenewal/scan.go
Normal file
245
internal/application/assetautorenewal/scan.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ScanResult 汇总一次每日扫描的可观察结果。
|
||||
type ScanResult struct {
|
||||
Converged int64
|
||||
Candidates int
|
||||
Attempted int
|
||||
Succeeded int
|
||||
Failed int
|
||||
Skipped int
|
||||
Duplicated int
|
||||
}
|
||||
|
||||
// RunDailyScan 执行一次每日自动续费扫描:先收敛历史非终态尝试,再按资产扫描候选并逐项执行。
|
||||
//
|
||||
// 只有扫描级失败(配置读取、候选读取、数据库不可用)返回错误交既有任务重试;单个资产执行失败
|
||||
// 在用例内捕获并落终态后继续处理其余资产。总开关关闭时不创建任何新尝试与订单,既有记录保留。
|
||||
func (s *Service) RunDailyScan(ctx context.Context) (ScanResult, error) {
|
||||
result := ScanResult{}
|
||||
if s.db == nil {
|
||||
return result, errors.New(errors.CodeServiceUnavailable, "自动续费用例未配置")
|
||||
}
|
||||
converged, err := s.attemptStore.ConvergeUnfinished(ctx, s.today())
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Converged = converged
|
||||
if converged > 0 {
|
||||
s.logger.Info("自动续费历史非终态尝试已收敛", zap.Int64("converged", converged))
|
||||
}
|
||||
config, err := s.configStore.Get(ctx)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return result, errors.New(errors.CodeNotFound, "自动续费配置不存在")
|
||||
}
|
||||
return result, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败")
|
||||
}
|
||||
if config.Enabled != 1 {
|
||||
s.logger.Info("自动续费总开关关闭,本次扫描不创建尝试与订单")
|
||||
return result, nil
|
||||
}
|
||||
if config.Scope != constants.AssetAutoRenewalScopeAll && config.Scope != constants.AssetAutoRenewalScopeSpecified {
|
||||
return result, errors.New(errors.CodeInvalidStatus, "自动续费配置范围取值非法")
|
||||
}
|
||||
candidates, err := s.candidates.Candidates(ctx, config.DaysBeforeExpiry)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Candidates = len(candidates)
|
||||
scope := newScopeMatcher(config)
|
||||
for _, candidate := range candidates {
|
||||
if !scope.matches(candidate.CurrentPackageID) {
|
||||
continue
|
||||
}
|
||||
outcome, processErr := s.processCandidate(ctx, candidate, config)
|
||||
if processErr != nil {
|
||||
// 单资产失败已落终态,继续处理其余资产;扫描任务本身不因该资产失败而失败。
|
||||
s.logger.Error("自动续费单资产执行失败,继续处理其余资产",
|
||||
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID),
|
||||
zap.Error(processErr))
|
||||
result.Failed++
|
||||
continue
|
||||
}
|
||||
switch outcome {
|
||||
case candidateSucceeded:
|
||||
result.Attempted++
|
||||
result.Succeeded++
|
||||
case candidateFailed:
|
||||
result.Attempted++
|
||||
result.Failed++
|
||||
case candidateSkipped:
|
||||
result.Attempted++
|
||||
result.Skipped++
|
||||
default:
|
||||
result.Duplicated++
|
||||
}
|
||||
}
|
||||
s.logger.Info("自动续费每日扫描完成",
|
||||
zap.Int64("converged", result.Converged), zap.Int("candidates", result.Candidates),
|
||||
zap.Int("succeeded", result.Succeeded), zap.Int("failed", result.Failed),
|
||||
zap.Int("skipped", result.Skipped), zap.Int("duplicated", result.Duplicated))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// scanOutcome 是一次候选处理的终态归属,用于汇总扫描结果。
|
||||
type scanOutcome int
|
||||
|
||||
const (
|
||||
candidateDuplicated scanOutcome = iota
|
||||
candidateSucceeded
|
||||
candidateFailed
|
||||
candidateSkipped
|
||||
)
|
||||
|
||||
// scopeMatcher 表达配置的适用范围:全部主套餐,或指定主套餐集合。
|
||||
//
|
||||
// 运行时只按当前主套餐商品是否在集合内判定,不因后来下架而拒绝——下架交给续费豁免判定。
|
||||
type scopeMatcher struct {
|
||||
all bool
|
||||
packageIDs map[uint]struct{}
|
||||
}
|
||||
|
||||
func newScopeMatcher(config *model.AssetAutoRenewalConfig) scopeMatcher {
|
||||
if config.Scope == constants.AssetAutoRenewalScopeAll {
|
||||
return scopeMatcher{all: true}
|
||||
}
|
||||
ids := make(map[uint]struct{}, len(config.PackageIDs))
|
||||
for _, packageID := range config.PackageIDs {
|
||||
ids[packageID] = struct{}{}
|
||||
}
|
||||
return scopeMatcher{packageIDs: ids}
|
||||
}
|
||||
|
||||
func (m scopeMatcher) matches(packageID uint) bool {
|
||||
if m.all {
|
||||
return true
|
||||
}
|
||||
_, exists := m.packageIDs[packageID]
|
||||
return exists
|
||||
}
|
||||
|
||||
// processCandidate 处理单个候选:占位写入、执行、落终态与通知。
|
||||
//
|
||||
// 占位冲突表示该资产当日已尝试,直接跳过且不重复扣款;执行阶段的失败与跳过各以独立短事务落终态。
|
||||
func (s *Service) processCandidate(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig) (scanOutcome, error) {
|
||||
triggerDate := s.today()
|
||||
attempt, err := s.buildAttempt(ctx, candidate, config, triggerDate)
|
||||
if err != nil {
|
||||
return candidateFailed, err
|
||||
}
|
||||
created, err := s.attemptStore.CreatePlaceholder(ctx, attempt)
|
||||
if err != nil {
|
||||
return candidateFailed, err
|
||||
}
|
||||
if !created {
|
||||
s.logger.Info("该资产当日已存在自动续费尝试,跳过",
|
||||
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID))
|
||||
return candidateDuplicated, nil
|
||||
}
|
||||
facts, halt, err := s.executeRenewal(ctx, candidate, attempt, config.DaysBeforeExpiry)
|
||||
if halt != nil {
|
||||
if halt.SkipReason != "" {
|
||||
return candidateSkipped, s.finalizeSkip(ctx, attempt, halt)
|
||||
}
|
||||
return candidateFailed, s.finalizeFailure(ctx, attempt, halt.FailureReason, halt.Detail)
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("自动续费事务失败并已整体回滚",
|
||||
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID), zap.Error(err))
|
||||
return candidateFailed, s.finalizeFailure(ctx, attempt, constants.AssetAutoRenewalFailureOrderFailed,
|
||||
"续购事务执行失败并已整体回滚,未产生订单、扣款与套餐事实")
|
||||
}
|
||||
s.logger.Info("自动续费续购成功",
|
||||
zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID),
|
||||
zap.Uint("order_id", facts.OrderID), zap.String("order_no", facts.OrderNo),
|
||||
zap.Int64("renew_price", facts.RenewPrice))
|
||||
return candidateSucceeded, nil
|
||||
}
|
||||
|
||||
// buildAttempt 组装占位尝试记录,冻结触发时的客户、店铺、配置窗口与套餐快照。
|
||||
func (s *Service) buildAttempt(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig, triggerDate time.Time) (*model.AssetAutoRenewalAttempt, error) {
|
||||
sequence, err := s.attemptStore.CountByAsset(ctx, candidate.AssetType, candidate.AssetID, triggerDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalExpiresAt := candidate.FinalExpiresAt
|
||||
attempt := &model.AssetAutoRenewalAttempt{
|
||||
AssetType: candidate.AssetType, AssetID: candidate.AssetID, TriggerDate: triggerDate,
|
||||
Status: constants.AssetAutoRenewalAttemptStatusProcessing,
|
||||
CustomerID: candidate.CustomerID,
|
||||
ShopID: candidate.ShopID,
|
||||
ConfigVersion: config.ConfigVersion, WindowDays: config.DaysBeforeExpiry,
|
||||
FinalExpiresAt: &finalExpiresAt,
|
||||
CurrentUsageID: candidate.CurrentUsageID,
|
||||
CurrentPackageID: candidate.CurrentPackageID,
|
||||
RenewPackageID: candidate.CurrentPackageID,
|
||||
OperatorType: constants.AssetAutoRenewalOperatorTypeSystemTask,
|
||||
OperatorID: constants.TaskTypeAssetAutoRenewalScan,
|
||||
AttemptSeq: int(sequence) + 1,
|
||||
}
|
||||
return attempt, nil
|
||||
}
|
||||
|
||||
// finalizeSkip 以独立短事务落跳过终态:不扣款、不建订单、不发送通知。
|
||||
func (s *Service) finalizeSkip(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, halt *renewalHalt) error {
|
||||
_, err := s.attemptStore.Finalize(ctx, attempt.ID, map[string]any{
|
||||
"status": constants.AssetAutoRenewalAttemptStatusSkipped,
|
||||
"skip_reason": halt.SkipReason,
|
||||
"failure_reason": "",
|
||||
"failure_detail": halt.Detail,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("自动续费跳过该资产",
|
||||
zap.String("asset_type", attempt.AssetType), zap.Uint("asset_id", attempt.AssetID),
|
||||
zap.String("skip_reason", halt.SkipReason))
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalizeFailure 以独立短事务落失败终态,并在同一事务内投递通知与写失败审计。
|
||||
//
|
||||
// 该短事务与已回滚的续费事务不共用连接或事务(ENG-TX-001 例外),条件更新依据尝试记录仍非终态;
|
||||
// 已被并发收敛时不再投递通知与审计。中断收敛(interrupted)不属于通知口径,不会被通知。
|
||||
func (s *Service) finalizeFailure(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, map[string]any{
|
||||
"status": constants.AssetAutoRenewalAttemptStatusFailed,
|
||||
"failure_reason": reason,
|
||||
"failure_detail": detail,
|
||||
"skip_reason": "",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
s.logger.Info("自动续费尝试已被并发收敛,跳过通知与审计",
|
||||
zap.Uint("attempt_id", attempt.ID))
|
||||
return nil
|
||||
}
|
||||
if notifyErr := s.appendFailureNotifications(ctx, tx, failureNotification{
|
||||
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
|
||||
Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID),
|
||||
ShopID: attempt.ShopID, CustomerID: attempt.CustomerID,
|
||||
TriggerDate: attempt.TriggerDate, Reason: reason,
|
||||
PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt,
|
||||
}); notifyErr != nil {
|
||||
return notifyErr
|
||||
}
|
||||
return s.appendFailureAudit(ctx, tx, attempt, reason, detail)
|
||||
})
|
||||
}
|
||||
178
internal/application/assetautorenewal/service.go
Normal file
178
internal/application/assetautorenewal/service.go
Normal file
@@ -0,0 +1,178 @@
|
||||
// Package assetautorenewal 编排资产钱包自动续费:受控配置维护、每日扫描与尝试、续费事务闭合、
|
||||
// 失败通知与复机可靠投递。
|
||||
//
|
||||
// 本包不调用任何支付渠道或运营商接口:续购价格与可售判定复用应用层购买校验与价格策略,
|
||||
// 复机执行通过 ResumeCommander 端口复用既有停复机单一事实源,通知与复机都通过公共 Outbox
|
||||
// 在业务事务内写出事件(ENG-OUTBOX-001)。资金、订单、套餐与成功审计在同一事务内闭合(ENG-TX-001)。
|
||||
package assetautorenewal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||
"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"
|
||||
)
|
||||
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// ResumeOutcome 是一次复机调用可安全记录的结果摘要。
|
||||
//
|
||||
// Applied 为 false 表示本次未满足可复机条件、没有发起任何复机调用;Result 取值
|
||||
// constants.AuditResultSuccess / Failed / Unknown。
|
||||
type ResumeOutcome struct {
|
||||
Applied bool
|
||||
IntegrationID string
|
||||
Result string
|
||||
SafeReason string
|
||||
}
|
||||
|
||||
// ResumeCommander 是自动续费成功后复机的执行边界。
|
||||
//
|
||||
// 实现必须复用既有停复机单一事实源(重试、Integration Log、统一审计、观测序列)与既有
|
||||
// 套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。
|
||||
type ResumeCommander interface {
|
||||
// AutoRenewalResumeReady 判断该资产当前是否满足自动复机条件;不满足时返回可安全记录的原因。
|
||||
AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error)
|
||||
// ResumeAssetForAutoRenewal 执行复机并返回结果分类。
|
||||
ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (ResumeOutcome, error)
|
||||
// QueryAutoRenewalResumeState 只查询运营商状态回填复机结果,绝不重复发起复机。
|
||||
QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (online bool, known bool, integrationID string, err error)
|
||||
}
|
||||
|
||||
// Dependencies 汇总自动续费用例的装配依赖。
|
||||
//
|
||||
// DB 与 Redis 用于构造本用例独占的资产钱包、流水、订单、套餐与资产 Store;
|
||||
// 其余依赖是配置、价格、候选、复机与可靠事件的能力边界。
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Redis *redis.Client
|
||||
Logger *zap.Logger
|
||||
Outbox *outbox.Repository
|
||||
AuditWriter *audit.Writer
|
||||
PurchaseValidation *purchase_validation.Service
|
||||
Candidates *assetquery.Query
|
||||
Resume ResumeCommander
|
||||
ObservationEvents cardObservationApp.SeriesEventWriter
|
||||
PriorityEvents priorityapp.PriorityEventWriter
|
||||
}
|
||||
|
||||
// Service 执行资产钱包自动续费的配置维护、每日扫描、续费事务与终止态收敛。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
configStore *postgres.AssetAutoRenewalConfigStore
|
||||
attemptStore *postgres.AssetAutoRenewalAttemptStore
|
||||
assetWalletStore *postgres.AssetWalletStore
|
||||
walletTransactionStore *postgres.AssetWalletTransactionStore
|
||||
orderStore *postgres.OrderStore
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
packageStore *postgres.PackageStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
personalCustomerStore *postgres.PersonalCustomerStore
|
||||
candidates *assetquery.Query
|
||||
purchaseValidation *purchase_validation.Service
|
||||
outbox *outbox.Repository
|
||||
auditWriter *audit.Writer
|
||||
resume ResumeCommander
|
||||
observationEvents cardObservationApp.SeriesEventWriter
|
||||
priorityEvents priorityapp.PriorityEventWriter
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService 创建资产钱包自动续费用例。
|
||||
func NewService(deps Dependencies) *Service {
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &Service{
|
||||
db: deps.DB,
|
||||
configStore: postgres.NewAssetAutoRenewalConfigStore(deps.DB),
|
||||
attemptStore: postgres.NewAssetAutoRenewalAttemptStore(deps.DB),
|
||||
assetWalletStore: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
walletTransactionStore: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
||||
orderStore: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||
packageUsageStore: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
|
||||
packageStore: postgres.NewPackageStore(deps.DB),
|
||||
iotCardStore: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
deviceStore: postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
personalCustomerStore: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
candidates: deps.Candidates,
|
||||
purchaseValidation: deps.PurchaseValidation,
|
||||
outbox: deps.Outbox,
|
||||
auditWriter: deps.AuditWriter,
|
||||
resume: deps.Resume,
|
||||
observationEvents: deps.ObservationEvents,
|
||||
priorityEvents: deps.PriorityEvents,
|
||||
logger: logger,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// today 返回当前上海自然日,作为触发日期与每日唯一键的统一口径。
|
||||
func (s *Service) today() time.Time {
|
||||
return assetquery.Today(s.now())
|
||||
}
|
||||
|
||||
// assetIdentifier 读取资产对外的可读标识,取不到时回退为资产 ID 文本。
|
||||
// 通知模板要求标识非空,因此绝不返回空串。
|
||||
func (s *Service) assetIdentifier(ctx context.Context, assetType string, assetID uint) string {
|
||||
switch assetType {
|
||||
case constants.AssetWalletResourceTypeIotCard:
|
||||
if card, err := s.iotCardStore.GetByID(ctx, assetID); err == nil && card.ICCID != "" {
|
||||
return card.ICCID
|
||||
}
|
||||
case constants.AssetWalletResourceTypeDevice:
|
||||
if device, err := s.deviceStore.GetByID(ctx, assetID); err == nil {
|
||||
if device.VirtualNo != "" {
|
||||
return device.VirtualNo
|
||||
}
|
||||
if device.IMEI != "" {
|
||||
return device.IMEI
|
||||
}
|
||||
}
|
||||
}
|
||||
return strconv.FormatUint(uint64(assetID), 10)
|
||||
}
|
||||
|
||||
// packageName 读取套餐商品名称,取不到时回退为套餐 ID 文本。
|
||||
func (s *Service) packageName(ctx context.Context, packageID uint) string {
|
||||
if packageID == 0 {
|
||||
return "未知套餐"
|
||||
}
|
||||
if pkg, err := s.packageStore.GetByID(ctx, packageID); err == nil && pkg.PackageName != "" {
|
||||
return pkg.PackageName
|
||||
}
|
||||
return strconv.FormatUint(uint64(packageID), 10)
|
||||
}
|
||||
|
||||
// loadPackagesByIDs 批量读取套餐商品,用于一次性取价与快照。
|
||||
func (s *Service) loadPackagesByIDs(ctx context.Context, packageIDs []uint) (map[uint]*model.Package, error) {
|
||||
result := make(map[uint]*model.Package, len(packageIDs))
|
||||
if len(packageIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var packages []*model.Package
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", packageIDs).Find(&packages).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费套餐商品失败")
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
result[pkg.ID] = pkg
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
493
internal/application/businessusergroup/service.go
Normal file
493
internal/application/businessusergroup/service.go
Normal file
@@ -0,0 +1,493 @@
|
||||
// Package businessusergroup 收口业务用户组、成员归属与店铺负责人批量交接的写用例。
|
||||
// 组只描述平台用户的业务分类,不改变后台角色、登录、权限或数据范围;
|
||||
// 店铺所属组始终由当前负责人实时推导,因此本包不写任何店铺组字段。
|
||||
package businessusergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Service 业务用户组与成员归属的简单写事务脚本。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
groupStore *postgres.BusinessUserGroupStore
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// New 创建业务用户组事务脚本。
|
||||
func New(db *gorm.DB, groupStore *postgres.BusinessUserGroupStore, auditWriters ...*audit.Writer) *Service {
|
||||
service := &Service{db: db, groupStore: groupStore}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// Create 创建业务用户组并在同一事务写入审计。
|
||||
func (s *Service) Create(ctx context.Context, request *dto.CreateBusinessUserGroupRequest) (*dto.BusinessUserGroupResponse, error) {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code := strings.TrimSpace(request.Code)
|
||||
name := strings.TrimSpace(request.Name)
|
||||
if code == "" || name == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务用户组编码与名称不能为空")
|
||||
}
|
||||
if !constants.IsValidBusinessLine(request.BusinessLine) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务线取值非法")
|
||||
}
|
||||
group := &model.BusinessUserGroup{
|
||||
Code: code, Name: name, BusinessLine: request.BusinessLine,
|
||||
SortOrder: sortValue(request.Sort), Status: statusValue(request.Enabled),
|
||||
Remark: request.Remark, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||||
}
|
||||
var response *dto.BusinessUserGroupResponse
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
exists, err := store.ExistsCode(ctx, code, 0)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验业务用户组编码失败")
|
||||
}
|
||||
if exists {
|
||||
return errors.New(errors.CodeInvalidParam, "业务用户组编码已存在")
|
||||
}
|
||||
if err := store.Create(ctx, group); err != nil {
|
||||
return mapCodeConflict(err)
|
||||
}
|
||||
if err := s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupCreated, "创建业务用户组", group, operatorID, nil, groupSnapshot(group)); err != nil {
|
||||
return err
|
||||
}
|
||||
response = toGroupResponse(group)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Update 更新业务用户组名称、业务线、排序、启停与备注;稳定编码永不允许修改。
|
||||
func (s *Service) Update(ctx context.Context, groupID uint, request *dto.UpdateBusinessUserGroupRequest) (*dto.BusinessUserGroupResponse, error) {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if groupID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if request.BusinessLine != nil && !constants.IsValidBusinessLine(*request.BusinessLine) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务线取值非法")
|
||||
}
|
||||
if request.Name != nil && strings.TrimSpace(*request.Name) == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "业务用户组名称不能为空")
|
||||
}
|
||||
var response *dto.BusinessUserGroupResponse
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
group, err := store.LockByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return groupLookupError(err)
|
||||
}
|
||||
before := groupSnapshot(group)
|
||||
if request.Name != nil {
|
||||
group.Name = strings.TrimSpace(*request.Name)
|
||||
}
|
||||
if request.BusinessLine != nil {
|
||||
group.BusinessLine = *request.BusinessLine
|
||||
}
|
||||
if request.Sort != nil {
|
||||
group.SortOrder = *request.Sort
|
||||
}
|
||||
// 停用保留成员关系:已有成员继续显示已停用,只是不得新增成员或作为批量目标。
|
||||
if request.Enabled != nil {
|
||||
group.Status = statusValue(request.Enabled)
|
||||
}
|
||||
if request.Remark != nil {
|
||||
group.Remark = *request.Remark
|
||||
}
|
||||
if err := store.Update(ctx, group, operatorID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新业务用户组失败")
|
||||
}
|
||||
after := groupSnapshot(group)
|
||||
// 启停是独立状态事实,与资料变更分开记录,保证审计动作可被独立检索。
|
||||
if before["status"] != after["status"] {
|
||||
action, summary := constants.AuditActionBusinessUserGroupEnabled, "启用业务用户组"
|
||||
if group.Status != constants.StatusEnabled {
|
||||
action, summary = constants.AuditActionBusinessUserGroupDisabled, "停用业务用户组"
|
||||
}
|
||||
if err := s.appendGroupAudit(ctx, tx, action, summary, group, operatorID,
|
||||
map[string]any{"status": before["status"]}, map[string]any{"status": after["status"]}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if groupProfileChanged(before, after) {
|
||||
if err := s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupUpdated, "更新业务用户组", group, operatorID, before, after); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
response = toGroupResponse(group)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Delete 删除无成员的业务用户组;有成员时只能停用或先移走成员。
|
||||
func (s *Service) Delete(ctx context.Context, groupID uint) error {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if groupID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
group, err := store.LockByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return groupLookupError(err)
|
||||
}
|
||||
count, err := store.CountMembers(ctx, group.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "统计业务用户组成员失败")
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeInvalidStatus, "用户组仍有成员,只能停用或先移走成员")
|
||||
}
|
||||
before := groupSnapshot(group)
|
||||
if err := store.Delete(ctx, group.ID, operatorID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "删除业务用户组失败")
|
||||
}
|
||||
return s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", group, operatorID, before, nil)
|
||||
})
|
||||
}
|
||||
|
||||
// SetMembers 把多个启用平台用户批量设置到指定启用组,直接替换每个账号的原归属。
|
||||
// 任一账号无效则整批不修改,成员前后值审计与业务事实同事务。
|
||||
func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.SetBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountIDs, err := normalizeAccountIDs(request.AccountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if groupID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result := &dto.BusinessUserGroupMembersResult{GroupID: groupID, AccountIDs: accountIDs}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
group, err := store.LockByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return groupLookupError(err)
|
||||
}
|
||||
if group.Status != constants.StatusEnabled {
|
||||
return errors.New(errors.CodeInvalidStatus, "目标用户组已停用,不能作为成员归属目标")
|
||||
}
|
||||
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
// 按 id 升序锁账号行:账号行锁保证同一账号串行化,
|
||||
// 同时消除「清空时无成员行导致锁不到行」的幻读与「多账号相反顺序」的死锁。
|
||||
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
|
||||
}
|
||||
before, err := store.MembersByAccountIDs(ctx, accountIDs)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
|
||||
}
|
||||
if err := store.ReplaceMemberGroup(ctx, accountIDs, group.ID, operatorID); err != nil {
|
||||
return mapMemberWriteError(err)
|
||||
}
|
||||
return s.appendMemberAudits(ctx, tx, group, accountIDs, before, operatorID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ClearMembers 清空指定启用平台用户的业务用户组归属,任一账号无效则整批不修改。
|
||||
func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) {
|
||||
operatorID, err := s.requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountIDs, err := normalizeAccountIDs(request.AccountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &dto.BusinessUserGroupMembersResult{AccountIDs: accountIDs}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.groupStore.WithTx(tx)
|
||||
if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败")
|
||||
}
|
||||
before, err := store.MembersByAccountIDs(ctx, accountIDs)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败")
|
||||
}
|
||||
if err := store.ClearMembers(ctx, accountIDs); err != nil {
|
||||
return mapMemberWriteError(err)
|
||||
}
|
||||
return s.appendMemberAudits(ctx, tx, nil, accountIDs, before, operatorID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// mapMemberWriteError 把成员关系写入失败收敛为稳定业务错误。
|
||||
// 并发为同一账号新增成员关系时唯一索引是最终裁决,不能把约束冲突暴露成 500。
|
||||
func mapMemberWriteError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if postgres.IsAccountMemberConflict(err) {
|
||||
return errors.New(errors.CodeConflict, "平台用户分组归属已被并发修改,请重试")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新平台用户分组失败")
|
||||
}
|
||||
|
||||
// requireOperator 校验调用者具备平台维护入口身份,并返回其账号 ID。
|
||||
func (s *Service) requireOperator(ctx context.Context) (uint, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// normalizeAccountIDs 去重并保持首次出现顺序,空集合视为非法参数。
|
||||
func normalizeAccountIDs(values []uint) ([]uint, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "账号列表不能为空")
|
||||
}
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
result := make([]uint, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "账号ID非法")
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ensureEnabledPlatformAccounts 校验全部账号都是当前启用的平台用户,任一不满足即整批失败。
|
||||
// 账号有效性统一走共享谓词,避免各入口对「平台 + 启用 + 未软删」出现口径分叉。
|
||||
func ensureEnabledPlatformAccounts(ctx context.Context, tx *gorm.DB, accountIDs []uint) error {
|
||||
var accounts []model.Account
|
||||
if err := tx.WithContext(ctx).Model(&model.Account{}).
|
||||
Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验平台用户失败")
|
||||
}
|
||||
valid := 0
|
||||
for _, account := range accounts {
|
||||
if constants.IsAvailablePlatformBusinessOwner(account.UserType, account.Status, account.DeletedAt.Valid) {
|
||||
valid++
|
||||
}
|
||||
}
|
||||
if valid != len(accountIDs) {
|
||||
return errors.New(errors.CodeInvalidParam, "存在无效或非启用的平台用户账号,整批未修改")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupLookupError(err error) error {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "业务用户组不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败")
|
||||
}
|
||||
|
||||
// mapCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误,并发创建以唯一索引为最终裁决。
|
||||
func mapCodeConflict(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "uk_business_user_group_code") {
|
||||
return errors.New(errors.CodeInvalidParam, "业务用户组编码已存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建业务用户组失败")
|
||||
}
|
||||
|
||||
func sortValue(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func statusValue(enabled *bool) int {
|
||||
if enabled == nil || *enabled {
|
||||
return constants.StatusEnabled
|
||||
}
|
||||
return constants.StatusDisabled
|
||||
}
|
||||
|
||||
func toGroupResponse(group *model.BusinessUserGroup) *dto.BusinessUserGroupResponse {
|
||||
return &dto.BusinessUserGroupResponse{
|
||||
ID: group.ID, Code: group.Code, Name: group.Name,
|
||||
BusinessLine: group.BusinessLine, BusinessLineName: constants.GetBusinessLineName(group.BusinessLine),
|
||||
Sort: group.SortOrder, Enabled: group.Status == constants.StatusEnabled, Remark: group.Remark,
|
||||
CreatedAt: group.CreatedAt.Format(time.RFC3339), UpdatedAt: group.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// groupSnapshot 生成业务用户组的前后值快照,不含任何凭证或敏感信息。
|
||||
func groupSnapshot(group *model.BusinessUserGroup) map[string]any {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": group.ID, "code": group.Code, "name": group.Name,
|
||||
"business_line": group.BusinessLine, "sort_order": group.SortOrder, "status": group.Status,
|
||||
"remark": group.Remark,
|
||||
}
|
||||
}
|
||||
|
||||
// groupProfileChanged 判断除启停外的可维护字段是否发生变化;编码不可修改,不参与比较。
|
||||
func groupProfileChanged(before, after map[string]any) bool {
|
||||
for _, field := range []string{"name", "business_line", "sort_order", "remark"} {
|
||||
if before[field] != after[field] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// businessUserGroupKey 返回业务用户组审计资源的稳定 Key。
|
||||
func businessUserGroupKey(group *model.BusinessUserGroup) string {
|
||||
if group == nil {
|
||||
return ""
|
||||
}
|
||||
if group.Code != "" {
|
||||
return group.Code
|
||||
}
|
||||
return strconv.FormatUint(uint64(group.ID), 10)
|
||||
}
|
||||
|
||||
// businessUserGroupIdentity 返回业务用户组审计身份快照,字段必须落在注册表白名单内。
|
||||
func businessUserGroupIdentity(group *model.BusinessUserGroup) map[string]any {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": group.ID, "code": group.Code, "name": group.Name,
|
||||
"business_line": group.BusinessLine, "status": group.Status,
|
||||
}
|
||||
}
|
||||
|
||||
// appendGroupAudit 在业务事务内追加业务用户组事件。
|
||||
func (s *Service) appendGroupAudit(ctx context.Context, tx *gorm.DB, action, summary string, group *model.BusinessUserGroup, operatorID uint, before, after map[string]any) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置")
|
||||
}
|
||||
var resourceID *string
|
||||
if group.ID != 0 {
|
||||
value := strconv.FormatUint(uint64(group.ID), 10)
|
||||
resourceID = &value
|
||||
}
|
||||
s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess,
|
||||
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourceBusinessUserGroup, ID: resourceID,
|
||||
Key: businessUserGroupKey(group), DisplayName: group.Name,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBusinessUserGroupTarget,
|
||||
IdentitySnapshot: businessUserGroupIdentity(group), BeforeData: before, AfterData: after,
|
||||
}},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// appendMemberAudits 在业务事务内为每个账号追加一条成员归属事件。
|
||||
// 账号是实际被替换归属的资源,因此作为主要资源;目标组仅作引用,清空操作没有目标组。
|
||||
func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *model.BusinessUserGroup, accountIDs []uint, before map[uint]model.BusinessUserGroupMember, operatorID uint) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置")
|
||||
}
|
||||
var accounts []model.Account
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询成员审计账号失败")
|
||||
}
|
||||
accountByID := make(map[uint]model.Account, len(accounts))
|
||||
for _, account := range accounts {
|
||||
accountByID[account.ID] = account
|
||||
}
|
||||
summary := "清空平台用户业务用户组归属"
|
||||
afterGroupID := any(nil)
|
||||
if group != nil {
|
||||
summary = "设置平台用户业务用户组归属"
|
||||
afterGroupID = group.ID
|
||||
}
|
||||
for _, accountID := range accountIDs {
|
||||
account, exists := accountByID[accountID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
beforeGroupID := any(nil)
|
||||
if member, ok := before[accountID]; ok {
|
||||
beforeGroupID = member.BusinessUserGroupID
|
||||
}
|
||||
resource := audit.AccountResource(&account, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleAccountTarget)
|
||||
resource.BeforeData = map[string]any{"business_user_group_id": beforeGroupID}
|
||||
resource.AfterData = map[string]any{"business_user_group_id": afterGroupID}
|
||||
resources := []audit.ResourceInput{resource}
|
||||
if group != nil {
|
||||
resources = append(resources, audit.ResourceInput{
|
||||
Type: constants.AuditResourceBusinessUserGroup, ID: optionalID(group.ID),
|
||||
Key: businessUserGroupKey(group), DisplayName: group.Name,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleBusinessUserGroupTarget,
|
||||
IdentitySnapshot: businessUserGroupIdentity(group), SortOrder: 1,
|
||||
})
|
||||
}
|
||||
s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionBusinessUserGroupMembersUpdated, Summary: summary,
|
||||
Result: constants.AuditResultSuccess,
|
||||
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionalID(id uint) *string {
|
||||
if id == 0 {
|
||||
return nil
|
||||
}
|
||||
value := strconv.FormatUint(uint64(id), 10)
|
||||
return &value
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -42,6 +43,12 @@ type CacheInvalidator interface {
|
||||
Invalidate(ctx context.Context, cardID uint)
|
||||
}
|
||||
|
||||
// ChannelThresholdEvaluator 在卡流量事务内判定运营商通道流量阈值。
|
||||
// 实现必须在调用方事务内写周期锁与停机事件,使锁事实与流量事实同事务提交。
|
||||
type ChannelThresholdEvaluator interface {
|
||||
EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation carrierthresholddomain.Evaluation) error
|
||||
}
|
||||
|
||||
// StateAudit 描述一次需要与卡事实关联保存的状态操作。
|
||||
type StateAudit struct {
|
||||
ActionCode string
|
||||
@@ -64,6 +71,8 @@ type Service struct {
|
||||
eventWriter EventWriter
|
||||
cache CacheInvalidator
|
||||
auditWriter StateAuditWriter
|
||||
// channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。
|
||||
channelThreshold ChannelThresholdEvaluator
|
||||
}
|
||||
|
||||
// NewService 创建卡实名观测应用服务。
|
||||
@@ -71,6 +80,11 @@ func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *S
|
||||
return &Service{db: db, eventWriter: eventWriter, cache: cache}
|
||||
}
|
||||
|
||||
// SetChannelThresholdEvaluator 注入运营商通道流量阈值达量判定能力。
|
||||
func (s *Service) SetChannelThresholdEvaluator(evaluator ChannelThresholdEvaluator) {
|
||||
s.channelThreshold = evaluator
|
||||
}
|
||||
|
||||
// SetStateAuditWriter 注入卡状态统一审计 Writer。
|
||||
func (s *Service) SetStateAuditWriter(writer StateAuditWriter) {
|
||||
s.auditWriter = writer
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
|
||||
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -80,6 +81,16 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
|
||||
return err
|
||||
}
|
||||
}
|
||||
if decision.ReadingAccepted && s.channelThreshold != nil {
|
||||
// 达量判定只使用本次已接受的网关累计读数,异常下降保护命中的观测不参与判定;
|
||||
// 判定失败必须回滚整个流量事务,与既有流量事实保持同事务语义。
|
||||
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
|
||||
CardID: card.ID, CarrierID: card.CarrierID,
|
||||
ReadingMB: decision.LastGatewayReadingMB, ObservedAt: observation.Metadata.ObservedAt,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB
|
||||
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
|
||||
if s.auditWriter == nil {
|
||||
|
||||
307
internal/application/carrierthreshold/cycle.go
Normal file
307
internal/application/carrierthreshold/cycle.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// cycleBatchSize 是单次周期处理扫描的锁行上限。
|
||||
const cycleBatchSize = 200
|
||||
|
||||
// CycleStats 是一次周期处理的可观察结果。
|
||||
//
|
||||
// Scanned 为扫到的待处理持锁数;Unlocked 为本次认领解锁成功的锁数;Resumed 为同时写出复机事件的锁数;
|
||||
// Anomaly 为因运营商配置缺失或重置日非法而解锁并转人工的锁数;Skipped 为本地事实缺失、被并发推进
|
||||
// 或判定失败而未改动的锁数。
|
||||
type CycleStats struct{ Scanned, Unlocked, Resumed, Anomaly, Skipped int }
|
||||
|
||||
// ProcessDueLocks 扫描待处理的持锁锁行:认领解锁,并在新周期条件满足时同事务写复机事件。
|
||||
//
|
||||
// 过期判断按锁行自身运营商的 data_reset_day(支持换运营商后旧锁仍按其归属处理)。解锁与复机事件
|
||||
// 在同一事务提交:事务失败则解锁一起回滚,下一分钟重新处理,不会出现「已解锁但没有复机任务」的中间态。
|
||||
// 任一复机条件不满足时只解锁、不调运营商,并把可观察原因写入锁行。
|
||||
//
|
||||
// 锁行引用的运营商已不存在或重置日非法时无法计算周期归属:这类行按「新周期对仍持锁卡解除通道锁」
|
||||
// 的语义解锁并标记异常转人工,绝不写复机事件、不调运营商,也绝不静默跳过(否则持锁卡会永久禁止复机)。
|
||||
func (s *Service) ProcessDueLocks(ctx context.Context, now time.Time) (CycleStats, error) {
|
||||
stats := CycleStats{}
|
||||
if s == nil || s.db == nil || s.repository == nil {
|
||||
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值周期处理能力未配置")
|
||||
}
|
||||
due, err := s.ScanDueLocks(ctx, now, cycleBatchSize)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Scanned = len(due)
|
||||
if len(due) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
if s.commander == nil {
|
||||
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
|
||||
}
|
||||
var firstErr error
|
||||
for index := range due {
|
||||
item := due[index]
|
||||
if err := s.processDueLock(ctx, &item, &stats); err != nil {
|
||||
stats.Skipped++
|
||||
s.logger.Warn("通道阈值跨期处理单条失败",
|
||||
zap.Uint("lock_id", item.Lock.ID), zap.Uint("card_id", item.Lock.CardID), zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats, firstErr
|
||||
}
|
||||
|
||||
// processDueLock 处理单条待处理锁行:周期归属不可判定时解锁并转人工,已跨期时认领解锁并条件复机。
|
||||
func (s *Service) processDueLock(ctx context.Context, item *DueLock, stats *CycleStats) error {
|
||||
if item.Kind == DueLockUnresolvable {
|
||||
return s.processUnresolvableLock(ctx, item, stats)
|
||||
}
|
||||
lock := &item.Lock
|
||||
card, err := s.loadCard(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if card == nil {
|
||||
stats.Skipped++
|
||||
s.logger.Warn("通道阈值跨期锁对应卡不存在,本次不处理",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID))
|
||||
return nil
|
||||
}
|
||||
// 复机条件复用既有单一事实源(有效主套餐 + 流量未耗尽 + 实名满足 + 非风险扩展 + 无其他停因)。
|
||||
ready, reason, err := s.commander.ResumeReady(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
// 判定失败时不解锁:保留锁与拒绝复机的语义,下一分钟重试。
|
||||
return err
|
||||
}
|
||||
unlocked := false
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, reason)
|
||||
if unlockErr != nil {
|
||||
return unlockErr
|
||||
}
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
unlocked = true
|
||||
if !ready {
|
||||
return nil
|
||||
}
|
||||
return AppendResumeRequested(ctx, tx, s.repository, lock)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !unlocked {
|
||||
stats.Skipped++
|
||||
s.logger.Info("通道阈值跨期锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID))
|
||||
return nil
|
||||
}
|
||||
stats.Unlocked++
|
||||
if !ready {
|
||||
s.logger.Info("通道阈值新周期仅解锁,不调用运营商复机",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.String("reason", reason))
|
||||
return nil
|
||||
}
|
||||
stats.Resumed++
|
||||
s.logger.Info("通道阈值新周期条件满足,已写复机事件",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// processUnresolvableLock 处理周期归属不可判定的锁行:同事务认领解锁并标记异常转人工。
|
||||
//
|
||||
// 只解锁不写复机事件、不调运营商:配置缺失时无法判断是否已跨期,解除通道锁交由既有复机链路
|
||||
// 与人工决定;anomaly_flag 与失败原因使运维可见并转人工核对。解锁与异常标记都是条件更新,
|
||||
// 重复执行不会产生第二次副作用。
|
||||
// 两者 MUST 共用同一个事务句柄:解锁已持有该行锁,若异常标记改走服务自身连接池,另一条连接
|
||||
// 会等待本事务的行锁(自锁),处理将挂死到语句超时并使该 cron 每分钟空转。
|
||||
func (s *Service) processUnresolvableLock(ctx context.Context, item *DueLock, stats *CycleStats) error {
|
||||
lock := &item.Lock
|
||||
s.logger.Warn("通道阈值锁行周期归属不可判定,解锁并转人工核对",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Uint("carrier_id", lock.CarrierID),
|
||||
zap.String("reason", item.Reason))
|
||||
unlocked := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, item.Reason)
|
||||
if unlockErr != nil {
|
||||
return unlockErr
|
||||
}
|
||||
if !claimed {
|
||||
return nil
|
||||
}
|
||||
unlocked = true
|
||||
_, anomalyErr := s.markAnomalyInTx(ctx, tx, lock.ID, item.Reason)
|
||||
return anomalyErr
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !unlocked {
|
||||
stats.Skipped++
|
||||
s.logger.Info("通道阈值不可判定锁已被并发处理,跳过", zap.Uint("lock_id", lock.ID))
|
||||
return nil
|
||||
}
|
||||
stats.Unlocked++
|
||||
stats.Anomaly++
|
||||
return nil
|
||||
}
|
||||
|
||||
// recoveryBatchSize 是单次恢复扫描的锁行上限。
|
||||
const recoveryBatchSize = 200
|
||||
|
||||
// RecoveryStats 是一次恢复扫描的可观察结果。
|
||||
//
|
||||
// Scanned 为扫到的待确认锁数;Confirmed 为本次回填为运营商已确认的锁数;Pending 为结果仍未知、
|
||||
// 等待下次扫描的锁数;Anomaly 为超过查询窗口仍不可确认、本次标记转人工的锁数;
|
||||
// Skipped 为本地事实缺失或已被并发推进而未改动状态的锁数。
|
||||
type RecoveryStats struct{ Scanned, Confirmed, Pending, Anomaly, Skipped int }
|
||||
|
||||
// RecoverSubmitted 扫描存在已提交子任务的锁:只查询运营商状态回填,绝不重复发起停复机。
|
||||
//
|
||||
// 每个子任务独立判断:查询确认到达目标状态即回填 confirmed 并补写卡状态(覆盖运营商调用成功但
|
||||
// 本地回写失败的场景);仍不可确认则等到下一次扫描;自提交起超过查询窗口仍不可确认时标记异常
|
||||
// 并退出自动扫描转人工,锁行与历史结果一律保留,绝不自动删除。
|
||||
func (s *Service) RecoverSubmitted(ctx context.Context, now time.Time) (RecoveryStats, error) {
|
||||
stats := RecoveryStats{}
|
||||
if s == nil || s.db == nil {
|
||||
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值恢复扫描能力未配置")
|
||||
}
|
||||
locks, err := s.ScanSubmittedLocks(ctx, recoveryBatchSize)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Scanned = len(locks)
|
||||
if len(locks) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
if s.commander == nil {
|
||||
return stats, errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
|
||||
}
|
||||
var firstErr error
|
||||
for index := range locks {
|
||||
lock := locks[index]
|
||||
if err := s.recoverSubmittedLock(ctx, &lock, now, &stats); err != nil {
|
||||
stats.Skipped++
|
||||
s.logger.Warn("通道阈值恢复扫描单条失败",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats, firstErr
|
||||
}
|
||||
|
||||
// recoverSubmittedLock 处理单条待确认锁:只查询状态回填,不发起任何停复机调用。
|
||||
//
|
||||
// 入口只处理未决子任务(submitted/unknown/failed):调用失败或结果未知同样可能已在运营商侧生效,
|
||||
// 必须继续收敛;confirmed 是终态,pending 表示从未对外调用,都不在本扫描范围。
|
||||
func (s *Service) recoverSubmittedLock(ctx context.Context, lock *model.CarrierTrafficThresholdLock, now time.Time, stats *RecoveryStats) error {
|
||||
if !domain.IsUnresolvedTaskStatus(lock.StopStatus) && !domain.IsUnresolvedTaskStatus(lock.ResumeStatus) {
|
||||
return nil
|
||||
}
|
||||
card, err := s.loadCard(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, known := constants.NetworkStatusOffline, false
|
||||
if card != nil {
|
||||
// 查询失败按「仍未确认」处理,绝不误判为失败终态。
|
||||
status, known, _, err = s.commander.CardNetworkStatus(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
s.logger.Warn("查询运营商卡状态失败,按仍未确认处理",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID), zap.Error(err))
|
||||
known = false
|
||||
}
|
||||
}
|
||||
confirmed := 0
|
||||
unconfirmed := 0
|
||||
if domain.IsUnresolvedTaskStatus(lock.StopStatus) {
|
||||
switch {
|
||||
case known && status == constants.NetworkStatusOffline:
|
||||
confirmed++
|
||||
if err := s.confirmStop(ctx, lock, card); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
unconfirmed++
|
||||
}
|
||||
}
|
||||
if domain.IsUnresolvedTaskStatus(lock.ResumeStatus) {
|
||||
switch {
|
||||
case known && status == constants.NetworkStatusOnline:
|
||||
confirmed++
|
||||
if err := s.confirmResume(ctx, lock, card); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
unconfirmed++
|
||||
}
|
||||
}
|
||||
if confirmed == 0 {
|
||||
stats.Pending++
|
||||
} else {
|
||||
stats.Confirmed++
|
||||
}
|
||||
if unconfirmed == 0 {
|
||||
return nil
|
||||
}
|
||||
// 仍有子任务不可确认:窗口内继续等待,超期标记异常并退出自动扫描。
|
||||
if !domain.SubmissionExpired(lock.StopSubmittedAt, now) && !domain.SubmissionExpired(lock.ResumeSubmittedAt, now) {
|
||||
return nil
|
||||
}
|
||||
marked, err := s.markAnomaly(ctx, lock.ID, "运营商停复机结果超过确认窗口仍不可查,请人工核对")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if marked {
|
||||
stats.Anomaly++
|
||||
s.logger.Warn("通道阈值停复机结果超期不可确认,已标记异常转人工",
|
||||
zap.Uint("lock_id", lock.ID), zap.Uint("card_id", lock.CardID),
|
||||
zap.Time("stop_submitted_at", valueOrZero(lock.StopSubmittedAt)),
|
||||
zap.Time("resume_submitted_at", valueOrZero(lock.ResumeSubmittedAt)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirmStop 停机已被运营商确认:先补写卡停机状态,再把子任务回填为已确认。
|
||||
// 顺序不可颠倒:先写卡状态才能保证「已确认」的锁不会掩盖未回写的卡事实。
|
||||
func (s *Service) confirmStop(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error {
|
||||
if card != nil && card.NetworkStatus != constants.NetworkStatusOffline {
|
||||
if err := s.commander.ConfirmCardState(ctx, lock.CardID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := s.markTaskConfirmed(ctx, lock.ID, stopTask, lock.StopIntegrationID)
|
||||
return err
|
||||
}
|
||||
|
||||
// confirmResume 复机已被运营商确认:先补写卡在线状态,再把子任务回填为已确认。
|
||||
func (s *Service) confirmResume(ctx context.Context, lock *model.CarrierTrafficThresholdLock, card *model.IotCard) error {
|
||||
if card != nil && card.NetworkStatus != constants.NetworkStatusOnline {
|
||||
if err := s.commander.ConfirmCardState(ctx, lock.CardID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := s.markTaskConfirmed(ctx, lock.ID, resumeTask, lock.ResumeIntegrationID)
|
||||
return err
|
||||
}
|
||||
|
||||
// valueOrZero 在日志中安全展开可空的提交时刻。
|
||||
func valueOrZero(value *time.Time) time.Time {
|
||||
if value == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return *value
|
||||
}
|
||||
107
internal/application/carrierthreshold/evaluate.go
Normal file
107
internal/application/carrierthreshold/evaluate.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// EvaluateInTx 在流量观测事务内判定通道阈值,达量时写周期锁并同事务写停机事件。
|
||||
//
|
||||
// 判定前提由调用方保证:只有「读数被接受」(ReadingAccepted)的观测才进入本方法,
|
||||
// 异常下降保护命中的观测不参与判定。重复达量观测由部分唯一索引的 23505 识别为
|
||||
// 「该周期已处理」并幂等跳过:不重复建锁、不重复写停机事件,该冲突与流量基线 CAS 的
|
||||
// CodeConflict 语义不同,绝不能混流。返回错误表示必须整体回滚(与既有流量事实同事务)。
|
||||
func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation domain.Evaluation) error {
|
||||
if s == nil || s.db == nil || s.repository == nil {
|
||||
return errors.New(errors.CodeInternalError, "通道流量阈值判定能力未完整配置")
|
||||
}
|
||||
if tx == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "通道流量阈值判定必须传入事务句柄")
|
||||
}
|
||||
if evaluation.CardID == 0 || evaluation.CarrierID == 0 {
|
||||
return nil
|
||||
}
|
||||
var carrier model.Carrier
|
||||
// 只取判定所需列:周期起点必须来自该运营商的 data_reset_day,漏取会让周期判定失去依据。
|
||||
if err := tx.WithContext(ctx).Select("id", "data_reset_day", "traffic_threshold_enabled", "traffic_threshold_value", "traffic_threshold_unit").
|
||||
Where("id = ?", evaluation.CarrierID).First(&carrier).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 卡引用的运营商已不存在:不判定、不建锁,保持与既有卡事实不一致的现状。
|
||||
return nil
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询运营商通道流量阈值配置失败")
|
||||
}
|
||||
threshold := thresholdOf(carrier)
|
||||
if !threshold.Enabled {
|
||||
return nil
|
||||
}
|
||||
if !threshold.Valid() {
|
||||
// 配置半残(启用但无数值/单位,或单位未知)时绝不按 0 判定,跳过并留可观测日志。
|
||||
s.logger.Warn("运营商通道流量阈值配置不完整,跳过达量判定",
|
||||
zap.Uint("carrier_id", carrier.ID), zap.String("unit", threshold.Unit), zap.Float64("value", threshold.Value))
|
||||
return nil
|
||||
}
|
||||
reached, err := threshold.Reached(evaluation.ReadingMB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !reached {
|
||||
return nil
|
||||
}
|
||||
periodStart, err := domain.PeriodStart(evaluation.ObservedAt, carrier.DataResetDay)
|
||||
if err != nil {
|
||||
s.logger.Warn("运营商上游流量重置日非法,跳过达量判定",
|
||||
zap.Uint("carrier_id", carrier.ID), zap.Int("data_reset_day", carrier.DataResetDay))
|
||||
return nil
|
||||
}
|
||||
lock := &model.CarrierTrafficThresholdLock{
|
||||
CarrierID: evaluation.CarrierID,
|
||||
CardID: evaluation.CardID,
|
||||
PeriodStart: periodStart,
|
||||
Status: domain.LockStatusLocked,
|
||||
StopStatus: domain.TaskStatusPending,
|
||||
ResumeStatus: domain.TaskStatusPending,
|
||||
}
|
||||
// 唯一冲突(该周期已处理)在 createLockInTx 内以保存点隔离:不重复建锁、不重复写停机事件。
|
||||
created, err := s.createLockInTx(ctx, tx, lock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !created {
|
||||
s.logger.Info("该计费周期已存在通道阈值停机锁,跳过重复判定",
|
||||
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID))
|
||||
return nil
|
||||
}
|
||||
if err := AppendStopRequested(ctx, tx, s.repository, lock); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("卡流量达到运营商通道阈值,已写周期锁与停机事件",
|
||||
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID),
|
||||
zap.Uint("lock_id", lock.ID), zap.Float64("reading_mb", evaluation.ReadingMB),
|
||||
zap.Time("period_start", periodStart))
|
||||
return nil
|
||||
}
|
||||
|
||||
// thresholdOf 把运营商持久化列转换为领域阈值配置。
|
||||
func thresholdOf(carrier model.Carrier) domain.Threshold {
|
||||
threshold := domain.Threshold{
|
||||
Enabled: carrier.TrafficThresholdEnabled == 1,
|
||||
Unit: carrier.TrafficThresholdUnit,
|
||||
}
|
||||
if carrier.TrafficThresholdValue != nil {
|
||||
threshold.Value = *carrier.TrafficThresholdValue
|
||||
}
|
||||
return threshold
|
||||
}
|
||||
|
||||
// lockKeyValue 返回周期锁在 Outbox 事件中的稳定字符串标识。
|
||||
func lockKeyValue(lockID uint) string {
|
||||
return strconv.FormatUint(uint64(lockID), 10)
|
||||
}
|
||||
159
internal/application/carrierthreshold/event.go
Normal file
159
internal/application/carrierthreshold/event.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
)
|
||||
|
||||
// EventCarrierThresholdStop 是卡流量达到通道阈值后的可靠停机事件。
|
||||
const EventCarrierThresholdStop = "carrier.threshold.stop.requested"
|
||||
|
||||
// EventCarrierThresholdResume 是通道阈值跨期解锁且条件满足后的可靠复机事件。
|
||||
const EventCarrierThresholdResume = "carrier.threshold.resume.requested"
|
||||
|
||||
// carrierThresholdPayloadVersion 是通道阈值事件的载荷版本。
|
||||
const carrierThresholdPayloadVersion = 1
|
||||
|
||||
// periodLockConstraint 是周期锁部分唯一索引名,用于把 23505 精确识别为该周期已处理。
|
||||
const periodLockConstraint = "uq_carrier_traffic_threshold_lock_key"
|
||||
|
||||
// StopPayload 是通道阈值停机事件的载荷,只携带锁与卡标识,消费者按锁 ID 认领提交权。
|
||||
type StopPayload struct {
|
||||
LockID uint `json:"lock_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
CarrierID uint `json:"carrier_id"`
|
||||
}
|
||||
|
||||
// AppendStopRequested 在达量判定事务内幂等写入通道阈值停机事件。
|
||||
// 事件 ID 由锁 ID 派生,同一周期锁重复投递不会创建第二个事件(ENG-OUTBOX-001)。
|
||||
func AppendStopRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, lock *model.CarrierTrafficThresholdLock) error {
|
||||
if repository == nil {
|
||||
return gorm.ErrInvalidDB
|
||||
}
|
||||
if lock == nil || lock.ID == 0 {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
value := lockKeyValue(lock.ID)
|
||||
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: outboxid.Stable(EventCarrierThresholdStop+":", value),
|
||||
EventType: EventCarrierThresholdStop,
|
||||
PayloadVersion: carrierThresholdPayloadVersion,
|
||||
AggregateType: "carrier_traffic_threshold_lock",
|
||||
AggregateID: value,
|
||||
ResourceType: "iot_card",
|
||||
ResourceID: lockKeyValue(lock.CardID),
|
||||
BusinessKey: EventCarrierThresholdStop + ":" + value,
|
||||
Payload: StopPayload{
|
||||
LockID: lock.ID,
|
||||
CardID: lock.CardID,
|
||||
CarrierID: lock.CarrierID,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// ResumePayload 是通道阈值复机事件的载荷,只携带锁与卡标识,消费者按锁 ID 认领提交权。
|
||||
type ResumePayload struct {
|
||||
LockID uint `json:"lock_id"`
|
||||
CardID uint `json:"card_id"`
|
||||
CarrierID uint `json:"carrier_id"`
|
||||
}
|
||||
|
||||
// AppendResumeRequested 在周期处理事务内幂等写入通道阈值复机事件。
|
||||
// 事件 ID 由锁 ID 派生:解锁认领与复机事件同事务写入,重复投递不会创建第二个事件(ENG-OUTBOX-001)。
|
||||
func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, lock *model.CarrierTrafficThresholdLock) error {
|
||||
if repository == nil {
|
||||
return gorm.ErrInvalidDB
|
||||
}
|
||||
if lock == nil || lock.ID == 0 {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
value := lockKeyValue(lock.ID)
|
||||
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: outboxid.Stable(EventCarrierThresholdResume+":", value),
|
||||
EventType: EventCarrierThresholdResume,
|
||||
PayloadVersion: carrierThresholdPayloadVersion,
|
||||
AggregateType: "carrier_traffic_threshold_lock",
|
||||
AggregateID: value,
|
||||
ResourceType: "iot_card",
|
||||
ResourceID: lockKeyValue(lock.CardID),
|
||||
BusinessKey: EventCarrierThresholdResume + ":" + value,
|
||||
Payload: ResumePayload{
|
||||
LockID: lock.ID,
|
||||
CardID: lock.CardID,
|
||||
CarrierID: lock.CarrierID,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// isPeriodLockConflict 判断错误是否为周期锁唯一键冲突,即「该周期已处理」。
|
||||
// 该冲突必须与流量基线 CAS 冲突(CodeConflict)区分:前者幂等跳过,后者由调用方重放重试。
|
||||
func isPeriodLockConflict(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !stderrors.As(err, &pgErr) {
|
||||
return false
|
||||
}
|
||||
return pgErr.Code == "23505" && pgErr.ConstraintName == periodLockConstraint
|
||||
}
|
||||
|
||||
// Consumer 把通道阈值停复机事件转成一次停复机动作。
|
||||
type Consumer struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewConsumer 创建通道阈值停复机事件消费者。
|
||||
func NewConsumer(service *Service) *Consumer {
|
||||
return &Consumer{service: service}
|
||||
}
|
||||
|
||||
// Consume 按事件类型幂等执行停机或复机;重复投递由锁行认领字段兜住。
|
||||
func (c *Consumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.service == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行能力未配置")
|
||||
}
|
||||
lockID, validationErr := decodeThresholdPayload(envelope)
|
||||
if validationErr != nil {
|
||||
return validationErr
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID})
|
||||
switch envelope.EventType {
|
||||
case EventCarrierThresholdStop:
|
||||
return c.service.ExecuteStop(ctx, lockID)
|
||||
case EventCarrierThresholdResume:
|
||||
return c.service.ExecuteResume(ctx, lockID)
|
||||
default:
|
||||
return outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeThresholdPayload 校验事件类型与载荷版本并取出锁 ID。
|
||||
// 载荷不合法属永久失败:重复投递不会改变结果,必须直接终结而不是重试。
|
||||
func decodeThresholdPayload(envelope outbox.DeliveryEnvelope) (uint, error) {
|
||||
if envelope.PayloadVersion != carrierThresholdPayloadVersion {
|
||||
return 0, outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
var payload struct {
|
||||
LockID uint `json:"lock_id"`
|
||||
}
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return 0, outbox.Permanent(err)
|
||||
}
|
||||
if payload.LockID == 0 {
|
||||
return 0, outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
return payload.LockID, nil
|
||||
}
|
||||
|
||||
// 编译期断言:通道阈值停复机消费者满足公共 Outbox 的消费边界。
|
||||
var _ outbox.EventConsumer = (*Consumer)(nil)
|
||||
199
internal/application/carrierthreshold/execute.go
Normal file
199
internal/application/carrierthreshold/execute.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// 无法从运营商确认结果时的可安全展示原因(不含内部细节与渠道报文)。
|
||||
const (
|
||||
failedStopReason = "运营商停机调用失败,等待状态查询确认"
|
||||
unknownStopReason = "运营商停机结果未知,等待状态查询确认"
|
||||
failedResumeReason = "运营商复机调用失败,等待状态查询确认"
|
||||
unknownResumeReason = "运营商复机结果未知,等待状态查询确认"
|
||||
)
|
||||
|
||||
// ExecuteStop 执行一次通道阈值达量停机,保证至多一次外部调用。
|
||||
//
|
||||
// 流程:提交认领(stop_submitted_at IS NULL 且仍持锁)→ 卡已停机则直接确认成功,不调运营商 →
|
||||
// 否则复用既有停机重试、Integration Log 与统一审计执行停机。认领失败表示该锁已提交过
|
||||
// (事件重复投递或人工重放),本次只结束,绝不重复调用;结果由恢复扫描查询收敛。
|
||||
// 任何分支都不删除锁行、不解锁。
|
||||
func (s *Service) ExecuteStop(ctx context.Context, lockID uint) error {
|
||||
if err := s.requireExecution(); err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := s.loadLock(ctx, lockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lock == nil {
|
||||
s.logger.Info("通道阈值停机事件对应锁不存在,幂等跳过", zap.Uint("lock_id", lockID))
|
||||
return nil
|
||||
}
|
||||
claimed, err := s.ClaimStopSubmission(ctx, lockID, s.now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
s.logger.Info("通道阈值停机已提交过,只等待恢复扫描确认",
|
||||
zap.Uint("lock_id", lockID), zap.String("stop_status", lock.StopStatus))
|
||||
return nil
|
||||
}
|
||||
card, err := s.loadCard(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if card == nil {
|
||||
// 卡事实不存在时无法调运营商,保留 submitted 由恢复扫描按窗口标记异常转人工。
|
||||
s.logger.Warn("通道阈值停机锁对应卡不存在,等待恢复扫描处理",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
|
||||
return nil
|
||||
}
|
||||
if card.NetworkStatus == constants.NetworkStatusOffline {
|
||||
// 其他停因已先行停机:不重复调用运营商,直接确认本次停机目标已达成。
|
||||
if _, err := s.markTaskConfirmed(ctx, lockID, stopTask, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("卡已停机,通道阈值停机直接确认成功",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.String("stop_reason", card.StopReason))
|
||||
return nil
|
||||
}
|
||||
outcome, callErr := s.commander.StopCardForThreshold(ctx, lock.CardID)
|
||||
if callErr != nil && outcome.Result == "" {
|
||||
// 基础设施故障(读卡、写审计或写卡状态失败)导致结果无法判定:保留 submitted,
|
||||
// 交由恢复扫描查询确认,本次不判定终态、不重复调用。
|
||||
s.logger.Error("通道阈值停机执行失败,等待恢复扫描确认",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.Error(callErr))
|
||||
return nil
|
||||
}
|
||||
if callErr != nil {
|
||||
s.logger.Warn("通道阈值停机运营商调用未成功,已按结果分类回填",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
|
||||
zap.String("result", outcome.Result), zap.Error(callErr))
|
||||
}
|
||||
_, err = s.markTaskOutcome(ctx, lockID, stopTask, []string{domain.TaskStatusSubmitted},
|
||||
taskResultOf(outcome), outcome.IntegrationID, stopFailureReason(outcome))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("通道阈值停机任务已回填结果",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
|
||||
zap.String("result", outcome.Result), zap.String("integration_id", outcome.IntegrationID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExecuteResume 执行一次通道阈值新周期复机,保证至多一次外部调用。
|
||||
//
|
||||
// 流程:提交认领(resume_submitted_at IS NULL)→ 卡已在线则直接确认成功,不调运营商 →
|
||||
// 否则复用既有复机重试、Integration Log 与统一审计执行复机(成功时同一事务写回卡状态,
|
||||
// 且只在停因为通道阈值时清除停因)。认领失败表示已提交过,绝不重复调用。
|
||||
func (s *Service) ExecuteResume(ctx context.Context, lockID uint) error {
|
||||
if err := s.requireExecution(); err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := s.loadLock(ctx, lockID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lock == nil {
|
||||
s.logger.Info("通道阈值复机事件对应锁不存在,幂等跳过", zap.Uint("lock_id", lockID))
|
||||
return nil
|
||||
}
|
||||
claimed, err := s.ClaimResumeSubmission(ctx, lockID, s.now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
s.logger.Info("通道阈值复机已提交过,只等待恢复扫描确认",
|
||||
zap.Uint("lock_id", lockID), zap.String("resume_status", lock.ResumeStatus))
|
||||
return nil
|
||||
}
|
||||
card, err := s.loadCard(ctx, lock.CardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if card == nil {
|
||||
s.logger.Warn("通道阈值复机锁对应卡不存在,等待恢复扫描处理",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
|
||||
return nil
|
||||
}
|
||||
if card.NetworkStatus == constants.NetworkStatusOnline {
|
||||
if _, err := s.markTaskConfirmed(ctx, lockID, resumeTask, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("卡已在线,通道阈值复机直接确认成功",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID))
|
||||
return nil
|
||||
}
|
||||
outcome, callErr := s.commander.ResumeCardForThreshold(ctx, lock.CardID)
|
||||
if callErr != nil && outcome.Result == "" {
|
||||
s.logger.Error("通道阈值复机执行失败,等待恢复扫描确认",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID), zap.Error(callErr))
|
||||
return nil
|
||||
}
|
||||
if callErr != nil {
|
||||
s.logger.Warn("通道阈值复机运营商调用未成功,已按结果分类回填",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
|
||||
zap.String("result", outcome.Result), zap.Error(callErr))
|
||||
}
|
||||
_, err = s.markTaskOutcome(ctx, lockID, resumeTask, []string{domain.TaskStatusSubmitted},
|
||||
taskResultOf(outcome), outcome.IntegrationID, resumeFailureReason(outcome))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("通道阈值复机任务已回填结果",
|
||||
zap.Uint("lock_id", lockID), zap.Uint("card_id", lock.CardID),
|
||||
zap.String("result", outcome.Result), zap.String("integration_id", outcome.IntegrationID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireExecution 校验消费者与周期处理所需的端口已配置。
|
||||
func (s *Service) requireExecution() error {
|
||||
if s == nil || s.db == nil || s.repository == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值执行能力未完整配置")
|
||||
}
|
||||
if s.commander == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "通道流量阈值停复机执行端口未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// taskResultOf 把运营商调用结果映射为子任务终态。
|
||||
func taskResultOf(outcome domain.CommandOutcome) string {
|
||||
switch outcome.Result {
|
||||
case constants.AuditResultSuccess:
|
||||
return domain.TaskStatusConfirmed
|
||||
case constants.AuditResultUnknown:
|
||||
return domain.TaskStatusUnknown
|
||||
default:
|
||||
return domain.TaskStatusFailed
|
||||
}
|
||||
}
|
||||
|
||||
// stopFailureReason 生成停机子任务的可安全失败原因,成功时为空。
|
||||
func stopFailureReason(outcome domain.CommandOutcome) string {
|
||||
if outcome.Confirmed() {
|
||||
return ""
|
||||
}
|
||||
if outcome.Unresolved() {
|
||||
return unknownStopReason
|
||||
}
|
||||
return failedStopReason
|
||||
}
|
||||
|
||||
// resumeFailureReason 生成复机子任务的可安全失败原因,成功时为空。
|
||||
func resumeFailureReason(outcome domain.CommandOutcome) string {
|
||||
if outcome.Confirmed() {
|
||||
return ""
|
||||
}
|
||||
if outcome.Unresolved() {
|
||||
return unknownResumeReason
|
||||
}
|
||||
return failedResumeReason
|
||||
}
|
||||
361
internal/application/carrierthreshold/lock_store.go
Normal file
361
internal/application/carrierthreshold/lock_store.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// activeLockScanLimit 是「按卡取当前周期锁」的候选扫描上限。
|
||||
// 一张卡正常情况下只有当前周期的锁与历史周期锁,取最近若干条足以覆盖换运营商后的场景。
|
||||
const activeLockScanLimit = 8
|
||||
|
||||
// createLockInTx 在调用方事务内插入周期锁,返回 created=false 表示该周期已处理(唯一键冲突)。
|
||||
func (s *Service) createLockInTx(ctx context.Context, tx *gorm.DB, lock *model.CarrierTrafficThresholdLock) (bool, error) {
|
||||
// 唯一冲突在 PostgreSQL 中会中止整个事务,因此插入必须隔离在保存点内:
|
||||
// GORM 对已开启事务的嵌套 Transaction 使用 SAVEPOINT,冲突只回滚本次插入。
|
||||
insertErr := tx.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
|
||||
return inner.Create(lock).Error
|
||||
})
|
||||
if insertErr == nil {
|
||||
return true, nil
|
||||
}
|
||||
if isPeriodLockConflict(insertErr) {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, insertErr, "写入通道流量阈值周期锁失败")
|
||||
}
|
||||
|
||||
// FindLock 按唯一键读取某卡在某运营商某计费周期内的锁;不存在返回 nil。
|
||||
func (s *Service) FindLock(ctx context.Context, carrierID, cardID uint, periodStart time.Time) (*model.CarrierTrafficThresholdLock, error) {
|
||||
if s == nil || s.db == nil || carrierID == 0 || cardID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var lock model.CarrierTrafficThresholdLock
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("carrier_id = ? AND card_id = ? AND period_start = ?", carrierID, cardID, periodStart).
|
||||
First(&lock).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通道流量阈值周期锁失败")
|
||||
}
|
||||
return &lock, nil
|
||||
}
|
||||
|
||||
// ActiveLock 返回该卡在 now 所属计费周期内仍然生效的通道阈值锁;无锁返回 nil。
|
||||
//
|
||||
// 周期归属按锁行自身运营商的 data_reset_day 判断,因此换运营商后旧周期锁不会误判为当前周期。
|
||||
// 锁行引用的运营商已不存在时无法计算周期归属,此时按「仍可能属于当前周期」处理并返回该锁:
|
||||
// 持有通道阈值锁的卡在周期内必须拒绝一切复机,不能因为配置缺失放开复机,只能由周期处理/人工核销。
|
||||
func (s *Service) ActiveLock(ctx context.Context, cardID uint, now time.Time) (*model.CarrierTrafficThresholdLock, error) {
|
||||
if s == nil || s.db == nil || cardID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var locks []model.CarrierTrafficThresholdLock
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("card_id = ? AND status = ?", cardID, domain.LockStatusLocked).
|
||||
Order("period_start DESC").Limit(activeLockScanLimit).Find(&locks).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡当前周期通道流量阈值锁失败")
|
||||
}
|
||||
if len(locks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
resetDays, err := s.carrierResetDays(ctx, lockCarrierIDs(locks))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range locks {
|
||||
resetDay, ok := resetDays[locks[index].CarrierID]
|
||||
if !ok {
|
||||
return &locks[index], nil
|
||||
}
|
||||
periodStart, err := domain.PeriodStart(now, resetDay)
|
||||
if err != nil {
|
||||
// 重置日非法时同样无法判定周期归属,按仍生效处理,避免放开复机。
|
||||
return &locks[index], nil
|
||||
}
|
||||
if locks[index].PeriodStart.Equal(periodStart) {
|
||||
return &locks[index], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ClaimStopSubmission 以 stop_submitted_at IS NULL 条件更新认领停机提交权。
|
||||
//
|
||||
// 返回 true 表示调用方获得提交权、可以调用停机接口;false 表示该锁已提交过(事件重复投递或
|
||||
// 人工重放),调用方只能查询结果。谓词同时要求锁仍处于 locked:已跨期解锁的锁不再停机。
|
||||
func (s *Service) ClaimStopSubmission(ctx context.Context, lockID uint, now time.Time) (bool, error) {
|
||||
if s == nil || s.db == nil || lockID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
claimed := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
|
||||
Where("id = ? AND stop_submitted_at IS NULL AND status = ?", lockID, domain.LockStatusLocked).
|
||||
Updates(map[string]any{"stop_submitted_at": now, "stop_status": domain.TaskStatusSubmitted})
|
||||
if claimed.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领通道阈值停机提交权失败")
|
||||
}
|
||||
return claimed.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// ClaimResumeSubmission 以 resume_submitted_at IS NULL 条件更新认领复机提交权。
|
||||
//
|
||||
// 复机发生在周期处理解锁之后,此时锁已是 unlocked,因此谓词只要求未提交过。
|
||||
func (s *Service) ClaimResumeSubmission(ctx context.Context, lockID uint, now time.Time) (bool, error) {
|
||||
if s == nil || s.db == nil || lockID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
claimed := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
|
||||
Where("id = ? AND resume_submitted_at IS NULL", lockID).
|
||||
Updates(map[string]any{"resume_submitted_at": now, "resume_status": domain.TaskStatusSubmitted})
|
||||
if claimed.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领通道阈值复机提交权失败")
|
||||
}
|
||||
return claimed.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// DueLockKind 描述一条仍在持锁的锁行的跨期判定结果。
|
||||
type DueLockKind string
|
||||
|
||||
const (
|
||||
// DueLockExpired 表示已跨期:锁行 period_start 早于该锁行自身运营商按 data_reset_day 算出的当前周期起点。
|
||||
DueLockExpired DueLockKind = "expired"
|
||||
// DueLockUnresolvable 表示周期归属不可判定:锁行引用的运营商已不存在(含软删)或重置日非法。
|
||||
DueLockUnresolvable DueLockKind = "unresolvable"
|
||||
)
|
||||
|
||||
// unresolvableCarrierReason 是周期归属不可判定时写入锁行的可安全原因。
|
||||
const unresolvableCarrierReason = "锁行引用的运营商已不存在或上游流量重置日非法,已按跨期解除通道锁并转人工核对"
|
||||
|
||||
// DueLock 是周期处理扫描到的一条待处理锁行。
|
||||
type DueLock struct {
|
||||
// Lock 是持锁锁行本身。
|
||||
Lock model.CarrierTrafficThresholdLock
|
||||
// Kind 是跨期判定结果:已跨期或周期归属不可判定。
|
||||
Kind DueLockKind
|
||||
// Reason 是周期归属不可判定时的可安全原因(Kind 为 DueLockExpired 时为空)。
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ScanDueLocks 扫描需要周期处理的持锁锁行,供周期处理解锁与条件复机。
|
||||
//
|
||||
// 过期判断按锁行自身运营商的 data_reset_day 计算其当前周期起点,与锁行 period_start 不一致即已跨期,
|
||||
// 因此换运营商后的旧锁仍按其旧 carrier 的归属被正确识别。锁行引用的运营商已不存在或重置日非法时
|
||||
// 无法计算周期归属,这类行以 DueLockUnresolvable 返回:周期处理必须给出出路(按跨期语义解锁并转人工),
|
||||
// 绝不能让持锁卡因配置缺失而永久禁止复机。
|
||||
func (s *Service) ScanDueLocks(ctx context.Context, now time.Time, limit int) ([]DueLock, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var locks []model.CarrierTrafficThresholdLock
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("status = ?", domain.LockStatusLocked).
|
||||
Order("id ASC").Limit(limit).Find(&locks).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描跨期通道流量阈值锁失败")
|
||||
}
|
||||
if len(locks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
resetDays, err := s.carrierResetDays(ctx, lockCarrierIDs(locks))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
due := make([]DueLock, 0, len(locks))
|
||||
for index := range locks {
|
||||
lock := locks[index]
|
||||
resetDay, ok := resetDays[lock.CarrierID]
|
||||
if !ok {
|
||||
due = append(due, DueLock{Lock: lock, Kind: DueLockUnresolvable, Reason: unresolvableCarrierReason})
|
||||
continue
|
||||
}
|
||||
periodStart, periodErr := domain.PeriodStart(now, resetDay)
|
||||
if periodErr != nil {
|
||||
due = append(due, DueLock{Lock: lock, Kind: DueLockUnresolvable, Reason: unresolvableCarrierReason})
|
||||
continue
|
||||
}
|
||||
if lock.PeriodStart.Before(periodStart) {
|
||||
due = append(due, DueLock{Lock: lock, Kind: DueLockExpired})
|
||||
}
|
||||
}
|
||||
return due, nil
|
||||
}
|
||||
|
||||
// ScanSubmittedLocks 扫描存在未决子任务的锁行,供恢复扫描查询运营商状态回填。
|
||||
//
|
||||
// 未决集合为 {submitted, unknown, failed}(domain.UnresolvedTaskStatuses):调用失败或结果未知的行
|
||||
// 仍可能已在运营商侧生效,因此必须继续用只读状态查询收敛,不得退出链路。
|
||||
// 已标记异常(转人工)的锁必须退出扫描,否则每次扫描都会重复查询同一笔无法收敛的结果。
|
||||
func (s *Service) ScanSubmittedLocks(ctx context.Context, limit int) ([]model.CarrierTrafficThresholdLock, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
unresolved := domain.UnresolvedTaskStatuses()
|
||||
var locks []model.CarrierTrafficThresholdLock
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("anomaly_flag = ? AND (stop_status IN ? OR resume_status IN ?)",
|
||||
domain.AnomalyFlagNone, unresolved, unresolved).
|
||||
Order("id ASC").Limit(limit).Find(&locks).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描待确认通道流量阈值任务失败")
|
||||
}
|
||||
return locks, nil
|
||||
}
|
||||
|
||||
// lockTask 标识锁行上的停复机子任务。
|
||||
type lockTask struct {
|
||||
// statusColumn 是子任务状态列名。
|
||||
statusColumn string
|
||||
// integrationColumn 是该子任务对应的 Integration Log 标识列名。
|
||||
integrationColumn string
|
||||
}
|
||||
|
||||
var (
|
||||
// stopTask 是停机子任务。
|
||||
stopTask = lockTask{statusColumn: "stop_status", integrationColumn: "stop_integration_id"}
|
||||
// resumeTask 是复机子任务。
|
||||
resumeTask = lockTask{statusColumn: "resume_status", integrationColumn: "resume_integration_id"}
|
||||
)
|
||||
|
||||
// failureReasonMaxRunes 与 tb_carrier_traffic_threshold_lock.failure_reason 的长度上限一致。
|
||||
const failureReasonMaxRunes = 500
|
||||
|
||||
// safeFailureReason 裁剪可安全展示的失败原因,超长截断,绝不写入内部细节。
|
||||
func safeFailureReason(reason string) string {
|
||||
trimmed := strings.TrimSpace(reason)
|
||||
runes := []rune(trimmed)
|
||||
if len(runes) <= failureReasonMaxRunes {
|
||||
return trimmed
|
||||
}
|
||||
return string(runes[:failureReasonMaxRunes])
|
||||
}
|
||||
|
||||
// loadLock 按 ID 读取锁行;不存在返回 nil(软删行不可见)。
|
||||
func (s *Service) loadLock(ctx context.Context, lockID uint) (*model.CarrierTrafficThresholdLock, error) {
|
||||
if s == nil || s.db == nil || lockID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var lock model.CarrierTrafficThresholdLock
|
||||
err := s.db.WithContext(ctx).Where("id = ?", lockID).First(&lock).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通道流量阈值周期锁失败")
|
||||
}
|
||||
return &lock, nil
|
||||
}
|
||||
|
||||
// loadCard 按 ID 读取卡事实;不存在返回 nil。
|
||||
func (s *Service) loadCard(ctx context.Context, cardID uint) (*model.IotCard, error) {
|
||||
if s == nil || s.db == nil || cardID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var card model.IotCard
|
||||
err := s.db.WithContext(ctx).Where("id = ?", cardID).First(&card).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通道阈值卡事实失败")
|
||||
}
|
||||
return &card, nil
|
||||
}
|
||||
|
||||
// markTaskOutcome 按 expected 状态集合条件更新把子任务推进到终态(ENG-CONC-001)。
|
||||
// 返回 false 表示记录已被并发推进或已处于终态,调用方必须按幂等处理,不再重复执行外部动作。
|
||||
func (s *Service) markTaskOutcome(ctx context.Context, lockID uint, task lockTask, expected []string, result, integrationID, failureReason string) (bool, error) {
|
||||
if s == nil || s.db == nil || lockID == 0 || len(expected) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
updates := map[string]any{task.statusColumn: result}
|
||||
if integrationID != "" {
|
||||
updates[task.integrationColumn] = integrationID
|
||||
}
|
||||
if failureReason != "" {
|
||||
updates["failure_reason"] = safeFailureReason(failureReason)
|
||||
}
|
||||
result_ := s.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
|
||||
Where("id = ? AND "+task.statusColumn+" IN ?", lockID, expected).
|
||||
Updates(updates)
|
||||
if result_.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result_.Error, "回填通道阈值子任务状态失败")
|
||||
}
|
||||
return result_.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// markTaskConfirmed 由恢复扫描在运营商状态确认成功后把子任务回填为已确认。
|
||||
// expected 取未决集合 {submitted, unknown, failed}:失败与结果未知同样可能已在运营商侧生效,
|
||||
// 必须允许收敛为已确认;confirmed 不在集合内,因此确认只会写入一次。
|
||||
func (s *Service) markTaskConfirmed(ctx context.Context, lockID uint, task lockTask, integrationID string) (bool, error) {
|
||||
return s.markTaskOutcome(ctx, lockID, task, domain.UnresolvedTaskStatuses(), domain.TaskStatusConfirmed, integrationID, "")
|
||||
}
|
||||
|
||||
// markAnomaly 把锁标记为需人工核对并退出自动扫描(使用服务自身连接池,调用方不得已持有该行锁)。
|
||||
// 只应在窗口超期且结果无法确认时调用;已标记的锁不再重复查询。
|
||||
func (s *Service) markAnomaly(ctx context.Context, lockID uint, reason string) (bool, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.markAnomalyInTx(ctx, s.db, lockID, reason)
|
||||
}
|
||||
|
||||
// markAnomalyInTx 在调用方事务内把锁标记为需人工核对(anomaly_flag=0 → 1 条件更新)。
|
||||
//
|
||||
// 调用方已在同一事务内写过该行时 MUST 使用本方法:改用服务自身连接池会让另一条连接
|
||||
// 等待本事务持有的行锁(自锁),表现为处理挂死到语句超时。返回 false 表示已被并发标记,
|
||||
// 调用方按幂等处理。
|
||||
func (s *Service) markAnomalyInTx(ctx context.Context, tx *gorm.DB, lockID uint, reason string) (bool, error) {
|
||||
if tx == nil || lockID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
|
||||
Where("id = ? AND anomaly_flag = ?", lockID, domain.AnomalyFlagNone).
|
||||
Updates(map[string]any{
|
||||
"anomaly_flag": domain.AnomalyFlagManual,
|
||||
"failure_reason": safeFailureReason(reason),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记通道阈值锁异常失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// unlockInTx 在调用方事务内按 status=locked 条件更新认领解锁,返回 false 表示已被并发推进。
|
||||
// 未满足复机条件时同事务写入可观察原因;锁行与历史任务结果一律保留,绝不删除。
|
||||
func (s *Service) unlockInTx(ctx context.Context, tx *gorm.DB, lockID uint, failureReason string) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, errors.New(errors.CodeInvalidStatus, "通道阈值解锁必须传入事务句柄")
|
||||
}
|
||||
updates := map[string]any{"status": domain.LockStatusUnlocked}
|
||||
if failureReason != "" {
|
||||
updates["failure_reason"] = safeFailureReason(failureReason)
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}).
|
||||
Where("id = ? AND status = ?", lockID, domain.LockStatusLocked).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "解除通道流量阈值周期锁失败")
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// lockCarrierIDs 收集锁行引用的运营商 ID 去重集合,用于显式批量查询(ENG-MODEL-001)。
|
||||
func lockCarrierIDs(locks []model.CarrierTrafficThresholdLock) []uint {
|
||||
seen := make(map[uint]struct{}, len(locks))
|
||||
ids := make([]uint, 0, len(locks))
|
||||
for index := range locks {
|
||||
if _, ok := seen[locks[index].CarrierID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[locks[index].CarrierID] = struct{}{}
|
||||
ids = append(ids, locks[index].CarrierID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
107
internal/application/carrierthreshold/service.go
Normal file
107
internal/application/carrierthreshold/service.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Package carrierthreshold 编排运营商通道流量阈值的达量判定与周期锁事实。
|
||||
//
|
||||
// 本包拥有 tb_carrier_traffic_threshold_lock 的全部读写:达量判定在流量观测事务内写入周期锁
|
||||
// 与可靠停机事件,消费者以提交认领字段取得至多一次的外部调用权,周期处理与恢复扫描只查询锁行。
|
||||
// 本包不调用任何运营商接口,也不在数据库事务内发起外部 I/O:停复机执行通过 CardCommander
|
||||
// 端口复用既有停复机单一事实源。
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
domain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// CardCommander 是通道阈值停复机对卡与运营商接口的能力边界。
|
||||
//
|
||||
// 实现必须复用既有停复机单一事实源(重试、Integration Log、统一审计、卡状态观测序列)与
|
||||
// 既有套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。
|
||||
type CardCommander interface {
|
||||
// StopCardForThreshold 执行通道阈值达量停机,成功时写回卡停机状态与停因。
|
||||
StopCardForThreshold(ctx context.Context, cardID uint) (domain.CommandOutcome, error)
|
||||
// ResumeCardForThreshold 执行新周期复机,成功时写回卡在线状态(只清除通道阈值停因)。
|
||||
ResumeCardForThreshold(ctx context.Context, cardID uint) (domain.CommandOutcome, error)
|
||||
// ResumeReady 判断解锁后的卡是否满足自动复机条件;不满足时返回可安全记录的原因。
|
||||
ResumeReady(ctx context.Context, cardID uint) (ready bool, reason string, err error)
|
||||
// CardNetworkStatus 只查询运营商卡状态并映射为本地网络状态;known 为 false 表示状态不可判定。
|
||||
CardNetworkStatus(ctx context.Context, cardID uint) (status int, known bool, integrationID string, err error)
|
||||
// ConfirmCardState 按已确认的运营商结果补写卡状态,覆盖 Gateway 成功但 DB 更新失败的场景。
|
||||
ConfirmCardState(ctx context.Context, cardID uint, offline bool) error
|
||||
}
|
||||
|
||||
// Service 执行通道阈值达量判定并维护周期锁事实。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
// repository 是公共 Outbox 仓储:达量停机事件与周期复机事件必须与锁事实在同一事务写入(ENG-OUTBOX-001)。
|
||||
repository *outbox.Repository
|
||||
logger *zap.Logger
|
||||
// commander 是停复机执行端口;未注入时消费者与 cron 拒绝执行外部调用。
|
||||
commander CardCommander
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService 创建通道阈值用例;repository 决定达量判定能否写出可靠停复机事件。
|
||||
func NewService(db *gorm.DB, repository *outbox.Repository) *Service {
|
||||
return &Service{db: db, repository: repository, logger: zap.NewNop(), now: time.Now}
|
||||
}
|
||||
|
||||
// SetLogger 注入通道阈值运行日志。
|
||||
func (s *Service) SetLogger(logger *zap.Logger) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
if logger == nil {
|
||||
s.logger = zap.NewNop()
|
||||
return s
|
||||
}
|
||||
s.logger = logger
|
||||
return s
|
||||
}
|
||||
|
||||
// SetCommander 注入停复机执行端口(消费者与周期处理必需)。
|
||||
func (s *Service) SetCommander(commander CardCommander) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
s.commander = commander
|
||||
return s
|
||||
}
|
||||
|
||||
// ChannelThresholdLocked 判断该卡当前计费周期是否持有通道阈值停机锁。
|
||||
//
|
||||
// 供复机入口前置拒绝复用:持锁即拒绝,判定口径与锁生效口径完全一致(按锁行自身运营商的
|
||||
// data_reset_day 判断周期归属),不另立一套判定。
|
||||
func (s *Service) ChannelThresholdLocked(ctx context.Context, cardID uint) (bool, error) {
|
||||
lock, err := s.ActiveLock(ctx, cardID, s.now())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return lock != nil, nil
|
||||
}
|
||||
|
||||
// carrierResetDays 按运营商 ID 集合显式查询上游流量重置日(ENG-MODEL-001:不使用关联标签)。
|
||||
// 返回结果只包含仍然存在的运营商,缺失 ID 由调用方按各自语义处理。
|
||||
func (s *Service) carrierResetDays(ctx context.Context, carrierIDs []uint) (map[uint]int, error) {
|
||||
resetDays := make(map[uint]int, len(carrierIDs))
|
||||
if len(carrierIDs) == 0 {
|
||||
return resetDays, nil
|
||||
}
|
||||
var carriers []model.Carrier
|
||||
if err := s.db.WithContext(ctx).
|
||||
Select("id", "data_reset_day").
|
||||
Where("id IN ?", carrierIDs).
|
||||
Find(&carriers).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商上游流量重置日失败")
|
||||
}
|
||||
for index := range carriers {
|
||||
resetDays[carriers[index].ID] = carriers[index].DataResetDay
|
||||
}
|
||||
return resetDays, nil
|
||||
}
|
||||
157
internal/application/distributionwithdrawal/audit.go
Normal file
157
internal/application/distributionwithdrawal/audit.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// 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 是公开扫码注册复用的短信验证码接缝。
|
||||
// CheckCode 只校验不消费;业务事实落库成功后再由 ConsumeCode 原子消费,
|
||||
// 使落库前的任何失败都不会消费验证码,重试无需重新获取短信验证码。
|
||||
type VerificationCodeVerifier interface {
|
||||
CheckCode(ctx context.Context, phone string, code string) error
|
||||
ConsumeCode(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,
|
||||
})
|
||||
}
|
||||
463
internal/application/distributionwithdrawal/qualification.go
Normal file
463
internal/application/distributionwithdrawal/qualification.go
Normal file
@@ -0,0 +1,463 @@
|
||||
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(¤t).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定有效提现资料资格失败")
|
||||
}
|
||||
if !qualificationRequiresApproval(¤t, input) {
|
||||
return nil, errors.New(errors.CodeConflict, "提现资料资格已生效,合同与法人身份证未变化")
|
||||
}
|
||||
if err := invalidateVersion(ctx, tx, ¤t, "代理替换合同或法人身份证资料", operatorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ¤t, 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
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
365
internal/application/distributionwithdrawal/registration.go
Normal file
365
internal/application/distributionwithdrawal/registration.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package distributionwithdrawal
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"slices"
|
||||
"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 创建待审批注册记录。
|
||||
// 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示。
|
||||
// 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取。
|
||||
// 手机号、用户名或店铺编号与既有账号/店铺冲突时返回对应已存在错误码;
|
||||
// 与其它待审批注册记录冲突时返回资源冲突错误并指明冲突字段。
|
||||
// 已通过或已驳回的终态记录不阻塞重新注册;同一关键字段的并发提交由事务级 advisory lock 串行裁决。
|
||||
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, "密码哈希失败")
|
||||
}
|
||||
parent, err := s.findDistributionParent(ctx, normalized.DistributionCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 只校验不消费:验证码在注册记录与审批实例落库成功后才消费,落库前的失败不消耗验证码。
|
||||
if err := s.verifier.CheckCode(ctx, normalized.Phone, code); err != nil {
|
||||
return nil, verificationFailure(err)
|
||||
}
|
||||
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 {
|
||||
// 关键字段门禁与注册记录插入必须同处一个串行化区间:先取 advisory lock,
|
||||
// 再在同一事务内校验并落库,避免并发提交落下两条指向同一手机号/用户名/店铺编号的待审批申请。
|
||||
if err := lockRegistrationKeyScopes(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureRegistrationKeysAvailable(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureNoPendingRegistration(ctx, tx, normalized.Phone, normalized.Username, normalized.ShopCode); err != nil {
|
||||
return err
|
||||
}
|
||||
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/source(Append 会以「审计操作者或入口不符合动作注册规则」失败)。
|
||||
// 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
|
||||
}
|
||||
// 注册记录与审批实例已落库:此后消费验证码失败只记录日志,不回滚既有事实,也不改变对外成功结果。
|
||||
// 消费失败(验证码已过期或已被并发的另一次提交消费)由验证码实现侧记录。
|
||||
_ = s.verifier.ConsumeCode(ctx, normalized.Phone, code)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// registrationKeyScopePrefix 是注册关键字段串行化点的键前缀,与其它用例的 advisory lock 键空间隔离。
|
||||
const registrationKeyScopePrefix = "agent-distribution-registration:"
|
||||
|
||||
// lockRegistrationKeyScopes 在事务内为注册关键字段(手机号、用户名、店铺编号)取稳定串行化点。
|
||||
// 目标关键字段的待审批记录可能尚不存在,行锁无法覆盖「首次并发提交」,
|
||||
// 因此按 key 字符串升序取事务级 advisory lock;升序保证并发提交不会形成 A→B / B→A 死锁环。
|
||||
// 锁随本次事务提交或回滚自动释放。
|
||||
func lockRegistrationKeyScopes(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
|
||||
keys := []string{
|
||||
registrationKeyScopePrefix + "phone:" + phone,
|
||||
registrationKeyScopePrefix + "username:" + username,
|
||||
registrationKeyScopePrefix + "shop_code:" + shopCode,
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for _, key := range keys {
|
||||
if err := tx.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(hashtext(?))", key).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定注册关键字段串行化点失败")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureRegistrationKeysAvailable 校验注册关键字段未被既有账号或店铺占用。
|
||||
// 手机号与用户名对应 tb_account 的条件唯一索引,店铺编号对应 tb_shop 的条件唯一索引;
|
||||
// 查询沿用 GORM 默认软删除范围,软删除账号或店铺占用的关键字段可被重新注册。
|
||||
func ensureRegistrationKeysAvailable(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
|
||||
if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "phone", phone); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册手机号失败")
|
||||
} else if exists {
|
||||
return errors.New(errors.CodePhoneExists, "手机号已被使用")
|
||||
}
|
||||
if exists, err := registrationKeyTaken(ctx, tx, &model.Account{}, "username", username); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册用户名失败")
|
||||
} else if exists {
|
||||
return errors.New(errors.CodeUsernameExists, "用户名已存在")
|
||||
}
|
||||
if exists, err := registrationKeyTaken(ctx, tx, &model.Shop{}, "shop_code", shopCode); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验注册店铺编号失败")
|
||||
} else if exists {
|
||||
return errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// registrationKeyTaken 判断目标表(默认软删除范围)是否已存在占用该关键字段的记录。
|
||||
func registrationKeyTaken(ctx context.Context, tx *gorm.DB, target any, column, value string) (bool, error) {
|
||||
var count int64
|
||||
if err := tx.WithContext(ctx).Model(target).Where(column+" = ?", value).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ensureNoPendingRegistration 校验关键字段没有正在等待审批的注册申请。
|
||||
// 已通过或已驳回的终态记录不阻塞重新注册:资料填错后重新扫码必须能形成新的申请与新审批实例。
|
||||
func ensureNoPendingRegistration(ctx context.Context, tx *gorm.DB, phone, username, shopCode string) error {
|
||||
var pending model.AgentDistributionRegistration
|
||||
err := tx.WithContext(ctx).
|
||||
Where("status = ? AND (phone = ? OR username = ? OR shop_code = ?)",
|
||||
constants.AgentDistributionRegistrationStatusPending, phone, username, shopCode).
|
||||
Order("id ASC").First(&pending).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
return pendingKeyConflict(&pending, phone, username)
|
||||
case stderrors.Is(err, gorm.ErrRecordNotFound):
|
||||
return nil
|
||||
default:
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验待审批注册申请失败")
|
||||
}
|
||||
}
|
||||
|
||||
// pendingKeyConflict 把命中的待审批记录映射为指明冲突字段的冲突错误。
|
||||
// 查询条件保证三个关键字段至少一个命中,店铺编号作为兜底分支。
|
||||
func pendingKeyConflict(pending *model.AgentDistributionRegistration, phone, username string) error {
|
||||
switch {
|
||||
case pending.Phone == phone:
|
||||
return errors.New(errors.CodeConflict, "该手机号已有待审批的注册申请,请等待审批结果")
|
||||
case pending.Username == username:
|
||||
return errors.New(errors.CodeConflict, "该用户名已有待审批的注册申请,请等待审批结果")
|
||||
default:
|
||||
return errors.New(errors.CodeConflict, "该店铺编号已有待审批的注册申请,请等待审批结果")
|
||||
}
|
||||
}
|
||||
|
||||
// findDistributionParent 按分销码定位上级店铺;未命中与已停用返回各自的可定位错误。
|
||||
// 软删除店铺不参与匹配,与店铺唯一索引的生效范围一致。
|
||||
func (s *RegistrationService) findDistributionParent(ctx context.Context, distributionCode string) (*model.Shop, error) {
|
||||
var parent model.Shop
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("distribution_code = ?", distributionCode).
|
||||
First(&parent).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "分销码无效")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败")
|
||||
}
|
||||
if parent.Status != constants.ShopStatusEnabled {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "分销码所属店铺已停用")
|
||||
}
|
||||
return &parent, nil
|
||||
}
|
||||
|
||||
// verificationFailure 把验证码校验失败转换为对外错误,保留验证码服务给出的可定位提示。
|
||||
func verificationFailure(err error) error {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err, appErr.Message)
|
||||
}
|
||||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
}
|
||||
|
||||
// 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.CodeInvalidStatus, "上级店铺未配置启用的主账号,请联系平台处理")
|
||||
}
|
||||
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(®istration, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "扫码注册记录不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定扫码注册记录失败")
|
||||
}
|
||||
return ®istration, nil
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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 在同一事务内建立店铺、账号、钱包、层级与业务员快照。
|
||||
// 上级店铺必须仍然存在且启用;手机号、用户名或店铺编号已被既有账号/店铺占用时整体回滚,不留半套实体。
|
||||
// 审批路径不取提交侧的关键字段 advisory lock:提交侧的校验与插入同处一个串行化区间,
|
||||
// 且账号/店铺写入与注册记录状态推进同事务提交,因此提交侧只会看到「已提交的账号/店铺」或「仍待审批的冲突记录」,
|
||||
// 两种情况都会拒绝。
|
||||
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 级")
|
||||
}
|
||||
// 提交时的关键字段门禁可能已被此后的并发事实占用(平台手工建店、历史待审批记录):
|
||||
// 此处复检把裸唯一索引错误换成可定位错误码,仍整体回滚,注册记录保持待审批。
|
||||
if err := ensureRegistrationKeysAvailable(ctx, tx, registration.Phone, registration.Username, registration.ShopCode); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
830
internal/application/distributionwithdrawal/withdrawal.go
Normal file
830
internal/application/distributionwithdrawal/withdrawal.go
Normal file
@@ -0,0 +1,830 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
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
|
||||
}
|
||||
671
internal/application/employeecollection/application.go
Normal file
671
internal/application/employeecollection/application.go
Normal file
@@ -0,0 +1,671 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
37
internal/application/employeecollection/application_audit.go
Normal file
37
internal/application/employeecollection/application_audit.go
Normal file
@@ -0,0 +1,37 @@
|
||||
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
|
||||
}
|
||||
501
internal/application/employeecollection/approval_decision.go
Normal file
501
internal/application/employeecollection/approval_decision.go
Normal file
@@ -0,0 +1,501 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
31
internal/application/employeecollection/audit_event.go
Normal file
31
internal/application/employeecollection/audit_event.go
Normal file
@@ -0,0 +1,31 @@
|
||||
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)
|
||||
}
|
||||
118
internal/application/employeecollection/bill_close.go
Normal file
118
internal/application/employeecollection/bill_close.go
Normal file
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
233
internal/application/employeecollection/bill_creation.go
Normal file
233
internal/application/employeecollection/bill_creation.go
Normal file
@@ -0,0 +1,233 @@
|
||||
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_id,seller_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,
|
||||
}
|
||||
}
|
||||
346
internal/application/employeecollection/payment_method.go
Normal file
346
internal/application/employeecollection/payment_method.go
Normal file
@@ -0,0 +1,346 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
161
internal/application/employeecollection/refund_offset.go
Normal file
161
internal/application/employeecollection/refund_offset.go
Normal file
@@ -0,0 +1,161 @@
|
||||
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, "不支持的退款冲销处理结果")
|
||||
}
|
||||
}
|
||||
171
internal/application/h5popup/asset.go
Normal file
171
internal/application/h5popup/asset.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// Package h5popup 提供 H5 风险换卡与运营弹窗的候选投放、风险地址提交与运营配置维护用例。
|
||||
//
|
||||
// 候选查询会创建或复用个人客户通知并保持未读,即 GET 有副作用,这是产品契约的一部分:
|
||||
// 运营弹窗只在客户请求页面时实时匹配、不预生成通知,而投放事实又必须与「客户确实访问过」对齐。
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// shanghaiLocation 是每日去重键使用的上海自然日时区。
|
||||
// 与 internal/query/packageexpiry 保持同一口径,避免跨自然日重投判定漂移。
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// AssetOwnership 校验当前个人客户是否持有指定资产的有效绑定。
|
||||
// 归属判定必须使用权威实现 customer_binding.OwnsAsset:换货服务内部只查设备绑定虚拟号的判定
|
||||
// 对无虚拟号卡恒为假,直接复用会让无虚拟号的广电卡永远无法自助换卡。
|
||||
type AssetOwnership interface {
|
||||
OwnsAsset(ctx context.Context, customerID uint, assetType string, assetID uint) (bool, error)
|
||||
}
|
||||
|
||||
// assetFacts 是候选匹配与风险资格判定依赖的当前资产事实。
|
||||
type assetFacts struct {
|
||||
AssetType string
|
||||
AssetID uint
|
||||
Identifier string
|
||||
ShopID *uint
|
||||
CarrierType string
|
||||
DeviceType string
|
||||
// RiskStopped 只在卡资产上可能为真:运营商为广电且运营商扩展状态严格等于风险停机常量。
|
||||
// 已销户不参与该判定,两者合并会把已销户卡一并当作风险换卡对象。
|
||||
RiskStopped bool
|
||||
}
|
||||
|
||||
// shanghaiDate 返回上海自然日的 yyyymmdd 文本。
|
||||
func shanghaiDate(now time.Time) string {
|
||||
return now.In(shanghaiLocation).Format("20060102")
|
||||
}
|
||||
|
||||
// invisibleAssetError 统一「资产不存在」与「资产不属于当前客户」的返回,避免形成可枚举差异。
|
||||
func invisibleAssetError() error {
|
||||
return errors.New(errors.CodeAssetNotFound)
|
||||
}
|
||||
|
||||
// isAssetNotFound 判断错误是否表示资产不存在或不可见(归属校验失败与资产不存在同态)。
|
||||
func isAssetNotFound(err error) bool {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr.Code == errors.CodeAssetNotFound
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isRecordNotFound 判断错误是否为 GORM 未命中记录。
|
||||
func isRecordNotFound(err error) bool {
|
||||
return stderrors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// resolveAssetIdentity 按客户端提交的 identifier 定位资产:(资产类型, 资产ID)。
|
||||
// 复用既有解析口径:先查全局标识注册表,再按设备与卡的既有标识回退;
|
||||
// 卡标识由 IotCardStore.GetByIdentifier 统一处理(virtual_no/iccid/msisdn/iccid_19/iccid_20),
|
||||
// 与资产详情解析保持一致,避免自实现查询漏掉 iccid_19/iccid_20 造成静默不投放。
|
||||
// 未命中返回空类型,由调用方按不可见处理。
|
||||
func (s *CandidateService) resolveAssetIdentity(ctx context.Context, identifier string) (string, uint, error) {
|
||||
record, err := s.identifiers.FindByIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产标识失败")
|
||||
}
|
||||
if record != nil {
|
||||
return record.AssetType, record.AssetID, nil
|
||||
}
|
||||
device, err := s.devices.GetByIdentifier(ctx, identifier)
|
||||
if err == nil && device != nil {
|
||||
return constants.AssetTypeDevice, device.ID, nil
|
||||
}
|
||||
if err != nil && !isRecordNotFound(err) {
|
||||
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||||
}
|
||||
card, err := s.cards.GetByIdentifier(ctx, identifier)
|
||||
if err == nil && card != nil {
|
||||
return constants.AssetTypeIotCard, card.ID, nil
|
||||
}
|
||||
if err != nil && !isRecordNotFound(err) {
|
||||
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡失败")
|
||||
}
|
||||
return "", 0, nil
|
||||
}
|
||||
|
||||
// loadAssetFacts 读取候选匹配与风险资格判定所需的资产事实。
|
||||
func (s *CandidateService) loadAssetFacts(ctx context.Context, assetType string, assetID uint) (*assetFacts, error) {
|
||||
switch assetType {
|
||||
case constants.AssetTypeIotCard:
|
||||
card, err := s.cards.GetByID(ctx, assetID)
|
||||
if err != nil {
|
||||
if isRecordNotFound(err) {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡资产失败")
|
||||
}
|
||||
facts := &assetFacts{
|
||||
AssetType: constants.AssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID,
|
||||
ShopID: card.ShopID, CarrierType: card.CarrierType,
|
||||
RiskStopped: card.CarrierType == constants.CarrierTypeCBN &&
|
||||
strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendRiskStop,
|
||||
}
|
||||
deviceType, err := s.boundDeviceType(ctx, card.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
facts.DeviceType = deviceType
|
||||
return facts, nil
|
||||
case constants.AssetTypeDevice:
|
||||
device, err := s.devices.GetByID(ctx, assetID)
|
||||
if err != nil {
|
||||
if isRecordNotFound(err) {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备资产失败")
|
||||
}
|
||||
return &assetFacts{
|
||||
AssetType: constants.AssetTypeDevice, AssetID: device.ID,
|
||||
Identifier: deviceIdentifier(device), ShopID: device.ShopID, DeviceType: device.DeviceType,
|
||||
}, nil
|
||||
default:
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
}
|
||||
|
||||
// boundDeviceType 经卡—设备绑定推导设备类型快照。
|
||||
// 独立卡或未绑定设备时该维度为空;空值不匹配任何已配置范围,只有「未配置范围」表示全量。
|
||||
func (s *CandidateService) boundDeviceType(ctx context.Context, cardID uint) (string, error) {
|
||||
var device model.Device
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("tb_device AS d").
|
||||
Joins("JOIN tb_device_sim_binding AS b ON b.device_id = d.id").
|
||||
Where("b.iot_card_id = ? AND b.bind_status = ? AND b.deleted_at IS NULL AND d.deleted_at IS NULL",
|
||||
cardID, constants.BindStatusBound).
|
||||
Order("b.is_current DESC, b.id DESC").
|
||||
Select("d.*").
|
||||
Take(&device).Error
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
|
||||
}
|
||||
return device.DeviceType, nil
|
||||
}
|
||||
|
||||
// deviceIdentifier 按虚拟号、IMEI、SN 的稳定优先级生成设备标识快照。
|
||||
func deviceIdentifier(device *model.Device) string {
|
||||
if device == nil {
|
||||
return ""
|
||||
}
|
||||
if device.VirtualNo != "" {
|
||||
return device.VirtualNo
|
||||
}
|
||||
if device.IMEI != "" {
|
||||
return device.IMEI
|
||||
}
|
||||
return device.SN
|
||||
}
|
||||
305
internal/application/h5popup/candidate.go
Normal file
305
internal/application/h5popup/candidate.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"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"
|
||||
)
|
||||
|
||||
// activeShippingExchangeStatuses 是压制风险候选的物流换货单状态集合。
|
||||
// 含已完成的 4:已完成物流换货单说明风险换卡已走完流程,此时必须停止新投放;
|
||||
// 必须同时限定 flow_type=shipping,直接换货单创建即已完成,不限定会永久压制风险候选。
|
||||
var activeShippingExchangeStatuses = []int{
|
||||
constants.ExchangeStatusPendingInfo,
|
||||
constants.ExchangeStatusPendingShip,
|
||||
constants.ExchangeStatusShipped,
|
||||
constants.ExchangeStatusCompleted,
|
||||
}
|
||||
|
||||
// CandidateService 按当前资产事实投放风险换卡或运营弹窗候选。
|
||||
// 查询会创建或复用通知并保持未读,即 GET 有副作用:运营弹窗只在客户请求页面时实时匹配、不预生成。
|
||||
type CandidateService struct {
|
||||
db *gorm.DB
|
||||
identifiers *postgres.AssetIdentifierStore
|
||||
cards *postgres.IotCardStore
|
||||
devices *postgres.DeviceStore
|
||||
ownership AssetOwnership
|
||||
notifications notificationapp.DirectWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewCandidateService 创建 H5 弹窗候选投放用例。
|
||||
// 资产标识解析复用既有 Store 方法,保证口径与资产详情、换货等入口一致。
|
||||
func NewCandidateService(
|
||||
db *gorm.DB,
|
||||
identifiers *postgres.AssetIdentifierStore,
|
||||
cards *postgres.IotCardStore,
|
||||
devices *postgres.DeviceStore,
|
||||
ownership AssetOwnership,
|
||||
notifications notificationapp.DirectWriter,
|
||||
) *CandidateService {
|
||||
return &CandidateService{
|
||||
db: db, identifiers: identifiers, cards: cards, devices: devices,
|
||||
ownership: ownership, notifications: notifications, now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCandidate 返回当前页面与当前资产的唯一弹窗候选;没有可投放弹窗时 candidate 为空。
|
||||
// 顺序固定:先判风险换卡资格,命中则只处理风险分支;未命中再匹配运营配置。
|
||||
func (s *CandidateService) GetCandidate(ctx context.Context, customerID uint, request dto.PopupCandidateRequest) (*dto.PopupCandidateResponse, error) {
|
||||
if customerID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
identifier := strings.TrimSpace(request.Identifier)
|
||||
if !constants.IsH5PopupPage(request.Page) || identifier == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "弹窗候选参数不合法")
|
||||
}
|
||||
if s == nil || s.db == nil || s.identifiers == nil || s.cards == nil || s.devices == nil ||
|
||||
s.ownership == nil || s.notifications == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "弹窗投放能力尚未配置")
|
||||
}
|
||||
assetType, assetID, err := s.resolveAssetIdentity(ctx, identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if assetType == "" {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
owned, err := s.ownership.OwnsAsset(ctx, customerID, assetType, assetID)
|
||||
if err != nil {
|
||||
if isAssetNotFound(err) {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !owned {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
facts, err := s.loadAssetFacts(ctx, assetType, assetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
|
||||
if facts.RiskStopped {
|
||||
blocked, err := findActiveShippingExchange(ctx, s.db, facts.AssetType, facts.AssetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked == nil {
|
||||
candidate, err := s.deliverRiskCandidate(ctx, customerID, facts, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.PopupCandidateResponse{Candidate: candidate}, nil
|
||||
}
|
||||
}
|
||||
|
||||
candidate, err := s.deliverOperationCandidate(ctx, customerID, request.Page, facts, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &dto.PopupCandidateResponse{Candidate: candidate}, nil
|
||||
}
|
||||
|
||||
// deliverRiskCandidate 创建或复用「客户+资产+上海自然日」的风险换卡通知。
|
||||
// 当日通知已存在且未读时返回同一通知;已被客户关闭(已读)时当日不再返回候选,次日条件成立会创建新通知。
|
||||
func (s *CandidateService) deliverRiskCandidate(ctx context.Context, customerID uint, facts *assetFacts, now time.Time) (*dto.PopupCandidateItem, error) {
|
||||
notification, err := s.notifications.CreateOrGetPersonal(ctx, riskEventKey(customerID, facts, now), customerID, notificationapp.PersonalDirectRequest{
|
||||
NotificationType: constants.NotificationTypeH5PopupRiskExchange,
|
||||
RefType: constants.NotificationRefTypeAsset,
|
||||
RefID: strconv.FormatUint(uint64(facts.AssetID), 10),
|
||||
RefKey: facts.Identifier,
|
||||
ExpiresAt: popupExpiresAt(now),
|
||||
PopupSnapshot: &model.NotificationPopupSnapshot{
|
||||
AssetType: facts.AssetType, AssetID: facts.AssetID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if notification.IsRead {
|
||||
return nil, nil
|
||||
}
|
||||
return toCandidateItem(notification), nil
|
||||
}
|
||||
|
||||
// deliverOperationCandidate 匹配运营配置并按频率创建或复用运营弹窗通知。
|
||||
// 只返回优先级最高一条;同优先级取最近更新时间最新,启停同样刷新该时间。
|
||||
func (s *CandidateService) deliverOperationCandidate(ctx context.Context, customerID uint, page string, facts *assetFacts, now time.Time) (*dto.PopupCandidateItem, error) {
|
||||
config, err := s.matchOperationConfig(ctx, page, facts, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config == nil {
|
||||
return nil, nil
|
||||
}
|
||||
notification, err := s.notifications.CreateOrGetPersonal(ctx, operationEventKey(customerID, config, now), customerID, notificationapp.PersonalDirectRequest{
|
||||
NotificationType: constants.NotificationTypeH5PopupOperation,
|
||||
TemplateData: map[string]string{"title": config.Title, "content": config.Content},
|
||||
RefType: constants.NotificationRefTypeAsset,
|
||||
RefID: strconv.FormatUint(uint64(facts.AssetID), 10),
|
||||
RefKey: facts.Identifier,
|
||||
ExpiresAt: popupExpiresAt(now),
|
||||
PopupSnapshot: &model.NotificationPopupSnapshot{
|
||||
ConfigID: config.ID, ConfigVersion: config.Version,
|
||||
AssetType: facts.AssetType, AssetID: facts.AssetID, ActionType: config.ActionType,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if notification.IsRead {
|
||||
return nil, nil
|
||||
}
|
||||
return toCandidateItem(notification), nil
|
||||
}
|
||||
|
||||
// matchOperationConfig 按时间、启停、页面、店铺、设备类型、卡类型范围匹配运营配置。
|
||||
// 范围同一维度多选取任一命中;未配置该维度即全量;已配置而资产该维度无值时该配置不命中。
|
||||
func (s *CandidateService) matchOperationConfig(ctx context.Context, page string, facts *assetFacts, now time.Time) (*model.H5PopupConfiguration, error) {
|
||||
pageJSON, err := jsonbScalar(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var shopID *string
|
||||
if facts.ShopID != nil {
|
||||
text := strconv.FormatUint(uint64(*facts.ShopID), 10)
|
||||
shopID = &text
|
||||
}
|
||||
shopJSON, err := jsonbScalarPointer(shopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceJSON, err := jsonbScalar(facts.DeviceType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cardJSON, err := jsonbScalar(facts.CarrierType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var config model.H5PopupConfiguration
|
||||
err = s.db.WithContext(ctx).Model(&model.H5PopupConfiguration{}).
|
||||
Where("enabled = ?", constants.H5PopupStatusEnabled).
|
||||
Where("starts_at <= ? AND ends_at >= ?", now, now).
|
||||
Where("?::jsonb <@ pages", pageJSON).
|
||||
Where("(jsonb_array_length(shop_ids) = 0 OR ?::jsonb <@ shop_ids)", shopJSON).
|
||||
Where("(jsonb_array_length(device_types) = 0 OR ?::jsonb <@ device_types)", deviceJSON).
|
||||
Where("(jsonb_array_length(card_types) = 0 OR ?::jsonb <@ card_types)", cardJSON).
|
||||
Order("priority DESC, updated_at DESC, id DESC").
|
||||
Take(&config).Error
|
||||
if err != nil {
|
||||
if isRecordNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "匹配运营弹窗配置失败")
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// findActiveShippingExchange 查询指定资产是否已存在活动物流换货单。
|
||||
// 取 flow_type=shipping 且状态属于待填写、待发货、已发货待确认、已完成,任一命中即视为已处理。
|
||||
func findActiveShippingExchange(ctx context.Context, db *gorm.DB, assetType string, assetID uint) (*model.ExchangeOrder, error) {
|
||||
var order model.ExchangeOrder
|
||||
err := db.WithContext(ctx).
|
||||
Where("old_asset_type = ? AND old_asset_id = ? AND flow_type = ?", assetType, assetID, constants.ExchangeFlowTypeShipping).
|
||||
Where("status IN ?", activeShippingExchangeStatuses).
|
||||
Order("id DESC").
|
||||
Take(&order).Error
|
||||
if err != nil {
|
||||
if isRecordNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询活动物流换货单失败")
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// riskEventKey 生成风险换卡通知事件键:客户 + 资产 + 上海自然日,复用通知唯一约束保证一天一条。
|
||||
func riskEventKey(customerID uint, facts *assetFacts, now time.Time) string {
|
||||
return popupEventKey(constants.H5PopupRiskEventKeyPrefix+"."+shanghaiDate(now),
|
||||
strconv.FormatUint(uint64(customerID), 10), facts.AssetType, strconv.FormatUint(uint64(facts.AssetID), 10))
|
||||
}
|
||||
|
||||
// operationEventKey 生成运营弹窗通知事件键:客户 + 配置 + 版本,daily 频率再追加上海自然日。
|
||||
// 频率口径按「每客户每配置支持仅一次或每天一次」,因此键内不含资产,客户换资产不会额外获得投放。
|
||||
func operationEventKey(customerID uint, config *model.H5PopupConfiguration, now time.Time) string {
|
||||
prefix := constants.H5PopupOperationOnceEventKeyPrefix
|
||||
if config.Frequency == constants.H5PopupFrequencyDaily {
|
||||
prefix = constants.H5PopupOperationDailyEventKeyPrefix + "." + shanghaiDate(now)
|
||||
}
|
||||
return popupEventKey(prefix,
|
||||
strconv.FormatUint(uint64(customerID), 10), strconv.FormatUint(uint64(config.ID), 10), strconv.FormatInt(config.Version, 10))
|
||||
}
|
||||
|
||||
// popupEventKey 生成固定长度的通知事件键:前缀 + 身份摘要。
|
||||
// tb_notification.event_id 为 varchar(64),身份部分用 sha256 前 12 字节十六进制压缩,
|
||||
// 保证资产与客户 ID 位数增长后仍不超长,同时保持确定性以便复用既有唯一约束去重。
|
||||
func popupEventKey(prefix string, parts ...string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
|
||||
return prefix + "." + hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
// popupExpiresAt 返回弹窗投放通知的展示截止时间:投放时间 + 90 天。
|
||||
// 弹窗类别沿用 system(展示上限 365 天),90 天在其内,事实物理保留仍按系统类别的 365 天。
|
||||
func popupExpiresAt(now time.Time) *time.Time {
|
||||
expiresAt := now.AddDate(0, 0, constants.H5PopupDisplayDays)
|
||||
return &expiresAt
|
||||
}
|
||||
|
||||
// jsonbScalar 将字符串编码为可直接参与 jsonb 包含判断的 JSON 标量。
|
||||
func jsonbScalar(value string) (string, error) {
|
||||
encoded, err := sonic.Marshal(value)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(errors.CodeInternalError, err, "编码弹窗匹配值失败")
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
// jsonbScalarPointer 将可空字符串编码为 JSON 标量;nil 编码为 JSON null,任何已配置范围都不命中。
|
||||
func jsonbScalarPointer(value *string) (string, error) {
|
||||
if value == nil {
|
||||
return "null", nil
|
||||
}
|
||||
return jsonbScalar(*value)
|
||||
}
|
||||
|
||||
// toCandidateItem 将冻结的通知投影为客户端候选;配置标识与受控动作取通知快照
|
||||
// 而不是当前配置,保证配置修改后旧通知与旧快照不被改写。
|
||||
func toCandidateItem(notification *model.Notification) *dto.PopupCandidateItem {
|
||||
if notification == nil {
|
||||
return nil
|
||||
}
|
||||
item := &dto.PopupCandidateItem{
|
||||
NotificationID: notification.ID, NotificationType: notification.Type,
|
||||
Title: notification.Title, Body: notification.Body,
|
||||
ExpiresAt: notification.ExpiresAt, CreatedAt: notification.CreatedAt,
|
||||
}
|
||||
if notification.Type == constants.NotificationTypeH5PopupRiskExchange {
|
||||
item.PopupType = constants.H5PopupCandidateTypeRiskExchange
|
||||
} else {
|
||||
item.PopupType = constants.H5PopupCandidateTypeOperation
|
||||
}
|
||||
if snapshot := notification.PopupSnapshot; snapshot != nil {
|
||||
item.AssetType = snapshot.AssetType
|
||||
item.AssetID = snapshot.AssetID
|
||||
item.ConfigID = snapshot.ConfigID
|
||||
item.ConfigVersion = snapshot.ConfigVersion
|
||||
item.ActionType = snapshot.ActionType
|
||||
}
|
||||
return item
|
||||
}
|
||||
490
internal/application/h5popup/configuration.go
Normal file
490
internal/application/h5popup/configuration.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
var (
|
||||
// popupURLPattern 匹配任意 URL 形态:带协议的绝对地址、www 前缀或站点域名。
|
||||
// 弹窗只允许受控动作,前端按 action_type 白名单映射页面,不接受运营配置下发跳转目标。
|
||||
popupURLPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+.\-]*://|www\.|\.(com|cn|net|org)(/|$|\s))`)
|
||||
// popupRoutePattern 匹配前端路由形态:以 / 开头的路径片段或 /#/ 哈希路由。
|
||||
popupRoutePattern = regexp.MustCompile(`(^|[\s((])/[A-Za-z#]`)
|
||||
)
|
||||
|
||||
// ConfigurationService 维护 H5 运营弹窗配置。
|
||||
// 配置只决定后续投放:更新在事务内递增版本,启停只改启停位并刷新最近更新时间,两者都记录前后值与版本。
|
||||
type ConfigurationService struct {
|
||||
db *gorm.DB
|
||||
audit *audit.Writer
|
||||
}
|
||||
|
||||
// NewConfigurationService 创建运营弹窗配置事务脚本。
|
||||
func NewConfigurationService(db *gorm.DB, audit *audit.Writer) *ConfigurationService {
|
||||
return &ConfigurationService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
// configurationInput 是校验后的配置值,创建与更新共用同一套归一化规则。
|
||||
type configurationInput struct {
|
||||
Title string
|
||||
Content string
|
||||
Pages []string
|
||||
ShopIDs []uint
|
||||
DeviceTypes []string
|
||||
CardTypes []string
|
||||
Priority int
|
||||
Frequency string
|
||||
ActionType string
|
||||
Enabled int
|
||||
StartsAt time.Time
|
||||
EndsAt time.Time
|
||||
}
|
||||
|
||||
// Create 创建运营弹窗配置,初始版本为 1,并在同一事务内写入配置审计。
|
||||
func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5PopupConfigurationRequest) (uint, error) {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
enabled := constants.H5PopupStatusDisabled
|
||||
if request.Enabled != nil && *request.Enabled {
|
||||
enabled = constants.H5PopupStatusEnabled
|
||||
}
|
||||
priority := 0
|
||||
if request.Priority != nil {
|
||||
priority = *request.Priority
|
||||
}
|
||||
actionType := ""
|
||||
if request.ActionType != nil {
|
||||
actionType = *request.ActionType
|
||||
}
|
||||
normalized, err := normalizeConfigurationInput(configurationInput{
|
||||
Title: request.Title, Content: request.Content, Pages: request.Pages,
|
||||
ShopIDs: request.ShopIDs, DeviceTypes: request.DeviceTypes, CardTypes: request.CardTypes,
|
||||
Priority: priority, Frequency: request.Frequency, ActionType: actionType,
|
||||
Enabled: enabled, StartsAt: request.StartsAt, EndsAt: request.EndsAt,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record := &model.H5PopupConfiguration{
|
||||
Title: normalized.Title, Content: normalized.Content,
|
||||
Pages: model.StringJSONBArray(normalized.Pages), ShopIDs: toJSONBStrings(normalized.ShopIDs),
|
||||
DeviceTypes: model.StringJSONBArray(normalized.DeviceTypes), CardTypes: model.StringJSONBArray(normalized.CardTypes),
|
||||
Priority: normalized.Priority, Frequency: normalized.Frequency, ActionType: normalized.ActionType,
|
||||
Enabled: normalized.Enabled, StartsAt: normalized.StartsAt, EndsAt: normalized.EndsAt,
|
||||
Version: 1, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
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, "创建运营弹窗配置失败")
|
||||
}
|
||||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationH5PopupConfigurationCreate,
|
||||
Description: "创建运营弹窗配置", ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return record.ID, nil
|
||||
}
|
||||
|
||||
// Update 更新运营弹窗配置:合并入参后整体校验,事务内递增版本并刷新最近更新时间。
|
||||
// 旧版本已投放通知的内容与快照不被改写,新版本可向原命中客户按频率重新投放。
|
||||
func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.UpdateH5PopupConfigurationRequest) error {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
record, err := lockConfiguration(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := *record
|
||||
beforeData := configurationAuditSnapshot(&before)
|
||||
|
||||
merged := configurationInput{
|
||||
Title: record.Title, Content: record.Content, Pages: storePages(record),
|
||||
ShopIDs: storeShopIDs(record), DeviceTypes: storeDeviceTypes(record), CardTypes: storeCardTypes(record),
|
||||
Priority: record.Priority, Frequency: record.Frequency, ActionType: record.ActionType,
|
||||
Enabled: record.Enabled, StartsAt: record.StartsAt, EndsAt: record.EndsAt,
|
||||
}
|
||||
if request.Title != nil {
|
||||
merged.Title = *request.Title
|
||||
}
|
||||
if request.Content != nil {
|
||||
merged.Content = *request.Content
|
||||
}
|
||||
if request.Pages != nil {
|
||||
merged.Pages = *request.Pages
|
||||
}
|
||||
if request.ShopIDs != nil {
|
||||
merged.ShopIDs = *request.ShopIDs
|
||||
}
|
||||
if request.DeviceTypes != nil {
|
||||
merged.DeviceTypes = *request.DeviceTypes
|
||||
}
|
||||
if request.CardTypes != nil {
|
||||
merged.CardTypes = *request.CardTypes
|
||||
}
|
||||
if request.Priority != nil {
|
||||
merged.Priority = *request.Priority
|
||||
}
|
||||
if request.Frequency != nil {
|
||||
merged.Frequency = *request.Frequency
|
||||
}
|
||||
if request.ActionType != nil {
|
||||
merged.ActionType = *request.ActionType
|
||||
}
|
||||
if request.Enabled != nil {
|
||||
merged.Enabled = enabledStatus(*request.Enabled)
|
||||
}
|
||||
if request.StartsAt != nil {
|
||||
merged.StartsAt = *request.StartsAt
|
||||
}
|
||||
if request.EndsAt != nil {
|
||||
merged.EndsAt = *request.EndsAt
|
||||
}
|
||||
normalized, err := normalizeConfigurationInput(merged)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record.Title = normalized.Title
|
||||
record.Content = normalized.Content
|
||||
record.Pages = model.StringJSONBArray(normalized.Pages)
|
||||
record.ShopIDs = toJSONBStrings(normalized.ShopIDs)
|
||||
record.DeviceTypes = model.StringJSONBArray(normalized.DeviceTypes)
|
||||
record.CardTypes = model.StringJSONBArray(normalized.CardTypes)
|
||||
record.Priority = normalized.Priority
|
||||
record.Frequency = normalized.Frequency
|
||||
record.ActionType = normalized.ActionType
|
||||
record.Enabled = normalized.Enabled
|
||||
record.StartsAt = normalized.StartsAt
|
||||
record.EndsAt = normalized.EndsAt
|
||||
record.Version++
|
||||
record.Updater = operatorID
|
||||
record.UpdatedAt = now
|
||||
if err := tx.WithContext(ctx).Save(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新运营弹窗配置失败")
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: constants.AuditOperationH5PopupConfigurationUpdate,
|
||||
Description: "更新运营弹窗配置", ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
BeforeData: beforeData, AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetEnabled 启停运营弹窗配置,只影响后续候选,并必须刷新最近更新时间。
|
||||
// 启停不递增版本:版本表达配置内容变化,频率去重键因此保持不变,已投放通知不会被再次投放。
|
||||
func (s *ConfigurationService) SetEnabled(ctx context.Context, id uint, enabled bool) error {
|
||||
operatorID, err := requirePlatformOperator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.ensureConfigured(); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 {
|
||||
return errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
operationType := constants.AuditOperationH5PopupConfigurationDisable
|
||||
description := "停用运营弹窗配置"
|
||||
if enabled {
|
||||
operationType = constants.AuditOperationH5PopupConfigurationEnable
|
||||
description = "启用运营弹窗配置"
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
record, err := lockConfiguration(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeData := configurationAuditSnapshot(record)
|
||||
now := time.Now().UTC()
|
||||
record.Enabled = enabledStatus(enabled)
|
||||
record.Updater = operatorID
|
||||
record.UpdatedAt = now
|
||||
if err := tx.WithContext(ctx).Save(record).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新运营弹窗配置启停失败")
|
||||
}
|
||||
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: operatorID, OperationType: operationType,
|
||||
Description: description, ConfigKey: configurationAuditKey(record.ID),
|
||||
Module: constants.H5PopupAuditModule, ResourceID: configurationAuditResourceID(record.ID),
|
||||
DisplayName: record.Title, Identity: configurationAuditIdentity(record),
|
||||
BeforeData: beforeData, AfterData: configurationAuditSnapshot(record), Result: constants.AuditResultSuccess,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ConfigurationService) ensureConfigured() error {
|
||||
if s == nil || s.db == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "运营弹窗配置维护能力尚未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requirePlatformOperator 校验当前调用者仅限超级管理员与平台账号,并返回其账号 ID。
|
||||
// 非上述身份与资源不存在返回同一禁止访问错误,避免形成可枚举差异。
|
||||
func requirePlatformOperator(ctx context.Context) (uint, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return 0, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// lockConfiguration 以行锁读取运营弹窗配置,未找到返回稳定不存在错误。
|
||||
func lockConfiguration(ctx context.Context, tx *gorm.DB, id uint) (*model.H5PopupConfiguration, error) {
|
||||
var record model.H5PopupConfiguration
|
||||
err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id).Take(&record).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeH5PopupConfigurationNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营弹窗配置失败")
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// normalizeConfigurationInput 归一化并校验配置,创建与更新共用同一套规则。
|
||||
// 拒绝任意 URL 与前端路由是应用层第一道保险,通知渲染的 URL 拦截是第二道。
|
||||
func normalizeConfigurationInput(input configurationInput) (configurationInput, error) {
|
||||
normalized := input
|
||||
normalized.Title = strings.TrimSpace(input.Title)
|
||||
if runes := utf8.RuneCountInString(normalized.Title); runes < 1 || runes > 100 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗标题长度必须在 1~100 字符之间")
|
||||
}
|
||||
// 标题与正文同一口径:两者都会冻结进通知并参与渲染,任一都不接受 URL 或前端路由。
|
||||
if popupURLPattern.MatchString(normalized.Title) || popupRoutePattern.MatchString(normalized.Title) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗标题不接受 URL 或前端路由,只能使用受控动作")
|
||||
}
|
||||
normalized.Content = strings.TrimSpace(input.Content)
|
||||
if runes := utf8.RuneCountInString(normalized.Content); runes < 1 || runes > 2000 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文长度必须在 1~2000 字符之间")
|
||||
}
|
||||
if popupURLPattern.MatchString(normalized.Content) || popupRoutePattern.MatchString(normalized.Content) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文不接受 URL 或前端路由,只能使用受控动作")
|
||||
}
|
||||
normalized.Pages = dedupeStrings(input.Pages)
|
||||
if len(normalized.Pages) == 0 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗至少需要一个命中页面")
|
||||
}
|
||||
if len(normalized.Pages) > 4 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗命中页面超出受控范围")
|
||||
}
|
||||
for _, page := range normalized.Pages {
|
||||
if !constants.IsH5PopupPage(page) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗命中页面不在受控白名单内")
|
||||
}
|
||||
}
|
||||
normalized.ShopIDs = dedupeShopIDs(input.ShopIDs)
|
||||
if len(normalized.ShopIDs) > 200 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗店铺范围超过 200 项")
|
||||
}
|
||||
normalized.DeviceTypes = dedupeStrings(input.DeviceTypes)
|
||||
if len(normalized.DeviceTypes) > 100 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗设备类型范围超过 100 项")
|
||||
}
|
||||
for _, deviceType := range normalized.DeviceTypes {
|
||||
if utf8.RuneCountInString(deviceType) > 50 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗设备类型超过 50 字符")
|
||||
}
|
||||
}
|
||||
// 卡类型是与 tb_iot_card.carrier_type 直接比较的受控枚举,统一大写后再校验。
|
||||
normalized.CardTypes = dedupeStrings(upperStrings(input.CardTypes))
|
||||
if len(normalized.CardTypes) > 4 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗卡类型范围超过受控取值数量")
|
||||
}
|
||||
for _, cardType := range normalized.CardTypes {
|
||||
if !constants.IsCarrierType(cardType) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗卡类型不在受控白名单内")
|
||||
}
|
||||
}
|
||||
if normalized.Priority < 0 || normalized.Priority > 1000000 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗优先级必须在 0~1000000 之间")
|
||||
}
|
||||
if !constants.IsH5PopupFrequency(normalized.Frequency) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗投放频率不在受控白名单内")
|
||||
}
|
||||
if !constants.IsH5PopupActionType(normalized.ActionType) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗受控动作不在受控白名单内")
|
||||
}
|
||||
if normalized.Enabled != constants.H5PopupStatusEnabled && normalized.Enabled != constants.H5PopupStatusDisabled {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗启停状态不合法")
|
||||
}
|
||||
if normalized.StartsAt.IsZero() || normalized.EndsAt.IsZero() {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗必须同时提供生效开始与结束时间")
|
||||
}
|
||||
normalized.StartsAt = normalized.StartsAt.UTC()
|
||||
normalized.EndsAt = normalized.EndsAt.UTC()
|
||||
if normalized.EndsAt.Before(normalized.StartsAt) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗结束时间不得早于开始时间")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// enabledStatus 把布尔启停转换为 0/1 状态。
|
||||
func enabledStatus(enabled bool) int {
|
||||
if enabled {
|
||||
return constants.H5PopupStatusEnabled
|
||||
}
|
||||
return constants.H5PopupStatusDisabled
|
||||
}
|
||||
|
||||
// dedupeStrings 去空白并按出现顺序去重,保留原始大小写。
|
||||
func dedupeStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[trimmed]; exists {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// upperStrings 去空白并统一大写,供受控枚举范围使用;空白项不保留。
|
||||
func upperStrings(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, strings.ToUpper(trimmed))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// dedupeShopIDs 去重店铺 ID 并丢弃非法值。
|
||||
func dedupeShopIDs(values []uint) []uint {
|
||||
result := make([]uint, 0, len(values))
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// toJSONBStrings 将店铺 ID 编码为 JSONB 文本数组,与范围匹配的文本比较口径一致。
|
||||
func toJSONBStrings(values []uint) model.StringJSONBArray {
|
||||
encoded := make(model.StringJSONBArray, 0, len(values))
|
||||
for _, value := range values {
|
||||
encoded = append(encoded, strconv.FormatUint(uint64(value), 10))
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func storePages(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.Pages...)
|
||||
}
|
||||
|
||||
func storeDeviceTypes(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.DeviceTypes...)
|
||||
}
|
||||
|
||||
func storeCardTypes(record *model.H5PopupConfiguration) []string {
|
||||
return append([]string{}, record.CardTypes...)
|
||||
}
|
||||
|
||||
// storeShopIDs 将 JSONB 店铺范围还原为 ID 列表用于合并更新。
|
||||
func storeShopIDs(record *model.H5PopupConfiguration) []uint {
|
||||
shopIDs := make([]uint, 0, len(record.ShopIDs))
|
||||
for _, value := range record.ShopIDs {
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil || parsed == 0 {
|
||||
continue
|
||||
}
|
||||
shopIDs = append(shopIDs, uint(parsed))
|
||||
}
|
||||
return shopIDs
|
||||
}
|
||||
|
||||
func configurationAuditKey(id uint) string {
|
||||
return constants.H5PopupAuditConfigKeyPrefix + "." + strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func configurationAuditResourceID(id uint) *string {
|
||||
value := strconv.FormatUint(uint64(id), 10)
|
||||
return &value
|
||||
}
|
||||
|
||||
// configurationAuditIdentity 生成配置身份快照,不含正文内容。
|
||||
func configurationAuditIdentity(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"id": record.ID, "title": record.Title, "pages": storePages(record),
|
||||
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
|
||||
"enabled": record.Enabled, "version": record.Version,
|
||||
}
|
||||
}
|
||||
|
||||
// configurationAuditSnapshot 生成配置审计前后值快照,覆盖范围、优先级、频率、受控动作、启停、有效期与版本。
|
||||
func configurationAuditSnapshot(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"id": record.ID, "title": record.Title, "content": record.Content,
|
||||
"pages": storePages(record), "shop_ids": storeShopIDs(record),
|
||||
"device_types": storeDeviceTypes(record), "card_types": storeCardTypes(record),
|
||||
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
|
||||
"enabled": record.Enabled, "starts_at": record.StartsAt, "ends_at": record.EndsAt,
|
||||
"version": record.Version, "updated_at": record.UpdatedAt,
|
||||
}
|
||||
}
|
||||
170
internal/application/h5popup/risk_exchange.go
Normal file
170
internal/application/h5popup/risk_exchange.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package h5popup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RiskExchangeService 处理个人客户自助风险换卡的地址提交。
|
||||
// 幂等靠「锁定旧资产行 + 去重查询既有活动物流换货单」实现,不引入数据库唯一约束:
|
||||
// 资产实例同一时刻只属于一个客户,锁资产行即可覆盖重复提交与并发提交。
|
||||
type RiskExchangeService struct {
|
||||
db *gorm.DB
|
||||
ownership AssetOwnership
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// NewRiskExchangeService 创建风险换卡地址提交事务脚本。
|
||||
func NewRiskExchangeService(db *gorm.DB, ownership AssetOwnership, auditWriter *audit.Writer) *RiskExchangeService {
|
||||
return &RiskExchangeService{db: db, ownership: ownership, auditWriter: auditWriter}
|
||||
}
|
||||
|
||||
// Submit 幂等提交风险换卡收货地址,创建关联旧资产的物流换货单。
|
||||
// 事务内顺序固定为:锁旧资产行 → 复核风险资格 → 去重查询 → 未命中才插入。
|
||||
// 重复提交返回首次创建的换货单与首次地址,不覆盖既有地址。
|
||||
func (s *RiskExchangeService) Submit(ctx context.Context, customerID, assetID uint, request dto.ClientRiskExchangeAddressParams) (*dto.ClientRiskExchangeResponse, error) {
|
||||
if customerID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
if assetID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "风险换卡资产ID不合法")
|
||||
}
|
||||
if s == nil || s.db == nil || s.ownership == nil || s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "风险换卡能力尚未配置")
|
||||
}
|
||||
// 归属校验必须使用权威实现;资产不存在与归属失败返回同态不可见结果。
|
||||
owned, err := s.ownership.OwnsAsset(ctx, customerID, constants.AssetTypeIotCard, assetID)
|
||||
if err != nil {
|
||||
if isAssetNotFound(err) {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !owned {
|
||||
return nil, invisibleAssetError()
|
||||
}
|
||||
|
||||
var result *dto.ClientRiskExchangeResponse
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", assetID).Take(&card).Error; err != nil {
|
||||
if isRecordNotFound(err) {
|
||||
return invisibleAssetError()
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定换卡资产失败")
|
||||
}
|
||||
// 锁内复核风险资格:持锁前的判定可能已被并发状态同步改变。
|
||||
if card.CarrierType != constants.CarrierTypeCBN ||
|
||||
strings.TrimSpace(card.GatewayExtend) != constants.GatewayCardExtendRiskStop {
|
||||
return errors.New(errors.CodeH5PopupRiskNotEligible)
|
||||
}
|
||||
existing, err := findActiveShippingExchange(ctx, tx, constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != nil {
|
||||
result = toRiskExchangeResponse(existing)
|
||||
return nil
|
||||
}
|
||||
order := &model.ExchangeOrder{
|
||||
ExchangeNo: model.GenerateExchangeNo(),
|
||||
FlowType: constants.ExchangeFlowTypeShipping,
|
||||
OldAssetType: constants.AssetTypeIotCard,
|
||||
OldAssetID: card.ID,
|
||||
OldAssetIdentifier: card.ICCID,
|
||||
RecipientName: request.RecipientName,
|
||||
RecipientPhone: request.RecipientPhone,
|
||||
RecipientAddress: request.RecipientAddress,
|
||||
ShopID: card.ShopID,
|
||||
ExchangeReason: constants.H5PopupRiskExchangeReason,
|
||||
// 客户已提交收货信息,因此创建即待发货;不预设业务数据迁移,发货选新资产时仍由后台按既有流程决定。
|
||||
Status: constants.ExchangeStatusPendingShip,
|
||||
MigrateData: false,
|
||||
MigrationStatus: constants.ExchangeMigrationStatusNotMigrated,
|
||||
// H5 客户上下文没有后台账号 ID,置 0 表示由客户自助发起,不冒用任何后台账号身份。
|
||||
BaseModel: model.BaseModel{Creator: 0, Updater: 0},
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建风险换卡单失败")
|
||||
}
|
||||
result = toRiskExchangeResponse(order)
|
||||
return s.appendRiskExchangeAudit(ctx, tx, customerID, order, &card)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// appendRiskExchangeAudit 在同一事务内记录客户自助换卡的状态事实与旧卡引用。
|
||||
func (s *RiskExchangeService) appendRiskExchangeAudit(ctx context.Context, tx *gorm.DB, customerID uint, order *model.ExchangeOrder, card *model.IotCard) error {
|
||||
orderID := strconv.FormatUint(uint64(order.ID), 10)
|
||||
cardID := strconv.FormatUint(uint64(card.ID), 10)
|
||||
customerText := strconv.FormatUint(uint64(customerID), 10)
|
||||
summary := "客户自助提交风险换卡地址"
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionCardRiskExchangeRequested, Summary: summary,
|
||||
Actor: audit.ActorInput{Kind: constants.AuditActorPersonalCustomer, ID: customerText},
|
||||
Source: constants.AuditSourcePersonalAPI,
|
||||
// 个人客户本人业务范围;不使用 platform,避免把客户自助事实记成后台操作。
|
||||
ScopeType: constants.AuditScopePersonalCustomer, ScopeID: customerText,
|
||||
Result: constants.AuditResultSuccess,
|
||||
Metadata: map[string]any{"flow_type": constants.ExchangeFlowTypeShipping, "migrate_data": false},
|
||||
Resources: []audit.ResourceInput{
|
||||
{
|
||||
Type: constants.AuditResourceExchangeOrder, ID: &orderID, Key: order.ExchangeNo, DisplayName: order.ExchangeNo,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCardExchangeOrder,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": order.ID, "exchange_no": order.ExchangeNo, "flow_type": order.FlowType,
|
||||
"old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID,
|
||||
"old_asset_identifier": order.OldAssetIdentifier, "shop_id": order.ShopID, "status": order.Status,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"status": order.Status, "migrate_data": order.MigrateData, "migration_status": order.MigrationStatus,
|
||||
},
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
},
|
||||
{
|
||||
Type: constants.AuditResourceIotCard, ID: &cardID, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
|
||||
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCardExchangeOldCard,
|
||||
IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
|
||||
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// toRiskExchangeResponse 将换货单投影为地址提交结果。
|
||||
// 地址取记录中的既有值:重复提交返回首次地址,不做任何覆盖。
|
||||
func toRiskExchangeResponse(order *model.ExchangeOrder) *dto.ClientRiskExchangeResponse {
|
||||
if order == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.ClientRiskExchangeResponse{
|
||||
ID: order.ID, ExchangeNo: order.ExchangeNo,
|
||||
Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status),
|
||||
FlowType: order.FlowType,
|
||||
OldAssetType: order.OldAssetType,
|
||||
OldAssetID: order.OldAssetID,
|
||||
OldAssetIdentifier: order.OldAssetIdentifier,
|
||||
RecipientName: order.RecipientName,
|
||||
RecipientPhone: order.RecipientPhone,
|
||||
RecipientAddress: order.RecipientAddress,
|
||||
MigrateData: order.MigrateData,
|
||||
MigrationStatus: order.MigrationStatus,
|
||||
MigrationStatusName: constants.GetExchangeMigrationStatusName(order.MigrationStatus),
|
||||
ExchangeReason: order.ExchangeReason,
|
||||
CreatedAt: order.CreatedAt,
|
||||
}
|
||||
}
|
||||
721
internal/application/merchantpayment/management.go
Normal file
721
internal/application/merchantpayment/management.go
Normal file
@@ -0,0 +1,721 @@
|
||||
// Package merchantpayment provides merchant pool payment routing use cases.
|
||||
package merchantpayment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
// ManagementService 负责商户、商户池与授权配置写入。
|
||||
type ManagementService struct {
|
||||
db *gorm.DB
|
||||
audit systemconfigapp.AuditWriter
|
||||
}
|
||||
|
||||
// NewManagementService 创建商户配置用例。
|
||||
func NewManagementService(db *gorm.DB, audit systemconfigapp.AuditWriter) *ManagementService {
|
||||
return &ManagementService{db: db, audit: audit}
|
||||
}
|
||||
|
||||
func requireManager(ctx context.Context) error {
|
||||
kind := middleware.GetUserTypeFromContext(ctx)
|
||||
if kind != constants.UserTypeSuperAdmin && kind != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "无权限访问支付商户配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePage(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
|
||||
func validPaymentMethod(method string) bool {
|
||||
return method == "wechat" || method == "alipay"
|
||||
}
|
||||
|
||||
func validateMerchantConfiguration(paymentMethod, providerType, merchantIdentity string, credentials model.JSONB) error {
|
||||
paymentMethod, providerType, merchantIdentity = strings.TrimSpace(paymentMethod), strings.TrimSpace(providerType), strings.TrimSpace(merchantIdentity)
|
||||
if !validPaymentMethod(paymentMethod) || merchantIdentity == "" || len(credentials) == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "支付商户配置不完整")
|
||||
}
|
||||
var config model.WechatConfig
|
||||
raw, err := sonic.Marshal(credentials)
|
||||
if err != nil || sonic.Unmarshal(raw, &config) != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "支付商户凭证格式无效")
|
||||
}
|
||||
switch paymentMethod {
|
||||
case "wechat":
|
||||
switch providerType {
|
||||
case model.ProviderTypeWechat:
|
||||
if config.WxMchID != merchantIdentity || strings.TrimSpace(config.WxAPIV3Key) == "" || strings.TrimSpace(config.WxCertContent) == "" || strings.TrimSpace(config.WxKeyContent) == "" || strings.TrimSpace(config.WxSerialNo) == "" || strings.TrimSpace(config.WxNotifyURL) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "微信直连商户凭证不完整或身份不一致")
|
||||
}
|
||||
case model.ProviderTypeWechatV2:
|
||||
if config.WxMchID != merchantIdentity || strings.TrimSpace(config.WxAPIV2Key) == "" || strings.TrimSpace(config.WxNotifyURL) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "微信 v2 商户凭证不完整或身份不一致")
|
||||
}
|
||||
case model.ProviderTypeFuiou:
|
||||
if config.FyMchntCd != merchantIdentity || strings.TrimSpace(config.FyInsCd) == "" || strings.TrimSpace(config.FyTermID) == "" || strings.TrimSpace(config.FyPrivateKey) == "" || strings.TrimSpace(config.FyPublicKey) == "" || strings.TrimSpace(config.FyAPIURL) == "" || strings.TrimSpace(config.FyNotifyURL) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "富友商户凭证不完整或身份不一致")
|
||||
}
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "微信支付服务商类型无效")
|
||||
}
|
||||
case "alipay":
|
||||
if providerType != "alipay" || config.AliAppID != merchantIdentity || strings.TrimSpace(config.AliPrivateKey) == "" || strings.TrimSpace(config.AliPublicKey) == "" || strings.TrimSpace(config.AliNotifyURL) == "" || strings.TrimSpace(config.AliReturnURL) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "支付宝商户凭证不完整或身份不一致")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePoolRequest(req dto.PaymentMerchantPoolRequest) error {
|
||||
if !validPaymentMethod(strings.TrimSpace(req.PaymentMethod)) {
|
||||
return errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
|
||||
}
|
||||
switch req.Strategy {
|
||||
case model.PaymentMerchantStrategyAmount:
|
||||
if req.ThresholdAmount == nil || *req.ThresholdAmount <= 0 || req.ThresholdCount != nil || req.StatisticCycle == nil || !validStatisticCycle(*req.StatisticCycle) || req.TimePeriodValue != nil || req.TimePeriodUnit != nil || req.TimePeriodStartedAt != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "金额轮询策略参数不完整")
|
||||
}
|
||||
case model.PaymentMerchantStrategyCount:
|
||||
if req.ThresholdCount == nil || *req.ThresholdCount <= 0 || req.ThresholdAmount != nil || req.StatisticCycle == nil || !validStatisticCycle(*req.StatisticCycle) || req.TimePeriodValue != nil || req.TimePeriodUnit != nil || req.TimePeriodStartedAt != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "笔数轮询策略参数不完整")
|
||||
}
|
||||
case model.PaymentMerchantStrategyTime:
|
||||
if req.TimePeriodValue == nil || *req.TimePeriodValue < 1 || req.TimePeriodUnit == nil || !validTimeUnit(*req.TimePeriodUnit) || req.TimePeriodStartedAt == nil || req.ThresholdAmount != nil || req.ThresholdCount != nil || req.StatisticCycle != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "时间轮询策略参数不完整")
|
||||
}
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "不支持的商户池轮询策略")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validStatisticCycle(value string) bool {
|
||||
return value == "round" || value == "day" || value == "month"
|
||||
}
|
||||
func validTimeUnit(value string) bool { return value == "minute" || value == "hour" || value == "day" }
|
||||
|
||||
func (s *ManagementService) writeAudit(ctx context.Context, tx *gorm.DB, operation, description, key, name string, id uint, identity, before, after map[string]any) error {
|
||||
if s.audit == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "支付商户管理审计接缝未配置")
|
||||
}
|
||||
resourceID := strconv.FormatUint(uint64(id), 10)
|
||||
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
|
||||
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
|
||||
ConfigKey: key, Module: "payment_merchant", ResourceID: &resourceID, DisplayName: name,
|
||||
Identity: identity, BeforeData: before, AfterData: after, Result: constants.AuditResultSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
func merchantAuditIdentity(m *model.PaymentMerchant) map[string]any {
|
||||
return map[string]any{"id": m.ID, "name": m.Name, "payment_method": m.PaymentMethod, "provider_type": m.ProviderType, "merchant_identity": m.MerchantIdentity, "status": m.Status, "credential_version": m.CredentialVersion}
|
||||
}
|
||||
|
||||
func poolAuditIdentity(p *model.PaymentMerchantPool) map[string]any {
|
||||
return map[string]any{"id": p.ID, "name": p.Name, "payment_method": p.PaymentMethod, "strategy": p.Strategy, "status": p.Status, "routing_epoch": p.RoutingEpoch}
|
||||
}
|
||||
|
||||
func authorizationAuditIdentity(a *model.WechatAuthorization) map[string]any {
|
||||
return map[string]any{"id": a.ID, "status": a.Status, "credential_version": a.CredentialVersion, "oa_app_id": a.OaAppID, "miniapp_app_id": a.MiniappAppID}
|
||||
}
|
||||
|
||||
// CreateMerchant 创建独立管理的支付商户。
|
||||
func (s *ManagementService) CreateMerchant(ctx context.Context, req dto.PaymentMerchantRequest) (*dto.PaymentMerchantResponse, error) {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil || s.db == nil || strings.TrimSpace(req.Name) == "" || len(req.Credentials) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "商户参数或凭证不完整")
|
||||
}
|
||||
if !validPaymentMethod(strings.TrimSpace(req.PaymentMethod)) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
|
||||
}
|
||||
m := &model.PaymentMerchant{Name: strings.TrimSpace(req.Name), PaymentMethod: strings.TrimSpace(req.PaymentMethod), ProviderType: strings.TrimSpace(req.ProviderType), MerchantIdentity: strings.TrimSpace(req.MerchantIdentity), Credentials: req.Credentials, CredentialVersion: 1, Remark: strings.TrimSpace(req.Remark), BaseModel: model.BaseModel{Creator: middleware.GetUserIDFromContext(ctx), Updater: middleware.GetUserIDFromContext(ctx)}}
|
||||
if req.Enabled {
|
||||
m.Status = model.PaymentMerchantStatusEnabled
|
||||
}
|
||||
if m.MerchantIdentity == "" || m.ProviderType == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "商户身份或服务商类型不能为空")
|
||||
}
|
||||
if err := validateMerchantConfiguration(m.PaymentMethod, m.ProviderType, m.MerchantIdentity, m.Credentials); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigCreate, "创建支付商户", "payment_merchant:"+strconv.FormatUint(uint64(m.ID), 10), m.Name, m.ID, merchantAuditIdentity(m), nil, merchantAuditIdentity(m))
|
||||
}); err != nil {
|
||||
if appErr, ok := err.(*errors.AppError); ok {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付商户失败")
|
||||
}
|
||||
return merchantResponse(m), nil
|
||||
}
|
||||
|
||||
// ListMerchants returns the privileged configuration projection.
|
||||
|
||||
// ListPools returns one page of merchant pools and their ordered members without per-pool member queries.
|
||||
func (s *ManagementService) ListPools(ctx context.Context, req dto.PaymentMerchantPoolListRequest) ([]*dto.PaymentMerchantPoolResponse, int64, error) {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page, size := normalizePage(req.Page, req.PageSize)
|
||||
query := s.db.WithContext(ctx).Model(&model.PaymentMerchantPool{})
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计商户池失败")
|
||||
}
|
||||
var pools []model.PaymentMerchantPool
|
||||
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&pools).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
|
||||
}
|
||||
poolIDs := make([]uint, 0, len(pools))
|
||||
for index := range pools {
|
||||
poolIDs = append(poolIDs, pools[index].ID)
|
||||
}
|
||||
membersByPool := make(map[uint][]uint, len(pools))
|
||||
if len(poolIDs) > 0 {
|
||||
var members []model.PaymentMerchantPoolMember
|
||||
if err := s.db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
|
||||
}
|
||||
for index := range members {
|
||||
member := &members[index]
|
||||
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
|
||||
}
|
||||
}
|
||||
result := make([]*dto.PaymentMerchantPoolResponse, 0, len(pools))
|
||||
for index := range pools {
|
||||
pool := &pools[index]
|
||||
result = append(result, &dto.PaymentMerchantPoolResponse{ID: pool.ID, Name: pool.Name, PaymentMethod: pool.PaymentMethod, Enabled: pool.Status == model.PaymentMerchantStatusEnabled, Strategy: pool.Strategy, ThresholdAmount: pool.ThresholdAmount, ThresholdCount: pool.ThresholdCount, StatisticCycle: pool.StatisticCycle, TimePeriodValue: pool.TimePeriodValue, TimePeriodUnit: pool.TimePeriodUnit, TimePeriodStartedAt: pool.TimePeriodStartedAt, RoutingEpoch: pool.RoutingEpoch, MemberIDs: membersByPool[pool.ID], Remark: pool.Remark})
|
||||
}
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// GetPool 查询一个商户池及其有序成员。
|
||||
func (s *ManagementService) GetPool(ctx context.Context, id uint) (*dto.PaymentMerchantPoolResponse, error) {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pool model.PaymentMerchantPool
|
||||
if err := s.db.WithContext(ctx).First(&pool, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
}
|
||||
|
||||
// ListMerchants 分页查询特权商户配置。
|
||||
func (s *ManagementService) ListMerchants(ctx context.Context, req dto.PaymentMerchantListRequest) ([]*dto.PaymentMerchantResponse, int64, error) {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page, size := normalizePage(req.Page, req.PageSize)
|
||||
query := s.db.WithContext(ctx).Model(&model.PaymentMerchant{})
|
||||
if req.PaymentMethod != nil {
|
||||
query = query.Where("payment_method = ?", strings.TrimSpace(*req.PaymentMethod))
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
status := model.PaymentMerchantStatusDisabled
|
||||
if *req.Enabled {
|
||||
status = model.PaymentMerchantStatusEnabled
|
||||
}
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
|
||||
}
|
||||
var rows []model.PaymentMerchant
|
||||
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
|
||||
}
|
||||
result := make([]*dto.PaymentMerchantResponse, 0, len(rows))
|
||||
for index := range rows {
|
||||
result = append(result, merchantResponse(&rows[index]))
|
||||
}
|
||||
return result, total, nil
|
||||
}
|
||||
|
||||
// GetMerchant 查询一个特权商户配置。
|
||||
func (s *ManagementService) GetMerchant(ctx context.Context, id uint) (*dto.PaymentMerchantResponse, error) {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m model.PaymentMerchant
|
||||
if err := s.db.WithContext(ctx).First(&m, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
|
||||
}
|
||||
return merchantResponse(&m), nil
|
||||
}
|
||||
|
||||
// UpdateMerchant 更新商户凭证和可变配置。
|
||||
func (s *ManagementService) UpdateMerchant(ctx context.Context, id uint, req dto.PaymentMerchantUpdateRequest) (*dto.PaymentMerchantResponse, error) {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.PaymentMethod != nil && !validPaymentMethod(strings.TrimSpace(*req.PaymentMethod)) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
|
||||
}
|
||||
var m model.PaymentMerchant
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&m, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
before := merchantAuditIdentity(&m)
|
||||
previous := m
|
||||
var refs int64
|
||||
if err := tx.Model(&model.Payment{}).Where("merchant_id = ?", id).Count(&refs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if refs > 0 && ((req.PaymentMethod != nil && *req.PaymentMethod != m.PaymentMethod) || (req.ProviderType != nil && *req.ProviderType != m.ProviderType) || (req.MerchantIdentity != nil && *req.MerchantIdentity != m.MerchantIdentity)) {
|
||||
return errors.New(errors.CodeConflict, "已被支付单引用,不能修改收款身份")
|
||||
}
|
||||
if req.Name != nil {
|
||||
m.Name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.PaymentMethod != nil {
|
||||
m.PaymentMethod = strings.TrimSpace(*req.PaymentMethod)
|
||||
}
|
||||
if req.ProviderType != nil {
|
||||
m.ProviderType = strings.TrimSpace(*req.ProviderType)
|
||||
}
|
||||
if req.MerchantIdentity != nil {
|
||||
m.MerchantIdentity = strings.TrimSpace(*req.MerchantIdentity)
|
||||
}
|
||||
if req.Remark != nil {
|
||||
m.Remark = strings.TrimSpace(*req.Remark)
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
m.Status = model.PaymentMerchantStatusDisabled
|
||||
if *req.Enabled {
|
||||
m.Status = model.PaymentMerchantStatusEnabled
|
||||
}
|
||||
}
|
||||
if req.Credentials != nil && !reflect.DeepEqual(m.Credentials, *req.Credentials) {
|
||||
m.Credentials = *req.Credentials
|
||||
}
|
||||
if previous.Name != m.Name || previous.PaymentMethod != m.PaymentMethod || previous.ProviderType != m.ProviderType || previous.MerchantIdentity != m.MerchantIdentity || previous.Status != m.Status || !reflect.DeepEqual(previous.Credentials, m.Credentials) {
|
||||
m.CredentialVersion++
|
||||
}
|
||||
if err := validateMerchantConfiguration(m.PaymentMethod, m.ProviderType, m.MerchantIdentity, m.Credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigUpdate, "更新支付商户", "payment_merchant:"+strconv.FormatUint(uint64(m.ID), 10), m.Name, m.ID, merchantAuditIdentity(&m), before, merchantAuditIdentity(&m))
|
||||
}); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
|
||||
}
|
||||
if appErr, ok := err.(*errors.AppError); ok {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新支付商户失败")
|
||||
}
|
||||
return merchantResponse(&m), nil
|
||||
}
|
||||
|
||||
// DeleteMerchant 仅在未被引用且二次确认后删除商户。
|
||||
func (s *ManagementService) DeleteMerchant(ctx context.Context, id uint, confirm bool) error {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !confirm {
|
||||
return errors.New(errors.CodeInvalidParam, "删除商户必须二次确认")
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var refs, members int64
|
||||
var merchant model.PaymentMerchant
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&merchant, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
before := merchantAuditIdentity(&merchant)
|
||||
if err := tx.Model(&model.Payment{}).Where("merchant_id = ?", id).Count(&refs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if refs > 0 {
|
||||
return errors.New(errors.CodeConflict, "已被支付单引用的商户不能删除")
|
||||
}
|
||||
if err := tx.Model(&model.PaymentMerchantPoolMember{}).Where("merchant_id = ?", id).Count(&members).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if members > 0 {
|
||||
return errors.New(errors.CodeConflict, "商户仍属于商户池")
|
||||
}
|
||||
r := tx.Delete(&model.PaymentMerchant{}, id)
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeNotFound, "支付商户不存在")
|
||||
}
|
||||
if err := s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDelete, "删除支付商户", "payment_merchant:"+strconv.FormatUint(uint64(merchant.ID), 10), merchant.Name, merchant.ID, before, before, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SavePool 创建或更新商户池,并原子替换有序成员。
|
||||
func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.PaymentMerchantPoolRequest) (*dto.PaymentMerchantPoolResponse, error) {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePoolRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(req.MemberIDs) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "商户池至少需要一个商户")
|
||||
}
|
||||
var pool model.PaymentMerchantPool
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
creating := id == 0
|
||||
var previousMemberIDs []uint
|
||||
if !creating {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pool, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var previousMembers []model.PaymentMerchantPoolMember
|
||||
if err := tx.Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&previousMembers).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
previousMemberIDs = make([]uint, 0, len(previousMembers))
|
||||
for _, member := range previousMembers {
|
||||
previousMemberIDs = append(previousMemberIDs, member.MerchantID)
|
||||
}
|
||||
} else {
|
||||
pool.Creator = middleware.GetUserIDFromContext(ctx)
|
||||
pool.RoutingEpoch = 1
|
||||
}
|
||||
before := poolAuditIdentity(&pool)
|
||||
if err := validatePoolMembers(ctx, tx, req.PaymentMethod, req.MemberIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if !creating && poolEpochChanged(&pool, &req, previousMemberIDs) {
|
||||
pool.RoutingEpoch++
|
||||
}
|
||||
pool.Name, pool.PaymentMethod, pool.Strategy, pool.Remark = strings.TrimSpace(req.Name), strings.TrimSpace(req.PaymentMethod), strings.TrimSpace(req.Strategy), strings.TrimSpace(req.Remark)
|
||||
pool.ThresholdAmount, pool.ThresholdCount, pool.StatisticCycle, pool.TimePeriodValue, pool.TimePeriodUnit, pool.TimePeriodStartedAt = req.ThresholdAmount, req.ThresholdCount, req.StatisticCycle, req.TimePeriodValue, req.TimePeriodUnit, req.TimePeriodStartedAt
|
||||
if req.Enabled {
|
||||
var others int64
|
||||
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if others > 0 {
|
||||
return errors.New(errors.CodeConflict, "该支付方式已有启用商户池")
|
||||
}
|
||||
}
|
||||
pool.Status = model.PaymentMerchantStatusDisabled
|
||||
if req.Enabled {
|
||||
pool.Status = model.PaymentMerchantStatusEnabled
|
||||
}
|
||||
pool.Updater = middleware.GetUserIDFromContext(ctx)
|
||||
if creating {
|
||||
if err := tx.Create(&pool).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := tx.Save(&pool).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("pool_id = ?", pool.ID).Delete(&model.PaymentMerchantPoolMember{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
members := make([]model.PaymentMerchantPoolMember, 0, len(req.MemberIDs))
|
||||
for i, merchantID := range req.MemberIDs {
|
||||
members = append(members, model.PaymentMerchantPoolMember{PoolID: pool.ID, MerchantID: merchantID, SortOrder: int64(i), BaseModel: model.BaseModel{Creator: middleware.GetUserIDFromContext(ctx), Updater: middleware.GetUserIDFromContext(ctx)}})
|
||||
}
|
||||
if err := tx.Create(&members).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
op := constants.AuditOperationPaymentConfigUpdate
|
||||
summary := "更新商户池"
|
||||
if creating {
|
||||
op = constants.AuditOperationPaymentConfigCreate
|
||||
summary = "创建商户池"
|
||||
}
|
||||
return s.writeAudit(ctx, tx, op, summary, "payment_merchant_pool:"+strconv.FormatUint(uint64(pool.ID), 10), pool.Name, pool.ID, poolAuditIdentity(&pool), before, poolAuditIdentity(&pool))
|
||||
})
|
||||
if err != nil {
|
||||
if appErr, ok := err.(*errors.AppError); ok {
|
||||
return nil, appErr
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存商户池失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
}
|
||||
|
||||
// SetPoolEnabled enables or disables a pool after rechecking the active-pool and member invariants.
|
||||
func (s *ManagementService) SetPoolEnabled(ctx context.Context, id uint, enabled bool) (*dto.PaymentMerchantPoolResponse, error) {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pool model.PaymentMerchantPool
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pool, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
before := poolAuditIdentity(&pool)
|
||||
if enabled {
|
||||
var others int64
|
||||
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if others > 0 {
|
||||
return errors.New(errors.CodeConflict, "该支付方式已有启用商户池")
|
||||
}
|
||||
var members []model.PaymentMerchantPoolMember
|
||||
if err := tx.Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&members).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make([]uint, 0, len(members))
|
||||
for _, member := range members {
|
||||
ids = append(ids, member.MerchantID)
|
||||
}
|
||||
if err := validatePoolMembers(ctx, tx, pool.PaymentMethod, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
pool.Status = model.PaymentMerchantStatusEnabled
|
||||
} else {
|
||||
pool.Status = model.PaymentMerchantStatusDisabled
|
||||
}
|
||||
pool.Updater = middleware.GetUserIDFromContext(ctx)
|
||||
if err := tx.Save(&pool).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
op := constants.AuditOperationPaymentConfigDeactivate
|
||||
summary := "停用商户池"
|
||||
if enabled {
|
||||
op = constants.AuditOperationPaymentConfigActivate
|
||||
summary = "启用商户池"
|
||||
}
|
||||
return s.writeAudit(ctx, tx, op, summary, "payment_merchant_pool:"+strconv.FormatUint(uint64(pool.ID), 10), pool.Name, pool.ID, poolAuditIdentity(&pool), before, poolAuditIdentity(&pool))
|
||||
})
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
|
||||
}
|
||||
if appErr, ok := err.(*errors.AppError); ok {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新商户池状态失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
}
|
||||
|
||||
func validatePoolMembers(ctx context.Context, tx *gorm.DB, method string, ids []uint) error {
|
||||
seen := map[uint]struct{}{}
|
||||
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "商户ID无效")
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return errors.New(errors.CodeInvalidParam, "商户池成员不能重复")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
var merchants []model.PaymentMerchant
|
||||
if err := tx.WithContext(ctx).Where("id IN ? AND payment_method = ? AND status = ?", ids, method, model.PaymentMerchantStatusEnabled).Find(&merchants).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(merchants) != len(ids) {
|
||||
return errors.New(errors.CodeConflict, "商户池成员必须存在、启用且支付方式一致")
|
||||
}
|
||||
for index := range merchants {
|
||||
merchant := &merchants[index]
|
||||
if err := validateMerchantConfiguration(merchant.PaymentMethod, merchant.ProviderType, merchant.MerchantIdentity, merchant.Credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func poolEpochChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerchantPoolRequest, previousMemberIDs []uint) bool {
|
||||
if pool.PaymentMethod != strings.TrimSpace(request.PaymentMethod) ||
|
||||
pool.Strategy != request.Strategy ||
|
||||
!sameString(pool.StatisticCycle, request.StatisticCycle) ||
|
||||
!sameInt64(pool.TimePeriodValue, request.TimePeriodValue) ||
|
||||
!sameString(pool.TimePeriodUnit, request.TimePeriodUnit) ||
|
||||
!sameTime(pool.TimePeriodStartedAt, request.TimePeriodStartedAt) {
|
||||
return true
|
||||
}
|
||||
if sameMemberOrder(previousMemberIDs, request.MemberIDs) {
|
||||
return false
|
||||
}
|
||||
return pool.StatisticCycle == nil || (*pool.StatisticCycle != "day" && *pool.StatisticCycle != "month") || !sameMemberSet(previousMemberIDs, request.MemberIDs)
|
||||
}
|
||||
|
||||
func sameMemberOrder(left, right []uint) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for i := range left {
|
||||
if left[i] != right[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sameMemberSet(left, right []uint) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[uint]struct{}, len(left))
|
||||
for _, id := range left {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range right {
|
||||
if _, ok := seen[id]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sameString(left, right *string) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func sameInt64(left, right *int64) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func sameTime(a, b *time.Time) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return a.Equal(*b)
|
||||
}
|
||||
func merchantResponse(m *model.PaymentMerchant) *dto.PaymentMerchantResponse {
|
||||
return &dto.PaymentMerchantResponse{ID: m.ID, Name: m.Name, PaymentMethod: m.PaymentMethod, ProviderType: m.ProviderType, MerchantIdentity: m.MerchantIdentity, Credentials: m.Credentials, CredentialVersion: m.CredentialVersion, Enabled: m.Status == model.PaymentMerchantStatusEnabled, Remark: m.Remark, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
|
||||
}
|
||||
func poolResponse(ctx context.Context, db *gorm.DB, p *model.PaymentMerchantPool) (*dto.PaymentMerchantPoolResponse, error) {
|
||||
var rows []model.PaymentMerchantPoolMember
|
||||
if err := db.WithContext(ctx).Where("pool_id = ?", p.ID).Order("sort_order ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.MerchantID)
|
||||
}
|
||||
return &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: ids, Remark: p.Remark}, nil
|
||||
}
|
||||
|
||||
// GetAuthorization 查询特权全局授权配置。
|
||||
func (s *ManagementService) GetAuthorization(ctx context.Context) (*dto.WechatAuthorizationResponse, error) {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a model.WechatAuthorization
|
||||
if err := s.db.WithContext(ctx).First(&a).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权配置失败")
|
||||
}
|
||||
return authorizationResponse(&a), nil
|
||||
}
|
||||
|
||||
// SaveAuthorization 创建或更新唯一启用的授权配置。
|
||||
func (s *ManagementService) SaveAuthorization(ctx context.Context, req dto.WechatAuthorizationRequest) (*dto.WechatAuthorizationResponse, error) {
|
||||
if err := requireManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Enabled && (strings.TrimSpace(req.OaAppID) == "" || strings.TrimSpace(req.OaAppSecret) == "" || strings.TrimSpace(req.MiniappAppID) == "" || strings.TrimSpace(req.MiniappAppSecret) == "") {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "启用微信授权配置时公众号和小程序凭证必须完整")
|
||||
}
|
||||
var authorization model.WechatAuthorization
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&authorization).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
previousStatus := authorization.Status
|
||||
creating := err == gorm.ErrRecordNotFound
|
||||
before := authorizationAuditIdentity(&authorization)
|
||||
if creating {
|
||||
authorization.Creator = middleware.GetUserIDFromContext(ctx)
|
||||
authorization.CredentialVersion = 1
|
||||
}
|
||||
changed := authorization.OaAppID != req.OaAppID || authorization.OaAppSecret != req.OaAppSecret || authorization.OaToken != req.OaToken || authorization.OaAesKey != req.OaAesKey || authorization.OaOAuthRedirectURL != req.OaOAuthRedirectURL || authorization.MiniappAppID != req.MiniappAppID || authorization.MiniappAppSecret != req.MiniappAppSecret
|
||||
authorization.OaAppID, authorization.OaAppSecret, authorization.OaToken, authorization.OaAesKey, authorization.OaOAuthRedirectURL, authorization.MiniappAppID, authorization.MiniappAppSecret = req.OaAppID, req.OaAppSecret, req.OaToken, req.OaAesKey, req.OaOAuthRedirectURL, req.MiniappAppID, req.MiniappAppSecret
|
||||
authorization.Status = model.PaymentMerchantStatusDisabled
|
||||
if req.Enabled {
|
||||
authorization.Status = model.PaymentMerchantStatusEnabled
|
||||
}
|
||||
if !creating && (changed || previousStatus != authorization.Status) {
|
||||
authorization.CredentialVersion++
|
||||
}
|
||||
authorization.Updater = middleware.GetUserIDFromContext(ctx)
|
||||
if creating {
|
||||
if err := tx.Create(&authorization).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Save(&authorization).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
op := constants.AuditOperationPaymentConfigUpdate
|
||||
summary := "更新微信授权配置"
|
||||
if creating {
|
||||
op = constants.AuditOperationPaymentConfigCreate
|
||||
summary = "创建微信授权配置"
|
||||
}
|
||||
return s.writeAudit(ctx, tx, op, summary, "wechat_authorization:"+strconv.FormatUint(uint64(authorization.ID), 10), "微信授权配置", authorization.ID, authorizationAuditIdentity(&authorization), before, authorizationAuditIdentity(&authorization))
|
||||
})
|
||||
if err != nil {
|
||||
if appErr, ok := err.(*errors.AppError); ok {
|
||||
return nil, appErr
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存微信授权配置失败")
|
||||
}
|
||||
return authorizationResponse(&authorization), nil
|
||||
}
|
||||
func authorizationResponse(a *model.WechatAuthorization) *dto.WechatAuthorizationResponse {
|
||||
return &dto.WechatAuthorizationResponse{ID: a.ID, OaAppID: a.OaAppID, OaAppSecret: a.OaAppSecret, OaToken: a.OaToken, OaAesKey: a.OaAesKey, OaOAuthRedirectURL: a.OaOAuthRedirectURL, MiniappAppID: a.MiniappAppID, MiniappAppSecret: a.MiniappAppSecret, CredentialVersion: a.CredentialVersion, Enabled: a.Status == model.PaymentMerchantStatusEnabled, UpdatedAt: a.UpdatedAt}
|
||||
}
|
||||
386
internal/application/merchantpayment/routing.go
Normal file
386
internal/application/merchantpayment/routing.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package merchantpayment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RouteSelection is the non-sensitive route frozen onto a new payment.
|
||||
type RouteSelection struct {
|
||||
Merchant *model.PaymentMerchant
|
||||
Pool *model.PaymentMerchantPool
|
||||
}
|
||||
|
||||
// RuntimeLoader loads current merchant and authorization credentials by version.
|
||||
type RuntimeLoader struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
// merchantCachePayload is used only for the internal versioned Redis cache and deliberately includes credentials.
|
||||
// It must never be used for DTOs, logs, audits, or payment snapshots.
|
||||
type merchantCachePayload struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
ProviderType string `json:"provider_type"`
|
||||
MerchantIdentity string `json:"merchant_identity"`
|
||||
Credentials model.JSONB `json:"credentials"`
|
||||
CredentialVersion int64 `json:"credential_version"`
|
||||
Status int `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
func merchantCachePayloadFrom(merchant *model.PaymentMerchant) merchantCachePayload {
|
||||
return merchantCachePayload{ID: merchant.ID, Name: merchant.Name, PaymentMethod: merchant.PaymentMethod, ProviderType: merchant.ProviderType, MerchantIdentity: merchant.MerchantIdentity, Credentials: merchant.Credentials, CredentialVersion: merchant.CredentialVersion, Status: merchant.Status, Remark: merchant.Remark}
|
||||
}
|
||||
|
||||
func (p merchantCachePayload) merchant() *model.PaymentMerchant {
|
||||
return &model.PaymentMerchant{Model: gorm.Model{ID: p.ID}, Name: p.Name, PaymentMethod: p.PaymentMethod, ProviderType: p.ProviderType, MerchantIdentity: p.MerchantIdentity, Credentials: p.Credentials, CredentialVersion: p.CredentialVersion, Status: p.Status, Remark: p.Remark}
|
||||
}
|
||||
|
||||
// authorizationCachePayload is used only for the internal versioned Redis cache and deliberately includes secrets.
|
||||
// It must never be used for DTOs, logs, audits, or payment snapshots.
|
||||
type authorizationCachePayload struct {
|
||||
ID uint `json:"id"`
|
||||
OaAppID string `json:"oa_app_id"`
|
||||
OaAppSecret string `json:"oa_app_secret"`
|
||||
OaToken string `json:"oa_token"`
|
||||
OaAesKey string `json:"oa_aes_key"`
|
||||
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url"`
|
||||
MiniappAppID string `json:"miniapp_app_id"`
|
||||
MiniappAppSecret string `json:"miniapp_app_secret"`
|
||||
CredentialVersion int64 `json:"credential_version"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
func authorizationCachePayloadFrom(authorization *model.WechatAuthorization) authorizationCachePayload {
|
||||
return authorizationCachePayload{ID: authorization.ID, OaAppID: authorization.OaAppID, OaAppSecret: authorization.OaAppSecret, OaToken: authorization.OaToken, OaAesKey: authorization.OaAesKey, OaOAuthRedirectURL: authorization.OaOAuthRedirectURL, MiniappAppID: authorization.MiniappAppID, MiniappAppSecret: authorization.MiniappAppSecret, CredentialVersion: authorization.CredentialVersion, Status: authorization.Status}
|
||||
}
|
||||
|
||||
func (p authorizationCachePayload) authorization() *model.WechatAuthorization {
|
||||
return &model.WechatAuthorization{Model: gorm.Model{ID: p.ID}, OaAppID: p.OaAppID, OaAppSecret: p.OaAppSecret, OaToken: p.OaToken, OaAesKey: p.OaAesKey, OaOAuthRedirectURL: p.OaOAuthRedirectURL, MiniappAppID: p.MiniappAppID, MiniappAppSecret: p.MiniappAppSecret, CredentialVersion: p.CredentialVersion, Status: p.Status}
|
||||
}
|
||||
|
||||
func NewRuntimeLoader(db *gorm.DB, redis *redis.Client) *RuntimeLoader {
|
||||
return &RuntimeLoader{db: db, redis: redis}
|
||||
}
|
||||
|
||||
// LoadMerchant first reads the current version from the primary database, then uses only that version's cache entry.
|
||||
// Disabled merchants remain loadable for frozen historical payments.
|
||||
func (l *RuntimeLoader) LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error) {
|
||||
if l == nil || l.db == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置")
|
||||
}
|
||||
return l.loadMerchant(ctx, l.db, id)
|
||||
}
|
||||
|
||||
// loadMerchant 先从当前事务或主库读取版本,再仅命中该版本的缓存。
|
||||
// 版本在凭证事务提交时递增,因此提交前遗留的旧缓存永远不会被新读取命中。
|
||||
func (l *RuntimeLoader) loadMerchant(ctx context.Context, db *gorm.DB, id uint) (*model.PaymentMerchant, error) {
|
||||
var current model.PaymentMerchant
|
||||
if err := db.WithContext(ctx).First(¤t, 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(¤t)); err == nil {
|
||||
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
|
||||
}
|
||||
}
|
||||
return ¤t, 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(¤t).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(¤t)); err == nil {
|
||||
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
|
||||
}
|
||||
}
|
||||
return ¤t, 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
|
||||
}
|
||||
@@ -51,6 +51,7 @@ type deliveryRequest struct {
|
||||
refID string
|
||||
refKey string
|
||||
expiresAt *time.Time
|
||||
popupSnapshot *model.NotificationPopupSnapshot
|
||||
}
|
||||
|
||||
// DeliveryService 校验接收人并幂等生成站内通知。
|
||||
@@ -123,6 +124,7 @@ func (s *DeliveryService) consumeDynamic(ctx context.Context, envelope outbox.De
|
||||
notificationType: payload.NotificationType, templateData: payload.TemplateData,
|
||||
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
|
||||
}
|
||||
// 载荷校验必须先于接收人解析:无效事件不应触发接收人查询。
|
||||
if err := validateDeliveryRequest(request); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -153,19 +155,45 @@ func validateDeliveryRequest(request deliveryRequest) error {
|
||||
if request.refType != "" && request.refID == "" && request.refKey == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少定位值")
|
||||
}
|
||||
// 投放快照与弹窗类型必须成对出现:非弹窗类型不得写快照,弹窗类型不得缺少快照。
|
||||
isPopup := constants.IsH5PopupNotificationType(request.notificationType)
|
||||
if request.popupSnapshot != nil && !isPopup {
|
||||
return errors.New(errors.CodeInvalidParam, "投放快照只允许用于弹窗通知类型")
|
||||
}
|
||||
if isPopup && request.popupSnapshot == nil {
|
||||
return errors.New(errors.CodeInvalidParam, "弹窗通知缺少投放快照")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
|
||||
// preparedDelivery 是一次事件共享的渲染结果、展示期与审计来源,与接收人数量无关。
|
||||
type preparedDelivery struct {
|
||||
rendered notificationinfra.Rendered
|
||||
now time.Time
|
||||
expiresAt *time.Time
|
||||
origin deliveryOrigin
|
||||
}
|
||||
|
||||
// deliveryOrigin 是投递审计的操作者与入口。
|
||||
// Outbox 消费路径留空,由统一审计从任务上下文补齐(与既有 worker 入口一致);
|
||||
// API 直投路径必须显式提供,因为个人客户请求上下文不携带审计上下文。
|
||||
type deliveryOrigin struct {
|
||||
actor audit.ActorInput
|
||||
source string
|
||||
}
|
||||
|
||||
// prepareDelivery 渲染模板并计算展示期;同一事件只计算一次,不随接收人重复计算。
|
||||
// 审计接缝缺失在此一次性判空:与既有行为一致,渲染之前就失败,而不是按接收人重复判断。
|
||||
func (s *DeliveryService) prepareDelivery(eventID, recipientKind string, request deliveryRequest, origin deliveryOrigin) (*preparedDelivery, error) {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
|
||||
}
|
||||
rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind)
|
||||
if err != nil {
|
||||
s.logger.Error("站内通知模板校验失败",
|
||||
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
|
||||
zap.String("failure_category", "template"))
|
||||
return errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
|
||||
return nil, errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
expiresAt, err := notificationDisplayExpiry(rendered.Category, request.expiresAt, now)
|
||||
@@ -173,46 +201,23 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st
|
||||
s.logger.Error("站内通知展示期限校验失败",
|
||||
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
|
||||
zap.String("failure_category", "display_policy"))
|
||||
return nil, err
|
||||
}
|
||||
return &preparedDelivery{rendered: rendered, now: now, expiresAt: expiresAt, origin: origin}, nil
|
||||
}
|
||||
|
||||
// deliver 对每个接收人执行同一套单接收人投放规则;接收人不可用时跳过,不影响其他接收人。
|
||||
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
|
||||
prepared, err := s.prepareDelivery(eventID, recipientKind, request, deliveryOrigin{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipientID := range recipientIDs {
|
||||
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
|
||||
notification, created, err := s.deliverOne(ctx, eventID, recipientKind, recipientID, request, prepared)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
|
||||
return err
|
||||
}
|
||||
if !active {
|
||||
s.logger.Info("站内通知接收人不可用,已跳过",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
continue
|
||||
}
|
||||
notification := &model.Notification{
|
||||
EventID: eventID, RecipientKind: recipientKind,
|
||||
RecipientID: recipientID, Category: rendered.Category, Type: rendered.Type,
|
||||
Severity: rendered.Severity, Title: rendered.Title, Body: rendered.Body,
|
||||
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
|
||||
ExpiresAt: expiresAt, CreatedAt: now,
|
||||
}
|
||||
created := false
|
||||
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var createErr error
|
||||
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
|
||||
if createErr != nil || !created {
|
||||
return createErr
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
|
||||
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
Metadata: map[string]any{"outbox_event_id": eventID},
|
||||
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
|
||||
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
|
||||
nil, map[string]any{"created": true, "is_read": false})},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
|
||||
}
|
||||
if !created {
|
||||
if notification != nil && !created {
|
||||
s.logger.Info("站内通知重复事件已幂等忽略",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
}
|
||||
@@ -220,6 +225,60 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st
|
||||
return nil
|
||||
}
|
||||
|
||||
// deliverOne 校验接收人并在单事务内幂等写入一条通知。
|
||||
// 事件键与接收人已存在时不重复投放,回查并返回既有行(created=false);接收人不可用时返回 (nil, false, nil)。
|
||||
// Outbox 消费与候选查询直投共用本方法,落库规则只有一处。
|
||||
func (s *DeliveryService) deliverOne(ctx context.Context, eventID, recipientKind string, recipientID uint, request deliveryRequest, prepared *preparedDelivery) (*model.Notification, bool, error) {
|
||||
if eventID == "" || recipientID == 0 || prepared == nil {
|
||||
return nil, false, errors.New(errors.CodeInvalidParam, "通知事件或接收人不完整")
|
||||
}
|
||||
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
|
||||
}
|
||||
if !active {
|
||||
s.logger.Info("站内通知接收人不可用,已跳过",
|
||||
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
|
||||
return nil, false, nil
|
||||
}
|
||||
notification := &model.Notification{
|
||||
EventID: eventID, RecipientKind: recipientKind,
|
||||
RecipientID: recipientID, Category: prepared.rendered.Category, Type: prepared.rendered.Type,
|
||||
Severity: prepared.rendered.Severity, Title: prepared.rendered.Title, Body: prepared.rendered.Body,
|
||||
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
|
||||
ExpiresAt: prepared.expiresAt, CreatedAt: prepared.now, PopupSnapshot: request.popupSnapshot,
|
||||
}
|
||||
created := false
|
||||
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var createErr error
|
||||
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
|
||||
if createErr != nil || !created {
|
||||
return createErr
|
||||
}
|
||||
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
|
||||
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
|
||||
Actor: prepared.origin.actor, Source: prepared.origin.source,
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
Metadata: map[string]any{"outbox_event_id": eventID},
|
||||
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
|
||||
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
|
||||
nil, map[string]any{"created": true, "is_read": false})},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
|
||||
}
|
||||
if created {
|
||||
return notification, true, nil
|
||||
}
|
||||
existing, err := s.repository.FindByEventRecipient(ctx, eventID, recipientKind, recipientID)
|
||||
if err != nil {
|
||||
return nil, false, errors.Wrap(errors.CodeDatabaseError, err, "回查既有站内通知失败")
|
||||
}
|
||||
return existing, false, nil
|
||||
}
|
||||
|
||||
func notificationDisplayExpiry(category string, requested *time.Time, now time.Time) (*time.Time, error) {
|
||||
switch category {
|
||||
case constants.NotificationCategoryApproval:
|
||||
|
||||
63
internal/application/notification/direct.go
Normal file
63
internal/application/notification/direct.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// PersonalDirectRequest 是当次直投或复用个人客户通知的请求参数。
|
||||
// PopupSnapshot 只允许弹窗投放类型携带,其余类型必须为空。
|
||||
type PersonalDirectRequest struct {
|
||||
NotificationType string
|
||||
TemplateData map[string]string
|
||||
RefType string
|
||||
RefID string
|
||||
RefKey string
|
||||
ExpiresAt *time.Time
|
||||
PopupSnapshot *model.NotificationPopupSnapshot
|
||||
}
|
||||
|
||||
// DirectWriter 是「当次创建或复用个人客户通知」的窄接口。
|
||||
// 候选查询必须当次拿到可用通知标识,不能依赖 Outbox 消费延迟,因此需要这条同步入口。
|
||||
type DirectWriter interface {
|
||||
CreateOrGetPersonal(ctx context.Context, eventID string, customerID uint, request PersonalDirectRequest) (*model.Notification, error)
|
||||
}
|
||||
|
||||
// CreateOrGetPersonal 当次渲染并幂等写入个人客户通知;事件键已存在时不重复投放,回查并返回既有行。
|
||||
// 与 Outbox 消费共用同一渲染、展示期与幂等写入规则,避免两条链路规则漂移。
|
||||
func (s *DeliveryService) CreateOrGetPersonal(ctx context.Context, eventID string, customerID uint, request PersonalDirectRequest) (*model.Notification, error) {
|
||||
if customerID == 0 || eventID == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "个人客户通知参数不完整")
|
||||
}
|
||||
delivery := deliveryRequest{
|
||||
notificationType: request.NotificationType, templateData: request.TemplateData,
|
||||
refType: request.RefType, refID: request.RefID, refKey: request.RefKey,
|
||||
expiresAt: request.ExpiresAt, popupSnapshot: request.PopupSnapshot,
|
||||
}
|
||||
if err := validateDeliveryRequest(delivery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// API 直投不经过 Outbox 消费,自行提供渲染结果与展示期,但仍复用同一落库规则。
|
||||
// 个人客户请求上下文不携带审计上下文,直投必须显式声明操作者与入口,否则投递审计会被入口规则拒绝并静默降级。
|
||||
prepared, err := s.prepareDelivery(eventID, constants.NotificationRecipientKindPersonalCustomer, delivery, deliveryOrigin{
|
||||
actor: audit.ActorInput{Kind: constants.AuditActorPersonalCustomer, ID: strconv.FormatUint(uint64(customerID), 10)},
|
||||
source: constants.AuditSourcePersonalAPI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notification, _, err := s.deliverOne(ctx, eventID, constants.NotificationRecipientKindPersonalCustomer, customerID, delivery, prepared)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if notification == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "个人客户通知接收人不可用")
|
||||
}
|
||||
return notification, nil
|
||||
}
|
||||
@@ -214,7 +214,13 @@ func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
|
||||
constants.NotificationRecipientKindPersonalCustomer,
|
||||
customerID,
|
||||
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem},
|
||||
[]string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated},
|
||||
[]string{
|
||||
constants.NotificationTypePackageExpiring,
|
||||
constants.NotificationTypeExchangeShippingCreated,
|
||||
constants.NotificationTypeH5PopupRiskExchange,
|
||||
constants.NotificationTypeH5PopupOperation,
|
||||
constants.NotificationTypeAssetAutoRenewalFailed,
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
299
internal/application/packagetrafficalert/rule.go
Normal file
299
internal/application/packagetrafficalert/rule.go
Normal file
@@ -0,0 +1,299 @@
|
||||
// Package packagetrafficalert 收口套餐真流量预警的规则维护事务脚本与每日扫描用例。
|
||||
//
|
||||
// 规则维护是简单写:Handler → Application 事务脚本 → Persistence,事实与审计同事务。
|
||||
// 每日扫描是复杂写:Application 编排 → Domain 判定 → Port/Infrastructure 原子写入预警事实、
|
||||
// 可靠通知事件与审计;判定只使用套餐使用记录的真流量快照,不读取虚流量、展示量、卡级累计或通道累计。
|
||||
package packagetrafficalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"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"
|
||||
)
|
||||
|
||||
// RuleService 套餐真流量预警规则维护事务脚本。
|
||||
type RuleService struct {
|
||||
db *gorm.DB
|
||||
store *postgres.PackageTrafficAlertStore
|
||||
auditWriter *audit.Writer
|
||||
}
|
||||
|
||||
// NewRuleService 创建套餐真流量预警规则事务脚本。
|
||||
func NewRuleService(db *gorm.DB, store *postgres.PackageTrafficAlertStore, auditWriters ...*audit.Writer) *RuleService {
|
||||
service := &RuleService{db: db, store: store}
|
||||
if len(auditWriters) > 0 {
|
||||
service.auditWriter = auditWriters[0]
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// Create 为套餐商品创建唯一预警规则。
|
||||
// 创建一律校验套餐存在且商品真流量额度大于零:商品 real_data_mb 只用于配置合法性,
|
||||
// 不作为扫描分母(分母取使用记录的真总量快照)。
|
||||
func (s *RuleService) Create(ctx context.Context, request *dto.CreatePackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleItem, error) {
|
||||
operatorID, err := requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request == nil || request.PackageID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐商品ID不能为空")
|
||||
}
|
||||
if !packagetrafficalert.IsValidThresholdPercent(request.ThresholdPercent) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "真流量预警阈值必须大于等于 1 且小于等于 100,允许两位小数")
|
||||
}
|
||||
enabled := constants.StatusEnabled
|
||||
if request.Enabled != nil && !*request.Enabled {
|
||||
enabled = constants.StatusDisabled
|
||||
}
|
||||
rule := &model.PackageTrafficAlertRule{
|
||||
PackageID: request.PackageID,
|
||||
ThresholdPercent: packagetrafficalert.NormalizeThresholdPercent(request.ThresholdPercent),
|
||||
Enabled: enabled,
|
||||
Remark: request.Remark,
|
||||
BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
|
||||
}
|
||||
var response *dto.PackageTrafficAlertRuleItem
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
pkg, loadErr := s.loadPackage(ctx, tx, request.PackageID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if pkg.RealDataMB <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "该套餐商品真流量额度不大于零,不能启用真流量预警规则")
|
||||
}
|
||||
store := s.store.WithTx(tx)
|
||||
exists, existsErr := store.ExistsRuleByPackageID(ctx, request.PackageID)
|
||||
if existsErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, existsErr, "校验套餐预警规则失败")
|
||||
}
|
||||
if exists {
|
||||
return errors.New(errors.CodeInvalidParam, "套餐已存在真流量预警规则")
|
||||
}
|
||||
if createErr := store.CreateRule(ctx, rule); createErr != nil {
|
||||
if isDuplicateKey(createErr) {
|
||||
return errors.New(errors.CodeInvalidParam, "套餐已存在真流量预警规则")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐真流量预警规则失败")
|
||||
}
|
||||
item := toRuleItem(rule, pkg.PackageName, pkg.RealDataMB)
|
||||
if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleCreated,
|
||||
"创建套餐真流量预警规则", rule, pkg.PackageName, nil, ruleAuditSnapshot(rule, pkg.PackageName)); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
response = item
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Update 修改阈值、启停与备注。
|
||||
// 修改不回填既有预警,也不改写已冻结的预警快照;结果状态为启用时同样校验商品真流量额度大于零。
|
||||
func (s *RuleService) Update(ctx context.Context, ruleID uint, request *dto.UpdatePackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleItem, error) {
|
||||
operatorID, err := requireOperator(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request == nil || ruleID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "预警规则ID不能为空")
|
||||
}
|
||||
if request.ThresholdPercent != nil && !packagetrafficalert.IsValidThresholdPercent(*request.ThresholdPercent) {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "真流量预警阈值必须大于等于 1 且小于等于 100,允许两位小数")
|
||||
}
|
||||
var response *dto.PackageTrafficAlertRuleItem
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
store := s.store.WithTx(tx)
|
||||
rule, lockErr := store.LockRuleByID(ctx, ruleID)
|
||||
if lockErr != nil {
|
||||
return ruleLookupError(lockErr)
|
||||
}
|
||||
before := ruleAuditSnapshot(rule, "")
|
||||
if request.ThresholdPercent != nil {
|
||||
rule.ThresholdPercent = packagetrafficalert.NormalizeThresholdPercent(*request.ThresholdPercent)
|
||||
}
|
||||
if request.Enabled != nil {
|
||||
rule.Enabled = constants.StatusEnabled
|
||||
if !*request.Enabled {
|
||||
rule.Enabled = constants.StatusDisabled
|
||||
}
|
||||
}
|
||||
if request.Remark != nil {
|
||||
rule.Remark = *request.Remark
|
||||
}
|
||||
pkg, pkgErr := s.loadPackage(ctx, tx, rule.PackageID)
|
||||
if pkgErr != nil {
|
||||
return pkgErr
|
||||
}
|
||||
if rule.Enabled == constants.StatusEnabled && pkg.RealDataMB <= 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "该套餐商品真流量额度不大于零,不能启用真流量预警规则")
|
||||
}
|
||||
packageName := pkg.PackageName
|
||||
if updateErr := store.UpdateRule(ctx, rule, operatorID); updateErr != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐真流量预警规则失败")
|
||||
}
|
||||
after := ruleAuditSnapshot(rule, packageName)
|
||||
if before["enabled"] != after["enabled"] {
|
||||
action, summary := constants.AuditActionPackageTrafficAlertRuleEnabled, "启用套餐真流量预警规则"
|
||||
if rule.Enabled != constants.StatusEnabled {
|
||||
action, summary = constants.AuditActionPackageTrafficAlertRuleDisabled, "停用套餐真流量预警规则"
|
||||
}
|
||||
if auditErr := s.appendRuleAudit(ctx, tx, action, summary, rule, packageName,
|
||||
map[string]any{"enabled": before["enabled"]}, map[string]any{"enabled": after["enabled"]}); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
if before["threshold_percent"] != after["threshold_percent"] || before["remark"] != after["remark"] {
|
||||
if auditErr := s.appendRuleAudit(ctx, tx, constants.AuditActionPackageTrafficAlertRuleUpdated,
|
||||
"更新套餐真流量预警规则", rule, packageName, before, after); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
response = toRuleItem(rule, packageName, pkg.RealDataMB)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// loadPackage 查询套餐商品;不存在时按参数错误返回。
|
||||
func (s *RuleService) loadPackage(ctx context.Context, tx *gorm.DB, packageID uint) (*model.Package, error) {
|
||||
var pkg model.Package
|
||||
if err := tx.WithContext(ctx).Where("id = ?", packageID).First(&pkg).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐商品不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐商品失败")
|
||||
}
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
// appendRuleAudit 在业务事务内追加预警规则事件。
|
||||
func (s *RuleService) appendRuleAudit(ctx context.Context, tx *gorm.DB, action, summary string,
|
||||
rule *model.PackageTrafficAlertRule, packageName string, before, after map[string]any) error {
|
||||
if s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "套餐真流量预警规则统一审计接缝未配置")
|
||||
}
|
||||
var resourceID *string
|
||||
if rule.ID != 0 {
|
||||
value := strconv.FormatUint(uint64(rule.ID), 10)
|
||||
resourceID = &value
|
||||
}
|
||||
displayName := packageName
|
||||
if displayName == "" {
|
||||
displayName = "套餐 " + strconv.FormatUint(uint64(rule.PackageID), 10)
|
||||
}
|
||||
// 使用 AppendAndGet:预警规则属于关键配置,「要求成功必达」的审计失败必须回滚事务(ENG-TX-001)。
|
||||
if _, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
|
||||
ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess,
|
||||
Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10)},
|
||||
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
|
||||
Resources: []audit.ResourceInput{{
|
||||
Type: constants.AuditResourcePackageTrafficAlertRule, ID: resourceID,
|
||||
Key: strconv.FormatUint(uint64(rule.PackageID), 10), DisplayName: displayName,
|
||||
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePackageTrafficAlertRuleTarget,
|
||||
IdentitySnapshot: ruleAuditIdentity(rule, packageName), BeforeData: before, AfterData: after,
|
||||
}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireOperator 要求调用方已通过后台鉴权,否则拒绝写入。
|
||||
func requireOperator(ctx context.Context) (uint, error) {
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return 0, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
return operatorID, nil
|
||||
}
|
||||
|
||||
// ruleLookupError 把规则不存在映射为统一资源不可见错误。
|
||||
func ruleLookupError(err error) error {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则失败")
|
||||
}
|
||||
|
||||
// isDuplicateKey 判断数据库错误是否为唯一键冲突。
|
||||
func isDuplicateKey(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := err.Error()
|
||||
return strings.Contains(text, "23505") || strings.Contains(text, "duplicate key") || strings.Contains(text, "SQLSTATE 23505")
|
||||
}
|
||||
|
||||
// ruleAuditSnapshot 返回预警规则可审计的可变字段快照。
|
||||
func ruleAuditSnapshot(rule *model.PackageTrafficAlertRule, packageName string) map[string]any {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
snapshot := map[string]any{
|
||||
"package_id": rule.PackageID,
|
||||
"threshold_percent": rule.ThresholdPercent,
|
||||
"enabled": rule.Enabled,
|
||||
"remark": rule.Remark,
|
||||
}
|
||||
if packageName != "" {
|
||||
snapshot["package_name"] = packageName
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// ruleAuditIdentity 返回预警规则审计身份快照,字段落在注册表白名单内。
|
||||
func ruleAuditIdentity(rule *model.PackageTrafficAlertRule, packageName string) map[string]any {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
identity := map[string]any{
|
||||
"id": rule.ID, "package_id": rule.PackageID, "threshold_percent": rule.ThresholdPercent,
|
||||
"enabled": rule.Enabled, "remark": rule.Remark,
|
||||
}
|
||||
if packageName != "" {
|
||||
identity["package_name"] = packageName
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// toRuleItem 把规则投影为对外响应项。
|
||||
func toRuleItem(rule *model.PackageTrafficAlertRule, packageName string, realDataMB int64) *dto.PackageTrafficAlertRuleItem {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.PackageTrafficAlertRuleItem{
|
||||
ID: rule.ID,
|
||||
PackageID: rule.PackageID,
|
||||
PackageName: packageName,
|
||||
RealDataMB: realDataMB,
|
||||
ThresholdPercent: rule.ThresholdPercent,
|
||||
Enabled: rule.Enabled == constants.StatusEnabled,
|
||||
EnabledName: enabledName(rule.Enabled),
|
||||
Remark: rule.Remark,
|
||||
UpdatedAt: rule.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// enabledName 返回启停状态的中文名称。
|
||||
func enabledName(enabled int) string {
|
||||
if enabled == constants.StatusEnabled {
|
||||
return "启用"
|
||||
}
|
||||
return "停用"
|
||||
}
|
||||
302
internal/application/packagetrafficalert/scan.go
Normal file
302
internal/application/packagetrafficalert/scan.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package packagetrafficalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// AssetKey 是扫描的资产聚合键:卡按 iot_card_id、设备按 device_id,二者互斥非零。
|
||||
type AssetKey struct {
|
||||
AssetType string
|
||||
AssetID uint
|
||||
}
|
||||
|
||||
// AssetAggregate 是同一资产全部当前有效套餐使用记录的真流量汇总。
|
||||
// UsedMB 汇总真已用量,LimitMB 汇总真总量快照;虚流量、展示量、卡级累计与通道累计一律不参与。
|
||||
type AssetAggregate struct {
|
||||
Key AssetKey
|
||||
UsedMB int64
|
||||
LimitMB int64
|
||||
}
|
||||
|
||||
// MainUsage 是资产的主套餐使用记录(阈值来源与预警锚点)。
|
||||
type MainUsage struct {
|
||||
PackageUsageID uint
|
||||
PackageID uint
|
||||
PackageName string
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// EnabledRule 是主套餐对应的当前启用预警规则。
|
||||
type EnabledRule struct {
|
||||
ID uint
|
||||
PackageID uint
|
||||
ThresholdPercent float64
|
||||
}
|
||||
|
||||
// AssetFacts 是触发时必须冻结的资产与归属展示事实。
|
||||
// 资产标识、卡标识、对端标识、设备类型与型号取自触发时的卡与设备绑定;
|
||||
// 归属只包含触发时店铺与「仅业务员」解析出的有效平台业务员。
|
||||
type AssetFacts struct {
|
||||
AssetIdentifier string
|
||||
CardIdentifier string
|
||||
CounterpartIdentifier string
|
||||
DeviceType string
|
||||
DeviceModel string
|
||||
ShopID uint
|
||||
ShopName string
|
||||
BusinessOwnerID *uint
|
||||
BusinessOwnerName string
|
||||
}
|
||||
|
||||
// ScanReader 读取扫描所需的只读事实。
|
||||
type ScanReader interface {
|
||||
// LoadAssetAggregates 按资产汇总当前有效套餐的真已用量与真总量快照。
|
||||
LoadAssetAggregates(ctx context.Context) ([]AssetAggregate, error)
|
||||
// LoadMainUsages 批量读取每个资产的主套餐使用记录(master_usage_id 为空,按优先级/生效时间/编号取第一条)。
|
||||
LoadMainUsages(ctx context.Context, keys []AssetKey) (map[AssetKey]MainUsage, error)
|
||||
// LoadEnabledRules 批量读取套餐商品当前启用的预警规则。
|
||||
LoadEnabledRules(ctx context.Context, packageIDs []uint) (map[uint]EnabledRule, error)
|
||||
// LoadAssetFacts 批量读取资产展示事实与触发时归属。
|
||||
LoadAssetFacts(ctx context.Context, keys []AssetKey) (map[AssetKey]AssetFacts, error)
|
||||
}
|
||||
|
||||
// AlertCandidate 是一次命中要原子落库的完整事实。
|
||||
type AlertCandidate struct {
|
||||
Alert model.PackageTrafficAlert
|
||||
// Notification 为空表示触发时店铺无有效业务员或到期时间不可推算,只保存预警不写通知事件。
|
||||
Notification *NotificationRequest
|
||||
}
|
||||
|
||||
// NotificationRequest 是一次可靠通知事件的最小输入。
|
||||
// 接收人是触发时冻结的业务员账号,投递期不再重新解析店铺业务员,避免向未来业务员补发。
|
||||
type NotificationRequest struct {
|
||||
RecipientAccountID uint
|
||||
ShopID uint
|
||||
TemplateData map[string]string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// AlertWriter 在同一事务内写入预警事实、可靠通知事件与审计。
|
||||
type AlertWriter interface {
|
||||
// SaveAlert 幂等创建预警;返回 false 表示唯一键冲突(视为已处理,不写事件与审计)。
|
||||
SaveAlert(ctx context.Context, candidate AlertCandidate) (bool, error)
|
||||
}
|
||||
|
||||
// ScanService 执行每日套餐真流量达量扫描。
|
||||
type ScanService struct {
|
||||
reader ScanReader
|
||||
writer AlertWriter
|
||||
logger *zap.Logger
|
||||
// now 可在验证时替换,默认使用系统时间。
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewScanService 创建套餐真流量达量扫描用例。
|
||||
func NewScanService(reader ScanReader, writer AlertWriter, logger *zap.Logger) *ScanService {
|
||||
return &ScanService{reader: reader, writer: writer, logger: logger, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// ScanResult 汇总一次扫描的可观察结果。
|
||||
type ScanResult struct {
|
||||
Assets int
|
||||
Hits int
|
||||
Created int
|
||||
Duplicates int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
// Run 执行一次可重跑扫描:按资产汇总真流量,按主套餐规则阈值判定,命中即原子落库。
|
||||
func (s *ScanService) Run(ctx context.Context) error {
|
||||
if s == nil || s.reader == nil || s.writer == nil {
|
||||
return errors.New(errors.CodeInternalError, "套餐真流量达量扫描用例未配置")
|
||||
}
|
||||
aggregates, err := s.reader.LoadAssetAggregates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := &ScanResult{Assets: len(aggregates)}
|
||||
if len(aggregates) == 0 {
|
||||
s.logScan(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]AssetKey, 0, len(aggregates))
|
||||
for _, aggregate := range aggregates {
|
||||
keys = append(keys, aggregate.Key)
|
||||
}
|
||||
mainUsages, err := s.reader.LoadMainUsages(ctx, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err := s.loadRulesForUsages(ctx, mainUsages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
facts, err := s.reader.LoadAssetFacts(ctx, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
triggeredAt := s.now()
|
||||
for _, aggregate := range aggregates {
|
||||
main, hasMain := mainUsages[aggregate.Key]
|
||||
if !hasMain {
|
||||
// 全是加油包、没有主套餐的资产没有阈值来源,直接跳过。
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
rule, hasRule := rules[main.PackageID]
|
||||
if !hasRule {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if aggregate.LimitMB <= 0 {
|
||||
// 汇总分母不是正数的资产不可判定,跳过而不是写入不可用的预警。
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
thresholdBasisPoints := packagetrafficalert.ThresholdBasisPoints(rule.ThresholdPercent)
|
||||
hit, ratioBasisPoints := packagetrafficalert.Decide(aggregate.UsedMB, aggregate.LimitMB, thresholdBasisPoints)
|
||||
if !hit {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
result.Hits++
|
||||
candidate := s.buildCandidate(aggregate, main, rule, ratioBasisPoints, facts[aggregate.Key], triggeredAt)
|
||||
created, saveErr := s.writer.SaveAlert(ctx, candidate)
|
||||
if saveErr != nil {
|
||||
s.logger.Error("套餐真流量达量预警写入失败",
|
||||
zap.String("asset_type", aggregate.Key.AssetType),
|
||||
zap.Uint("asset_id", aggregate.Key.AssetID),
|
||||
zap.Error(saveErr))
|
||||
return saveErr
|
||||
}
|
||||
if created {
|
||||
result.Created++
|
||||
} else {
|
||||
result.Duplicates++
|
||||
}
|
||||
}
|
||||
s.logScan(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRulesForUsages 批量读取主套餐对应的启用规则。
|
||||
func (s *ScanService) loadRulesForUsages(ctx context.Context, usages map[AssetKey]MainUsage) (map[uint]EnabledRule, error) {
|
||||
seen := make(map[uint]struct{}, len(usages))
|
||||
packageIDs := make([]uint, 0, len(usages))
|
||||
for _, usage := range usages {
|
||||
if usage.PackageID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[usage.PackageID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[usage.PackageID] = struct{}{}
|
||||
packageIDs = append(packageIDs, usage.PackageID)
|
||||
}
|
||||
if len(packageIDs) == 0 {
|
||||
return map[uint]EnabledRule{}, nil
|
||||
}
|
||||
sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] })
|
||||
return s.reader.LoadEnabledRules(ctx, packageIDs)
|
||||
}
|
||||
|
||||
// buildCandidate 组装唯一的资产级预警事实与可选通知请求。
|
||||
func (s *ScanService) buildCandidate(aggregate AssetAggregate, main MainUsage, rule EnabledRule,
|
||||
ratioBasisPoints int64, facts AssetFacts, triggeredAt time.Time) AlertCandidate {
|
||||
packageName := main.PackageName
|
||||
if packageName == "" {
|
||||
packageName = "套餐#" + strconv.FormatUint(uint64(main.PackageID), 10)
|
||||
}
|
||||
assetIdentifier := facts.AssetIdentifier
|
||||
if assetIdentifier == "" {
|
||||
// 回落值同步写入快照,保证快照、列表与通知正文一致。
|
||||
assetIdentifier = "资产#" + strconv.FormatUint(uint64(aggregate.Key.AssetID), 10)
|
||||
}
|
||||
alert := model.PackageTrafficAlert{
|
||||
PackageUsageID: main.PackageUsageID,
|
||||
PackageID: main.PackageID,
|
||||
RuleID: rule.ID,
|
||||
AssetType: aggregate.Key.AssetType,
|
||||
AssetID: aggregate.Key.AssetID,
|
||||
AssetIdentifierSnapshot: assetIdentifier,
|
||||
CardIdentifierSnapshot: facts.CardIdentifier,
|
||||
CounterpartIdentifierSnapshot: facts.CounterpartIdentifier,
|
||||
DeviceTypeSnapshot: facts.DeviceType,
|
||||
DeviceModelSnapshot: facts.DeviceModel,
|
||||
PackageNameSnapshot: packageName,
|
||||
UsedMBSnapshot: aggregate.UsedMB,
|
||||
LimitMBSnapshot: aggregate.LimitMB,
|
||||
UsagePercentSnapshot: packagetrafficalert.PercentFromBasisPoints(ratioBasisPoints),
|
||||
ThresholdPercentSnapshot: packagetrafficalert.NormalizeThresholdPercent(rule.ThresholdPercent),
|
||||
ExpiresAtSnapshot: main.ExpiresAt,
|
||||
TriggeredAt: triggeredAt,
|
||||
ShopIDSnapshot: facts.ShopID,
|
||||
ShopNameSnapshot: facts.ShopName,
|
||||
BusinessOwnerAccountIDSnapshot: facts.BusinessOwnerID,
|
||||
BusinessOwnerNameSnapshot: facts.BusinessOwnerName,
|
||||
}
|
||||
candidate := AlertCandidate{Alert: alert}
|
||||
if facts.BusinessOwnerID == nil || *facts.BusinessOwnerID == 0 {
|
||||
// 无有效业务员:只保存预警,不写通知事件,也不在未来补发。
|
||||
return candidate
|
||||
}
|
||||
candidate.Notification = &NotificationRequest{
|
||||
RecipientAccountID: *facts.BusinessOwnerID,
|
||||
ShopID: facts.ShopID,
|
||||
ExpiresAt: notificationExpiresAt(main.ExpiresAt, triggeredAt),
|
||||
TemplateData: map[string]string{
|
||||
"asset_identifier": assetIdentifier,
|
||||
"package_name": packageName,
|
||||
"usage_percent": formatPercent(packagetrafficalert.PercentFromBasisPoints(ratioBasisPoints)),
|
||||
"threshold_percent": formatPercent(alert.ThresholdPercentSnapshot),
|
||||
},
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
// notificationExpiresAt 计算站内通知的展示期结束时间。
|
||||
// 优先使用主套餐到期时间快照;快照为空时沿用既有默认展示期常量兜底,
|
||||
// 预警行的到期时间快照保持为空,不伪造业务到期时间。
|
||||
func notificationExpiresAt(snapshot *time.Time, triggeredAt time.Time) time.Time {
|
||||
if snapshot != nil {
|
||||
return snapshot.UTC()
|
||||
}
|
||||
return triggeredAt.AddDate(0, 0, constants.NotificationSystemDefaultDisplayDays).UTC()
|
||||
}
|
||||
|
||||
// formatPercent 把百分比格式化为最多两位小数、去掉无意义尾零的展示文本。
|
||||
func formatPercent(value float64) string {
|
||||
return strconv.FormatFloat(packagetrafficalert.NormalizeThresholdPercent(value), 'f', -1, 64)
|
||||
}
|
||||
|
||||
// logScan 输出一次扫描的结构化结果,供维护者按日志核对。
|
||||
func (s *ScanService) logScan(result *ScanResult) {
|
||||
if s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Info("套餐真流量达量扫描完成",
|
||||
zap.Int("assets", result.Assets),
|
||||
zap.Int("hits", result.Hits),
|
||||
zap.Int("created", result.Created),
|
||||
zap.Int("duplicates", result.Duplicates),
|
||||
zap.Int("skipped", result.Skipped))
|
||||
}
|
||||
|
||||
// EventIDFor 返回预警通知事件的稳定ID:内嵌主套餐使用记录与阈值快照(万分比)。
|
||||
func EventIDFor(packageUsageID uint, thresholdPercent float64) string {
|
||||
return fmt.Sprintf("%s:%d:%d", constants.PackageTrafficAlertEventIDPrefix, packageUsageID,
|
||||
packagetrafficalert.ThresholdBasisPoints(thresholdPercent))
|
||||
}
|
||||
62
internal/application/prioritypolling/event.go
Normal file
62
internal/application/prioritypolling/event.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Package prioritypolling 定义卡轮询优先队列的应用层契约。
|
||||
//
|
||||
// 本包只承载业务事实的形状与执行语义,不依赖 Fiber、GORM、Redis、Asynq 或具体外部 SDK;
|
||||
// Outbox 写入、消费者展开与执行接缝适配分别位于基础设施与任务层。
|
||||
package prioritypolling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PriorityRequestedEvent 是业务成功边界可靠请求「为该资源的卡建立优先轮询项」的事实。
|
||||
//
|
||||
// 载荷沿用既有业务观测事件的形状(资源类型 / 资源 ID / 资源 ID 列表),并在业务事务内冻结卡快照:
|
||||
// 独立卡为自身(ResourceIDs 为空),绑定设备的资产为绑定状态有效的全部在用卡(写入 ResourceIDs)。
|
||||
// TriggerType 取值与 pkg/constants.PollingPriorityTrigger* 一致;本轮要逐类型建项的任务类型集合
|
||||
// 由代码常量给出(pkg/constants.PollingPriorityTaskTypes),不由事件载荷决定,避免同一决策出现两个权威。
|
||||
// 来源订单与来源套餐使用记录只保存 ID,由消费者与读侧显式查询。
|
||||
type PriorityRequestedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID uint `json:"resource_id"`
|
||||
ResourceIDs []uint `json:"resource_ids,omitempty"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
SourceOrderID uint `json:"source_order_id,omitempty"`
|
||||
SourcePackageUsageID uint `json:"source_package_usage_id,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// PromptPublisher 把优先执行提示下发到既有轮询提示通道。
|
||||
//
|
||||
// 提示通道不是权威:丢失或缓存服务重启时库内活动优先项仍在,后续普通轮询执行按条件认领仍会执行,
|
||||
// 只退化为延迟一个普通轮询周期;调用方不得以提示的下发成功作为入队事实成立的条件。
|
||||
type PromptPublisher interface {
|
||||
EnqueuePriority(ctx context.Context, cardID uint, taskType string) error
|
||||
}
|
||||
|
||||
// PriorityEventWriter 在原业务事务中追加优先轮询请求事件。
|
||||
type PriorityEventWriter interface {
|
||||
AppendPriorityRequested(ctx context.Context, tx *gorm.DB, event PriorityRequestedEvent) error
|
||||
}
|
||||
|
||||
type pollingPriorityTriggerKey struct{}
|
||||
|
||||
// WithPollingPriorityTrigger 标记本次业务评估来自普通套餐轮询。
|
||||
//
|
||||
// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、保护期轮询、
|
||||
// 既有兼容入口等多条调用链复用;只有普通套餐轮询来源才允许追加优先轮询请求,
|
||||
// 否则会在观测序列、手动实名、保护期与退款等上下文凭空产生加急触发。
|
||||
func WithPollingPriorityTrigger(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, pollingPriorityTriggerKey{}, true)
|
||||
}
|
||||
|
||||
// IsPollingPriorityTrigger 判断当前业务评估是否来自普通套餐轮询。
|
||||
func IsPollingPriorityTrigger(ctx context.Context) bool {
|
||||
value, _ := ctx.Value(pollingPriorityTriggerKey{}).(bool)
|
||||
return value
|
||||
}
|
||||
@@ -4,9 +4,12 @@ package refundapproval
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
@@ -17,10 +20,15 @@ import (
|
||||
)
|
||||
|
||||
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
|
||||
//
|
||||
// 本次提交的审批尝试记录由本用例在退款申请落库后于同一事务内构造,调用方只提供
|
||||
// 需要按冻结商户派生、应用层无法自行生成的渠道退款请求号。
|
||||
type CreateCommand struct {
|
||||
Refund *model.RefundRequest
|
||||
Order *model.Order
|
||||
SubmitterAccountID uint
|
||||
// ChannelRefundRequestNo 是原路退款本次尝试冻结的渠道退款请求号;非原路方式为空。
|
||||
ChannelRefundRequestNo string
|
||||
}
|
||||
|
||||
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
|
||||
@@ -29,6 +37,12 @@ type ApplicationAudit struct {
|
||||
Order *model.Order
|
||||
Approval *model.ApprovalInstance
|
||||
Submitter *model.Account
|
||||
// Attempt 非空时表示本次写入新增了一条审批尝试记录。
|
||||
Attempt *model.RefundRequestAttempt
|
||||
// Action 与 EventID 为空时按「首次提交」写入;重提时由调用方显式指定,
|
||||
// 使同一次重提的审计事件在该尝试上保持幂等。
|
||||
Action string
|
||||
EventID string
|
||||
}
|
||||
|
||||
// AuditWriter 接收退款申请事务内审计事实。
|
||||
@@ -39,11 +53,16 @@ type AuditWriter interface {
|
||||
// CreateResult 返回原子保存后的退款申请和初始审批状态。
|
||||
type CreateResult struct {
|
||||
Refund *model.RefundRequest
|
||||
Attempt *model.RefundRequestAttempt
|
||||
SubmitterName string
|
||||
ApprovalStatus int
|
||||
}
|
||||
|
||||
// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。
|
||||
// CreationService 原子创建退款申请、审批尝试记录、通用审批实例和提交 Outbox。
|
||||
//
|
||||
// 每次提交或重提新增一条不可变审批尝试记录,并以尝试记录主键作为通用审批业务标识,
|
||||
// 使同一退款单的每次提交各自持有独立审批实例;退款单只保存最新尝试与最新实例引用用于展示,
|
||||
// 其既有 approval_instance_id 语义与唯一约束保持不变。
|
||||
type CreationService struct {
|
||||
db *gorm.DB
|
||||
approval approvalapp.Port
|
||||
@@ -55,8 +74,8 @@ func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWrite
|
||||
return &CreationService{db: db, approval: approval, audit: audit}
|
||||
}
|
||||
|
||||
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
|
||||
// TriggerHistorical 为历史待审批退款补发一次企业微信审批。
|
||||
// 历史申请尚未接入尝试模式,因此本次补发同时建立首条尝试记录并把业务标识切换到该记录。
|
||||
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) (*CreateResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
@@ -91,12 +110,8 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(&refund, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var approvalStatus int
|
||||
var result *CreateResult
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, refundID).Error; err != nil {
|
||||
@@ -113,47 +128,66 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
|
||||
if err := tx.WithContext(ctx).First(¤tOrder, current.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||||
}
|
||||
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, ¤tOrder, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.SubmittedByAccountID = current.Creator
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||||
}
|
||||
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||||
BusinessID: current.ID, SubmitterAccountID: current.Creator,
|
||||
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
|
||||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||||
CorrelationID: current.RefundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND approval_instance_id IS NULL", current.ID, model.RefundStatusPending).
|
||||
Update("approval_instance_id", reference.InstanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
|
||||
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
|
||||
if err := attachRefundFirstInstance(ctx, tx, ¤t, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
current.ApprovalInstanceID = &reference.InstanceID
|
||||
if err := updateRefundLatest(ctx, tx, ¤t, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refund = current
|
||||
order = currentOrder
|
||||
approvalStatus = reference.Status
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
|
||||
}
|
||||
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||||
Refund: ¤t, Order: ¤tOrder, Approval: &instance, Submitter: account,
|
||||
})
|
||||
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||||
Refund: ¤t, Order: ¤tOrder, Approval: &instance, Submitter: account, Attempt: attempt,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
result = &CreateResult{Refund: &refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CreateResult{Refund: &refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实、审批尝试事实和审批事实。
|
||||
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
if command.Refund == nil || command.Order == nil || command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
|
||||
if command.Refund == nil || command.Order == nil ||
|
||||
command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
|
||||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
@@ -173,54 +207,214 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
|
||||
return nil, err
|
||||
}
|
||||
var approvalStatus int
|
||||
var attempt *model.RefundRequestAttempt
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(command.Refund.OrderID)).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款订单申请边界失败")
|
||||
}
|
||||
var activeCount int64
|
||||
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("order_id = ? AND status IN ?", command.Refund.OrderID, []int{model.RefundStatusPending, model.RefundStatusApproved}).
|
||||
Where("order_id = ? AND status IN ?", command.Refund.OrderID, model.RefundActiveStatuses()).
|
||||
Count(&activeCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败")
|
||||
}
|
||||
if activeCount > 0 {
|
||||
return errors.New(errors.CodeConflict, "该订单已存在退款申请")
|
||||
return errors.New(errors.CodeConflict, "该订单已存在活动退款申请")
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
|
||||
}
|
||||
attempt, err = buildAttempt(ctx, tx, command.Refund, command.Order, command.ChannelRefundRequestNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||||
BusinessID: command.Refund.ID, SubmitterAccountID: command.SubmitterAccountID,
|
||||
BusinessID: attempt.ID, SubmitterAccountID: command.SubmitterAccountID,
|
||||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||||
CorrelationID: command.Refund.RefundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", command.Refund.ID).
|
||||
Update("approval_instance_id", reference.InstanceID)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败")
|
||||
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款审批实例关联已变化")
|
||||
if err := attachRefundFirstInstance(ctx, tx, command.Refund, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateRefundLatest(ctx, tx, command.Refund, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
command.Refund.ApprovalInstanceID = &reference.InstanceID
|
||||
approvalStatus = reference.Status
|
||||
var approval model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).First(&approval, reference.InstanceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
|
||||
}
|
||||
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||||
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account,
|
||||
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account, Attempt: attempt,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
|
||||
return &CreateResult{Refund: command.Refund, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil
|
||||
}
|
||||
|
||||
// ResubmitCommand 描述重提时的材料变更。
|
||||
// Refund 携带本次重提后的新值(方式、金额、原因、客户收款信息、凭证与冻结实收);
|
||||
// 本次新增的不可变审批尝试记录由本用例在同一事务内构造。
|
||||
type ResubmitCommand struct {
|
||||
Refund *model.RefundRequest
|
||||
// ChannelRefundRequestNo 是原路退款本次重提冻结的渠道退款请求号;非原路方式为空。
|
||||
ChannelRefundRequestNo string
|
||||
}
|
||||
|
||||
// Resubmit 修改并重提未成功退款申请,新增审批尝试记录与新的企业微信审批实例。
|
||||
//
|
||||
// 仅已拒绝、已退回或原路退款失败且无审批异常的申请可重提;已成功、待审批、原路处理中或
|
||||
// 存在审批异常的申请返回状态冲突。每次重提新增不可变尝试记录与独立审批实例,
|
||||
// 历史材料与审批结果不被覆盖,退款单只更新为最新尝试引用。
|
||||
func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command ResubmitCommand) (*CreateResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
if refundID == 0 || command.Refund == nil || command.Refund.Creator == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "重提退款申请参数不完整")
|
||||
}
|
||||
account, err := s.loadSubmitter(ctx, command.Refund.Creator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var created *CreateResult
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(refundID)).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请重提边界失败")
|
||||
}
|
||||
var current model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, refundID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||||
}
|
||||
if !isResubmittable(¤t) {
|
||||
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
|
||||
}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).First(&order, current.OrderID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||||
}
|
||||
|
||||
// 材料已在调用方校验,这里把新值并入当前事实后冻结快照。
|
||||
current.Method = command.Refund.Method
|
||||
current.RequestedRefundAmount = command.Refund.RequestedRefundAmount
|
||||
current.FrozenActualReceivedAmount = command.Refund.FrozenActualReceivedAmount
|
||||
current.RefundReason = command.Refund.RefundReason
|
||||
current.RefundVoucherKey = command.Refund.RefundVoucherKey
|
||||
current.CustomerAccountInfo = command.Refund.CustomerAccountInfo
|
||||
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, &order, command.ChannelRefundRequestNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.SubmittedByAccountID = current.Creator
|
||||
|
||||
preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{
|
||||
BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: current.Creator,
|
||||
CorrelationID: current.RefundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同一事务内回写材料、回到待审批并创建新的审批实例。
|
||||
updates := map[string]any{
|
||||
"status": model.RefundStatusPending,
|
||||
"method": current.Method,
|
||||
"requested_refund_amount": current.RequestedRefundAmount,
|
||||
"frozen_actual_received_amount": current.FrozenActualReceivedAmount,
|
||||
"refund_reason": current.RefundReason,
|
||||
"refund_voucher_key": current.RefundVoucherKey,
|
||||
"customer_account_info": current.CustomerAccountInfo,
|
||||
"failure_reason": "",
|
||||
"failure_message": "",
|
||||
"channel_refund_status": constants.RefundChannelStatusNone,
|
||||
"reject_reason": "",
|
||||
"processor_id": nil,
|
||||
"processed_at": nil,
|
||||
"updater": current.Creator,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status IN ?", refundID, model.RefundResubmittableStatuses()).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款申请重提材料失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款申请状态已变化")
|
||||
}
|
||||
current.Status = model.RefundStatusPending
|
||||
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||||
}
|
||||
reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{
|
||||
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund,
|
||||
BusinessID: attempt.ID, SubmitterAccountID: current.Creator,
|
||||
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot,
|
||||
CorrelationID: current.RefundNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := attachAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateRefundLatest(ctx, tx, ¤t, attempt, reference.InstanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
|
||||
}
|
||||
if err := s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
|
||||
Refund: ¤t, Order: &order, Approval: &instance, Submitter: account, Attempt: attempt,
|
||||
Action: constants.AuditActionRefundResubmitted,
|
||||
EventID: "refund:" + strconv.FormatUint(uint64(refundID), 10) + ":attempt:" + strconv.FormatUint(uint64(attempt.ID), 10),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
created = &CreateResult{Refund: ¤t, Attempt: attempt, SubmitterName: account.Username, ApprovalStatus: reference.Status}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// isResubmittable 判断退款申请是否处于可重提状态且不存在审批异常。
|
||||
// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由人工线下处理。
|
||||
func isResubmittable(refund *model.RefundRequest) bool {
|
||||
if refund == nil || refund.AnomalyFlag != 0 {
|
||||
return false
|
||||
}
|
||||
for _, status := range model.RefundResubmittableStatuses() {
|
||||
if refund.Status == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) {
|
||||
@@ -234,6 +428,123 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、客户收款信息与套餐使用快照。
|
||||
// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。
|
||||
// package_usage_snapshot 必须是非空 JSON 对象,因此快照只能在这里按订单事实生成,不能由调用方预置。
|
||||
func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, channelRefundRequestNo string) (*model.RefundRequestAttempt, error) {
|
||||
attemptNo, err := nextAttemptNo(ctx, tx, refund.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot, err := packageUsageSnapshot(ctx, tx, refund, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.RefundRequestAttempt{
|
||||
RefundID: refund.ID,
|
||||
AttemptNo: attemptNo,
|
||||
Method: refund.Method,
|
||||
RefundAmount: refund.RequestedRefundAmount,
|
||||
FrozenActualReceivedAmount: refund.FrozenActualReceivedAmount,
|
||||
RefundReason: refund.RefundReason,
|
||||
CustomerAccountInfo: refund.CustomerAccountInfo,
|
||||
CustomerVoucherKeys: refund.RefundVoucherKey,
|
||||
PackageUsageSnapshot: snapshot,
|
||||
ChannelRefundRequestNo: strings.TrimSpace(channelRefundRequestNo),
|
||||
SubmittedByAccountID: refund.Creator,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// nextAttemptNo 返回该退款申请的下一条审批尝试序号;退款申请行已加锁,序号在同一事务内唯一。
|
||||
func nextAttemptNo(ctx context.Context, tx *gorm.DB, refundID uint) (int, error) {
|
||||
var row struct {
|
||||
MaxAttemptNo int
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
|
||||
Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no").
|
||||
Where("refund_id = ?", refundID).Scan(&row).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试序号失败")
|
||||
}
|
||||
return row.MaxAttemptNo + 1, nil
|
||||
}
|
||||
|
||||
// packageUsageSnapshot 冻结本次申请关联的套餐使用情况,作为企业微信审批判断材料。
|
||||
// 本期退款不按套餐已用流量计算金额,因此该快照只作审批与追溯材料,不参与金额校验。
|
||||
func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (datatypes.JSON, error) {
|
||||
snapshot := map[string]any{
|
||||
"order_type": order.OrderType,
|
||||
"asset_identifier": order.AssetIdentifier,
|
||||
}
|
||||
if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 {
|
||||
var usage model.PackageUsage
|
||||
if err := tx.WithContext(ctx).First(&usage, *refund.PackageUsageID).Error; err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐使用记录失败")
|
||||
}
|
||||
} else {
|
||||
snapshot["package_usage"] = map[string]any{
|
||||
"id": usage.ID, "package_id": usage.PackageID, "package_name": usage.PackageName,
|
||||
"usage_type": usage.UsageType, "status": usage.Status,
|
||||
"data_limit_mb": usage.DataLimitMB, "data_usage_mb": usage.DataUsageMB,
|
||||
"activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded, err := sonic.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "编码退款套餐使用快照失败")
|
||||
}
|
||||
return datatypes.JSON(encoded), nil
|
||||
}
|
||||
|
||||
// attachAttemptInstance 把审批实例 ID 回写到本次审批尝试记录,写入一次后不可修改。
|
||||
func attachAttemptInstance(ctx context.Context, tx *gorm.DB, attempt *model.RefundRequestAttempt, instanceID uint) error {
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
|
||||
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
|
||||
}
|
||||
|
||||
// attachRefundFirstInstance 把审批实例回写到退款申请的首次接入引用。
|
||||
// 既有 approval_instance_id 保持「首次接入企业微信审批的实例」语义:条件更新在引用为空时才写入,
|
||||
// 因此重提只新增尝试引用,不会改写首次接入事实,也不会破坏其部分唯一索引。
|
||||
func attachRefundFirstInstance(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, instanceID uint) error {
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND approval_instance_id IS NULL", refund.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, "退款申请首次审批实例已变化")
|
||||
}
|
||||
refund.ApprovalInstanceID = &instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateRefundLatest 更新退款申请的最新审批尝试与最新审批实例引用,仅用于展示。
|
||||
// 既有 approval_instance_id 由 attachRefundFirstInstance 单独回写,保持「首次接入企业微信审批的实例」语义不变。
|
||||
func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, instanceID uint) error {
|
||||
updates := map[string]any{
|
||||
"latest_attempt_id": attempt.ID,
|
||||
"latest_approval_instance_id": instanceID,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(&model.RefundRequest{}).Where("id = ?", refund.ID).Updates(updates).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新退款申请最新审批引用失败")
|
||||
}
|
||||
refund.LatestAttemptID = attempt.ID
|
||||
refund.LatestApprovalInstanceID = instanceID
|
||||
return nil
|
||||
}
|
||||
|
||||
func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
|
||||
@@ -247,7 +558,7 @@ func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byt
|
||||
constants.ApprovalFieldOrderNo: refund.OrderNo,
|
||||
constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier,
|
||||
constants.ApprovalFieldAssetType: refund.OrderType,
|
||||
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.ActualReceivedAmount),
|
||||
constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.FrozenActualReceivedAmount),
|
||||
constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount),
|
||||
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
|
||||
constants.ApprovalFieldRefundReason: refund.RefundReason,
|
||||
|
||||
101
internal/application/refundapproval/resolve.go
Normal file
101
internal/application/refundapproval/resolve.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package refundapproval
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ResolveRefundInTx 按审批业务标识解析出退款申请与本次审批尝试记录。
|
||||
//
|
||||
// 退款审批的业务标识在审批尝试模式下取尝试记录主键;本能力上线前的存量申请取退款申请主键。
|
||||
// 尝试记录与退款申请来自两个独立序列,必然存在同值,因此不能只按 businessID 判定归属:
|
||||
// 必须同时匹配 approval_instance_id,才能唯一确定是尝试记录还是退款申请。
|
||||
//
|
||||
// 解析顺序固定为「尝试记录优先、退款申请兜底」:
|
||||
// 1. tb_refund_request_attempt 中 id = businessID 且 approval_instance_id = instanceID;
|
||||
// 2. tb_refund_request 中 id = businessID 且 approval_instance_id = instanceID;
|
||||
// 3. 两者均不匹配返回稳定冲突错误,绝不回落到任一候选业务单。
|
||||
//
|
||||
// attempt 在存量兼容路径下为 nil。
|
||||
func ResolveRefundInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
|
||||
if tx == nil || businessID == 0 || instanceID == 0 {
|
||||
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效")
|
||||
}
|
||||
|
||||
var attempt model.RefundRequestAttempt
|
||||
err := tx.WithContext(ctx).
|
||||
Where("id = ? AND approval_instance_id = ?", businessID, instanceID).
|
||||
First(&attempt).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
var refund model.RefundRequest
|
||||
if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在")
|
||||
}
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
|
||||
}
|
||||
return &refund, &attempt, nil
|
||||
case err != gorm.ErrRecordNotFound:
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批尝试记录失败")
|
||||
}
|
||||
|
||||
var refund model.RefundRequest
|
||||
err = tx.WithContext(ctx).
|
||||
Where("id = ? AND approval_instance_id = ?", businessID, instanceID).
|
||||
First(&refund).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
return &refund, nil, nil
|
||||
case err == gorm.ErrRecordNotFound:
|
||||
return nil, nil, errors.New(errors.CodeConflict, "退款申请的关联审批实例不一致")
|
||||
default:
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveRefundIDInTx 只解析退款申请标识,供审计资源构造与查询关联使用。
|
||||
func ResolveRefundIDInTx(ctx context.Context, tx *gorm.DB, businessID, instanceID uint) (uint, error) {
|
||||
refund, _, err := ResolveRefundInTx(ctx, tx, businessID, instanceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return refund.ID, nil
|
||||
}
|
||||
|
||||
// ResolveRefundForApprovalRequestInTx 解析「审批申请已建立但审批实例尚未回写到业务记录」时刻的业务归属。
|
||||
//
|
||||
// 通用审批创建用例在同一事务内先写审批实例并写审批申请审计,业务侧随后才把实例 ID 回写到
|
||||
// 审批尝试记录。该审计时刻尝试记录已存在但其 approval_instance_id 仍为空,因此按实例一致性
|
||||
// 校验的常规解析必然不命中。本函数只承认这一种在途形态:
|
||||
//
|
||||
// attempt.id = businessID AND attempt.approval_instance_id IS NULL
|
||||
//
|
||||
// 其余情况一律返回不存在,由调用方按常规解析的错误失败关闭,不得放宽为任意未回写记录。
|
||||
func ResolveRefundForApprovalRequestInTx(ctx context.Context, tx *gorm.DB, businessID uint) (*model.RefundRequest, *model.RefundRequestAttempt, error) {
|
||||
if tx == nil || businessID == 0 {
|
||||
return nil, nil, errors.New(errors.CodeInvalidParam, "退款审批业务标识参数无效")
|
||||
}
|
||||
var attempt model.RefundRequestAttempt
|
||||
err := tx.WithContext(ctx).
|
||||
Where("id = ? AND approval_instance_id IS NULL", businessID).
|
||||
First(&attempt).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, errors.New(errors.CodeNotFound, "退款审批尝试记录未回写审批实例")
|
||||
}
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询在途退款审批尝试记录失败")
|
||||
}
|
||||
var refund model.RefundRequest
|
||||
if err := tx.WithContext(ctx).First(&refund, attempt.RefundID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, errors.New(errors.CodeConflict, "退款审批尝试记录所属退款申请不存在")
|
||||
}
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批关联退款申请失败")
|
||||
}
|
||||
return &refund, &attempt, nil
|
||||
}
|
||||
32
internal/application/refundchannel/audit.go
Normal file
32
internal/application/refundchannel/audit.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// AuditWriter 写退款渠道调用与恢复的可审计事实。
|
||||
// 实现必须与业务更新在同一事务内写入,且摘要不得包含凭证或渠道报文原文。
|
||||
type AuditWriter interface {
|
||||
WriteRefundChannelResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, action string, message string) error
|
||||
}
|
||||
|
||||
// CompletionNotifier 在渠道明确退款成功时补写退款完成通知事实。
|
||||
// 通知载荷由退款能力拥有,本包只负责在正确的时点与事务内触发。
|
||||
type CompletionNotifier interface {
|
||||
AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error
|
||||
}
|
||||
|
||||
// appErrorCode 读取应用错误码;非应用错误返回 0。
|
||||
func appErrorCode(err error) int {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr.Code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
75
internal/application/refundchannel/event.go
Normal file
75
internal/application/refundchannel/event.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
|
||||
)
|
||||
|
||||
// EventRefundChannelRefund 是退款进入渠道原路处理中后的执行事件。
|
||||
const EventRefundChannelRefund = "refund.channel.refund.requested"
|
||||
|
||||
// refundChannelPayloadVersion 是渠道原路退款事件的载荷版本。
|
||||
const refundChannelPayloadVersion = 1
|
||||
|
||||
// Payload 是渠道原路退款事件的载荷。
|
||||
type Payload struct {
|
||||
RefundID uint `json:"refund_id"`
|
||||
OrderID uint `json:"order_id"`
|
||||
}
|
||||
|
||||
// AppendRefundChannelRefund 在企微通过事务内幂等写入渠道原路退款执行事件。
|
||||
// 同一退款申请使用稳定事件 ID,重复投递不会重复创建事实。
|
||||
func AppendRefundChannelRefund(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, refundID, orderID uint) error {
|
||||
if repository == nil {
|
||||
return gorm.ErrInvalidDB
|
||||
}
|
||||
value := strconv.FormatUint(uint64(refundID), 10)
|
||||
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||||
EventID: outboxid.Stable(EventRefundChannelRefund+":", value),
|
||||
EventType: EventRefundChannelRefund,
|
||||
PayloadVersion: refundChannelPayloadVersion,
|
||||
AggregateType: "refund", AggregateID: value,
|
||||
ResourceType: "refund", ResourceID: value,
|
||||
BusinessKey: EventRefundChannelRefund + ":" + value,
|
||||
Payload: Payload{RefundID: refundID, OrderID: orderID},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Consumer 把渠道原路退款事件转成一次性资金动作。
|
||||
type Consumer struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewConsumer 创建渠道原路退款事件消费者。
|
||||
func NewConsumer(service *Service) *Consumer {
|
||||
return &Consumer{service: service}
|
||||
}
|
||||
|
||||
// Consume 幂等执行渠道原路退款;重复投递由退款申请状态与渠道请求号共同兜住。
|
||||
func (c *Consumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
var payload Payload
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return outbox.Permanent(err)
|
||||
}
|
||||
if envelope.EventType != EventRefundChannelRefund ||
|
||||
envelope.PayloadVersion != refundChannelPayloadVersion || payload.RefundID == 0 {
|
||||
return outbox.Permanent(gorm.ErrInvalidData)
|
||||
}
|
||||
if c == nil || c.service == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款执行能力未配置")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID})
|
||||
return c.service.Execute(ctx, payload.RefundID)
|
||||
}
|
||||
|
||||
// 编译期断言:渠道原路退款消费者满足公共 Outbox 的消费边界。
|
||||
var _ outbox.EventConsumer = (*Consumer)(nil)
|
||||
71
internal/application/refundchannel/number.go
Normal file
71
internal/application/refundchannel/number.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 渠道退款请求号生成规则参数。
|
||||
const (
|
||||
// channelRefundRequestNoAlphabet 随机段字符集:大写字母与数字。
|
||||
channelRefundRequestNoAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
// channelRefundRequestNoRandomLen 随机段长度,取渠道规则上限 18 位。
|
||||
channelRefundRequestNoRandomLen = 18
|
||||
// channelRefundRequestNoLength 请求号总长:前缀 4 + 日期 8 + 随机段 18。
|
||||
channelRefundRequestNoLength = 30
|
||||
// channelRefundRequestNoPrefixLen 前缀固定长度,不足左侧补 0,超过取前 4 位。
|
||||
channelRefundRequestNoPrefixLen = 4
|
||||
)
|
||||
|
||||
// shanghaiLocation 上海时区(东八区),用于按渠道规则生成日期段。
|
||||
var shanghaiLocation = time.FixedZone("CST", 8*3600)
|
||||
|
||||
// BuildChannelRefundRequestNo 按三渠道共性规则生成渠道退款请求号。
|
||||
//
|
||||
// 规则与富友流水号完全一致(本包不引入渠道 SDK,因此在此独立实现同一规则):
|
||||
// 前缀规整为 4 位(不足左侧补 0,超过取前 4 位)+ 上海时区日期 yyyyMMdd + 18 位大写字母
|
||||
// 数字随机段,总长 30。prefix 由调用方按冻结服务商类型传入:富友传机构码,其余渠道传
|
||||
// 商户标识数字段。生成结果一经写入审批尝试记录即不可变,作为渠道幂等标识复用。
|
||||
func BuildChannelRefundRequestNo(prefix string, now time.Time) string {
|
||||
var builder strings.Builder
|
||||
builder.Grow(channelRefundRequestNoLength)
|
||||
builder.WriteString(normalizeChannelRefundPrefix(prefix))
|
||||
builder.WriteString(now.In(shanghaiLocation).Format("20060102"))
|
||||
|
||||
buffer := make([]byte, channelRefundRequestNoRandomLen)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
// 随机源不可用时退回时间派生的同字符集随机段,保证结果仍满足格式与长度约束。
|
||||
builder.WriteString(fallbackRandomSegment(now))
|
||||
return builder.String()
|
||||
}
|
||||
for _, value := range buffer {
|
||||
builder.WriteByte(channelRefundRequestNoAlphabet[int(value)%len(channelRefundRequestNoAlphabet)])
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// normalizeChannelRefundPrefix 将前缀规整为 4 位:不足左侧补 0,超过取前 4 位。
|
||||
func normalizeChannelRefundPrefix(prefix string) string {
|
||||
normalized := strings.TrimSpace(prefix)
|
||||
if len(normalized) >= channelRefundRequestNoPrefixLen {
|
||||
return normalized[:channelRefundRequestNoPrefixLen]
|
||||
}
|
||||
return strings.Repeat("0", channelRefundRequestNoPrefixLen-len(normalized)) + normalized
|
||||
}
|
||||
|
||||
// fallbackRandomSegment 生成 18 位大写字母数字随机段,仅用于随机源不可用时的兜底。
|
||||
func fallbackRandomSegment(now time.Time) string {
|
||||
segment := strings.ToUpper(strconv.FormatInt(now.UnixNano(), 36))
|
||||
segment = strings.Map(func(char rune) rune {
|
||||
if (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') {
|
||||
return char
|
||||
}
|
||||
return 'X'
|
||||
}, segment)
|
||||
if len(segment) >= channelRefundRequestNoRandomLen {
|
||||
return segment[:channelRefundRequestNoRandomLen]
|
||||
}
|
||||
return segment + strings.Repeat("0", channelRefundRequestNoRandomLen-len(segment))
|
||||
}
|
||||
193
internal/application/refundchannel/recover.go
Normal file
193
internal/application/refundchannel/recover.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// Stats 是一次恢复扫描的可观察结果。
|
||||
//
|
||||
// Scanned 为本次扫描到的申请数;Confirmed 为回填为渠道明确成功的申请数;
|
||||
// Failed 为回填为渠道失败终态的申请数(含渠道明确失败,以及富友与微信 v2 的本地查询窗口
|
||||
// 超期后终止本次渠道执行);Pending 为结果仍未知、等待下次扫描的申请数(含查询调用失败);
|
||||
// Skipped 为本地事实不可用或已被并发推进而未由本次扫描改动状态的申请数。
|
||||
type Stats struct{ Scanned, Confirmed, Failed, Pending, Skipped int }
|
||||
|
||||
// ProcessBatch 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。
|
||||
func (s *Service) ProcessBatch(ctx context.Context) (Stats, error) {
|
||||
stats := Stats{}
|
||||
if err := s.requireReady(); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
var refunds []model.RefundRequest
|
||||
if err := s.db.WithContext(ctx).
|
||||
// 已置异常标记的申请转人工处理,必须退出轮询:否则每次扫描都会重复查询同一笔未知结果。
|
||||
Where("deleted_at IS NULL AND status = ? AND channel_refund_status = ? AND channel_refund_request_no <> ? AND anomaly_flag = ?",
|
||||
model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing, "", 0).
|
||||
Order("id ASC").Limit(recoveryBatchSize).Find(&refunds).Error; err != nil {
|
||||
return stats, errors.Wrap(errors.CodeDatabaseError, err, "扫描原路处理中的退款申请失败")
|
||||
}
|
||||
stats.Scanned = len(refunds)
|
||||
if len(refunds) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
payments, err := s.loadPaidPayments(ctx, refunds)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var firstErr error
|
||||
for index := range refunds {
|
||||
if err := s.recoverOne(ctx, &refunds[index], payments, now, &stats); err != nil {
|
||||
stats.Skipped++
|
||||
s.logger.Warn("渠道原路退款恢复单条处理失败",
|
||||
zap.Uint("refund_id", refunds[index].ID), zap.Error(err))
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats, firstErr
|
||||
}
|
||||
|
||||
// recoverOne 只查询该申请对应的渠道退款状态并按结果回填,不发起任何资金动作。
|
||||
func (s *Service) recoverOne(ctx context.Context, refund *model.RefundRequest, payments map[uint]*model.Payment, now time.Time, stats *Stats) error {
|
||||
target, failureReason, _, err := s.buildTarget(ctx, refund, nil, payments[refund.OrderID])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if failureReason != "" {
|
||||
// 恢复阶段绝不改写为明确失败:渠道可能已受理资金动作,只能留待人工与环境修复。
|
||||
stats.Pending++
|
||||
s.logger.Warn("渠道原路退款恢复缺少本地事实,跳过本次查询",
|
||||
zap.Uint("refund_id", refund.ID), zap.String("failure_reason", failureReason))
|
||||
return nil
|
||||
}
|
||||
if window, reason := queryWindowPolicy(target.ProviderType); window > 0 && now.Sub(refundWindowStart(refund)) > window {
|
||||
return s.flagQueryWindowExpired(ctx, refund, reason, now, stats)
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
|
||||
defer cancel()
|
||||
result, callErr := s.refunder.Query(callCtx, target)
|
||||
if callErr != nil {
|
||||
// 查询失败不能推断渠道结果,保持原路处理中等待下次扫描。
|
||||
stats.Pending++
|
||||
return nil
|
||||
}
|
||||
applied, err := s.writeback(ctx, refund, target, result, constants.AuditActionRefundChannelRecovered, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !applied {
|
||||
stats.Skipped++
|
||||
return nil
|
||||
}
|
||||
switch result.State {
|
||||
case StateSuccess:
|
||||
stats.Confirmed++
|
||||
case StateFailed:
|
||||
stats.Failed++
|
||||
default:
|
||||
stats.Pending++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryWindowPolicy 返回该服务商类型的本地查询窗口与其超期原因。
|
||||
// 返回 0 表示不设本地窗口,持续查询直到渠道给出终态。
|
||||
//
|
||||
// - 富友:退款查询接口只支持 3 日内的退款交易,超期后渠道侧已无法查询,属渠道硬约束;
|
||||
// - 微信 v2:受理响应不含退款状态、渠道侧无查询时限,此处按本地阈值放弃轮询并转人工,
|
||||
// 避免一笔未知结果被无限重试。
|
||||
func queryWindowPolicy(providerType string) (time.Duration, string) {
|
||||
switch providerType {
|
||||
case model.ProviderTypeFuiou:
|
||||
return fuiouQueryWindow, anomalyReasonFuiouQueryWindow
|
||||
case model.ProviderTypeWechatV2:
|
||||
return wechatV2QueryWindow, anomalyReasonWechatV2QueryWindow
|
||||
default:
|
||||
return 0, ""
|
||||
}
|
||||
}
|
||||
|
||||
// flagQueryWindowExpired 在本地查询窗口超期且结果仍未知时终止本次渠道执行并转人工处理。
|
||||
//
|
||||
// 生效后果:退款申请转「原路退款失败」、渠道退款状态转「已失败」、写入稳定的
|
||||
// timeout_unknown 分类与异常标记,并写一次审计;重复扫描不重复写入。
|
||||
// 「结果未确认」这一性质由 failure_reason 承载(它不是明确失败,因此不进入后续回溯判定),
|
||||
// 而 status 只表达该尝试的渠道路径已终止。
|
||||
//
|
||||
// 为何不放行自动重提:本次渠道请求可能已被受理但结果未知,放行重提会以新的请求号再次
|
||||
// 提交资金动作,存在重复退款风险。因此保留异常标记,由人工先向渠道核对再决定处置。
|
||||
func (s *Service) flagQueryWindowExpired(ctx context.Context, refund *model.RefundRequest, reason string, now time.Time, stats *Stats) error {
|
||||
stats.Failed++
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND channel_refund_status = ? AND anomaly_flag = 0",
|
||||
refund.ID, model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing).
|
||||
// UpdateColumns 不隐式推进 updated_at:窗口起算点必须保留在进入原路处理中的时刻,
|
||||
// 否则置标记会把窗口重置,下一轮扫描将重新查询同一笔未知结果。
|
||||
UpdateColumns(map[string]any{
|
||||
"status": model.RefundStatusChannelFailed,
|
||||
"channel_refund_status": constants.RefundChannelStatusFailed,
|
||||
"failure_reason": constants.RefundFailureTimeoutUnknown,
|
||||
"anomaly_flag": 1,
|
||||
"anomaly_reason": reason,
|
||||
})
|
||||
if updated.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "标记退款查询窗口超期失败")
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return nil
|
||||
}
|
||||
refund.Status = model.RefundStatusChannelFailed
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
|
||||
refund.FailureReason = constants.RefundFailureTimeoutUnknown
|
||||
refund.AnomalyFlag = 1
|
||||
refund.AnomalyReason = reason
|
||||
return s.audit.WriteRefundChannelResult(ctx, tx, refund,
|
||||
constants.AuditActionRefundAnomalyFlagged, reason+",结果未知,已终止渠道执行并转人工核对")
|
||||
})
|
||||
if err != nil {
|
||||
if appErrorCode(err) != 0 {
|
||||
return err
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "标记退款查询窗口超期失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refundWindowStart 返回查询窗口的起算时点:渠道明确成功时间优先,否则取最后一次实质性状态变更时间。
|
||||
// 结果未知的回写不会推进 updated_at,因此窗口始终从进入原路处理中的时点起算。
|
||||
func refundWindowStart(refund *model.RefundRequest) time.Time {
|
||||
if refund.ChannelRefundedAt != nil {
|
||||
return refund.ChannelRefundedAt.UTC()
|
||||
}
|
||||
return refund.UpdatedAt.UTC()
|
||||
}
|
||||
|
||||
// loadPaidPayments 批量读取该批订单最近一笔已支付的套餐支付单。
|
||||
func (s *Service) loadPaidPayments(ctx context.Context, refunds []model.RefundRequest) (map[uint]*model.Payment, error) {
|
||||
orderIDs := make([]uint, 0, len(refunds))
|
||||
for index := range refunds {
|
||||
orderIDs = append(orderIDs, refunds[index].OrderID)
|
||||
}
|
||||
var payments []model.Payment
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("order_id IN ? AND order_type = ? AND status = ?", orderIDs, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
|
||||
Order("id ASC").Find(&payments).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量读取原支付单失败")
|
||||
}
|
||||
latest := make(map[uint]*model.Payment, len(payments))
|
||||
for index := range payments {
|
||||
latest[payments[index].OrderID] = &payments[index]
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
701
internal/application/refundchannel/service.go
Normal file
701
internal/application/refundchannel/service.go
Normal file
@@ -0,0 +1,701 @@
|
||||
// Package refundchannel 执行与恢复渠道原路退款。
|
||||
//
|
||||
// 本包只编排渠道退款的资金动作与本地状态流转:请求号决定执行幂等、结果按条件更新回写、
|
||||
// 失败按稳定分类终结、未知结果交由恢复扫描查询收敛。具体渠道协议由按服务商类型注入的
|
||||
// Refunder 实现,本包不依赖任何渠道 SDK,也绝不在数据库事务内发起渠道调用。
|
||||
package refundchannel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// State 是渠道调用的稳定结果状态。
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateSuccess State = "success" // 渠道明确成功
|
||||
StateFailed State = "failed" // 渠道明确失败
|
||||
StateUnknown State = "unknown" // 超时或结果未确认,可恢复
|
||||
)
|
||||
|
||||
// 渠道原路退款的固定运行参数。
|
||||
const (
|
||||
// recoveryBatchSize 是恢复扫描的单批上限,与既有批次扫描用例保持一致。
|
||||
recoveryBatchSize = 50
|
||||
// fuiouQueryWindow 是富友退款查询窗口:其退款查询接口只支持 3 日内的退款交易。
|
||||
fuiouQueryWindow = 72 * time.Hour
|
||||
// wechatV2QueryWindow 是微信 v2 退款结果的本地确认上限。
|
||||
// 微信 v2 退款接口的受理响应不含退款状态,终态只能由退款查询确认;渠道侧没有查询时限,
|
||||
// 因此这里只设本地的放弃阈值:超过该期限仍未确认即停止轮询并转人工核对,避免无限查询。
|
||||
wechatV2QueryWindow = 7 * 24 * time.Hour
|
||||
// channelCallTimeout 是单次渠道退款申请或查询调用的最长等待时间。
|
||||
channelCallTimeout = 30 * time.Second
|
||||
// fuiouOrderTypeWechat 是富友原交易的 order_type 当前唯一可达值(富友微信主扫)。
|
||||
// 与 pkg/fuiou.OrderTypeWechat 取值一致;本包不引入渠道 SDK,因此在此固定回传该冻结值。
|
||||
fuiouOrderTypeWechat = "WECHAT"
|
||||
// anomalyReasonFuiouQueryWindow 是富友退款查询窗口超期的异常原因。
|
||||
anomalyReasonFuiouQueryWindow = "富友退款查询窗口已过,需人工核对"
|
||||
// anomalyReasonWechatV2QueryWindow 是微信 v2 退款结果超过本地确认上限的异常原因。
|
||||
anomalyReasonWechatV2QueryWindow = "微信 v2 退款超过 7 天未确认结果,需人工核对"
|
||||
// failureMessageUnknown 是渠道退款调用结果未确认时的安全摘要。
|
||||
failureMessageUnknown = "渠道退款调用结果未确认,等待查询恢复"
|
||||
// failureMessagePaymentFact 是本地原支付事实不可用时的安全摘要。
|
||||
failureMessagePaymentFact = "本地原支付事实不可用,未能发起渠道退款"
|
||||
// failureMessageCredential 是商户退款必需凭证不完整时的安全摘要。
|
||||
failureMessageCredential = "商户退款必需凭证不完整,未发起渠道退款"
|
||||
// failureMessageNoRequestNo 是退款申请缺少渠道退款请求号时的安全摘要。
|
||||
failureMessageNoRequestNo = "退款申请缺少渠道退款请求号,未发起渠道退款"
|
||||
// failureMessageMaxRunes 是失败安全摘要的字符上限,与 failure_message 列宽约束一致。
|
||||
failureMessageMaxRunes = 480
|
||||
// providerTypeAlipay 是支付宝商户的 provider_type 取值;model 未定义该常量,
|
||||
// 取值与商户凭证管理保持的 "alipay" 完全一致。
|
||||
providerTypeAlipay = "alipay"
|
||||
)
|
||||
|
||||
// Target 是执行一次渠道原路退款所需的全部冻结事实。
|
||||
type Target struct {
|
||||
RefundID uint
|
||||
RefundNo string
|
||||
OrderID uint
|
||||
OrderNo string
|
||||
ProviderType string // model.ProviderType*
|
||||
Config *model.WechatConfig // 商户当前凭证,绝不落库或记日志
|
||||
PaymentNo string // 原支付单商户订单号(微信/支付宝 out_trade_no、富友 mchnt_order_no)
|
||||
ChannelTradeNo string // 原支付单渠道交易流水
|
||||
ChannelOrderType string // 富友原交易 order_type
|
||||
PaidAt *time.Time
|
||||
PaidAmount int64 // 原支付单渠道订单总金额(分),渠道退款请求的 total_amt 必须回传该值
|
||||
RefundAmount int64
|
||||
FrozenActualReceivedAmount int64
|
||||
ChannelRefundRequestNo string
|
||||
}
|
||||
|
||||
// Result 是渠道调用或查询的映射结果。
|
||||
type Result struct {
|
||||
State State
|
||||
ChannelRefundNo string // 渠道退款流水号
|
||||
ChannelRefundAmount int64 // 渠道退款金额(分)
|
||||
SettledAt string // 渠道结算日期原文,可空
|
||||
FailureReason string // pkg/constants.RefundFailure* 稳定编码,仅 State!=StateSuccess 时有值
|
||||
FailureMessage string // 安全摘要,不得含凭证或报文原文
|
||||
}
|
||||
|
||||
// Refunder 是渠道原路退款 Port,由基础设施层按服务商类型实现。
|
||||
type Refunder interface {
|
||||
// Refund 至多提交一次可确认的退款请求;请求号由 Target.ChannelRefundRequestNo 提供。
|
||||
Refund(ctx context.Context, target Target) (Result, error)
|
||||
// Query 只查询渠道退款状态,不得发起资金动作。
|
||||
Query(ctx context.Context, target Target) (Result, error)
|
||||
}
|
||||
|
||||
// MerchantLoader 按冻结商户 ID 加载商户当前凭证与渠道所需的全局授权配置。
|
||||
type MerchantLoader interface {
|
||||
LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error)
|
||||
LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error)
|
||||
}
|
||||
|
||||
// Service 执行与恢复原路退款。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
loader MerchantLoader
|
||||
refunder Refunder
|
||||
audit AuditWriter
|
||||
notifier CompletionNotifier
|
||||
logger *zap.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService 创建渠道原路退款用例。
|
||||
func NewService(db *gorm.DB, loader MerchantLoader, refunder Refunder, audit AuditWriter) *Service {
|
||||
return &Service{db: db, loader: loader, refunder: refunder, audit: audit, logger: zap.NewNop(), now: time.Now}
|
||||
}
|
||||
|
||||
// SetCompletionNotifier 注入退款完成通知写入能力;未注入时成功路径不写通知事实。
|
||||
func (s *Service) SetCompletionNotifier(notifier CompletionNotifier) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
s.notifier = notifier
|
||||
return s
|
||||
}
|
||||
|
||||
// SetLogger 注入渠道原路退款运行日志。
|
||||
func (s *Service) SetLogger(logger *zap.Logger) *Service {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
s.logger = logger
|
||||
return s
|
||||
}
|
||||
|
||||
// PrepareInTx 在企微通过事务内为原路方式生成请求号并把退款申请置为原路处理中。
|
||||
//
|
||||
// 请求号由提交或重提在不可变审批尝试记录上生成并冻结;尝试记录已带请求号时直接复用,
|
||||
// 仅在缺失时防御性补生成。条件更新要求申请仍处于待审批,否则视为并发冲突。
|
||||
func (s *Service) PrepareInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error {
|
||||
if s == nil || tx == nil || refund == nil || refund.ID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "渠道原路退款准备参数无效")
|
||||
}
|
||||
if refund.Method != constants.RefundMethodOriginalRoute {
|
||||
return nil
|
||||
}
|
||||
requestNo := ""
|
||||
if attempt != nil {
|
||||
requestNo = strings.TrimSpace(attempt.ChannelRefundRequestNo)
|
||||
}
|
||||
if requestNo == "" {
|
||||
// 正常运行不会走到这里:请求号在提交/重提时已冻结到尝试记录上。
|
||||
requestNo = BuildChannelRefundRequestNo(strconv.FormatUint(uint64(refund.ID), 10), s.now())
|
||||
}
|
||||
now := s.now().UTC()
|
||||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
|
||||
Updates(map[string]any{
|
||||
"status": model.RefundStatusChannelProcessing,
|
||||
"channel_refund_status": constants.RefundChannelStatusProcessing,
|
||||
"channel_refund_request_no": requestNo,
|
||||
"updated_at": now,
|
||||
})
|
||||
if updated.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "进入渠道原路退款处理中失败")
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款申请状态不允许进入渠道原路退款处理中")
|
||||
}
|
||||
if attempt != nil && attempt.ID != 0 {
|
||||
write := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
|
||||
Where("id = ?", attempt.ID).
|
||||
Update("channel_refund_request_no", requestNo)
|
||||
if write.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, write.Error, "写入退款尝试渠道退款请求号失败")
|
||||
}
|
||||
if write.RowsAffected != 1 {
|
||||
s.logger.Warn("退款尝试渠道退款请求号未写入", zap.Uint("refund_id", refund.ID), zap.Uint("attempt_id", attempt.ID))
|
||||
}
|
||||
attempt.ChannelRefundRequestNo = requestNo
|
||||
}
|
||||
if err := AppendRefundChannelRefund(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款事件失败")
|
||||
}
|
||||
refund.Status = model.RefundStatusChannelProcessing
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
|
||||
refund.ChannelRefundRequestNo = requestNo
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute 幂等执行一次原路退款;已明确成功或已失败终结的申请直接返回 nil。
|
||||
//
|
||||
// 本地事实在只读事务内锁定读取。资金动作「至多提交一次」由提交认领保证:
|
||||
// 提交前先以 channel_submitted_at IS NULL 条件认领,只有认领成功的执行才调用 Refund;
|
||||
// 认领失败表示该尝试已提交过渠道退款请求(例如 Outbox 事件被重复投递或人工重放),
|
||||
// 此时只查询渠道结果并回填,绝不再次提交资金动作。
|
||||
func (s *Service) Execute(ctx context.Context, refundID uint) error {
|
||||
if err := s.requireReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
if refundID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "渠道原路退款缺少退款申请标识")
|
||||
}
|
||||
facts, proceed, err := s.loadExecutionFacts(ctx, refundID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !proceed {
|
||||
return nil
|
||||
}
|
||||
payment, err := s.loadPaidPayment(ctx, facts.refund.OrderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, failureReason, failureMessage, err := s.buildTarget(ctx, facts.refund, facts.attempt, payment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
if failureReason != "" {
|
||||
// 本地事实不可用时绝不调用渠道,按稳定失败分类终结本次原路退款。
|
||||
if _, err := s.writeback(ctx, facts.refund, target, Result{
|
||||
State: StateFailed, FailureReason: failureReason, FailureMessage: failureMessage,
|
||||
}, constants.AuditActionRefundChannelCalled, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 认领本次提交:认领成功才拥有提交权,失败则本次只做查询。
|
||||
claimed, err := s.claimChannelSubmission(ctx, refundID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
|
||||
defer cancel()
|
||||
if !claimed {
|
||||
// 已提交过:只查询渠道结果,绝不再次提交资金动作。
|
||||
result, callErr := s.refunder.Query(callCtx, target)
|
||||
if callErr != nil {
|
||||
// 查询失败不能推断渠道结果,保持原路处理中等待恢复扫描。
|
||||
return nil
|
||||
}
|
||||
s.logger.Info("渠道退款请求已提交过,本次仅查询结果",
|
||||
zap.Uint("refund_id", refundID), zap.String("channel_refund_request_no", target.ChannelRefundRequestNo))
|
||||
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelRecovered, now)
|
||||
return err
|
||||
}
|
||||
result, callErr := s.refunder.Refund(callCtx, target)
|
||||
if callErr != nil {
|
||||
// 传输层错误不能推断渠道未受理,一律按结果未知保持可恢复。
|
||||
result = Result{State: StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: failureMessageUnknown}
|
||||
}
|
||||
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelCalled, now)
|
||||
return err
|
||||
}
|
||||
|
||||
// claimChannelSubmission 以条件更新认领本次渠道退款提交权。
|
||||
//
|
||||
// 返回 true 表示调用方获得提交权、可以调用渠道退款接口;false 表示该尝试在此之前
|
||||
// 已提交过(重复投递或人工重放),调用方只能查询。认领与回写同以 status = 原路处理中
|
||||
// 为谓词,因此并发执行也至多有一次认领成功。
|
||||
func (s *Service) claimChannelSubmission(ctx context.Context, refundID uint, now time.Time) (bool, error) {
|
||||
claimed := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ? AND channel_submitted_at IS NULL",
|
||||
refundID, model.RefundStatusChannelProcessing).
|
||||
UpdateColumn("channel_submitted_at", now)
|
||||
if claimed.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领渠道退款提交权失败")
|
||||
}
|
||||
return claimed.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
// executionFacts 是一次渠道执行所需的本地冻结事实。
|
||||
type executionFacts struct {
|
||||
refund *model.RefundRequest
|
||||
attempt *model.RefundRequestAttempt
|
||||
}
|
||||
|
||||
// loadExecutionFacts 在只读事务内锁定退款申请并读取本次执行所需的尝试记录。
|
||||
// proceed 为 false 表示申请已终结、方式不符或已由并发执行推进,调用方必须直接结束本次执行。
|
||||
func (s *Service) loadExecutionFacts(ctx context.Context, refundID uint) (*executionFacts, bool, error) {
|
||||
facts := &executionFacts{}
|
||||
proceed := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var refund model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refundID).First(&refund).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||||
}
|
||||
facts.refund = &refund
|
||||
if refund.Method != constants.RefundMethodOriginalRoute {
|
||||
s.logger.Warn("退款方式不是原路,跳过渠道退款", zap.Uint("refund_id", refund.ID), zap.String("method", refund.Method))
|
||||
return nil
|
||||
}
|
||||
// 已通过或已失败终结的申请直接返回;渠道已明确成功的申请也不得再次调用渠道。
|
||||
if refund.Status != model.RefundStatusChannelProcessing ||
|
||||
refund.ChannelRefundStatus == constants.RefundChannelStatusSucceeded {
|
||||
return nil
|
||||
}
|
||||
attempt, err := loadAttempt(ctx, tx, &refund)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
facts.attempt = attempt
|
||||
proceed = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return facts, proceed, nil
|
||||
}
|
||||
|
||||
// loadAttempt 按申请冻结的最新尝试引用读取尝试记录;引用缺失时退回该申请的最大尝试序号。
|
||||
func loadAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) (*model.RefundRequestAttempt, error) {
|
||||
var attempt model.RefundRequestAttempt
|
||||
query := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{})
|
||||
if refund.LatestAttemptID != 0 {
|
||||
if err := query.Where("id = ?", refund.LatestAttemptID).First(&attempt).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
if err := query.Where("refund_id = ?", refund.ID).Order("attempt_no DESC").First(&attempt).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
// loadPaidPayment 读取订单最近一笔已支付的套餐支付单,作为原路退款的原支付事实。
|
||||
func (s *Service) loadPaidPayment(ctx context.Context, orderID uint) (*model.Payment, error) {
|
||||
var payment model.Payment
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
|
||||
Order("id DESC").First(&payment).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取原支付单失败")
|
||||
}
|
||||
return &payment, nil
|
||||
}
|
||||
|
||||
// buildTarget 在事务外组装渠道调用目标。
|
||||
// 返回非空 failureReason 表示本地事实不可用:调用方必须按该分类回写且绝不调用渠道。
|
||||
func (s *Service) buildTarget(ctx context.Context, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, payment *model.Payment) (Target, string, string, error) {
|
||||
target := Target{
|
||||
RefundID: refund.ID, RefundNo: refund.RefundNo, OrderID: refund.OrderID, OrderNo: refund.OrderNo,
|
||||
RefundAmount: resolveRefundAmount(refund, attempt),
|
||||
FrozenActualReceivedAmount: resolveFrozenAmount(refund, attempt),
|
||||
ChannelRefundRequestNo: resolveChannelRefundRequestNo(refund, attempt),
|
||||
}
|
||||
if target.ChannelRefundRequestNo == "" {
|
||||
// 没有请求号就没有渠道幂等标识:本次尝试从未提交过资金动作,可按明确失败终结。
|
||||
return target, constants.RefundFailurePaymentFactInvalid, failureMessageNoRequestNo, nil
|
||||
}
|
||||
if payment == nil {
|
||||
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
|
||||
}
|
||||
target.PaymentNo = strings.TrimSpace(payment.PaymentNo)
|
||||
target.ChannelTradeNo = strings.TrimSpace(payment.ThirdPartyTradeNo)
|
||||
target.PaidAt = payment.PaidAt
|
||||
target.PaidAmount = payment.Amount
|
||||
if target.RefundAmount <= 0 || target.FrozenActualReceivedAmount <= 0 ||
|
||||
target.RefundAmount > target.FrozenActualReceivedAmount {
|
||||
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
|
||||
}
|
||||
config, providerType, err := s.loadChannelConfig(ctx, payment)
|
||||
if err != nil {
|
||||
if !credentialFailure(err) {
|
||||
return target, "", "", err
|
||||
}
|
||||
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
|
||||
}
|
||||
if !credentialComplete(providerType, config) {
|
||||
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
|
||||
}
|
||||
target.ProviderType = providerType
|
||||
target.Config = config
|
||||
if providerType == model.ProviderTypeFuiou {
|
||||
target.ChannelOrderType = fuiouOrderTypeWechat
|
||||
}
|
||||
return target, "", "", nil
|
||||
}
|
||||
|
||||
// loadChannelConfig 加载原支付单实际收款商户的当前凭证。
|
||||
// 新支付按冻结商户标识加载该商户当前凭证与全局微信授权;merchant_id 为空仅表示数据留存期内的
|
||||
// 历史支付,按其原支付配置读取,禁止按当前启用商户池推断历史商户。
|
||||
func (s *Service) loadChannelConfig(ctx context.Context, payment *model.Payment) (*model.WechatConfig, string, error) {
|
||||
if payment.MerchantID != nil {
|
||||
merchant, err := s.loader.LoadMerchant(ctx, *payment.MerchantID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if merchant == nil {
|
||||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "原支付收款商户不存在")
|
||||
}
|
||||
// 仅微信直连(v3/v2)需要全局微信授权配置中的 AppID;其他服务商传 nil 避免无谓失败。
|
||||
var authorization *model.WechatAuthorization
|
||||
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
|
||||
authorization, err = s.loader.LoadAuthorization(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
config, err := merchantpayment.MerchantConfig(merchant, authorization)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return config, merchant.ProviderType, nil
|
||||
}
|
||||
if payment.PaymentConfigID == nil {
|
||||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
|
||||
}
|
||||
var legacy model.WechatConfig
|
||||
if err := s.db.WithContext(ctx).Unscoped().Where("id = ?", *payment.PaymentConfigID).First(&legacy).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
|
||||
}
|
||||
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败")
|
||||
}
|
||||
return &legacy, legacy.ProviderType, nil
|
||||
}
|
||||
|
||||
// credentialComplete 判断该服务商类型发起原路退款所需的凭证是否完整。
|
||||
// 规则与本 Change 冻结的商户退款凭证要求一致,只判断必需字段非空,不新增任何凭证键。
|
||||
// RefundCredentialIssue 返回该服务商类型的退款必需凭证缺失原因;凭证完整时返回空串。
|
||||
//
|
||||
// 这是退款能力的唯一判定入口:退款请求不向渠道传递任何通知地址,因此支付通知地址与
|
||||
// 支付跳转地址都不是退款必需凭证。微信 v2 退款接口(/secapi/pay/refund)请求需要双向
|
||||
// 证书,因此其必需凭证包含 API 客户端证书;缺少该证书的 v2 商户按其凭证完整性判定为
|
||||
// 不可用,补录证书后即可用。判定结果不提供人工开关。
|
||||
func RefundCredentialIssue(providerType string, config *model.WechatConfig) string {
|
||||
if config == nil {
|
||||
return failureMessageCredential
|
||||
}
|
||||
switch providerType {
|
||||
case model.ProviderTypeWechat:
|
||||
if !completeFields(config.WxMchID, config.WxAPIV3Key, config.WxCertContent,
|
||||
config.WxKeyContent, config.WxSerialNo) {
|
||||
return "冻结微信商户退款凭证不完整"
|
||||
}
|
||||
case model.ProviderTypeWechatV2:
|
||||
// v2 退款接口为双向证书接口:缺少 API 客户端证书时按其凭证完整性判定为不可用。
|
||||
if !completeFields(config.WxMchID, config.WxAPIV2Key, config.WxClientCertContent, config.WxClientKeyContent) {
|
||||
return "冻结微信 v2 商户退款凭证不完整(缺少 API 客户端证书)"
|
||||
}
|
||||
case model.ProviderTypeFuiou:
|
||||
if !completeFields(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey,
|
||||
config.FyPublicKey, config.FyAPIURL) {
|
||||
return "冻结富友商户退款凭证不完整"
|
||||
}
|
||||
case providerTypeAlipay:
|
||||
if !completeFields(config.AliAppID, config.AliPrivateKey, config.AliPublicKey) {
|
||||
return "冻结支付宝商户退款凭证不完整"
|
||||
}
|
||||
default:
|
||||
return "冻结商户不支持原路退款"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// credentialComplete 判断该服务商类型的退款必需凭证是否完整。
|
||||
func credentialComplete(providerType string, config *model.WechatConfig) bool {
|
||||
return RefundCredentialIssue(providerType, config) == ""
|
||||
}
|
||||
|
||||
func completeFields(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// credentialFailure 判断凭证加载错误属于渠道侧不可执行的凭证问题,而不是可重试的基础设施错误。
|
||||
func credentialFailure(err error) bool {
|
||||
switch appErrorCode(err) {
|
||||
case errors.CodeNoPaymentConfig, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeWechatConfigUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveChannelRefundRequestNo 取本次执行的渠道幂等标识。
|
||||
// 尝试记录持有本次提交冻结的请求号,优先级高于退款单上的展示快照:重提会生成新请求号,
|
||||
// 沿用旧快照会让渠道按旧请求号再次受理;两者一致时结果相同。
|
||||
func resolveChannelRefundRequestNo(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) string {
|
||||
if attempt != nil {
|
||||
if requestNo := strings.TrimSpace(attempt.ChannelRefundRequestNo); requestNo != "" {
|
||||
return requestNo
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(refund.ChannelRefundRequestNo)
|
||||
}
|
||||
|
||||
// resolveRefundAmount 取本次原路退款的权威金额:优先审批实际退款金额,其次尝试记录冻结金额。
|
||||
func resolveRefundAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
|
||||
if refund.ApprovedRefundAmount != nil && *refund.ApprovedRefundAmount > 0 {
|
||||
return *refund.ApprovedRefundAmount
|
||||
}
|
||||
if refund.RequestedRefundAmount > 0 {
|
||||
return refund.RequestedRefundAmount
|
||||
}
|
||||
if attempt != nil {
|
||||
return attempt.RefundAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveFrozenAmount 取本次原路退款的冻结实收金额。
|
||||
func resolveFrozenAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
|
||||
if refund.FrozenActualReceivedAmount > 0 {
|
||||
return refund.FrozenActualReceivedAmount
|
||||
}
|
||||
if attempt != nil {
|
||||
return attempt.FrozenActualReceivedAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// writeback 在独立事务内按渠道结果条件更新退款申请、订单与审计事实。
|
||||
// applied 为 false 表示记录已被并发推进,本次不改动任何状态。
|
||||
func (s *Service) writeback(ctx context.Context, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
|
||||
applied := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
applied, err = s.applyResult(ctx, tx, refund, target, result, action, now)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !applied {
|
||||
s.logger.Warn("渠道原路退款结果未回写,记录已被并发推进",
|
||||
zap.Uint("refund_id", refund.ID), zap.String("action", action), zap.String("state", string(result.State)))
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// applyResult 按结果状态把渠道事实条件回写到退款申请,成功时同步把订单置为已退款。
|
||||
// 所有状态流转都以 status = 原路处理中 为谓词,RowsAffected 为 0 表示并发已推进该记录。
|
||||
func (s *Service) applyResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
|
||||
update := map[string]any{}
|
||||
syncRefund := func() {}
|
||||
orderRefunded := false
|
||||
reason := result.FailureReason
|
||||
message := ""
|
||||
switch result.State {
|
||||
case StateSuccess:
|
||||
amount := result.ChannelRefundAmount
|
||||
if amount <= 0 {
|
||||
amount = target.RefundAmount
|
||||
}
|
||||
update["status"] = model.RefundStatusApproved
|
||||
update["channel_refund_status"] = constants.RefundChannelStatusSucceeded
|
||||
update["channel_refund_no"] = result.ChannelRefundNo
|
||||
update["channel_refund_amount"] = amount
|
||||
update["channel_refunded_at"] = now
|
||||
update["processed_at"] = now
|
||||
update["failure_reason"] = ""
|
||||
update["failure_message"] = ""
|
||||
update["updated_at"] = now
|
||||
orderRefunded = true
|
||||
message = "渠道原路退款明确成功"
|
||||
syncRefund = func() {
|
||||
refund.Status = model.RefundStatusApproved
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusSucceeded
|
||||
refund.ChannelRefundNo = result.ChannelRefundNo
|
||||
refund.ChannelRefundAmount = amount
|
||||
refund.ChannelRefundedAt = &now
|
||||
refund.ProcessedAt = &now
|
||||
refund.FailureReason = ""
|
||||
refund.FailureMessage = ""
|
||||
}
|
||||
case StateFailed:
|
||||
if reason == "" {
|
||||
reason = constants.RefundFailureChannelRejected
|
||||
}
|
||||
message = "渠道原路退款明确失败:" + constants.RefundFailureReasonName(reason)
|
||||
failureMessage := safeMessage(result.FailureMessage, message)
|
||||
update["status"] = model.RefundStatusChannelFailed
|
||||
update["channel_refund_status"] = constants.RefundChannelStatusFailed
|
||||
update["failure_reason"] = reason
|
||||
update["failure_message"] = failureMessage
|
||||
update["updated_at"] = now
|
||||
syncRefund = func() {
|
||||
refund.Status = model.RefundStatusChannelFailed
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
|
||||
refund.FailureReason = reason
|
||||
refund.FailureMessage = failureMessage
|
||||
}
|
||||
default:
|
||||
// 超时或结果未确认:保持原路处理中,等待恢复扫描查询收敛。
|
||||
// 不修改 updated_at,使富友查询窗口从进入原路处理中的时点起算。
|
||||
reason = constants.RefundFailureTimeoutUnknown
|
||||
message = "渠道原路退款结果未确认,保持处理中"
|
||||
failureMessage := safeMessage(result.FailureMessage, failureMessageUnknown)
|
||||
update["channel_refund_status"] = constants.RefundChannelStatusProcessing
|
||||
update["failure_reason"] = reason
|
||||
update["failure_message"] = failureMessage
|
||||
syncRefund = func() {
|
||||
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
|
||||
refund.FailureReason = reason
|
||||
refund.FailureMessage = failureMessage
|
||||
}
|
||||
}
|
||||
// UpdateColumns 不会隐式推进 updated_at:结果未知时必须保留进入原路处理中的时点,
|
||||
// 富友 72 小时查询窗口正是以该时点起算;需要推进的分支已在 update 中显式写入。
|
||||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusChannelProcessing).
|
||||
UpdateColumns(update)
|
||||
if updated.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, updated.Error, "回写渠道原路退款结果失败")
|
||||
}
|
||||
if updated.RowsAffected != 1 {
|
||||
return false, nil
|
||||
}
|
||||
syncRefund()
|
||||
if orderRefunded {
|
||||
if err := s.markOrderRefunded(ctx, tx, refund, now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 原路退款的完成时点是渠道明确成功,与客户收款信息退款在企微通过时完成的语义不同:
|
||||
// 退款完成通知必须在同一事务内补写,否则该方式的店铺通知永远不会发出。
|
||||
if s.notifier != nil {
|
||||
if err := s.notifier.AppendCompletedNotification(ctx, tx, refund); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.audit.WriteRefundChannelResult(ctx, tx, refund, action, message); err != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款审计失败")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// markOrderRefunded 在渠道明确成功后按方式把订单置为已退款。
|
||||
// 条件更新命中 0 行时容忍订单已是已退款;其他状态只记录告警,不覆盖业务事实。
|
||||
func (s *Service) markOrderRefunded(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, now time.Time) error {
|
||||
updated := tx.WithContext(ctx).Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", refund.OrderID, model.PaymentStatusPaid).
|
||||
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": now})
|
||||
if updated.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "更新订单退款状态失败")
|
||||
}
|
||||
if updated.RowsAffected == 1 {
|
||||
return nil
|
||||
}
|
||||
var order model.Order
|
||||
if err := tx.WithContext(ctx).Select("id", "payment_status").Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "读取退款关联订单状态失败")
|
||||
}
|
||||
if order.PaymentStatus != model.PaymentStatusRefunded {
|
||||
s.logger.Warn("订单支付状态未置为已退款",
|
||||
zap.Uint("refund_id", refund.ID), zap.Uint("order_id", refund.OrderID), zap.Int("payment_status", order.PaymentStatus))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeMessage 生成失败安全摘要:裁剪空白、限定字符数,空值退回该状态的固定摘要。
|
||||
func safeMessage(message, fallback string) string {
|
||||
text := strings.TrimSpace(message)
|
||||
if text == "" {
|
||||
text = fallback
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) > failureMessageMaxRunes {
|
||||
text = string(runes[:failureMessageMaxRunes])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// requireReady 校验渠道原路退款的全部依赖已配置。
|
||||
func (s *Service) requireReady() error {
|
||||
if s == nil || s.db == nil || s.loader == nil || s.refunder == nil || s.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款能力未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
252
internal/application/shop/batch_business_owner.go
Normal file
252
internal/application/shop/batch_business_owner.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package shop
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// BusinessOwnerBatchChange 描述一家店铺在批量交接中的负责人前后事实。
|
||||
// 账号快照用于审计引用资源,已软删账号同样保留历史事实。
|
||||
type BusinessOwnerBatchChange struct {
|
||||
Shop *model.Shop
|
||||
BeforeOwnerID *uint
|
||||
AfterOwnerID *uint
|
||||
PreviousOwner *model.Account
|
||||
Owner *model.Account
|
||||
}
|
||||
|
||||
// BusinessOwnerBatchAudit 描述一次批量交接的批次根事实与逐店子事实。
|
||||
// Result 为空表示成功批次,由实现写批次根事件与逐店子事件;
|
||||
// 非空表示业务回滚后的失败或拒绝事实,此时只写批次根事件。
|
||||
type BusinessOwnerBatchAudit struct {
|
||||
BatchKey string
|
||||
Operation string
|
||||
Result string
|
||||
OperatorID uint
|
||||
Total int
|
||||
Owner *model.Account
|
||||
Changes []BusinessOwnerBatchChange
|
||||
}
|
||||
|
||||
// BusinessOwnerBatchAuditWriter 接收店铺负责人批量交接受理事务内的审计事实。
|
||||
// 接口定义在应用层,具体实现由装配注入,避免应用层依赖下游用例包。
|
||||
type BusinessOwnerBatchAuditWriter interface {
|
||||
WriteBusinessOwnerBatch(ctx context.Context, tx *gorm.DB, batch BusinessOwnerBatchAudit) error
|
||||
}
|
||||
|
||||
// SetBatchBusinessOwnerAudit 注入批量交接的批次审计接缝。
|
||||
func (s *BatchBusinessOwnerService) SetBatchBusinessOwnerAudit(writer BusinessOwnerBatchAuditWriter) {
|
||||
s.batchAudit = writer
|
||||
}
|
||||
|
||||
// BatchBusinessOwnerService 收口勾选店铺批量设置或清空平台业务员负责人的事务脚本。
|
||||
// 全量预校验通过后在同一事务内统一更新并逐店写审计;任一项失败整批不修改,
|
||||
// 且失败文案不区分无权、不存在与已删除。
|
||||
type BatchBusinessOwnerService struct {
|
||||
db *gorm.DB
|
||||
batchAudit BusinessOwnerBatchAuditWriter
|
||||
}
|
||||
|
||||
// NewBatchBusinessOwnerService 创建店铺负责人批量交接事务脚本。
|
||||
func NewBatchBusinessOwnerService(db *gorm.DB) *BatchBusinessOwnerService {
|
||||
return &BatchBusinessOwnerService{db: db}
|
||||
}
|
||||
|
||||
// Execute 批量设置或清空店铺负责人。
|
||||
func (s *BatchBusinessOwnerService) Execute(ctx context.Context, request *dto.BatchUpdateShopBusinessOwnerRequest) (*dto.BatchUpdateShopBusinessOwnerResult, error) {
|
||||
userType := middleware.GetUserTypeFromContext(ctx)
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
|
||||
}
|
||||
operatorID := middleware.GetUserIDFromContext(ctx)
|
||||
if operatorID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
if !request.BusinessOwnerAccountIDSet {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "必须显式提交业务员归属字段,null 表示清空")
|
||||
}
|
||||
shopIDs, err := normalizeShopIDs(request.ShopIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.batchAudit == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "店铺负责人批量交接统一审计接缝未配置")
|
||||
}
|
||||
operation := "clear"
|
||||
if request.BusinessOwnerAccountID != nil {
|
||||
operation = "assign"
|
||||
}
|
||||
batchKey := batchEventPrefix + uuid.NewString()
|
||||
|
||||
var result *dto.BatchUpdateShopBusinessOwnerResult
|
||||
txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
lockedShops, err := lockManageableShops(ctx, tx, shopIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 命中数不等于请求数即失败,不区分越权、不存在与已删除,避免泄露店铺存在性。
|
||||
if len(lockedShops) != len(shopIDs) {
|
||||
return errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
|
||||
}
|
||||
var owner *uint
|
||||
var ownerAccount *model.Account
|
||||
if request.BusinessOwnerAccountID != nil {
|
||||
account, err := validateBatchBusinessOwner(ctx, tx, *request.BusinessOwnerAccountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownerID := account.ID
|
||||
owner, ownerAccount = &ownerID, account
|
||||
}
|
||||
update := tx.WithContext(ctx).Model(&model.Shop{}).Where("id IN ?", shopIDs).
|
||||
Updates(map[string]any{
|
||||
"business_owner_account_id": owner, "updater": operatorID, "updated_at": time.Now(),
|
||||
})
|
||||
if update.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, update.Error, "批量更新店铺负责人失败")
|
||||
}
|
||||
if int(update.RowsAffected) != len(shopIDs) {
|
||||
return errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
|
||||
}
|
||||
if err := s.batchAudit.WriteBusinessOwnerBatch(ctx, tx, BusinessOwnerBatchAudit{
|
||||
BatchKey: batchKey, Operation: operation, OperatorID: operatorID,
|
||||
Total: len(shopIDs), Owner: ownerAccount,
|
||||
Changes: collectBatchChanges(ctx, tx, lockedShops, owner, ownerAccount),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
result = &dto.BatchUpdateShopBusinessOwnerResult{
|
||||
BatchKey: batchKey, ShopCount: len(shopIDs), Cleared: owner == nil, BusinessOwnerAccountID: owner,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
s.recordFailure(ctx, batchKey, operation, operatorID, shopIDs, txErr)
|
||||
return nil, txErr
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// collectBatchChanges 装配逐店审计事实:锁定的店铺携带变更前负责人,
|
||||
// 原负责人账号按一次批量查询载入,目标账号快照由调用方复用,避免 N+1。
|
||||
func collectBatchChanges(ctx context.Context, tx *gorm.DB, shops []*model.Shop, owner *uint, ownerAccount *model.Account) []BusinessOwnerBatchChange {
|
||||
previousIDs := make([]uint, 0, len(shops))
|
||||
seen := make(map[uint]struct{}, len(shops))
|
||||
for _, shop := range shops {
|
||||
if shop.BusinessOwnerAccountID == nil {
|
||||
continue
|
||||
}
|
||||
id := *shop.BusinessOwnerAccountID
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
previousIDs = append(previousIDs, id)
|
||||
}
|
||||
previous := make(map[uint]*model.Account, len(previousIDs))
|
||||
if len(previousIDs) > 0 {
|
||||
var accounts []*model.Account
|
||||
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", previousIDs).Find(&accounts).Error; err == nil {
|
||||
for _, account := range accounts {
|
||||
previous[account.ID] = account
|
||||
}
|
||||
}
|
||||
}
|
||||
changes := make([]BusinessOwnerBatchChange, 0, len(shops))
|
||||
for _, shop := range shops {
|
||||
change := BusinessOwnerBatchChange{Shop: shop, BeforeOwnerID: shop.BusinessOwnerAccountID, AfterOwnerID: owner, Owner: ownerAccount}
|
||||
if shop.BusinessOwnerAccountID != nil {
|
||||
change.PreviousOwner = previous[*shop.BusinessOwnerAccountID]
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
// recordFailure 在业务回滚后使用独立短事务记录批次失败或拒绝事实。
|
||||
// 二次写入失败不能静默丢弃,按 pkg/auditfailure 既有先例上报为关键级失败。
|
||||
func (s *BatchBusinessOwnerService) recordFailure(ctx context.Context, batchKey, operation string, operatorID uint, shopIDs []uint, originalErr error) {
|
||||
writeErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return s.batchAudit.WriteBusinessOwnerBatch(ctx, tx, BusinessOwnerBatchAudit{
|
||||
BatchKey: batchKey, Operation: operation, Result: shopAuditFailureResult(originalErr),
|
||||
OperatorID: operatorID, Total: len(shopIDs),
|
||||
})
|
||||
})
|
||||
if writeErr != nil {
|
||||
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionShopBusinessOwnerBatchUpdated,
|
||||
batchKey, "", batchKey, strconv.Itoa(errorCodeOf(originalErr)), writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// errorCodeOf 返回稳定错误的编码文本,非稳定错误归入内部错误码。
|
||||
func errorCodeOf(err error) int {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
return appErr.Code
|
||||
}
|
||||
return errors.CodeInternalError
|
||||
}
|
||||
|
||||
// batchBusinessOwnerFailureMessage 复用平台维护入口的统一失败文案,不区分无权、不存在与已删除。
|
||||
const batchBusinessOwnerFailureMessage = constants.PlatformManagementForbiddenMessage
|
||||
|
||||
// batchEventPrefix 是批次根事件标识前缀,与随机后缀共同保证稳定且不超审计列宽。
|
||||
const batchEventPrefix = "shop-owner-batch:"
|
||||
|
||||
// normalizeShopIDs 去重并保持首次出现顺序,空集合视为非法参数。
|
||||
func normalizeShopIDs(values []uint) ([]uint, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "店铺ID列表不能为空")
|
||||
}
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
result := make([]uint, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "店铺ID非法")
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// lockManageableShops 在数据范围约束下按主键加行锁读取全部目标店铺。
|
||||
func lockManageableShops(ctx context.Context, tx *gorm.DB, shopIDs []uint) ([]*model.Shop, error) {
|
||||
query := middleware.ApplyShopIDFilter(ctx, tx.WithContext(ctx).Model(&model.Shop{}))
|
||||
var shops []*model.Shop
|
||||
if err := query.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id IN ?", shopIDs).Order("id ASC").Find(&shops).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定批量交接目标店铺失败")
|
||||
}
|
||||
return shops, nil
|
||||
}
|
||||
|
||||
// validateBatchBusinessOwner 校验目标账号是当前启用的平台业务员。
|
||||
func validateBatchBusinessOwner(ctx context.Context, tx *gorm.DB, accountID uint) (*model.Account, error) {
|
||||
if accountID == 0 {
|
||||
return nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
|
||||
}
|
||||
var account model.Account
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "SHARE"}).
|
||||
Where("id = ? AND user_type = ? AND status = ?", accountID, constants.UserTypePlatform, constants.StatusEnabled).
|
||||
First(&account).Error; err != nil {
|
||||
return nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
@@ -112,8 +113,9 @@ func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest
|
||||
}
|
||||
shop.Creator = operatorID
|
||||
shop.Updater = operatorID
|
||||
if err := tx.Create(shop).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建店铺失败")
|
||||
// 分销码在创建时随机生成且唯一;冲突时重新生成并重试,不提供人工指定或编辑入口。
|
||||
if err := CreateShopWithDistributionCode(ctx, tx, shop); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account := &model.Account{
|
||||
@@ -201,6 +203,8 @@ 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
|
||||
@@ -285,7 +289,8 @@ 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, ParentID: shop.ParentID,
|
||||
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode,
|
||||
DistributionCode: shop.DistributionCode, ParentID: shop.ParentID,
|
||||
BusinessOwnerAccountID: shop.BusinessOwnerAccountID,
|
||||
ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName,
|
||||
ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City,
|
||||
|
||||
95
internal/application/shop/distribution_code.go
Normal file
95
internal/application/shop/distribution_code.go
Normal file
@@ -0,0 +1,95 @@
|
||||
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/RollbackTo:GORM 的嵌套事务会自行处理 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
|
||||
}
|
||||
@@ -16,8 +16,21 @@ import (
|
||||
|
||||
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
|
||||
type UpdateService struct {
|
||||
db *gorm.DB
|
||||
audit accessauditapp.Writer
|
||||
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 创建店铺更新事务脚本。
|
||||
@@ -81,6 +94,8 @@ 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, "更新店铺失败")
|
||||
}
|
||||
@@ -110,6 +125,14 @@ 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 {
|
||||
|
||||
@@ -317,6 +317,10 @@ 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{
|
||||
@@ -333,6 +337,70 @@ 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
|
||||
}
|
||||
@@ -353,14 +421,36 @@ func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComCon
|
||||
}
|
||||
|
||||
func validApprovalBusinessType(businessType string) bool {
|
||||
return businessType == constants.ApprovalBusinessTypeRefund || businessType == constants.ApprovalBusinessTypeOfflineRecharge
|
||||
switch businessType {
|
||||
case constants.ApprovalBusinessTypeRefund,
|
||||
constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
constants.ApprovalBusinessTypeEmployeeCollection,
|
||||
constants.ApprovalBusinessTypeAgentDistribution,
|
||||
constants.ApprovalBusinessTypeWithdrawalQualification,
|
||||
constants.ApprovalBusinessTypeCommissionWithdrawal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func approvalBusinessTypeName(businessType string) string {
|
||||
if businessType == constants.ApprovalBusinessTypeRefund {
|
||||
switch businessType {
|
||||
case 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 {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
businessUserGroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup"
|
||||
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
|
||||
h5PopupApp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
|
||||
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"
|
||||
@@ -15,16 +20,23 @@ import (
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
|
||||
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
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"
|
||||
businessUserGroupQuery "github.com/break/junhong_cmp_fiber/internal/query/businessusergroup"
|
||||
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"
|
||||
h5PopupQuery "github.com/break/junhong_cmp_fiber/internal/query/h5popup"
|
||||
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
|
||||
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
|
||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
|
||||
priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling"
|
||||
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
|
||||
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
|
||||
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
||||
@@ -56,7 +68,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
packageSeriesStore := postgres.NewPackageSeriesStore(deps.DB)
|
||||
shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(deps.DB)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis)
|
||||
businessUserGroupStore := postgres.NewBusinessUserGroupStore(deps.DB)
|
||||
carrierStore := postgres.NewCarrierStore(deps.DB)
|
||||
pollingPriorityItemStore := postgres.NewPollingPriorityItemStore(deps.DB)
|
||||
rechargeOrderStore := postgres.NewRechargeOrderStore(deps.DB, deps.Redis)
|
||||
paymentStore := postgres.NewPaymentStore(deps.DB, deps.Redis)
|
||||
commissionRecordStore := postgres.NewCommissionRecordStore(deps.DB, deps.Redis)
|
||||
@@ -86,7 +100,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
personalCustomerOpenIDStore,
|
||||
personalCustomerStore,
|
||||
personalCustomerPhoneStore,
|
||||
svc.WechatConfig,
|
||||
svc.Order,
|
||||
packageSeriesStore,
|
||||
shopSeriesAllocationStore,
|
||||
@@ -108,9 +121,13 @@ 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 {
|
||||
@@ -154,6 +171,25 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
))
|
||||
svc.Account.SetWeComMemberFinder(wecomMembers)
|
||||
|
||||
// H5 弹窗候选必须当次返回可用通知标识,因此 API 进程直接复用 Outbox 消费的同一套渲染、展示期与幂等写入规则。
|
||||
notificationAudit := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
|
||||
notificationDirectWriter := notificationApp.NewDeliveryService(
|
||||
notificationInfra.NewRepository(deps.DB), notificationInfra.NewRegistry(), nil, deps.Logger, notificationAudit,
|
||||
)
|
||||
// 资产标识解析复用既有 Store 方法,保证与资产详情、换货入口同一口径。
|
||||
candidateService := h5PopupApp.NewCandidateService(
|
||||
deps.DB,
|
||||
postgres.NewAssetIdentifierStore(deps.DB),
|
||||
postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
svc.CustomerBinding,
|
||||
notificationDirectWriter,
|
||||
)
|
||||
riskExchangeService := h5PopupApp.NewRiskExchangeService(deps.DB, svc.CustomerBinding, notificationAudit)
|
||||
popupConfigurationService := h5PopupApp.NewConfigurationService(deps.DB, notificationAudit)
|
||||
popupConfigurationQuery := h5PopupQuery.NewQuery(deps.DB)
|
||||
packageTrafficAlertQuery := packagetrafficalertquery.NewQuery(deps.DB)
|
||||
|
||||
return &Handlers{
|
||||
Auth: authHandler.NewHandler(svc.Auth, validate),
|
||||
Account: admin.NewAccountHandler(svc.Account),
|
||||
@@ -173,7 +209,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
return handler
|
||||
}(),
|
||||
ClientWallet: func() *app.ClientWalletHandler {
|
||||
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
|
||||
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
|
||||
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
|
||||
handler.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
|
||||
return handler
|
||||
@@ -192,22 +228,44 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
}(),
|
||||
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
|
||||
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB),
|
||||
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
|
||||
notificationApp.NewReadService(deps.DB, notificationAudit), validate),
|
||||
ClientPopup: app.NewClientPopupHandler(candidateService, riskExchangeService, validate),
|
||||
Shop: func() *admin.ShopHandler {
|
||||
handler := admin.NewShopHandler(svc.Shop, validate)
|
||||
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
|
||||
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB, svc.AccessAudit))
|
||||
updateService := shopApp.NewUpdateService(deps.DB, svc.AccessAudit)
|
||||
// 店铺停用必须联动失效提现资料资格,接入点在同一停用事务内。
|
||||
updateService.SetWithdrawalQualificationInvalidator(svc.WithdrawalQualification)
|
||||
handler.SetUpdateService(updateService)
|
||||
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
|
||||
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB, svc.AccessAudit))
|
||||
return handler
|
||||
}(),
|
||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||
ShopCommission: func() *admin.ShopCommissionHandler {
|
||||
handler := admin.NewShopCommissionHandler(svc.ShopCommission)
|
||||
handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB))
|
||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||
BusinessUserGroup: func() *admin.BusinessUserGroupHandler {
|
||||
handler := admin.NewBusinessUserGroupHandler(
|
||||
businessUserGroupApp.New(deps.DB, businessUserGroupStore, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)),
|
||||
validate,
|
||||
)
|
||||
handler.SetQuery(businessUserGroupQuery.NewQuery(deps.DB, businessUserGroupStore))
|
||||
batchService := shopApp.NewBatchBusinessOwnerService(deps.DB)
|
||||
batchService.SetBatchBusinessOwnerAudit(auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
|
||||
handler.SetBatchService(batchService)
|
||||
return handler
|
||||
}(),
|
||||
ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(svc.ShopBusinessOwnerImport),
|
||||
PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(svc.PhoneAssetAssociation, validate),
|
||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||
ShopCommission: func() *admin.ShopCommissionHandler {
|
||||
handler := admin.NewShopCommissionHandler(svc.ShopCommission, validate)
|
||||
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),
|
||||
@@ -218,7 +276,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
|
||||
ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
|
||||
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB),
|
||||
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
|
||||
notificationApp.NewReadService(deps.DB, notificationAudit), validate),
|
||||
H5PopupConfiguration: admin.NewH5PopupConfigurationHandler(popupConfigurationService, popupConfigurationQuery, validate),
|
||||
Device: admin.NewDeviceHandler(svc.Device),
|
||||
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
|
||||
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
|
||||
@@ -227,6 +286,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
||||
Package: admin.NewPackageHandler(svc.Package),
|
||||
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
||||
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate),
|
||||
AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(svc.AssetAutoRenewal, validate),
|
||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
||||
@@ -260,6 +321,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
|
||||
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
||||
PollingPriority: admin.NewPriorityPollingHandler(
|
||||
svc.PriorityPolling, priorityPollingQuery.NewQuery(deps.DB, pollingPriorityItemStore), validate,
|
||||
),
|
||||
Asset: func() *admin.AssetHandler {
|
||||
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
|
||||
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
|
||||
@@ -276,6 +340,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
h.SetObservationSeriesDispatcher(svc.ObservationSeries)
|
||||
h.SetPackageExpiryQuery(packageExpiry)
|
||||
h.SetPackageExpiryQueue(deps.QueueClient)
|
||||
h.SetExportTaskService(svc.ExportTask)
|
||||
h.SetValidator(validate)
|
||||
return h
|
||||
}(),
|
||||
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
|
||||
@@ -284,11 +350,25 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
h.SetAssetService(svc.Asset)
|
||||
return h
|
||||
}(),
|
||||
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||
PaymentMerchant: admin.NewPaymentMerchantHandler(merchantPaymentApp.NewManagementService(deps.DB, systemConfigAudit)),
|
||||
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),
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 {
|
||||
|
||||
@@ -7,9 +7,16 @@ import (
|
||||
|
||||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||||
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
|
||||
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"
|
||||
packagetrafficalertapp "github.com/break/junhong_cmp_fiber/internal/application/packagetrafficalert"
|
||||
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
|
||||
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
@@ -18,9 +25,11 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
|
||||
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
|
||||
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
|
||||
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
|
||||
"github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
|
||||
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
|
||||
agentOpenAPISvc "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api"
|
||||
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
|
||||
@@ -65,8 +74,10 @@ import (
|
||||
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
|
||||
operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password"
|
||||
orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
|
||||
phoneAssetAssociationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association"
|
||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
|
||||
shopBusinessOwnerImportSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_business_owner_import"
|
||||
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
|
||||
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
|
||||
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
|
||||
@@ -87,6 +98,9 @@ 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
|
||||
@@ -105,6 +119,8 @@ type services struct {
|
||||
Package *packageSvc.Service
|
||||
PackageDailyRecord *packageSvc.DailyRecordService
|
||||
PackageCustomerView *packageSvc.CustomerViewService
|
||||
PackageTrafficAlertRule *packagetrafficalertapp.RuleService
|
||||
AssetAutoRenewal *assetAutoRenewalApp.Service
|
||||
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
|
||||
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
|
||||
ShopSeriesGrant *shopSeriesGrantSvc.Service
|
||||
@@ -119,6 +135,7 @@ type services struct {
|
||||
PollingAlert *pollingSvc.AlertService
|
||||
PollingCleanup *pollingSvc.CleanupService
|
||||
PollingManualTrigger *pollingSvc.ManualTriggerService
|
||||
PriorityPolling *pollingSvc.PriorityEnqueueService
|
||||
Asset *assetSvc.Service
|
||||
AssetLifecycle *assetSvc.LifecycleService
|
||||
AssetWallet *assetWalletSvc.Service
|
||||
@@ -127,6 +144,7 @@ type services struct {
|
||||
AgentRecharge *agentRechargeSvc.Service
|
||||
AgentRechargeOnline *agentrechargeApp.OnlineCreationService
|
||||
AgentRechargePaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService
|
||||
AgentRechargeVoucherOCR *agentrechargeApp.PaymentVoucherOCRService
|
||||
PackageActivation *packageSvc.ActivationService
|
||||
Refund *refundSvc.Service
|
||||
TrafficQuery *trafficSvc.QueryService
|
||||
@@ -135,6 +153,8 @@ type services struct {
|
||||
CustomerBinding *customerBindingSvc.Service
|
||||
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
|
||||
AssetPackageBatchOrder *assetPackageBatchOrderSvc.Service
|
||||
ShopBusinessOwnerImport *shopBusinessOwnerImportSvc.Service
|
||||
PhoneAssetAssociation *phoneAssetAssociationSvc.Service
|
||||
ObservationSeries cardObservationApp.BestEffortSeriesDispatcher
|
||||
CardObservation *cardObservationApp.Service
|
||||
CardObservationSeries *cardObservationApp.SeriesAttemptService
|
||||
@@ -170,12 +190,17 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
iotCard.SetAccessAudit(auditWriter)
|
||||
cardObservationOutbox := outbox.NewRepository()
|
||||
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
|
||||
priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox)
|
||||
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
|
||||
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
|
||||
cardObservationService := cardObservationApp.NewService(
|
||||
deps.DB,
|
||||
cardObservationInfra.NewEventWriter(cardObservationOutbox),
|
||||
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
|
||||
)
|
||||
cardObservationService.SetStateAuditWriter(iotCard)
|
||||
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
|
||||
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
|
||||
iotCard.SetCardObservationService(cardObservationService)
|
||||
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
|
||||
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
|
||||
@@ -218,6 +243,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
)
|
||||
packageActivation.SetLifecycleAudit(auditWriter)
|
||||
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
packageActivation.SetPriorityEventWriter(priorityEvents)
|
||||
|
||||
stopResumeService := iotCardSvc.NewStopResumeService(
|
||||
deps.Redis,
|
||||
@@ -229,12 +255,16 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
)
|
||||
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
|
||||
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
|
||||
stopResumeService.SetPriorityEventWriter(priorityEvents)
|
||||
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
|
||||
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
|
||||
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
|
||||
iotCard.SetRealnameActivator(packageActivation)
|
||||
iotCard.SetStopResumeService(stopResumeService)
|
||||
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
|
||||
iotCard.SetEnterpriseCardAuthStore(s.EnterpriseCardAuthorization)
|
||||
iotCard.SetEnterpriseStore(s.Enterprise)
|
||||
iotCard.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
|
||||
iotCard.SetRedisClient(deps.Redis)
|
||||
device := deviceSvc.New(
|
||||
deps.DB,
|
||||
@@ -253,6 +283,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.Enterprise,
|
||||
)
|
||||
device.SetAccessAudit(auditWriter)
|
||||
device.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
|
||||
device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB))
|
||||
device.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
device.SetObservationSeriesDispatcher(observationSeries)
|
||||
@@ -264,9 +295,14 @@ 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))
|
||||
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
orderService.SetPriorityEventWriter(priorityEvents)
|
||||
walletOutbox := outbox.NewRepository()
|
||||
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
|
||||
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
|
||||
@@ -286,6 +322,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
paymentIntegration := integrationlog.NewRepository(deps.DB)
|
||||
agentRechargeOnline := agentrechargeApp.NewOnlineCreationService(
|
||||
deps.DB,
|
||||
merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis),
|
||||
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
|
||||
paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
|
||||
paymentInfra.NewFuiouScanAdapter(paymentIntegration, deps.Logger),
|
||||
@@ -296,6 +333,11 @@ 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,
|
||||
@@ -313,11 +355,23 @@ 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)
|
||||
// 渠道原路退款的登记与执行共用同一用例:API 侧只登记待执行事实与可靠事件,
|
||||
// 真正的渠道调用由 Worker 消费该事件执行。
|
||||
refundService.SetChannelRefundService(
|
||||
refundchannelApp.NewService(
|
||||
deps.DB,
|
||||
merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis),
|
||||
paymentInfra.NewRefundAdapter(wechat.NewRedisCache(deps.Redis), deps.Logger),
|
||||
auditWriter,
|
||||
).SetLogger(deps.Logger).SetCompletionNotifier(refundService),
|
||||
)
|
||||
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())))
|
||||
exchangeService.SetAccessAudit(auditWriter)
|
||||
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder)
|
||||
assetService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
|
||||
assetService.SetAccessAudit(auditWriter)
|
||||
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
|
||||
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
|
||||
@@ -348,6 +402,10 @@ 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),
|
||||
)
|
||||
@@ -359,6 +417,18 @@ 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,
|
||||
@@ -387,6 +457,12 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
pollingManualTriggerService := pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger)
|
||||
pollingManualTriggerService.SetAudit(deps.DB, auditWriter)
|
||||
|
||||
// 人工优先入队:与人工触发共用同包权限判定,但使用独立的优先提示通道与合并语义。
|
||||
priorityPollingService := pollingSvc.NewPriorityEnqueueService(
|
||||
deps.DB, s.IotCard, s.PollingPriorityItem, pollingQueueMgr, deps.Logger,
|
||||
)
|
||||
priorityPollingService.SetAudit(auditWriter)
|
||||
|
||||
return &services{
|
||||
AccessAudit: auditWriter,
|
||||
Approval: approvalCreationService,
|
||||
@@ -400,6 +476,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.PersonalCustomerOpenID,
|
||||
s.PersonalCustomer,
|
||||
s.PersonalCustomerPhone,
|
||||
s.PhoneAssetAssociation,
|
||||
s.IotCard,
|
||||
s.Device,
|
||||
wechatConfig,
|
||||
@@ -413,6 +490,9 @@ 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,
|
||||
@@ -431,6 +511,13 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
Package: packageService,
|
||||
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
|
||||
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
|
||||
PackageTrafficAlertRule: packagetrafficalertapp.NewRuleService(deps.DB, s.PackageTrafficAlert, auditWriter),
|
||||
AssetAutoRenewal: assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{
|
||||
DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger,
|
||||
Outbox: outbox.NewRepository(), AuditWriter: auditWriter,
|
||||
PurchaseValidation: purchaseValidation,
|
||||
Candidates: assetAutoRenewalQuery.NewQuery(deps.DB),
|
||||
}),
|
||||
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter),
|
||||
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter),
|
||||
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter),
|
||||
@@ -445,6 +532,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
PollingAlert: pollingAlertService,
|
||||
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
||||
PollingManualTrigger: pollingManualTriggerService,
|
||||
PriorityPolling: priorityPollingService,
|
||||
Asset: assetService,
|
||||
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, auditWriter),
|
||||
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
|
||||
@@ -453,6 +541,7 @@ 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,
|
||||
@@ -461,8 +550,13 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
CustomerBinding: customerBinding,
|
||||
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient, auditWriter),
|
||||
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient, auditWriter),
|
||||
ObservationSeries: observationSeries,
|
||||
CardObservation: cardObservationService,
|
||||
CardObservationSeries: cardObservationSeries,
|
||||
ShopBusinessOwnerImport: shopBusinessOwnerImportSvc.New(s.ShopBusinessOwnerImportTask, deps.QueueClient, auditWriter),
|
||||
PhoneAssetAssociation: phoneAssetAssociationSvc.New(
|
||||
deps.DB, s.PhoneAssetAssociation, s.PhoneAssetUnbindImportTask,
|
||||
s.AssetIdentifier, s.IotCard, s.Device, deps.QueueClient, auditWriter,
|
||||
),
|
||||
ObservationSeries: observationSeries,
|
||||
CardObservation: cardObservationService,
|
||||
CardObservationSeries: cardObservationSeries,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type stores struct {
|
||||
PersonalCustomerOpenID *postgres.PersonalCustomerOpenIDStore
|
||||
PersonalCustomerDevice *postgres.PersonalCustomerDeviceStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
PhoneAssetAssociation *postgres.PhoneAssetAssociationStore
|
||||
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
|
||||
CommissionRecord *postgres.CommissionRecordStore
|
||||
CommissionWithdrawalSetting *postgres.CommissionWithdrawalSettingStore
|
||||
@@ -51,6 +52,7 @@ type stores struct {
|
||||
DataCleanupConfig *postgres.DataCleanupConfigStore
|
||||
DataCleanupLog *postgres.DataCleanupLogStore
|
||||
PollingManualTriggerLog *postgres.PollingManualTriggerLogStore
|
||||
PollingPriorityItem *postgres.PollingPriorityItemStore
|
||||
// 代理钱包系统
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
@@ -69,8 +71,15 @@ type stores struct {
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
// 资产套餐批量订购任务
|
||||
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
|
||||
// 业务用户组与成员归属
|
||||
BusinessUserGroup *postgres.BusinessUserGroupStore
|
||||
// 店铺负责人 CSV 导入任务
|
||||
ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore
|
||||
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
|
||||
// 流量系统
|
||||
CardDailyUsage *postgres.CardDailyUsageStore
|
||||
// 套餐真流量预警规则与达量预警事实
|
||||
PackageTrafficAlert *postgres.PackageTrafficAlertStore
|
||||
// 资产标识符注册表
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
}
|
||||
@@ -89,6 +98,7 @@ func initStores(deps *Dependencies) *stores {
|
||||
PersonalCustomerOpenID: postgres.NewPersonalCustomerOpenIDStore(deps.DB),
|
||||
PersonalCustomerDevice: postgres.NewPersonalCustomerDeviceStore(deps.DB),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
PhoneAssetAssociation: postgres.NewPhoneAssetAssociationStore(deps.DB),
|
||||
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
|
||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||
CommissionWithdrawalSetting: postgres.NewCommissionWithdrawalSettingStore(deps.DB, deps.Redis),
|
||||
@@ -123,20 +133,25 @@ func initStores(deps *Dependencies) *stores {
|
||||
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
|
||||
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
||||
PollingManualTriggerLog: postgres.NewPollingManualTriggerLogStore(deps.DB),
|
||||
PollingPriorityItem: postgres.NewPollingPriorityItemStore(deps.DB),
|
||||
// 代理钱包系统
|
||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis),
|
||||
// 资产钱包系统
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
||||
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
|
||||
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
|
||||
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||
RefundRequest: postgres.NewRefundStore(deps.DB),
|
||||
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
||||
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
|
||||
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
|
||||
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||
RefundRequest: postgres.NewRefundStore(deps.DB),
|
||||
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
|
||||
BusinessUserGroup: postgres.NewBusinessUserGroupStore(deps.DB),
|
||||
ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB),
|
||||
PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB),
|
||||
PackageTrafficAlert: postgres.NewPackageTrafficAlertStore(deps.DB),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,13 @@ type Handlers struct {
|
||||
ClientDevice *app.ClientDeviceHandler
|
||||
ClientRechargeOrder *app.ClientRechargeOrderHandler
|
||||
ClientNotification *app.ClientNotificationHandler
|
||||
ClientPopup *app.ClientPopupHandler
|
||||
Shop *admin.ShopHandler
|
||||
ShopRole *admin.ShopRoleHandler
|
||||
AdminAuth *admin.AuthHandler
|
||||
ShopCommission *admin.ShopCommissionHandler
|
||||
WithdrawalQualification *admin.WithdrawalQualificationHandler
|
||||
AgentDistribution *app.AgentDistributionHandler
|
||||
CommissionWithdrawal *admin.CommissionWithdrawalHandler
|
||||
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
|
||||
Enterprise *admin.EnterpriseHandler
|
||||
@@ -39,6 +42,7 @@ type Handlers struct {
|
||||
IotCardImport *admin.IotCardImportHandler
|
||||
ExportTask *admin.ExportTaskHandler
|
||||
Notification *admin.NotificationHandler
|
||||
H5PopupConfiguration *admin.H5PopupConfigurationHandler
|
||||
Device *admin.DeviceHandler
|
||||
DeviceImport *admin.DeviceImportHandler
|
||||
AssetAllocationRecord *admin.AssetAllocationRecordHandler
|
||||
@@ -64,14 +68,22 @@ type Handlers struct {
|
||||
PollingAlert *admin.PollingAlertHandler
|
||||
PollingCleanup *admin.PollingCleanupHandler
|
||||
PollingManualTrigger *admin.PollingManualTriggerHandler
|
||||
PollingPriority *admin.PriorityPollingHandler
|
||||
Asset *admin.AssetHandler
|
||||
AssetLifecycle *admin.AssetLifecycleHandler
|
||||
AssetWallet *admin.AssetWalletHandler
|
||||
WechatConfig *admin.WechatConfigHandler
|
||||
PaymentMerchant *admin.PaymentMerchantHandler
|
||||
EmployeeCollection *admin.EmployeeCollectionHandler
|
||||
AgentRecharge *admin.AgentRechargeHandler
|
||||
Refund *admin.RefundHandler
|
||||
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
|
||||
AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler
|
||||
BusinessUserGroup *admin.BusinessUserGroupHandler
|
||||
ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler
|
||||
PhoneAssetAssociation *admin.PhoneAssetAssociationHandler
|
||||
PackageTrafficAlert *admin.PackageTrafficAlertHandler
|
||||
AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler
|
||||
ClientWechat *app.ClientWechatHandler
|
||||
SuperAdmin *admin.SuperAdminHandler
|
||||
SystemConfig *admin.SystemConfigHandler
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
|
||||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||||
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
|
||||
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
|
||||
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
|
||||
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
|
||||
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
|
||||
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
|
||||
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
@@ -95,6 +101,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
)
|
||||
cardObservationOutbox := outbox.NewRepository()
|
||||
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
|
||||
priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox)
|
||||
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
|
||||
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
|
||||
cardObservationService := cardObservationApp.NewService(
|
||||
deps.DB,
|
||||
cardObservationInfra.NewEventWriter(cardObservationOutbox),
|
||||
@@ -106,7 +115,10 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
deps.GatewayClient, deps.Logger,
|
||||
)
|
||||
iotCardAuditService.SetAccessAudit(auditWriter)
|
||||
iotCardAuditService.SetPhoneAssetAssociationStore(stores.PhoneAssetAssociation)
|
||||
cardObservationService.SetStateAuditWriter(iotCardAuditService)
|
||||
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
|
||||
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
|
||||
cardObservationIntegration := integrationlog.NewRepository(deps.DB)
|
||||
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
|
||||
cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService(
|
||||
@@ -149,6 +161,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil))
|
||||
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
|
||||
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
orderService.SetPriorityEventWriter(priorityEvents)
|
||||
|
||||
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
|
||||
stopResumeService := iotCardSvc.NewStopResumeService(
|
||||
@@ -160,8 +173,24 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
deps.Logger,
|
||||
)
|
||||
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
|
||||
stopResumeService.SetPriorityEventWriter(priorityEvents)
|
||||
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
|
||||
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
|
||||
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
|
||||
// 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。
|
||||
carrierThresholdService.SetCommander(carrierThresholdInfra.NewCardCommander(stopResumeService))
|
||||
// 资产钱包自动续费:复机执行同样复用既有停复机单一事实源,通知与复机都走公共 Outbox。
|
||||
assetAutoRenewalService := assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{
|
||||
DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger,
|
||||
Outbox: cardObservationOutbox, AuditWriter: auditWriter,
|
||||
PurchaseValidation: purchaseValidation,
|
||||
Candidates: assetAutoRenewalQuery.NewQuery(deps.DB),
|
||||
Resume: assetAutoRenewalInfra.NewCardCommander(stopResumeService),
|
||||
ObservationEvents: observationSeriesEvents,
|
||||
PriorityEvents: priorityEvents,
|
||||
})
|
||||
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
|
||||
activationService.SetPriorityEventWriter(priorityEvents)
|
||||
usageService.SetStopResumeCallback(stopResumeService)
|
||||
activationService.SetResumeCallback(stopResumeService)
|
||||
orderService.SetResumeCallback(stopResumeService)
|
||||
@@ -171,6 +200,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
stores.AssetAllocationRecord, stores.ShopPackageAllocation, stores.ShopSeriesAllocation,
|
||||
stores.PackageSeries, deps.GatewayClient, stores.AssetIdentifier, nil, nil,
|
||||
)
|
||||
deviceBatchAllocator.SetPhoneAssetAssociationStore(stores.PhoneAssetAssociation)
|
||||
|
||||
return &queue.WorkerServices{
|
||||
PaymentAudit: auditWriter,
|
||||
@@ -178,6 +208,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
CardObservation: cardObservationService,
|
||||
CardObservationSeries: cardObservationSeriesService,
|
||||
ObservationSeriesEvents: observationSeriesEvents,
|
||||
PriorityEvents: priorityEvents,
|
||||
CommissionCalculation: commissionCalculationService,
|
||||
CommissionStats: commissionStatsService,
|
||||
UsageService: usageService,
|
||||
@@ -186,6 +217,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
||||
AlertService: alertService,
|
||||
CleanupService: cleanupService,
|
||||
StopResumeService: stopResumeService,
|
||||
CarrierThreshold: carrierThresholdService,
|
||||
AssetAutoRenewal: assetAutoRenewalService,
|
||||
OrderExpirer: orderService,
|
||||
AssetPackageOrderCreator: orderService,
|
||||
DeviceBatchAllocator: deviceBatchAllocator,
|
||||
|
||||
@@ -6,105 +6,114 @@ import (
|
||||
)
|
||||
|
||||
type workerStores struct {
|
||||
AssetAllocationRecord *postgres.AssetAllocationRecordStore
|
||||
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||
IotCard *postgres.IotCardStore
|
||||
DeviceImportTask *postgres.DeviceImportTaskStore
|
||||
ExportTask *postgres.ExportTaskStore
|
||||
ExportShardTask *postgres.ExportShardTaskStore
|
||||
Device *postgres.DeviceStore
|
||||
DeviceSimBinding *postgres.DeviceSimBindingStore
|
||||
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||
CommissionRecord *postgres.CommissionRecordStore
|
||||
Shop *postgres.ShopStore
|
||||
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||
PackageSeries *postgres.PackageSeriesStore
|
||||
Order *postgres.OrderStore
|
||||
OrderItem *postgres.OrderItemStore
|
||||
Package *postgres.PackageStore
|
||||
PackageUsage *postgres.PackageUsageStore
|
||||
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
PollingAlertRule *postgres.PollingAlertRuleStore
|
||||
PollingAlertHistory *postgres.PollingAlertHistoryStore
|
||||
DataCleanupConfig *postgres.DataCleanupConfigStore
|
||||
DataCleanupLog *postgres.DataCleanupLogStore
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
|
||||
AssetAllocationRecord *postgres.AssetAllocationRecordStore
|
||||
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||
IotCard *postgres.IotCardStore
|
||||
DeviceImportTask *postgres.DeviceImportTaskStore
|
||||
ExportTask *postgres.ExportTaskStore
|
||||
ExportShardTask *postgres.ExportShardTaskStore
|
||||
Device *postgres.DeviceStore
|
||||
DeviceSimBinding *postgres.DeviceSimBindingStore
|
||||
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||
CommissionRecord *postgres.CommissionRecordStore
|
||||
Shop *postgres.ShopStore
|
||||
ShopSeriesAllocation *postgres.ShopSeriesAllocationStore
|
||||
PackageSeries *postgres.PackageSeriesStore
|
||||
Order *postgres.OrderStore
|
||||
OrderItem *postgres.OrderItemStore
|
||||
Package *postgres.PackageStore
|
||||
PackageUsage *postgres.PackageUsageStore
|
||||
PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
|
||||
PollingAlertRule *postgres.PollingAlertRuleStore
|
||||
PollingAlertHistory *postgres.PollingAlertHistoryStore
|
||||
DataCleanupConfig *postgres.DataCleanupConfigStore
|
||||
DataCleanupLog *postgres.DataCleanupLogStore
|
||||
AgentWallet *postgres.AgentWalletStore
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
PhoneAssetAssociation *postgres.PhoneAssetAssociationStore
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore
|
||||
ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore
|
||||
PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore
|
||||
}
|
||||
|
||||
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
stores := &workerStores{
|
||||
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
|
||||
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
|
||||
ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis),
|
||||
ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis),
|
||||
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
|
||||
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
|
||||
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
|
||||
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
|
||||
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
||||
Package: postgres.NewPackageStore(deps.DB),
|
||||
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
|
||||
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
|
||||
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
||||
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
|
||||
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
|
||||
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
|
||||
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
|
||||
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
|
||||
ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis),
|
||||
ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis),
|
||||
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
|
||||
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||
Shop: postgres.NewShopStore(deps.DB, deps.Redis),
|
||||
ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB),
|
||||
PackageSeries: postgres.NewPackageSeriesStore(deps.DB),
|
||||
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
||||
Package: postgres.NewPackageStore(deps.DB),
|
||||
PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis),
|
||||
PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis),
|
||||
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
||||
PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB),
|
||||
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
|
||||
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
PhoneAssetAssociation: postgres.NewPhoneAssetAssociationStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB),
|
||||
ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB),
|
||||
PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB),
|
||||
}
|
||||
|
||||
return &queue.WorkerStores{
|
||||
AssetAllocationRecord: stores.AssetAllocationRecord,
|
||||
IotCardImportTask: stores.IotCardImportTask,
|
||||
IotCard: stores.IotCard,
|
||||
DeviceImportTask: stores.DeviceImportTask,
|
||||
ExportTask: stores.ExportTask,
|
||||
ExportShardTask: stores.ExportShardTask,
|
||||
Device: stores.Device,
|
||||
DeviceSimBinding: stores.DeviceSimBinding,
|
||||
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
|
||||
ShopPackageAllocation: stores.ShopPackageAllocation,
|
||||
CommissionRecord: stores.CommissionRecord,
|
||||
Shop: stores.Shop,
|
||||
ShopSeriesAllocation: stores.ShopSeriesAllocation,
|
||||
PackageSeries: stores.PackageSeries,
|
||||
Order: stores.Order,
|
||||
OrderItem: stores.OrderItem,
|
||||
Package: stores.Package,
|
||||
PackageUsage: stores.PackageUsage,
|
||||
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
|
||||
PollingAlertRule: stores.PollingAlertRule,
|
||||
PollingAlertHistory: stores.PollingAlertHistory,
|
||||
DataCleanupConfig: stores.DataCleanupConfig,
|
||||
DataCleanupLog: stores.DataCleanupLog,
|
||||
AgentWallet: stores.AgentWallet,
|
||||
AgentWalletTransaction: stores.AgentWalletTransaction,
|
||||
AssetWallet: stores.AssetWallet,
|
||||
AssetIdentifier: stores.AssetIdentifier,
|
||||
PersonalCustomer: stores.PersonalCustomer,
|
||||
PersonalCustomerPhone: stores.PersonalCustomerPhone,
|
||||
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
|
||||
AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask,
|
||||
AssetAllocationRecord: stores.AssetAllocationRecord,
|
||||
IotCardImportTask: stores.IotCardImportTask,
|
||||
IotCard: stores.IotCard,
|
||||
DeviceImportTask: stores.DeviceImportTask,
|
||||
ExportTask: stores.ExportTask,
|
||||
ExportShardTask: stores.ExportShardTask,
|
||||
Device: stores.Device,
|
||||
DeviceSimBinding: stores.DeviceSimBinding,
|
||||
ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats,
|
||||
ShopPackageAllocation: stores.ShopPackageAllocation,
|
||||
CommissionRecord: stores.CommissionRecord,
|
||||
Shop: stores.Shop,
|
||||
ShopSeriesAllocation: stores.ShopSeriesAllocation,
|
||||
PackageSeries: stores.PackageSeries,
|
||||
Order: stores.Order,
|
||||
OrderItem: stores.OrderItem,
|
||||
Package: stores.Package,
|
||||
PackageUsage: stores.PackageUsage,
|
||||
PackageUsageDailyRecord: stores.PackageUsageDailyRecord,
|
||||
PollingAlertRule: stores.PollingAlertRule,
|
||||
PollingAlertHistory: stores.PollingAlertHistory,
|
||||
DataCleanupConfig: stores.DataCleanupConfig,
|
||||
DataCleanupLog: stores.DataCleanupLog,
|
||||
AgentWallet: stores.AgentWallet,
|
||||
AgentWalletTransaction: stores.AgentWalletTransaction,
|
||||
AssetWallet: stores.AssetWallet,
|
||||
AssetIdentifier: stores.AssetIdentifier,
|
||||
PersonalCustomer: stores.PersonalCustomer,
|
||||
PersonalCustomerPhone: stores.PersonalCustomerPhone,
|
||||
PhoneAssetAssociation: stores.PhoneAssetAssociation,
|
||||
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
|
||||
AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask,
|
||||
ShopBusinessOwnerImportTask: stores.ShopBusinessOwnerImportTask,
|
||||
PhoneAssetUnbindImportTask: stores.PhoneAssetUnbindImportTask,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,25 +24,28 @@ const (
|
||||
|
||||
// PaymentConfirmationFacts 是支付确认所需的渠道与本地权威事实。
|
||||
type PaymentConfirmationFacts struct {
|
||||
OrderType string
|
||||
ExpectedOrderType string
|
||||
PaymentMethod string
|
||||
RechargePaymentMethod string
|
||||
RechargePaymentChannel string
|
||||
PaymentConfigID uint
|
||||
RechargePaymentConfigID uint
|
||||
ConfirmedConfigID uint
|
||||
MerchantIdentity string
|
||||
ConfirmedMerchantIdentity string
|
||||
PaymentAmount int64
|
||||
RechargeAmount int64
|
||||
ConfirmedAmount int64
|
||||
PaymentOrderID uint
|
||||
RechargeID uint
|
||||
PaymentState PaymentState
|
||||
RechargeStatus int
|
||||
StoredTradeNo string
|
||||
ConfirmedTradeNo string
|
||||
OrderType string
|
||||
ExpectedOrderType string
|
||||
PaymentMethod string
|
||||
RechargePaymentMethod string
|
||||
RechargePaymentChannel string
|
||||
PaymentConfigID uint
|
||||
RechargePaymentConfigID uint
|
||||
ConfirmedConfigID uint
|
||||
FrozenMerchant bool
|
||||
FrozenMerchantPaymentMethod string
|
||||
FrozenMerchantProviderType string
|
||||
MerchantIdentity string
|
||||
ConfirmedMerchantIdentity string
|
||||
PaymentAmount int64
|
||||
RechargeAmount int64
|
||||
ConfirmedAmount int64
|
||||
PaymentOrderID uint
|
||||
RechargeID uint
|
||||
PaymentState PaymentState
|
||||
RechargeStatus int
|
||||
StoredTradeNo string
|
||||
ConfirmedTradeNo string
|
||||
}
|
||||
|
||||
// ValidatePaymentConfirmation 校验支付确认不变量,并返回是否属于完全一致的重复确认。
|
||||
@@ -60,7 +63,20 @@ func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
|
||||
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
|
||||
}
|
||||
identity := strings.TrimSpace(facts.MerchantIdentity)
|
||||
if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
|
||||
if facts.FrozenMerchant {
|
||||
frozenMethod := strings.TrimSpace(facts.FrozenMerchantPaymentMethod)
|
||||
providerType := strings.TrimSpace(facts.FrozenMerchantProviderType)
|
||||
if identity == "" || frozenMethod != method || providerType == "" {
|
||||
return false, errors.New(errors.CodeConflict, "冻结商户支付事实不一致")
|
||||
}
|
||||
expectedChannel := method
|
||||
if method == constants.RechargeMethodWechat && providerType == model.ProviderTypeFuiou {
|
||||
expectedChannel = model.ProviderTypeFuiou
|
||||
}
|
||||
if channel != expectedChannel {
|
||||
return false, errors.New(errors.CodeConflict, "支付渠道与冻结商户不一致")
|
||||
}
|
||||
} else if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
|
||||
facts.PaymentConfigID != facts.ConfirmedConfigID || identity == "" ||
|
||||
identity != strings.TrimSpace(facts.ConfirmedMerchantIdentity) {
|
||||
return false, errors.New(errors.CodeConflict, "支付配置身份与创建记录不一致")
|
||||
|
||||
45
internal/domain/carrierthreshold/command.go
Normal file
45
internal/domain/carrierthreshold/command.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// SubmissionQueryWindow 是停/复机子任务自提交起允许自动查询确认的窗口。
|
||||
//
|
||||
// 语义:子任务进入 submitted 后,恢复扫描只查询运营商状态回填;超过该窗口仍无法确认结果的
|
||||
// 锁会标记异常并转人工处理,不再每分钟重复查询同一笔无法收敛的结果,也不自动删除锁。
|
||||
const SubmissionQueryWindow = 30 * time.Minute
|
||||
|
||||
// SubmissionExpired 判断子任务自提交起是否已超过自动查询窗口。
|
||||
// 未提交(submittedAt 为空)不算超期。
|
||||
func SubmissionExpired(submittedAt *time.Time, now time.Time) bool {
|
||||
if submittedAt == nil {
|
||||
return false
|
||||
}
|
||||
return now.Sub(*submittedAt) >= SubmissionQueryWindow
|
||||
}
|
||||
|
||||
// CommandOutcome 是一次运营商停复机调用可安全记录的结果摘要。
|
||||
//
|
||||
// 它只承载回填可靠任务状态所需的事实:Integration Log 标识、结果分类与可安全展示的原因,
|
||||
// 不携带渠道报文原文、凭证或内部错误细节。
|
||||
type CommandOutcome struct {
|
||||
// IntegrationID 是本次 Gateway 调用的 Integration Log 标识,失败重试时取最后一次尝试。
|
||||
IntegrationID string
|
||||
// Result 取值 constants.AuditResultSuccess / AuditResultFailed / AuditResultUnknown。
|
||||
Result string
|
||||
// SafeReason 是可安全对外展示的失败原因,成功时为空。
|
||||
SafeReason string
|
||||
}
|
||||
|
||||
// Confirmed 判断本次运营商调用是否已明确成功。
|
||||
func (o CommandOutcome) Confirmed() bool {
|
||||
return o.Result == constants.AuditResultSuccess
|
||||
}
|
||||
|
||||
// Unresolved 判断本次运营商调用结果是否未知(必须由恢复扫描查询收敛)。
|
||||
func (o CommandOutcome) Unresolved() bool {
|
||||
return o.Result == constants.AuditResultUnknown
|
||||
}
|
||||
18
internal/domain/carrierthreshold/evaluation.go
Normal file
18
internal/domain/carrierthreshold/evaluation.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package carrierthreshold
|
||||
|
||||
import "time"
|
||||
|
||||
// Evaluation 是一次通道阈值达量判定所需的卡与读数事实。
|
||||
//
|
||||
// 读数只来自运营商回传的网关累计读数(IoT 卡的 last_gateway_reading_mb),
|
||||
// 不使用本地用量统计、当月用量或套餐真流量;ObservedAt 决定该读数所属的计费周期。
|
||||
type Evaluation struct {
|
||||
// CardID 是触发判定的物联网卡 ID。
|
||||
CardID uint
|
||||
// CarrierID 是卡当前所属运营商 ID,决定阈值配置与周期起点。
|
||||
CarrierID uint
|
||||
// ReadingMB 是本次已接受的运营商网关累计读数。
|
||||
ReadingMB float64
|
||||
// ObservedAt 是本次读数的观测时刻。
|
||||
ObservedAt time.Time
|
||||
}
|
||||
50
internal/domain/carrierthreshold/lock.go
Normal file
50
internal/domain/carrierthreshold/lock.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package carrierthreshold
|
||||
|
||||
// 周期锁整行生命周期取值,与 tb_carrier_traffic_threshold_lock.status 的 CHECK 一致。
|
||||
const (
|
||||
// LockStatusLocked 表示该卡在该计费周期内持有通道阈值停机锁,周期内拒绝一切复机。
|
||||
LockStatusLocked = "locked"
|
||||
// LockStatusUnlocked 表示已跨期解除通道阈值锁,锁行保留为历史事实。
|
||||
LockStatusUnlocked = "unlocked"
|
||||
)
|
||||
|
||||
// 停复机子任务状态取值,与 stop_status / resume_status 的 CHECK 一致。
|
||||
const (
|
||||
// TaskStatusPending 表示子任务待提交,达量判定写锁时停机子任务处于该状态。
|
||||
TaskStatusPending = "pending"
|
||||
// TaskStatusSubmitted 表示子任务已提交待确认,认领成功后处于该状态。
|
||||
TaskStatusSubmitted = "submitted"
|
||||
// TaskStatusConfirmed 表示运营商调用或状态查询已明确成功。
|
||||
TaskStatusConfirmed = "confirmed"
|
||||
// TaskStatusFailed 表示运营商明确失败。
|
||||
TaskStatusFailed = "failed"
|
||||
// TaskStatusUnknown 表示结果未知,交由恢复扫描查询收敛。
|
||||
TaskStatusUnknown = "unknown"
|
||||
)
|
||||
|
||||
// 异常标记取值,与 anomaly_flag 的 CHECK 一致。
|
||||
const (
|
||||
// AnomalyFlagNone 表示无需人工核对。
|
||||
AnomalyFlagNone = 0
|
||||
// AnomalyFlagManual 表示查询窗口超期或失败无法自动确认,已转人工核对并退出自动扫描。
|
||||
AnomalyFlagManual = 1
|
||||
)
|
||||
|
||||
// UnresolvedTaskStatuses 返回仍需由恢复扫描查询运营商状态收敛的子任务状态集合。
|
||||
//
|
||||
// 语义:submitted 表示已提交待确认,unknown 表示结果未知,failed 表示调用明确失败但运营商侧
|
||||
// 状态仍可能已生效(例如请求已到达而响应超时/异常)——三者都必须继续用只读状态查询确认,
|
||||
// 因此恢复扫描的查询谓词与超期判定共用本集合,避免「失败或结果未知」的锁行退出收敛链路。
|
||||
// pending 表示尚未对运营商发起过调用,confirmed 是终态,都不属于本集合。
|
||||
func UnresolvedTaskStatuses() []string {
|
||||
return []string{TaskStatusSubmitted, TaskStatusUnknown, TaskStatusFailed}
|
||||
}
|
||||
|
||||
// IsUnresolvedTaskStatus 判断子任务状态是否仍需恢复扫描收敛。
|
||||
func IsUnresolvedTaskStatus(status string) bool {
|
||||
switch status {
|
||||
case TaskStatusSubmitted, TaskStatusUnknown, TaskStatusFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
29
internal/domain/carrierthreshold/period.go
Normal file
29
internal/domain/carrierthreshold/period.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// shanghaiLocation 是计费周期边界的固定时区。
|
||||
// 中国自 1991 年起不实行夏令时,使用固定偏移可避免依赖宿主机 tzdata。
|
||||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
// PeriodStart 计算 now 所属计费周期的起点(上海时区零点)。
|
||||
//
|
||||
// 周期起点 = 本月重置日 0 点,若 now 早于本月重置日 0 点则取上月重置日 0 点。
|
||||
// resetDay 由该卡所属运营商(锁行自身的 carrier)的 data_reset_day 提供,合法范围 1-28,
|
||||
// 因此每个月都有定义。该口径与 isTrafficResetWindow(判断读数回落是否为合法清零的观测窗口)
|
||||
// 是两个独立口径,互不修改。
|
||||
func PeriodStart(now time.Time, resetDay int) (time.Time, error) {
|
||||
if resetDay < MinResetDay || resetDay > MaxResetDay {
|
||||
return time.Time{}, errors.New(errors.CodeInvalidParam, "运营商上游流量重置日必须在 1-28 之间")
|
||||
}
|
||||
local := now.In(shanghaiLocation)
|
||||
current := time.Date(local.Year(), local.Month(), resetDay, 0, 0, 0, 0, shanghaiLocation)
|
||||
if !local.Before(current) {
|
||||
return current, nil
|
||||
}
|
||||
return current.AddDate(0, -1, 0), nil
|
||||
}
|
||||
69
internal/domain/carrierthreshold/threshold.go
Normal file
69
internal/domain/carrierthreshold/threshold.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Package carrierthreshold 定义运营商通道流量阈值的领域规则。
|
||||
//
|
||||
// 本包只表达与传输、持久化无关的纯规则:阈值单位与 GB→MB 换算、按上游流量重置日计算的
|
||||
// 计费周期起点、以及周期锁与停复机子任务的状态取值。运营商通道即既有 Carrier,
|
||||
// 计费周期与网关计数器清零周期是同一事实,因此周期起点只由 carrier.data_reset_day 决定。
|
||||
package carrierthreshold
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// 阈值单位枚举:只允许 MB 与 GB,换算后统一以 MB 与网关累计读数比较。
|
||||
const (
|
||||
UnitMB = "MB"
|
||||
UnitGB = "GB"
|
||||
)
|
||||
|
||||
// MBPerGB 是 GB→MB 的固定换算系数(1 GB = 1024 MB)。
|
||||
const MBPerGB = 1024
|
||||
|
||||
// 计费周期起点的合法重置日范围:1-28 保证每个月都有定义,不存在 2 月 30 日问题。
|
||||
const (
|
||||
MinResetDay = 1
|
||||
MaxResetDay = 28
|
||||
)
|
||||
|
||||
// Threshold 是通道阈值配置的领域值。
|
||||
type Threshold struct {
|
||||
// Enabled 为 true 表示该通道参与达量停机判定。
|
||||
Enabled bool
|
||||
// Value 是阈值数值,启用时必须为正数。
|
||||
Value float64
|
||||
// Unit 是阈值单位,取值 UnitMB 或 UnitGB。
|
||||
Unit string
|
||||
}
|
||||
|
||||
// Valid 判断阈值配置是否为可用于判定的完整配置。
|
||||
// 未启用、数值非正、单位未知都视为不可判定;不可判定必须跳过判定而不是按 0 停机。
|
||||
func (t Threshold) Valid() bool {
|
||||
if !t.Enabled || t.Value <= 0 {
|
||||
return false
|
||||
}
|
||||
return t.Unit == UnitMB || t.Unit == UnitGB
|
||||
}
|
||||
|
||||
// LimitMB 返回换算为 MB 的阈值上限;单位未知返回稳定参数错误。
|
||||
func (t Threshold) LimitMB() (float64, error) {
|
||||
switch t.Unit {
|
||||
case UnitMB:
|
||||
return t.Value, nil
|
||||
case UnitGB:
|
||||
return t.Value * MBPerGB, nil
|
||||
default:
|
||||
return 0, errors.New(errors.CodeInvalidParam, "通道流量阈值单位仅支持 MB 与 GB")
|
||||
}
|
||||
}
|
||||
|
||||
// Reached 判断运营商回传的当前周期累计读数是否达到或超过阈值。
|
||||
// readingMB 只来自网关累计读数(last_gateway_reading_mb),不使用本地用量或套餐真流量。
|
||||
func (t Threshold) Reached(readingMB float64) (bool, error) {
|
||||
if !t.Valid() {
|
||||
return false, nil
|
||||
}
|
||||
limitMB, err := t.LimitMB()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return readingMB >= limitMB, nil
|
||||
}
|
||||
199
internal/domain/distribution/distribution.go
Normal file
199
internal/domain/distribution/distribution.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// 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 == "" {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不能为空")
|
||||
}
|
||||
if input.Phone == "" {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号不能为空")
|
||||
}
|
||||
if input.Username == "" {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名不能为空")
|
||||
}
|
||||
if input.ShopName == "" {
|
||||
return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "店铺名称不能为空")
|
||||
}
|
||||
if 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
|
||||
}
|
||||
58
internal/domain/employeecollection/allocation.go
Normal file
58
internal/domain/employeecollection/allocation.go
Normal file
@@ -0,0 +1,58 @@
|
||||
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
|
||||
}
|
||||
110
internal/domain/employeecollection/application.go
Normal file
110
internal/domain/employeecollection/application.go
Normal file
@@ -0,0 +1,110 @@
|
||||
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:])
|
||||
}
|
||||
51
internal/domain/employeecollection/approval_opinion.go
Normal file
51
internal/domain/employeecollection/approval_opinion.go
Normal file
@@ -0,0 +1,51 @@
|
||||
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 ""
|
||||
}
|
||||
173
internal/domain/employeecollection/bill.go
Normal file
173
internal/domain/employeecollection/bill.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// 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
|
||||
}
|
||||
56
internal/domain/employeecollection/order_bill.go
Normal file
56
internal/domain/employeecollection/order_bill.go
Normal file
@@ -0,0 +1,56 @@
|
||||
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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user