Compare commits
11 Commits
Iteration/
...
5797fd0e94
| Author | SHA1 | Date | |
|---|---|---|---|
| 5797fd0e94 | |||
| ba677a35e1 | |||
| 22b95db2f9 | |||
| 143df60485 | |||
| 656a921ff0 | |||
| 46c8e819df | |||
| 247d7d9f6e | |||
| d256f6d176 | |||
| 7029104e5c | |||
| a0de08d789 | |||
| 1efb665619 |
@@ -3,9 +3,7 @@ name: 构建并部署到测试环境(无 SSH)
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- Iteration/7-11
|
||||
- dev
|
||||
- test
|
||||
- main
|
||||
|
||||
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=latest" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: 登录 Docker Registry
|
||||
run: |
|
||||
@@ -61,8 +51,8 @@ jobs:
|
||||
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: 部署到测试环境(仅 main 分支)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
# 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件)
|
||||
mkdir -p ${{ env.DEPLOY_DIR }}/logs
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
@@ -41,7 +41,7 @@
|
||||
- 不访问生产服务、真实支付渠道或外部审批系统进行自动验证。
|
||||
- 生产环境为 systemd 管理的手工二进制发布,和仓库 Docker/CI 测试环境不同;生产发布、迁移与回滚事实见 [`docs/deployment/production-runbook.md`](docs/deployment/production-runbook.md)。Agent 不连接生产主机或数据库,生产操作由维护者执行并提供结果。
|
||||
- 不把密钥、Token、证书或个人敏感数据写入代码、文档和日志。
|
||||
- `.lh-harness/` 仅保存本地执行证据,不是事实源且不得纳入 Git。
|
||||
- `docs/verification/context-reset/` 仅保存上下文健康检查证据,不是业务事实源;证据应随项目文档维护。
|
||||
- 自动化测试当前为 N/A(用户决策);不恢复旧测试,也不写虚假测试入口。
|
||||
|
||||
## 架构选择
|
||||
|
||||
@@ -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,24 @@ 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 != 0 || 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 := 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))
|
||||
@@ -261,7 +275,12 @@ func archiveMonth(ctx context.Context, db *gorm.DB, service *auditarchive.Servic
|
||||
Updates(map[string]any{"result": constants.IntegrationResultSuccess, "updated_at": time.Now()}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return service.FinalizeIntegrationMonth(ctx, start)
|
||||
for date := start; date.Before(end); date = date.AddDate(0, 0, 1) {
|
||||
if err := service.FinalizeIntegrationDate(ctx, date); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectBeforeCleanup(ctx context.Context, db *gorm.DB, start, end time.Time, dryRun auditarchive.RetentionResult) (simulationSummary, error) {
|
||||
@@ -311,10 +330,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
|
||||
}
|
||||
|
||||
@@ -122,6 +122,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 +153,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, appLogger, retentionLogger)
|
||||
outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
|
||||
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
|
||||
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
|
||||
@@ -444,6 +449,7 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
|
||||
runtime.db,
|
||||
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger),
|
||||
paymentInfra.NewAlipayWapAdapter(integration, appLogger),
|
||||
paymentInfra.NewFuiouScanAdapter(integration, appLogger),
|
||||
confirm,
|
||||
runtime.workerResult.Services.PaymentAudit,
|
||||
)
|
||||
@@ -793,43 +799,32 @@ 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 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.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).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 {
|
||||
@@ -838,13 +833,11 @@ func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, clean
|
||||
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger).Handle)
|
||||
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger)
|
||||
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).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.String("retention_task_type", constants.TaskTypeAuditDailyRetention),
|
||||
zap.Bool("retention_cleanup_enabled", cleanupEnabled))
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,13 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \
|
||||
|
||||
迁移失败时不启动新二进制;按失败迁移的事务状态决定处理,必要时恢复已确认可用的数据库备份。启动失败时覆盖回部署前备份的二进制,再恢复数据库备份(如迁移已改变数据库)。
|
||||
|
||||
### 零金额退款发布后核验
|
||||
|
||||
发布本次退款审批变更后,维护者应先等待既有重试处理稳定事件 `approval:26:approved`;若重试已耗尽,按受控运维流程重放同一事件,不得直接修改退款、订单或钱包数据。随后核验:
|
||||
|
||||
1. 退款单 `RF20260820170954264700` 已通过,关联订单支付状态为已退款。
|
||||
2. 该退款没有代理主钱包或资产钱包的零金额回款流水。
|
||||
|
||||
### 锁的含义与发布影响
|
||||
|
||||
`000171` 会对 `tb_agent_wallet`、`000178` 会对 `tb_iot_card` 使用 PostgreSQL `ACCESS EXCLUSIVE` 锁。该锁执行期间会阻塞该表的读写及其他 DDL,直到迁移事务提交或回滚;若有未结束业务查询/事务,它也会等待。因此必须在 API 和全部 Worker 停止后执行,并在迁移前检查没有长事务。锁持续时间取决于表数据量、索引创建和等待中的旧事务;不能从仓库估算具体秒数。
|
||||
@@ -88,3 +95,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_RETENTION_CLEANUP_ENABLED=false` 时仅验证归档、对象、manifest、数据库数量和日期连续性,不写清理断点、不删除在线数据。
|
||||
|
||||
1. 发布新 Worker 后保持开关关闭。维护者先核对 `tb_log_archive_run` 中 Audit 与 Integration 两个来源从历史最早在线日期起没有缺失账本;缺失日期必须先受控补归档,不能跳过失败日。
|
||||
2. 以关闭开关的 Worker 完成只读演练,观察 `/opt/junhong_cmp/worker/logs/audit-retention.log`:每个日期应有来源计数和耗时;若出现日期、来源、失败分类或安全错误摘要,先修复该日归档或 pending 记录后再演练。
|
||||
3. 低峰期将调度 Worker 的 `JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED=true`,重启该 Worker,并持续观察上述独立日志、`tb_log_archive_run.cleanup_started_at` / `cleaned_at` 断点及表大小。任何日期失败都会阻断该日和后续日期,不得手工跳过。
|
||||
4. 异常时立即将开关改回 `false` 并重启调度 Worker。已清理日期按已验证的对象和 manifest 执行归档恢复;尚未清理或阻断的日期仍保留在线,无需数据库恢复。恢复后先重新执行只读演练,再决定是否重新开启清理。
|
||||
|
||||
44
docs/hotfix-main-package-activation-recovery/功能总结.md
Normal file
44
docs/hotfix-main-package-activation-recovery/功能总结.md
Normal 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。
|
||||
File diff suppressed because it is too large
Load Diff
1485
docs/verification/context-reset/requirement-evidence.json
Normal file
1485
docs/verification/context-reset/requirement-evidence.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(¤t, 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: ¤t, 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,
|
||||
|
||||
@@ -49,17 +49,18 @@ type OnlineCreationService struct {
|
||||
db *gorm.DB
|
||||
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, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
|
||||
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
|
||||
}
|
||||
|
||||
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
|
||||
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
|
||||
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.audit == nil {
|
||||
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil {
|
||||
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
|
||||
}
|
||||
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
|
||||
@@ -139,7 +140,7 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
|
||||
}
|
||||
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
|
||||
}
|
||||
if s.wechat.Available(&config) {
|
||||
if s.wechat.Available(&config) || s.fuiou.Available(&config) {
|
||||
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
|
||||
}
|
||||
if s.alipay.Available(&config) {
|
||||
@@ -180,7 +181,7 @@ func (s *OnlineCreationService) loadCreationFacts(
|
||||
}
|
||||
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
|
||||
}
|
||||
adapter := s.adapter(command.PaymentMethod)
|
||||
adapter := s.adapter(command.PaymentMethod, &config)
|
||||
if adapter == nil || !adapter.Available(&config) {
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
|
||||
}
|
||||
@@ -209,7 +210,7 @@ func (s *OnlineCreationService) createLocalFacts(
|
||||
expireMinutes = model.DefaultAliPayExpireMinutes
|
||||
}
|
||||
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
|
||||
channel, requestID := command.PaymentMethod, command.RequestID
|
||||
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,
|
||||
@@ -247,6 +248,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 +259,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 +328,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 {
|
||||
|
||||
@@ -16,19 +16,20 @@ type RecoverOnlinePaymentService struct {
|
||||
db *gorm.DB
|
||||
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, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
|
||||
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
|
||||
}
|
||||
|
||||
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
|
||||
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
|
||||
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil || s.audit == nil {
|
||||
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
|
||||
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
@@ -70,7 +71,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, "代理充值创建时支付配置不可用")
|
||||
}
|
||||
@@ -200,8 +201,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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -105,8 +88,6 @@ func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.T
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"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 +18,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,193 +38,143 @@ type RetentionResult struct {
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// RetentionBlockedError 描述阻断日期推进的安全上下文。
|
||||
type RetentionBlockedError struct {
|
||||
ArchiveDate string
|
||||
Source string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *RetentionBlockedError) Error() string {
|
||||
return e.ArchiveDate + " " + e.Source + ": " + e.Err.Error()
|
||||
}
|
||||
func (e *RetentionBlockedError) Unwrap() error { return e.Err }
|
||||
|
||||
type retentionRuns struct {
|
||||
audit []*model.LogArchiveRun
|
||||
integration []*model.LogArchiveRun
|
||||
audit *model.LogArchiveRun
|
||||
integration *model.LogArchiveRun
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// 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 从最早仍在线的日期连续处理至昨天。cleanup 为 false 时只读校验。
|
||||
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
|
||||
}
|
||||
results := make([]RetentionResult, 0)
|
||||
for date := start; date.Before(end); date = date.AddDate(0, 0, 1) {
|
||||
result, err := s.retainDate(ctx, date, cleanup)
|
||||
if err != nil {
|
||||
source := "retention"
|
||||
if strings.HasPrefix(err.Error(), "Audit") {
|
||||
source = constants.AuditArchiveSource
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), "Integration") {
|
||||
source = constants.IntegrationArchiveSource
|
||||
}
|
||||
return results, &RetentionBlockedError{ArchiveDate: date.Format(time.DateOnly), Source: source, Err: err}
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
return results, 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 string }{{"tb_audit_event", "created_at"}, {"tb_integration_log", "created_at"}} {
|
||||
var value *time.Time
|
||||
if err := s.db.WithContext(ctx).Table(item.table).Select("MIN(" + item.column + ")").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)
|
||||
var existing []model.LogArchiveRun
|
||||
if err := s.db.WithContext(ctx).Where("source IN ? AND archive_date = ? AND instance_id = ?", []string{constants.AuditArchiveSource, constants.IntegrationArchiveSource}, date.Format(time.DateOnly), s.instanceID).Find(&existing).Error; err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("读取日归档账本失败: %w", err)
|
||||
}
|
||||
statuses := map[string]string{}
|
||||
for _, run := range existing {
|
||||
statuses[run.Source] = run.Status
|
||||
}
|
||||
if statuses[constants.AuditArchiveSource] != constants.ArchiveStatusSuccess {
|
||||
if err := s.ArchiveDate(ctx, date); err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("Audit 归档失败: %w", err)
|
||||
}
|
||||
}
|
||||
if statuses[constants.IntegrationArchiveSource] != constants.ArchiveStatusSuccess {
|
||||
if err := s.ArchiveIntegrationDate(ctx, date); err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("Integration Log 归档失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := s.FinalizeIntegrationDate(ctx, date); err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("Integration Log 最终归档失败: %w", err)
|
||||
}
|
||||
runs, err := s.loadRetentionRuns(ctx, date)
|
||||
if err != nil {
|
||||
return result, err
|
||||
return RetentionResult{}, err
|
||||
}
|
||||
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
|
||||
return result, err
|
||||
if err := s.validateAuditRetentionDay(ctx, date, runs.audit); err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("Audit 完整性校验失败: %w", err)
|
||||
}
|
||||
summarizeRetentionRuns(runs, &result)
|
||||
if err := s.validateIntegrationRetentionDay(ctx, date, runs.integration); err != nil {
|
||||
return RetentionResult{}, fmt.Errorf("Integration Log 完整性校验失败: %w", err)
|
||||
}
|
||||
result := RetentionResult{ArchiveDate: date.Format(time.DateOnly), EventCount: runs.audit.EventCount, ResourceCount: runs.audit.ResourceCount, IntegrationCount: runs.integration.RecordCount, ManifestKeys: []string{runs.audit.ManifestKey, runs.integration.ManifestKey}}
|
||||
result.EstimatedBatches = estimatedRetentionBatches(result)
|
||||
if cleanup {
|
||||
if err := s.cleanupAuditDate(ctx, date, runs.audit); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := s.cleanupIntegrationDate(ctx, date, runs.integration); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return result, 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)
|
||||
}
|
||||
return result, fmt.Errorf("记录留存清理成功审计失败: %w", auditErr)
|
||||
}
|
||||
return result, cleanupErr
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return 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
|
||||
}
|
||||
}
|
||||
runs, err := s.loadRetentionRuns(ctx, start, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
|
||||
return err
|
||||
}
|
||||
summarizeRetentionRuns(runs, result)
|
||||
if err := s.cleanupAuditMonth(ctx, start, end, runs.audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.cleanupIntegrationMonth(ctx, start, end, runs.integration)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadRetentionRuns(ctx context.Context, start, end time.Time) (retentionRuns, error) {
|
||||
func (s *Service) loadRetentionRuns(ctx context.Context, date 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 err := s.db.WithContext(ctx).Where("source IN ? AND archive_date = ? AND instance_id = ?", []string{constants.AuditArchiveSource, constants.IntegrationArchiveSource}, date.Format(time.DateOnly), s.instanceID).Find(&rows).Error; err != nil {
|
||||
return retentionRuns{}, fmt.Errorf("读取日归档账本失败: %w", err)
|
||||
}
|
||||
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)}
|
||||
runs := retentionRuns{}
|
||||
for index := range rows {
|
||||
run := &rows[index]
|
||||
switch run.Source {
|
||||
switch rows[index].Source {
|
||||
case constants.AuditArchiveSource:
|
||||
runs.audit = append(runs.audit, run)
|
||||
runs.audit = &rows[index]
|
||||
case constants.IntegrationArchiveSource:
|
||||
runs.integration = append(runs.integration, run)
|
||||
runs.integration = &rows[index]
|
||||
}
|
||||
}
|
||||
if len(runs.audit) != days || len(runs.integration) != days {
|
||||
return retentionRuns{}, fmt.Errorf("月度 Audit 或 Integration 归档账本不完整")
|
||||
if runs.audit == nil || runs.integration == nil {
|
||||
return retentionRuns{}, fmt.Errorf("Audit 或 Integration Log 归档账本缺失")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Service) validateAuditRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
|
||||
if err := validateRunBase(run, date, constants.AuditArchiveSchemaVersion, false); err != nil {
|
||||
return err
|
||||
@@ -258,25 +200,21 @@ 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)
|
||||
if run.CleanedAt != nil && count != 0 {
|
||||
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条记录", count)
|
||||
}
|
||||
if run.CleanupStartedAt != nil && count > run.RecordCount {
|
||||
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
|
||||
}
|
||||
if run.CleanupStartedAt == nil {
|
||||
file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if run.CleanupStartedAt != nil {
|
||||
if count > run.RecordCount {
|
||||
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
|
||||
defer os.Remove(file.path)
|
||||
if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 {
|
||||
return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致")
|
||||
}
|
||||
return 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 +223,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 +234,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 +252,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 +298,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 +328,35 @@ 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 {
|
||||
if run.CleanedAt != nil {
|
||||
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 < ?", date, end).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 +365,11 @@ 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) 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 +379,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 +391,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
|
||||
}
|
||||
|
||||
@@ -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(¤t, 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(¤tOrder, 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: ¤t, Order: ¤tOrder, 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, "退款审批能力未配置")
|
||||
|
||||
@@ -288,6 +288,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
deps.DB,
|
||||
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
|
||||
paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
|
||||
paymentInfra.NewFuiouScanAdapter(paymentIntegration, deps.Logger),
|
||||
auditWriter,
|
||||
)
|
||||
agentRechargePaymentConfirm := agentrechargeApp.NewConfirmOnlinePaymentService(
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -51,8 +52,11 @@ 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -283,8 +283,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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
172
internal/infrastructure/payment/fuiou_scan.go
Normal file
172
internal/infrastructure/payment/fuiou_scan.go
Normal 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)
|
||||
}
|
||||
@@ -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,7 @@ 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,28 +309,9 @@ 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"))
|
||||
}
|
||||
activationErr := h.activateNextPackage(ctx, h.db.WithContext(ctx), carrierType, carrierID)
|
||||
h.triggerStopAfterExpiry(ctx, carrierType, carrierID)
|
||||
return activationErr
|
||||
}
|
||||
@@ -401,6 +367,30 @@ func (h *PackageActivationHandler) getCarrierInfo(pkg *model.PackageUsage) (stri
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// activateNextPackage 提交下一个待生效主套餐的异步激活任务。
|
||||
func (h *PackageActivationHandler) activateNextPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) 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 nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return h.enqueueActivationTask(ctx, nextPkg.ID, carrierType, carrierID, "queue")
|
||||
}
|
||||
|
||||
// triggerStopAfterExpiry 套餐过期后异步触发停机检查
|
||||
// 仅在确认无后续生效套餐时有效;CheckAndStopCard 内部有幂等保护,重复调用安全
|
||||
func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, carrierType string, carrierID uint) {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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{"代理预充值"},
|
||||
|
||||
@@ -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{"退款管理"},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,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 {
|
||||
@@ -388,7 +409,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 +1201,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, "审批退款金额不能大于申请退款金额")
|
||||
|
||||
@@ -2,101 +2,47 @@ 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
|
||||
}
|
||||
|
||||
// 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 bool) *AuditRetentionHandler {
|
||||
return &AuditRetentionHandler{service: service, logger: logger, cleanupEnabled: cleanupEnabled}
|
||||
}
|
||||
|
||||
// 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.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
|
||||
}
|
||||
|
||||
@@ -18,12 +18,7 @@ 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
|
||||
@@ -61,33 +56,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 +67,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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-18
|
||||
@@ -0,0 +1,47 @@
|
||||
## Context
|
||||
|
||||
代理在线充值现有 `OnlinePaymentPort` 抽象只有微信直连 H5/MWEB 与支付宝 WAP 两个 Adapter;`payment-methods` 只调用 `wechat.Available` 与 `alipay.Available`。当前生效配置为富友时,微信 Adapter 因 `provider_type=fuiou` 判定不可用,导致只返回 `alipay`。富友本质是微信支付上游通道,`pkg/fuiou` 已具备 XML/GBK/双重 URL 编码/RSA 签名验签与回调解析能力,但缺少主扫下单与订单查询。参见 proposal.md - Why。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 对外支付方式枚举保持 `wechat` / `alipay`,不暴露 `fuiou`。
|
||||
- 富友配置下 `wechat` 走富友主扫统一下单,复用现有 `qr_content` 契约。
|
||||
- 富友通道下主动查单通过 `/commonQuery` 收敛状态,不重建支付链接。
|
||||
- 回调与确认校验接受 `channel=fuiou` 对应业务方式 `wechat`。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不新增对外 `fuiou` 支付方式,不改前端枚举。
|
||||
- 不接入富友退款、撤销、条码支付(商户扫用户)等其它交易类型。
|
||||
- 不改支付宝通道(仍走直接支付宝 WAP)。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 新增富友扫码 Adapter 而非扩展微信 Adapter
|
||||
|
||||
新增 `FuiouScanAdapter` 实现 `OnlinePaymentPort`,`Available` 判定 `provider_type==fuiou` 且富友字段完整;`CreatePaymentURL` 调主扫下单返回 `qr_code`;`Query` 调订单查询映射 `trans_stat`。备选方案是在 `WechatWebAdapter` 内部分支,但会混淆微信直连与富友的日志提供方、错误语义与恢复策略,故放弃。
|
||||
|
||||
### Adapter 选择改为配置感知
|
||||
|
||||
`OnlineCreationService` 与 `RecoverOnlinePaymentService` 的 `adapter()` 增加 `config` 参数:`wechat` 方法 + `provider_type==fuiou` 返回富友 Adapter,否则返回微信 Adapter。恢复阶段富友与微信一致只查单不重建链接。
|
||||
|
||||
### 业务方式与渠道分离存储
|
||||
|
||||
`Payment.PaymentMethod` 与充值记录 `PaymentMethod` 保持 `wechat`(业务语义),充值记录 `PaymentChannel` 存 `fuiou`(实际渠道);`paymentMerchantIdentity` 在富友下返回 `FyMchntCd`。回调 `PaymentMethod=fuiou` 在确认入口归一化为业务方式 `wechat`,领域校验允许 `channel=fuiou` 映射到 `method=wechat`。
|
||||
|
||||
### 复用现有富友回调
|
||||
|
||||
异步通知复用 `FuiouPayCallback` 与 `VerifyNotify`,`NotifyRequest` 字段与主扫通知报文一致,不做改动。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [富友主扫 `mchnt_order_no` 必须全局唯一,重复会被拒绝] → 复用本地 `payment_no` 作为商户订单号,且恢复阶段只查单不重建链接。
|
||||
- [富友查询 `trans_stat` 为 `9999`/空/`1010` 时状态未知] → 映射为 unknown,保持待恢复继续查,不确认收款也不关闭。
|
||||
- [富友 `reserved_*` 字段不参与签名且渠道会新增] → 复用 `pkg/fuiou` 现有 `structToMap` 排除 reserved 前缀的签名规则。
|
||||
- [回调无支付时间或金额不一致] → 现有确认用例已校验金额、配置身份与支付时间,富友金额用 `order_amt`(分)、时间用 `reserved_txn_fin_ts`。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
无数据库迁移、无新外部依赖。代码上线后,将生效支付配置切为富友即可使代理在线充值展示微信扫码;回滚为恢复生效配置为微信直连或回退代码,不改变既有数据语义。
|
||||
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
当前生效支付配置为富友(`provider_type=fuiou`)时,代理在线充值可用支付方式接口只返回 `alipay`,不返回 `wechat`。原因是现有微信 Adapter 只支持微信直连(`wechat`/`wechat_v2`),而富友虽然本质是微信支付上游通道,却未接入代理在线充值链路。本变更让富友主扫下单成为内部 `wechat` 通道,使代理在线充值在富友配置下也能展示并完成微信扫码支付。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 代理在线充值 `payment-methods` 在富友配置完整时把 `wechat` 列为可用支付方式(对外枚举仍只有 `wechat` 与 `alipay`,不新增 `fuiou`)。
|
||||
- `POST /api/admin/agent-recharges` 使用 `wechat` 创建时,若生效配置为富友,则调用富友主扫统一下单,返回 `qr_code` 作为支付链接。
|
||||
- 新增富友主扫下单(`/preCreate`)与主动查单(`/commonQuery`)客户端能力;支付结果继续复用现有富友异步通知回调。
|
||||
- 代理在线充值支付恢复(主动查单)在富友通道下通过 `commonQuery` 收敛状态,不重建链接。
|
||||
- 支付确认校验允许富友渠道(`channel=fuiou`)对应业务支付方式 `wechat`。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(无)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `agent-funds-commission`: 代理在线充值可用支付方式与创建行为在富友配置下按微信通道处理。
|
||||
- `external-integration`: 富友主扫统一下单与订单查询的调用、状态映射与失败边界。
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/application/agentrecharge`(`OnlineCreationService`、`RecoverOnlinePaymentService`、确认用例渠道校验)
|
||||
- `internal/domain/agentrecharge`(支付确认渠道一致性校验)
|
||||
- `internal/infrastructure/payment`(新增富友扫码 Adapter)
|
||||
- `pkg/fuiou`(新增主扫下单与订单查询请求/响应)
|
||||
- `internal/handler/callback`(富友回调支付方式归一化为业务方式 `wechat`)
|
||||
- 无数据库迁移、无新外部依赖;对外支付方式枚举不变。
|
||||
@@ -0,0 +1,29 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 代理在线充值可用支付方式按支付配置判定
|
||||
|
||||
系统 SHALL 按当前生效支付配置判定代理在线充值可用支付方式:微信直连(`wechat` 或 `wechat_v2`)配置完整,或富友(`fuiou`)配置完整时,返回 `wechat`;支付宝字段完整时返回 `alipay`。对外支付方式枚举 MUST 固定为 `wechat` 与 `alipay`,MUST NOT 返回 `fuiou`。代理账号以 `wechat` 创建在线充值单时,若生效配置为富友,系统 MUST 使用富友主扫统一下单并将返回的二维码链接作为支付链接。
|
||||
|
||||
#### Scenario: 富友配置完整时微信可用
|
||||
|
||||
- **GIVEN** 当前生效支付配置 `provider_type=fuiou` 且富友机构号、商户号、终端号、私钥、公钥、API 地址、通知地址均非空
|
||||
- **WHEN** 代理账号查询可用支付方式
|
||||
- **THEN** 系统返回包含 `wechat` 的方式列表且不包含 `fuiou`
|
||||
|
||||
#### Scenario: 微信直连配置完整时微信可用
|
||||
|
||||
- **GIVEN** 当前生效支付配置为微信直连且对应字段完整
|
||||
- **WHEN** 代理账号查询可用支付方式
|
||||
- **THEN** 系统返回包含 `wechat` 的方式列表
|
||||
|
||||
#### Scenario: 支付宝字段完整时支付宝可用
|
||||
|
||||
- **GIVEN** 当前生效支付配置的支付宝应用 ID、应用私钥、支付宝公钥、通知地址均非空
|
||||
- **WHEN** 代理账号查询可用支付方式
|
||||
- **THEN** 系统返回包含 `alipay` 的方式列表
|
||||
|
||||
#### Scenario: 富友配置下微信创建走主扫下单
|
||||
|
||||
- **GIVEN** 当前生效支付配置为富友且字段完整
|
||||
- **WHEN** 代理账号以 `wechat` 创建在线充值单
|
||||
- **THEN** 系统调用富友主扫统一下单并返回二维码链接作为支付链接,本地充值单支付方式为 `wechat`、支付渠道为 `fuiou`
|
||||
@@ -0,0 +1,36 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 富友主扫统一下单与订单查询
|
||||
|
||||
系统 SHALL 通过富友主扫统一下单创建微信二维码支付并返回 `qr_code` 二维码链接;系统 SHALL 通过富友订单查询按 `trans_stat` 将状态映射为已支付、已关闭、待支付或未知,未知状态 MUST 保持待恢复。下单与查询失败 MUST 映射为项目稳定错误,已接入外部交互日志的调用保留脱敏结果。
|
||||
|
||||
#### Scenario: 主扫下单成功返回二维码链接
|
||||
|
||||
- **GIVEN** 富友支付配置完整
|
||||
- **WHEN** 系统发起主扫统一下单且渠道返回成功
|
||||
- **THEN** 系统返回 `qr_code` 作为支付链接,业务以该链接生成二维码
|
||||
|
||||
#### Scenario: 查询映射支付成功
|
||||
|
||||
- **WHEN** 富友订单查询返回 `trans_stat=SUCCESS`
|
||||
- **THEN** 系统将状态映射为已支付并取得渠道交易号、金额与支付时间
|
||||
|
||||
#### Scenario: 查询映射已关闭
|
||||
|
||||
- **WHEN** 富友订单查询返回 `trans_stat` 为 `PAYERROR`、`CLOSED` 或 `REVOKED`
|
||||
- **THEN** 系统将状态映射为已关闭
|
||||
|
||||
#### Scenario: 查询映射待支付
|
||||
|
||||
- **WHEN** 富友订单查询返回 `trans_stat` 为 `USERPAYING` 或 `NOTPAY`
|
||||
- **THEN** 系统将状态映射为待支付
|
||||
|
||||
#### Scenario: 查询状态未知保持待恢复
|
||||
|
||||
- **WHEN** 富友订单查询返回系统错误、找不到交易或无法识别的 `trans_stat`
|
||||
- **THEN** 系统将状态映射为未知并保持本地支付单待恢复,不得据此确认收款或关闭订单
|
||||
|
||||
#### Scenario: 下单失败返回稳定错误
|
||||
|
||||
- **WHEN** 富友主扫统一下单返回失败或请求结果未知
|
||||
- **THEN** 系统返回项目稳定错误且已接入外部交互日志的调用记录脱敏结果
|
||||
@@ -0,0 +1,33 @@
|
||||
## 1. 富友主扫下单与查询客户端
|
||||
|
||||
- [x] 1.1 在 `pkg/fuiou` 新增主扫统一下单请求/响应结构(`/preCreate`,含 `order_type`、`notify_url`、`reserved_expire_minute`,响应含 `qr_code`)
|
||||
- [x] 1.2 在 `pkg/fuiou` 新增主扫下单方法,复用 `Client.Sign` 与 `DoRequest`
|
||||
- [x] 1.3 在 `pkg/fuiou` 新增订单查询请求/响应结构(`/commonQuery`,响应含 `trans_stat`、`order_amt`、`transaction_id`、`reserved_txn_fin_ts`)
|
||||
- [x] 1.4 在 `pkg/fuiou` 新增订单查询方法,复用 `Client.Sign` 与 `DoRequest`
|
||||
- [x] 1.5 补充 `trans_stat` 到统一支付状态的映射(SUCCESS→已支付;PAYERROR/CLOSED/REVOKED→已关闭;USERPAYING/NOTPAY→待支付;其余→未知)
|
||||
|
||||
## 2. 富友扫码 Adapter
|
||||
|
||||
- [x] 2.1 新增 `internal/infrastructure/payment/fuiou_scan.go` 的 `FuiouScanAdapter`,实现 `OnlinePaymentPort`
|
||||
- [x] 2.2 `Available` 判定 `provider_type==fuiou` 且富友机构号/商户号/终端号/私钥/公钥/API 地址/通知地址完整
|
||||
- [x] 2.3 `CreatePaymentURL` 调主扫下单并以 `qr_code` 作为 `QRContent`,接入外部交互日志
|
||||
- [x] 2.4 `Query` 调订单查询并按映射返回统一查询结果
|
||||
|
||||
## 3. 代理在线充值 Adapter 选择与渠道事实
|
||||
|
||||
- [x] 3.1 `OnlineCreationService` 注入富友 Adapter,`adapter()` 增加配置参数并按 `provider_type==fuiou` 分流
|
||||
- [x] 3.2 `RecoverOnlinePaymentService` 同样注入并按配置分流,恢复阶段富友只查单不重建链接
|
||||
- [x] 3.3 `paymentMerchantIdentity` 在富友下返回 `FyMchntCd`
|
||||
- [x] 3.4 创建本地事实时 `PaymentChannel` 存 `fuiou`,`PaymentMethod` 仍存 `wechat`
|
||||
|
||||
## 4. 回调与确认校验
|
||||
|
||||
- [x] 4.1 `confirmAgentRechargePayment` 将回调 `PaymentMethod==fuiou` 归一化为业务方式 `wechat` 后再进入确认用例
|
||||
- [x] 4.2 `domain.ValidatePaymentConfirmation` 允许 `channel=fuiou` 对应 `method=wechat`,保留其余一致性校验
|
||||
|
||||
## 5. 装配与验证
|
||||
|
||||
- [x] 5.1 `bootstrap/services.go` 注入富友扫码 Adapter 到在线创建与恢复服务
|
||||
- [x] 5.2 `gofmt -w` 变更文件,`go build ./cmd/api ./cmd/worker` 通过
|
||||
- [x] 5.3 `go run cmd/gendocs/main.go` 重新生成文档(如路由/DTO 有变化)
|
||||
- [x] 5.4 `openspec validate --all` 通过
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-18
|
||||
@@ -0,0 +1,45 @@
|
||||
## Context
|
||||
|
||||
历史记录在 `tb_refund_request` 或 `tb_agent_recharge_record` 中保持待审批但 `approval_instance_id` 为空。现有新建用例已经在单个事务中创建通用审批实例、企业微信上下文、审批提交 Outbox,并回填该关联;两个业务表的 `approval_instance_id` 均有唯一索引,通用审批实例还以业务类型和业务 ID 唯一。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 以最小增量复用现有审批创建和可靠提交链路补发历史记录。
|
||||
- 以原创建账号构造企业微信发起人及审批快照。
|
||||
- 使并发请求和重复请求均不会形成第二张审批单。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不批量扫描或自动补发历史记录。
|
||||
- 不改变既有审批终态、企业微信提交重试或人工审批接口。
|
||||
- 不新增迁移、重置既有审批关联,或为提交失败创建第二张审批单。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 在各业务审批创建用例中增加历史记录发起入口
|
||||
|
||||
退款和线下代理充值分别新增面向既有记录的 Application 用例入口,复用各自已有的快照构造、提交人校验、通用审批 `Prepare`/`CreateInTx`、审计及 DTO 组装逻辑。Handler 只解析路径 ID 并调用服务;Service 负责加载完整业务事实和调用 Application。
|
||||
|
||||
选择按业务保留两个小入口,而不引入跨退款/充值的通用“历史审批补发器”:二者的资格条件、快照和关联事实不同,现有两个创建用例已是最短复用边界。
|
||||
|
||||
### 以事务内条件更新和既有唯一约束保证一次性
|
||||
|
||||
发起前可在事务外执行审批渠道预检;事务内必须重新读取或条件更新业务记录,要求 `status=待审批 AND approval_instance_id IS NULL`,再创建通用审批及渠道上下文/Outbox,并回填 `approval_instance_id`。任一环节失败回滚,不消耗发起资格;成功提交后,由业务表关联唯一索引和通用审批业务唯一索引共同拒绝并发的第二次创建。
|
||||
|
||||
不增加“已尝试”字段:用户确认以成功创建审批实例作为一次性边界,已有唯一关联就是持久化且可恢复的事实源。
|
||||
|
||||
### 发起人和授权语义
|
||||
|
||||
企业微信发起人固定为业务记录 `Creator`,不使用点击接口的账号;该账号不可用时失败关闭。接口沿用各自当前路由组的账号类型授权,不扩大既有退款或代理充值管理入口的访问范围。返回值沿用现有详情 DTO 的审批摘要字段,避免新增响应类型。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [原创建账号已禁用或未绑定企业微信] → 不创建任何审批事实并返回错误;维护者修复账号/绑定后可再次操作。
|
||||
- [两个请求同时发起] → 事务条件和数据库唯一约束确保仅一个提交成功,调用方对另一个请求按冲突处理。
|
||||
- [提交 Outbox 后企微调用结果未知] → 沿用已有结果未知恢复流程,禁止通过本接口重建审批。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 发布 API 与 Worker 均包含该版本的应用代码,确保 Outbox 消费者已注册。
|
||||
2. 维护者在生产环境按发布运行说明,通过列表筛选待审批历史记录后逐单调用新接口,并核对返回的审批摘要与审计/Outbox 事实。
|
||||
3. 如需回滚,仅停止暴露新路由并回滚应用二进制;已成功创建的审批实例继续由既有 Worker 流程处理,不删除审批关联或重新发起。
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
七月迭代上线前已创建且仍待审批的退款申请、员工线下代充值申请未关联通用审批实例,无法进入企业微信审批流。需要由管理员按单主动补发,同时避免同一业务重复创建审批单。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 为待审批且尚未关联审批实例的历史退款申请新增主动发起企业微信审批接口。
|
||||
- 为待审批、线下支付且尚未关联审批实例的历史代理充值申请新增主动发起企业微信审批接口。
|
||||
- 主动发起时复用原业务创建人作为企业微信审批发起人;原创建人不可用或审批场景不可用时不创建审批实例。
|
||||
- 在同一事务创建通用审批实例、企业微信上下文、提交 Outbox 并回填业务记录的 `approval_instance_id`,以该唯一关联保证成功创建后不可再次发起。
|
||||
- 仅在业务保持待审批状态时允许主动发起;已关联审批实例、非线下充值或非待审批记录均拒绝。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- 无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `order-refund-exchange`: 退款申请可对历史待审批且未关联审批实例的记录主动创建一次企业微信审批。
|
||||
- `agent-funds-commission`: 历史待审批线下代理充值申请可主动创建一次企业微信审批。
|
||||
|
||||
## Impact
|
||||
|
||||
- 路由、退款与代理充值 Handler/Service,以及审批创建 Application 用例。
|
||||
- 新增两个后台 API 并同步 OpenAPI 文档生成入口。
|
||||
- 复用现有通用审批、企业微信审批上下文、Outbox、审计和既有 `approval_instance_id` 唯一索引;不新增外部依赖或数据库表结构。
|
||||
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 历史待审批线下代理充值可主动接入企业微信审批
|
||||
系统 SHALL 提供 `POST /api/admin/agent-recharges/{id}/trigger-approval`,使具有既有代理充值管理访问权限的后台账号可为历史线下代理充值申请主动创建企业微信审批。系统 MUST 仅在线下充值记录处于待审批状态且 `approval_instance_id` 为空时创建审批;审批发起人 MUST 使用该充值记录的原创建账号。创建成功后,系统 MUST 原子保存唯一审批实例、审批提交请求及充值记录的审批实例关联,并返回更新后的充值申请审批摘要。
|
||||
|
||||
#### Scenario: 主动发起历史线下代理充值审批成功
|
||||
- **GIVEN** 线下代理充值申请处于待审批状态、未关联审批实例,且其原创建账号和企业微信线下充值审批场景均可用
|
||||
- **WHEN** 有既有代理充值管理访问权限的后台账号请求 `POST /api/admin/agent-recharges/{id}/trigger-approval`
|
||||
- **THEN** 系统创建以原创建账号为发起人的唯一企业微信审批并返回审批摘要,后续由既有可靠提交流程提交至企业微信
|
||||
|
||||
#### Scenario: 在线、非待审批或已发起记录被拒绝
|
||||
- **WHEN** 请求主动发起的充值记录不是线下充值、不是待审批状态或已关联审批实例
|
||||
- **THEN** 系统返回状态冲突且不创建新的审批实例或提交请求
|
||||
|
||||
#### Scenario: 并发主动发起同一充值审批
|
||||
- **WHEN** 两个请求同时为同一符合条件的线下代理充值申请主动发起审批
|
||||
- **THEN** 系统至多创建一个审批实例和一个审批提交请求,未成功创建关联的请求返回冲突
|
||||
|
||||
#### Scenario: 原创建人或审批渠道不可用
|
||||
- **WHEN** 充值申请原创建账号不可用,或企业微信线下充值审批场景不可用
|
||||
- **THEN** 系统返回相应错误,充值申请保持未关联审批实例,修复条件后可再次发起
|
||||
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 历史待审批退款可主动接入企业微信审批
|
||||
系统 SHALL 提供 `POST /api/admin/refunds/{id}/trigger-approval`,使具有既有退款管理访问权限的后台账号可为历史退款申请主动创建企业微信审批。系统 MUST 仅在退款申请状态为待审批且 `approval_instance_id` 为空时创建审批;审批发起人 MUST 使用该退款申请的原创建账号。创建成功后,系统 MUST 原子保存唯一审批实例、审批提交请求及退款申请的审批实例关联,并返回更新后的退款申请审批摘要。
|
||||
|
||||
#### Scenario: 主动发起历史退款审批成功
|
||||
- **GIVEN** 退款申请处于待审批状态、未关联审批实例,且其原创建账号和企业微信退款审批场景均可用
|
||||
- **WHEN** 有既有退款管理访问权限的后台账号请求 `POST /api/admin/refunds/{id}/trigger-approval`
|
||||
- **THEN** 系统创建以原创建账号为发起人的唯一企业微信审批并返回审批摘要,后续由既有可靠提交流程提交至企业微信
|
||||
|
||||
#### Scenario: 非待审批或已发起记录被拒绝
|
||||
- **WHEN** 请求主动发起的退款申请不是待审批状态或已关联审批实例
|
||||
- **THEN** 系统返回状态冲突且不创建新的审批实例或提交请求
|
||||
|
||||
#### Scenario: 并发主动发起同一退款审批
|
||||
- **WHEN** 两个请求同时为同一符合条件的退款申请主动发起审批
|
||||
- **THEN** 系统至多创建一个审批实例和一个审批提交请求,未成功创建关联的请求返回冲突
|
||||
|
||||
#### Scenario: 原创建人或审批渠道不可用
|
||||
- **WHEN** 退款申请原创建账号不可用,或企业微信退款审批场景不可用
|
||||
- **THEN** 系统返回相应错误,退款申请保持未关联审批实例,修复条件后可再次发起
|
||||
@@ -0,0 +1,16 @@
|
||||
## 1. 审批补发用例
|
||||
|
||||
- [x] 1.1 在退款审批 Application 中实现历史待审批退款的主动发起:加载原创建人和订单事实、复用既有审批快照与 `Prepare`/`CreateInTx` 链路,并在同一事务内按待审批且未关联审批实例的条件回填关联和审计。
|
||||
- [x] 1.2 在线下代理充值 Application 中实现历史待审批充值的主动发起:校验线下支付、待审批和未关联审批实例,加载原创建人、店铺和钱包事实,并复用既有审批创建、快照、Outbox 与审计链路。
|
||||
- [x] 1.3 在退款和代理充值 Service 中接入补发用例,复核既有路由权限与资源查询范围,向调用方返回包含审批摘要的既有 DTO;将并发或已关联审批实例映射为状态冲突。
|
||||
|
||||
## 2. HTTP 入口与文档
|
||||
|
||||
- [x] 2.1 在退款 Handler 和路由注册 `POST /api/admin/refunds/{id}/trigger-approval`,完成路径 ID 绑定并交由 Service 处理。
|
||||
- [x] 2.2 在代理充值 Handler 和路由注册 `POST /api/admin/agent-recharges/{id}/trigger-approval`,完成路径 ID 绑定并交由 Service 处理。
|
||||
- [x] 2.3 同步 `cmd/api/docs.go` 与 `cmd/gendocs/main.go` 所依赖的路由元数据,确保两个接口及其响应模型生成到 OpenAPI 文档。
|
||||
|
||||
## 3. 验证
|
||||
|
||||
- [x] 3.1 以隔离环境或最小可运行验证覆盖:两个符合资格的历史记录各只创建一次审批实例和提交 Outbox;非待审批、已关联、在线充值、原创建人/场景不可用及并发重复请求不创建第二实例。
|
||||
- [x] 3.2 执行 `gofmt -w`(变更的 Go 文件)、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate --all` 和 `./scripts/context-health.sh`。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-20
|
||||
@@ -0,0 +1,43 @@
|
||||
## Context
|
||||
|
||||
见 proposal.md。退款申请创建已允许 0 元金额,但退款通过路径的公共金额校验拒绝 `<= 0`。企微终态消费者和受控人工审批均复用该校验。退款通过事务还会无条件调用钱包回款;代理主钱包回款命令不接受 0 元。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 让零金额退款在两条通过路径中保持一致的状态推进语义。
|
||||
- 避免零金额钱包流水、余额变更或调用资金回款用例。
|
||||
- 保持退款、订单、通知及既有后处理的事务与幂等边界。
|
||||
|
||||
**Non-Goals:**
|
||||
- 不允许负数退款金额。
|
||||
- 不修改退款申请、企微审批或订单的数据库结构。
|
||||
- 不改变非零退款的校验、资金回款和后处理语义。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 公共金额校验接受零、拒绝负数
|
||||
将公共审批退款金额的下限从“必须大于零”调整为“不得小于零”,继续校验不超过申请金额和订单实收金额。这样企微终态与人工路径不会出现规则漂移。
|
||||
|
||||
备选方案是在企微消费者单独放行零金额;不采用,因为人工审批仍会拒绝,且重复了金额规则。
|
||||
|
||||
### 零金额在退款事务内跳过资金回款
|
||||
当审批通过金额为零时,退款事务仍更新退款和订单状态、写入审计/通知/既有后处理事件,但跳过 `refundWalletPayment`。非零金额保留原调用。
|
||||
|
||||
备选方案是让钱包模块接受 0 元退款命令;不采用,因为会创建无资金含义的流水并放宽资金模块的金额不变量。
|
||||
|
||||
### 已失败的生产决策等待安全重试
|
||||
现有 `approval:26:approved` 决策投递已经处于失败待重试,部署后由既有重试机制重新执行;若重试已耗尽,维护者按既有受控运维流程重放同一稳定事件,而不直接修改退款、订单或钱包数据。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [0 元退款仍将订单标记为已退款] → 这是业务已确认语义;审计中保留审批退款金额为 0。
|
||||
- [遗漏某条通过路径] → 两条路径复用公共校验,并在退款服务公共回款接缝处按金额分支。
|
||||
- [旧失败任务已耗尽重试] → 发布后先核验决策投递状态;必要时以稳定事件 ID 受控重放。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 发布 API 与 Worker 二进制,不执行数据库迁移。
|
||||
2. 维护者核验 `approval:26:approved` 的决策投递是否被重试并成功,以及退款单 `RF20260820170954264700` 是否更新。
|
||||
3. 若未自动重试,由维护者受控重放同一审批终态事件并核验未产生 0 元钱包流水。
|
||||
4. 回滚仅恢复旧二进制;已完成的零金额退款不回退业务事实。
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
当前退款申请允许保存 0 元申请金额,但企微审批通过后的退款终态处理会拒绝该金额,导致通用审批已通过而退款申请持续待审批、反复重试。业务已确认 0 元退款是有效场景,需要将其作为不产生资金回款的退款完成。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 允许 0 元退款申请在企微审批或受控人工审批通过后完成。
|
||||
- 0 元退款通过时,将退款申请和关联订单推进为已通过/已退款,但不创建代理主钱包或资产钱包回款流水。
|
||||
- 保持负数金额、超过申请金额或超过订单实收金额的审批退款金额无效。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `refund-approval`: 退款审批终态、订单状态和资金回款的业务语义。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- 无。
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/service/refund/` 的退款金额校验、企微审批终态处理和人工审批处理。
|
||||
- 退款状态、订单支付状态、钱包流水和退款审批 Outbox 消费链路。
|
||||
- 不新增 API、数据库迁移或第三方依赖。
|
||||
@@ -0,0 +1,30 @@
|
||||
## Purpose
|
||||
|
||||
定义退款审批终态对退款、订单和资金回款事实的统一推进规则,确保零金额退款能够完成而不制造虚假的资金流水。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 零金额退款审批通过
|
||||
系统 SHALL 接受金额为零的退款申请在企业微信审批或受控人工审批通过后完成;退款申请状态 SHALL 更新为已通过,关联订单支付状态 SHALL 更新为已退款,并记录审批通过时间和零金额审批退款金额。
|
||||
|
||||
#### Scenario: 企业微信通过零金额退款
|
||||
- **WHEN** 本地关联的企业微信退款审批返回通过,且申请退款金额为零
|
||||
- **THEN** 系统将退款申请和关联订单分别更新为已通过和已退款
|
||||
|
||||
#### Scenario: 人工通过零金额退款
|
||||
- **WHEN** 启用的人工退款审批入口通过申请退款金额为零的退款申请
|
||||
- **THEN** 系统将退款申请和关联订单分别更新为已通过和已退款
|
||||
|
||||
### Requirement: 零金额退款不产生资金回款
|
||||
系统 SHALL 在零金额退款通过时不创建代理主钱包或资产钱包的退款回款流水,且不得调用资金回款处理。
|
||||
|
||||
#### Scenario: 钱包支付订单的零金额退款通过
|
||||
- **WHEN** 钱包支付订单关联的零金额退款审批通过
|
||||
- **THEN** 系统不写入任何该退款对应的钱包回款流水
|
||||
|
||||
### Requirement: 非法退款金额仍被拒绝
|
||||
系统 MUST 拒绝负数审批退款金额、超过申请退款金额的审批退款金额,以及超过订单实收金额的审批退款金额。
|
||||
|
||||
#### Scenario: 负数退款金额
|
||||
- **WHEN** 审批退款金额小于零
|
||||
- **THEN** 系统拒绝完成退款且保持退款与订单原有状态
|
||||
@@ -0,0 +1,11 @@
|
||||
## 1. 退款通过规则
|
||||
|
||||
- [x] 1.1 调整公共审批退款金额校验:接受零、拒绝负数,并保留申请金额和订单实收金额上限校验。
|
||||
- [x] 1.2 在企微审批终态退款处理里,对零金额跳过钱包回款,同时保持退款、订单、审计、通知和后处理事件的既有事务语义。
|
||||
- [x] 1.3 在受控人工退款审批路径复用相同的零金额回款跳过规则。
|
||||
|
||||
## 2. 验证与运维核验
|
||||
|
||||
- [x] 2.1 格式化变更的 Go 文件并执行 `go build ./cmd/api ./cmd/worker`。
|
||||
- [x] 2.2 执行 `openspec validate allow-zero-refund-approval --strict`,确认行为契约有效。
|
||||
- [x] 2.3 为维护者记录生产发布后的核验项:重试或受控重放 `approval:26:approved`,确认退款单 `RF20260820170954264700` 与关联订单完成,且没有零金额钱包回款流水。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-20
|
||||
@@ -0,0 +1,68 @@
|
||||
## Context
|
||||
|
||||
现有归档已按日生成 `tb_log_archive_run`,但 Integration Log 仅在月初终结整月 revision,物理删除任务也只接受完整自然月。`tb_log_archive_run` 已按来源和归档日保存对象键、manifest、哈希、统计数和 `cleanup_started_at` / `cleaned_at`,可作为按日留存的断点账本。审计调查的在线边界当前通过每个来源的最大已清理范围计算;若出现清理日期空洞会错误隐藏仍在线的数据。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 每天将昨天及更早连续积压的归档日推进到可恢复、可校验、已物理清理的状态。
|
||||
- 在任一日期归档、终结、对象校验或删除失败时保留在线数据,并使后续运行可安全续跑。
|
||||
- 保持归档对象和 manifest 不删除,保持现有配置开关作为物理删除总闸门。
|
||||
- 让在线查询边界只反映连续完成的日期。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不降低卡观测、轮询、Gateway 或 Integration Log 的写入频率。
|
||||
- 不直接对生产表执行 SQL 删除,不绕过归档验证清理历史数据。
|
||||
- 不改变归档 JSONL 格式、对象存储提供商或调查 API 路由。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 以“归档日对”为最小留存单元
|
||||
|
||||
日留存以同一 Asia/Shanghai 日期的 Audit 与 Integration 两条归档账本为一个逻辑单元。任务处理上限固定为昨天,先确保两类归档存在;随后为 Integration 生成最终 revision,确认该日不存在 `pending` 记录,并复用现有 manifest、对象 metadata、哈希和在线计数复核。
|
||||
|
||||
只有两个来源均通过验证后,才开始删除该日在线数据,顺序为 Audit Resource、Audit Event、Integration Log。删除仍按既有 1000 行批次进行;每个来源独立落 `cleanup_started_at` 和 `cleaned_at`,从而在进程中断后依据剩余计数续跑。
|
||||
|
||||
不采用“定时归档完成即直接 DELETE”的方案:对象上传成功不等于可恢复,且 Integration Log 的创建日可能仍有待完成记录。
|
||||
|
||||
### 按日期从早到晚推进,不跨越失败日
|
||||
|
||||
任务枚举昨天及更早仍未完成的归档日,从最早日期开始。某日失败、缺少归档或存在 pending Integration Log 时停止,不处理更晚日期。这样在线数据的已归档边界永远是连续前缀,历史积压在补齐归档后会由同一任务自动追赶。
|
||||
|
||||
不采用并行或跳过失败日期的方案:虽然可更快释放部分容量,但会产生在线数据日期空洞;当前调查响应只表达单一 `archived_before` 边界,不能正确描述空洞。
|
||||
|
||||
### 将 Integration 最终版由月度改为逐日形成
|
||||
|
||||
保留每日普通归档任务作为预归档;日留存任务在准备删除某日数据时对该日期执行最终归档。最终归档仍要求该日没有 pending Integration Log;否则不删除并等待下次运行。原月度最终复核和月度留存调度移除,避免与日留存争夺同一账本和产生不同的清理范围。
|
||||
|
||||
不采用固定延迟天数:用户要求昨天及更早数据在可恢复后尽快清理;pending 校验是每日期间的实际安全门禁。
|
||||
|
||||
### 查询边界按连续清理前缀计算
|
||||
|
||||
留存边界查询不再取任意来源的 `MAX(range_end)`。它必须从归档账本确认连续已清理日期,且多来源时间线使用两来源共同完成的连续边界;遇到未清理日期时停止。这样部分删除、失败重试或历史补档都不会把仍在线的旧日期标记为已归档。
|
||||
|
||||
### 使用独立文件记录留存结果与失败
|
||||
|
||||
新增独立的 Lumberjack/Zap 留存日志配置,默认路径为 Worker 工作目录下的 `logs/audit-retention.log`。日留存和日归档只向该日志写入日期推进、成功数量与耗时,或带日期、来源、失败分类的安全错误摘要;运营者无需在高噪声 `app.log` 中筛选。
|
||||
|
||||
不把诊断详情写入 `tb_log_archive_run.error_summary`。账本仍只保存归档状态、对象引用和清理断点;归档或留存失败的具体错误以独立文件为准。保留日志轮转和压缩,避免长期错误积压占满 Worker 磁盘。
|
||||
|
||||
不采用为每个日期新建单独文件的方案:一个按日轮转的专用流已经能按日期字段筛选,并避免文件数量随积压日期增长。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [某日大量 pending 长期不终结,阻塞后续日期清理] → 记录日期、数量和原因;人工修复业务状态后由日任务续跑,不删除未完成数据。
|
||||
- [单日数百万记录清理超过任务超时] → 维持小批次和来源级断点,任务重试时基于剩余记录续跑;按实测调整任务超时,不增大单事务范围。
|
||||
- [归档或删除期间进程重启] → 账本的运行租约和清理标记保证归档可重做、删除可继续,且每次删除前重新验证剩余范围。
|
||||
- [历史日期未建归档账本] → 日任务在最早缺失日期停止;先通过受控补归档形成完整连续账本,再允许自动物理清理。
|
||||
- [日清理后数据库文件空间不立即下降] → 删除仅回收可复用空间;由维护者根据 PostgreSQL 运行状态决定后续 VACUUM 策略,不在 Worker 内执行高风险表重写。
|
||||
- [独立留存日志写满磁盘或轮转配置错误] → 使用已有 Lumberjack 轮转、压缩和目录初始化机制;上线前核对 `logs/audit-retention.log` 的生成和轮转。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 发布包含日归档最终版、日留存和连续边界计算的 Worker/API 二进制,保持物理清理开关关闭。
|
||||
2. 维护者核对历史归档账本的连续性,按日期补齐缺失归档,并以只读演练确认对象、manifest 和在线计数。
|
||||
3. 维护者在低峰期启用 `JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED=true` 并重启调度 Worker;观察 `/opt/junhong_cmp/worker/logs/audit-retention.log`、账本断点和表大小。
|
||||
4. 出现异常时关闭开关并重启调度 Worker;已删除数据通过已验证的对象存储归档恢复,未删除日期保留在线数据。
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
`tb_audit_event`、`tb_audit_event_resource` 与 `tb_integration_log` 每日持续写入百万级记录;现有物理留存任务仅在月初清理上一个完整自然月,在线表、索引和备份在月内持续膨胀。生产需要在确认对象存储归档完整且可恢复后,按日清理在线日志数据。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 将统一审计事件、审计资源快照和 Integration Log 的物理留存单位由完整自然月改为完整自然日。
|
||||
- 每次日留存任务处理昨天及更早仍未完成清理的归档日;仅在对应 Audit 与 Integration 归档均成功、完整、可读取且满足最终版要求时,分批物理删除在线表中该日的数据。
|
||||
- 将清理断点和查询留存边界改为按归档日表达,支持失败后从未完成日期续跑,且不删除未通过校验的日期。
|
||||
- 保留对象存储归档和现有物理清理开关;不降低卡观测、轮询或外部交互日志的写入频率。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `operations-audit`: 审计在线数据在逐日归档校验通过后按归档日物理留存,并向调查查询提供逐日清理边界。
|
||||
- `external-integration`: 外部交互日志在逐日最终归档校验通过后按归档日物理留存。
|
||||
|
||||
## Impact
|
||||
|
||||
- Worker 定时任务、审计归档服务、月度留存处理器和留存边界查询。
|
||||
- `tb_log_archive_run` 的清理断点语义,以及 `tb_audit_event`、`tb_audit_event_resource`、`tb_integration_log` 的物理删除范围。
|
||||
- 对象存储归档读取、manifest 与哈希校验。
|
||||
- 生产需更新 Worker 二进制、`JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED` 及独立留存日志配置;日志写入 Worker 工作目录下的 `logs/audit-retention.log`,不新增外部 API,不直接执行生产数据删除或迁移。
|
||||
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 外部交互日志逐日物理留存
|
||||
系统 SHALL 以 Asia/Shanghai 已结束的自然日为单位物理留存 `tb_integration_log`。删除某日在线外部交互日志前,系统 MUST 为该日生成最终归档版本,确认不存在待完成记录,并验证归档对象、清单、日期范围、记录数量和校验摘要可作为恢复凭证。每次执行 SHALL 从最早尚未完成清理的归档日连续处理至昨天;任一日期不能形成或验证最终归档时,系统 MUST 不删除该日及更晚日期的在线外部交互日志。
|
||||
|
||||
#### Scenario: 最终归档通过后清理在线外部交互日志
|
||||
- **GIVEN** 某已结束自然日的外部交互日志均已进入终态,且该日最终归档对象和清单校验成功
|
||||
- **WHEN** 日留存任务处理该日期
|
||||
- **THEN** 系统分批删除该日的在线外部交互日志并将该归档日标记为已清理,归档对象保持可用
|
||||
|
||||
#### Scenario: 存在待完成外部交互日志
|
||||
- **GIVEN** 某归档日仍存在待完成的外部交互日志
|
||||
- **WHEN** 日留存任务尝试处理该日期
|
||||
- **THEN** 系统不删除该日及更晚日期的在线外部交互日志,并在独立日留存日志中记录该日期未形成最终归档的原因
|
||||
|
||||
#### Scenario: 历史积压按日期连续补清
|
||||
- **GIVEN** 存在多个昨天及更早日期尚未完成物理清理
|
||||
- **WHEN** 日留存任务执行且这些日期依次通过最终归档和完整性校验
|
||||
- **THEN** 系统按日期从早到晚清理全部连续合格日期
|
||||
@@ -0,0 +1,41 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 审计在线数据逐日物理留存
|
||||
系统 SHALL 以 Asia/Shanghai 已结束的自然日为单位处理统一审计事件及其资源快照的在线留存。每次留存执行 SHALL 从最早尚未完成清理的归档日开始,连续处理至昨天;任一归档日未通过完整性校验时,系统 MUST 保留该日及其后续日期的在线审计数据,且不得将它们标记为已清理。系统 MUST 先删除该日的审计资源快照,再删除该日的审计事件,并保留对象存储归档对象及清单作为恢复凭证。
|
||||
|
||||
#### Scenario: 已验证日期完成物理清理
|
||||
- **GIVEN** 某已结束自然日的审计归档成功,归档对象和清单可读取且与在线记录范围、数量和校验摘要一致
|
||||
- **WHEN** 日留存任务处理该日期
|
||||
- **THEN** 系统分批删除该日在线审计资源快照和审计事件,并将该归档日标记为已清理
|
||||
|
||||
#### Scenario: 归档校验失败阻断连续清理
|
||||
- **GIVEN** 某尚未清理的归档日缺少归档、归档校验失败或清单与在线数据不一致
|
||||
- **WHEN** 日留存任务处理该日期
|
||||
- **THEN** 系统不删除该日或更晚日期的在线审计数据,并将失败信息写入独立日留存日志
|
||||
|
||||
#### Scenario: 失败后从未完成日期续跑
|
||||
- **GIVEN** 日留存任务在某个归档日失败,较早日期已经标记为已清理
|
||||
- **WHEN** 后续日留存任务再次执行
|
||||
- **THEN** 系统从最早未完成清理的归档日继续处理,不重复删除已完成日期的数据
|
||||
|
||||
### Requirement: 日留存独立运行日志
|
||||
系统 SHALL 将日留存的日期推进、清理成功和阻断失败写入独立轮转日志。该日志 MUST 位于 Worker 工作目录下配置的 `logs/audit-retention.log`,每条失败记录 MUST 包含归档日期、数据来源、失败分类和安全错误摘要。日留存和日归档路径 MUST NOT 将失败详情写入 `tb_log_archive_run.error_summary`。
|
||||
|
||||
#### Scenario: 留存日期被阻断
|
||||
- **GIVEN** 日留存处理某个归档日时发生缺失账本、pending 记录、归档校验或删除错误
|
||||
- **WHEN** 系统停止该日期推进
|
||||
- **THEN** `logs/audit-retention.log` 记录该日期、来源、失败分类和错误摘要
|
||||
- **AND** 对应账本记录的 `error_summary` 不写入该失败详情
|
||||
|
||||
#### Scenario: 留存日期清理成功
|
||||
- **GIVEN** 某归档日通过全部校验并完成在线数据物理删除
|
||||
- **WHEN** 系统完成该日期处理
|
||||
- **THEN** `logs/audit-retention.log` 记录归档日期、各来源清理数量和耗时
|
||||
|
||||
### Requirement: 审计调查留存边界连续
|
||||
系统 SHALL 仅将连续完成物理清理的审计归档日期间公开为已归档边界。在线审计调查接口 MUST 继续允许查询任何尚未物理清理的较早日期,且不得因某个较晚日期已归档而将仍在线的日期错误标记为已归档。
|
||||
|
||||
#### Scenario: 清理链存在未完成日期
|
||||
- **GIVEN** 某较早归档日尚未完成清理
|
||||
- **WHEN** 后续日期的归档已成功生成
|
||||
- **THEN** 审计调查接口仍将较早未清理日期视为在线可查询数据
|
||||
@@ -0,0 +1,19 @@
|
||||
## 1. 按日归档与留存编排
|
||||
|
||||
- [x] 1.1 将 Integration Log 最终归档能力收敛为指定已结束自然日;保留 pending 记录时不产生最终 revision,并通过独立留存日志输出原因而不更新 `error_summary`。
|
||||
- [x] 1.2 将现有月度留存服务改为逐日处理:枚举昨天及更早未完成日期,按日期从早到晚确保双来源归档、最终归档和完整性校验。
|
||||
- [x] 1.3 复用现有分批删除和来源级清理断点,按 Audit Resource、Audit Event、Integration Log 的顺序物理删除单日在线数据;中断或失败时可从剩余记录续跑。
|
||||
- [x] 1.4 遇到缺失账本、归档失败、对象或 manifest 校验失败、数量不一致或 pending Integration Log 时停止日期推进;不清理该日及后续日期,且不向 `tb_log_archive_run.error_summary` 写入失败详情。
|
||||
|
||||
## 2. Worker 调度与查询边界
|
||||
|
||||
- [x] 2.1 为 Worker 新增独立留存日志配置和目录初始化,默认输出 `logs/audit-retention.log`,复用现有轮转与压缩能力,并在退出时刷新日志。
|
||||
- [x] 2.2 将 Worker 留存调度由月初任务改为每日任务,调整任务类型、唯一窗口、超时、处理器日志及归档注册,避免月度任务与日留存并发处理同一账本。
|
||||
- [x] 2.3 保留 `JUNHONG_WORKER_AUDIT_RETENTION_CLEANUP_ENABLED` 作为物理删除总闸门;关闭时执行逐日只读校验,不写清理断点、不删除在线数据。
|
||||
- [x] 2.4 改造审计和 Integration Log 在线查询留存边界,按连续已清理日期计算,并使混合时间线不将仍在线的空洞日期误报为已归档。
|
||||
- [x] 2.5 更新留存模拟 CLI 或等价可执行验证入口,以逐日范围验证归档、最终版、对象、manifest、数据库数量、清理断点及独立日志输出。
|
||||
|
||||
## 3. 运行说明与验证
|
||||
|
||||
- [x] 3.1 更新生产运行说明,明确日留存启用前的历史归档补齐、只读演练、开关启用、`/opt/junhong_cmp/worker/logs/audit-retention.log` 观察、账本断点、异常停用和归档恢复步骤。
|
||||
- [x] 3.2 执行 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate daily-audit-log-retention --strict` 与日留存模拟验证;不新增或恢复自动化测试。(隔离库模拟待维护者在具备显式 `JUNHONG_*` 配置的环境执行确认)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-03
|
||||
@@ -0,0 +1,106 @@
|
||||
## Context
|
||||
|
||||
线上只读数据得到旧孤儿扫描窗口 `100/100/0`:固定取出的 100 条待生效记录全部仍有 `status IN (1,2)` 占位主套餐,而真正无占位套餐的记录位于窗口之外。当前实现先 `LIMIT 100`,再逐条查询占位状态,因此同一批无效候选会永久挡住真实孤儿。
|
||||
|
||||
过期路径还在数据库事务内调用 `enqueueActivationTask`。Asynq 消费者可能早于事务提交读取旧主套餐,随后 `ActivateSpecificPackage` 判断“已有生效主套餐”并返回 `nil`;Handler 继续记录“套餐激活成功”,任务不再重试。Redis 锁冲突也返回 `nil`,存在另一条假成功路径。
|
||||
|
||||
本热修保持当前线上 `Polling Handler → GORM transaction → Asynq → Package Activation Service` 架构,不引入新的可靠投递设施。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 每轮 100 个恢复名额只用于真实孤儿载体,同一载体只选择队首套餐。
|
||||
- 旧套餐过期事实提交后才投递 Asynq,消除事务可见性竞态。
|
||||
- Redis 锁冲突返回错误,由现有 Asynq 重试。
|
||||
- 成功日志只对应实际激活或明确的已完成幂等事实。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不新增 Outbox、迁移、索引、依赖、队列或任务类型。
|
||||
- 不重构套餐购买、实名激活、流量扣减、退款和停复机。
|
||||
- 不修改 API、DTO、路由或前端。
|
||||
- 不批量修复历史数据。
|
||||
- 按用户要求,不新增、修改或运行自动化测试。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:数据库先筛选真实孤儿队首,再执行 LIMIT
|
||||
|
||||
使用 GORM `Raw` 执行 PostgreSQL CTE/窗口查询:
|
||||
|
||||
1. 从有效 `status=0` 主套餐按卡/设备载体分组。
|
||||
2. 每组按 `priority ASC, created_at ASC, id ASC` 选 `ROW_NUMBER()=1`。
|
||||
3. 使用相关 `NOT EXISTS` 排除同载体 `status IN (1,2)` 主套餐。
|
||||
4. 稳定排序后 `LIMIT 100`。
|
||||
|
||||
删除现有逐条 `Count` 和 Go map 分组。查询仍返回完整 `PackageUsage`,沿用现有投递循环。
|
||||
|
||||
**拒绝:扩大 LIMIT。** 只会推迟复现并增加 N+1。
|
||||
|
||||
**拒绝:分页遍历所有 pending。** 需要游标状态,复杂度高于一次正确查询。
|
||||
|
||||
### 决策 2:过期事务提交后再投递现有 Asynq
|
||||
|
||||
`processExpiredPackage` 事务只更新旧主套餐和关联加油包。提交成功后,使用普通数据库句柄调用现有 `activateNextPackage` 查询队首并入队。
|
||||
|
||||
提交后入队失败时返回错误并记录上下文;同一轮末尾及后续轮询的真实孤儿扫描会再次发现该载体,提供持久状态驱动的补偿。
|
||||
|
||||
**拒绝:任务增加固定延迟。** 固定延迟不能证明事务已提交。
|
||||
|
||||
**拒绝:引入 Outbox。** 当前线上没有该基础设施,热修不扩张架构。
|
||||
|
||||
### 决策 3:锁冲突必须触发 Asynq 重试
|
||||
|
||||
`ActivateSpecificPackage` 未取得 `RedisPackageActivationLockKey` 时返回现有 `CodePackageActivationConflict`。Handler 原样返回错误,由任务已有 `MaxRetry(3)` 处理。
|
||||
|
||||
Redis Key 继续使用 `pkg/constants/redis.go` 的生成函数,不新增硬编码 Key。
|
||||
|
||||
### 决策 4:显式返回本次是否激活
|
||||
|
||||
`ActivateSpecificPackage` 返回 `(bool, error)`:
|
||||
|
||||
- `true,nil`:本次把待生效套餐推进为生效中;
|
||||
- `false,nil`:记录已非待生效、存在占位套餐或条件暂不满足;
|
||||
- `false,error`:数据库、Redis 或锁冲突,应由任务重试或记录失败。
|
||||
|
||||
Handler 仅在 `true,nil` 时记录“套餐激活成功”。Handler 已在调用前识别 `status=1` 的重复任务并记录幂等跳过。
|
||||
|
||||
### 决策 5:依赖注入和事务边界保持不变
|
||||
|
||||
`PackageActivationHandler` 继续通过结构体字段持有 `*gorm.DB`、`*redis.Client`、`*asynq.Client`、`*ActivationService` 和 Zap Logger;不新增单实现接口或工厂。套餐激活仍由 Service 自己开启 GORM 事务,Handler 不直接更新新套餐状态。
|
||||
|
||||
### 决策 6:公共能力与验证
|
||||
|
||||
- Audit Event:N/A,系统自动生命周期推进。
|
||||
- Domain Ledger:N/A,`tb_package_usage` 是权威事实。
|
||||
- Integration Log:N/A,无新增外部调用。
|
||||
- Outbox:N/A,保持当前 Asynq + 周期自愈。
|
||||
|
||||
按用户要求不写或运行自动化测试。验证使用 `gofmt`、`git diff --check`、`go build ./...`、只读 SQL、查询计划和日志检查。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[风险] CTE 扫描大量 pending** → 用 `EXPLAIN (ANALYZE, BUFFERS)` 验证;无证据不新增索引。
|
||||
- **[风险] 提交后、入队前进程退出** → 下一轮真实孤儿扫描恢复,最长增加一个轮询周期。
|
||||
- **[风险] 多实例重复入队** → 载体 Redis 锁和套餐状态幂等保证只实际激活一次。
|
||||
- **[风险] 方法签名变化遗漏调用点** → 使用 `rg` 检查全部调用方并以全量构建证明编译契约。
|
||||
- **[权衡] Asynq 最终失败后仍依赖轮询重新入队** → 这是当前线上架构的既有补偿边界,本热修不扩建基础设施。
|
||||
- **[权衡] 不新增自动化测试** → 遵循用户边界,以构建、SQL 和日志证据替代。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 实施真实孤儿查询、提交后入队、锁冲突重试和准确日志。
|
||||
2. 执行格式化、静态检查、`go build ./...` 和只读 SQL语义检查。
|
||||
3. 形成独立中文 Lore 热修提交。
|
||||
4. 部署 Worker,观察至少两个轮询周期内真实孤儿收敛和任务日志。
|
||||
|
||||
### 回滚
|
||||
|
||||
- 无数据库迁移,revert 热修提交并重新部署 Worker。
|
||||
- 已正确激活的套餐保持业务事实,不执行反向 SQL。
|
||||
- 回滚后新增孤儿继续使用带状态保护的单卡 SQL逐条恢复。
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。
|
||||
@@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
功能 ID:`hotfix-main-package-activation-recovery`
|
||||
|
||||
线上已第二次出现原主套餐成功过期、队首待生效套餐仍长期停留在 `status=0` 的故障。只读诊断确认当前孤儿扫描固定取出的 100 条记录全部仍有占位套餐,真实孤儿永远无法进入恢复窗口;同时,过期事务提交前投递 Asynq 会让消费者读到旧套餐仍为生效中并把跳过误判为成功。
|
||||
|
||||
## What Changes
|
||||
|
||||
- PostgreSQL 在 `LIMIT 100` 前完成每个载体队首选择和 `status IN (1,2)` 占位排除,删除逐条检查的 N+1 查询。
|
||||
- 旧主套餐过期和加油包失效事务提交成功后,才查询队首套餐并投递现有 `package:queue:activation` Asynq 任务。
|
||||
- Redis 激活锁冲突返回现有套餐激活冲突错误,让 Asynq 按既有策略重试,不再确认假成功。
|
||||
- Asynq Handler 仅在实际推进套餐或确认已完成幂等事实时记录成功;占位阻塞、等待实名和锁冲突记录明确原因。
|
||||
- 不新增 Outbox、数据库迁移、依赖或新任务类型,保持线上现有纯 Asynq 架构。
|
||||
- 按用户明确要求,不新增、修改或运行自动化测试;使用全量构建、只读 SQL、查询计划和日志核验。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `package-queue-activation`:补充纯 Asynq 过期接续的提交后投递、真实孤儿公平扫描、锁冲突重试和准确成功语义。
|
||||
|
||||
## Impact
|
||||
|
||||
- **适用范围**:仅当前 `main` 线上代码。
|
||||
- **架构通道**:旧套餐轮询复杂写用例,沿用 `Polling Handler → GORM transaction → Asynq → Package Activation Service`;不迁移未触碰的套餐模块。
|
||||
- **代码**:`internal/polling/package_activation_handler.go`、`internal/service/package/activation_service.go`。
|
||||
- **数据库**:无迁移;仅调整 `tb_package_usage` 查询顺序与过滤。
|
||||
- **API/前端**:无改动。
|
||||
- **依赖**:继续使用 GORM、PostgreSQL、Redis、Asynq 和 Zap,不新增依赖。
|
||||
- **性能**:孤儿扫描由最多 101 次查询收敛为一次候选查询和有限任务投递;使用查询计划确认数据库耗时。
|
||||
- **审计与可靠性**:Audit Event、Domain Ledger、Integration Log、Outbox 均为 N/A;`tb_package_usage` 是权威状态,现有 Asynq + 周期孤儿扫描负责最终恢复。
|
||||
- **验证**:不写自动化测试;执行 `gofmt`、静态检查、`go build ./...`、只读 SQL及日志核验。
|
||||
@@ -0,0 +1,77 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 当前主套餐过期后自动激活下一个
|
||||
|
||||
系统 SHALL 在生效中或已用完主套餐到期时,先提交旧主套餐及关联加油包的状态事务,再投递同一载体队首待生效套餐的现有 Asynq 激活任务;系统 MUST NOT 在旧套餐事务提交前投递任务。
|
||||
|
||||
#### Scenario: 事务提交后投递队首套餐
|
||||
|
||||
- **WHEN** 轮询处理一个到期的 `status=1` 或 `status=2` 主套餐,且存在队首待生效套餐
|
||||
- **THEN** 系统先提交旧主套餐 `status=3` 和关联加油包 `status=4` 的事务
|
||||
- **AND** 提交成功后查询稳定队首并投递 `package:queue:activation`
|
||||
- **AND** 消费者读取时旧主套餐不再处于 `status IN (1,2)`
|
||||
|
||||
#### Scenario: 过期事务失败
|
||||
|
||||
- **WHEN** 更新旧主套餐或关联加油包失败
|
||||
- **THEN** 事务回滚
|
||||
- **AND** 系统不得投递下一套餐激活任务
|
||||
- **AND** 下一轮过期扫描仍可重新处理
|
||||
|
||||
#### Scenario: 提交后入队失败
|
||||
|
||||
- **WHEN** 旧套餐事务已经提交,但 Asynq 入队失败或进程退出
|
||||
- **THEN** 队首套餐保持 `status=0`
|
||||
- **AND** 周期孤儿扫描重新发现并补投该套餐
|
||||
|
||||
#### Scenario: 无待生效套餐
|
||||
|
||||
- **WHEN** 旧套餐过期提交后不存在有效队首待生效套餐
|
||||
- **THEN** 系统不投递激活任务
|
||||
- **AND** 沿用既有无套餐停机检查
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 孤儿待生效套餐必须公平恢复
|
||||
|
||||
系统 SHALL 在数据库中先选择每个卡或设备载体唯一的队首待生效主套餐,并排除仍有 `status IN (1,2)` 占位主套餐的载体,最后才执行单轮 100 个真实孤儿上限。队首顺序 MUST 为 `priority ASC, created_at ASC, id ASC`。
|
||||
|
||||
#### Scenario: 固定窗口全部为非孤儿
|
||||
|
||||
- **WHEN** 排序靠前的 100 条待生效记录均有占位主套餐,窗口之后存在真实孤儿
|
||||
- **THEN** 数据库先排除前 100 条非孤儿
|
||||
- **AND** 窗口后的真实孤儿进入本轮候选并被投递
|
||||
|
||||
#### Scenario: 同一载体有多条 pending
|
||||
|
||||
- **WHEN** 一个真实孤儿载体存在多条待生效主套餐
|
||||
- **THEN** 本轮只选择 priority 最小、created_at 最早、id 最小的一条
|
||||
- **AND** 该载体只占一个恢复名额
|
||||
|
||||
#### Scenario: 生效中或已用完套餐占位
|
||||
|
||||
- **WHEN** pending 所属载体存在 `status=1` 或 `status=2` 主套餐
|
||||
- **THEN** 该载体不得进入孤儿候选
|
||||
|
||||
### Requirement: Asynq 激活结果必须准确且可重试
|
||||
|
||||
系统 SHALL 仅在本次实际把套餐推进为 `status=1` 时记录新激活成功。Redis 激活锁冲突 MUST 返回错误,使 Asynq 按既有 `MaxRetry(3)` 重试,不得确认未执行任务成功。
|
||||
|
||||
#### Scenario: 锁冲突触发重试
|
||||
|
||||
- **WHEN** 消费者未取得载体级套餐激活锁
|
||||
- **THEN** Service 返回套餐激活冲突错误
|
||||
- **AND** Handler 将错误返回 Asynq
|
||||
- **AND** 本次不记录激活成功
|
||||
|
||||
#### Scenario: 本次实际激活
|
||||
|
||||
- **WHEN** 套餐为待生效、载体无占位主套餐且满足激活条件
|
||||
- **THEN** Service 返回 `activated=true`
|
||||
- **AND** Handler 记录包含套餐使用记录和触发类型的成功日志
|
||||
|
||||
#### Scenario: 条件暂不满足
|
||||
|
||||
- **WHEN** Service 复检发现占位套餐或等待实名条件
|
||||
- **THEN** Service 返回 `activated=false`且不修改套餐
|
||||
- **AND** Handler 记录未激活原因,不记录成功
|
||||
@@ -0,0 +1,20 @@
|
||||
## 1. Main 分支隔离与基线
|
||||
|
||||
- [x] 1.1 确认变更仅面向 `main` 的纯 Asynq 套餐接续链路,并保持 Outbox 与新任务基础设施为非目标。
|
||||
- [x] 1.2 检索套餐激活方法的全部调用点及现有错误码、Redis 键和任务重试配置,锁定最小修改边界。
|
||||
|
||||
## 2. 过期接续与孤儿恢复
|
||||
|
||||
- [x] 2.1 将孤儿恢复改为数据库先按载体选择队首并排除 `status IN (1,2)` 占位套餐,最后限制 100 个真实孤儿,删除逐条占位查询。
|
||||
- [x] 2.2 将旧主套餐过期后的下一套餐投递移到事务提交后,保留现有停机检查和周期孤儿补偿。
|
||||
|
||||
## 3. 激活结果与重试语义
|
||||
|
||||
- [x] 3.1 让指定套餐激活显式返回是否实际激活,并在 Redis 激活锁冲突时返回现有套餐激活冲突错误。
|
||||
- [x] 3.2 调整 Asynq Handler 日志,仅在实际激活时记录成功,未激活时记录明确的跳过信息。
|
||||
|
||||
## 4. 文档、验证与提交
|
||||
|
||||
- [x] 4.1 更新功能总结和 README,说明 `main` 纯 Asynq 热修边界、部署观察项及回滚方式。
|
||||
- [x] 4.2 执行 `gofmt`、`git diff --check`、`go build ./...` 和 OpenSpec 严格校验;按用户要求不新增、修改或运行自动化测试。
|
||||
- [x] 4.3 仅暂存本热修代码、文档和独立 OpenSpec,创建符合 Lore 协议的中文提交。
|
||||
@@ -97,13 +97,66 @@
|
||||
- **WHEN** 当前账号请求资金概况列表但未提供 `shop_id`
|
||||
- **THEN** 系统继续按既有分页、数据范围、店铺名称和主账号用户名条件返回结果
|
||||
|
||||
### Requirement: 历史待审批线下代理充值可主动接入企业微信审批
|
||||
|
||||
系统 SHALL 提供 `POST /api/admin/agent-recharges/{id}/trigger-approval`,使具有既有代理充值管理访问权限的后台账号可为历史线下代理充值申请主动创建企业微信审批。系统 MUST 仅在线下充值记录处于待审批状态且 `approval_instance_id` 为空时创建审批;审批发起人 MUST 使用该充值记录的原创建账号。创建成功后,系统 MUST 原子保存唯一审批实例、审批提交请求及充值记录的审批实例关联,并返回更新后的充值申请审批摘要。
|
||||
|
||||
#### Scenario: 主动发起历史线下代理充值审批成功
|
||||
|
||||
- **GIVEN** 线下代理充值申请处于待审批状态、未关联审批实例,且其原创建账号和企业微信线下充值审批场景均可用
|
||||
- **WHEN** 有既有代理充值管理访问权限的后台账号请求 `POST /api/admin/agent-recharges/{id}/trigger-approval`
|
||||
- **THEN** 系统创建以原创建账号为发起人的唯一企业微信审批并返回审批摘要,后续由既有可靠提交流程提交至企业微信
|
||||
|
||||
#### Scenario: 在线、非待审批或已发起记录被拒绝
|
||||
|
||||
- **WHEN** 请求主动发起的充值记录不是线下充值、不是待审批状态或已关联审批实例
|
||||
- **THEN** 系统返回状态冲突且不创建新的审批实例或提交请求
|
||||
|
||||
#### Scenario: 并发主动发起同一充值审批
|
||||
|
||||
- **WHEN** 两个请求同时为同一符合条件的线下代理充值申请主动发起审批
|
||||
- **THEN** 系统至多创建一个审批实例和一个审批提交请求,未成功创建关联的请求返回冲突
|
||||
|
||||
#### Scenario: 原创建人或审批渠道不可用
|
||||
|
||||
- **WHEN** 充值申请原创建账号不可用,或企业微信线下充值审批场景不可用
|
||||
- **THEN** 系统返回相应错误,充值申请保持未关联审批实例,修复条件后可再次发起
|
||||
|
||||
### Requirement: 代理在线充值可用支付方式按支付配置判定
|
||||
|
||||
系统 SHALL 按当前生效支付配置判定代理在线充值可用支付方式:微信直连(`wechat` 或 `wechat_v2`)配置完整,或富友(`fuiou`)配置完整时,返回 `wechat`;支付宝字段完整时返回 `alipay`。对外支付方式枚举 MUST 固定为 `wechat` 与 `alipay`,MUST NOT 返回 `fuiou`。代理账号以 `wechat` 创建在线充值单时,若生效配置为富友,系统 MUST 使用富友主扫统一下单并将返回的二维码链接作为支付链接。
|
||||
|
||||
#### Scenario: 富友配置完整时微信可用
|
||||
|
||||
- **GIVEN** 当前生效支付配置 `provider_type=fuiou` 且富友机构号、商户号、终端号、私钥、公钥、API 地址、通知地址均非空
|
||||
- **WHEN** 代理账号查询可用支付方式
|
||||
- **THEN** 系统返回包含 `wechat` 的方式列表且不包含 `fuiou`
|
||||
|
||||
#### Scenario: 微信直连配置完整时微信可用
|
||||
|
||||
- **GIVEN** 当前生效支付配置为微信直连且对应字段完整
|
||||
- **WHEN** 代理账号查询可用支付方式
|
||||
- **THEN** 系统返回包含 `wechat` 的方式列表
|
||||
|
||||
#### Scenario: 支付宝字段完整时支付宝可用
|
||||
|
||||
- **GIVEN** 当前生效支付配置的支付宝应用 ID、应用私钥、支付宝公钥、通知地址均非空
|
||||
- **WHEN** 代理账号查询可用支付方式
|
||||
- **THEN** 系统返回包含 `alipay` 的方式列表
|
||||
|
||||
#### Scenario: 富友配置下微信创建走主扫下单
|
||||
|
||||
- **GIVEN** 当前生效支付配置为富友且字段完整
|
||||
- **WHEN** 代理账号以 `wechat` 创建在线充值单
|
||||
- **THEN** 系统调用富友主扫统一下单并返回二维码链接作为支付链接,本地充值单支付方式为 `wechat`、支付渠道为 `fuiou`
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
### 代理预充值
|
||||
|
||||
`GET /api/admin/agent-recharges`(查询代理充值订单列表);`POST /api/admin/agent-recharges`(创建代理充值订单);`GET /api/admin/agent-recharges/{id}`(查询代理充值订单详情);`POST /api/admin/agent-recharges/{id}/offline-pay`(确认线下充值);`GET /api/admin/agent-recharges/{id}/payment-status`(查询代理充值本地支付与到账状态);`POST /api/admin/agent-recharges/{id}/reject`(驳回代理充值订单);`GET /api/admin/agent-recharges/payment-methods`(查询代理在线充值可用支付方式)。
|
||||
`GET /api/admin/agent-recharges`(查询代理充值订单列表);`POST /api/admin/agent-recharges`(创建代理充值订单);`GET /api/admin/agent-recharges/{id}`(查询代理充值订单详情);`POST /api/admin/agent-recharges/{id}/trigger-approval`(补发历史线下代理充值审批);`POST /api/admin/agent-recharges/{id}/offline-pay`(确认线下充值);`GET /api/admin/agent-recharges/{id}/payment-status`(查询代理充值本地支付与到账状态);`POST /api/admin/agent-recharges/{id}/reject`(驳回代理充值订单);`GET /api/admin/agent-recharges/payment-methods`(查询代理在线充值可用支付方式)。
|
||||
|
||||
### 代理商资金管理
|
||||
|
||||
|
||||
@@ -66,6 +66,60 @@
|
||||
- **WHEN** 渠道再次发送相同业务事实
|
||||
- **THEN** 系统返回渠道可接受响应且不重复推进卡状态
|
||||
|
||||
### Requirement: 富友主扫统一下单与订单查询
|
||||
|
||||
系统 SHALL 通过富友主扫统一下单创建微信二维码支付并返回 `qr_code` 二维码链接;系统 SHALL 通过富友订单查询按 `trans_stat` 将状态映射为已支付、已关闭、待支付或未知,未知状态 MUST 保持待恢复。下单与查询失败 MUST 映射为项目稳定错误,已接入外部交互日志的调用保留脱敏结果。
|
||||
|
||||
#### Scenario: 主扫下单成功返回二维码链接
|
||||
|
||||
- **GIVEN** 富友支付配置完整
|
||||
- **WHEN** 系统发起主扫统一下单且渠道返回成功
|
||||
- **THEN** 系统返回 `qr_code` 作为支付链接,业务以该链接生成二维码
|
||||
|
||||
#### Scenario: 查询映射支付成功
|
||||
|
||||
- **WHEN** 富友订单查询返回 `trans_stat=SUCCESS`
|
||||
- **THEN** 系统将状态映射为已支付并取得渠道交易号、金额与支付时间
|
||||
|
||||
#### Scenario: 查询映射已关闭
|
||||
|
||||
- **WHEN** 富友订单查询返回 `trans_stat` 为 `PAYERROR`、`CLOSED` 或 `REVOKED`
|
||||
- **THEN** 系统将状态映射为已关闭
|
||||
|
||||
#### Scenario: 查询映射待支付
|
||||
|
||||
- **WHEN** 富友订单查询返回 `trans_stat` 为 `USERPAYING` 或 `NOTPAY`
|
||||
- **THEN** 系统将状态映射为待支付
|
||||
|
||||
#### Scenario: 查询状态未知保持待恢复
|
||||
|
||||
- **WHEN** 富友订单查询返回系统错误、找不到交易或无法识别的 `trans_stat`
|
||||
- **THEN** 系统将状态映射为未知并保持本地支付单待恢复,不得据此确认收款或关闭订单
|
||||
|
||||
#### Scenario: 下单失败返回稳定错误
|
||||
|
||||
- **WHEN** 富友主扫统一下单返回失败或请求结果未知
|
||||
- **THEN** 系统返回项目稳定错误且已接入外部交互日志的调用记录脱敏结果
|
||||
|
||||
### Requirement: 外部交互日志逐日物理留存
|
||||
|
||||
系统 SHALL 以 Asia/Shanghai 已结束的自然日为单位物理留存 `tb_integration_log`。删除某日在线外部交互日志前,系统 MUST 为该日生成最终归档版本,确认不存在待完成记录,并验证归档对象、清单、日期范围、记录数量和校验摘要可作为恢复凭证。每次执行 SHALL 从最早尚未完成清理的归档日连续处理至昨天;任一日期不能形成或验证最终归档时,系统 MUST 不删除该日及更晚日期的在线外部交互日志。
|
||||
|
||||
#### Scenario: 最终归档通过后清理在线外部交互日志
|
||||
- **GIVEN** 某已结束自然日的外部交互日志均已进入终态,且该日最终归档对象和清单校验成功
|
||||
- **WHEN** 日留存任务处理该日期
|
||||
- **THEN** 系统分批删除该日的在线外部交互日志并将该归档日标记为已清理,归档对象保持可用
|
||||
|
||||
#### Scenario: 存在待完成外部交互日志
|
||||
- **GIVEN** 某归档日仍存在待完成的外部交互日志
|
||||
- **WHEN** 日留存任务尝试处理该日期
|
||||
- **THEN** 系统不删除该日及更晚日期的在线外部交互日志,并在独立日留存日志中记录该日期未形成最终归档的原因
|
||||
|
||||
#### Scenario: 历史积压按日期连续补清
|
||||
- **GIVEN** 存在多个昨天及更早日期尚未完成物理清理
|
||||
- **WHEN** 日留存任务执行且这些日期依次通过最终归档和完整性校验
|
||||
- **THEN** 系统按日期从早到晚清理全部连续合格日期
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
@@ -46,6 +46,49 @@
|
||||
- **WHEN** 该操作的审计事件构造、校验或持久化失败
|
||||
- **THEN** 系统提交或返回该业务操作原本的结果,并以请求关联标识、动作编码和资源标识记录审计失败
|
||||
|
||||
### Requirement: 审计在线数据逐日物理留存
|
||||
|
||||
系统 SHALL 以 Asia/Shanghai 已结束的自然日为单位处理统一审计事件及其资源快照的在线留存。每次留存执行 SHALL 从最早尚未完成清理的归档日开始,连续处理至昨天;任一归档日未通过完整性校验时,系统 MUST 保留该日及其后续日期的在线审计数据,且不得将它们标记为已清理。系统 MUST 先删除该日的审计资源快照,再删除该日的审计事件,并保留对象存储归档对象及清单作为恢复凭证。
|
||||
|
||||
#### Scenario: 已验证日期完成物理清理
|
||||
- **GIVEN** 某已结束自然日的审计归档成功,归档对象和清单可读取且与在线记录范围、数量和校验摘要一致
|
||||
- **WHEN** 日留存任务处理该日期
|
||||
- **THEN** 系统分批删除该日在线审计资源快照和审计事件,并将该归档日标记为已清理
|
||||
|
||||
#### Scenario: 归档校验失败阻断连续清理
|
||||
- **GIVEN** 某尚未清理的归档日缺少归档、归档校验失败或清单与在线数据不一致
|
||||
- **WHEN** 日留存任务处理该日期
|
||||
- **THEN** 系统不删除该日或更晚日期的在线审计数据,并将失败信息写入独立日留存日志
|
||||
|
||||
#### Scenario: 失败后从未完成日期续跑
|
||||
- **GIVEN** 日留存任务在某个归档日失败,较早日期已经标记为已清理
|
||||
- **WHEN** 后续日留存任务再次执行
|
||||
- **THEN** 系统从最早未完成清理的归档日继续处理,不重复删除已完成日期的数据
|
||||
|
||||
### Requirement: 日留存独立运行日志
|
||||
|
||||
系统 SHALL 将日留存的日期推进、清理成功和阻断失败写入独立轮转日志。该日志 MUST 位于 Worker 工作目录下配置的 `logs/audit-retention.log`,每条失败记录 MUST 包含归档日期、数据来源、失败分类和安全错误摘要。日留存和日归档路径 MUST NOT 将失败详情写入 `tb_log_archive_run.error_summary`。
|
||||
|
||||
#### Scenario: 留存日期被阻断
|
||||
- **GIVEN** 日留存处理某个归档日时发生缺失账本、pending 记录、归档校验或删除错误
|
||||
- **WHEN** 系统停止该日期推进
|
||||
- **THEN** `logs/audit-retention.log` 记录该日期、来源、失败分类和错误摘要
|
||||
- **AND** 对应账本记录的 `error_summary` 不写入该失败详情
|
||||
|
||||
#### Scenario: 留存日期清理成功
|
||||
- **GIVEN** 某归档日通过全部校验并完成在线数据物理删除
|
||||
- **WHEN** 系统完成该日期处理
|
||||
- **THEN** `logs/audit-retention.log` 记录归档日期、各来源清理数量和耗时
|
||||
|
||||
### Requirement: 审计调查留存边界连续
|
||||
|
||||
系统 SHALL 仅将连续完成物理清理的审计归档日期间公开为已归档边界。在线审计调查接口 MUST 继续允许查询任何尚未物理清理的较早日期,且不得因某个较晚日期已归档而将仍在线的日期错误标记为已归档。
|
||||
|
||||
#### Scenario: 清理链存在未完成日期
|
||||
- **GIVEN** 某较早归档日尚未完成清理
|
||||
- **WHEN** 后续日期的归档已成功生成
|
||||
- **THEN** 审计调查接口仍将较早未清理日期视为在线可查询数据
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
@@ -38,6 +38,31 @@
|
||||
- **WHEN** 该代理查询退款列表或退款申请详情
|
||||
- **THEN** 系统返回空列表或不存在,且不泄露任何退款申请
|
||||
|
||||
### Requirement: 历史待审批退款可主动接入企业微信审批
|
||||
|
||||
系统 SHALL 提供 `POST /api/admin/refunds/{id}/trigger-approval`,使具有既有退款管理访问权限的后台账号可为历史退款申请主动创建企业微信审批。系统 MUST 仅在退款申请状态为待审批且 `approval_instance_id` 为空时创建审批;审批发起人 MUST 使用该退款申请的原创建账号。创建成功后,系统 MUST 原子保存唯一审批实例、审批提交请求及退款申请的审批实例关联,并返回更新后的退款申请审批摘要。
|
||||
|
||||
#### Scenario: 主动发起历史退款审批成功
|
||||
|
||||
- **GIVEN** 退款申请处于待审批状态、未关联审批实例,且其原创建账号和企业微信退款审批场景均可用
|
||||
- **WHEN** 有既有退款管理访问权限的后台账号请求 `POST /api/admin/refunds/{id}/trigger-approval`
|
||||
- **THEN** 系统创建以原创建账号为发起人的唯一企业微信审批并返回审批摘要,后续由既有可靠提交流程提交至企业微信
|
||||
|
||||
#### Scenario: 非待审批或已发起记录被拒绝
|
||||
|
||||
- **WHEN** 请求主动发起的退款申请不是待审批状态或已关联审批实例
|
||||
- **THEN** 系统返回状态冲突且不创建新的审批实例或提交请求
|
||||
|
||||
#### Scenario: 并发主动发起同一退款审批
|
||||
|
||||
- **WHEN** 两个请求同时为同一符合条件的退款申请主动发起审批
|
||||
- **THEN** 系统至多创建一个审批实例和一个审批提交请求,未成功创建关联的请求返回冲突
|
||||
|
||||
#### Scenario: 原创建人或审批渠道不可用
|
||||
|
||||
- **WHEN** 退款申请原创建账号不可用,或企业微信退款审批场景不可用
|
||||
- **THEN** 系统返回相应错误,退款申请保持未关联审批实例,修复条件后可再次发起
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
@@ -48,7 +73,7 @@
|
||||
|
||||
### 退款管理
|
||||
|
||||
`GET /api/admin/refunds`(退款申请列表);`POST /api/admin/refunds`(创建退款申请);`GET /api/admin/refunds/{id}`(退款申请详情);`POST /api/admin/refunds/{id}/approve`(审批通过退款申请);`POST /api/admin/refunds/{id}/reject`(审批拒绝退款申请);`POST /api/admin/refunds/{id}/resubmit`(重新提交退款申请);`POST /api/admin/refunds/{id}/return`(退回退款申请)。
|
||||
`GET /api/admin/refunds`(退款申请列表);`POST /api/admin/refunds`(创建退款申请);`GET /api/admin/refunds/{id}`(退款申请详情);`POST /api/admin/refunds/{id}/trigger-approval`(补发历史退款审批);`POST /api/admin/refunds/{id}/approve`(审批通过退款申请);`POST /api/admin/refunds/{id}/reject`(审批拒绝退款申请);`POST /api/admin/refunds/{id}/resubmit`(重新提交退款申请);`POST /api/admin/refunds/{id}/return`(退回退款申请)。
|
||||
|
||||
### 换货管理
|
||||
|
||||
|
||||
32
openspec/specs/refund-approval/spec.md
Normal file
32
openspec/specs/refund-approval/spec.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# refund-approval Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
定义退款审批终态对退款、订单和资金回款事实的统一推进规则,确保零金额退款能够完成而不制造虚假的资金流水。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 零金额退款审批通过
|
||||
系统 SHALL 接受金额为零的退款申请在企业微信审批或受控人工审批通过后完成;退款申请状态 SHALL 更新为已通过,关联订单支付状态 SHALL 更新为已退款,并记录审批通过时间和零金额审批退款金额。
|
||||
|
||||
#### Scenario: 企业微信通过零金额退款
|
||||
- **WHEN** 本地关联的企业微信退款审批返回通过,且申请退款金额为零
|
||||
- **THEN** 系统将退款申请和关联订单分别更新为已通过和已退款
|
||||
|
||||
#### Scenario: 人工通过零金额退款
|
||||
- **WHEN** 启用的人工退款审批入口通过申请退款金额为零的退款申请
|
||||
- **THEN** 系统将退款申请和关联订单分别更新为已通过和已退款
|
||||
|
||||
### Requirement: 零金额退款不产生资金回款
|
||||
系统 SHALL 在零金额退款通过时不创建代理主钱包或资产钱包的退款回款流水,且不得调用资金回款处理。
|
||||
|
||||
#### Scenario: 钱包支付订单的零金额退款通过
|
||||
- **WHEN** 钱包支付订单关联的零金额退款审批通过
|
||||
- **THEN** 系统不写入任何该退款对应的钱包回款流水
|
||||
|
||||
### Requirement: 非法退款金额仍被拒绝
|
||||
系统 MUST 拒绝负数审批退款金额、超过申请退款金额的审批退款金额,以及超过订单实收金额的审批退款金额。
|
||||
|
||||
#### Scenario: 负数退款金额
|
||||
- **WHEN** 审批退款金额小于零
|
||||
- **THEN** 系统拒绝完成退款且保持退款与订单原有状态
|
||||
@@ -10,10 +10,11 @@ import (
|
||||
)
|
||||
|
||||
type DirectoryResult struct {
|
||||
TempDir string
|
||||
AppLogDir string
|
||||
AccessLogDir string
|
||||
Fallbacks []string
|
||||
TempDir string
|
||||
AppLogDir string
|
||||
AccessLogDir string
|
||||
RetentionLogDir string
|
||||
Fallbacks []string
|
||||
}
|
||||
|
||||
func EnsureDirectories(cfg *config.Config, logger *zap.Logger) (*DirectoryResult, error) {
|
||||
@@ -27,6 +28,7 @@ func EnsureDirectories(cfg *config.Config, logger *zap.Logger) (*DirectoryResult
|
||||
{cfg.Storage.TempDir, "storage.temp_dir", &result.TempDir},
|
||||
{filepath.Dir(cfg.Logging.AppLog.Filename), "logging.app_log.filename", &result.AppLogDir},
|
||||
{filepath.Dir(cfg.Logging.AccessLog.Filename), "logging.access_log.filename", &result.AccessLogDir},
|
||||
{filepath.Dir(cfg.Logging.RetentionLog.Filename), "logging.retention_log.filename", &result.RetentionLogDir},
|
||||
}
|
||||
|
||||
for _, dir := range directories {
|
||||
|
||||
@@ -79,10 +79,11 @@ type QueueConfig struct {
|
||||
|
||||
// LoggingConfig 日志配置
|
||||
type LoggingConfig struct {
|
||||
Level string `mapstructure:"level"` // debug, info, warn, error
|
||||
Development bool `mapstructure:"development"` // 启用开发模式(美化输出)
|
||||
AppLog LogRotationConfig `mapstructure:"app_log"` // 应用日志配置
|
||||
AccessLog LogRotationConfig `mapstructure:"access_log"` // HTTP 访问日志配置
|
||||
Level string `mapstructure:"level"` // debug, info, warn, error
|
||||
Development bool `mapstructure:"development"` // 启用开发模式(美化输出)
|
||||
AppLog LogRotationConfig `mapstructure:"app_log"` // 应用日志配置
|
||||
AccessLog LogRotationConfig `mapstructure:"access_log"` // HTTP 访问日志配置
|
||||
RetentionLog LogRotationConfig `mapstructure:"retention_log"` // Worker 日留存日志配置
|
||||
}
|
||||
|
||||
// LogRotationConfig Lumberjack 日志轮转配置
|
||||
@@ -168,7 +169,7 @@ type GatewayConfig struct {
|
||||
type WorkerConfig struct {
|
||||
Role string `mapstructure:"role"` // Worker 运行角色:all、leader、consumer
|
||||
InstanceName string `mapstructure:"instance_name"` // Worker 实例名称,用于多实例日志区分
|
||||
AuditRetentionCleanupEnabled bool `mapstructure:"audit_retention_cleanup_enabled"` // 是否启用审计日志月度物理清理
|
||||
AuditRetentionCleanupEnabled bool `mapstructure:"audit_retention_cleanup_enabled"` // 是否启用审计日志物理清理
|
||||
}
|
||||
|
||||
// ApprovalConfig 审批新旧入口切换配置。
|
||||
@@ -291,12 +292,18 @@ func (c *Config) Validate() error {
|
||||
if c.Logging.AccessLog.Filename == "" {
|
||||
return fmt.Errorf("invalid configuration: logging.access_log.filename: must be non-empty valid file path")
|
||||
}
|
||||
if c.Logging.RetentionLog.Filename == "" {
|
||||
return fmt.Errorf("invalid configuration: logging.retention_log.filename: must be non-empty valid file path")
|
||||
}
|
||||
if c.Logging.AppLog.MaxSize < 1 || c.Logging.AppLog.MaxSize > 1000 {
|
||||
return fmt.Errorf("invalid configuration: logging.app_log.max_size: size out of range (current value: %d, expected: 1-1000 MB)", c.Logging.AppLog.MaxSize)
|
||||
}
|
||||
if c.Logging.AccessLog.MaxSize < 1 || c.Logging.AccessLog.MaxSize > 1000 {
|
||||
return fmt.Errorf("invalid configuration: logging.access_log.max_size: size out of range (current value: %d, expected: 1-1000 MB)", c.Logging.AccessLog.MaxSize)
|
||||
}
|
||||
if c.Logging.RetentionLog.MaxSize < 1 || c.Logging.RetentionLog.MaxSize > 1000 {
|
||||
return fmt.Errorf("invalid configuration: logging.retention_log.max_size: size out of range (current value: %d, expected: 1-1000 MB)", c.Logging.RetentionLog.MaxSize)
|
||||
}
|
||||
|
||||
// 中间件验证
|
||||
if c.Middleware.RateLimiter.Max <= 0 {
|
||||
|
||||
@@ -65,6 +65,12 @@ logging:
|
||||
max_backups: 3
|
||||
max_age: 7
|
||||
compress: true
|
||||
retention_log:
|
||||
filename: "logs/audit-retention.log"
|
||||
max_size: 100
|
||||
max_backups: 3
|
||||
max_age: 7
|
||||
compress: true
|
||||
|
||||
# 任务队列配置
|
||||
queue:
|
||||
@@ -137,7 +143,7 @@ polling_auto_trigger:
|
||||
worker:
|
||||
role: "all"
|
||||
instance_name: ""
|
||||
# 完整自然月灰度验收通过前必须保持关闭
|
||||
# 日留存只读演练验收通过前必须保持关闭
|
||||
audit_retention_cleanup_enabled: false
|
||||
|
||||
# 审批新旧入口切换配置
|
||||
|
||||
@@ -94,6 +94,11 @@ func bindEnvVariables(v *viper.Viper) {
|
||||
"logging.access_log.max_backups",
|
||||
"logging.access_log.max_age",
|
||||
"logging.access_log.compress",
|
||||
"logging.retention_log.filename",
|
||||
"logging.retention_log.max_size",
|
||||
"logging.retention_log.max_backups",
|
||||
"logging.retention_log.max_age",
|
||||
"logging.retention_log.compress",
|
||||
"queue.concurrency",
|
||||
"queue.retry_max",
|
||||
"queue.timeout",
|
||||
|
||||
@@ -5,10 +5,8 @@ const (
|
||||
TaskTypeAuditDailyArchive = "audit:daily:archive"
|
||||
// TaskTypeIntegrationDailyArchive 表示 Integration Log 每日冷归档任务。
|
||||
TaskTypeIntegrationDailyArchive = "integration:daily:archive"
|
||||
// TaskTypeIntegrationMonthlyFinalize 表示 Integration Log 月度最终版本复核任务。
|
||||
TaskTypeIntegrationMonthlyFinalize = "integration:monthly:finalize"
|
||||
// TaskTypeAuditMonthlyRetention 表示审计日志月度物理清理任务。
|
||||
TaskTypeAuditMonthlyRetention = "audit:monthly:retention"
|
||||
// TaskTypeAuditDailyRetention 表示审计日志日留存任务。
|
||||
TaskTypeAuditDailyRetention = "audit:daily:retention"
|
||||
|
||||
// AuditArchiveSource 表示 Audit Event 与 Event Resource 归档数据源。
|
||||
AuditArchiveSource = "audit"
|
||||
@@ -32,6 +30,6 @@ const (
|
||||
// ArchiveStatusFailed 表示归档任务执行失败并等待重试。
|
||||
ArchiveStatusFailed = "failed"
|
||||
|
||||
// AuditRetentionDeleteBatchSize 表示月度物理清理单批删除上限。
|
||||
// AuditRetentionDeleteBatchSize 表示日物理清理单批删除上限。
|
||||
AuditRetentionDeleteBatchSize = 1000
|
||||
)
|
||||
|
||||
@@ -298,7 +298,7 @@ func QueueForTaskType(taskType string) string {
|
||||
return QueueDataCleanup
|
||||
case TaskTypeDailyTrafficFlush:
|
||||
return QueueDailyTrafficFlush
|
||||
case TaskTypeAuditDailyArchive, TaskTypeIntegrationDailyArchive, TaskTypeIntegrationMonthlyFinalize, TaskTypeAuditMonthlyRetention:
|
||||
case TaskTypeAuditDailyArchive, TaskTypeIntegrationDailyArchive, TaskTypeAuditDailyRetention:
|
||||
return QueueDataCleanup
|
||||
case TaskTypeOutboxDeliver:
|
||||
return QueueOutboxDeliver
|
||||
|
||||
92
pkg/fuiou/scan.go
Normal file
92
pkg/fuiou/scan.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package fuiou
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PreCreate 主扫统一下单(C扫B,生成用户扫码支付的二维码)。
|
||||
// orderType: 订单类型,WECHAT=微信主扫、ALIPAY=支付宝主扫。
|
||||
// reservedExpireMinute: 订单有效期分钟(reserved 字段,不参与签名)。
|
||||
func (c *Client) PreCreate(orderNo, amount, goodsDesc, termIP, orderType, reservedExpireMinute string) (*PreCreateResponse, error) {
|
||||
req := &PreCreateRequest{
|
||||
Version: "1",
|
||||
InsCd: c.InsCd,
|
||||
MchntCd: c.MchntCd,
|
||||
TermId: c.TermId,
|
||||
RandomStr: generateRandomStr(),
|
||||
OrderType: orderType,
|
||||
MchntOrderNo: orderNo,
|
||||
OrderAmt: amount,
|
||||
GoodsDesc: goodsDesc,
|
||||
TermIp: termIP,
|
||||
TxnBeginTs: time.Now().Format("20060102150405"),
|
||||
NotifyUrl: c.NotifyURL,
|
||||
ReservedExpireMinute: reservedExpireMinute,
|
||||
}
|
||||
|
||||
sign, err := c.Sign(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("签名失败: %w", err)
|
||||
}
|
||||
req.Sign = sign
|
||||
|
||||
var resp PreCreateResponse
|
||||
if err := c.DoRequest("/preCreate", req, &resp); err != nil {
|
||||
return nil, fmt.Errorf("请求富友失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.ResultCode != "000000" {
|
||||
c.logger.Error("富友主扫下单失败",
|
||||
zap.String("order_no", orderNo),
|
||||
zap.String("result_code", resp.ResultCode),
|
||||
zap.String("result_msg", resp.ResultMsg),
|
||||
)
|
||||
return nil, fmt.Errorf("富友主扫下单失败: %s", resp.ResultMsg)
|
||||
}
|
||||
|
||||
c.logger.Info("富友主扫下单成功",
|
||||
zap.String("order_no", orderNo),
|
||||
zap.String("order_type", orderType),
|
||||
)
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CommonQuery 主动查询订单状态。
|
||||
// orderType: 订单类型,须与下单时一致。
|
||||
func (c *Client) CommonQuery(orderNo, orderType string) (*CommonQueryResponse, error) {
|
||||
req := &CommonQueryRequest{
|
||||
Version: "1",
|
||||
InsCd: c.InsCd,
|
||||
MchntCd: c.MchntCd,
|
||||
TermId: c.TermId,
|
||||
RandomStr: generateRandomStr(),
|
||||
OrderType: orderType,
|
||||
MchntOrderNo: orderNo,
|
||||
}
|
||||
|
||||
sign, err := c.Sign(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("签名失败: %w", err)
|
||||
}
|
||||
req.Sign = sign
|
||||
|
||||
var resp CommonQueryResponse
|
||||
if err := c.DoRequest("/commonQuery", req, &resp); err != nil {
|
||||
return nil, fmt.Errorf("请求富友失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.ResultCode != "000000" {
|
||||
c.logger.Error("富友订单查询失败",
|
||||
zap.String("order_no", orderNo),
|
||||
zap.String("result_code", resp.ResultCode),
|
||||
zap.String("result_msg", resp.ResultMsg),
|
||||
)
|
||||
return nil, fmt.Errorf("富友订单查询失败: %s", resp.ResultMsg)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
@@ -4,6 +4,12 @@ package fuiou
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
// 主扫统一下单与订单查询的订单类型常量
|
||||
const (
|
||||
OrderTypeWechat = "WECHAT" // 微信主扫
|
||||
OrderTypeAlipay = "ALIPAY" // 支付宝主扫
|
||||
)
|
||||
|
||||
// WxPreCreateRequest wxPreCreate 下单请求(3.3接口)
|
||||
// 所有非 reserved 字段必须出现在 XML 中(含空值),否则富友验签不通过
|
||||
type WxPreCreateRequest struct {
|
||||
@@ -77,3 +83,69 @@ type NotifyResponse struct {
|
||||
ResultCode string `xml:"result_code"` // 结果码
|
||||
ResultMsg string `xml:"result_msg"` // 结果消息
|
||||
}
|
||||
|
||||
// PreCreateRequest 主扫统一下单请求(C扫B)
|
||||
// 所有非 reserved 字段必须出现在 XML 中(含空值),否则富友验签不通过
|
||||
type PreCreateRequest struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Version string `xml:"version"` // 版本号: 1.0
|
||||
InsCd string `xml:"ins_cd"` // 机构号
|
||||
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||
TermId string `xml:"term_id"` // 终端号
|
||||
RandomStr string `xml:"random_str"` // 随机字符串
|
||||
Sign string `xml:"sign"` // 签名
|
||||
OrderType string `xml:"order_type"` // 订单类型: WECHAT=微信主扫
|
||||
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
|
||||
CurrType string `xml:"curr_type"` // 货币类型(可选)
|
||||
OrderAmt string `xml:"order_amt"` // 订单金额(分)
|
||||
TermIp string `xml:"term_ip"` // 终端IP
|
||||
TxnBeginTs string `xml:"txn_begin_ts"` // 交易起始时间,格式 yyyyMMddHHmmss
|
||||
NotifyUrl string `xml:"notify_url"` // 回调地址
|
||||
GoodsDesc string `xml:"goods_des"` // 商品描述
|
||||
GoodsDetail string `xml:"goods_detail"` // 商品详情(可选)
|
||||
GoodsTag string `xml:"goods_tag"` // 商品标记(可选,非 reserved,参与签名)
|
||||
AddnInf string `xml:"addn_inf"` // 附加数据(可选)
|
||||
ReservedExpireMinute string `xml:"reserved_expire_minute"` // 订单有效期分钟(reserved,不参与签名)
|
||||
}
|
||||
|
||||
// PreCreateResponse 主扫统一下单响应
|
||||
type PreCreateResponse struct {
|
||||
ResultCode string `xml:"result_code"` // 结果码: 000000=成功
|
||||
ResultMsg string `xml:"result_msg"` // 结果消息
|
||||
InsCd string `xml:"ins_cd"` // 机构号
|
||||
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||
RandomStr string `xml:"random_str"` // 随机字符串
|
||||
Sign string `xml:"sign"` // 签名
|
||||
QrCode string `xml:"qr_code"` // 二维码内容
|
||||
ReservedFyTraceNo string `xml:"reserved_fy_trace_no"` // 富友流水号
|
||||
}
|
||||
|
||||
// CommonQueryRequest 订单查询请求
|
||||
type CommonQueryRequest struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Version string `xml:"version"` // 版本号: 1.0
|
||||
InsCd string `xml:"ins_cd"` // 机构号
|
||||
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||
TermId string `xml:"term_id"` // 终端号
|
||||
RandomStr string `xml:"random_str"` // 随机字符串
|
||||
Sign string `xml:"sign"` // 签名
|
||||
OrderType string `xml:"order_type"` // 订单类型
|
||||
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
|
||||
}
|
||||
|
||||
// CommonQueryResponse 订单查询响应
|
||||
type CommonQueryResponse struct {
|
||||
ResultCode string `xml:"result_code"` // 结果码: 000000=成功
|
||||
ResultMsg string `xml:"result_msg"` // 结果消息
|
||||
InsCd string `xml:"ins_cd"` // 机构号
|
||||
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||
RandomStr string `xml:"random_str"` // 随机字符串
|
||||
Sign string `xml:"sign"` // 签名
|
||||
OrderType string `xml:"order_type"` // 订单类型
|
||||
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
|
||||
TransStat string `xml:"trans_stat"` // 交易状态
|
||||
OrderAmt string `xml:"order_amt"` // 订单金额(分)
|
||||
TransactionId string `xml:"transaction_id"` // 交易流水号
|
||||
ReservedTxnFinTs string `xml:"reserved_txn_fin_ts"` // 交易完成时间(reserved)
|
||||
ReservedFyTraceNo string `xml:"reserved_fy_trace_no"` // 富友流水号(reserved)
|
||||
}
|
||||
|
||||
17
pkg/logger/retention.go
Normal file
17
pkg/logger/retention.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// NewRetentionLogger 创建仅供 Worker 日留存使用的独立轮转日志。
|
||||
func NewRetentionLogger(level string, config LogRotationConfig) (*zap.Logger, func() error) {
|
||||
encoder := zapcore.NewJSONEncoder(zapcore.EncoderConfig{
|
||||
TimeKey: "time", LevelKey: "level", MessageKey: "msg", CallerKey: "caller",
|
||||
EncodeTime: zapcore.ISO8601TimeEncoder, EncodeLevel: zapcore.CapitalLevelEncoder,
|
||||
EncodeCaller: zapcore.ShortCallerEncoder,
|
||||
})
|
||||
logger := zap.New(zapcore.NewCore(encoder, zapcore.AddSync(newLumberjackLogger(config)), parseLevel(level)), zap.AddCaller())
|
||||
return logger, logger.Sync
|
||||
}
|
||||
@@ -14,8 +14,8 @@ done
|
||||
[[ -z "$(find "$HOME/.codex/agents" -maxdepth 1 -name 'gsd-*' -print 2>/dev/null)" ]]
|
||||
[[ -z "$(command -v omx 2>/dev/null || true)" ]]
|
||||
! grep -RIlE 'oh-my-codex|OMX:' AGENTS.md CLAUDE.md .agents .claude >/dev/null 2>&1
|
||||
[[ -z "$(git ls-files .lh-harness)" ]]
|
||||
[[ -z "$(find . -type f -name '*_test.go' -not -path './.git/*' -not -path './.lh-harness/*' -print)" ]]
|
||||
|
||||
[[ -z "$(find . -type f -name '*_test.go' -not -path './.git/*' -print)" ]]
|
||||
[[ ! -e tests ]]
|
||||
[[ ! -e internal/testutil ]]
|
||||
[[ ! -e scripts/benchmark ]]
|
||||
@@ -49,7 +49,7 @@ for spec in Path('openspec/specs').glob('*/spec.md'):
|
||||
spec_requirements.update(f'{spec.parent.name}::{name}' for name in names)
|
||||
indexed_routes.update(f'{method} {path}' for method,path in re.findall(r'`(GET|POST|PUT|PATCH|DELETE) ([^`]+)`', text))
|
||||
|
||||
evidence_dir=Path('.lh-harness/context-reset')
|
||||
evidence_dir=Path('docs/verification/context-reset')
|
||||
evidence=json.loads((evidence_dir/'requirement-evidence.json').read_text())
|
||||
matrix=json.loads((evidence_dir/'entry-capability-requirement-matrix.json').read_text())
|
||||
evidence_requirements={f"{row['capability']}::{row['requirement']}" for row in evidence}
|
||||
|
||||
Reference in New Issue
Block a user