27 Commits

Author SHA1 Message Date
6f8db180fb 补齐支付商户池字段备注
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m41s
2026-09-10 11:03:20 +08:00
a9e2302f7c 1 2026-09-10 10:53:07 +08:00
bcb1304937 修复支付商户路由权限泄漏
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m8s
registerPaymentMerchantRoutes 使用空前缀 Group 承载权限中间件,
Fiber 将其等价于在整个 /api/admin 上 Use,导致代理账号访问
/api/admin/agent-recharges 等无关路由被误拦。

改为 requirePaymentMerchantAccess 包装函数,仅包装 payment-merchants、
payment-merchant-pools、wechat-authorizations 共 13 个管理路由,
路径、方法与权限语义不变。
2026-09-10 10:00:53 +08:00
88d7965641 固定测试镜像迁移工具版本
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m31s
2026-09-09 18:34:23 +08:00
e09c4632fb 修正测试环境按提交镜像部署
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 6m5s
Gitea workflow 只以 github.sha 构建/推送镜像标签,部署时写入
IMAGE_TAG 到 .env;compose 的 api/worker image 引用 IMAGE_TAG,
默认 latest 仅作本地兜底,测试部署必须解析为提交 SHA。
2026-09-09 18:18:13 +08:00
98c145fe70 实现支付商户池与微信授权配置
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m55s
新增收款商户、商户池轮询、微信授权配置独立管理;三类新支付
(C端套餐购买、C端资产钱包充值、代理在线预存款充值)无条件
经商户池选择并冻结路由,无旧综合配置回退。merchant_id 为空
历史支付继续按 payment_config_id 双读。凭证版本化加载与
ID+版本缓存保证轮换一致性。删除商户池新支付创建开关及全部
引用。
2026-09-09 18:13:04 +08:00
ff25586dc9 修复已用完套餐展示
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m43s
2026-09-08 17:25:34 +08:00
a48ff5d782 切换测试环境部署至八月迭代分支
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m36s
2026-09-07 17:32:19 +08:00
696120ab38 feat: 资产套餐历史增加主子层级查询 2026-09-07 17:17:15 +08:00
c7c2b17d78 归档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 54s
2026-09-07 11:34:23 +08:00
3a093ecd6b 清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m36s
2026-09-07 10:09:51 +08:00
5424751993 修复停复机忽略免实名策略并归档变更
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-09-07 10:06:17 +08:00
370fd3e67f update
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m49s
2026-09-03 09:28:28 +08:00
dbfeeee253 缓解io压力 2026-09-02 16:14:27 +08:00
395e5fb47c 修复套餐接续停机竞态与轮询兜底
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m13s
2026-08-29 16:27:48 +08:00
62f3d25e81 修复批量订购 2026-08-26 14:54:26 +08:00
5797fd0e94 修复企微兜底机制
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m40s
2026-08-20 18:06:45 +08:00
ba677a35e1 Update deploy.yaml
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 28h53m35s
2026-08-20 12:06:58 +08:00
22b95db2f9 每日清理 2026-08-20 12:03:17 +08:00
143df60485 修复 2026-08-18 17:32:15 +08:00
656a921ff0 富友支付支持 2026-08-18 17:13:20 +08:00
46c8e819df 修改相关证据 2026-08-18 16:30:16 +08:00
247d7d9f6e 新增接口 2026-08-18 16:15:46 +08:00
d256f6d176 合并七月迭代分支 2026-08-18 14:53:29 +08:00
7029104e5c 让迁移套餐恢复月流量重置调度
缺少 next_reset_at 时,轮询根据已有激活时间或到期时间与套餐天数推算下一重置点;已有值通过查询条件和条件更新双重保护,不会被覆盖。

Constraint: 兼容迁移套餐缺少 activated_at 与 next_reset_at 的历史数据
Rejected: 单次 SQL 人工回填 | 后续迁移数据仍可能再次遗漏
Confidence: high
Scope-risk: narrow
Directive: 保持 next_reset_at 非空记录不可覆盖
Not-tested: 按用户要求未运行测试
2026-08-05 14:33:16 +08:00
a0de08d789 避免套餐过期后排队权益永久失联
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m6s
线上保持现有纯 Asynq 架构,以公平孤儿扫描和提交后投递消除永久饥饿及事务可见性竞态。

Constraint: 线上保持现有纯 Asynq 架构,不引入 Outbox、迁移或新任务基础设施。

Rejected: 事务内投递或扩大扫描 LIMIT | 无法消除竞态和永久饥饿。

Confidence: high

Scope-risk: narrow

Directive: 后续分支整合时按目标分支的套餐接续架构独立处理,不混用本热修实现。

Tested: go build ./...(退出码 0);git diff --check;openspec validate fix-main-package-activation-starvation --strict。

Not-tested: 按用户要求未新增、修改或运行自动化测试;线上 SQL、查询计划和日志待部署后核验。
2026-08-03 09:58:05 +08:00
1efb665619 fix: 修正排队顺延套餐激活时错误按下单时间计算生效日期
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m57s
activatePendingUsage 被"前一个主套餐到期后顺延激活下一个待生效套餐"和
"等待实名认证后激活"两种场景共用,但其中 ExpiryBase=from_purchase 计时
基准分支(REALNAME-04)本来只为后者设计,却被无差别套用到前者。

导致主套餐配置为 from_purchase 且需要排队等待前一个套餐到期才能生效的
套餐,激活时错误地把生效时间算成下单时间,而不是真正开始生效的那一刻,
使到期时间提前了排队等待的天数,客户少享受了相应天数的服务。

现改为只有当 usage.PendingRealnameActivation 为 true(确实是在等实名)
时才按 ExpiryBase 选择计时基准,纯排队顺延场景一律使用当前时刻,即顺延
语义。
2026-07-20 11:56:25 +09:00
311 changed files with 21592 additions and 17802 deletions

View File

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

2
.gitignore vendored
View File

@@ -111,6 +111,4 @@ scripts/batch_package_purchase/assets.example_购买结果_20260715_115804.csv
scripts/batch_package_purchase/assets.example_购买结果_20260715_115814.csv
scripts/migration/output
# LongHorizon 本地执行证据
.lh-harness/
.scratch/go-build-cache

File diff suppressed because it is too large Load Diff

1757
111.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -38,10 +38,10 @@
- 行为变化必须通过独立 OpenSpec Change主 Specs 只描述当前行为。
- 当前 Bug 与兼容行为按实际结果记录,禁止在基线任务中顺手修复。
- 不修改既有迁移;新 Schema 变化使用新的成对迁移。
- 不访问生产服务、真实支付渠道或外部审批系统进行自动验证。
- 生产环境为 systemd 管理的手工二进制发布,和仓库 Docker/CI 测试环境不同;生产发布、迁移与回滚事实见 [`docs/deployment/production-runbook.md`](docs/deployment/production-runbook.md)。Agent 不连接生产主机或数据库,生产操作由维护者执行并提供结果。
- 不访问真实支付渠道或外部审批系统进行自动验证。
- 生产环境为 systemd 管理的手工二进制发布,和仓库 Docker/CI 测试环境不同;生产发布、迁移与回滚事实见 [`docs/deployment/production-runbook.md`](docs/deployment/production-runbook.md)。Agent 可仅通过 dbhub 对生产数据库执行只读诊断查询;不连接生产主机,生产发布、迁移、回滚及其他写操作由维护者执行并提供结果,除非明确要求
- 不把密钥、Token、证书或个人敏感数据写入代码、文档和日志。
- `.lh-harness/` 仅保存本地执行证据,不是事实源且不得纳入 Git
- `docs/verification/context-reset/` 仅保存上下文健康检查证据,不是业务事实源;证据应随项目文档维护
- 自动化测试当前为 N/A用户决策不恢复旧测试也不写虚假测试入口。
## 架构选择
@@ -84,6 +84,7 @@ openspec validate --all
```
启动、隔离数据库重置、smoke 与日志读取见 [`README.md`](README.md)。迁移使用 `scripts/migrate.sh` 和显式 `DB_*` 参数;生产迁移仅按生产运行说明由维护者手工执行。
- 测试验证涉及迁移、Redis、部署或集成 Smoke 时MUST 读取 [`docs/engineering/工程约束.md`](docs/engineering/工程约束.md) 的 ENG-TEST-001维护者指定的测试环境是唯一验证面。
## 渐进披露

View File

@@ -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: 运行阶段

1047
README.md

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +1,4 @@
// Command audit-retention-simulate 在测试环境演练完整自然月归档与清理边界。
// Command audit-retention-simulate 在测试环境演练逐日归档与清理边界。
package main
import (
@@ -6,22 +6,21 @@ import (
"crypto/sha256"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"gorm.io/datatypes"
"gorm.io/gorm"
auditarchive "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
taskapp "github.com/break/junhong_cmp_fiber/internal/task"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/database"
logpkg "github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
@@ -67,6 +66,11 @@ func run(ctx context.Context) error {
return fmt.Errorf("仅允许显式确认的测试数据库,当前数据库为 %q", cfg.Database.DBName)
}
logger := zap.NewNop()
if err := os.MkdirAll(filepath.Dir(cfg.Logging.RetentionLog.Filename), 0755); err != nil {
return err
}
retentionLogger, syncRetentionLogger := logpkg.NewRetentionLogger(cfg.Logging.Level, logpkg.LogRotationConfig{Filename: cfg.Logging.RetentionLog.Filename, MaxSize: cfg.Logging.RetentionLog.MaxSize, MaxBackups: cfg.Logging.RetentionLog.MaxBackups, MaxAge: cfg.Logging.RetentionLog.MaxAge, Compress: cfg.Logging.RetentionLog.Compress})
defer func() { _ = syncRetentionLogger() }()
db, err := database.InitPostgreSQL(&cfg.Database, logger)
if err != nil {
return err
@@ -81,8 +85,7 @@ func run(ctx context.Context) error {
if err != nil {
return err
}
auditWriter := auditinfra.NewWriter(auditinfra.NewRegistry(), nil)
service, err := auditarchive.NewService(db, provider, simulationInstance, auditWriter)
service, err := auditarchive.NewService(db, provider, simulationInstance)
if err != nil {
return err
}
@@ -108,14 +111,17 @@ func run(ctx context.Context) error {
}
}
payload, _ := sonic.Marshal(taskapp.AuditMonthlyRetentionPayload{ArchiveMonth: simulationMonth})
dryRunTask := asynq.NewTask(constants.TaskTypeAuditMonthlyRetention, payload)
if err := taskapp.NewAuditMonthlyRetentionHandler(service, logger, false).Handle(ctx, dryRunTask); err != nil {
return fmt.Errorf("月度只读演练失败: %w", err)
}
dryRun, err := service.ValidateMonth(ctx, monthStart)
if err != nil {
return err
var dryRun auditarchive.RetentionResult
for date := monthStart; date.Before(monthEnd); date = date.AddDate(0, 0, 1) {
result, retainErr := service.RetainDate(ctx, date, false)
if retainErr != nil {
return fmt.Errorf("逐日只读演练失败: %w", retainErr)
}
retentionLogger.Info("日留存仿真只读校验通过", zap.String("archive_date", result.ArchiveDate))
dryRun.EventCount += result.EventCount
dryRun.ResourceCount += result.ResourceCount
dryRun.IntegrationCount += result.IntegrationCount
dryRun.EstimatedBatches += result.EstimatedBatches
}
summary, err := collectBeforeCleanup(ctx, db, monthStart, monthEnd, dryRun)
if err != nil {
@@ -125,16 +131,39 @@ func run(ctx context.Context) error {
return fmt.Errorf("只归档模式写入了 %d 个清理断点", summary.CleanupMarkersBefore)
}
cleanupTask := asynq.NewTask(constants.TaskTypeAuditMonthlyRetention, payload)
if err := taskapp.NewAuditMonthlyRetentionHandler(service, logger, true).Handle(ctx, cleanupTask); err != nil {
return fmt.Errorf("隔离测试库物理清理演练失败: %w", err)
for date := monthStart; date.Before(monthEnd); date = date.AddDate(0, 0, 1) {
result, retainErr := service.RetainDate(ctx, date, true)
if retainErr != nil {
return fmt.Errorf("隔离测试库逐日物理清理演练失败: %w", retainErr)
}
retentionLogger.Info("日留存仿真物理清理完成", zap.String("archive_date", result.ArchiveDate))
}
if err := collectAfterCleanup(ctx, db, monthStart, monthEnd, &summary); err != nil {
return err
}
if summary.TargetRowsAfterCleanup != 0 || summary.BoundaryRowsAfterCleanup != 6 || summary.CleanupMarkersAfter != int64(summary.Days*2) || !summary.RetentionAuditRecorded {
return fmt.Errorf("清理范围复核失败: target=%d boundary=%d markers=%d audit=%t",
summary.TargetRowsAfterCleanup, summary.BoundaryRowsAfterCleanup, summary.CleanupMarkersAfter, summary.RetentionAuditRecorded)
if summary.TargetRowsAfterCleanup != 1 || summary.BoundaryRowsAfterCleanup != 6 || summary.CleanupMarkersAfter != int64(summary.Days*2) {
return fmt.Errorf("清理范围复核失败: target=%d boundary=%d markers=%d", summary.TargetRowsAfterCleanup, summary.BoundaryRowsAfterCleanup, summary.CleanupMarkersAfter)
}
if err := db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("integration_id = ?", "int_"+simulationPrefix+"-"+monthStart.Format("20060102")).
Updates(map[string]any{"result": constants.IntegrationResultSuccess, "updated_at": time.Now()}).Error; err != nil {
return err
}
if _, err := service.RetainDate(ctx, monthStart, true); err != nil {
return fmt.Errorf("pending 终结后的续跑清理演练失败: %w", err)
}
var remaining int64
if err := db.WithContext(ctx).Model(&model.IntegrationLog{}).Where("created_at >= ? AND created_at < ?", monthStart, monthEnd).Count(&remaining).Error; err != nil {
return err
}
if remaining != 0 {
return fmt.Errorf("pending 终结后的续跑清理未完成,剩余 %d 条记录", remaining)
}
if err := syncRetentionLogger(); err != nil {
return fmt.Errorf("刷新日留存仿真日志失败: %w", err)
}
if _, err := os.Stat(cfg.Logging.RetentionLog.Filename); err != nil {
return fmt.Errorf("独立日留存日志未生成: %w", err)
}
encoded, _ := sonic.MarshalIndent(summary, "", " ")
fmt.Println(string(encoded))
@@ -256,12 +285,7 @@ func archiveMonth(ctx context.Context, db *gorm.DB, service *auditarchive.Servic
if err := service.ArchiveDate(ctx, start); err != nil {
return fmt.Errorf("Audit 故障重试演练失败: %w", err)
}
if err := db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("integration_id = ?", "int_"+simulationPrefix+"-"+start.Format("20060102")).
Updates(map[string]any{"result": constants.IntegrationResultSuccess, "updated_at": time.Now()}).Error; err != nil {
return err
}
return service.FinalizeIntegrationMonth(ctx, start)
return nil
}
func collectBeforeCleanup(ctx context.Context, db *gorm.DB, start, end time.Time, dryRun auditarchive.RetentionResult) (simulationSummary, error) {
@@ -311,10 +335,5 @@ func collectAfterCleanup(ctx context.Context, db *gorm.DB, start, end time.Time,
Count(&summary.CleanupMarkersAfter).Error; err != nil {
return err
}
var auditCount int64
if err := db.WithContext(ctx).Model(&model.AuditEvent{}).Where("event_id = ?", "evt_retention_"+strings.ReplaceAll(simulationMonth, "-", "_")).Count(&auditCount).Error; err != nil {
return err
}
summary.RetentionAuditRecorded = auditCount == 1
return nil
}

View File

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

View File

@@ -18,6 +18,7 @@ import (
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
@@ -122,6 +123,11 @@ func runWorker(cfg *config.Config) {
defer func() {
_ = logger.Sync() // 忽略 sync 错误
}()
retentionLogger, syncRetentionLogger := logger.NewRetentionLogger(cfg.Logging.Level, logger.LogRotationConfig{
Filename: cfg.Logging.RetentionLog.Filename, MaxSize: cfg.Logging.RetentionLog.MaxSize,
MaxBackups: cfg.Logging.RetentionLog.MaxBackups, MaxAge: cfg.Logging.RetentionLog.MaxAge, Compress: cfg.Logging.RetentionLog.Compress,
})
defer func() { _ = syncRetentionLogger() }()
appLogger := logger.GetAppLogger()
ctx, cancel := context.WithCancel(context.Background())
@@ -148,7 +154,7 @@ func runWorker(cfg *config.Config) {
taskHandler.RegisterHandlers()
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, 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)
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
@@ -281,6 +287,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingIotCardStore,
appLogger,
cfg.Polling.VerboseLog,
cfg.Worker.PollingTotalMaxConcurrency,
)
pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient)
@@ -370,6 +377,7 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil),
)
refundService.SetNotificationOutbox(outbox.NewRepository())
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient))
refundService.SetLifecycleAudit(auditWriter)
if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil {
appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err))
@@ -442,8 +450,10 @@ 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),
confirm,
runtime.workerResult.Services.PaymentAudit,
)
@@ -687,7 +697,7 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
&asynq.SchedulerOpts{Location: time.Local},
)
if err := registerAsynqScheduleTasks(asynqScheduler); err != nil {
if err := registerAsynqScheduleTasks(asynqScheduler, cfg.Worker.AuditArchiveTasksEnabled); err != nil {
appLogger.Fatal("注册 Asynq 定时任务失败", zap.Error(err))
}
@@ -698,12 +708,14 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
}()
appLogger.Info("Asynq Scheduler 已启动",
zap.Bool("audit_retention_cleanup_enabled", cfg.Worker.AuditRetentionCleanupEnabled))
zap.Bool("audit_retention_cleanup_enabled", cfg.Worker.AuditRetentionCleanupEnabled),
zap.Bool("audit_archive_tasks_enabled", cfg.Worker.AuditArchiveTasksEnabled),
zap.Int("polling_total_max_concurrency", cfg.Worker.PollingTotalMaxConcurrency))
return asynqScheduler
}
// registerAsynqScheduleTasks 注册 Worker 入口需要的全部定时任务。
func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEnabled bool) error {
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeAgentRechargeRecovery,
nil,
@@ -773,6 +785,9 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
); err != nil {
return fmt.Errorf("注册每日流量落盘定时任务失败: %w", err)
}
if !auditArchiveEnabled {
return nil
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 4 * * *", asynq.NewTask(
constants.TaskTypeAuditDailyArchive,
nil,
@@ -793,59 +808,46 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); err != nil {
return fmt.Errorf("注册 Integration Log 每日冷归档定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 5 1 * *", asynq.NewTask(
constants.TaskTypeIntegrationMonthlyFinalize,
nil,
asynq.MaxRetry(10),
asynq.Timeout(6*time.Hour),
asynq.Unique(27*24*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIntegrationMonthlyFinalize)),
)); err != nil {
return fmt.Errorf("注册 Integration Log 月度最终版本复核任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 1 * *", asynq.NewTask(
constants.TaskTypeAuditMonthlyRetention,
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 5 * * *", asynq.NewTask(
constants.TaskTypeAuditDailyRetention,
nil,
asynq.MaxRetry(10),
asynq.Timeout(12*time.Hour),
asynq.Unique(27*24*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditMonthlyRetention)),
asynq.Unique(23*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditDailyRetention)),
)); err != nil {
return fmt.Errorf("注册月度日志留存演练或清理任务失败: %w", err)
return fmt.Errorf("注册日志留存演练或清理任务失败: %w", err)
}
return nil
}
// registerAuditArchiveTask 注册 Audit 与 Integration 冷归档任务处理器。
func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, cleanupEnabled bool, appLogger *zap.Logger) {
func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, cleanupEnabled, enabled bool, appLogger, retentionLogger *zap.Logger) {
if runtime.storageSvc == nil {
appLogger.Warn("对象存储未配置,审计归档任务将在执行时重试")
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(nil, appLogger).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(nil, appLogger)
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(nil, appLogger, enabled).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(nil, appLogger, enabled)
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(nil, appLogger, cleanupEnabled).Handle)
mux.HandleFunc(constants.TaskTypeAuditDailyRetention, task.NewAuditRetentionHandler(nil, retentionLogger, cleanupEnabled, enabled).Handle)
return
}
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
if !ok || auditWriter == nil {
appLogger.Fatal("初始化月度日志留存清理失败:统一审计 Writer 未配置")
appLogger.Fatal("初始化日志留存清理失败:统一审计 Writer 未配置")
}
service, err := auditArchiveApp.NewService(runtime.db, runtime.storageSvc.Provider(), constants.AuditArchiveInstanceID, auditWriter)
if err != nil {
appLogger.Fatal("初始化统一审计归档服务失败", zap.Error(err))
}
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger)
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger, enabled).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger, enabled)
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(service, appLogger, cleanupEnabled).Handle)
mux.HandleFunc(constants.TaskTypeAuditDailyRetention, task.NewAuditRetentionHandler(service, retentionLogger, cleanupEnabled, enabled).Handle)
appLogger.Info("注册审计归档任务处理器",
zap.String("audit_task_type", constants.TaskTypeAuditDailyArchive),
zap.String("integration_daily_task_type", constants.TaskTypeIntegrationDailyArchive),
zap.String("integration_monthly_task_type", constants.TaskTypeIntegrationMonthlyFinalize),
zap.String("retention_task_type", constants.TaskTypeAuditMonthlyRetention),
zap.Bool("retention_cleanup_enabled", cleanupEnabled))
zap.String("retention_task_type", constants.TaskTypeAuditDailyRetention),
zap.Bool("retention_cleanup_enabled", cleanupEnabled), zap.Bool("archive_tasks_enabled", enabled))
}
// createTaskHandler 创建并返回包含全部任务处理器的 Asynq Handler。

View File

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

View File

@@ -8,7 +8,7 @@
- Owner生产维护者。
- 最后核验2026-08-13基于维护者提供的信息未连接生产环境
- 更新触发Unit、目录、进程数、Worker 角色、发布顺序、迁移方式或回滚方式变化。
- 验证:由维护者在服务器执行本文列出的只读核对命令,并回填结果Agent 不连接生产环境
- 验证:Agent 可通过 dbhub 对生产数据库执行只读诊断查询;服务器上的只读核对、生产发布、迁移、回滚及其他写操作由维护者执行并回填结果Agent 不连接生产主机
## 与测试环境的边界
@@ -52,7 +52,7 @@ GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o ./build/worker ./cmd/worker
- 二进制只从嵌入默认配置和 `JUNHONG_` 环境变量读取;`.env.prod` 必须由 systemd Unit 显式加载,或由 Unit 启动脚本 `source` 后启动。需要以 Unit 内容核实实际方式。
- API 与 Worker 共享数据库、Redis、日志、JWT、对象存储、Gateway、短信、支付等基础配置只记录键名不将实际凭据写入仓库文档。
- 七月新增的 API/Worker 共用配置:`JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED``JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED``JUNHONG_WECOM_BASE_URL``JUNHONG_WECOM_TIMEOUT`
- 七月新增的 Worker 配置:`JUNHONG_WORKER_ROLE``JUNHONG_WORKER_INSTANCE_NAME``JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED`
- 本次新增的 Worker 配置:`JUNHONG_WORKER_ROLE``JUNHONG_WORKER_INSTANCE_NAME``JUNHONG_WORKER_POLLING_TOTAL_MAX_CONCURRENCY``JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED``JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED`。轮询总并发必须保持在 1-1000初始生产值建议 100归档/留存总开关默认关闭
- 首发要求:退款人工入口、线下充值人工确认、企微审批、运营商实名回调、微信/支付宝在线充值均按维护者决定启用;审计物理清理保持关闭。
- 企微应用凭据由后台配置写入数据库明文字段;这不是启动环境变量。本文不记录其值。
@@ -74,6 +74,35 @@ 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`;若重试已耗尽,按受控运维流程重放同一事件,不得直接修改退款、订单或钱包数据。随后核验:
1. 退款单 `RF20260820170954264700` 已通过,关联订单支付状态为已退款。
2. 该退款没有代理主钱包或资产钱包的零金额回款流水。
### 锁的含义与发布影响
`000171` 会对 `tb_agent_wallet``000178` 会对 `tb_iot_card` 使用 PostgreSQL `ACCESS EXCLUSIVE` 锁。该锁执行期间会阻塞该表的读写及其他 DDL直到迁移事务提交或回滚若有未结束业务查询/事务,它也会等待。因此必须在 API 和全部 Worker 停止后执行,并在迁移前检查没有长事务。锁持续时间取决于表数据量、索引创建和等待中的旧事务;不能从仓库估算具体秒数。
@@ -88,3 +117,12 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
## 已确认数据库备份
每日凌晨 02:00 自动备份 `junhong_cmp_prod`:数据库运行在 Docker 容器 `postgres` 中,备份脚本执行 `pg_dump -Fc -Z 6`,写入 `/data/backups/postgresql/<库名>_<时间>.dump`,同时生成 MD5 文件并以 `pg_restore --list` 校验结构;保留 30 天。发布前仍须人工新建一次备份并确认校验通过,不能只依赖凌晨的最近备份。恢复命令待维护者实际演练或确认后补充。
## 日审计日志留存启用与恢复
归档与日留存由调度 Worker 受控处理 Asia/Shanghai 的已结束自然日;`JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=false` 时不注册调度,已入队任务也安全跳过、不扫描在线日志表。开启后每次最多推进一个日期。`JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED=false` 时仅验证归档、对象、manifest、数据库数量和日期连续性不写清理断点、不删除在线数据。Integration 的 `pending` 记录保留在线但不会阻断同日终态日志、Audit 数据或后续日期的清理;若 pending 后续终结Worker 会重建该日 revision 后续跑删除。
1. 发布新 Worker 后保持 `JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=false` 和物理清理开关关闭;轮询总并发先设为 100。维护者先核对 `tb_log_archive_run` 中 Audit 与 Integration 两个来源从历史最早在线日期起没有缺失账本;缺失日期必须先受控补归档,不能跳过失败日。
2. 以关闭开关的 Worker 完成只读演练,观察 `/opt/junhong_cmp/worker/logs/audit-retention.log`:每个日期应有来源计数和耗时;若出现日期、来源、失败分类或安全错误摘要,先修复该来源的归档或校验问题后再演练。仅有 pending 不需要人工终结才可继续。
3. 低峰期先将调度 Worker 的 `JUNHONG_WORKER_AUDIT_ARCHIVE_TASKS_ENABLED=true`,重启该 Worker确认单日归档稳定后再将 `JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED=true`,并持续观察上述独立日志、`tb_log_archive_run.cleanup_started_at` / `cleaned_at` 断点及表大小。DELETE 释放的 PostgreSQL 页面会供后续写入复用,表文件不会立即缩小;维护者按既有运维窗口评估 VACUUMWorker 不执行 VACUUM 或表重写。
4. 异常时立即将开关改回 `false` 并重启调度 Worker。已清理日期按已验证的对象和 manifest 执行归档恢复;尚未清理或阻断的日期仍保留在线,无需数据库恢复。恢复后先重新执行只读演练,再决定是否重新开启清理。

View File

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

View File

@@ -0,0 +1,44 @@
# Main 分支套餐接续饥饿热修
## 修复边界
本热修仅适用于 `main` 的纯 Asynq 套餐接续链路,不引入 Outbox、数据库迁移、新任务类型或新依赖。
- 孤儿扫描先在 PostgreSQL 中按卡或设备选择队首套餐,并排除仍有 `status IN (1,2)` 占位主套餐的载体,最后取 100 个真实孤儿。
- 旧主套餐及加油包状态事务提交后,再投递现有 `package:queue:activation` 任务。
- Redis 激活锁冲突返回套餐激活冲突错误,由现有 `MaxRetry(3)` 重试。
- 只有套餐实际从待生效推进为生效中时Handler 才记录“套餐激活成功”。
## 部署观察
部署 Worker 后至少观察两个套餐轮询周期:
1. `孤儿套餐扫描完成``orphan_count` 应能覆盖真实无占位套餐,不再固定被同一批占位载体挡住。
2. `已提交套餐激活任务` 后应出现实际激活、明确跳过或可重试错误,不再出现未改状态却打印成功。
3. Redis 锁冲突应进入 Asynq 重试,不应确认任务成功。
可使用以下只读 SQL 检查仍未恢复的真实孤儿数量:
```sql
SELECT COUNT(*) AS orphan_pending_count
FROM tb_package_usage AS pending
WHERE pending.status = 0
AND pending.master_usage_id IS NULL
AND pending.deleted_at IS NULL
AND (COALESCE(pending.iot_card_id, 0) > 0 OR COALESCE(pending.device_id, 0) > 0)
AND NOT EXISTS (
SELECT 1
FROM tb_package_usage AS occupied
WHERE occupied.status IN (1, 2)
AND occupied.master_usage_id IS NULL
AND occupied.deleted_at IS NULL
AND (
(COALESCE(pending.iot_card_id, 0) > 0 AND occupied.iot_card_id = pending.iot_card_id)
OR (COALESCE(pending.iot_card_id, 0) = 0 AND pending.device_id > 0 AND occupied.device_id = pending.device_id)
)
);
```
## 回滚
本次无数据库迁移。回滚热修提交并重新部署 Worker 即可;已经正确激活的套餐属于有效业务事实,不执行反向 SQL。

View File

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

View File

@@ -0,0 +1,224 @@
# 新卡管 2026 年 8 月迭代 PRD讨论稿
> 状态:讨论中
>
> 本文是本轮需求澄清的唯一决策记录。确认后的需求将重编稳定需求 ID每项实施前再以独立 OpenSpec Change 固化可观察行为、任务和验收。
>
> 来源:业务提供的《新卡管 8 月迭代需求》2026-08-11。
## 0. 已确认的产品边界
1. 本文覆盖一个需求池,不承诺整体一次上线。完成完整 PRD 后,按影响范围由小到大拆分为独立 Change 实施。
2. 原文编号存在重复和漂移。讨论稿将建立新的稳定需求 ID原编号仅用于追溯来源。
3. 审批类业务必须接入企业微信。企业微信是唯一终审来源;审批节点、审批人、条件和流转规则完全由企业微信模板配置,本系统不读取、校验或固化这些规则。系统只保存本地审批实例、业务状态、审计和幂等闭环,并按企业微信最终通过或驳回结果推进业务;不提供本地人工通过或拒绝来绕过企业微信。企业微信回调延迟、提交失败或结果未知时,使用既有兜底轮询查询渠道状态并同步本地实例。所有审批业务均须将企业微信审批单号返回至卡管并在详情展示;详情仅展示本地或企业微信实际可取得的字段,不展示无法返回、无值或无可靠来源的字段。
4. OCR 是已有的外部能力但本期仅作为交易流水号的预填来源。申请人或审核人必须能更正并最终确认OCR 识别结果不是资金事实。OCR 协议、字段能力、失败和重试规则等待外部契约文档。
## 0.1 稳定需求 ID 与原文映射
| 稳定 ID | 能力 | 原文来源 |
| --- | --- | --- |
| AUG26-001 | 员工代收款账单与核销 | PRD-08-001、PRD-08-013核销字段 |
| AUG26-002 | 支付商户池与微信授权配置 | PRD-08-002 |
| AUG26-003 | 店铺业务员与业务用户组 | PRD-08-004、PRD-08-005 |
| AUG26-004 | 套餐真流量预警 | PRD-08-006 |
| AUG26-005 | 换货业务数据迁移展示 | PRD-08-007 |
| AUG26-006 | 套餐退款与原路退款 | PRD-08-008、PRD-08-013退款字段 |
| AUG26-007 | H5 风险换卡与运营通知 | PRD-08-009 |
| AUG26-008 | 代理分销码、资料资格与佣金提现 | PRD-08-010、PRD-08-013提现/分销审批字段) |
| AUG26-009 | 手机号—资产关联 | PRD-08-011 |
| AUG26-010 | 自动续费 | PRD-08-012 |
| AUG26-011 | 运营商通道流量阈值 | PRD-08-017 |
| AUG26-012 | 佣金回溯明细 | PRD-08-015 |
| AUG26-013 | 资产套餐层级展示 | PRD-08-016资产详情 |
| AUG26-014 | 导出与统一时间筛选 | PRD-08-014、PRD-08-020 |
| AUG26-015 | 报表管理 | PRD-08-016报表原编号重复 |
| AUG26-016 | 优先轮询通道 | PRD-08-019 |
| AUG26-017 | 代理自充收款方式 | PRD-08-021 |
## 1. 已确认的领域语言
### 1.1 员工代收款账单
员工代客户购买套餐、充值等业务时,系统按来源业务生成的待核销记录。它表示员工经办业务形成的暂挂欠款;员工提交客户付款凭证,企业微信审批人员可通过该凭证在外部第三方收款记录中核验支付,且企业微信审批通过后,欠款相应减少或结清。客户款项可支付至公司微信、银行卡或其他由业务维护并认可的线下收款方式,不要求必须支付给员工或由员工另行回款。
代理线下预存款/主钱包充值仍按既有企业微信审批决定是否入账;该审批通过并完成入账后,系统再创建相应员工代收款账单,使员工欠款增加。后续账单核销是独立于充值审批的业务流程。
员工代收款账单的欠款人固定为实际发起线下套餐订单或线下代理充值的后台账号,创建后不可修改;平台业务员和超级管理员实际经办时均生成本人账单。员工离职或账号禁用后,其欠款人身份不变;仅超级管理员可代办创建、修改或重新提交核销申请,且必须记录实际代办人及代办原因。
已确认:支持一笔外部支付记录分摊核销多张账单,也支持一张账单由多笔支付记录分次核销。员工应按一笔外部付款创建一张核销申请,在申请中上传该笔付款的凭证、填写交易流水号,并选择多张账单及各自分摊金额;一张申请只产生一个企业微信审批实例。每笔分摊必须独立保留账单、外部支付记录标识、本次核销金额、凭证、审批实例和剩余未核销金额。
员工代收款账单的核销状态独立于核销申请审批状态:账单状态为待核销、部分核销、已核销或已关闭;申请状态为审批中、已通过、已驳回、已撤销/已关闭。只有企业微信审批通过的分摊才减少员工欠款。账单列表需同时展示账单核销状态和是否存在审批中申请,避免部分核销与审批中互相覆盖。
企业微信最终驳回的核销申请允许员工在原申请上修改后重新提交;系统为同一申请保留每一次企业微信审批实例及其当次业务快照,历史材料和审批结果不得被覆盖。已驳回申请可修改全部申请内容:收款账户、外部付款信息、附件、备注、勾选账单和分摊金额;已通过的分摊不可修改。
超级管理员可关闭待核销、已驳回或部分核销账单,关闭即作废当时未核销余额且不再计入员工欠款;已核销金额保留。关闭必须填写原因、保留操作审计,且存在审批中核销申请时不得关闭。
已确认:员工代收款账单按业务场景确定公司应收金额。
- 代理代购套餐:取订单 `actual_paid_amount`(代理实际结算/成本金额。对会生成员工代收款账单的后台线下套餐订单创建订单时不再强制上传付款凭证订单仍按当前规则立即激活并同时创建待核销账单。付款凭证、OCR 与外部交易流水号只在后续核销申请提交。赠送套餐等不产生员工账单的线下订单继续保持创建时上传凭证的当前规则。
- 无代理归属的自营 C 端套餐购买:取订单 `actual_paid_amount`。当前实现该值等于 `total_amount`;未来优惠券等价格优惠也应使其表示实际收款金额。
- 代理线下充值预存款/主钱包:既有充值审批通过并入账后创建员工代收款账单,金额取 `tb_agent_recharge_record.amount`
- 不支持平台代理 C 端客户充值资产钱包;原需求中对此类场景不纳入本期。
- “其他”来源第一版不支持手工创建,必须先定义可追溯来源单和金额口径。
> 待决:`actual_paid_amount` 在未来优惠场景的写入时机与权威性;外部支付记录在本地的最小留存字段、收款账户目录的范围和退款时的冲销规则。
## 2. 当前发现的需求结构问题
| 问题 | 处理原则 |
| --- | --- |
| `PRD-08-013``014``015``016` 在范围表与正文含义不一致 | 后续以新稳定 ID 重编,保留来源编号。 |
| 审批字段在第 17 节集中出现,但相关主需求中存在不同口径 | 先定义每个审批业务的业务单、状态、资金生效时点和企业微信模板,再定义页面字段。 |
| OCR 被写入字段说明但缺少外部契约 | OCR 仅预填,待提供契约后再定义使用范围。 |
| 收款方式被写为固定示例(如某人微信/支付宝) | 收款方式由业务字典维护,不得将名称硬编码为支付方式枚举;是否还需独立收款账户目录待确认。 |
| 多处要求“字典维护”,但当前系统没有业务字典 | 新建通用业务字典模块;字典分类由研发固定注册,业务只维护分类下的字典项。已被业务单引用的字典项不得物理删除,只能停用并保留历史名称快照。 |
| 自动续费含“有余额自动续费,不停机” | 必须先区分续费订单、钱包扣款、运营商停复机和轮询同步,不能将它们描述为同一动作。 |
| 运营商通道阈值、套餐流量预警都使用“阈值” | 前者是通道控制规则,后者是套餐预警规则;二者的流量口径、周期和触发后果必须独立定义。 |
## 2.1 已确认的外部付款分摊边界
1. 同一笔外部付款可覆盖多张员工代收款账单,也可由多个员工的多张核销申请引用。
2. 本期不对同一外部付款在多张申请中的累计分摊金额做系统防重或金额上限校验;企业微信审批人员以外部第三方记录为准核验。
3. 系统仍须逐核销申请保存所选线下收款方式、交易流水号、付款金额、付款方、付款时间、支付凭证、其他凭证、备注及分摊明细,供超级管理员和企业微信审批追溯。交易流水号和付款金额可由 OCR 预填,但必须允许人工确认或更正。
4. 员工通过勾选账单创建分摊明细,不手填账单编号、订单、客户或资产字段;系统自动带出这些只读信息。系统按账单产生时间由早至晚,使用本次外部付款金额自动填充分摊,最后一张填剩余金额;员工可以修改本次核销金额。对同一账单的审批中分摊应预占可核销余额,避免并发申请超额核销。
5. 本期只做一套固定分类的线下收款方式字典。超级管理员维护字典项名称、稳定编码、排序、启停、备注;核销申请和预存款审批选择字典项并冻结名称快照。不额外建设收款账户目录。
## 2.2 已确认的历史数据边界
1. 员工代收款账单仅对功能上线后新创建的符合条件线下套餐订单、功能上线后审批通过并入账的线下代理预存款/主钱包充值自动生成。
2. 不回填、不补建任何上线前历史业务;上线前业务不纳入员工账单和欠款统计。
## 2.6 已确认的商户池覆盖范围
1. C 端套餐购买、C 端资产钱包充值、代理在线预存款充值三类当前线上收款入口,均应按支付方式通过商户池选择实际收款商户;后台钱包/线下套餐订单不经过商户池。
2. 平台范围内每种支付方式最多一个启用商户池;可保留停用历史池,但不得同时启用多个相同支付方式的商户池。未来需要多池时,必须先定义订单/店铺/资产等路由维度。
3. 新建独立商户管理模块,收款凭证与商户支付能力从现有综合支付配置中分离,由商户池选择商户;不复用现有支付配置记录作为商户。一个商户仅对应一种支付方式:微信商户和支付宝商户分别建档、分别加入对应商户池。
4. 新建独立微信授权配置,平台范围内最多一个启用配置,保存 C 端公众号 H5 登录/JSSDK 和小程序登录所需参数C 端微信登录、AppID、JSSDK 与 OpenID 只使用该全局配置。微信商户仅保存微信支付或富友收款凭证;商户池命中的微信商户统一使用全局授权配置的 AppID 发起支付,微信侧 AppID 与商户的绑定/授权由业务保证。微信直连商户和富友商户都属于微信支付商户,可混合加入唯一启用的微信商户池;系统按命中商户自身服务商凭证执行支付、回调验签和退款。
5. 商户停用后仅对新支付单自动跳过;已命中该商户的历史支付单不换商户,支付回调、状态查询及后续原路退款仍使用该商户凭证。被历史支付单引用的商户不得物理删除,只能停用。商户原路退款能力不提供人工开关,系统仅按服务商类型及退款所需凭证是否完整判定;服务商实现不支持退款的商户固定视为不支持。商户池没有可用商户或商户池停用时,创建支付单失败且不得回退到旧全局支付配置。
6. 上线时从当前生效综合支付配置一次性自动迁移:创建一条全局微信授权配置、具备完整凭证的微信/支付宝商户及各自包含一个商户的启用商户池。新订单仅走商户池;旧综合支付配置和历史订单引用保留,仅服务历史回调、查询和退款,不自动改写。缺少完整凭证的支付方式不创建商户池,新支付单按暂无可用商户失败。迁移仅在数据库内复制密钥/证书,不得在日志、审计详情或接口响应暴露敏感值。
7. 商户保存名称、支付方式、服务商类型、商户号/应用标识、敏感凭证、状态和备注。每笔新支付单保存实际商户 ID 并冻结名称、支付方式、服务商类型及商户号/应用标识快照,不复制敏感凭证。被支付单引用后,支付方式、服务商类型和商户号/应用标识不得修改;名称、备注、状态及凭证可更新。历史支付单和退款单优先展示快照,回调和退款读取商户当前有效凭证。
8. 超级管理员和平台用户均可创建、修改、启用、停用商户、商户池和微信授权配置;其他角色没有管理入口。为使配置人员可核对完整配置,管理 API 的列表和详情向上述已认证角色返回商户及微信授权配置的完整密钥、证书和私钥字段,不做脱敏;不得将这些字段写入日志、审计快照、错误信息或普通业务单据。未被任何支付单命中的商户,超级管理员或平台用户可先将其移出商户池后经二次确认删除;删除审计不得含敏感凭证。被支付单引用的商户一律不得删除。
9. 每笔通过商户池创建的支付单,除实际商户 ID 和商户身份快照外,还须保存商户池 ID、商户池名称快照和轮询方式快照不另建逐次支付路由日志表。
10. 金额阈值、笔数阈值和时间周期均为商户池统一配置,池内所有商户共用,不支持成员单独配置。
6. 金额/笔数轮询创建池时必须配置统计周期,支持每轮累计、自然日累计、自然月累计。每轮累计在商户重新被轮到时清零;自然日/自然月累计在周期边界重置,达到阈值的商户在当期自动跳过。当前周期所有商户都达到阈值时,创建支付单失败并提示当前周期暂无可用商户。
7. 时间轮询创建池时必须配置周期数值、分钟/小时/天单位和起始时间。自起始时间起按固定周期、商户列表顺序轮换;进入新时段时仅在有新支付单时选择并记录商户。当前商户停用时即时跳到下一个可用商户,时间段不重置;修改时间周期、起始时间或商户顺序后,保存成功时间成为新起点并从列表第一项重新计算。时间周期最小为 1 分钟。
8. 金额/笔数轮询仅修改阈值时保留当前统计周期内的成功收款累计并立即按新阈值判断;修改统计周期或在金额/笔数两种方式间切换时,保存成功即开始新统计周期、所有商户从零累计。新增商户从零开始;移除/停用商户不再参与新订单选择。调整商户排序时,自然日/自然月累计保留未移除商户的当期累计;每轮累计从新列表第一项开启新一轮、所有商户从零累计。
9. 商户预下单失败时不自动切换商户或重试;任一失败按当前错误处理返回,客户再次发起支付时重新按商户池选择。失败订单不计入金额/笔数成功累计。
10. 金额/笔数轮询严格仅统计支付成功结果,不在预下单时预占商户额度或笔数;并发预下单可同时命中当前商户,已创建支付单不因后续轮询切换而改挂商户。
11. 支付成功后发生的全额或部分退款不回冲商户池金额/笔数成功累计;退款是独立后续资金动作。
## 2.3 已确认的退款完成规则
1. 客户提供收款信息的退款以企业微信最终通过为退款完成时点;系统不感知、也不以外部线下实际打款、交易流水号或付款凭证作为退款状态条件。企业微信审批通过即标记已退款。
2. 原路退款经企业微信最终通过后,系统必须以原实际收款商户自动调用渠道退款接口,超级管理员不得改选其他商户。只有渠道明确退款成功后退款单才标记已退款并保存渠道退款流水号;调用失败、超时或结果未知时,退款单保持原路退款处理中/失败,保留可恢复事实且不得重复退款。需改为凭证退款时,必须重新提交申请并重新走企业微信审批。
3. 原路退款在选择方式、提交申请和实际执行前均重新校验原支付单、原实际收款商户、原渠道交易流水号、商户退款能力/凭证以及可退金额。商户停用不阻断历史订单原路退款。渠道实际调用是退款资格的最终判断;渠道明确拒绝、超期、凭证失效或余额不足时不标记成功、保留失败原因。系统不额外提供退款凭证预探测接口。
4. 一笔订单最多只允许一张最终退款成功的退款申请;该申请可部分退款,成功后订单剩余未退款金额不再允许申请退款。
5. 退款申请提交金额即企业微信审批授权金额和最终允许退款金额。原路退款按此金额调用渠道;凭证退款实际转出此金额后才可成功。金额需变更时不得继续执行原申请,必须重新提交并重新走企业微信审批。
6. 一笔订单同一时间最多一张审批中、原路退款处理中或原路退款失败的退款申请。企业微信驳回、申请撤销/关闭或原路退款明确失败后,允许修改未成功退款申请并重提;可修改金额、原因、退款方式、客户收款信息和附件。每次重提必须新建企业微信审批实例及业务快照,历史材料不可覆盖。任一退款方式最终成功后,申请和订单均不得再发起退款。
7. 企业微信最终通过即按当前规则使退款关联套餐失效、接续下一套餐并在必要时停机;原路退款渠道或线下实际打款的后续结果不影响套餐失效,也不恢复权益。
8. 凭证退款申请必须提供客户收款账户信息和客户提供的收款凭证,作为企业微信审批材料;不要求、也不提供公司实际线下付款后的交易流水号或付款凭证回填。
9. 超级管理员、平台用户和代理均可在各自数据权限内对已支付套餐订单发起退款申请;企业微信是唯一终审来源,本地不提供人工通过、拒绝或退回操作。任一具有该订单数据权限的上述账号,均可修改并重新提交未成功退款申请。
10. 凭证退款的客户收款账户信息使用一个必填自由文本字段留存,客户提供的收款凭证附件必填;不新增退款收款账户类型字典、账户表,且不得复用公司线下收款方式字典。
11. 退款申请的实收金额必须由系统从来源订单/原支付记录自动带出并冻结,提交人不可填写或修改。线上支付取原成功支付记录金额;资产钱包、代理预存款和后台线下订单取来源订单的实际收款或实际扣款金额。退款金额不得超过冻结实收金额;无法确定权威实收金额时拒绝创建申请。
12. 本期不按套餐已用流量自动计算退款金额;提交人填写申请金额,系统仅校验不超过冻结实收金额,套餐使用情况作为退款原因、凭证和企业微信审批判断材料。
13. 企业微信在本地收到通过前发生驳回、撤销或删除时,退款申请进入审批未通过/已关闭,未发生退款且可修改重提。企业微信通过后撤销时,不回滚已失效套餐、已发起原路退款或已完成凭证退款;申请标记审批异常、保留审计、禁止自动重提,由超级管理员线下处理。企业微信提交失败或结果未知时,退款申请仍为在途,不允许另建或重提,使用既有查询/重试闭环确认渠道是否受理。
14. 对线上支付订单,系统在申请退款时预检可本地确定的原路退款条件:条件满足时同时提供原路退款和凭证退款;条件不满足时禁用原路退款并说明原因,只允许凭证退款。后端在提交及企微通过后的实际执行前均重复校验;后续条件失效时不得强行原路退款。钱包、预存款和后台线下订单不展示不适用的退款方式。
15. 所有退款申请必须填写退款原因;退款原因冻结在当次企业微信审批快照中。
16. 当前业务实际上限制一张订单仅购买一个套餐;虽数据模型预留多套餐订单能力,但本期退款申请不提供套餐选择,系统自动带出订单唯一关联套餐及其使用情况。企业微信通过后失效该套餐;若为主套餐,仍按现有规则连带失效其加油包。退款上限只按订单冻结实收金额校验。
## 2.3.1 退款管理补充字段
退款管理列表、详情及导出新增“当前退款套餐已用量”和“当前退款套餐总量”。当前退款套餐为退款订单自动关联的唯一套餐;两个字段读取该套餐使用记录当前可取得的真流量已用量和套餐总量,仅用于展示、查询和导出,不改变既定的退款金额校验、套餐失效或佣金回溯规则。
## 2.4 已确认的退款方式矩阵
| 来源订单的实际支付方式 | 本期允许退款方式 |
| --- | --- |
| 微信/支付宝等线上支付套餐订单 | 原路退款、客户提供收款信息退款 |
| C 端资产钱包余额支付套餐订单 | 仅自动退回原资产钱包 |
| 代理预存款/主钱包支付套餐订单 | 仅自动退回原代理预存款钱包 |
| 后台线下套餐订单 / 员工代收款套餐订单 | 仅客户提供收款信息退款 |
| 代理充值预存款业务单本身 | 当前退款模块不覆盖,需另立需求 |
## 2.5 已确认的退款联动
1. 来源订单全额退款且账单从未核销时,系统自动关闭账单,关闭原因记为来源订单全额退款。
2. 来源订单部分退款且账单从未核销时,系统按退款金额冲减账单应收金额,并保留来源订单退款冲销记录。
3. 账单存在任一已通过核销分摊时,系统不得自动冲销该账单;后续由超级管理员人工处理。
4. 已核销账单的来源订单后续退款不恢复员工欠款;账单详情仅提示来源订单已退款并保留退款关联。正常订单退款不等于员工欠款重新产生。
## 2.8 已确认的手机号绑定基线
当前系统已有个人客户手机号绑定和全局 H5 强制绑定开关;是否强制绑定由全局配置决定,不存在、也不新增按资产导入批次设置“是否需要绑定手机号”的能力。原 PRD 第 15.4 节与当前行为冲突,不能作为本期新增功能依据。本期保留既有全局强制绑定逻辑,并新增已验证手机号与资产的关联记录:同一手机号最多关联 10 个当前有效资产资产详情展示关联手机号后台支持按资产解绑、批量解绑及操作记录。全局强制绑定开启时客户首次登录一项尚未关联该手机号的资产必须再次完成短信验证码校验后才建立关联已关联该手机号的资产不再重复验证。全局强制绑定关闭时H5 登录不要求短信验证码且不新增手机号—资产关联,已有关系保留并可后台查看、解绑。客户更换手机号并完成旧、新手机号验证码校验后,系统原子迁移旧手机号全部有效资产关联至新手机号;新手机号现有关联数加待迁移数超过 10 时整次换绑失败,原关系不变。资产详情列出全部当前关联手机号,单个解绑须明确选择一条资产手机号关系;批量解绑和 Excel 按资产标识导入解绑时,解除每项资产全部当前有效手机号关联,必须二次确认、填写原因并逐条记录实际解除关系。批量解绑逐资产独立执行:有权限且已绑定的资产成功解绑,其余资产失败;任务返回成功数、失败数和逐行失败明细,无权限项使用统一失败文案。具备资产数据权限的后台账号在资产详情、列表、导出和批量任务结果中均展示完整关联手机号,不做脱敏;操作日志不得记录完整手机号。仅超级管理员和平台用户可在资产数据权限范围内执行单个解绑、批量解绑和 Excel 导入解绑;代理、企业和个人客户没有后台解绑能力。手机号—资产关联只能由 H5 短信验证建立,后台不提供补录,后台仅查看和解绑。上线时不回填既有个人客户手机号与资产关系;功能上线后,既有客户首次登录每项资产仍须重新短信验证建立关联,超过 10 项时阻断本次新关联。换货不迁移手机号—资产关联,新资产首次访问时按全局开关重新验证。
## 2.7 已确认的业务用户组
1. 业务用户组用于标记平台用户所属业务,既不是后台权限角色,也不是代理店铺分组。
2. 每个启用的平台用户最多属于一个启用的业务用户组;用户组不改变后台角色权限、数据范围或店铺具体业务员归属。
3. 店铺所属用户组由当前绑定的平台业务员所属用户组实时推导;更换店铺负责人或负责人更换用户组后,店铺所属组随之变化。店铺未绑定负责人、负责人未分组或所属组已停用时,店铺所属组为空或显示已停用。店铺列表、详情和筛选均支持展示及按该推导用户组查询。
4. 用户组停用后不得新增成员;现有成员关系保留,用户及其负责店铺均显示该组已停用。停用不影响用户登录、权限、数据范围和店铺负责人,管理员可后续改组或清空成员。
5. 仅无成员的用户组可由超级管理员或平台用户二次确认删除;仍有成员时只能停用或先移走成员。
6. 超级管理员和平台用户可勾选多个平台用户,批量设置为某个启用用户组或批量清空所属组;设置会直接替换原所属组,停用组不得作为批量目标。
7. 超级管理员和平台用户可勾选多家有数据权限的店铺,批量设置为某个启用的平台业务员或批量清空业务员;店铺所属用户组随负责人实时推导更新。批量操作先校验全部目标店铺,任一店铺无权、不存在、已删除或目标业务员无效时整批不修改并统一失败。
8. 用户组维护名称、稳定编码、排序、启用状态和备注;不设上级组、层级或组管理员。编码创建时必填、当前未删除用户组内唯一且创建后不可修改;名称、排序、状态和备注可修改。
## 2.10 已确认的 H5 风险换卡弹窗
广电风险停机自动弹窗只在当前 H5 登录并访问的资产同时满足以下条件时展示资产为广电卡、运营商回传扩展状态为风险停机、且不存在待填写收货信息、待发货、已发货待确认或已完成的物流换货单。命中后向当前客户展示换卡提醒。客户提交地址后自动创建一张关联该旧资产的物流换货单不另建风险换卡待处理记录。首次提交地址即锁定客户后续不得修改只能联系后台处理。风险换卡自动创建的物流换货单不在客户提交地址时预设业务数据迁移后台发货并选择新资产时仍由操作人按既有换货流程决定是否迁移。同一客户、同一资产的风险换卡弹窗每天最多展示一次客户点击稍后处理后当天不再展示次日仍命中条件时可再次展示。运营主动弹窗按店铺、设备类型、卡类型等已配置维度同时匹配同一维度多选满足任意一个即可未配置任何适用范围则面向全量客户。主动弹窗配置必须设置优先级后端按请求条件仅返回优先级最高的一条优先级相同取最近更新时间最新的一条风险换卡通知固定高于主动弹窗。H5 弹窗复用个人客户站内通知记录,投放后同时出现在 H5 通知列表供查看历史内容。后端创建或返回弹窗候选时通知保持未读H5 在客户关闭弹窗、点击操作按钮或进入通知详情后调用现有已读接口。运营主动弹窗仅在客户请求配置页面的候选弹窗时实时匹配并创建或复用通知,未访问 H5 的客户不预生成通知。后台固定支持首页、资产详情、套餐购买、资产钱包充值四种展示页面。频率仅支持每个客户对每条配置仅一次或每天一次。运营配置修改标题、内容、范围、页面、频率或有效期只影响后续投放,既有通知保留原快照;已修改配置作为新版本,对原“仅一次”配置的命中客户可重新投放一次。运营弹窗可不设操作,或设置一个受控按钮,目标仅可为套餐购买或资产钱包充值;不得配置任意 URL。H5 请求候选必须携带当前页面资产标识,店铺、设备类型和卡类型均按该资产匹配,首页由 H5 传当前选中的资产。运营弹窗配置到期或停用后停止新投放,既有通知在通知中心保留 90 天。风险换卡地址提交后停止新投放,既有风险换卡通知保留 90 天。风险换卡收货信息保留收货人姓名、收货手机号和一个完整地址文本三项;地址不拆分省、市、区及详细地址字段。超级管理员和平台用户可管理全局 H5 弹窗配置,代理、企业和个人客户不可管理。
## 2.9 已确认的换货迁移展示
换货列表和详情使用“业务数据迁移”及状态:不迁移、待迁移、已迁移、迁移失败;迁移失败可查看失败原因。详情明确迁移范围仅包括资产钱包余额、有效套餐使用记录、累计充值字段和资产标签。资产归属及个人客户—资产绑定属于换货完成固有动作;手机号—资产关联不属于迁移范围,新资产首次访问时按全局开关重新验证。迁移失败时换货单保持原可完成状态,记录最近一次失败原因;超级管理员或平台用户修复条件后可再次确认完成,整套迁移重新原子执行。
## 2.11 已确认的自动续费基线
自动续费仅扣待续费同一资产的资产钱包可用余额;对当前有效主套餐续购同一套餐商品,价格按执行时当前渠道可售续费价计算。第一版仅按最终到期前 N 天触发,不按流量阈值触发。平台设置一个总开关,并配置全部主套餐或指定主套餐及统一的到期前 N 天;进入触发窗口后,每项资产每天最多尝试一次,成功即停止,套餐到期后不再自动尝试。余额不足或套餐不可续费时,向当前个人客户及资产所属店铺当时有效业务员创建站内通知;同一资产同一天不重复通知。自动续费成功后,仅当资产处于可恢复停机状态且运营商状态不是风险停机或已销户时,自动调用既有复机;复机失败不回滚已成功的续费和钱包扣款,记录失败并通知客户和业务员。自动任务与手动续购并发时,手动续购优先;自动任务加锁重读后发现人工已完成续购即跳过,避免重复扣款。客户需要额外购买第二个周期时,须在首笔手动订单成功后再次主动下单。
## 2.12 已确认的代理分销码与提现资料基线
每个代理店铺创建时系统生成不可修改、全局唯一的随机分销码;二维码仅编码 H5 注册入口和该码。新代理扫码后以手机号短信验证码注册、自行设置密码,并创建待企业微信审批的下级代理及店铺,审批通过后启用。审批通过时新店铺设为分销码所属店铺的直接下级,并复制上级当时业务员为初始业务员,后续双方可独立调整。代理停用后其分销码立即不可注册,既有下级代理和既有佣金关系不受级联影响。
代理首次提现前提交提现资料资格申请,合同与法人身份证必填,企业微信审批通过且资料未过期才有效;资料变更或过期必须重审。代理提交提现申请时冻结可提现余额、金额、手续费、收款信息和可选发票快照,并自动创建企业微信提现审批,本地不提供人工通过或驳回。合同与法人身份证通过后长期有效,仅资料被代理替换、被超级管理员作废或代理停用时失效。法人身份证正、反面附件均必传。企业代理必须填写统一社会信用代码,个人代理填写法人身份证号;可选发票仅企业代理可上传并校验统一社会信用代码。营业执照、门头照、发票均为可选。合同资格申请必须填写签约主体统一社会信用代码或身份证号;上传发票时由代理填写发票抬头和统一社会信用代码,系统校验其与合同主体代码一致,企业微信审批人员核验附件真实性。企业微信驳回提现申请后,解冻该申请冻结的佣金余额;代理可修改金额、收款信息及本次可选发票后重新提交,每次创建新的企业微信审批实例,资料资格仍有效。企业微信通过即视为代理提现已到账,系统不登记或等待实际线下打款。发票是每笔提现申请的可选材料,如上传则按当前有效合同主体代码校验,并冻结至当次提现企业微信审批快照。
## 2.13 已确认的流量预警与通道阈值基线
套餐真流量预警使用套餐实际真流量,阈值为 1%100% 的小数百分比;每个套餐商品最多一条当前规则,修改覆盖当前值,既有预警冻结阈值快照。按同一资产全部当前有效套餐的真流量用量和总量汇总计算使用比例,并使用主套餐规则判断;同一套餐使用记录与命中阈值只创建一条预警。以实际消耗流量的套餐记录关联资产为准,插拔卡场景同时展示卡和当前关联设备但不汇总多张卡。达到阈值时仅通知资产所属店铺当时有效业务员。规则停用后停止新触发且保留历史;启用或降低阈值后,下次扫描发现已有有效套餐达到阈值即补建预警。
运营商通道阈值默认关闭开启时按运营商回传的卡当前计费周期累计流量判断。通道下每张卡独立判断达量后系统创建可靠停机任务并自动调用运营商停机达阈值即写入本地通道阈值停机锁当前周期内拒绝复机停机调用通过可靠重试或人工恢复确认最终结果。每个通道配置计费周期起始日128上海时区。新周期开始后仅对仍持有通道阈值停机锁、存在有效主套餐且不存在风险停机、销户或其他停机锁的卡自动复机不符合条件只解除通道锁不调用运营商复机。自动复机失败记录结果并按既有可靠机制处理。
## 2.14 已确认的佣金回溯与套餐层级展示基线
套餐退款佣金回溯后,原佣金记录保持不变,另建关联原佣金记录及退款单的负数佣金明细,佣金明细新增不可提现的“回溯”终态;回溯明细冻结原订单号及其他原佣金字段。部分退款按本次退款金额与订单冻结实收金额的比例,对每条原佣金等比例回溯,按分向下取整、最后一条补足舍入差,累计不超过原佣金。退款发生时佣金计算尚未完成的,等待其终态后再生成全部应有回溯明细;确认无佣金才标记无需回溯。同一退款业务幂等,不重复生成回溯明细。本期只处理套餐退款回溯,换货回溯待独立定义。佣金回溯时直接扣减佣金钱包,允许余额为负;先拒绝并释放待审核提现,再生成回溯明细和扣款流水。
后台资产详情及 H5 资产套餐历史均返回主套餐及关联加油包的层级结构,直接依据现有 `PackageUsage.master_usage_id` 分组,不新增关联表。加油包按生效时间正序排列,待生效包按购买创建时间正序并排在已生效包之后。无论主套餐或加油包已失效、过期或用尽,均保留层级关系;仅关联主套餐物理缺失时以“关联主套餐缺失”的异常独立项展示。
## 2.15 已确认的优先轮询定位
优先轮询是与现有普通轮询并行的高优先级调度队列,而不是一套新的轮询业务逻辑。资产可同时存在于普通轮询和优先轮询,进入优先队列不移除、暂停或改变普通轮询;优先队列仅使该资产额外优先执行同一套既有轮询内容、外部调用及状态同步。一次优先轮询执行成功后任务退出优先队列,资产仍按普通轮询继续运行;外部调用失败或超时按既有失败重试。第一版纳入无有效套餐、套餐过期续购、流量用完购买加油包、人工触发和普通轮询异常补偿五类场景。同一资产有未完成优先任务时,后续触发合并到该任务,追加触发次数、最近时间及来源,不重复调用同一轮普通轮询。优先队列复用普通轮询既有并发上限、失败重试和外部调用保护,仅调度顺序优先,不另建参数配置。
## 2.16 已确认的导出、时间筛选与代理自充收款方式基线
临期列表、佣金明细和套餐流量达量预警均复用现有异步导出任务,创建时冻结筛选条件、操作者及可见店铺范围。临期导出一行对应一项资产,取其当前生效主套餐最终到期时间和剩余天数;加油包不单独成行。流量达量预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组按导出执行时当前归属补充。佣金明细的入账后金额冻结每次佣金钱包变动后的实际余额,回溯记录可为负。
所有要求时间筛选的页面使用统一“开始时间—结束时间”组件和 `start_time/end_time` 参数,支持带时区的 RFC3339 秒级时间闭区间任一端可不传。IoT/设备任务、换货、分配、订单、代理充值、佣金、提现和导出按创建或申请时间筛选;授权按授权发生时间;临期列表按套餐最终到期时间。导出必须复用当前页面全部筛选条件和创建时数据权限快照。
代理自充方式由超级管理员维护允许范围(仅微信、仅支付宝、同时支持);代理实际可用方式取该允许范围与当前可用商户池方式的交集,交集为空时拒绝创建在线充值单。平台用户和代理只可查询实际可用方式。配置变更不影响已创建未支付充值单的支付方式及商户快照,只影响后续新单。代理充值记录新增交易流水号字段,用于保存该笔充值对应的交易流水号。
## 2.17 已确认的报表基线
激活报表的采购数量以成功导入系统的设备数量计算,不另建采购或入库台账。功能上线后每日生成稳定日报快照;上线前日期不提供报表或明确显示无快照数据,不回填历史。累计激活设备严格采用任一当前关联卡已实名的口径;每日快照中的累计在网设备为已实名且存在有效主套餐的设备,活跃设备为该套餐周期内任一卡真流量大于零的设备,用量为设备当前套餐周期内全部关联卡真流量之和。报表可选择设备名称、型号、制造商、用户组、代理、店铺、业务员中的一个分组维度;未选择时汇总为一行。套餐续费按资产去重,统计期内有主套餐到期的资产计一次到期,至少成功续购一次主套餐计一次续费,续费率不超过 100%。日报快照冻结当天店铺、业务员及用户组归属。后端提供日/月趋势汇总数据和异步导出,图表渲染由前端负责。
## 2.18 已确认的店铺批量换绑 Excel 基线
店铺列表勾选批量换绑保持全量预校验、任一项失败整批不更新。Excel 导入复用现有统一导入任务处理方式,逐行执行:成功行提交,失败行不影响其他行,并输出成功数、失败数和逐行失败明细;不设 1000 行硬上限。两种入口均保留。Excel 使用店铺编码唯一定位店铺;换绑时以目标平台用户登录账号唯一定位业务员。每家实际变更店铺复用现有统一审计,记录原业务员、新业务员、操作人、时间和 Excel 行备注;批量任务同时保留汇总结果。
## 3. 当前阻塞与待后续独立需求
以下不是本轮继续扩展的产品设计题:
1. **OCR 外部契约**:仅可作为交易流水号、付款金额等字段的预填能力;接口、字段置信度、失败和重试规则须以外部契约为准。
2. **支付渠道契约**:微信直连、富友和支付宝的原路退款接口、超时查询及可恢复错误处理,须以各渠道实际契约为准。
3. **运营商契约**:通道计费周期起始日、停复机实际能力及结果未知后的查询/恢复,以运营商接口及业务提供的通道政策为准。
4. **换货佣金回溯**:本期只处理套餐退款;“不同资产换货”何时、按何金额回溯佣金未定义,后续如需实施须单独提出需求。
除上述依赖及独立后续需求外,本轮原始需求的业务规则已收口;后续工作是重编稳定需求 ID、建立原编号映射并按影响范围拆分独立 OpenSpec Change不直接开始编码。

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import (
"gorm.io/gorm"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -14,21 +15,23 @@ import (
// RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。
type RecoverOnlinePaymentService struct {
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
confirm *ConfirmOnlinePaymentService
audit PaymentAuditWriter
now func() time.Time
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, 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.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()
@@ -51,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, "待恢复支付单缺少充值单或创建配置")
@@ -70,7 +79,7 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er
}
func (s *RecoverOnlinePaymentService) recoverOne(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord, config *model.WechatConfig, now time.Time) error {
adapter := s.adapter(payment.PaymentMethod)
adapter := s.adapter(payment.PaymentMethod, config)
if adapter == nil {
return errors.New(errors.CodeNoPaymentConfig, "代理充值创建时支付配置不可用")
}
@@ -140,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)
}
}
@@ -150,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))
@@ -165,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 {
@@ -200,8 +227,11 @@ func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment
})
}
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string) OnlinePaymentPort {
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string, config *model.WechatConfig) OnlinePaymentPort {
if paymentMethod == constants.RechargeMethodWechat {
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
return s.fuiou
}
return s.wechat
}
if paymentMethod == constants.RechargeMethodAlipay {

View File

@@ -57,26 +57,9 @@ func (s *Service) ArchiveIntegrationDate(ctx context.Context, archiveDate time.T
return s.archiveIntegrationDate(ctx, archiveDate, false)
}
// FinalizePreviousIntegrationMonth 复核并终结上一个完整自然月的 Integration Log 归档
func (s *Service) FinalizePreviousIntegrationMonth(ctx context.Context) error {
now := time.Now().In(s.location)
return s.FinalizeIntegrationMonth(ctx, now.AddDate(0, -1, 0))
}
// FinalizeIntegrationMonth 逐日复核指定完整自然月,并为变化内容创建最终 revision。
func (s *Service) FinalizeIntegrationMonth(ctx context.Context, month time.Time) error {
monthStart := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
currentMonth := time.Now().In(s.location)
currentMonthStart := time.Date(currentMonth.Year(), currentMonth.Month(), 1, 0, 0, 0, 0, s.location)
if !monthStart.Before(currentMonthStart) {
return fmt.Errorf("只能终结已经结束的 Integration Log 完整自然月")
}
for date := monthStart; date.Before(monthStart.AddDate(0, 1, 0)); date = date.AddDate(0, 0, 1) {
if err := s.archiveIntegrationDate(ctx, date, true); err != nil {
return fmt.Errorf("终结 %s Integration Log 归档失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
// FinalizeIntegrationDate 为指定已结束自然日形成 Integration Log 最终归档版本
func (s *Service) FinalizeIntegrationDate(ctx context.Context, archiveDate time.Time) error {
return s.archiveIntegrationDate(ctx, archiveDate, true)
}
func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.Time, final bool) error {
@@ -94,22 +77,20 @@ func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.T
if err != nil {
return err
}
if final && run.Status == constants.ArchiveStatusSuccess && run.IsFinal && run.CleanedAt != nil {
terminalCount, countErr := s.integrationTerminalCount(ctx, start, end)
if countErr != nil {
return countErr
}
if terminalCount == 0 {
return nil
}
}
file, err := s.buildIntegrationArchiveFile(ctx, start, end)
if err != nil {
return err
}
defer os.Remove(file.path)
if final {
pending, pendingErr := s.integrationPendingCount(ctx, start, end)
if pendingErr != nil {
return pendingErr
}
if pending > 0 {
_ = s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).
Updates(map[string]any{"is_final": false, "error_summary": "存在 pending Integration Log无法形成最终归档", "updated_at": time.Now()}).Error
return fmt.Errorf("仍有 %d 条 pending Integration Log无法形成最终归档", pending)
}
}
if run.Status == constants.ArchiveStatusSuccess && run.RecordCount == file.recordCount && run.SHA256 == file.sha256 {
valid, validateErr := s.validateIntegrationRun(ctx, run)
if validateErr == nil && valid && (!final || run.IsFinal) {
@@ -165,7 +146,8 @@ func (s *Service) acquireIntegrationRun(ctx context.Context, run *model.LogArchi
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, now.Add(-3*time.Hour)).
Updates(map[string]any{
"status": constants.ArchiveStatusRunning, "revision": revision, "is_final": false,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "", "completed_at": nil, "updated_at": now,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "", "completed_at": nil,
"cleanup_started_at": nil, "cleaned_at": nil, "updated_at": now,
})
if result.Error != nil {
return false, fmt.Errorf("锁定 Integration Log 归档任务失败: %w", result.Error)
@@ -320,16 +302,6 @@ func (s *Service) integrationRecordCount(ctx context.Context, start, end time.Ti
return count, nil
}
func (s *Service) integrationPendingCount(ctx context.Context, start, end time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("created_at >= ? AND created_at < ? AND result = ?", start, end, constants.IntegrationResultPending).
Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计 pending Integration Log 失败: %w", err)
}
return count, nil
}
func integrationObjectKeys(date time.Time, revision int) (string, string) {
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
name := fmt.Sprintf("integration-logs-%s-r%d", date.Format(time.DateOnly), revision)

View File

@@ -7,11 +7,9 @@ import (
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -19,25 +17,18 @@ import (
const maxManifestBytes = 1024 * 1024
// RetentionAudit 描述月度物理清理的统一审计事实
// RetentionAudit 保留系统审计 Writer 的输入兼容类型
type RetentionAudit struct {
EventID string
Month string
Summary string
Result string
ErrorSummary string
RangeStart time.Time
RangeEnd time.Time
EventCount int64
ResourceCount int64
IntegrationCount int64
ManifestKeys []string
DurationMS int64
EventID, Month, Summary, Result, ErrorSummary string
RangeStart, RangeEnd time.Time
EventCount, ResourceCount, IntegrationCount int64
ManifestKeys []string
DurationMS int64
}
// RetentionResult 是月度留存清理的结构化执行结果。
// RetentionResult 是单日留存处理结果。
type RetentionResult struct {
Month string
ArchiveDate string
EventCount int64
ResourceCount int64
IntegrationCount int64
@@ -46,191 +37,150 @@ type RetentionResult struct {
Duration time.Duration
}
type retentionRuns struct {
audit []*model.LogArchiveRun
integration []*model.LogArchiveRun
// RetentionBlockedError 描述阻断日期推进的安全上下文。
type RetentionBlockedError struct {
ArchiveDate string
Source string
Err error
}
// CleanupPreviousMonth 校验并物理清理上一个完整自然月的在线审计日志。
func (s *Service) CleanupPreviousMonth(ctx context.Context) (RetentionResult, error) {
now := time.Now().In(s.location)
return s.CleanupMonth(ctx, now.AddDate(0, -1, 0))
func (e *RetentionBlockedError) Error() string {
return e.ArchiveDate + " " + e.Source + ": " + e.Err.Error()
}
func (e *RetentionBlockedError) Unwrap() error { return e.Err }
// ValidatePreviousMonth 只读校验上一个完整自然月的归档与清理门禁
func (s *Service) ValidatePreviousMonth(ctx context.Context) (RetentionResult, error) {
now := time.Now().In(s.location)
return s.ValidateMonth(ctx, now.AddDate(0, -1, 0))
}
// ValidateMonth 只读校验指定完整自然月,不写清理断点且不删除在线数据。
func (s *Service) ValidateMonth(ctx context.Context, month time.Time) (result RetentionResult, err error) {
// RetainPendingDays 每次最多处理一个最早待处理的上海自然日,避免单任务跨历史日期长时间占用数据库
func (s *Service) RetainPendingDays(ctx context.Context, cleanup bool) ([]RetentionResult, error) {
if s.db == nil || s.store == nil {
return result, fmt.Errorf("日志留存演练数据库或对象存储未配置")
return nil, fmt.Errorf("日志留存数据库或对象存储未配置")
}
start, end, err := s.retentionMonthRange(month)
start, end, err := s.pendingRetentionRange(ctx)
if err != nil {
return result, err
return nil, err
}
if !start.Before(end) {
return nil, nil
}
result, err := s.retainDate(ctx, start, cleanup)
if err != nil {
return nil, &RetentionBlockedError{ArchiveDate: start.Format(time.DateOnly), Source: constants.AuditArchiveSource, Err: err}
}
return []RetentionResult{result}, nil
}
// RetainDate 校验并按需清理指定已结束自然日,供受控演练使用。
func (s *Service) RetainDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
return s.retainDate(ctx, date, cleanup)
}
func (s *Service) pendingRetentionRange(ctx context.Context) (time.Time, time.Time, error) {
today := time.Now().In(s.location)
end := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)
var earliest *time.Time
for _, item := range []struct{ table, column, condition string }{
{"tb_audit_event", "created_at", ""},
{"tb_integration_log", "created_at", "result <> 'pending'"},
} {
query := s.db.WithContext(ctx).Table(item.table).Select("MIN(" + item.column + ")")
if item.condition != "" {
query = query.Where(item.condition)
}
var value *time.Time
if err := query.Scan(&value).Error; err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("查询日留存起点失败: %w", err)
}
if value != nil && (earliest == nil || value.Before(*earliest)) {
local := value.In(s.location)
earliest = &local
}
}
if earliest == nil {
return end, end, nil
}
start := time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, s.location)
return start, end, nil
}
func (s *Service) retainDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
startedAt := time.Now()
result.Month = start.Format("2006-01")
runs, err := s.loadRetentionRuns(ctx, start, end)
auditResult, err := s.retainAuditDate(ctx, date, cleanup)
if err != nil {
return result, err
return RetentionResult{}, err
}
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
return result, err
integrationResult, err := s.retainIntegrationDate(ctx, date, cleanup)
if err != nil {
return RetentionResult{}, err
}
summarizeRetentionRuns(runs, &result)
result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), EventCount: auditResult.EventCount, ResourceCount: auditResult.ResourceCount, IntegrationCount: integrationResult.IntegrationCount, ManifestKeys: append(auditResult.ManifestKeys, integrationResult.ManifestKeys...)}
result.EstimatedBatches = estimatedRetentionBatches(result)
result.Duration = time.Since(startedAt)
return result, nil
}
// CleanupMonth 校验归档硬门禁后按固定顺序物理清理指定完整自然月。
func (s *Service) CleanupMonth(ctx context.Context, month time.Time) (result RetentionResult, cleanupErr error) {
if s.db == nil || s.store == nil || s.audit == nil {
return result, fmt.Errorf("日志留存清理数据库、对象存储或审计 Writer 未配置")
}
start, end, err := s.retentionMonthRange(month)
func (s *Service) retainAuditDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
run, err := s.retentionRun(ctx, date, constants.AuditArchiveSource)
if err != nil {
return result, err
return RetentionResult{}, err
}
startedAt := time.Now()
result.Month = start.Format("2006-01")
cleanupErr = s.executeRetention(ctx, start, end, &result)
result.Duration = time.Since(startedAt)
if auditErr := s.recordRetentionAudit(ctx, start, end, result, cleanupErr); auditErr != nil {
if cleanupErr != nil {
return result, fmt.Errorf("%w记录留存清理失败审计失败: %v", cleanupErr, auditErr)
if run == nil || run.Status != constants.ArchiveStatusSuccess {
if err := s.ArchiveDate(ctx, date); err != nil {
return RetentionResult{}, fmt.Errorf("Audit 归档失败: %w", err)
}
run, err = s.retentionRun(ctx, date, constants.AuditArchiveSource)
if err != nil {
return RetentionResult{}, err
}
return result, fmt.Errorf("记录留存清理成功审计失败: %w", auditErr)
}
return result, cleanupErr
if err := s.validateAuditRetentionDay(ctx, date, run); err != nil {
return RetentionResult{}, fmt.Errorf("Audit 完整性校验失败: %w", err)
}
result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), EventCount: run.EventCount, ResourceCount: run.ResourceCount, ManifestKeys: []string{run.ManifestKey}}
if cleanup {
if err := s.cleanupAuditDate(ctx, date, run); err != nil {
return result, err
}
}
return result, nil
}
func (s *Service) retentionMonthRange(month time.Time) (time.Time, time.Time, error) {
start := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
end := start.AddDate(0, 1, 0)
now := time.Now().In(s.location)
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, s.location)
if !end.Before(currentMonth) && !end.Equal(currentMonth) {
return time.Time{}, time.Time{}, fmt.Errorf("只能清理已经结束的完整自然月")
}
return start, end, nil
}
func (s *Service) executeRetention(ctx context.Context, start, end time.Time, result *RetentionResult) error {
started, err := s.retentionCleanupStarted(ctx, start, end)
func (s *Service) retainIntegrationDate(ctx context.Context, date time.Time, cleanup bool) (RetentionResult, error) {
run, err := s.retentionRun(ctx, date, constants.IntegrationArchiveSource)
if err != nil {
return err
return RetentionResult{}, err
}
if !started {
lastDay := end.AddDate(0, 0, -1)
if err := s.ArchiveDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Audit 归档失败: %w", err)
}
if err := s.ArchiveIntegrationDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Integration Log 归档失败: %w", err)
}
if err := s.FinalizeIntegrationMonth(ctx, start); err != nil {
return err
if run == nil || run.Status != constants.ArchiveStatusSuccess {
if err := s.ArchiveIntegrationDate(ctx, date); err != nil {
return RetentionResult{}, fmt.Errorf("Integration Log 归档失败: %w", err)
}
}
runs, err := s.loadRetentionRuns(ctx, start, end)
if err := s.FinalizeIntegrationDate(ctx, date); err != nil {
return RetentionResult{}, fmt.Errorf("Integration Log 最终归档失败: %w", err)
}
run, err = s.retentionRun(ctx, date, constants.IntegrationArchiveSource)
if err != nil {
return err
return RetentionResult{}, err
}
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
return err
if err := s.validateIntegrationRetentionDay(ctx, date, run); err != nil {
return RetentionResult{}, fmt.Errorf("Integration Log 完整性校验失败: %w", err)
}
summarizeRetentionRuns(runs, result)
if err := s.cleanupAuditMonth(ctx, start, end, runs.audit); err != nil {
return err
result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), IntegrationCount: run.RecordCount, ManifestKeys: []string{run.ManifestKey}}
if cleanup {
if err := s.cleanupIntegrationDate(ctx, date, run); err != nil {
return result, err
}
}
return s.cleanupIntegrationMonth(ctx, start, end, runs.integration)
return result, nil
}
func (s *Service) retentionCleanupStarted(ctx context.Context, start, end time.Time) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return false, fmt.Errorf("读取月度清理断点失败: %w", err)
func (s *Service) retentionRun(ctx context.Context, date time.Time, source string) (*model.LogArchiveRun, error) {
var runs []model.LogArchiveRun
if err := s.db.WithContext(ctx).Where("source = ? AND archive_date = ? AND instance_id = ?", source, date.Format(time.DateOnly), s.instanceID).Find(&runs).Error; err != nil {
return nil, fmt.Errorf("读取日归档账本失败: %w", err)
}
return count > 0, nil
}
func (s *Service) loadRetentionRuns(ctx context.Context, start, end time.Time) (retentionRuns, error) {
var rows []model.LogArchiveRun
err := s.db.WithContext(ctx).Where(
"source IN ? AND archive_date >= ? AND archive_date < ? AND instance_id = ?",
[]string{constants.AuditArchiveSource, constants.IntegrationArchiveSource}, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID,
).Order("archive_date ASC, source ASC").Find(&rows).Error
if err != nil {
return retentionRuns{}, fmt.Errorf("读取月度归档账本失败: %w", err)
if len(runs) == 0 {
return nil, nil
}
days := int(end.Sub(start).Hours() / 24)
if len(rows) != days*2 {
return retentionRuns{}, fmt.Errorf("月度归档账本缺日:期望 %d 条,实际 %d 条", days*2, len(rows))
}
runs := retentionRuns{audit: make([]*model.LogArchiveRun, 0, days), integration: make([]*model.LogArchiveRun, 0, days)}
for index := range rows {
run := &rows[index]
switch run.Source {
case constants.AuditArchiveSource:
runs.audit = append(runs.audit, run)
case constants.IntegrationArchiveSource:
runs.integration = append(runs.integration, run)
}
}
if len(runs.audit) != days || len(runs.integration) != days {
return retentionRuns{}, fmt.Errorf("月度 Audit 或 Integration 归档账本不完整")
}
return runs, nil
}
func (s *Service) validateRetentionRuns(ctx context.Context, start, end time.Time, runs retentionRuns) error {
if err := validateCleanupLedgerState(runs.audit); err != nil {
return fmt.Errorf("Audit 清理断点非法: %w", err)
}
if err := validateCleanupLedgerState(runs.integration); err != nil {
return fmt.Errorf("Integration 清理断点非法: %w", err)
}
for index := range runs.audit {
date := start.AddDate(0, 0, index)
if err := s.validateAuditRetentionDay(ctx, date, runs.audit[index]); err != nil {
return fmt.Errorf("%s Audit 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
if err := s.validateIntegrationRetentionDay(ctx, date, runs.integration[index]); err != nil {
return fmt.Errorf("%s Integration 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
}
func validateCleanupLedgerState(runs []*model.LogArchiveRun) error {
started, cleaned := 0, 0
for _, run := range runs {
if run.CleanupStartedAt != nil {
started++
}
if run.CleanedAt != nil {
cleaned++
}
}
if started != 0 && started != len(runs) {
return fmt.Errorf("清理开始断点不是整月原子状态")
}
if cleaned != 0 && cleaned != len(runs) {
return fmt.Errorf("清理完成断点不是整月原子状态")
}
if cleaned > 0 && started == 0 {
return fmt.Errorf("清理完成但缺少开始断点")
}
return nil
return &runs[0], nil
}
func (s *Service) validateAuditRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
@@ -258,25 +208,25 @@ func (s *Service) validateIntegrationRetentionDay(ctx context.Context, date time
if err != nil {
return err
}
if run.CleanedAt != nil {
if count != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条记录", count)
}
return nil
}
if run.CleanupStartedAt != nil {
if count > run.RecordCount {
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
}
return nil
}
file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd)
terminalCount, err := s.integrationTerminalCount(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 {
return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致")
if run.CleanedAt != nil && terminalCount != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条终态记录", terminalCount)
}
if run.CleanupStartedAt != nil && count > run.RecordCount {
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
}
if terminalCount > 0 && run.CleanupStartedAt == nil {
file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 {
return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致")
}
}
return nil
}
@@ -285,8 +235,7 @@ func validateRunBase(run *model.LogArchiveRun, date time.Time, schema string, fi
if run.Status != constants.ArchiveStatusSuccess || run.SchemaVersion != schema {
return fmt.Errorf("归档状态或 schema version 不符合清理要求")
}
if run.ArchiveDate.Format(time.DateOnly) != date.Format(time.DateOnly) ||
!run.RangeStart.Equal(date) || !run.RangeEnd.Equal(date.AddDate(0, 0, 1)) {
if run.ArchiveDate.Format(time.DateOnly) != date.Format(time.DateOnly) || !run.RangeStart.Equal(date) || !run.RangeEnd.Equal(date.AddDate(0, 0, 1)) {
return fmt.Errorf("归档日期或半开时间范围不一致")
}
if final && !run.IsFinal {
@@ -297,21 +246,14 @@ func validateRunBase(run *model.LogArchiveRun, date time.Time, schema string, fi
}
return nil
}
func validateRemainingCounts(run *model.LogArchiveRun, events, resources int64) error {
if run.CleanedAt != nil {
if events != 0 || resources != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有事件或资源")
}
return nil
if run.CleanedAt != nil && (events != 0 || resources != 0) {
return fmt.Errorf("已标记清理完成但数据库仍有事件或资源")
}
if run.CleanupStartedAt != nil {
if events > run.EventCount || resources > run.ResourceCount {
return fmt.Errorf("续跑窗口数量超过已归档数量")
}
return nil
if run.CleanupStartedAt != nil && (events > run.EventCount || resources > run.ResourceCount) {
return fmt.Errorf("续跑窗口数量超过已归档数量")
}
if events != run.EventCount || resources != run.ResourceCount {
if run.CleanupStartedAt == nil && (events != run.EventCount || resources != run.ResourceCount) {
return fmt.Errorf("数据库事件或资源数量与 manifest 不一致")
}
return nil
@@ -322,43 +264,27 @@ func (s *Service) validateAuditManifest(ctx context.Context, run *model.LogArchi
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
return err
}
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess ||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
manifest.EventCount != run.EventCount || manifest.ResourceCount != run.ResourceCount ||
manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey ||
manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone || manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) || manifest.EventCount != run.EventCount || manifest.ResourceCount != run.ResourceCount || manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
return fmt.Errorf("Audit manifest 与 ledger 不一致")
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}); err != nil {
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision)}); err != nil {
return err
}
return s.verifyRetentionObject(ctx, run, false)
}
func (s *Service) validateIntegrationManifest(ctx context.Context, run *model.LogArchiveRun) error {
var manifest integrationArchiveManifest
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
return err
}
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || !manifest.Final ||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
manifest.RecordCount != run.RecordCount || manifest.CompressedBytes != run.CompressedBytes ||
manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || !manifest.Final || manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone || manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) || manifest.RecordCount != run.RecordCount || manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
return fmt.Errorf("Integration manifest 与最终 ledger 不一致")
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256,
"revision": strconv.Itoa(run.Revision), "final": "true",
}); err != nil {
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision), "final": "true"}); err != nil {
return err
}
return s.verifyRetentionObject(ctx, run, true)
}
func (s *Service) readManifest(ctx context.Context, key string, target any) error {
object, err := s.store.Stat(ctx, key)
if err != nil {
@@ -384,13 +310,8 @@ func (s *Service) readManifest(ctx context.Context, key string, target any) erro
}
return nil
}
func (s *Service) verifyRetentionObject(ctx context.Context, run *model.LogArchiveRun, final bool) error {
metadata := map[string]string{
"schema-version": run.SchemaVersion, "source": run.Source,
"archive-date": run.RangeStart.Format(time.DateOnly), "timezone": constants.AuditArchiveTimezone,
"sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}
metadata := map[string]string{"schema-version": run.SchemaVersion, "source": run.Source, "archive-date": run.RangeStart.Format(time.DateOnly), "timezone": constants.AuditArchiveTimezone, "sha256": run.SHA256, "revision": strconv.Itoa(run.Revision)}
if run.Source == constants.AuditArchiveSource {
metadata["event-count"] = strconv.FormatInt(run.EventCount, 10)
metadata["resource-count"] = strconv.FormatInt(run.ResourceCount, 10)
@@ -419,53 +340,39 @@ func (s *Service) verifyRetentionObject(ctx context.Context, run *model.LogArchi
}
return nil
}
func summarizeRetentionRuns(runs retentionRuns, result *RetentionResult) {
result.ManifestKeys = make([]string, 0, len(runs.audit)+len(runs.integration))
for _, run := range runs.audit {
result.EventCount += run.EventCount
result.ResourceCount += run.ResourceCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
for _, run := range runs.integration {
result.IntegrationCount += run.RecordCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
}
func estimatedRetentionBatches(result RetentionResult) int64 {
batchSize := int64(constants.AuditRetentionDeleteBatchSize)
return (result.EventCount+batchSize-1)/batchSize +
(result.ResourceCount+batchSize-1)/batchSize +
(result.IntegrationCount+batchSize-1)/batchSize
batch := int64(constants.AuditRetentionDeleteBatchSize)
return (result.EventCount+batch-1)/batch + (result.ResourceCount+batch-1)/batch + (result.IntegrationCount+batch-1)/batch
}
func (s *Service) cleanupAuditMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
func (s *Service) cleanupAuditDate(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
if run.CleanedAt != nil {
return nil
}
if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, start, end); err != nil {
if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, date); err != nil {
return err
}
if err := s.deleteAuditResources(ctx, start, end); err != nil {
if err := s.deleteAuditResources(ctx, date, date.AddDate(0, 0, 1)); err != nil {
return err
}
if err := s.deleteAuditEvents(ctx, start, end); err != nil {
if err := s.deleteAuditEvents(ctx, date, date.AddDate(0, 0, 1)); err != nil {
return err
}
return s.markCleaned(ctx, constants.AuditArchiveSource, start, end)
return s.markCleaned(ctx, constants.AuditArchiveSource, date)
}
func (s *Service) cleanupIntegrationMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
func (s *Service) cleanupIntegrationDate(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
terminalCount, err := s.integrationTerminalCount(ctx, date, date.AddDate(0, 0, 1))
if err != nil {
return err
}
if run.CleanedAt != nil && terminalCount == 0 {
return nil
}
if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, start, end); err != nil {
if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, date); err != nil {
return err
}
end := date.AddDate(0, 0, 1)
for {
subquery := s.db.Model(&model.IntegrationLog{}).Select("id").
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
subquery := s.db.Model(&model.IntegrationLog{}).Select("id").Where("created_at >= ? AND created_at < ? AND result <> ?", date, end, constants.IntegrationResultPending).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.IntegrationLog{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Integration Log 失败: %w", deleted.Error)
@@ -474,15 +381,21 @@ func (s *Service) cleanupIntegrationMonth(ctx context.Context, start, end time.T
break
}
}
return s.markCleaned(ctx, constants.IntegrationArchiveSource, start, end)
return s.markCleaned(ctx, constants.IntegrationArchiveSource, date)
}
func (s *Service) integrationTerminalCount(ctx context.Context, start, end time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("created_at >= ? AND created_at < ? AND result <> ?", start, end, constants.IntegrationResultPending).
Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计终态 Integration Log 失败: %w", err)
}
return count, nil
}
func (s *Service) deleteAuditResources(ctx context.Context, start, end time.Time) error {
for {
subquery := s.db.Model(&model.AuditEventResource{}).Select("tb_audit_event_resource.id").
Joins("JOIN tb_audit_event ON tb_audit_event.id = tb_audit_event_resource.audit_event_id").
Where("tb_audit_event.created_at >= ? AND tb_audit_event.created_at < ?", start, end).
Order("tb_audit_event_resource.id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
subquery := s.db.Model(&model.AuditEventResource{}).Select("tb_audit_event_resource.id").Joins("JOIN tb_audit_event ON tb_audit_event.id = tb_audit_event_resource.audit_event_id").Where("tb_audit_event.created_at >= ? AND tb_audit_event.created_at < ?", start, end).Order("tb_audit_event_resource.id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEventResource{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Audit Event Resource 失败: %w", deleted.Error)
@@ -492,11 +405,9 @@ func (s *Service) deleteAuditResources(ctx context.Context, start, end time.Time
}
}
}
func (s *Service) deleteAuditEvents(ctx context.Context, start, end time.Time) error {
for {
subquery := s.db.Model(&model.AuditEvent{}).Select("id").
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
subquery := s.db.Model(&model.AuditEvent{}).Select("id").Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEvent{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Audit Event 失败: %w", deleted.Error)
@@ -506,75 +417,19 @@ func (s *Service) deleteAuditEvents(ctx context.Context, start, end time.Time) e
}
}
}
func (s *Service) markCleanupStarted(ctx context.Context, source string, start, end time.Time) error {
func (s *Service) markCleanupStarted(ctx context.Context, source string, date time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NULL",
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Updates(map[string]any{"cleanup_started_at": now, "updated_at": now})
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("source = ? AND archive_date = ? AND instance_id = ? AND cleanup_started_at IS NULL", source, date.Format(time.DateOnly), s.instanceID).Updates(map[string]any{"cleanup_started_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录月度清理开始断点失败: %w", result.Error)
}
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleanup_started_at IS NOT NULL", "开始")
}
func (s *Service) markCleaned(ctx context.Context, source string, start, end time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Updates(map[string]any{"cleaned_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录月度清理完成断点失败: %w", result.Error)
}
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleaned_at IS NOT NULL", "完成")
}
func (s *Service) validateCleanupMarkerCount(ctx context.Context, source string, start, end time.Time, marker, label string) error {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND "+marker,
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return fmt.Errorf("复核月度清理%s断点失败: %w", label, err)
}
expected := int64(end.Sub(start).Hours() / 24)
if count != expected {
return fmt.Errorf("月度清理%s断点不完整期望 %d 条,实际 %d 条", label, expected, count)
return fmt.Errorf("记录清理开始断点失败: %w", result.Error)
}
return nil
}
func allRunsCleaned(runs []*model.LogArchiveRun) bool {
return len(runs) > 0 && runs[0].CleanedAt != nil
}
func (s *Service) recordRetentionAudit(ctx context.Context, start, end time.Time, result RetentionResult, cleanupErr error) error {
audit := RetentionAudit{
Month: result.Month, RangeStart: start, RangeEnd: end,
EventCount: result.EventCount, ResourceCount: result.ResourceCount,
IntegrationCount: result.IntegrationCount, ManifestKeys: result.ManifestKeys,
DurationMS: result.Duration.Milliseconds(), Result: constants.AuditResultSuccess,
Summary: "完成已归档在线日志月度物理清理",
EventID: "evt_retention_" + strings.ReplaceAll(result.Month, "-", "_"),
func (s *Service) markCleaned(ctx context.Context, source string, date time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("source = ? AND archive_date = ? AND instance_id = ? AND cleanup_started_at IS NOT NULL", source, date.Format(time.DateOnly), s.instanceID).Updates(map[string]any{"cleaned_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录日清理完成断点失败: %w", result.Error)
}
if cleanupErr != nil {
audit.EventID = ""
audit.Result = constants.AuditResultFailed
audit.Summary = "已归档在线日志月度物理清理失败"
audit.ErrorSummary = truncateRetentionError(cleanupErr)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteRetentionCleanup(ctx, tx, audit)
})
}
func truncateRetentionError(err error) string {
value := []rune(err.Error())
if len(value) > 500 {
value = value[:500]
}
return string(value)
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import (
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
@@ -286,8 +287,10 @@ 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),
auditWriter,
)
agentRechargePaymentConfirm := agentrechargeApp.NewConfirmOnlinePaymentService(
@@ -312,6 +315,7 @@ func initServices(s *stores, deps *Dependencies) *services {
)
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
refundService.SetNotificationOutbox(walletOutbox)
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis))
refundService.SetLifecycleAudit(auditWriter)
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
@@ -319,7 +323,6 @@ func initServices(s *stores, deps *Dependencies) *services {
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.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)
agentOpenAPI.SetObservationSeriesDispatcher(observationSeries)
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
wecomSceneRepository := wecomInfra.NewSceneRepository(deps.DB)
wecomMemberRepository := wecomInfra.NewMemberRepository(deps.DB)

View File

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

View File

@@ -3,6 +3,7 @@ package agentrecharge
import (
"strings"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -23,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 校验支付确认不变量,并返回是否属于完全一致的重复确认。
@@ -51,12 +55,28 @@ func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
return false, errors.New(errors.CodeConflict, "支付单与代理充值单关联不一致")
}
method := strings.TrimSpace(facts.PaymentMethod)
if method == "" || method != strings.TrimSpace(facts.RechargePaymentMethod) ||
method != strings.TrimSpace(facts.RechargePaymentChannel) {
if method == "" || method != strings.TrimSpace(facts.RechargePaymentMethod) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
channel := strings.TrimSpace(facts.RechargePaymentChannel)
if channel != method && !(method == constants.RechargeMethodWechat && channel == model.ProviderTypeFuiou) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
identity := strings.TrimSpace(facts.MerchantIdentity)
if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
if facts.FrozenMerchant {
frozenMethod := strings.TrimSpace(facts.FrozenMerchantPaymentMethod)
providerType := strings.TrimSpace(facts.FrozenMerchantProviderType)
if identity == "" || frozenMethod != method || providerType == "" {
return false, errors.New(errors.CodeConflict, "冻结商户支付事实不一致")
}
expectedChannel := method
if method == constants.RechargeMethodWechat && providerType == model.ProviderTypeFuiou {
expectedChannel = model.ProviderTypeFuiou
}
if channel != expectedChannel {
return false, errors.New(errors.CodeConflict, "支付渠道与冻结商户不一致")
}
} else if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
facts.PaymentConfigID != facts.ConfirmedConfigID || identity == "" ||
identity != strings.TrimSpace(facts.ConfirmedMerchantIdentity) {
return false, errors.New(errors.CodeConflict, "支付配置身份与创建记录不一致")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,7 @@ type Attempt struct {
CorrelationID *string
AuditEventID *uint
InitialResult string
StateChanged bool
RecoveryStrategy *string
}
@@ -135,7 +136,7 @@ func (r *Repository) Start(ctx context.Context, input Attempt) (*model.Integrati
Operation: input.Operation, ExternalID: sanitizedOptionalText(input.ExternalID), ResourceType: resourceType,
ResourceID: input.ResourceID, ResourceKey: sanitizedOptionalText(input.ResourceKey), TriggerSource: input.TriggerSource,
TriggerScene: sanitizedOptionalText(input.TriggerScene), TriggerSeries: input.TriggerSeries, ScheduledAt: input.ScheduledAt,
StartedAt: input.StartedAt, Attempt: input.Attempt, Result: result,
StartedAt: input.StartedAt, Attempt: input.Attempt, Result: result, StateChanged: input.StateChanged,
RequestSummary: requestSummary, Metadata: metadata, RequestID: input.RequestID,
CorrelationID: input.CorrelationID, AuditEventID: input.AuditEventID,
RecoveryStrategy: sanitizedOptionalText(input.RecoveryStrategy),
@@ -416,8 +417,8 @@ func validateAttempt(input Attempt) error {
if input.Direction != constants.IntegrationDirectionInbound && input.Direction != constants.IntegrationDirectionOutbound {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 方向无效")
}
if input.InitialResult != "" && input.InitialResult != constants.IntegrationResultPending && !isUnsentResult(input.InitialResult) {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 初始结果只能是待处理或未发送终态")
if input.InitialResult != "" && input.InitialResult != constants.IntegrationResultPending && !isTerminalResult(input.InitialResult) {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 初始结果无效")
}
if !validGeneratedString(input.IntegrationID, constants.IntegrationIDMaxLength) ||
!validOptionalString(input.TriggerSeries, constants.IntegrationTriggerSeriesMaxLength) ||

View File

@@ -0,0 +1,172 @@
package payment
import (
"context"
"strconv"
"strings"
"time"
"go.uber.org/zap"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
)
// FuiouScanAdapter 按富友主扫统一下单生成微信扫码支付链接并主动查单。
type FuiouScanAdapter struct {
integration *integrationlog.Repository
logger *zap.Logger
}
// NewFuiouScanAdapter 创建富友扫码支付适配器。
func NewFuiouScanAdapter(integration *integrationlog.Repository, logger *zap.Logger) *FuiouScanAdapter {
return &FuiouScanAdapter{integration: integration, logger: logger}
}
// Available 判断富友主扫下单、验签与查单所需的配置是否完整。
func (a *FuiouScanAdapter) Available(config *model.WechatConfig) bool {
return fuiouConfigComplete(config, true)
}
// CreatePaymentURL 调用富友主扫统一下单并返回二维码链接。
func (a *FuiouScanAdapter) CreatePaymentURL(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentResult, error) {
attempt, err := a.startAttempt(ctx, request, constants.IntegrationOperationPaymentPreCreate, request.Config.ID, request.Amount)
if err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
startedAt := time.Now()
client, err := a.newClient(request.Config)
if err != nil {
return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, err)
}
expireMinutes := int(time.Until(request.ExpireAt).Minutes())
if expireMinutes < 1 {
expireMinutes = 1
}
resp, callErr := client.PreCreate(
request.PaymentNo, strconv.FormatInt(request.Amount, 10), request.Description,
fuiou.GetServerIP(), fuiou.OrderTypeWechat, strconv.Itoa(expireMinutes),
)
if callErr != nil {
return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr)
}
if strings.TrimSpace(resp.QrCode) == "" {
return agentrecharge.OnlinePaymentResult{}, a.completeFailed(ctx, attempt.IntegrationID, startedAt, "empty_qr_code", "富友主扫下单未返回二维码链接")
}
if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"success": true},
DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: true,
}); err != nil {
return agentrecharge.OnlinePaymentResult{}, err
}
return agentrecharge.OnlinePaymentResult{QRContent: resp.QrCode}, nil
}
// Query 调用富友订单查询并按 trans_stat 返回统一查询结果。
func (a *FuiouScanAdapter) Query(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentQueryResult, error) {
attempt, err := a.startAttempt(ctx, request, constants.IntegrationOperationPaymentQuery, request.Config.ID, 0)
if err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, err
}
startedAt := time.Now()
client, err := a.newClient(request.Config)
if err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, err)
}
resp, callErr := client.CommonQuery(request.PaymentNo, fuiou.OrderTypeWechat)
if callErr != nil {
return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr)
}
result := agentrecharge.OnlinePaymentQueryResult{State: mapFuiouTransStat(resp.TransStat)}
if result.State == agentrecharge.OnlinePaymentStatePaid {
result.ThirdPartyTradeNo = strings.TrimSpace(resp.TransactionId)
result.Amount, _ = strconv.ParseInt(strings.TrimSpace(resp.OrderAmt), 10, 64)
if paidAt, ok := parseWechatPaidAt(strings.TrimSpace(resp.ReservedTxnFinTs)); ok {
result.PaidAt = &paidAt
}
}
providerCode := resp.TransStat
if strings.TrimSpace(providerCode) == "" {
providerCode = "unknown"
}
if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ProviderCode: providerCode,
ResponseSummary: map[string]any{"state": result.State, "has_trade_no": result.ThirdPartyTradeNo != ""},
DurationMS: time.Since(startedAt).Milliseconds(),
}); err != nil {
return agentrecharge.OnlinePaymentQueryResult{}, err
}
return result, nil
}
func fuiouConfigComplete(config *model.WechatConfig, requireActive bool) bool {
return config != nil && (!requireActive || config.IsActive) && config.ProviderType == model.ProviderTypeFuiou &&
config.FyInsCd != "" && config.FyMchntCd != "" && config.FyTermID != "" &&
config.FyPrivateKey != "" && config.FyPublicKey != "" && config.FyAPIURL != "" && config.FyNotifyURL != ""
}
func (a *FuiouScanAdapter) newClient(config *model.WechatConfig) (*fuiou.Client, error) {
if !fuiouConfigComplete(config, false) {
return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "富友扫码支付配置不可用")
}
return fuiou.NewClient(
config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyAPIURL, config.FyNotifyURL,
config.FyPrivateKey, config.FyPublicKey, a.logger,
)
}
func mapFuiouTransStat(transStat string) string {
switch transStat {
case "SUCCESS":
return agentrecharge.OnlinePaymentStatePaid
case "PAYERROR", "CLOSED", "REVOKED":
return agentrecharge.OnlinePaymentStateClosed
case "USERPAYING", "NOTPAY":
return agentrecharge.OnlinePaymentStatePending
default:
return agentrecharge.OnlinePaymentStateUnknown
}
}
func (a *FuiouScanAdapter) startAttempt(ctx context.Context, request agentrecharge.OnlinePaymentRequest, operation string, configID uint, amount int64) (*model.IntegrationLog, error) {
resourceID, resourceKey := strconv.FormatUint(uint64(request.PaymentID), 10), request.PaymentNo
series := "agent-recharge-payment:" + resourceID + ":" + operation
correlationID := request.CorrelationID
return a.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderFuiou, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ResourceType: constants.IntegrationResourceTypeAgentRechargePayment,
ResourceID: &resourceID, ResourceKey: &resourceKey, ExternalID: &resourceKey,
TriggerSeries: &series, CorrelationID: &correlationID,
RequestSummary: map[string]any{"payment_config_id": configID, "amount": amount},
})
}
func (a *FuiouScanAdapter) completeUnknown(ctx context.Context, integrationID string, startedAt time.Time, cause error) error {
if a.logger != nil {
a.logger.Warn("富友支付请求结果未知", zap.String("integration_id", integrationID), zap.Error(cause))
}
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", SafeProviderMessage: "富友支付请求结果未知",
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
})
if err != nil {
return err
}
return apperrors.Wrap(apperrors.CodeTimeout, cause, "富友支付请求结果未知")
}
func (a *FuiouScanAdapter) completeFailed(ctx context.Context, integrationID string, startedAt time.Time, providerCode, providerMessage string) error {
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderCode: providerCode, SafeProviderMessage: providerMessage,
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
})
if err != nil {
return err
}
return apperrors.New(apperrors.CodeServiceUnavailable, providerMessage)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -223,29 +223,14 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con
count := 0
for _, usage := range orphanUsages {
carrierType, carrierID := h.getCarrierInfo(usage)
activated, activationErr := h.activationService.ActivateNextPendingMainPackage(ctx, carrierType, carrierID)
if activationErr != nil {
h.logger.Warn("孤儿套餐同步激活失败",
if err := h.enqueueActivationTask(ctx, usage.ID, carrierType, carrierID, "orphan_recovery"); err != nil {
h.logger.Warn("提交孤儿套餐激活任务失败",
zap.Uint("package_usage_id", usage.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "orphan_recovery"),
zap.Error(activationErr))
zap.Error(err))
continue
}
if !activated {
h.logger.Info("孤儿套餐本轮未激活",
zap.Uint("package_usage_id", usage.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "orphan_recovery"))
continue
}
h.logger.Info("孤儿套餐同步激活成功",
zap.Uint("package_usage_id", usage.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "orphan_recovery"))
count++
}
@@ -268,7 +253,8 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
}
// processExpiredPackage 处理单个过期套餐
// 流程:先同步最新流量 → 事务内标记过期和失效加油包 → 提交后同步接续并触发停机检查
// 流程:先同步最新流量 → 事务内标记过期和失效加油包 → 提交后投递下一套餐;
// 仅明确没有后续套餐时才触发停机检查,避免与异步激活任务竞态。
func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error {
carrierType, carrierID := h.getCarrierInfo(pkg)
@@ -324,30 +310,17 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
return nil
}
// 事务提交后再投递,确保消费者只能读取到旧套餐已经过期的状态。
if carrierType != "" && carrierID > 0 {
activated, activationErr := h.activationService.ActivateNextPendingMainPackage(ctx, carrierType, carrierID)
if activationErr != nil {
h.logger.Warn("过期后同步接续套餐失败",
zap.Uint("expired_package_usage_id", pkg.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "expired_package"),
zap.Error(activationErr))
} else if activated {
h.logger.Info("过期后同步接续套餐成功",
zap.Uint("expired_package_usage_id", pkg.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "expired_package"))
} else {
h.logger.Info("过期后本轮未接续套餐",
zap.Uint("expired_package_usage_id", pkg.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "expired_package"))
activationEnqueued, err := h.activateNextPackage(ctx, h.db.WithContext(ctx), carrierType, carrierID)
if err != nil {
// 激活结果未知时不能将其当作无套餐;孤儿扫描和套餐轮询会继续兜底。
return err
}
if activationEnqueued {
return nil
}
h.triggerStopAfterExpiry(ctx, carrierType, carrierID)
return activationErr
}
return nil
@@ -401,8 +374,36 @@ func (h *PackageActivationHandler) getCarrierInfo(pkg *model.PackageUsage) (stri
return "", 0
}
// triggerStopAfterExpiry 套餐过期后异步触发停机检查
// 仅在确认无后续生效套餐时有效CheckAndStopCard 内部有幂等保护,重复调用安全
// activateNextPackage 提交下一个待生效主套餐的异步激活任务。
// 返回 true 表示已找到并成功提交后续套餐false 表示不存在后续套餐。
func (h *PackageActivationHandler) activateNextPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
var nextPkg model.PackageUsage
query := tx.Where("status = ?", constants.PackageUsageStatusPending).
Where("master_usage_id IS NULL").
Order("priority ASC, created_at ASC, id ASC").
Limit(1)
if carrierType == constants.AssetTypeIotCard {
query = query.Where("iot_card_id = ?", carrierID)
} else if carrierType == constants.AssetTypeDevice {
query = query.Where("device_id = ?", carrierID)
}
if err := query.First(&nextPkg).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil
}
return false, err
}
if err := h.enqueueActivationTask(ctx, nextPkg.ID, carrierType, carrierID, "queue"); err != nil {
return false, err
}
return true, nil
}
// triggerStopAfterExpiry 在明确无后续套餐时异步触发停机检查。
// 后续套餐存在时,停机重评估由激活任务在完成后顺序执行。
func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, carrierType string, carrierID uint) {
if h.stopResumeCallback == nil {
return
@@ -441,6 +442,42 @@ func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, c
}
}
// reconcileCarrierAfterActivation 在套餐激活任务完成后按最新事实重评估停复机。
// 它必须在激活调用返回后执行,不能与激活任务并发读取过期权益快照。
func (h *PackageActivationHandler) reconcileCarrierAfterActivation(ctx context.Context, packageUsageID uint, carrierType string, carrierID uint) error {
if h.stopResumeCallback == nil {
h.logger.Warn("套餐激活后停复机回调未注入,跳过重评估",
zap.Uint("package_usage_id", packageUsageID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
}
if carrierID == 0 {
return errors.New(errors.CodeInvalidParam, "套餐使用记录缺少有效载体")
}
if carrierType == constants.AssetTypeIotCard {
if err := h.stopResumeCallback.CheckAndStopCard(ctx, carrierID); err != nil {
return err
}
return nil
}
if carrierType != constants.AssetTypeDevice {
return errors.New(errors.CodeInvalidParam, "套餐使用记录载体类型无效")
}
bindings, err := h.deviceSimBinding.ListByDeviceID(ctx, carrierID)
if err != nil {
return err
}
for _, binding := range bindings {
if err := h.stopResumeCallback.CheckAndStopCard(ctx, binding.IotCardID); err != nil {
return err
}
}
return nil
}
// enqueueActivationTask 提交套餐激活任务到 Asynq
func (h *PackageActivationHandler) enqueueActivationTask(ctx context.Context, packageUsageID uint, carrierType string, carrierID uint, activationType string) error {
linkage := auditcontext.From(ctx)
@@ -509,14 +546,7 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont
return err
}
// 幂等性检查:如果已经是生效状态,跳过
if pkg.Status == constants.PackageUsageStatusActive {
h.logger.Info("套餐已激活,跳过",
zap.Uint("package_usage_id", payload.PackageUsageID))
return nil
}
// 调用 ActivationService 执行激活
// 调用 ActivationService 执行激活。即使套餐已由其他任务激活,仍须重新评估停复机。
if h.activationService != nil {
if err := h.activationService.ActivateSpecificPackage(ctx, payload.PackageUsageID); err != nil {
h.logger.Error("套餐激活失败",
@@ -531,8 +561,20 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont
return errors.New(errors.CodeInternalError, "激活服务未注入,无法执行套餐激活")
}
h.logger.Info("套餐激活成功",
zap.Uint("package_usage_id", payload.PackageUsageID))
carrierType, carrierID := h.getCarrierInfo(&pkg)
if err := h.reconcileCarrierAfterActivation(ctx, payload.PackageUsageID, carrierType, carrierID); err != nil {
h.logger.Error("套餐激活后停复机重评估失败",
zap.Uint("package_usage_id", payload.PackageUsageID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Error(err))
return err
}
h.logger.Info("套餐激活及停复机重评估完成",
zap.Uint("package_usage_id", payload.PackageUsageID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
}

View File

@@ -98,6 +98,20 @@ func (m *PollingQueueManager) Requeue(ctx context.Context, cardID uint, taskType
}).Err()
}
// EnsureQueued 仅在任务当前不在分片队列中时补入任务,不覆盖已有任务的执行时间。
func (m *PollingQueueManager) EnsureQueued(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) (bool, error) {
shardID := int(cardID) % m.shardCount
key := constants.RedisPollingShardQueueKey(shardID, taskType)
added, err := m.redis.ZAddArgs(ctx, key, redis.ZAddArgs{
NX: true,
Members: []redis.Z{{
Score: float64(nextCheckAt.Unix()),
Member: fmt.Sprintf("%d", cardID),
}},
}).Result()
return added > 0, err
}
// RemoveFromAllQueues 从所有分片的所有5个队列realname/carddata/package/protect/card_status移除指定卡
// 修复 Bug3旧实现漏掉 protect 队列
func (m *PollingQueueManager) RemoveFromAllQueues(ctx context.Context, cardID uint) error {

View File

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

View File

@@ -13,75 +13,108 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Source 表示受在线留存边界约束的数据源。
type Source string
const (
// SourceAudit 表示统一审计事件。
SourceAudit Source = constants.AuditArchiveSource
// SourceIntegration 表示外部交互日志。
SourceAudit Source = constants.AuditArchiveSource
SourceIntegration Source = constants.IntegrationArchiveSource
)
// Info 是查询响应公开的在线留存边界。
type Info struct {
OnlineFrom time.Time `json:"online_from" description:"当前可在线查询的最早时间"`
ArchivedBefore *time.Time `json:"archived_before" description:"早于该时间的数据已归档;尚未清理时为空"`
Timezone string `json:"timezone" description:"留存自然日时区"`
}
// Load 从归档账本读取已完成物理清理的数据边界
// Load 仅公开从最早在线日期开始连续完成物理清理的边界,绝不跨越清理空洞
func Load(ctx context.Context, db *gorm.DB, sources ...Source) (Info, error) {
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
if err != nil {
return Info{}, errors.Wrap(errors.CodeInternalError, err, "加载审计留存时区失败")
}
now := time.Now().In(location)
info := Info{OnlineFrom: time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location), Timezone: constants.AuditArchiveTimezone}
boundaries := make([]sourceRetention, 0, len(sources))
for _, source := range sources {
boundary, cleaned, err := sourceBoundary(ctx, db, source, location)
boundary, err := sourceBoundary(ctx, db, source, location)
if err != nil {
return Info{}, err
}
if boundary.Before(info.OnlineFrom) && info.ArchivedBefore == nil {
info.OnlineFrom = boundary
boundaries = append(boundaries, boundary)
}
now := time.Now().In(location)
fallback := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
info := Info{OnlineFrom: fallback, Timezone: constants.AuditArchiveTimezone}
if len(boundaries) == 0 {
return info, nil
}
for _, boundary := range boundaries {
if boundary.onlineFrom.Before(info.OnlineFrom) {
info.OnlineFrom = boundary.onlineFrom
}
if cleaned && (info.ArchivedBefore == nil || boundary.After(*info.ArchivedBefore)) {
value := boundary
info.ArchivedBefore = &value
info.OnlineFrom = boundary
}
if len(boundaries) == 1 && boundaries[0].cleaned {
value := boundaries[0].onlineFrom
info.ArchivedBefore = &value
return info, nil
}
if len(boundaries) > 1 {
common := boundaries[0].onlineFrom
allCleaned := boundaries[0].cleaned
for _, boundary := range boundaries[1:] {
if boundary.onlineFrom.Before(common) {
common = boundary.onlineFrom
}
allCleaned = allCleaned && boundary.cleaned
}
if allCleaned {
info.OnlineFrom = common
info.ArchivedBefore = &common
}
}
return info, nil
}
func sourceBoundary(ctx context.Context, db *gorm.DB, source Source, location *time.Location) (time.Time, bool, error) {
var cleanedEnd sql.NullTime
if err := db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND cleaned_at IS NOT NULL", source).
Select("MAX(range_end)").Scan(&cleanedEnd).Error; err != nil {
return time.Time{}, false, errors.Wrap(errors.CodeDatabaseError, err, "查询审计留存清理边界失败")
}
if cleanedEnd.Valid {
return cleanedEnd.Time.In(location), true, nil
}
type sourceRetention struct {
onlineFrom time.Time
cleaned bool
}
var earliest sql.NullTime
table, column := "tb_audit_event", "occurred_at"
func sourceBoundary(ctx context.Context, db *gorm.DB, source Source, location *time.Location) (sourceRetention, error) {
table, column := "tb_audit_event", "created_at"
if source == SourceIntegration {
table, column = "tb_integration_log", "created_at"
}
if err := db.WithContext(ctx).Table(table).Select("MIN(" + column + ")").Scan(&earliest).Error; err != nil {
return time.Time{}, false, errors.Wrap(errors.CodeDatabaseError, err, "查询审计在线数据边界失败")
var earliestOnline, earliestLedger sql.NullTime
if err := db.WithContext(ctx).Table(table).Select("MIN(" + column + ")").Scan(&earliestOnline).Error; err != nil {
return sourceRetention{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审计在线数据边界失败")
}
if earliest.Valid {
return earliest.Time.In(location), false, nil
if err := db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("source = ?", source).Select("MIN(archive_date)").Scan(&earliestLedger).Error; err != nil {
return sourceRetention{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审计留存账本边界失败")
}
now := time.Now().In(location)
return time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location), false, nil
if !earliestOnline.Valid && !earliestLedger.Valid {
now := time.Now().In(location)
return sourceRetention{onlineFrom: time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)}, nil
}
start := earliestLedger.Time.In(location)
if earliestOnline.Valid && (!earliestLedger.Valid || earliestOnline.Time.Before(earliestLedger.Time)) {
start = earliestOnline.Time.In(location)
}
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, location)
var rows []model.LogArchiveRun
if err := db.WithContext(ctx).Where("source = ? AND archive_date >= ?", source, start.Format(time.DateOnly)).Order("archive_date ASC").Find(&rows).Error; err != nil {
return sourceRetention{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审计留存清理边界失败")
}
expected := start
for _, row := range rows {
date := row.ArchiveDate
date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
if !date.Equal(expected) || row.CleanedAt == nil {
break
}
expected = expected.AddDate(0, 0, 1)
}
return sourceRetention{onlineFrom: expected, cleaned: !expected.Equal(start)}, nil
}
// NormalizeRange 将缺省范围收敛到在线窗口,并拒绝归档或跨边界查询。
func NormalizeRange(info Info, from, to *time.Time, maxRange ...time.Duration) (*time.Time, *time.Time, error) {
explicitFrom := from != nil
if from != nil && info.ArchivedBefore != nil && from.Before(info.OnlineFrom) {
@@ -110,7 +143,6 @@ func NormalizeRange(info Info, from, to *time.Time, maxRange ...time.Duration) (
}
return from, to, nil
}
func archivedError(info Info) error {
return errors.NewWithData(errors.CodeAuditDataArchived, map[string]any{"retention": info})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,9 @@ import (
"context"
"fmt"
"math/rand"
"strconv"
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -40,12 +38,6 @@ type Service struct {
agentWalletStore *postgres.AgentWalletStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
deviceStore *postgres.DeviceStore
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesDispatcher 注入 OpenAPI 读取后的后台观测序列端口。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
}
// New 创建代理开放接口业务编排服务
@@ -91,11 +83,7 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
if cardErr == nil {
// 独立卡:直接查卡维度流量
if card.IsStandalone {
resp, err := s.buildCardTrafficResponse(ctx, req.CardNo, "iot_card", card.ID, "")
if err == nil {
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardTraffic, card.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, err
return s.buildCardTrafficResponse(ctx, req.CardNo, "iot_card", card.ID, "")
}
// 已绑定设备的卡:反查设备后查设备维度流量
binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
@@ -106,11 +94,7 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定设备信息失败")
}
resp, buildErr := s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
if buildErr == nil {
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardTraffic, card.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, buildErr
return s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
}
// 兜底:尝试解析为设备标识(支持 IMEI/虚拟号)
@@ -119,11 +103,7 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
// 两种解析都失败,返回原始卡解析错误(语义更贴近入参)
return nil, cardErr
}
resp, buildErr := s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
if buildErr == nil {
s.dispatchDeviceCardObservations(ctx, constants.CardObservationSceneOpenCardTraffic, device.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, buildErr
return s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
}
// buildCardTrafficResponse 统一构造卡流量响应,支持卡和设备两种载体维度
@@ -195,7 +175,6 @@ func (s *Service) GetCardStatus(ctx context.Context, req *dto.AgentOpenAPICardQu
StopReason: stopReason,
StopReasonName: constants.AgentOpenAPIStopReasonName(stopReason),
}
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardNetwork, card.ID, constants.CardObservationSyncTypeNetwork)
return resp, nil
}
@@ -210,7 +189,6 @@ func (s *Service) GetRealnameStatus(ctx context.Context, req *dto.AgentOpenAPICa
CardNo: req.CardNo,
IsRealnamed: card.RealNameStatus == constants.RealNameStatusVerified,
}
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardRealname, card.ID, constants.CardObservationSyncTypeRealname)
return resp, nil
}
@@ -420,41 +398,6 @@ func (s *Service) CreateWalletPackageOrders(ctx context.Context, req *dto.AgentO
return resp, nil
}
func (s *Service) dispatchDeviceCardObservations(ctx context.Context, scene string, deviceID uint, syncType string) {
if s.observationSeries == nil || deviceID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
s.observationSeries.DispatchDeviceCards(ctx, cardObservationApp.DeviceCardsSeriesRequest{
DeviceID: deviceID,
Request: cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeDevice,
ResourceID: strconv.FormatUint(uint64(deviceID), 10), SyncType: syncType,
Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
},
})
}
func (s *Service) dispatchCardObservation(ctx context.Context, scene string, cardID uint, syncType string) {
if s.observationSeries == nil || cardID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
s.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(cardID), 10), SyncType: syncType,
Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
// resolveOpenAPIDevice 将开放接口设备标识解析为当前代理可见的设备
// 设备不存在或不属于代理管辖店铺时统一返回 CodeForbidden避免信息泄露
func (s *Service) resolveOpenAPIDevice(ctx context.Context, deviceNo string) (*model.Device, error) {
@@ -532,7 +475,6 @@ func (s *Service) GetDeviceTraffic(ctx context.Context, req *dto.AgentOpenAPIDev
resp.PendingPackages = append(resp.PendingPackages, s.buildTrafficItem(usage, packageMap, seriesMap, false))
}
s.dispatchDeviceCardObservations(ctx, constants.CardObservationSceneOpenDeviceTraffic, device.ID, constants.CardObservationSyncTypeTraffic)
return resp, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -249,10 +249,25 @@ func (s *StopResumeService) isTrafficExhausted(ctx context.Context, card *model.
}
// isRealnameOK 检查卡是否满足实名要求
// 行业卡card_category='industry')无需实名;其他卡需 real_name_status=1
func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool {
return card.CardCategory == constants.CardCategoryIndustry ||
card.RealNameStatus == constants.RealNameStatusVerified
// 行业卡、已实名卡直接放行;其他卡按有效设备或自身的无需实名策略判断。
func (s *StopResumeService) isRealnameOK(ctx context.Context, card *model.IotCard) (bool, error) {
if card.CardCategory == constants.CardCategoryIndustry || card.RealNameStatus == constants.RealNameStatusVerified {
return true, nil
}
deviceID, bound, err := s.getCardDeviceID(ctx, card)
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名策略绑定关系失败")
}
if !bound {
return card.RealnamePolicy == constants.RealnamePolicyNone, nil
}
var device model.Device
if err := s.db.WithContext(ctx).Select("realname_policy").First(&device, deviceID).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备实名策略失败")
}
return device.RealnamePolicy == constants.RealnamePolicyNone, nil
}
// checkStopReasons 检查卡的停机原因列表,按优先级排序
@@ -278,8 +293,12 @@ func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.Io
reasons = append(reasons, constants.StopReasonNoPackage)
}
// 条件C非行业卡且未实名
if !s.isRealnameOK(card) {
// 条件C不满足实际生效的实名要求
realnameOK, err := s.isRealnameOK(ctx, card)
if err != nil {
return nil, err
}
if !realnameOK {
reasons = append(reasons, constants.StopReasonNotRealname)
}
@@ -305,7 +324,7 @@ func (s *StopResumeService) shouldResume(ctx context.Context, card *model.IotCar
return false, nil
}
return s.isRealnameOK(card), nil
return s.isRealnameOK(ctx, card)
}
// stopDeviceCards 停机设备下所有在线卡(含设备维度幂等锁)
@@ -349,7 +368,7 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint,
}
// resumeDeviceCards 复机设备下满足条件的停机卡(含设备维度幂等锁)
// 遍历时对每张卡检查实名状态:未实名普通卡更新 stop_reason='not_realname' 后跳过
// 遍历时对每张卡检查有效实名策略:不满足要求的卡更新 stop_reason='not_realname' 后跳过
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) error {
// 设备维度幂等锁:与 stopDeviceCards 共用,防止停/复机并发
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
@@ -374,7 +393,14 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint
var cardErrors []error
for _, card := range cards {
if !s.isRealnameOK(card) {
realnameOK, realnameErr := s.isRealnameOK(ctx, card)
if realnameErr != nil {
cardErrors = append(cardErrors, realnameErr)
s.logger.Warn("检查设备卡实名策略失败,继续处理其他卡",
zap.Uint("device_id", deviceID), zap.Uint("card_id", card.ID), zap.Error(realnameErr))
continue
}
if !realnameOK {
if updateErr := s.updateCardStopReasonWithAudit(ctx, card, constants.StopReasonNotRealname); updateErr != nil {
cardErrors = append(cardErrors, updateErr)
s.logger.Warn("更新未实名卡停机原因失败",
@@ -763,7 +789,12 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
}
actionCode, summary := stopAuditAction(ctx, constants.StopReasonManual)
if !s.isRealnameOK(card) {
realnameOK, err := s.isRealnameOK(ctx, card)
if err != nil {
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, err)
return err
}
if !realnameOK {
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr)
return denyErr
@@ -815,7 +846,12 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
return denyErr
}
if !s.isRealnameOK(card) {
realnameOK, err := s.isRealnameOK(ctx, card)
if err != nil {
s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, err)
return err
}
if !realnameOK {
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr)
return denyErr

View File

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

View File

@@ -334,10 +334,10 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
return errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败")
}
if !locked {
s.logger.Warn("套餐激活正在进行中,跳过",
s.logger.Warn("套餐激活正在进行中,等待任务重试",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
return errors.New(errors.CodePackageActivationConflict)
}
defer s.redis.Del(ctx, lockKey)

View File

@@ -127,8 +127,8 @@ func (s *ConcurrencyService) GetByTaskType(ctx context.Context, taskType string)
// UpdateMaxConcurrency 更新最大并发数
func (s *ConcurrencyService) UpdateMaxConcurrency(ctx context.Context, taskType string, maxConcurrency int, updatedBy uint) error {
// 验证参数
if maxConcurrency < 1 {
return errors.New(errors.CodeInvalidParam, "并发数必须为正整数")
if maxConcurrency < 1 || maxConcurrency > constants.PollingMaxConcurrencyLimit {
return errors.New(errors.CodeInvalidParam, "并发数必须为 1-1000")
}
// 验证任务类型存在

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
@@ -56,6 +57,7 @@ type Service struct {
notificationOutbox *outbox.Repository
auditWriter *audit.Writer
logger *zap.Logger
paymentMerchantRuntime *merchantpayment.RuntimeLoader
}
// New 创建退款业务服务实例
@@ -238,6 +240,27 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re
}
// GetByID 根据 ID 查询退款申请详情
// TriggerApproval 为历史退款申请主动补发企业微信审批。
func (s *Service) TriggerApproval(ctx context.Context, id uint) (*dto.RefundResponse, error) {
if s.refundApprovalCreation == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
result, err := s.refundApprovalCreation.TriggerHistorical(ctx, refund.ID)
if err != nil {
return nil, err
}
resp := buildRefundResponse(result.Refund)
resp.SubmitterName = result.SubmitterName
resp.ApprovalProvider = constants.IntegrationProviderWeCom
resp.ApprovalStatus = &result.ApprovalStatus
resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus)
return resp, nil
}
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, error) {
refund, err := s.refundStore.GetByID(ctx, id)
if err != nil {
@@ -334,6 +357,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
if orderResult.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "订单状态已变更,无法执行退款审批")
}
if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil {
return err
}
if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil {
return err
@@ -388,7 +414,7 @@ func (s *Service) appendCompletedNotification(ctx context.Context, tx *gorm.DB,
// refundWalletPayment 处理钱包支付订单的退款回款。
func (s *Service) refundWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
if order.PaymentMethod != model.PaymentMethodWallet {
if amount == 0 || order.PaymentMethod != model.PaymentMethodWallet {
return nil
}
@@ -1180,8 +1206,8 @@ func validateRequestedRefundAmountByOrder(requestedRefundAmount int64, order *mo
// validateApprovedRefundAmount 校验审批退款金额不能超过申请金额和订单实收金额。
func validateApprovedRefundAmount(approvedAmount int64, requestedRefundAmount int64, order *model.Order) error {
if approvedAmount <= 0 {
return errors.New(errors.CodeInvalidParam, "审批退款金额必须大于0")
if approvedAmount < 0 {
return errors.New(errors.CodeInvalidParam, "审批退款金额不能小于0")
}
if approvedAmount > requestedRefundAmount {
return errors.New(errors.CodeInvalidParam, "审批退款金额不能大于申请退款金额")

View File

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

View File

@@ -58,13 +58,12 @@ func (s *PackageUsageStore) Update(ctx context.Context, usage *model.PackageUsag
return s.db.WithContext(ctx).Save(usage).Error
}
// GetActiveMainPackage 查询载体的生效中主套餐
// carrierType: "iot_card" 或 "device"
// carrierID: iot_card_id 或 device_id
func (s *PackageUsageStore) GetActiveMainPackage(ctx context.Context, carrierType string, carrierID uint) (*model.PackageUsage, error) {
// GetCurrentMainPackage 查询载体当前可展示的主套餐
// 当前套餐包含生效中和已用完状态;carrierType "iot_card" 或 "device"
func (s *PackageUsageStore) GetCurrentMainPackage(ctx context.Context, carrierType string, carrierID uint) (*model.PackageUsage, error) {
var usage model.PackageUsage
query := s.db.WithContext(ctx).
Where("status = ?", constants.PackageUsageStatusActive).
Where("status IN (?, ?)", constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted).
Where("master_usage_id IS NULL"). // 主套餐的 master_usage_id 为 NULL
Order("priority ASC, activated_at ASC")

View File

@@ -121,19 +121,13 @@ func (h *AssetPackageBatchOrderHandler) finishBatchOrderTask(ctx context.Context
return err
}
rootID := audit.TaskEventID(constants.AuditResourceAssetPackageBatchOrderTask, taskRecord.ID, "completed")
var childCount int64
if err := tx.WithContext(ctx).Model(&model.AuditEvent{}).
Where("parent_event_id = ? AND action_code = ?", rootID, constants.AuditActionOrderCreated).
Count(&childCount).Error; err != nil {
return err
}
result := batchAuditResult(int(childCount), failCount)
result := batchAuditResult(successCount, failCount)
return h.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
EventID: rootID, ActionCode: constants.AuditActionAssetPackageBatchOrderTaskCompleted,
Summary: "完成资产套餐批量订购任务", TaskID: taskRecord.ID, TaskNo: taskRecord.TaskNo,
Result: result, CorrelationID: taskRecord.TaskNo,
ParentEventID: audit.TaskEventID(constants.AuditResourceAssetPackageBatchOrderTask, taskRecord.ID, "created"),
BatchTotal: len(items), SuccessCount: int(childCount), FailCount: failCount,
BatchTotal: len(items), SuccessCount: successCount, FailCount: failCount,
IdentitySnapshot: map[string]any{
"id": taskRecord.ID, "task_no": taskRecord.TaskNo, "file_name": taskRecord.FileName,
"package_id": taskRecord.PackageID, "package_code": taskRecord.PackageCode,

View File

@@ -22,15 +22,20 @@ type AuditDailyArchivePayload struct {
type AuditDailyArchiveHandler struct {
service *auditarchive.Service
logger *zap.Logger
enabled bool
}
// NewAuditDailyArchiveHandler 创建统一审计每日冷归档任务处理器。
func NewAuditDailyArchiveHandler(service *auditarchive.Service, logger *zap.Logger) *AuditDailyArchiveHandler {
return &AuditDailyArchiveHandler{service: service, logger: logger}
func NewAuditDailyArchiveHandler(service *auditarchive.Service, logger *zap.Logger, enabled bool) *AuditDailyArchiveHandler {
return &AuditDailyArchiveHandler{service: service, logger: logger, enabled: enabled}
}
// Handle 执行前一完整自然日归档,或按任务载荷补档指定自然日。
func (h *AuditDailyArchiveHandler) Handle(ctx context.Context, task *asynq.Task) error {
if !h.enabled {
h.logger.Info("统一审计日归档已停用,跳过任务")
return nil
}
if h.service == nil {
return fmt.Errorf("统一审计归档服务未配置")
}

View File

@@ -2,101 +2,52 @@ package task
import (
"context"
stderrors "errors"
"fmt"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
)
// AuditMonthlyRetentionPayload 是人工补跑月度清理时可选的任务载荷
type AuditMonthlyRetentionPayload struct {
ArchiveMonth string `json:"archive_month"`
}
// AuditMonthlyRetentionHandler 处理归档完整性门禁与上月在线日志物理清理。
type AuditMonthlyRetentionHandler struct {
// AuditRetentionHandler 连续校验并按日物理清理已结束的在线日志
type AuditRetentionHandler struct {
service *auditarchive.Service
logger *zap.Logger
cleanupEnabled bool
enabled bool
}
// NewAuditMonthlyRetentionHandler 创建月度日志留存清理处理器。
func NewAuditMonthlyRetentionHandler(service *auditarchive.Service, logger *zap.Logger, cleanupEnabled bool) *AuditMonthlyRetentionHandler {
return &AuditMonthlyRetentionHandler{service: service, logger: logger, cleanupEnabled: cleanupEnabled}
// NewAuditRetentionHandler 创建日留存处理器。
func NewAuditRetentionHandler(service *auditarchive.Service, logger *zap.Logger, cleanupEnabled, enabled bool) *AuditRetentionHandler {
return &AuditRetentionHandler{service: service, logger: logger, cleanupEnabled: cleanupEnabled, enabled: enabled}
}
// Handle 校验整月归档后按固定顺序分批物理删除 PostgreSQL 在线日志
func (h *AuditMonthlyRetentionHandler) Handle(ctx context.Context, task *asynq.Task) error {
if !h.cleanupEnabled {
return h.handleDryRun(ctx, task)
// Handle 在关闭清理开关时只读校验,开启后从最早未完成日连续删除
func (h *AuditRetentionHandler) Handle(ctx context.Context, _ *asynq.Task) error {
if !h.enabled {
h.logger.Info("日志日留存已停用,跳过任务")
return nil
}
if h.service == nil {
return fmt.Errorf("月度日志留存清理服务未配置")
return fmt.Errorf("日志留存服务未配置")
}
startedAt := time.Now()
var result auditarchive.RetentionResult
var err error
if len(task.Payload()) == 0 {
result, err = h.service.CleanupPreviousMonth(ctx)
} else {
var payload AuditMonthlyRetentionPayload
if unmarshalErr := sonic.Unmarshal(task.Payload(), &payload); unmarshalErr != nil {
return fmt.Errorf("解析月度日志留存清理任务载荷失败: %w", unmarshalErr)
}
month, parseErr := parseArchiveMonth(payload.ArchiveMonth)
if parseErr != nil {
return parseErr
}
result, err = h.service.CleanupMonth(ctx, month)
}
fields := []zap.Field{
zap.String("archive_month", result.Month), zap.Int64("audit_event_count", result.EventCount),
zap.Int64("event_resource_count", result.ResourceCount), zap.Int64("integration_log_count", result.IntegrationCount),
zap.Duration("duration", time.Since(startedAt)), zap.Int("manifest_count", len(result.ManifestKeys)),
results, err := h.service.RetainPendingDays(ctx, h.cleanupEnabled)
for _, result := range results {
h.logger.Info("日志日留存处理完成",
zap.String("archive_date", result.ArchiveDate), zap.Bool("cleanup_enabled", h.cleanupEnabled),
zap.Int64("audit_event_count", result.EventCount), zap.Int64("event_resource_count", result.ResourceCount),
zap.Int64("integration_log_count", result.IntegrationCount), zap.Duration("duration", result.Duration))
}
if err != nil {
fields = append(fields, zap.String("severity", "critical"), zap.Error(err))
h.logger.Error("月度日志留存清理失败PostgreSQL 整月清理已阻断或等待断点续跑", fields...)
var blocked *auditarchive.RetentionBlockedError
if stderrors.As(err, &blocked) {
h.logger.Error("日志日留存日期推进已阻断", zap.String("archive_date", blocked.ArchiveDate), zap.String("source", blocked.Source), zap.String("failure_category", "archive_or_validation"), zap.Error(err))
} else {
h.logger.Error("日志日留存日期推进已阻断", zap.String("source", "retention"), zap.String("failure_category", "internal"), zap.Error(err))
}
return err
}
h.logger.Info("月度日志留存清理完成", fields...)
return nil
}
func (h *AuditMonthlyRetentionHandler) handleDryRun(ctx context.Context, task *asynq.Task) error {
if h.service == nil {
return fmt.Errorf("月度日志留存演练服务未配置")
}
var result auditarchive.RetentionResult
var err error
if len(task.Payload()) == 0 {
result, err = h.service.ValidatePreviousMonth(ctx)
} else {
var payload AuditMonthlyRetentionPayload
if unmarshalErr := sonic.Unmarshal(task.Payload(), &payload); unmarshalErr != nil {
return fmt.Errorf("解析月度日志留存演练任务载荷失败: %w", unmarshalErr)
}
month, parseErr := parseArchiveMonth(payload.ArchiveMonth)
if parseErr != nil {
return parseErr
}
result, err = h.service.ValidateMonth(ctx, month)
}
fields := []zap.Field{
zap.Bool("cleanup_enabled", false), zap.String("archive_month", result.Month),
zap.Int64("audit_event_count", result.EventCount), zap.Int64("event_resource_count", result.ResourceCount),
zap.Int64("integration_log_count", result.IntegrationCount), zap.Int64("estimated_cleanup_batches", result.EstimatedBatches),
zap.Duration("validation_duration", result.Duration), zap.Int("manifest_count", len(result.ManifestKeys)),
}
if err != nil {
fields = append(fields, zap.String("severity", "critical"), zap.Error(err))
h.logger.Error("审计日志月度只读演练失败,物理清理保持关闭", fields...)
return err
}
h.logger.Info("审计日志月度只读演练通过,物理清理保持关闭", fields...)
return nil
}

View File

@@ -18,24 +18,24 @@ type IntegrationDailyArchivePayload struct {
ArchiveDate string `json:"archive_date"`
}
// IntegrationMonthlyFinalizePayload 是人工月度复核时可选的任务载荷
type IntegrationMonthlyFinalizePayload struct {
ArchiveMonth string `json:"archive_month"`
}
// IntegrationArchiveHandler 处理 Integration Log 每日归档与月度最终复核。
// IntegrationArchiveHandler 处理 Integration Log 每日归档
type IntegrationArchiveHandler struct {
service *auditarchive.Service
logger *zap.Logger
enabled bool
}
// NewIntegrationArchiveHandler 创建 Integration Log 归档任务处理器。
func NewIntegrationArchiveHandler(service *auditarchive.Service, logger *zap.Logger) *IntegrationArchiveHandler {
return &IntegrationArchiveHandler{service: service, logger: logger}
func NewIntegrationArchiveHandler(service *auditarchive.Service, logger *zap.Logger, enabled bool) *IntegrationArchiveHandler {
return &IntegrationArchiveHandler{service: service, logger: logger, enabled: enabled}
}
// HandleDaily 执行前一完整自然日归档,或按任务载荷补档指定自然日。
func (h *IntegrationArchiveHandler) HandleDaily(ctx context.Context, task *asynq.Task) error {
if !h.enabled {
h.logger.Info("Integration Log 日归档已停用,跳过任务")
return nil
}
if h.service == nil {
return fmt.Errorf("Integration Log 归档服务未配置")
}
@@ -61,33 +61,6 @@ func (h *IntegrationArchiveHandler) HandleDaily(ctx context.Context, task *asynq
return nil
}
// HandleMonthlyFinalize 执行上一个完整自然月复核,或按任务载荷复核指定月份。
func (h *IntegrationArchiveHandler) HandleMonthlyFinalize(ctx context.Context, task *asynq.Task) error {
if h.service == nil {
return fmt.Errorf("Integration Log 归档服务未配置")
}
var err error
if len(task.Payload()) == 0 {
err = h.service.FinalizePreviousIntegrationMonth(ctx)
} else {
var payload IntegrationMonthlyFinalizePayload
if unmarshalErr := sonic.Unmarshal(task.Payload(), &payload); unmarshalErr != nil {
return fmt.Errorf("解析 Integration Log 月度复核任务载荷失败: %w", unmarshalErr)
}
month, parseErr := parseArchiveMonth(payload.ArchiveMonth)
if parseErr != nil {
return parseErr
}
err = h.service.FinalizeIntegrationMonth(ctx, month)
}
if err != nil {
h.logger.Error("Integration Log 月度最终版本复核失败,后续清理必须阻止", zap.Error(err))
return err
}
h.logger.Info("Integration Log 月度最终版本复核完成")
return nil
}
func parseArchiveDate(value string) (time.Time, error) {
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
if err != nil {
@@ -99,15 +72,3 @@ func parseArchiveDate(value string) (time.Time, error) {
}
return date, nil
}
func parseArchiveMonth(value string) (time.Time, error) {
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
if err != nil {
return time.Time{}, fmt.Errorf("加载 Integration Log 归档时区失败: %w", err)
}
month, err := time.ParseInLocation("2006-01", value, location)
if err != nil {
return time.Time{}, fmt.Errorf("解析 Integration Log 归档月份失败: %w", err)
}
return month, nil
}

View File

@@ -16,28 +16,32 @@ import (
// acquireConcurrencyScript 原子获取并发信号量的 Lua 脚本
// INCR + EXPIRE 合并为单个服务端操作,消除二者之间的崩溃窗口:
// 若 Worker 在 INCR 后、EXPIRE 前崩溃key 将永久留在 Redis 导致计数器卡死。
// KEYS[1]: 当前并发计数 key
// ARGV[1]: 最大并发数ARGV[2]: key TTL
// 返回 -1 表示超额拒绝>0 表示成功获取后的计数值
// KEYS[1]: 全部轮询计数KEYS[2]: 分类轮询计数
// ARGV[1]: 总量上限ARGV[2]: 分类上限ARGV[3]: key TTL
// 返回 -1 表示任一上限超额,>0 表示成功获取后的计数值
var acquireConcurrencyScript = redis.NewScript(`
local current = redis.call('INCR', KEYS[1])
if tonumber(current) > tonumber(ARGV[1]) then
local total = redis.call('INCR', KEYS[1])
local kind = redis.call('INCR', KEYS[2])
if tonumber(total) > tonumber(ARGV[1]) or tonumber(kind) > tonumber(ARGV[2]) then
redis.call('DECR', KEYS[1])
redis.call('DECR', KEYS[2])
return -1
end
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
return current
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[3]))
return total
`)
var releaseConcurrencyScript = redis.NewScript(`
local current = tonumber(redis.call('GET', KEYS[1]) or '0') or 0
if current <= 0 then
if redis.call('EXISTS', KEYS[1]) == 1 then
redis.call('SET', KEYS[1], 0, 'KEEPTTL')
for _, key in ipairs(KEYS) do
local current = tonumber(redis.call('GET', key) or '0') or 0
if current <= 0 then
if redis.call('EXISTS', key) == 1 then redis.call('SET', key, 0, 'KEEPTTL') end
else
redis.call('DECR', key)
end
return 0
end
return redis.call('DECR', KEYS[1])
return 0
`)
const pollingFallbackOperationTimeout = 5 * time.Second
@@ -52,13 +56,14 @@ func pollingFallbackContext() (context.Context, context.CancelFunc) {
// PollingBase 轮询共享基类
// 封装并发控制、卡缓存、重入队、配置间隔查询等公共方法,所有 Handler 共享
type PollingBase struct {
redis *redis.Client
queueMgr *polling.PollingQueueManager
configMgr *polling.PollingConfigManager
iotCardStore *postgres.IotCardStore
logger *zap.Logger
verboseLog bool
trafficLock *cardtrafficlock.Lock
redis *redis.Client
queueMgr *polling.PollingQueueManager
configMgr *polling.PollingConfigManager
iotCardStore *postgres.IotCardStore
logger *zap.Logger
verboseLog bool
totalMaxConcurrency int
trafficLock *cardtrafficlock.Lock
}
// NewPollingBase 创建轮询共享基类
@@ -69,15 +74,17 @@ func NewPollingBase(
iotCardStore *postgres.IotCardStore,
logger *zap.Logger,
verboseLog bool,
totalMaxConcurrency int,
) *PollingBase {
return &PollingBase{
redis: redisClient,
queueMgr: queueMgr,
configMgr: configMgr,
iotCardStore: iotCardStore,
logger: logger,
verboseLog: verboseLog,
trafficLock: cardtrafficlock.New(redisClient),
redis: redisClient,
queueMgr: queueMgr,
configMgr: configMgr,
iotCardStore: iotCardStore,
logger: logger,
verboseLog: verboseLog,
totalMaxConcurrency: totalMaxConcurrency,
trafficLock: cardtrafficlock.New(redisClient),
}
}
@@ -88,22 +95,33 @@ func (b *PollingBase) acquireConcurrency(ctx context.Context, taskType string) b
shortType := shortTaskType(taskType)
configKey := constants.RedisPollingConcurrencyConfigKey(shortType)
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
totalKey := constants.RedisPollingConcurrencyTotalCurrentKey()
maxConcurrency, err := b.redis.Get(ctx, configKey).Int()
if err != nil {
if err != nil || maxConcurrency < 1 || maxConcurrency > constants.PollingMaxConcurrencyLimit {
maxConcurrency = constants.PollingDefaultMaxConcurrency
}
totalMax := b.totalMaxConcurrency
if totalMax < 1 || totalMax > constants.PollingMaxConcurrencyLimit {
totalMax = constants.PollingDefaultTotalMaxConcurrency
}
result, err := acquireConcurrencyScript.Run(
ctx, b.redis, []string{currentKey},
maxConcurrency, constants.PollingConcurrencyKeyTTL,
ctx, b.redis, []string{totalKey, currentKey},
totalMax, maxConcurrency, constants.PollingConcurrencyKeyTTL,
).Int64()
if err != nil {
b.logger.Warn("获取并发计数失败,放行任务", zap.Error(err))
return true
}
return result != -1
if result == -1 {
b.logger.Info("轮询因并发令牌不足延后", zap.String("task_type", taskType),
zap.Int("max_concurrency", maxConcurrency), zap.Int("total_max_concurrency", totalMax),
zap.String("metric", "polling.deferred.concurrency_limit"))
return false
}
return true
}
// releaseConcurrency 释放并发信号量
@@ -112,7 +130,7 @@ func (b *PollingBase) releaseConcurrency(_ context.Context, taskType string) {
defer cancel()
currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType)
if err := releaseConcurrencyScript.Run(ctx, b.redis, []string{currentKey}).Err(); err != nil {
if err := releaseConcurrencyScript.Run(ctx, b.redis, []string{constants.RedisPollingConcurrencyTotalCurrentKey(), currentKey}).Err(); err != nil {
b.logger.Warn("释放并发计数失败", zap.String("task_type", taskType), zap.Error(err))
}
}
@@ -132,6 +150,29 @@ func (b *PollingBase) releaseCardTrafficSyncLock(_ context.Context, cardID uint,
}
}
// ensureMissingTask 根据数据库最新卡状态,仅在对应分片队列缺失时补入任务。
func (b *PollingBase) ensureMissingTask(ctx context.Context, cardID uint, taskType string) error {
card, err := b.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return err
}
info, ok := b.configMgr.MergedTaskIntervals(card)[taskType]
if !ok || info.Interval <= 0 {
return nil
}
added, err := b.queueMgr.EnsureQueued(ctx, cardID, taskType, time.Now())
if err != nil {
return err
}
if added {
b.logger.Info("卡状态轮询补齐缺失套餐任务",
zap.Uint("card_id", cardID), zap.String("task_type", taskType))
}
return nil
}
// requeueCardAt 使用独立短超时上下文执行真正的 ZADD 重入队。
func (b *PollingBase) requeueCardAt(cardID uint, taskType string, nextCheckAt time.Time) error {
ctx, cancel := pollingFallbackContext()

View File

@@ -60,23 +60,16 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
if h.gateway == nil {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
attemptStartedAt := time.Now()
attempt, err := startGatewayAttempt(ctx, h.integration, cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling)
if err != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "建立 Gateway 流量 Integration Log 失败", err)
}
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling)
result, err := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
if err != nil {
_ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt)
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询流量失败")
return h.failAndRequeue(ctx, cardID, startedAt, "查询流量失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
_ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt)
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "流量查询响应缺少 ICCID")
return h.failAndRequeue(ctx, cardID, startedAt, "流量查询响应缺少 ICCID", nil)
}
if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 流量 Integration Log 失败", logErr)
}
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingCarddata, "卡流量轮询任务", attempt.IntegrationID)
if h.observation == nil || h.carrier == nil {
return h.failAndRequeue(ctx, cardID, startedAt, "卡流量观测能力未配置", nil)
@@ -90,13 +83,23 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
},
})
if err != nil {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用流量观测失败")
return h.failAndRequeue(ctx, cardID, startedAt, "应用流量观测失败", err)
}
changed := decision.IncrementMB > 0 || decision.CrossMonth
if changed {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "流量业务事实发生变化")
}
if h.base.verboseLog {
h.base.logger.Info("流量轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID),
zap.Float64("gateway_flow_mb", float64(result.Used)), zap.Float64("increment_mb", decision.IncrementMB),
zap.Bool("is_cross_month", decision.CrossMonth), zap.Bool("reading_accepted", decision.ReadingAccepted))
}
metric := "polling.observation.unchanged"
if changed {
metric = "polling.observation.persisted"
}
h.base.logger.Info("流量轮询观测完成", zap.Uint("card_id", cardID), zap.Bool("changed", changed), zap.String("metric", metric))
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startedAt))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}

View File

@@ -59,23 +59,16 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task)
if h.gateway == nil {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
attemptStartedAt := time.Now()
attempt, err := startGatewayAttempt(ctx, h.integration, cardID, constants.IntegrationOperationGatewayNetwork, constants.CardObservationSceneNetworkPolling)
if err != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "建立 Gateway 网络 Integration Log 失败", err)
}
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayNetwork, constants.CardObservationSceneNetworkPolling)
result, err := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
_ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt)
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询卡状态失败")
return h.failAndRequeue(ctx, cardID, startedAt, "查询卡状态失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
_ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt)
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "卡状态查询响应缺少 ICCID")
return h.failAndRequeue(ctx, cardID, startedAt, "卡状态查询响应缺少 ICCID", nil)
}
if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 网络 Integration Log 失败", logErr)
}
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingCardStatus, "卡网络状态轮询任务", attempt.IntegrationID)
if h.observation == nil {
return h.failAndRequeue(ctx, cardID, startedAt, "卡网络观测能力未配置", nil)
@@ -89,19 +82,35 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task)
},
})
if err != nil {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用网络状态观测失败")
return h.failAndRequeue(ctx, cardID, startedAt, "应用网络状态观测失败", err)
}
changed := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling
if changed {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "网络状态发生变化")
}
if h.base.verboseLog || !decision.StatusKnown {
h.base.logger.Info("卡状态轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID),
zap.String("card_status", result.CardStatus), zap.String("extend", strings.TrimSpace(result.Extend)),
zap.Int("new_network_status", decision.AfterStatus), zap.Bool("status_known", decision.StatusKnown),
zap.Bool("changed", decision.StatusChanged), zap.Bool("stop_polling", decision.StopPolling))
}
metric := "polling.observation.unchanged"
if changed {
metric = "polling.observation.persisted"
}
h.base.logger.Info("卡状态轮询观测完成", zap.Uint("card_id", cardID), zap.Bool("changed", changed), zap.String("metric", metric))
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startedAt))
if decision.StopPolling {
h.base.logger.Info("独立卡命中风险状态,已关闭轮询", zap.Uint("card_id", cardID), zap.String("gateway_extend", decision.GatewayExtend))
return nil
}
// 卡状态任务仍正常但套餐任务丢失时,仅补入缺失项;不改写已有套餐任务的执行时间。
h.base.invalidateCardCache(ctx, cardID)
if err := h.base.ensureMissingTask(ctx, cardID, constants.TaskTypePollingPackage); err != nil {
h.base.logger.Warn("卡状态轮询补齐套餐任务失败", zap.Uint("card_id", cardID), zap.Error(err))
}
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}

View File

@@ -13,24 +13,30 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// startGatewayAttempt 在实际调用 Gateway 前建立 Integration Log 尝试事实
func startGatewayAttempt(ctx context.Context, repository *integrationlog.Repository, cardID uint, operation, scene string) (*model.IntegrationLog, error) {
if repository == nil {
return nil, errors.New(errors.CodeInternalError, "Gateway Integration Log 未配置")
}
// newGatewayAttempt 只在内存中准备稳定关联标识;成功无变化的轮询不落库
func newGatewayAttempt(cardID uint, operation, scene string) integrationlog.Attempt {
resourceID := strconv.FormatUint(uint64(cardID), 10)
integrationID := uuid.NewString()
triggerSource := constants.CardObservationSourcePolling
triggerScene := scene
return repository.Start(ctx, integrationlog.Attempt{
IntegrationID: integrationID,
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
return integrationlog.Attempt{IntegrationID: integrationID,
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ResourceType: constants.AssetTypeIotCard, ResourceID: &resourceID,
TriggerSource: &triggerSource, TriggerScene: &triggerScene, TriggerSeries: &integrationID,
CorrelationID: &integrationID,
RequestSummary: map[string]any{"card_id": cardID}, Metadata: map[string]any{"scene": scene},
InitialResult: constants.IntegrationResultPending,
})
CorrelationID: &integrationID, RequestSummary: map[string]any{"card_id": cardID},
Metadata: map[string]any{"scene": scene}}
}
// recordGatewayAttempt 在结果明确后才写入外部交互日志。
func recordGatewayAttempt(ctx context.Context, repository *integrationlog.Repository, attempt integrationlog.Attempt, result string, changed bool, summary string) error {
if repository == nil {
return errors.New(errors.CodeInternalError, "Gateway Integration Log 未配置")
}
attempt.InitialResult = result
attempt.StateChanged = changed
attempt.Metadata = map[string]any{"scene": attempt.TriggerScene, "summary": summary}
_, err := repository.Start(ctx, attempt)
return err
}
// completeGatewayAttempt 记录 Gateway 查询成功或明确失败,不把响应正文写入日志。
@@ -38,11 +44,13 @@ func completeGatewayAttempt(ctx context.Context, repository *integrationlog.Repo
if repository == nil || attempt == nil {
return errors.New(errors.CodeInternalError, "Gateway Integration Log 尝试不存在")
}
completionCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
result := constants.IntegrationResultFailed
if success {
result = constants.IntegrationResultSuccess
}
_, err := repository.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
_, err := repository.Complete(completionCtx, attempt.IntegrationID, integrationlog.Completion{
Result: result, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: false,
ResponseSummary: map[string]any{"success": success},
})

View File

@@ -51,23 +51,16 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e
if card.CardCategory == constants.CardCategoryIndustry || h.gateway == nil {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
}
attemptStartedAt := time.Now()
attempt, err := startGatewayAttempt(ctx, h.integration, cardID, constants.IntegrationOperationGatewayRealname, constants.CardObservationSceneRealnamePolling)
if err != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "建立 Gateway 实名 Integration Log 失败", err)
}
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayRealname, constants.CardObservationSceneRealnamePolling)
result, err := h.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
_ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt)
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询实名状态失败")
return h.failAndRequeue(ctx, cardID, startedAt, "查询实名状态失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
_ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt)
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "实名查询响应缺少 ICCID")
return h.failAndRequeue(ctx, cardID, startedAt, "实名查询响应缺少 ICCID", nil)
}
if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 实名 Integration Log 失败", logErr)
}
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingRealname, "实名状态轮询任务", attempt.IntegrationID)
if h.observation == nil {
return h.failAndRequeue(ctx, cardID, startedAt, "卡实名观测能力未配置", nil)
@@ -81,13 +74,23 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e
},
})
if err != nil {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用实名观测失败")
return h.failAndRequeue(ctx, cardID, startedAt, "应用实名观测失败", err)
}
changed := decision.StatusChanged
if changed {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "实名状态发生变化")
}
if h.base.verboseLog {
h.base.logger.Info("实名状态轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID),
zap.Bool("real_status", result.RealStatus), zap.Int("new_status", decision.AfterStatus),
zap.Bool("changed", decision.StatusChanged), zap.Bool("reversal_pending", decision.ReversalPending))
}
metric := "polling.observation.unchanged"
if changed {
metric = "polling.observation.persisted"
}
h.base.logger.Info("实名轮询观测完成", zap.Uint("card_id", cardID), zap.Bool("changed", changed), zap.String("metric", metric))
h.base.updateStats(ctx, constants.TaskTypePollingRealname, true, time.Since(startedAt))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
## Context
现有佣金提现有申请和钱包事实;本 Change 以资格及审批实例补齐其前置条件,不以外部线下打款作为完成条件。
## Decisions
- 分销码由店铺唯一约束保护;扫码注册与审批结果使用稳定审批实例幂等消费。
- 资格申请与附件版本分表/快照,替换、作废和代理停用以状态失效,不覆盖历史审批。
- 提现创建在事务内锁定佣金钱包并写冻结和审批提交;通过仅一次扣减/确认,驳回仅一次释放。
## 业务动作契约
### 分销码与扫码注册
- 代理店铺创建事务生成全局唯一、不可修改的随机 `distribution_code`;二维码只包含 H5 注册入口及该码。唯一冲突重试生成,不允许人工指定或编辑。
- `POST /api/c/v1/agent-distribution-registrations`:提交 `distribution_code`、短信已验证手机号、密码及既有注册必填资料。服务锁定上级店铺,校验其代理启用;失效码/停用代理统一返回“分销码不可用”。成功仅创建待审批代理/店铺和企业微信审批实例,不建立下级归属。
- 企业微信通过消费者幂等启用新代理/店铺,并在同一事务写直接上级店铺和上级当前业务员快照;驳回不启用。重复回调不重复创建层级或账号。
### 提现资格资料
- `POST /shops/:shop_id/withdrawal-qualifications`:仅本人代理店铺。请求合同、法人身份证正反面附件;企业必须提供统一社会信用代码,个人必须提供法人身份证号;可选营业执照、门头照;企业可选发票,发票抬头和统一社会信用代码必须与合同主体一致。创建新资料版本并提交企业微信,替换合同或身份证立即使旧有效资格失效。
- 超级管理员作废资格必须填写原因;代理停用自动失效全部有效资格。合同与身份证审批通过后长期有效,直至替换、作废或停用;历史版本、附件、审批实例永不覆盖。
### 提现申请与审批
- 既有 `POST /shops/:shop_id/withdrawal-requests` 增加资格有效校验。锁定该店铺佣金钱包后冻结可提现余额、申请金额、手续费/实际到账金额、收款信息和可选发票快照;余额不足、资格无效或非本人代理均不创建申请或冻结。
- 企业微信通过时以申请/审批实例条件更新一次确认到账;驳回时仅一次释放冻结。代理可在资格仍有效时修改被驳回申请的金额、收款信息和申请级发票并创建新审批实例;资料资格不因提现驳回失效。
- 新业务禁用本地 `approve/reject` 终审;提交/回调未知复用既有审批恢复,不允许通过重提制造第二笔冻结。每次资格、冻结、审批终态和重提均记录审计。
## Migration Plan
新增成对迁移及唯一/状态索引;隔离库验证码唯一和停用、资格失效、提现冻结/重提、重复审批回调及 up/down/up。

View File

@@ -0,0 +1,25 @@
## Scope
- 迭代编号:`AUG26-008`
## Why
代理下级归属和提现资料/资金缺少企业微信终审及失效边界,无法可靠追溯。
## What Changes
- 代理店铺唯一分销码和审批后下级注册。
- 合同、法人身份证为核心的提现资格审批与失效。
- 提现余额/资料快照、企微终审和驳回释放。
## Capabilities
### New Capabilities
- `agent-distribution-withdrawal`: 分销注册、资料资格与提现审批。
### Modified Capabilities
- 无。
## Impact
影响代理店铺、H5 注册、附件、佣金钱包、企业微信审批和 Schema。

View File

@@ -0,0 +1,26 @@
## Purpose
使代理下级注册和佣金提现均以企业微信审批、资格有效性和不可变业务快照为准,避免代理层级或资金事实因资料变更、重复回调而漂移。
## ADDED Requirements
### Requirement: 分销码与下级代理注册
系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码。代理扫码后以手机号短信验证、设置密码并创建待企业微信审批的下级代理和店铺;仅审批通过时启用,并将新店铺设为分销码所属店铺的直接下级,复制上级当时业务员为初始业务员。代理停用后其码立即不可注册,既有下级和佣金关系不级联变更。
#### Scenario: 停用代理码注册
- **WHEN** 客户使用已停用代理所属店铺的分销码注册
- **THEN** 系统拒绝创建下级代理或店铺
### Requirement: 提现资料资格
代理首次提现前 SHALL 提交企业微信资料资格申请;合同和法人身份证正反面必填,企业代理填写统一社会信用代码、个人代理填写法人身份证号。合同资格主体必须填写统一社会信用代码或身份证号;营业执照、门头照可选,发票仅企业可选且其抬头/统一社会信用代码必须与合同主体一致。审批通过且资料未过期才有效;合同和身份证通过后长期有效,直到代理替换资料、超级管理员作废或代理停用。
#### Scenario: 资料替换后提现
- **WHEN** 有效资格的代理替换合同或法人身份证资料
- **THEN** 原资格失效,代理必须重新审批通过后才可提现
### Requirement: 提现冻结与企业微信终审
提现申请 SHALL 冻结可提现余额、金额、手续费、收款信息和可选发票快照,并创建企业微信审批。本地不得人工通过或驳回;企业微信通过即视为已到账,驳回时释放本申请冻结余额。驳回后代理可修改金额、收款信息和本次发票重新提交,每次新建审批实例;资料资格保持有效。发票为申请级材料,若上传必须按当时有效合同主体校验并冻结。
#### Scenario: 提现审批驳回
- **WHEN** 企业微信最终驳回一笔提现申请
- **THEN** 系统仅一次释放其冻结佣金余额,保留审批快照,并允许在资格仍有效时修改后重提

View File

@@ -0,0 +1,13 @@
## 1. 分销与资格
- [ ] 1.1 追踪代理/店铺创建、H5 短信注册、佣金提现、附件、企微审批和钱包冻结链路。
- [ ] 1.2 新增分销码、资格申请/资料版本、提现审批快照的成对迁移、模型、状态与唯一约束。
- [ ] 1.3 实现分销注册、停用门禁、审批后下级归属/业务员快照及审计。
- [ ] 1.4 实现资格提交、主体/附件/发票校验、失效和企微回调。
## 2. 提现
- [ ] 2.1 实现有效资格校验、钱包锁定冻结、申请快照、企微终审、驳回释放和新实例重提。
- [ ] 2.2 注册后台/H5 路由及 OpenAPI保障附件和数据范围。
## 3. 验证
- [ ] 3.1 隔离库验证迁移、分销码停用、资格替换、冻结/释放和重复回调。
- [ ] 3.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-agent-distribution-withdrawal-qualification --strict``openspec doctor --json`;自动化测试按项目决策为 N/A。

View File

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

View File

@@ -0,0 +1,26 @@
## Decisions
- 线上充值复用支付单和实际商户路由,成功消费者以支付单/钱包流水唯一约束入账。
- 线下申请保存不可变金额和付款证据快照;审批回调在主钱包锁事务中条件入账。
- 线上未知结果和线下审批未知均保持在途,复用既有查询/恢复,不把重试当作新入账。
## 配置与充值动作契约
### 允许方式与查询
- `GET /agent-self-recharge-payment-methods`:代理、平台用户仅返回“全局允许方式 ∩ 当前启用商户池可用方式”的有序 `wechat`/`alipay` 列表;不得返回商户身份、凭证或全局允许范围。交集为空返回空列表。
- `PUT /agent-self-recharge-payment-methods`:仅超级管理员,保存 `wechat_only``alipay_only``wechat_and_alipay`;记录操作者、前后值和时间。修改不更新任何已有充值/支付单的支付方式、商户 ID 或快照。
### 自身店铺充值
- `POST /agent-recharges` 的代理在线分支强制 `shop_id` 为空且认证店铺为已启用代理自身店铺;传入下级/其他店铺返回无权。线上请求 `amount``payment_method``request_id`,其中方式必须在当前交集内;以 `request_id` 与调用者/店铺唯一复用既有支付创建结果,失败预下单不入账。
- 在线创建在事务内选择实际商户、冻结支付/商户快照并创建充值记录;渠道成功消费者锁定支付、充值和主钱包,以支付 ID/钱包流水唯一约束一次入账。失败、关闭、退款或未知状态不加余额;未知结果由既有查单/回调恢复,禁止客户端重试直接增加余额。
### 线下转账
- 线下请求必须包含 `amount`(正分)、`payer_name``transferred_at`(带时区时间)、`transfer_channel_or_bank``transaction_no`、至少一个 `payment_voucher_key` 和可选备注;创建时冻结全部字段、状态为待企业微信审批,不增加主钱包。
- 企业微信通过消费者锁定申请、审批实例和主钱包,条件更新一次增加余额和钱包流水;驳回、撤回、关闭不入账。仅未成功申请可修改上述材料并创建新审批实例重提;未知审批保持在途。平台/超级管理员查询和处理一律先应用既有店铺数据范围,审计不记录凭证正文。
## Migration Plan
新增线下充值申请、附件快照、审批关联及唯一约束的成对迁移;验证权限、回调重放、未知、驳回重提和 up/down/up。

View File

@@ -0,0 +1,25 @@
## Scope
- 迭代编号:`AUG26-017`
## Why
代理自助充值需同时覆盖线上支付和可审计的线下转账,且不能将付款成功与钱包入账混淆。
## What Changes
- 新增代理自身店铺线上微信/支付宝充值。
- 新增带凭证的线下转账申请及企业微信终审。
- 固化支付回调、审批回调和钱包入账幂等。
## Capabilities
### New Capabilities
- `agent-self-recharge-payment`: 代理自助充值支付方式。
### Modified Capabilities
- 无。
## Impact
影响代理主钱包、支付商户、企业微信审批、附件、审计和 Schema。

View File

@@ -0,0 +1,18 @@
## ADDED Requirements
### Requirement: 代理自助充值支付方式
超级管理员 SHALL 维护代理在线自充允许方式:仅微信、仅支付宝或微信和支付宝;代理实际可用线上方式为该允许范围与当前可用对应商户池方式的交集,交集为空时拒绝创建线上充值单。平台用户和代理仅可查询实际可用方式,不得查看或修改允许范围。配置变更只影响后续新单,已创建未支付充值单保留其支付方式及商户快照。系统 SHALL 允许已启用代理在其自身店铺充值入口选择实际可用的线上微信、线上支付宝或线下转账;不得为下级店铺代充。线上方式创建支付单并按实际商户路由,渠道成功回调幂等增加该代理主钱包余额;失败、关闭或未知不得增加余额,未知结果通过既有支付查询/回调恢复,不允许重复支付单入账。
线下转账申请必须填写转账金额、付款人、转账时间、银行/支付渠道、流水号和凭证附件;创建后状态为待企业微信审批,不立即入账。企业微信通过时在事务内锁定申请并仅一次增加主钱包余额;驳回、关闭或撤回不入账,代理可修改未成功申请后以新审批实例重提。充值金额以分保存,展示元时两位小数;平台/超级管理员仅可查看和处理其既有数据范围。
#### Scenario: 配置与商户池交集为空
- **WHEN** 超级管理员允许一种线上方式,但该方式没有可用商户池成员
- **THEN** 代理可用线上方式列表不含该方式,创建该方式充值单被拒绝,已创建未支付单不受影响
#### Scenario: 重复线上成功回调
- **WHEN** 同一线上充值支付成功回调被重复投递
- **THEN** 系统只增加一次代理主钱包余额并保留幂等支付事实
#### Scenario: 线下申请审批驳回
- **WHEN** 企业微信驳回线下转账充值申请
- **THEN** 系统不增加钱包余额,并允许代理修改申请后创建新审批实例重提

View File

@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: 代理自充支付方式
系统 SHALL 使代理在线自充可用支付方式等于超级管理员维护的允许范围与当前启用商户池支付方式的交集;交集为空时拒绝创建新充值单。配置变更仅影响后续订单,既有未支付订单保留其支付方式和商户快照。
#### Scenario: 规则命中
- **WHEN** 业务请求或任务满足本需求定义的前置条件
- **THEN** 系统按上述规则完成处理、保留可追溯事实,并拒绝与状态、权限或幂等约束冲突的重复操作

View File

@@ -0,0 +1,9 @@
## 1. 充值实现
- [ ] 1.1 追踪代理钱包、线上支付、商户路由、线下附件和企业微信审批链路。
- [ ] 1.2 新增线下申请/快照/审批关联的成对迁移、模型、状态和幂等约束。
- [ ] 1.3 实现自身店铺门禁、线上支付创建/成功入账恢复、线下申请/企微终审/重提和钱包事务审计。
- [ ] 1.4 注册路由、OpenAPI及代理/平台查询数据范围。
## 2. 验证
- [ ] 2.1 隔离库验证支付方式、越权代充、重复回调、未知恢复、线下驳回重提和 up/down/up。
- [ ] 2.2 运行 `gofmt -w``go build ./cmd/api ./cmd/worker``go run cmd/gendocs/main.go``openspec validate add-agent-self-recharge-payment-methods --strict``openspec doctor --json`;自动化测试按项目决策为 N/A。

View File

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

View File

@@ -0,0 +1,31 @@
## Context
套餐续购、资产钱包和复机已有独立事实;自动续费仅编排这些既有能力,不能把复机失败当作资金失败。
## Decisions
- 保存全局配置和按资产/日期的尝试记录,用唯一约束保证每日一次。
- Worker 按资产锁重读资格、当前售价和余额,以既有订单/钱包事务完成续购;人工订单通过同一锁优先。
- 成功后以可靠任务调用复机,单独记录结果;通知使用既有事件去重。
## 配置、扫描与执行契约
### 配置维护
- `GET /asset-auto-renewal-config``PUT /asset-auto-renewal-config` 仅超级管理员、平台用户。配置为单例:`enabled``scope``all_main_packages`/`specified_main_packages`)、`package_ids`(指定范围时非空且只能是可售主套餐)、`days_before_expiry`(正整数)。保存时记录操作者、前后快照和时间;代理、企业、个人客户无读取或修改入口。
- 配置变更只影响后续扫描,已产生的尝试记录不重算;关闭开关后 Worker 不创建新尝试或订单。
### 每日扫描与尝试
- Worker 在上海自然日按资产扫描,先用 `(asset_id, attempt_date)` 唯一记录占位,确保每资产每天至多一次尝试。仅选择存在当前有效主套餐、套餐未到期、最终到期时间进入 `days_before_expiry` 窗口、套餐在配置范围且资产钱包正常的资产;加油包、已到期套餐、流量阈值事件均不是触发源。
- Worker 对每项候选锁定资产、当前主套餐、资产钱包和当日尝试,再次读取配置、到期时间、当前可售续费价与人工订单。人工成功续购已产生时,标记跳过且不扣款;余额不足或套餐不可续费时记录失败原因、不建订单、不扣款。
### 续费、通知与复机
- 合格项复用既有资产钱包订单/套餐生效事务,以执行时当前续费价扣同一资产钱包可用余额,创建同套餐商品续购订单、钱包流水和套餐使用事实;任一步失败整体回滚资金、订单和套餐,并写失败尝试。成功后当日不再处理该资产。
- 余额不足、不可续费、订单失败和复机失败均使用客户/业务员/日期/原因类型幂等键投递最多一条通知;接收人只在事件创建时解析,业务员不存在时不阻断续费或尝试记录。
- 续费成功后仅当资产处于可恢复停机且运营商状态不是风险停机或已销户时投递可靠复机任务。复机成功更新既有状态;失败/未知保存执行结果并走既有恢复,不回滚钱包扣款、订单、套餐生效或续费成功事实。
## Migration Plan
新增成对迁移;隔离库验证范围、窗口、每日去重、价格、余额、手动并发、复机和 up/down/up。

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