diff --git a/cmd/api/docs.go b/cmd/api/docs.go index f2cd08a..ca1ff30 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -33,6 +33,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil) // 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil) + // 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 + handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil) handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil) handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index b0f3650..6b0f90e 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -42,6 +42,8 @@ func generateAdminDocs(outputPath string) error { handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil) // 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil) + // 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。 + handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil) handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil) handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 diff --git a/cmd/worker/main.go b/cmd/worker/main.go index bb2feb9..5a26612 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -16,6 +16,7 @@ import ( agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" + assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal" auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold" @@ -28,6 +29,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/gateway" approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval" + assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal" auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold" @@ -94,6 +96,8 @@ type workerRuntime struct { refundChannelService *refundchannelApp.Service // carrierThresholdService 是通道流量阈值停复机与两个计划任务的唯一用例实例。 carrierThresholdService *carrierThresholdApp.Service + // assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。 + assetAutoRenewalService *assetAutoRenewalApp.Service } func main() { @@ -167,6 +171,7 @@ func runWorker(cfg *config.Config) { registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger) registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger) registerCarrierThresholdTasks(taskHandler.GetMux(), runtime, appLogger) + registerAssetAutoRenewalTasks(taskHandler.GetMux(), runtime, appLogger) registerRefundCommissionRecoveryTask(taskHandler.GetMux(), runtime, appLogger) registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger) outboxHandler := outbox.NewHandler(runtime.outboxConsumers) @@ -342,12 +347,15 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L lifecycleSvc: lifecycleSvc, // 通道阈值停复机复用既有停复机服务作为唯一执行事实源(重试、Integration Log、统一审计与既有判定)。 carrierThresholdService: workerResult.Services.CarrierThreshold, + // 自动续费的复机执行同样复用既有停复机单一事实源。 + assetAutoRenewalService: workerResult.Services.AssetAutoRenewal, } registerNotificationOutboxConsumer(runtime, appLogger) registerWalletOutboxConsumer(runtime, appLogger) registerCardObservationOutboxConsumer(runtime, appLogger) registerPriorityPollingOutboxConsumer(runtime, appLogger) registerCarrierThresholdOutboxConsumer(runtime, appLogger) + registerAssetAutoRenewalOutboxConsumer(runtime, appLogger) registerWeComApprovalOutboxConsumer(runtime, cfg, appLogger) return runtime } @@ -387,6 +395,19 @@ func registerCarrierThresholdOutboxConsumer(runtime *workerRuntime, appLogger *z } } +// registerAssetAutoRenewalOutboxConsumer 注册资产钱包自动续费的复机请求事件消费者。 +// 消费者按尝试记录认领执行权后执行复机,重复投递不会产生第二次外部调用。 +func registerAssetAutoRenewalOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { + if runtime == nil || runtime.assetAutoRenewalService == nil { + appLogger.Fatal("资产钱包自动续费用例未配置") + } + consumer := assetAutoRenewalApp.NewResumeConsumer(runtime.assetAutoRenewalService) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAssetAutoRenewalResumeRequested, consumer); err != nil { + appLogger.Fatal("注册资产钱包自动续费 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeAssetAutoRenewalResumeRequested), zap.Error(err)) + } +} + // registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。 func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) { auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer) @@ -588,6 +609,20 @@ func registerCarrierThresholdTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger.Info("注册运营商通道流量阈值结果恢复任务处理器", zap.String("task_type", constants.TaskTypeCarrierThresholdRecovery)) } +// registerAssetAutoRenewalTasks 注册资产钱包自动续费的每日扫描与复机结果恢复任务。 +// 两个任务共用同一用例实例:每日扫描负责终态收敛与续购执行,恢复扫描只查询状态回填,绝不重复发起复机。 +func registerAssetAutoRenewalTasks(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) { + if runtime == nil || runtime.assetAutoRenewalService == nil { + appLogger.Fatal("资产钱包自动续费用例未配置") + } + scanHandler := assetAutoRenewalInfra.NewDailyScanTaskHandler(runtime.assetAutoRenewalService) + mux.HandleFunc(constants.TaskTypeAssetAutoRenewalScan, scanHandler.Handle) + appLogger.Info("注册资产钱包自动续费每日扫描任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalScan)) + recoveryHandler := assetAutoRenewalInfra.NewRecoveryTaskHandler(runtime.assetAutoRenewalService) + mux.HandleFunc(constants.TaskTypeAssetAutoRenewalRecovery, recoveryHandler.Handle) + appLogger.Info("注册资产钱包自动续费复机结果恢复任务处理器", zap.String("task_type", constants.TaskTypeAssetAutoRenewalRecovery)) +} + // registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。 func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface) @@ -948,6 +983,25 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna )); err != nil { return fmt.Errorf("注册每日套餐真流量达量预警扫描定时任务失败: %w", err) } + if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 7 * * *", asynq.NewTask( + constants.TaskTypeAssetAutoRenewalScan, + nil, + asynq.MaxRetry(3), + asynq.Timeout(30*time.Minute), + asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalScan)), + )); err != nil { + return fmt.Errorf("注册每日资产钱包自动续费扫描定时任务失败: %w", err) + } + if _, err := asynqScheduler.Register("@every 5m", asynq.NewTask( + constants.TaskTypeAssetAutoRenewalRecovery, + nil, + asynq.MaxRetry(3), + asynq.Timeout(10*time.Minute), + asynq.Unique(5*time.Minute), + asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetAutoRenewalRecovery)), + )); err != nil { + return fmt.Errorf("注册资产钱包自动续费复机结果恢复定时任务失败: %w", err) + } if _, err := asynqScheduler.Register( "0 2 * * *", asynq.NewTask( diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 9e600b7..01780ba 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -4437,5 +4437,39 @@ "priority-polling-queue::优先轮询事实与查询的可追溯" ], "classification": "behavior" + }, + { + "entry_type": "async", + "entry": "constants.OutboxEventTypeAssetAutoRenewalResumeRequested", + "capability": "asset-auto-renewal", + "requirements": [ + "asset-auto-renewal::成功后的可靠复机" + ], + "classification": "behavior" + }, + { + "entry_type": "async", + "entry": "constants.TaskTypeAssetAutoRenewalScan", + "capability": "asset-auto-renewal", + "requirements": [ + "asset-auto-renewal::自动续费配置与权限", + "asset-auto-renewal::每日扫描与续购资格", + "asset-auto-renewal::每日一次尝试与尝试记录终态收敛", + "asset-auto-renewal::资金、价格与单事务闭合", + "asset-auto-renewal::失败通知与接收人", + "asset-auto-renewal::尝试记录与可追溯字段", + "asset-auto-renewal::手动续购优先", + "asset-auto-renewal::不得因钱包余额跳过停机判定" + ], + "classification": "behavior" + }, + { + "entry_type": "async", + "entry": "constants.TaskTypeAssetAutoRenewalRecovery", + "capability": "asset-auto-renewal", + "requirements": [ + "asset-auto-renewal::成功后的可靠复机" + ], + "classification": "behavior" } ] diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index 123112a..288064d 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -4498,5 +4498,337 @@ ], "exit_status": 0 } + }, + { + "capability": "asset-auto-renewal", + "requirement": "自动续费配置与权限", + "spec": "openspec/specs/asset-auto-renewal/spec.md", + "entries": [ + "constants.TaskTypeAssetAutoRenewalScan", + "constants.TaskTypeAssetAutoRenewalRecovery", + "constants.OutboxEventTypeAssetAutoRenewalResumeRequested" + ], + "handler_consumer_job": [ + "internal/handler/admin/asset_auto_renewal.go", + "internal/routes/asset_auto_renewal.go" + ], + "application_service_query": [ + "internal/application/assetautorenewal/config.go", + "internal/application/assetautorenewal/scan.go" + ], + "domain_state_amount": [ + "pkg/constants/asset_auto_renewal.go", + "internal/model/asset_auto_renewal.go" + ], + "store_migration_config": [ + "migrations/000229_add_asset_auto_renewal_config.up.sql", + "internal/store/postgres/asset_auto_renewal_store.go" + ], + "verification": { + "command": "代码中检索配置路由的超管/平台门禁与总开关消费,并核对隔离库 junhong_cmp_aug26010_verify 场景 7.2 (证据全文 /tmp/aug26-010-verify/evidence/7.2.txt);静态门禁本轮全绿:go build ./cmd/api ./cmd/worker、go vet ./internal/... ./pkg/... ./cmd/...、go run cmd/gendocs/main.go、openspec validate --all(35 passed/0 failed)、openspec doctor --json(healthy=true)均 exit 0", + "literal_output": [ + "internal/routes/asset_auto_renewal.go:19:\tif userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {", + "internal/routes/asset_auto_renewal.go:20:\t\treturn errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)", + "internal/routes/asset_auto_renewal.go:16:func registerAssetAutoRenewalRoutes(router fiber.Router, handler *admin.AssetAutoRenewalConfigHandler, doc *openapi.Generator, basePath string) {", + "隔离库场景 7.2(/tmp/aug26-010-verify/evidence/7.2.txt):总开关关闭后 RunDailyScan 返回 Candidates=0/Attempted=0,尝试行数 0、已支付订单 0、钱包 9900001 balance=100000 version=0;既有 status=2 成功尝试行保留未被改写", + "隔离库场景 7.4 轮次 B(/tmp/aug26-010-verify/evidence/7.4.txt):窗口改为 90 天后同一资产重新进入执行,尝试行 config_version=1/window_days=90 快照为该次触发时的配置值,已产生尝试记录不重算" + ], + "exit_status": 0 + } + }, + { + "capability": "asset-auto-renewal", + "requirement": "每日扫描与续购资格", + "spec": "openspec/specs/asset-auto-renewal/spec.md", + "entries": [ + "constants.TaskTypeAssetAutoRenewalScan", + "constants.TaskTypeAssetAutoRenewalRecovery", + "constants.OutboxEventTypeAssetAutoRenewalResumeRequested" + ], + "handler_consumer_job": [ + "internal/infrastructure/assetautorenewal/task.go", + "internal/bootstrap/worker_services.go" + ], + "application_service_query": [ + "internal/application/assetautorenewal/scan.go", + "internal/query/assetautorenewal/query.go", + "internal/query/packageexpiry/query.go" + ], + "domain_state_amount": [ + "pkg/constants/package_export.go", + "pkg/constants/asset_auto_renewal.go" + ], + "store_migration_config": [ + "internal/store/postgres/asset_auto_renewal_store.go" + ], + "verification": { + "command": "代码中检索窗口闭区间、明确推算与独立卡口径,并核对隔离库场景 7.3(证据全文 /tmp/aug26-010-verify/evidence/7.3.txt);静态门禁本轮全绿:go build ./cmd/api ./cmd/worker、go vet ./internal/... ./pkg/... ./cmd/...、go run cmd/gendocs/main.go、openspec validate --all(35 passed/0 failed)、openspec doctor --json(healthy=true)均 exit 0", + "literal_output": [ + "internal/query/assetautorenewal/query.go:391:\tif estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {", + "internal/query/assetautorenewal/query.go:398:\tif days < 0 || days > windowDays {", + "internal/query/assetautorenewal/query.go:239:\t\tWhere(\"is_standalone = ?\", true).", + "隔离库场景 7.3(/tmp/aug26-010-verify/evidence/7.3.txt)六轮:剩余天数 0 与等于配置天数 15 进入执行(status=2),16 与 -1 时 Candidates=0 且尝试行 0、无订单;上海今天 23:59:59 与明天 00:00:01 分别判定为 0 与 1", + "隔离库场景 7.7 轮次 C2/C3(/tmp/aug26-010-verify/evidence/7.7.txt):当前主套餐不在可购买范围或资产未关联套餐系列时记 not_renewable,不建订单不扣款", + "隔离库场景 7.5 各轮 Candidates=1(/tmp/aug26-010-verify/evidence/7.5.txt)而绑定设备的卡 9900002 始终未被列为候选,与个人客户购买入口对绑定卡的拒绝口径一致" + ], + "exit_status": 0 + } + }, + { + "capability": "asset-auto-renewal", + "requirement": "每日一次尝试与尝试记录终态收敛", + "spec": "openspec/specs/asset-auto-renewal/spec.md", + "entries": [ + "constants.TaskTypeAssetAutoRenewalScan", + "constants.TaskTypeAssetAutoRenewalRecovery", + "constants.OutboxEventTypeAssetAutoRenewalResumeRequested" + ], + "handler_consumer_job": [ + "internal/application/assetautorenewal/scan.go" + ], + "application_service_query": [ + "internal/application/assetautorenewal/scan.go", + "internal/store/postgres/asset_auto_renewal_store.go" + ], + "domain_state_amount": [ + "pkg/constants/asset_auto_renewal.go" + ], + "store_migration_config": [ + "migrations/000230_add_asset_auto_renewal_attempt.up.sql", + "internal/store/postgres/asset_auto_renewal_store.go" + ], + "verification": { + "command": "代码中检索部分唯一索引与 23505 精确识别,并核对隔离库场景 7.5 与 7.13(证据全文 /tmp/aug26-010-verify/evidence/7.5.txt、/tmp/aug26-010-verify/evidence/7.13.txt);静态门禁本轮全绿:go build ./cmd/api ./cmd/worker、go vet ./internal/... ./pkg/... ./cmd/...、go run cmd/gendocs/main.go、openspec validate --all(35 passed/0 failed)、openspec doctor --json(healthy=true)均 exit 0", + "literal_output": [ + "internal/store/postgres/asset_auto_renewal_store.go:20:const autoRenewalAttemptConstraint = \"uq_asset_auto_renewal_attempt_key\"", + "migrations/000230_add_asset_auto_renewal_attempt.up.sql:111:CREATE UNIQUE INDEX uq_asset_auto_renewal_attempt_key", + "internal/store/postgres/asset_auto_renewal_store.go:19:// 因此占位写入使用显式插入 + 23505 识别。", + "隔离库场景 7.5(/tmp/aug26-010-verify/evidence/7.5.txt)四轮:预置当日 status=1/2/3/4 后 RunDailyScan 均返回 Duplicated=1,当日仍 1 行、无第二笔订单与扣款", + "隔离库场景 7.13(/tmp/aug26-010-verify/evidence/7.13.txt):trigger_date=上海昨天的 status=1 收敛为 status=3/failure_reason=interrupted 且不发通知;同时存在今天 status=1 时只收敛昨天一行" + ], + "exit_status": 0 + } + }, + { + "capability": "asset-auto-renewal", + "requirement": "资金、价格与单事务闭合", + "spec": "openspec/specs/asset-auto-renewal/spec.md", + "entries": [ + "constants.TaskTypeAssetAutoRenewalScan", + "constants.TaskTypeAssetAutoRenewalRecovery", + "constants.OutboxEventTypeAssetAutoRenewalResumeRequested" + ], + "handler_consumer_job": [ + "internal/infrastructure/assetautorenewal/task.go" + ], + "application_service_query": [ + "internal/application/assetautorenewal/renew.go", + "internal/service/purchase_validation/service.go" + ], + "domain_state_amount": [ + "internal/store/postgres/asset_wallet_store.go", + "internal/model/asset_wallet.go", + "pkg/constants/wallet.go" + ], + "store_migration_config": [ + "internal/store/postgres/asset_wallet_transaction_store.go", + "internal/store/postgres/asset_auto_renewal_store.go" + ], + "verification": { + "command": "代码中检索单事务闭合与可用余额扣款,并核对隔离库场景 7.6、7.7 与 7.10(证据全文 /tmp/aug26-010-verify/evidence/7.10.txt、/tmp/aug26-010-verify/evidence/7.6.txt、/tmp/aug26-010-verify/evidence/7.7.txt);静态门禁本轮全绿:go build ./cmd/api ./cmd/worker、go vet ./internal/... ./pkg/... ./cmd/...、go run cmd/gendocs/main.go、openspec validate --all(35 passed/0 failed)、openspec doctor --json(healthy=true)均 exit 0", + "literal_output": [ + "internal/application/assetautorenewal/renew.go:74:\terr = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {", + "internal/application/assetautorenewal/renew.go:195:\tif wallet.GetAvailableBalance() < price {", + "internal/store/postgres/asset_wallet_store.go:78:func (s *AssetWalletStore) DeductBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64, version int) error", + "internal/model/asset_wallet.go:32:// GetAvailableBalance 获取可用余额 = balance - frozen_balance", + "隔离库场景 7.10(/tmp/aug26-010-verify/evidence/7.10.txt):尝试行 renew_price=deduct_amount=3000、balance 100000→97000;订单 payment_status=2/total_amount=3000、明细 unit_price=3000、支付 status=2/amount=3000、流水 transaction_type=deduct/amount=-3000/reference_no=order_no 与套餐使用记录同事务提交后同时可见;无关钱包 9900003 保持 100000/version 0", + "隔离库场景 7.6 与 7.7(/tmp/aug26-010-verify/evidence/7.6.txt、/tmp/aug26-010-verify/evidence/7.7.txt):余额不足 2000<3000、冻结占用 5000/3000、套餐不可续费四类来源均不建订单不扣款,钱包 version 保持 0;冻结轮 balance 保持 5000 不侵占冻结额" + ], + "exit_status": 0 + } + }, + { + "capability": "asset-auto-renewal", + "requirement": "失败通知与接收人", + "spec": "openspec/specs/asset-auto-renewal/spec.md", + "entries": [ + "constants.TaskTypeAssetAutoRenewalScan", + "constants.TaskTypeAssetAutoRenewalRecovery", + "constants.OutboxEventTypeAssetAutoRenewalResumeRequested" + ], + "handler_consumer_job": [ + "internal/infrastructure/notification/registry.go", + "internal/infrastructure/notification/delivery.go" + ], + "application_service_query": [ + "internal/application/assetautorenewal/notify.go", + "internal/application/notification/direct.go", + "internal/application/notification/read.go" + ], + "domain_state_amount": [ + "pkg/constants/asset_auto_renewal.go", + "pkg/constants/notification.go" + ], + "store_migration_config": [ + "internal/infrastructure/messaging/outbox", + "internal/query/notification/query.go" + ], + "verification": { + "command": "代码中检索通知类型注册与可通知原因白名单,并核对隔离库场景 7.6 的通知落地(证据全文 /tmp/aug26-010-verify/evidence/7.6.txt);静态门禁本轮全绿:go build ./cmd/api ./cmd/worker、go vet ./internal/... ./pkg/... ./cmd/...、go run cmd/gendocs/main.go、openspec validate --all(35 passed/0 failed)、openspec doctor --json(healthy=true)均 exit 0", + "literal_output": [ + "internal/infrastructure/notification/registry.go:162:\t\tconstants.NotificationTypeAssetAutoRenewalFailed: {", + "internal/infrastructure/notification/registry.go:163:\t\t\tType: constants.NotificationTypeAssetAutoRenewalFailed, Category: constants.NotificationCategoryExpiry,", + "internal/application/assetautorenewal/notify.go:43:\tif !constants.IsAssetAutoRenewalNotifiableFailureReason(request.Reason) {", + "隔离库场景 7.6(/tmp/aug26-010-verify/evidence/7.6.txt):可用余额 2000<3000 记 insufficient_balance 后落地 3 条通知 —— personal_customer/9900001 与 account/9900001(店铺业务员)、account/9900002(店铺账号),均 type=asset.auto_renewal.failed、category=expiry、severity=warning、ref_type=iot_card、ref_id=9900001 且 expires_at 非空;当日重复扫描仍为 3 条", + "隔离库场景 7.13(/tmp/aug26-010-verify/evidence/7.13.txt):收敛产生的 interrupted 不属于通知口径,收敛前后通知数为 0" + ], + "exit_status": 0 + } + }, + { + "capability": "asset-auto-renewal", + "requirement": "成功后的可靠复机", + "spec": "openspec/specs/asset-auto-renewal/spec.md", + "entries": [ + "constants.TaskTypeAssetAutoRenewalScan", + "constants.TaskTypeAssetAutoRenewalRecovery", + "constants.OutboxEventTypeAssetAutoRenewalResumeRequested" + ], + "handler_consumer_job": [ + "internal/infrastructure/assetautorenewal/task.go", + "internal/infrastructure/assetautorenewal/commander.go", + "cmd/worker/main.go" + ], + "application_service_query": [ + "internal/application/assetautorenewal/recovery.go", + "internal/application/assetautorenewal/event.go", + "internal/application/assetautorenewal/renew.go" + ], + "domain_state_amount": [ + "internal/service/iot_card/auto_renewal.go", + "internal/service/iot_card/stop_resume_service.go", + "pkg/constants/asset_auto_renewal.go" + ], + "store_migration_config": [ + "migrations/000230_add_asset_auto_renewal_attempt.up.sql", + "internal/store/postgres/asset_auto_renewal_store.go" + ], + "verification": { + "command": "代码中检索复机 Outbox 事件、消费者与恢复扫描口径,并核对隔离库场景 7.11 与 7.12(含本机 stub 网关真实调用链)(证据全文 /tmp/aug26-010-verify/evidence/7.11.txt、/tmp/aug26-010-verify/evidence/7.12.txt);静态门禁本轮全绿:go build ./cmd/api ./cmd/worker、go vet ./internal/... ./pkg/... ./cmd/...、go run cmd/gendocs/main.go、openspec validate --all(35 passed/0 failed)、openspec doctor --json(healthy=true)均 exit 0", + "literal_output": [ + "internal/application/assetautorenewal/recovery.go:26:// RecoverResumeResults 扫描未收敛的复机子结果:只查询运营商状态回填,绝不重复发起复机调用。", + "internal/application/assetautorenewal/recovery.go:65:\tonline, known, integrationID, err := s.resume.QueryAutoRenewalResumeState(ctx, attempt.AssetType, attempt.AssetID)", + "internal/infrastructure/assetautorenewal/task.go:51:func NewRecoveryTaskHandler(service *assetAutoRenewalApp.Service) *RecoveryTaskHandler {", + "隔离库场景 7.11(/tmp/aug26-010-verify/evidence/7.11.txt):复机条件不成立时 resume_status=1(跳过)、无 asset_auto_renewal.resume.requested 事件、通知 0,订单/支付/流水事实完好", + "隔离库场景 7.12(/tmp/aug26-010-verify/evidence/7.12.txt):进程内 127.0.0.1 stub 网关按 Gateway 契约返回「正常」时 RecoveryResult Confirmed=1 且集成日志记 query_card_status/trigger_scene=asset_auto_renewal_resume_recovery/trigger_source=scheduler;网关不可达或返回「停机」时 Pending=1、Anomaly=1、resume_integration_id 非空、通知 0,恢复前后 resume 投递事件数不变" + ], + "exit_status": 0 + } + }, + { + "capability": "asset-auto-renewal", + "requirement": "尝试记录与可追溯字段", + "spec": "openspec/specs/asset-auto-renewal/spec.md", + "entries": [ + "constants.TaskTypeAssetAutoRenewalScan", + "constants.TaskTypeAssetAutoRenewalRecovery", + "constants.OutboxEventTypeAssetAutoRenewalResumeRequested" + ], + "handler_consumer_job": [ + "internal/application/assetautorenewal/scan.go" + ], + "application_service_query": [ + "internal/application/assetautorenewal/scan.go", + "internal/application/assetautorenewal/renew.go" + ], + "domain_state_amount": [ + "internal/model/asset_auto_renewal.go", + "pkg/constants/asset_auto_renewal.go" + ], + "store_migration_config": [ + "migrations/000230_add_asset_auto_renewal_attempt.up.sql" + ], + "verification": { + "command": "代码中检索尝试记录可追溯字段定义,并核对隔离库场景 7.10 与 7.5 的实际列取值(证据全文 /tmp/aug26-010-verify/evidence/7.10.txt、/tmp/aug26-010-verify/evidence/7.5.txt);静态门禁本轮全绿:go build ./cmd/api ./cmd/worker、go vet ./internal/... ./pkg/... ./cmd/...、go run cmd/gendocs/main.go、openspec validate --all(35 passed/0 failed)、openspec doctor --json(healthy=true)均 exit 0", + "literal_output": [ + "internal/model/asset_auto_renewal.go:65:\tRenewPrice int64 `gorm:\"column:renew_price;type:bigint;not null;default:0;comment:执行时当前可售续费价(分)\" json:\"renew_price\"`", + "internal/model/asset_auto_renewal.go:70:\tWalletTransactionID uint `gorm:\"column:wallet_transaction_id;type:bigint;not null;default:0;comment:资产钱包流水标识(tb_asset_wallet_transaction.id)\"`", + "internal/model/asset_auto_renewal.go:84:\tResumeIntegrationID string `gorm:\"column:resume_integration_id;type:varchar(64);not null;default:'';comment:复机外部交互标识(Integration Log 标识)\"`", + "internal/model/asset_auto_renewal.go:93:\tAttemptSeq int `gorm:\"column:attempt_seq;type:integer;not null;default:1;comment:跨日尝试次数\" json:\"attempt_seq\"`", + "隔离库场景 7.10(/tmp/aug26-010-verify/evidence/7.10.txt):成功尝试行含 renew_price=3000、deduct_amount=3000、wallet_id=9900001、wallet_transaction_id>0、balance_before=100000、balance_after=97000、order_id>0、order_no=ORD…、current_usage_id/current_package_id、customer_id=9900001、shop_id=9900001、config_version=1、window_days=15、attempt_seq", + "隔离库场景 7.5 与 7.8(/tmp/aug26-010-verify/evidence/7.5.txt、/tmp/aug26-010-verify/evidence/7.8.txt):跳过尝试行只含 skip_reason 且 order_id=0、order_no 为空、deduct_amount=0" + ], + "exit_status": 0 + } + }, + { + "capability": "asset-auto-renewal", + "requirement": "手动续购优先", + "spec": "openspec/specs/asset-auto-renewal/spec.md", + "entries": [ + "constants.TaskTypeAssetAutoRenewalScan", + "constants.TaskTypeAssetAutoRenewalRecovery", + "constants.OutboxEventTypeAssetAutoRenewalResumeRequested" + ], + "handler_consumer_job": [ + "internal/application/assetautorenewal/scan.go" + ], + "application_service_query": [ + "internal/application/assetautorenewal/renew.go", + "internal/query/assetautorenewal/query.go" + ], + "domain_state_amount": [ + "pkg/constants/asset_auto_renewal.go" + ], + "store_migration_config": [ + "internal/store/postgres/asset_auto_renewal_store.go", + "migrations/000230_add_asset_auto_renewal_attempt.up.sql" + ], + "verification": { + "command": "代码中检索跳过原因常量与锁后重读的资格不变式,并核对隔离库场景 7.8 与 7.9(证据全文 /tmp/aug26-010-verify/evidence/7.8.txt、/tmp/aug26-010-verify/evidence/7.9.txt);静态门禁本轮全绿:go build ./cmd/api ./cmd/worker、go vet ./internal/... ./pkg/... ./cmd/...、go run cmd/gendocs/main.go、openspec validate --all(35 passed/0 failed)、openspec doctor --json(healthy=true)均 exit 0", + "literal_output": [ + "pkg/constants/asset_auto_renewal.go:80:\tAssetAutoRenewalSkipManualRenewed = \"manual_renewed\"", + "pkg/constants/asset_auto_renewal.go:82:\tAssetAutoRenewalSkipManualOrderPending = \"manual_order_pending\"", + "internal/application/assetautorenewal/renew.go:// 1. 先判资格不变式——已存在待生效主套餐即「人工已完成续购 / 不叠加周期」,无论最终到期被推到多远", + "隔离库场景 7.8(/tmp/aug26-010-verify/evidence/7.8.txt):存在待生效主套餐时 status=4/skip_reason=manual_renewed/failure_detail=锁后重读发现该资产已存在待生效主套餐,paid_orders=0、deduct_amount=0、待生效仍 1 条;含占位写入后原子出现待生效的并发形态轮次", + "隔离库场景 7.9(/tmp/aug26-010-verify/evidence/7.9.txt):未关闭待支付个人资产钱包主套餐订单在场时 status=4/skip_reason=manual_order_pending、通知 0、订单保持 payment_status=1;关闭订单后同一资产转为 status=2 成功", + "隔离库场景 7.14(/tmp/aug26-010-verify/evidence/7.14.txt):3 个独立进程并发触发同一资产仅 1 笔已支付续购(paid_orders=1、flow_rows=1、balance 97000、version=1)" + ], + "exit_status": 0 + } + }, + { + "capability": "asset-auto-renewal", + "requirement": "不得因钱包余额跳过停机判定", + "spec": "openspec/specs/asset-auto-renewal/spec.md", + "entries": [ + "constants.TaskTypeAssetAutoRenewalScan", + "constants.TaskTypeAssetAutoRenewalRecovery", + "constants.OutboxEventTypeAssetAutoRenewalResumeRequested" + ], + "handler_consumer_job": [ + "internal/infrastructure/assetautorenewal/commander.go" + ], + "application_service_query": [ + "internal/service/iot_card/auto_renewal.go", + "internal/service/iot_card/stop_resume_service.go" + ], + "domain_state_amount": [ + "internal/domain/cardobservation/network.go", + "pkg/constants/iot.go" + ], + "store_migration_config": [ + "pkg/config/defaults/config.yaml" + ], + "verification": { + "command": "代码中检索复机判定仅只读复用既有停复机单一事实源且未新增运行期开关,并核对隔离库场景 7.11(证据全文 /tmp/aug26-010-verify/evidence/7.11.txt);静态门禁本轮全绿:go build ./cmd/api ./cmd/worker、go vet ./internal/... ./pkg/... ./cmd/...、go run cmd/gendocs/main.go、openspec validate --all(35 passed/0 failed)、openspec doctor --json(healthy=true)均 exit 0", + "literal_output": [ + "internal/service/iot_card/auto_renewal.go:38:func (s *StopResumeService) AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error) {", + "internal/service/iot_card/auto_renewal.go:98:// 判定不成立时返回 Applied=false 且不发起任何调用;判定成立但调用失败或结果未知时,续费事实不回滚,结果交可靠任务与恢复扫描收敛。", + "rg -c 'auto_renewal|AutoRenewal' pkg/config/defaults/config.yaml → 输出 0(无匹配,exit 1):未新增任何自动续费运行期开关", + "隔离库场景 7.11(/tmp/aug26-010-verify/evidence/7.11.txt):续购成功但新主套餐按既有规则为待生效时未发起复机(resume_status=1 跳过、无 resume 投递事件、通知 0),停机与复机判定未被本能力改动" + ], + "exit_status": 0 + } } ] diff --git a/internal/application/assetautorenewal/config.go b/internal/application/assetautorenewal/config.go new file mode 100644 index 0000000..afdbffa --- /dev/null +++ b/internal/application/assetautorenewal/config.go @@ -0,0 +1,226 @@ +package assetautorenewal + +import ( + "context" + "sort" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// ConfigView 是自动续费配置的读取视图。 +type ConfigView struct { + Enabled int `json:"enabled"` + Scope string `json:"scope"` + PackageIDs []uint `json:"package_ids"` + DaysBeforeExpiry int `json:"days_before_expiry"` + ConfigVersion int64 `json:"config_version"` + Updater uint `json:"updater"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ConfigRequest 是保存自动续费配置的请求。 +type ConfigRequest struct { + Enabled int `json:"enabled"` + Scope string `json:"scope"` + PackageIDs []uint `json:"package_ids"` + DaysBeforeExpiry int `json:"days_before_expiry"` +} + +// GetConfig 读取唯一的自动续费配置;仅超级管理员与平台账号可见。 +func (s *Service) GetConfig(ctx context.Context) (*ConfigView, error) { + if _, err := requirePlatformOperator(ctx); err != nil { + return nil, err + } + config, err := s.configStore.Get(ctx) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "自动续费配置不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败") + } + return toConfigView(config), nil +} + +// SaveConfig 保存自动续费配置:单行事务锁串行化、事务内自增配置版本,并与审计同事务写入。 +// +// 保存只影响后续扫描:已产生的尝试记录保留触发时的配置版本快照,不重算。 +func (s *Service) SaveConfig(ctx context.Context, request ConfigRequest) (*ConfigView, error) { + operatorID, err := requirePlatformOperator(ctx) + if err != nil { + return nil, err + } + packageIDs, err := normalizeConfigRequest(&request) + if err != nil { + return nil, err + } + if len(packageIDs) > 0 { + if err := s.validateSellableMainPackages(ctx, packageIDs); err != nil { + return nil, err + } + } + if s.auditWriter == nil { + return nil, errors.New(errors.CodeInvalidStatus, "自动续费配置审计接缝未配置") + } + saved := &model.AssetAutoRenewalConfig{} + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + current, lockErr := s.configStore.LockInTx(ctx, tx) + if lockErr != nil { + if lockErr == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "自动续费配置不存在") + } + return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定自动续费配置失败") + } + before := configSnapshot(current) + saved.Enabled = request.Enabled + saved.Scope = request.Scope + saved.PackageIDs = model.UintJSONBArray(packageIDs) + saved.DaysBeforeExpiry = request.DaysBeforeExpiry + saved.ConfigVersion = current.ConfigVersion + 1 + saved.Creator = current.Creator + saved.Updater = operatorID + if saveErr := s.configStore.SaveInTx(ctx, tx, saved, operatorID); saveErr != nil { + return errors.Wrap(errors.CodeDatabaseError, saveErr, "保存自动续费配置失败") + } + if auditErr := s.auditWriter.WriteAssetAutoRenewalConfigChange(ctx, tx, audit.AssetAutoRenewalConfigAudit{ + OperatorID: operatorID, + OperationType: constants.AuditOperationAssetAutoRenewalConfigUpdate, + Description: "保存资产钱包自动续费配置", + BeforeData: before, + AfterData: configSnapshot(saved), + RequestID: derefString(middleware.GetRequestIDFromContext(ctx)), + CorrelationID: derefString(middleware.GetRequestIDFromContext(ctx)), + }); auditErr != nil { + return auditErr + } + return nil + }) + if err != nil { + return nil, err + } + view := &ConfigView{ + Enabled: saved.Enabled, Scope: saved.Scope, PackageIDs: packageIDs, + DaysBeforeExpiry: saved.DaysBeforeExpiry, ConfigVersion: saved.ConfigVersion, + Updater: saved.Updater, UpdatedAt: s.now(), + } + s.logger.Info("资产钱包自动续费配置已保存", + zap.Int("enabled", view.Enabled), zap.String("scope", view.Scope), + zap.Int("days_before_expiry", view.DaysBeforeExpiry), zap.Int64("config_version", view.ConfigVersion)) + return view, nil +} + +// derefString 安全解引用可空字符串,供审计上下文可选字段复用。 +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} + +// requirePlatformOperator 复核调用者仅限超级管理员与平台账号,并返回其账号 ID。 +// +// 路由组已做粗粒度门禁,这里在业务边界再复核一次账号类型(ENG-AUTHZ-001): +// 代理、企业与个人客户一律按「无权限或不存在」统一拒绝,不形成可枚举差异。 +func requirePlatformOperator(ctx context.Context) (uint, error) { + userType := middleware.GetUserTypeFromContext(ctx) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) + } + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) + } + return operatorID, nil +} + +// normalizeConfigRequest 归一化并校验保存请求,返回去重升序的指定套餐集合。 +func normalizeConfigRequest(request *ConfigRequest) ([]uint, error) { + if request.Enabled != constants.AssetAutoRenewalConfigEnabledOff && + request.Enabled != constants.AssetAutoRenewalConfigEnabledOn { + return nil, errors.New(errors.CodeInvalidParam, "自动续费总开关取值非法") + } + if request.Scope != constants.AssetAutoRenewalScopeAll && request.Scope != constants.AssetAutoRenewalScopeSpecified { + return nil, errors.New(errors.CodeInvalidParam, "自动续费适用范围取值非法") + } + if request.DaysBeforeExpiry < constants.AssetAutoRenewalMinDaysBeforeExpiry || + request.DaysBeforeExpiry > constants.AssetAutoRenewalMaxDaysBeforeExpiry { + return nil, errors.New(errors.CodeInvalidParam, "自动续费到期前天数必须在 1 至 90 之间") + } + seen := make(map[uint]struct{}, len(request.PackageIDs)) + packageIDs := make([]uint, 0, len(request.PackageIDs)) + for _, packageID := range request.PackageIDs { + if packageID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "自动续费指定套餐包含无效 ID") + } + if _, exists := seen[packageID]; exists { + continue + } + seen[packageID] = struct{}{} + packageIDs = append(packageIDs, packageID) + } + sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] }) + if request.Scope == constants.AssetAutoRenewalScopeSpecified && len(packageIDs) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "指定范围必须至少选择一个主套餐") + } + if request.Scope == constants.AssetAutoRenewalScopeAll { + packageIDs = nil + } + return packageIDs, nil +} + +// validateSellableMainPackages 校验指定集合只能选择当前可售主套餐。 +// +// 可售口径与购买校验的平台分支一致:套餐为正式套餐、全局启用且上架。 +// 运行时不因后来下架而拒绝(交由续费豁免判定),因此下架只在此处拦截配置保存。 +func (s *Service) validateSellableMainPackages(ctx context.Context, packageIDs []uint) error { + packages, err := s.loadPackagesByIDs(ctx, packageIDs) + if err != nil { + return err + } + for _, packageID := range packageIDs { + pkg, exists := packages[packageID] + if !exists { + return errors.New(errors.CodeInvalidParam, "指定套餐不存在") + } + if pkg.PackageType != constants.PackageTypeFormal { + return errors.New(errors.CodeInvalidParam, "指定范围只能选择主套餐") + } + if pkg.Status != constants.StatusEnabled { + return errors.New(errors.CodeInvalidParam, "指定套餐已禁用") + } + if pkg.ShelfStatus != constants.ShelfStatusOn { + return errors.New(errors.CodeInvalidParam, "指定套餐已下架") + } + } + return nil +} + +// configSnapshot 生成配置前后值快照,字段口径固定,便于审计比对。 +func configSnapshot(config *model.AssetAutoRenewalConfig) map[string]any { + return map[string]any{ + "enabled": config.Enabled, + "scope": config.Scope, + "package_ids": []uint(config.PackageIDs), + "days_before_expiry": config.DaysBeforeExpiry, + "config_version": config.ConfigVersion, + } +} + +func toConfigView(config *model.AssetAutoRenewalConfig) *ConfigView { + return &ConfigView{ + Enabled: config.Enabled, + Scope: config.Scope, + PackageIDs: []uint(config.PackageIDs), + DaysBeforeExpiry: config.DaysBeforeExpiry, + ConfigVersion: config.ConfigVersion, + Updater: config.Updater, + UpdatedAt: config.UpdatedAt, + } +} diff --git a/internal/application/assetautorenewal/event.go b/internal/application/assetautorenewal/event.go new file mode 100644 index 0000000..78a167c --- /dev/null +++ b/internal/application/assetautorenewal/event.go @@ -0,0 +1,222 @@ +package assetautorenewal + +import ( + "context" + "strconv" + + "github.com/bytedance/sonic" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/outboxid" +) + +// assetAutoRenewalResumePayloadVersion 是自动续费复机事件的载荷版本。 +const assetAutoRenewalResumePayloadVersion = 1 + +// resumePayload 是自动续费复机事件的载荷,只携带尝试与资产标识,消费者按尝试 ID 认领执行权。 +type resumePayload struct { + AttemptID uint `json:"attempt_id"` + AssetType string `json:"asset_type"` + AssetID uint `json:"asset_id"` +} + +// AppendResumeRequested 在续费事务内幂等写入复机事件。 +// +// 事件 ID 由尝试记录 ID 派生:续费成功与复机状态同事务写入,重复投递不会创建第二个事件 +// (ENG-OUTBOX-001)。调用方必须已确认可复机条件成立,本函数不做条件判定。 +func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, attempt *model.AssetAutoRenewalAttempt) error { + if repository == nil { + return gorm.ErrInvalidDB + } + if attempt == nil || attempt.ID == 0 { + return gorm.ErrInvalidData + } + value := strconv.FormatUint(uint64(attempt.ID), 10) + _, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: outboxid.Stable(constants.OutboxEventTypeAssetAutoRenewalResumeRequested+":", value), + EventType: constants.OutboxEventTypeAssetAutoRenewalResumeRequested, + PayloadVersion: assetAutoRenewalResumePayloadVersion, + AggregateType: "asset_auto_renewal_attempt", + AggregateID: value, + ResourceType: attempt.AssetType, + ResourceID: strconv.FormatUint(uint64(attempt.AssetID), 10), + BusinessKey: constants.OutboxEventTypeAssetAutoRenewalResumeRequested + ":" + value, + Payload: resumePayload{ + AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID, + }, + }) + return err +} + +// ResumeConsumer 把自动续费复机事件转成一次复机动作。 +type ResumeConsumer struct { + service *Service +} + +// NewResumeConsumer 创建自动续费复机事件消费者。 +func NewResumeConsumer(service *Service) *ResumeConsumer { + return &ResumeConsumer{service: service} +} + +// Consume 按尝试记录认领执行权后执行复机;重复投递由认领字段兜住,不会产生第二次外部调用。 +func (c *ResumeConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error { + if c == nil || c.service == nil { + return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行能力未配置") + } + if envelope.EventType != constants.OutboxEventTypeAssetAutoRenewalResumeRequested { + return outbox.Permanent(gorm.ErrInvalidData) + } + if envelope.PayloadVersion != assetAutoRenewalResumePayloadVersion { + return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷版本不受支持")) + } + var payload resumePayload + if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil { + return outbox.Permanent(errors.Wrap(errors.CodeInvalidParam, err, "自动续费复机事件载荷格式错误")) + } + if payload.AttemptID == 0 { + return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷不完整")) + } + // 消费者不经过计划任务入口,必须自带操作者与来源,否则失败审计会因审计上下文缺失被拒(fail-closed)。 + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.OutboxEventTypeAssetAutoRenewalResumeRequested, + ActorName: "资产钱包自动续费复机结果消费者", Source: constants.AuditSourceWorker, + CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID, + }) + return c.service.ExecuteResume(ctx, payload.AttemptID) +} + +// ExecuteResume 认领并执行一次自动续费复机,回写尝试记录的复机状态、外部交互号与失败原因。 +// +// 「回写复机终态 + 投递失败通知 + 失败审计」在同一个短事务内闭合:任一失败整体回滚, +// 记录退回「已投递且已提交」,由恢复扫描按只读查询继续收敛,因此通知不会因一次写入抖动而永久丢失。 +// 复机失败或结果未知时绝不回滚续费事实:订单、套餐生效与钱包扣款保持已提交状态。 +func (s *Service) ExecuteResume(ctx context.Context, attemptID uint) error { + if s.resume == nil { + return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置") + } + attempt, err := s.attemptStore.Load(ctx, attemptID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费尝试记录失败") + } + if attempt.ResumeStatus != constants.AssetAutoRenewalResumeStatusRequested { + // 已收敛或未投递复机:重复投递与非复机尝试都按幂等结束。 + return nil + } + claimed, err := s.attemptStore.ClaimResumeSubmission(ctx, attemptID, s.now()) + if err != nil { + return err + } + if !claimed { + // 认领已被占用:可能是并发重复投递,也可能是上次「已调用但未回写」的进程中断。 + // 两种情况都不得再次调用运营商,留给恢复扫描按只读查询收敛。 + s.logger.Info("自动续费复机已被并发执行,跳过重复调用", zap.Uint("attempt_id", attemptID)) + return nil + } + outcome, resumeErr := s.resume.ResumeAssetForAutoRenewal(ctx, attempt.AssetType, attempt.AssetID) + status := constants.AssetAutoRenewalResumeStatusUnknown + reason := outcome.SafeReason + switch { + case !outcome.Applied: + // 判定在执行时已不成立:按跳过记录,不通知,也不改写任何续费事实。 + status = constants.AssetAutoRenewalResumeStatusSkipped + reason = "" + case outcome.Result == constants.AuditResultSuccess: + status = constants.AssetAutoRenewalResumeStatusSucceeded + reason = "" + case outcome.Result == constants.AuditResultFailed: + status = constants.AssetAutoRenewalResumeStatusFailed + reason = resumeFailureDetail(outcome.SafeReason) + default: + status = constants.AssetAutoRenewalResumeStatusUnknown + reason = resumeFailureDetail(outcome.SafeReason) + } + if err := s.finalizeResumeOutcome(ctx, attempt, status, reason, outcome.IntegrationID); err != nil { + return err + } + if resumeErr != nil { + s.logger.Warn("自动续费复机执行未确认完成", + zap.Uint("attempt_id", attemptID), zap.String("result", outcome.Result), zap.Error(resumeErr)) + } + return nil +} + +// finalizeResumeOutcome 在同一短事务内回写复机终态;确认失败时同事务投递通知并写失败审计。 +func (s *Service) finalizeResumeOutcome(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, status int, reason, integrationID string) error { + expected := []int{constants.AssetAutoRenewalResumeStatusRequested} + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + updated, err := s.attemptStore.MarkResumeOutcomeInTx(ctx, tx, attempt.ID, expected, status, integrationID, reason) + if err != nil { + return err + } + if !updated { + // 已被并发收敛:不重复投递通知与审计。 + s.logger.Info("自动续费复机结果已被并发收敛,跳过通知与审计", zap.Uint("attempt_id", attempt.ID)) + return nil + } + if status != constants.AssetAutoRenewalResumeStatusFailed { + return nil + } + if err := s.appendFailureNotifications(ctx, tx, failureNotification{ + AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID, + Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID), + ShopID: attempt.ShopID, CustomerID: attempt.CustomerID, + TriggerDate: attempt.TriggerDate, Reason: constants.AssetAutoRenewalFailureResumeFailed, + PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt, + }); err != nil { + return err + } + return s.appendResumeFailureAudit(ctx, tx, attempt, reason) + }) +} + +// appendResumeFailureAudit 在复机失败终态事务内写统一审计,主资源为本次尝试记录。 +func (s *Service) appendResumeFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason string) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置") + } + attemptID := strconv.FormatUint(uint64(attempt.ID), 10) + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费复机失败", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed, + ErrorSummary: reason, + CorrelationID: attemptID, + Metadata: map[string]any{ + "asset_type": attempt.AssetType, "asset_id": attempt.AssetID, + "trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), + "failure_kind": constants.AssetAutoRenewalFailureResumeFailed, + }, + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID, + DisplayName: "自动续费尝试 " + attemptID, + Relation: constants.AuditResourceRelationPrimary, + Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget, + IdentitySnapshot: map[string]any{ + "id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID, + "trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), + "resume_status": constants.AssetAutoRenewalResumeStatusFailed, + "failure_reason": constants.AssetAutoRenewalFailureResumeFailed, + }, + BeforeData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusRequested}, + AfterData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusFailed}, + SubjectVisibility: constants.AuditSubjectInternalOnly, + }}, + }) +} + +// resumeFailureDetail 组装可安全展示的复机失败原因,不写渠道报文原文。 +func resumeFailureDetail(safeReason string) string { + if safeReason == "" { + return "复机结果确认为失败" + } + return safeReason +} diff --git a/internal/application/assetautorenewal/notify.go b/internal/application/assetautorenewal/notify.go new file mode 100644 index 0000000..9f26256 --- /dev/null +++ b/internal/application/assetautorenewal/notify.go @@ -0,0 +1,136 @@ +package assetautorenewal + +import ( + "context" + "fmt" + "strconv" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/outboxid" +) + +// failureNotification 是一条失败通知事件所需冻结的事实。 +type failureNotification struct { + AttemptID uint + AssetType string + AssetID uint + Identifier string + ShopID *uint + CustomerID uint + TriggerDate time.Time + Reason string + PackageName string + FinalExpiresAt *time.Time +} + +// appendFailureNotifications 在调用方事务内为当前个人客户与资产所属店铺各写一条幂等通知事件。 +// +// 每日至多一条由上锁的两个条件推出:同一资产同一自然日至多一次尝试,且幂等键内嵌资产类型与资产 ID、 +// 上海自然日、原因类型与接收人。资产所属店铺当时无有效业务员时不阻断:店铺接收人由既有店铺解析 +// 在投递期完成,解析为空列表即正常结束,不影响续费事实与尝试记录;资产无店铺归属时只创建客户通知。 +// 非通知原因(如占位中断收敛)一律不投递,未登记原因按 fail-closed 处理。 +func (s *Service) appendFailureNotifications(ctx context.Context, tx *gorm.DB, request failureNotification) error { + if s.outbox == nil { + return errors.New(errors.CodeInvalidStatus, "自动续费通知 Outbox 未配置") + } + if !constants.IsAssetAutoRenewalNotifiableFailureReason(request.Reason) { + s.logger.Warn("自动续费失败原因不属于通知口径,已跳过通知投递", + zap.Uint("attempt_id", request.AttemptID), zap.String("reason", request.Reason)) + return nil + } + templateData := map[string]string{ + "asset_identifier": request.Identifier, + "package_name": request.PackageName, + "failure_reason": constants.GetAssetAutoRenewalFailureReasonName(request.Reason), + "expiry_date": formatShanghaiDate(request.FinalExpiresAt, s.now()), + } + assetIDText := strconv.FormatUint(uint64(request.AssetID), 10) + // 资源引用按资产类型选择既有可跳转目标:卡用 iot_card 详情、设备用 device 详情 + // (两者都在 internal/query/notification/target.go 的目标定义里,idTarget + 可用性复核), + // 使店铺/业务员点开通知能进入对应资产详情,而不是落到无目标类型。 + refType := assetRefType(request.AssetType) + expiresAt := request.FinalExpiresAt + if expiresAt == nil { + fallback := s.now().UTC() + expiresAt = &fallback + } + if request.CustomerID > 0 { + eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "c", request.CustomerID) + _, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification, + PayloadVersion: constants.NotificationPayloadVersionV1, + AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10), + ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID, + Payload: notificationapp.PersonalCustomerDirectPayload{ + RecipientID: request.CustomerID, NotificationType: constants.NotificationTypeAssetAutoRenewalFailed, + TemplateData: templateData, RefType: refType, RefID: assetIDText, + ExpiresAt: expiresAt, + }, + }) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费客户通知事件失败") + } + } + if request.ShopID == nil || *request.ShopID == 0 { + return nil + } + eventID := failureEventID(request.AssetType, request.AssetID, request.TriggerDate, request.Reason, "shop", *request.ShopID) + _, err := s.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification, + PayloadVersion: constants.NotificationPayloadVersionV1, + AggregateType: "asset_auto_renewal_attempt", AggregateID: strconv.FormatUint(uint64(request.AttemptID), 10), + ResourceType: request.AssetType, ResourceID: assetIDText, BusinessKey: eventID, + Payload: notificationapp.AdminDynamicPayload{ + TargetKind: constants.NotificationTargetKindShop, TargetID: *request.ShopID, + NotificationType: constants.NotificationTypeAssetAutoRenewalFailed, + TemplateData: templateData, RefType: refType, RefID: assetIDText, + ExpiresAt: expiresAt, + }, + }) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入自动续费店铺通知事件失败") + } + return nil +} + +// assetRefType 把资产类型映射为可跳转的通知引用类型(卡片详情 / 设备详情)。 +func assetRefType(assetType string) string { + if assetType == constants.AssetWalletResourceTypeDevice { + return constants.NotificationRefTypeDevice + } + return constants.NotificationRefTypeIotCard +} + +// failureEventID 构造失败通知的稳定幂等键。 +// +// 键内嵌资产类型与资产 ID、上海自然日、原因类型与接收人;复机失败沿用该次尝试的日期键, +// 因此同一尝试只通知一次且不跨日新增。超长时由 outboxid.Stable 追加稳定摘要,仍保持唯一。 +func failureEventID(assetType string, assetID uint, triggerDate time.Time, reason, recipientKind string, recipientID uint) string { + dateKey := triggerDate.In(shanghaiLocation).Format("20060102") + return outboxid.Stable("aar:", fmt.Sprintf("%s:%d:%s:%s:%s:%d", + assetCode(assetType), assetID, dateKey, reason, recipientKind, recipientID)) +} + +// assetCode 把资产类型压缩为单字母代码,只为把幂等键长度压进 Outbox 预算。 +func assetCode(assetType string) string { + if assetType == constants.AssetWalletResourceTypeDevice { + return "d" + } + return "c" +} + +// formatShanghaiDate 把业务到期时间格式化为上海自然日文本,供通知模板与展示期使用。 +func formatShanghaiDate(value *time.Time, fallback time.Time) string { + target := fallback + if value != nil { + target = *value + } + return target.In(shanghaiLocation).Format("2006-01-02") +} diff --git a/internal/application/assetautorenewal/recovery.go b/internal/application/assetautorenewal/recovery.go new file mode 100644 index 0000000..fdc55cf --- /dev/null +++ b/internal/application/assetautorenewal/recovery.go @@ -0,0 +1,108 @@ +package assetautorenewal + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// RecoveryResult 是一次复机结果恢复扫描的可观察结果。 +// +// Scanned 为扫到的未收敛尝试数;Confirmed 为本次回填为已确认结果的尝试数; +// Pending 为结果仍未确认、等待下次扫描的尝试数;Anomaly 为超过查询窗口仍不可确认、 +// 本次标记转人工的尝试数。 +type RecoveryResult struct { + Scanned int + Confirmed int + Pending int + Anomaly int +} + +// RecoverResumeResults 扫描未收敛的复机子结果:只查询运营商状态回填,绝不重复发起复机调用。 +// +// 收敛口径与既有停复机恢复一致(internal/application/carrierthreshold/cycle.go:246-296): +// - 查询确认已复机 → 回填成功; +// - 「已知但未复机」或不可判定 → 仍算未确认,等到下一次扫描; +// - 自提交起超过查询窗口仍不可确认 → 标记异常并退出自动扫描转人工核对。 +// +// 恢复扫描**不**据此判定「复机失败」:续购后新主套餐多为待生效,卡在此期间本就可能仍处于停机, +// 把「未复机」当失败会发出误报通知。复机失败只由消费者在网关明确返回失败时确认(「仅确认失败才通知」)。 +// 单条失败不中断整批,但会作为首个错误返回,交既有任务重试。 +func (s *Service) RecoverResumeResults(ctx context.Context) (RecoveryResult, error) { + result := RecoveryResult{} + if s.resume == nil { + return result, errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置") + } + now := s.now() + attempts, err := s.attemptStore.ScanUnresolvedResumes(ctx, now, constants.AssetAutoRenewalRecoveryBatchSize) + if err != nil { + return result, err + } + result.Scanned = len(attempts) + var firstErr error + for index := range attempts { + if err := s.recoverResumeResult(ctx, &attempts[index], now, &result); err != nil { + s.logger.Warn("自动续费复机结果恢复单条失败", + zap.Uint("attempt_id", attempts[index].ID), zap.Error(err)) + if firstErr == nil { + firstErr = err + } + } + } + s.logger.Info("自动续费复机结果恢复扫描完成", + zap.Int("scanned", result.Scanned), zap.Int("confirmed", result.Confirmed), + zap.Int("pending", result.Pending), zap.Int("anomaly", result.Anomaly)) + return result, firstErr +} + +// recoverResumeResult 处理单条未收敛的复机子结果。 +func (s *Service) recoverResumeResult(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, now time.Time, result *RecoveryResult) error { + online, known, integrationID, err := s.resume.QueryAutoRenewalResumeState(ctx, attempt.AssetType, attempt.AssetID) + if err != nil || !known || !online { + // 查询失败、状态不可判定、或已知仍未复机:一律按「仍未确认」处理, + // 绝不误判为失败终态,也绝不据此发出失败通知。 + result.Pending++ + if !expiredResumeQueryWindow(attempt.ResumeSubmittedAt, now) { + return nil + } + marked, markErr := s.attemptStore.MarkResumeAnomaly(ctx, attempt.ID, + "复机结果超过确认窗口仍不可查,请人工核对") + if markErr != nil { + return markErr + } + if marked { + result.Anomaly++ + s.logger.Warn("自动续费复机结果超期不可确认,已标记异常转人工", + zap.Uint("attempt_id", attempt.ID), zap.String("asset_type", attempt.AssetType), + zap.Uint("asset_id", attempt.AssetID)) + } + return nil + } + expected := []int{ + constants.AssetAutoRenewalResumeStatusRequested, + constants.AssetAutoRenewalResumeStatusUnknown, + } + marked, markErr := s.attemptStore.MarkResumeOutcome(ctx, attempt.ID, expected, + constants.AssetAutoRenewalResumeStatusSucceeded, integrationID, "") + if markErr != nil { + return markErr + } + if marked { + result.Confirmed++ + } + return nil +} + +// expiredResumeQueryWindow 判断复机子任务自提交起是否已超过自动查询窗口。 +// 未提交(提交认领时刻为空)表示尚未发起复机,不算超期。 +func expiredResumeQueryWindow(submittedAt *time.Time, now time.Time) bool { + if submittedAt == nil { + return false + } + return now.Sub(*submittedAt) >= constants.AssetAutoRenewalResumeQueryWindow +} diff --git a/internal/application/assetautorenewal/renew.go b/internal/application/assetautorenewal/renew.go new file mode 100644 index 0000000..d76792a --- /dev/null +++ b/internal/application/assetautorenewal/renew.go @@ -0,0 +1,809 @@ +package assetautorenewal + +import ( + "context" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery" + "github.com/break/junhong_cmp_fiber/internal/model" + assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal" + packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" + "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/outboxid" +) + +// renewalHalt 是执行事务内重读资格事实后必须终止本次尝试的可安全记录原因。 +// +// 它作为事务闭包的返回错误使已取得行锁的续费事务整体回滚(此时尚未写入任何资金与订单事实), +// 再由调用方在独立短事务中落终态、投递通知与写审计。 +// FailureReason 与 SkipReason 恰有一个非空:非空失败原因触发通知,跳过原因不触发通知。 +type renewalHalt struct { + FailureReason string + SkipReason string + Detail string +} + +// Error 实现 error,使事务闭包能把终止信号回传给调用方。 +func (h *renewalHalt) Error() string { + if h.SkipReason != "" { + return "自动续费跳过:" + constants.GetAssetAutoRenewalSkipReasonName(h.SkipReason) + } + return "自动续费未执行:" + constants.GetAssetAutoRenewalFailureReasonName(h.FailureReason) +} + +// renewalFacts 是一次续费执行成功后用于运行日志的关键事实。 +type renewalFacts struct { + RenewPrice int64 + OrderID uint + OrderNo string +} + +// executeRenewal 在单个事务内闭合一次续购:先锁资产钱包行、后锁资产载体行,锁后重读全部资格事实, +// 再扣可用余额、建订单与明细、写已支付支付记录、写钱包流水、激活套餐、写佣金与观测 Outbox、 +// 更新尝试记录为成功并写成功审计。任一步失败整体回滚,不存在部分成功状态。 +// +// windowDays 是本次扫描使用的配置窗口,必须传入实际配置值:窗口是触发条件而不是资格不变式, +// 用常量上限会让「人工已把最终到期推远」被误判为失败。 +// +// 返回 halt 表示重读后应落失败或跳过终态(事务已回滚且未写入任何事实);返回 err 表示事务失败。 +func (s *Service) executeRenewal(ctx context.Context, candidate assetquery.Candidate, attempt *model.AssetAutoRenewalAttempt, windowDays int) (*renewalFacts, *renewalHalt, error) { + if s.outbox == nil { + return nil, nil, errors.New(errors.CodeInvalidStatus, "自动续费 Outbox 未配置") + } + wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, candidate.AssetType, candidate.AssetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, &renewalHalt{ + FailureReason: constants.AssetAutoRenewalFailureNotRenewable, + Detail: "资产钱包不存在,无法以可用余额续购", + }, nil + } + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "读取资产钱包失败") + } + facts := &renewalFacts{} + var halt *renewalHalt + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 锁序固定为「先资产钱包行、后资产载体行」,与人工路径的「先冻结钱包、后激活套餐」一致, + // 避免与人工事务的锁序反转形成死锁。 + lockedWallet, lockErr := s.assetWalletStore.LockByIDWithTx(ctx, tx, wallet.ID) + if lockErr != nil { + return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定资产钱包失败") + } + if carrierErr := s.lockCarrier(ctx, tx, candidate.AssetType, candidate.AssetID); carrierErr != nil { + return carrierErr + } + // 锁后重读:最终到期、当前主套餐、待生效主套餐、可售续费价与可用余额都以重读结果为准。 + execution, execErr := s.rereadUnderLock(ctx, tx, candidate, lockedWallet, windowDays) + if execErr != nil { + var halted *renewalHalt + if asRenewalHalt(execErr, &halted) { + halt = halted + return execErr + } + return execErr + } + if err := s.writeRenewalFacts(ctx, tx, execution, attempt, facts); err != nil { + return err + } + return nil + }) + if err != nil { + if halt != nil { + return nil, halt, nil + } + return nil, nil, err + } + return facts, nil, nil +} + +// executionPlan 是锁后重读得到的执行输入。 +type executionPlan struct { + candidate assetquery.Candidate + asset *assetSnapshot + wallet *model.AssetWallet + pkg *model.Package + sellerShop *uint + price int64 + costPrice int64 +} + +// assetSnapshot 是执行事务内锁定的资产事实。 +type assetSnapshot struct { + assetType string + assetID uint + identifier string + shopID *uint + seriesID *uint + generation int +} + +// rereadUnderLock 在行锁内重读全部资格事实,并给出可执行或必须终止的判断。 +// +// 判定顺序体现「资格不变式先于触发条件」: +// 1. 先判资格不变式——已存在待生效主套餐即「人工已完成续购 / 不叠加周期」,无论最终到期被推到多远 +// 都 MUST 跳过(规格 Requirement 8),绝不退化为「不可续费」失败与错误通知; +// 2. 再判窗口与推算(触发条件)——不在窗口或推算不再明确,才是「当前条件不允许自动续购」。 +// +// 之后依次判在途人工订单、钱包状态、可售续费价与可用余额。 +func (s *Service) rereadUnderLock(ctx context.Context, tx *gorm.DB, candidate assetquery.Candidate, wallet *model.AssetWallet, windowDays int) (*executionPlan, error) { + asset, err := s.lockAndSnapshotAsset(ctx, tx, candidate.AssetType, candidate.AssetID) + if err != nil { + return nil, err + } + inTxQuery := s.candidates.WithDB(tx) + // 第 1 步:资格不变式(先于窗口判定)。 + state, err := inTxQuery.MainUsageStateOf(ctx, candidate.AssetType, candidate.AssetID) + if err != nil { + return nil, err + } + if state.HasPendingMainPackage { + return nil, &renewalHalt{ + SkipReason: constants.AssetAutoRenewalSkipManualRenewed, + Detail: "锁后重读发现该资产已存在待生效主套餐", + } + } + if state.CurrentPackageID == 0 { + return nil, &renewalHalt{ + FailureReason: constants.AssetAutoRenewalFailureNotRenewable, + Detail: "锁后重读未找到当前主套餐商品", + } + } + // 第 2 步:触发条件(窗口与推算口径),窗口取本次扫描的配置值。 + current, err := inTxQuery.Candidate(ctx, candidate.AssetType, candidate.AssetID, windowDays) + if err != nil { + return nil, err + } + if current == nil { + return nil, &renewalHalt{ + FailureReason: constants.AssetAutoRenewalFailureNotRenewable, + Detail: "锁后重读最终到期已不在触发窗口或推算结果不再明确", + } + } + inFlight, err := inTxQuery.OpenManualMainPackageOrder(ctx, wallet.ID, candidate.AssetType, candidate.AssetID) + if err != nil { + return nil, err + } + if inFlight { + return nil, &renewalHalt{ + SkipReason: constants.AssetAutoRenewalSkipManualOrderPending, + Detail: "锁后重读发现该资产存在未关闭的个人资产钱包主套餐订单", + } + } + if wallet.Status != constants.AssetWalletStatusNormal { + return nil, &renewalHalt{ + FailureReason: constants.AssetAutoRenewalFailureNotRenewable, + Detail: "资产钱包当前不可用于扣款", + } + } + price, pkg, sellerShop, costPrice, err := s.resolveExecutablePrice(ctx, candidate.AssetType, candidate.AssetID, current.CurrentPackageID) + if err != nil { + var halted *renewalHalt + if asRenewalHalt(err, &halted) { + return nil, halted + } + return nil, err + } + if wallet.GetAvailableBalance() < price { + return nil, &renewalHalt{ + FailureReason: constants.AssetAutoRenewalFailureInsufficientBalance, + Detail: "资产钱包可用余额小于执行时当前可售续费价", + } + } + return &executionPlan{ + candidate: *current, asset: asset, wallet: wallet, pkg: pkg, + sellerShop: sellerShop, price: price, costPrice: costPrice, + }, nil +} + +// resolveExecutablePrice 复用应用层购买校验与价格策略取得可续费判定与执行时续费价。 +// +// 校验入口是个人卡/设备购买校验(含续费豁免下架与生效零售价、成本价比较), +// 绝不依赖 handler 层续费价实现;任何校验失败都归一为「不可续费」并保留可安全记录的说明。 +func (s *Service) resolveExecutablePrice(ctx context.Context, assetType string, assetID, renewPackageID uint) (int64, *model.Package, *uint, int64, error) { + if s.purchaseValidation == nil { + return 0, nil, nil, 0, errors.New(errors.CodeServiceUnavailable, "购买校验能力未配置") + } + packageIDs := []uint{renewPackageID} + var result *purchase_validation.PurchaseValidationResult + var err error + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + result, err = s.purchaseValidation.ValidatePersonalCardPurchase(ctx, assetID, packageIDs) + case constants.AssetWalletResourceTypeDevice: + result, err = s.purchaseValidation.ValidatePersonalDevicePurchase(ctx, assetID, packageIDs) + default: + return 0, nil, nil, 0, errors.New(errors.CodeInvalidParam, "资产类型无效") + } + if err != nil { + return 0, nil, nil, 0, &renewalHalt{ + FailureReason: constants.AssetAutoRenewalFailureNotRenewable, + Detail: "当前条件不允许自动续购:" + purchaseValidationReason(err), + } + } + if len(result.Packages) == 0 { + return 0, nil, nil, 0, &renewalHalt{ + FailureReason: constants.AssetAutoRenewalFailureNotRenewable, + Detail: "当前条件不允许自动续购:未解析到可售续费套餐", + } + } + var sellerShop uint + if result.Card != nil && result.Card.ShopID != nil { + sellerShop = *result.Card.ShopID + } + if result.Device != nil && result.Device.ShopID != nil { + sellerShop = *result.Device.ShopID + } + costPrice := int64(0) + if sellerShop > 0 { + resolved, costErr := s.purchaseValidation.GetCostPrice(ctx, result.Packages[0], sellerShop) + if costErr != nil { + return 0, nil, nil, 0, &renewalHalt{ + FailureReason: constants.AssetAutoRenewalFailureNotRenewable, + Detail: "当前条件不允许自动续购:渠道成本价不可读", + } + } + costPrice = resolved + } + if result.TotalPrice <= 0 { + return 0, nil, nil, 0, &renewalHalt{ + FailureReason: constants.AssetAutoRenewalFailureNotRenewable, + Detail: "当前条件不允许自动续购:生效续费价异常", + } + } + var sellerShopPtr *uint + if sellerShop > 0 { + sellerShopPtr = &sellerShop + } + return result.TotalPrice, result.Packages[0], sellerShopPtr, costPrice, nil +} + +// writeRenewalFacts 在同一事务内闭合扣款、订单、支付、钱包流水、套餐生效、可靠事件、尝试记录与审计。 +func (s *Service) writeRenewalFacts(ctx context.Context, tx *gorm.DB, plan *executionPlan, attempt *model.AssetAutoRenewalAttempt, facts *renewalFacts) error { + now := s.now() + wallet := plan.wallet + if err := s.assetWalletStore.DeductBalanceWithTx(ctx, tx, wallet.ID, plan.price, wallet.Version); err != nil { + return errors.Wrap(errors.CodeConflict, err, "资产钱包扣款失败") + } + order, item, err := s.buildRenewalOrder(ctx, tx, plan, now) + if err != nil { + return err + } + if err := tx.WithContext(ctx).Create(order).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单失败") + } + item.OrderID = order.ID + if err := tx.WithContext(ctx).Create(item).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入续费订单明细失败") + } + payment := &model.Payment{ + PaymentNo: order.OrderNo, + OrderID: order.ID, + OrderType: model.PaymentOrderTypePackage, + PaymentMethod: model.PaymentByWallet, + Amount: plan.price, + Status: model.PaymentRecordStatusPaid, + } + if err := tx.WithContext(ctx).Create(payment).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入续费支付记录失败") + } + referenceType := constants.ReferenceTypeOrder + walletTransaction := &model.AssetWalletTransaction{ + AssetWalletID: wallet.ID, + ResourceType: wallet.ResourceType, + ResourceID: wallet.ResourceID, + UserID: plan.candidate.CustomerID, + TransactionType: constants.AssetTransactionTypeDeduct, + Amount: -plan.price, + BalanceBefore: wallet.Balance, + BalanceAfter: wallet.Balance - plan.price, + Status: constants.TransactionStatusSuccess, + ReferenceType: &referenceType, + ReferenceNo: &order.OrderNo, + Creator: plan.candidate.CustomerID, + ShopIDTag: wallet.ShopIDTag, + EnterpriseIDTag: wallet.EnterpriseIDTag, + } + if err := s.walletTransactionStore.CreateWithTx(ctx, tx, walletTransaction); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入续费钱包流水失败") + } + usage, err := s.activateMainPackage(ctx, tx, order, plan, now) + if err != nil { + return err + } + if err := commissiondelivery.AppendCommissionCalculate(ctx, tx, s.outbox, order.ID); err != nil { + return err + } + if s.observationEvents == nil { + return errors.New(errors.CodeInvalidStatus, "自动续费观测 Outbox 未配置") + } + observationID := "asset-auto-renewal:" + strconv.FormatUint(uint64(attempt.ID), 10) + if err := s.observationEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{ + EventID: outboxEventID(observationID), Scene: constants.CardObservationScenePackageChanged, + ResourceType: observationResourceType(plan.asset.assetType), ResourceID: plan.asset.assetID, + SyncTypes: []string{ + constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, + constants.CardObservationSyncTypeNetwork, + }, + Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now.UTC(), + RequestID: observationID, CorrelationID: observationID, + }); err != nil { + return err + } + updates := map[string]any{ + "status": constants.AssetAutoRenewalAttemptStatusSucceeded, + "failure_reason": "", + "failure_detail": "", + "skip_reason": "", + "final_expires_at": plan.candidate.FinalExpiresAt, + "current_usage_id": plan.candidate.CurrentUsageID, + "current_package_id": plan.candidate.CurrentPackageID, + "renew_package_id": plan.pkg.ID, + "renew_price": plan.price, + "wallet_id": wallet.ID, + "wallet_transaction_id": walletTransaction.ID, + "deduct_amount": plan.price, + "balance_before": wallet.Balance, + "balance_after": wallet.Balance - plan.price, + "order_id": order.ID, + "order_no": order.OrderNo, + } + // current_usage_id / current_package_id 保留**触发时**解析到的当前主套餐快照(规格 Requirement 7 + // 要求「触发时解析」),不覆盖为本次新生成的套餐使用记录;新记录通过 order_id / order_no 追溯, + // 「续购后处于待生效」也可由 usage_after_success 断言直接观察。 + resumeReady, resumeReason, err := s.evaluateResumeGate(ctx, plan) + if err != nil { + return err + } + if resumeReady { + updates["resume_status"] = constants.AssetAutoRenewalResumeStatusRequested + updates["resume_failure_reason"] = "" + } else { + updates["resume_status"] = constants.AssetAutoRenewalResumeStatusSkipped + updates["resume_failure_reason"] = resumeReason + } + updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, updates) + if err != nil { + return err + } + if !updated { + return errors.Wrap(errors.CodeConflict, gorm.ErrInvalidData, "续费尝试已非处理中,拒绝重复成功") + } + facts.RenewPrice = plan.price + facts.OrderID = order.ID + facts.OrderNo = order.OrderNo + if resumeReady { + attempt.ResumeStatus = constants.AssetAutoRenewalResumeStatusRequested + if err := AppendResumeRequested(ctx, tx, s.outbox, attempt); err != nil { + return err + } + } + return s.appendRenewalAudit(ctx, tx, plan, attempt, order, payment, wallet, walletTransaction, usage, updates) +} + +// evaluateResumeGate 在同一事务内按可复机判定给出复机去向。 +// +// 判定只做数据库读取、不持有任何外部 I/O,因此可以安全地留在资金事务闭包内(ENG-TX-001); +// 它也不对资产钱包行或载体行加锁,因此不会与已持有的行锁形成等待。 +func (s *Service) evaluateResumeGate(ctx context.Context, plan *executionPlan) (bool, string, error) { + if s.resume == nil { + return false, "", errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置") + } + ready, reason, err := s.resume.AutoRenewalResumeReady(ctx, plan.asset.assetType, plan.asset.assetID) + if err != nil { + return false, "", err + } + return ready, reason, nil +} + +// buildRenewalOrder 组装续购订单与唯一明细:买家恒为当前个人客户,金额为执行时当前可售续费价。 +func (s *Service) buildRenewalOrder(ctx context.Context, tx *gorm.DB, plan *executionPlan, now time.Time) (*model.Order, *model.OrderItem, error) { + orderType := model.OrderTypeSingleCard + var iotCardID, deviceID *uint + if plan.asset.assetType == constants.AssetWalletResourceTypeDevice { + orderType = model.OrderTypeDevice + deviceID = &plan.asset.assetID + } else { + iotCardID = &plan.asset.assetID + } + generation := plan.asset.generation + if generation <= 0 { + generation = 1 + } + paidAmount := plan.price + operatorAccountID, operatorAccountName := s.personalCustomerOperatorSnapshot(ctx, plan.candidate.CustomerID) + order := &model.Order{ + BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID}, + OrderNo: s.orderStore.GenerateOrderNo(), OrderType: orderType, + BuyerType: model.BuyerTypePersonal, BuyerID: plan.candidate.CustomerID, + IotCardID: iotCardID, DeviceID: deviceID, AssetIdentifier: plan.asset.identifier, + TotalAmount: plan.price, PaymentMethod: model.PaymentMethodWallet, + PaymentStatus: model.PaymentStatusPaid, PaidAt: &now, + CommissionStatus: model.CommissionStatusPending, CommissionConfigVersion: 0, + Source: constants.OrderSourceClient, Generation: generation, ActualPaidAmount: &paidAmount, + OperatorAccountID: operatorAccountID, OperatorAccountType: model.OperatorAccountTypePersonalCustomer, + OperatorAccountName: operatorAccountName, SellerShopID: plan.sellerShop, + SeriesID: plan.asset.seriesID, SellerCostPrice: plan.costPrice, + } + item := &model.OrderItem{ + BaseModel: model.BaseModel{Creator: plan.candidate.CustomerID, Updater: plan.candidate.CustomerID}, + PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, Quantity: 1, + UnitPrice: plan.price, Amount: plan.price, + PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift, + } + return order, item, nil +} + +// personalCustomerOperatorSnapshot 读取个人客户昵称作为订单操作者名称快照。 +func (s *Service) personalCustomerOperatorSnapshot(ctx context.Context, customerID uint) (*uint, string) { + if customerID == 0 { + return nil, "" + } + customer, err := s.personalCustomerStore.GetByID(ctx, customerID) + if err != nil { + return &customerID, "" + } + return &customerID, customer.Nickname +} + +// activateMainPackage 在同一事务内激活续购的主套餐:按既有排队规则决定待生效或立即生效。 +// +// 资格前置保证本次执行前不存在待生效主套餐,因此续购最多领先一个周期; +// 只有当当前主套餐在执行前刚好过期时新记录才立即生效,此时按既有规则追加套餐生效优先轮询请求。 +func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, plan *executionPlan, now time.Time) (*model.PackageUsage, error) { + terms, err := packagepkg.ResolveTermsFromTx(ctx, tx, plan.pkg, order.SellerShopID) + if err != nil { + return nil, err + } + hasCurrentMain, err := packagepkg.HasCurrentMainPackageForQueue(tx.WithContext(ctx), plan.asset.assetType, plan.asset.assetID, now) + if err != nil { + return nil, err + } + var status, priority int + var activatedAt, expiresAt time.Time + var nextResetAt *time.Time + pendingRealnameActivation := false + if terms.ExpiryBase == constants.PackageExpiryBaseFromActivation { + realnamed, realnameErr := s.isCarrierRealnamed(ctx, tx, plan.asset.assetType, plan.asset.assetID) + if realnameErr != nil { + return nil, realnameErr + } + pendingRealnameActivation = !realnamed + } + if hasCurrentMain { + status = constants.PackageUsageStatusPending + var maxPriority int + if err := tx.WithContext(ctx).Model(&model.PackageUsage{}). + Where(carrierColumn(plan.asset.assetType)+" = ?", plan.asset.assetID). + Select("COALESCE(MAX(priority), 0)").Scan(&maxPriority).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐排队优先级失败") + } + priority = maxPriority + 1 + } else { + priority = 1 + if pendingRealnameActivation { + status = constants.PackageUsageStatusPending + } else { + status = constants.PackageUsageStatusActive + activatedAt = now + expiresAt = packagepkg.CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays) + nextResetAt = packagepkg.CalculateNextResetTime(plan.pkg.DataResetCycle, terms.CalendarType, now, activatedAt) + } + } + virtualTotalMB, displayGainRatio, enableVirtualData := model.BuildPackageUsageSnapshotValues(plan.pkg) + retailAmount := order.TotalAmount + usage := &model.PackageUsage{ + BaseModel: model.BaseModel{Creator: order.Creator, Updater: order.Creator}, + OrderID: order.ID, OrderNo: order.OrderNo, + PackageID: plan.pkg.ID, PackageName: plan.pkg.PackageName, UsageType: order.OrderType, + DataLimitMB: plan.pkg.RealDataMB, + VirtualTotalMBSnapshot: virtualTotalMB, DisplayGainRatioSnapshot: displayGainRatio, + EnableVirtualDataSnapshot: enableVirtualData, Status: status, Priority: priority, + DataResetCycle: plan.pkg.DataResetCycle, PendingRealnameActivation: pendingRealnameActivation, + Generation: order.Generation, PaidAmount: &order.SellerCostPrice, RetailAmount: &retailAmount, + PackagePriceConfigStatus: plan.pkg.PriceConfigStatus, PackageIsGift: plan.pkg.IsGift, + } + terms.Apply(usage) + if plan.asset.assetType == constants.AssetWalletResourceTypeIotCard { + usage.IotCardID = plan.asset.assetID + } else { + usage.DeviceID = plan.asset.assetID + } + if status == constants.PackageUsageStatusActive { + usage.ActivatedAt = &activatedAt + usage.ExpiresAt = &expiresAt + usage.NextResetAt = nextResetAt + } + if err := tx.WithContext(ctx).Omit("status", "pending_realname_activation").Create(usage).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "写入续费套餐使用记录失败") + } + if err := tx.WithContext(ctx).Model(usage).Updates(map[string]any{ + "status": usage.Status, "pending_realname_activation": usage.PendingRealnameActivation, + }).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "写回续费套餐使用记录状态失败") + } + if status != constants.PackageUsageStatusActive { + return usage, nil + } + triggerType, err := packagepkg.ResolveActivationTriggerType(ctx, tx, plan.asset.assetType, plan.asset.assetID, usage.ID) + if err != nil { + return nil, err + } + if err := packagepkg.AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage, + plan.asset.assetType, plan.asset.assetID, triggerType, activatedAt); err != nil { + return nil, err + } + return usage, nil +} + +// isCarrierRealnamed 判断载体是否已满足实名激活条件,口径与既有自动购包一致。 +func (s *Service) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (bool, error) { + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + var card model.IotCard + if err := tx.WithContext(ctx).Select("real_name_status").First(&card, assetID).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "读取卡实名状态失败") + } + return card.RealNameStatus == constants.RealNameStatusVerified, nil + case constants.AssetWalletResourceTypeDevice: + var count int64 + subQuery := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Select("iot_card_id").Where("device_id = ? AND bind_status = ?", assetID, constants.BindStatusBound) + if err := tx.WithContext(ctx).Model(&model.IotCard{}). + Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified). + Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "统计设备实名卡失败") + } + return count > 0, nil + default: + return false, errors.New(errors.CodeInvalidParam, "资产类型无效") + } +} + +// lockCarrier 在事务内按资产类型对载体行加行锁,作为与人工路径共享的序列化点。 +func (s *Service) lockCarrier(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error { + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + var card model.IotCard + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&card, assetID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败") + } + return nil + case constants.AssetWalletResourceTypeDevice: + var device model.Device + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, assetID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产载体失败") + } + return nil + default: + return errors.New(errors.CodeInvalidParam, "资产类型无效") + } +} + +// lockAndSnapshotAsset 在已有行锁的事务内读取资产快照。 +func (s *Service) lockAndSnapshotAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (*assetSnapshot, error) { + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + var card model.IotCard + if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费卡事实失败") + } + if !card.IsStandalone { + return nil, &renewalHalt{ + FailureReason: constants.AssetAutoRenewalFailureNotRenewable, + Detail: "该卡已绑定设备,独立卡维度不执行自动续费", + } + } + return &assetSnapshot{ + assetType: constants.AssetWalletResourceTypeIotCard, assetID: card.ID, + identifier: card.ICCID, shopID: card.ShopID, seriesID: card.SeriesID, generation: card.Generation, + }, nil + case constants.AssetWalletResourceTypeDevice: + var device model.Device + if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取续费设备事实失败") + } + identifier := device.VirtualNo + if identifier == "" { + identifier = device.IMEI + } + return &assetSnapshot{ + assetType: constants.AssetWalletResourceTypeDevice, assetID: device.ID, + identifier: identifier, shopID: device.ShopID, seriesID: device.SeriesID, generation: device.Generation, + }, nil + default: + return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") + } +} + +// carrierColumn 返回套餐使用记录上的资产外键列名。 +func carrierColumn(assetType string) string { + if assetType == constants.AssetWalletResourceTypeDevice { + return "device_id" + } + return "iot_card_id" +} + +// observationResourceType 把资产类型映射为观测序列的资源类型。 +func observationResourceType(assetType string) string { + if assetType == constants.AssetWalletResourceTypeDevice { + return constants.CardObservationResourceTypeDevice + } + return constants.CardObservationResourceTypeCard +} + +// asRenewalHalt 从错误中取出终止信号,非终止信号返回 false。 +func asRenewalHalt(err error, target **renewalHalt) bool { + halt, ok := err.(*renewalHalt) + if !ok { + return false + } + *target = halt + return true +} + +// appendRenewalAudit 在续费事务内写成功审计,资源覆盖尝试记录、订单、钱包、流水与套餐使用记录。 +func (s *Service) appendRenewalAudit( + ctx context.Context, + tx *gorm.DB, + plan *executionPlan, + attempt *model.AssetAutoRenewalAttempt, + order *model.Order, + payment *model.Payment, + wallet *model.AssetWallet, + walletTransaction *model.AssetWalletTransaction, + usage *model.PackageUsage, + updates map[string]any, +) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置") + } + attemptID := strconv.FormatUint(uint64(attempt.ID), 10) + resources := []audit.ResourceInput{{ + Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID, + DisplayName: "自动续费尝试 " + attemptID, + Relation: constants.AuditResourceRelationPrimary, + Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget, + IdentitySnapshot: map[string]any{ + "id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID, + "trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), + "status": constants.AssetAutoRenewalAttemptStatusSucceeded, + "renew_package_id": plan.pkg.ID, "renew_price": plan.price, + "wallet_id": wallet.ID, "wallet_transaction_id": walletTransaction.ID, + "deduct_amount": plan.price, "balance_before": wallet.Balance, + "balance_after": wallet.Balance - plan.price, + "order_id": order.ID, "order_no": order.OrderNo, + "resume_status": updates["resume_status"], + }, + BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing}, + AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusSucceeded, "failure_reason": ""}, + SubjectVisibility: constants.AuditSubjectInternalOnly, + }} + orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRoleAssetAutoRenewalOrder) + resources = append(resources, orderResource) + walletID := strconv.FormatUint(uint64(wallet.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceAssetWallet, ID: &walletID, Key: walletID, DisplayName: "资产钱包 " + walletID, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAutoRenewalWallet, + IdentitySnapshot: map[string]any{ + "id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID, + }, + BeforeData: map[string]any{"balance": walletTransaction.BalanceBefore}, + AfterData: map[string]any{"balance": walletTransaction.BalanceAfter}, + }) + walletTxID := strconv.FormatUint(uint64(walletTransaction.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceAssetWalletTransaction, ID: &walletTxID, Key: walletTxID, + DisplayName: "资产钱包流水 " + walletTxID, + Relation: constants.AuditResourceRelationAffected, + Role: constants.AuditResourceRoleAssetAutoRenewalWalletTransaction, + IdentitySnapshot: map[string]any{ + "id": walletTransaction.ID, "asset_wallet_id": walletTransaction.AssetWalletID, + "resource_type": walletTransaction.ResourceType, "resource_id": walletTransaction.ResourceID, + "transaction_type": walletTransaction.TransactionType, + "reference_no": walletTransaction.ReferenceNo, "status": walletTransaction.Status, + }, + AfterData: map[string]any{ + "amount": walletTransaction.Amount, "balance_before": walletTransaction.BalanceBefore, + "balance_after": walletTransaction.BalanceAfter, + }, + }) + resources = append(resources, audit.PaymentResource(payment, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPayment, nil, nil)) + if usage != nil { + resources = append(resources, audit.PackageUsageResource(usage, + constants.AuditResourceRelationAffected, constants.AuditResourceRolePackageUsageTarget, nil, nil)) + } + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: constants.AuditActionAssetAutoRenewalRenewed, Summary: "资产钱包自动续费完成", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + CorrelationID: order.OrderNo, + Metadata: map[string]any{ + "asset_type": plan.asset.assetType, "asset_id": plan.asset.assetID, + "trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), + }, + Resources: resources, + }) +} + +// appendFailureAudit 在独立短事务内写失败审计;跳过终态不写审计,由尝试记录本身承载(Domain Ledger)。 +func (s *Service) appendFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置") + } + attemptID := strconv.FormatUint(uint64(attempt.ID), 10) + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费失败", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed, + ErrorCode: strconv.Itoa(reasonCode(reason)), ErrorSummary: detail, + CorrelationID: attemptID, + Metadata: map[string]any{ + "asset_type": attempt.AssetType, "asset_id": attempt.AssetID, + "trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), "failure_reason": reason, + }, + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID, + DisplayName: "自动续费尝试 " + attemptID, + Relation: constants.AuditResourceRelationPrimary, + Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget, + IdentitySnapshot: map[string]any{ + "id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID, + "trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()), + "status": constants.AssetAutoRenewalAttemptStatusFailed, + "failure_reason": reason, + }, + BeforeData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusProcessing}, + AfterData: map[string]any{"status": constants.AssetAutoRenewalAttemptStatusFailed, "failure_reason": reason}, + SubjectVisibility: constants.AuditSubjectInternalOnly, + }}, + }) +} + +// reasonCode 把失败归类映射为审计错误码位,便于按原因检索失败事件。 +func reasonCode(reason string) int { + switch reason { + case constants.AssetAutoRenewalFailureInsufficientBalance: + return 1 + case constants.AssetAutoRenewalFailureNotRenewable: + return 2 + case constants.AssetAutoRenewalFailureOrderFailed: + return 3 + default: + return 0 + } +} + +// purchaseValidationReason 从购买校验错误中提取可安全记录的说明,不写底层错误细节。 +func purchaseValidationReason(err error) string { + if err == nil { + return "" + } + message := err.Error() + switch { + case strings.Contains(message, "套餐已禁用"): + return "套餐商品被禁用" + case strings.Contains(message, "套餐已下架"): + return "当前渠道下架且不满足续费豁免" + case strings.Contains(message, "价格配置异常"): + return "生效零售价低于成本价" + case strings.Contains(message, "可购买范围"), strings.Contains(message, "未关联套餐系列"), + strings.Contains(message, "绑定设备"): + return "不在可购买范围或资产未关联套餐系列" + case strings.Contains(message, "赠送套餐"): + return "赠送套餐不参与自动续购" + default: + return "当前条件不允许自动续购" + } +} + +// outboxEventID 把观测事件标识裁剪进 Outbox 的事件 ID 长度预算。 +func outboxEventID(value string) string { + return outboxid.Stable("card-observation:", value) +} diff --git a/internal/application/assetautorenewal/scan.go b/internal/application/assetautorenewal/scan.go new file mode 100644 index 0000000..6f6e354 --- /dev/null +++ b/internal/application/assetautorenewal/scan.go @@ -0,0 +1,245 @@ +package assetautorenewal + +import ( + "context" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ScanResult 汇总一次每日扫描的可观察结果。 +type ScanResult struct { + Converged int64 + Candidates int + Attempted int + Succeeded int + Failed int + Skipped int + Duplicated int +} + +// RunDailyScan 执行一次每日自动续费扫描:先收敛历史非终态尝试,再按资产扫描候选并逐项执行。 +// +// 只有扫描级失败(配置读取、候选读取、数据库不可用)返回错误交既有任务重试;单个资产执行失败 +// 在用例内捕获并落终态后继续处理其余资产。总开关关闭时不创建任何新尝试与订单,既有记录保留。 +func (s *Service) RunDailyScan(ctx context.Context) (ScanResult, error) { + result := ScanResult{} + if s.db == nil { + return result, errors.New(errors.CodeServiceUnavailable, "自动续费用例未配置") + } + converged, err := s.attemptStore.ConvergeUnfinished(ctx, s.today()) + if err != nil { + return result, err + } + result.Converged = converged + if converged > 0 { + s.logger.Info("自动续费历史非终态尝试已收敛", zap.Int64("converged", converged)) + } + config, err := s.configStore.Get(ctx) + if err != nil { + if err == gorm.ErrRecordNotFound { + return result, errors.New(errors.CodeNotFound, "自动续费配置不存在") + } + return result, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败") + } + if config.Enabled != 1 { + s.logger.Info("自动续费总开关关闭,本次扫描不创建尝试与订单") + return result, nil + } + if config.Scope != constants.AssetAutoRenewalScopeAll && config.Scope != constants.AssetAutoRenewalScopeSpecified { + return result, errors.New(errors.CodeInvalidStatus, "自动续费配置范围取值非法") + } + candidates, err := s.candidates.Candidates(ctx, config.DaysBeforeExpiry) + if err != nil { + return result, err + } + result.Candidates = len(candidates) + scope := newScopeMatcher(config) + for _, candidate := range candidates { + if !scope.matches(candidate.CurrentPackageID) { + continue + } + outcome, processErr := s.processCandidate(ctx, candidate, config) + if processErr != nil { + // 单资产失败已落终态,继续处理其余资产;扫描任务本身不因该资产失败而失败。 + s.logger.Error("自动续费单资产执行失败,继续处理其余资产", + zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID), + zap.Error(processErr)) + result.Failed++ + continue + } + switch outcome { + case candidateSucceeded: + result.Attempted++ + result.Succeeded++ + case candidateFailed: + result.Attempted++ + result.Failed++ + case candidateSkipped: + result.Attempted++ + result.Skipped++ + default: + result.Duplicated++ + } + } + s.logger.Info("自动续费每日扫描完成", + zap.Int64("converged", result.Converged), zap.Int("candidates", result.Candidates), + zap.Int("succeeded", result.Succeeded), zap.Int("failed", result.Failed), + zap.Int("skipped", result.Skipped), zap.Int("duplicated", result.Duplicated)) + return result, nil +} + +// scanOutcome 是一次候选处理的终态归属,用于汇总扫描结果。 +type scanOutcome int + +const ( + candidateDuplicated scanOutcome = iota + candidateSucceeded + candidateFailed + candidateSkipped +) + +// scopeMatcher 表达配置的适用范围:全部主套餐,或指定主套餐集合。 +// +// 运行时只按当前主套餐商品是否在集合内判定,不因后来下架而拒绝——下架交给续费豁免判定。 +type scopeMatcher struct { + all bool + packageIDs map[uint]struct{} +} + +func newScopeMatcher(config *model.AssetAutoRenewalConfig) scopeMatcher { + if config.Scope == constants.AssetAutoRenewalScopeAll { + return scopeMatcher{all: true} + } + ids := make(map[uint]struct{}, len(config.PackageIDs)) + for _, packageID := range config.PackageIDs { + ids[packageID] = struct{}{} + } + return scopeMatcher{packageIDs: ids} +} + +func (m scopeMatcher) matches(packageID uint) bool { + if m.all { + return true + } + _, exists := m.packageIDs[packageID] + return exists +} + +// processCandidate 处理单个候选:占位写入、执行、落终态与通知。 +// +// 占位冲突表示该资产当日已尝试,直接跳过且不重复扣款;执行阶段的失败与跳过各以独立短事务落终态。 +func (s *Service) processCandidate(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig) (scanOutcome, error) { + triggerDate := s.today() + attempt, err := s.buildAttempt(ctx, candidate, config, triggerDate) + if err != nil { + return candidateFailed, err + } + created, err := s.attemptStore.CreatePlaceholder(ctx, attempt) + if err != nil { + return candidateFailed, err + } + if !created { + s.logger.Info("该资产当日已存在自动续费尝试,跳过", + zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID)) + return candidateDuplicated, nil + } + facts, halt, err := s.executeRenewal(ctx, candidate, attempt, config.DaysBeforeExpiry) + if halt != nil { + if halt.SkipReason != "" { + return candidateSkipped, s.finalizeSkip(ctx, attempt, halt) + } + return candidateFailed, s.finalizeFailure(ctx, attempt, halt.FailureReason, halt.Detail) + } + if err != nil { + s.logger.Error("自动续费事务失败并已整体回滚", + zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID), zap.Error(err)) + return candidateFailed, s.finalizeFailure(ctx, attempt, constants.AssetAutoRenewalFailureOrderFailed, + "续购事务执行失败并已整体回滚,未产生订单、扣款与套餐事实") + } + s.logger.Info("自动续费续购成功", + zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID), + zap.Uint("order_id", facts.OrderID), zap.String("order_no", facts.OrderNo), + zap.Int64("renew_price", facts.RenewPrice)) + return candidateSucceeded, nil +} + +// buildAttempt 组装占位尝试记录,冻结触发时的客户、店铺、配置窗口与套餐快照。 +func (s *Service) buildAttempt(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig, triggerDate time.Time) (*model.AssetAutoRenewalAttempt, error) { + sequence, err := s.attemptStore.CountByAsset(ctx, candidate.AssetType, candidate.AssetID, triggerDate) + if err != nil { + return nil, err + } + finalExpiresAt := candidate.FinalExpiresAt + attempt := &model.AssetAutoRenewalAttempt{ + AssetType: candidate.AssetType, AssetID: candidate.AssetID, TriggerDate: triggerDate, + Status: constants.AssetAutoRenewalAttemptStatusProcessing, + CustomerID: candidate.CustomerID, + ShopID: candidate.ShopID, + ConfigVersion: config.ConfigVersion, WindowDays: config.DaysBeforeExpiry, + FinalExpiresAt: &finalExpiresAt, + CurrentUsageID: candidate.CurrentUsageID, + CurrentPackageID: candidate.CurrentPackageID, + RenewPackageID: candidate.CurrentPackageID, + OperatorType: constants.AssetAutoRenewalOperatorTypeSystemTask, + OperatorID: constants.TaskTypeAssetAutoRenewalScan, + AttemptSeq: int(sequence) + 1, + } + return attempt, nil +} + +// finalizeSkip 以独立短事务落跳过终态:不扣款、不建订单、不发送通知。 +func (s *Service) finalizeSkip(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, halt *renewalHalt) error { + _, err := s.attemptStore.Finalize(ctx, attempt.ID, map[string]any{ + "status": constants.AssetAutoRenewalAttemptStatusSkipped, + "skip_reason": halt.SkipReason, + "failure_reason": "", + "failure_detail": halt.Detail, + }) + if err != nil { + return err + } + s.logger.Info("自动续费跳过该资产", + zap.String("asset_type", attempt.AssetType), zap.Uint("asset_id", attempt.AssetID), + zap.String("skip_reason", halt.SkipReason)) + return nil +} + +// finalizeFailure 以独立短事务落失败终态,并在同一事务内投递通知与写失败审计。 +// +// 该短事务与已回滚的续费事务不共用连接或事务(ENG-TX-001 例外),条件更新依据尝试记录仍非终态; +// 已被并发收敛时不再投递通知与审计。中断收敛(interrupted)不属于通知口径,不会被通知。 +func (s *Service) finalizeFailure(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, map[string]any{ + "status": constants.AssetAutoRenewalAttemptStatusFailed, + "failure_reason": reason, + "failure_detail": detail, + "skip_reason": "", + }) + if err != nil { + return err + } + if !updated { + s.logger.Info("自动续费尝试已被并发收敛,跳过通知与审计", + zap.Uint("attempt_id", attempt.ID)) + return nil + } + if notifyErr := s.appendFailureNotifications(ctx, tx, failureNotification{ + AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID, + Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID), + ShopID: attempt.ShopID, CustomerID: attempt.CustomerID, + TriggerDate: attempt.TriggerDate, Reason: reason, + PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt, + }); notifyErr != nil { + return notifyErr + } + return s.appendFailureAudit(ctx, tx, attempt, reason, detail) + }) +} diff --git a/internal/application/assetautorenewal/service.go b/internal/application/assetautorenewal/service.go new file mode 100644 index 0000000..66b0e63 --- /dev/null +++ b/internal/application/assetautorenewal/service.go @@ -0,0 +1,178 @@ +// Package assetautorenewal 编排资产钱包自动续费:受控配置维护、每日扫描与尝试、续费事务闭合、 +// 失败通知与复机可靠投递。 +// +// 本包不调用任何支付渠道或运营商接口:续购价格与可售判定复用应用层购买校验与价格策略, +// 复机执行通过 ResumeCommander 端口复用既有停复机单一事实源,通知与复机都通过公共 Outbox +// 在业务事务内写出事件(ENG-OUTBOX-001)。资金、订单、套餐与成功审计在同一事务内闭合(ENG-TX-001)。 +package assetautorenewal + +import ( + "context" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" + + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/internal/model" + assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal" + "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +// ResumeOutcome 是一次复机调用可安全记录的结果摘要。 +// +// Applied 为 false 表示本次未满足可复机条件、没有发起任何复机调用;Result 取值 +// constants.AuditResultSuccess / Failed / Unknown。 +type ResumeOutcome struct { + Applied bool + IntegrationID string + Result string + SafeReason string +} + +// ResumeCommander 是自动续费成功后复机的执行边界。 +// +// 实现必须复用既有停复机单一事实源(重试、Integration Log、统一审计、观测序列)与既有 +// 套餐/流量/实名/风险判定;本包绝不复制这些规则,也绝不直接调用运营商接口。 +type ResumeCommander interface { + // AutoRenewalResumeReady 判断该资产当前是否满足自动复机条件;不满足时返回可安全记录的原因。 + AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error) + // ResumeAssetForAutoRenewal 执行复机并返回结果分类。 + ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (ResumeOutcome, error) + // QueryAutoRenewalResumeState 只查询运营商状态回填复机结果,绝不重复发起复机。 + QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (online bool, known bool, integrationID string, err error) +} + +// Dependencies 汇总自动续费用例的装配依赖。 +// +// DB 与 Redis 用于构造本用例独占的资产钱包、流水、订单、套餐与资产 Store; +// 其余依赖是配置、价格、候选、复机与可靠事件的能力边界。 +type Dependencies struct { + DB *gorm.DB + Redis *redis.Client + Logger *zap.Logger + Outbox *outbox.Repository + AuditWriter *audit.Writer + PurchaseValidation *purchase_validation.Service + Candidates *assetquery.Query + Resume ResumeCommander + ObservationEvents cardObservationApp.SeriesEventWriter + PriorityEvents priorityapp.PriorityEventWriter +} + +// Service 执行资产钱包自动续费的配置维护、每日扫描、续费事务与终止态收敛。 +type Service struct { + db *gorm.DB + configStore *postgres.AssetAutoRenewalConfigStore + attemptStore *postgres.AssetAutoRenewalAttemptStore + assetWalletStore *postgres.AssetWalletStore + walletTransactionStore *postgres.AssetWalletTransactionStore + orderStore *postgres.OrderStore + packageUsageStore *postgres.PackageUsageStore + packageStore *postgres.PackageStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + personalCustomerStore *postgres.PersonalCustomerStore + candidates *assetquery.Query + purchaseValidation *purchase_validation.Service + outbox *outbox.Repository + auditWriter *audit.Writer + resume ResumeCommander + observationEvents cardObservationApp.SeriesEventWriter + priorityEvents priorityapp.PriorityEventWriter + logger *zap.Logger + now func() time.Time +} + +// NewService 创建资产钱包自动续费用例。 +func NewService(deps Dependencies) *Service { + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } + return &Service{ + db: deps.DB, + configStore: postgres.NewAssetAutoRenewalConfigStore(deps.DB), + attemptStore: postgres.NewAssetAutoRenewalAttemptStore(deps.DB), + assetWalletStore: postgres.NewAssetWalletStore(deps.DB, deps.Redis), + walletTransactionStore: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis), + orderStore: postgres.NewOrderStore(deps.DB, deps.Redis), + packageUsageStore: postgres.NewPackageUsageStore(deps.DB, deps.Redis), + packageStore: postgres.NewPackageStore(deps.DB), + iotCardStore: postgres.NewIotCardStore(deps.DB, deps.Redis), + deviceStore: postgres.NewDeviceStore(deps.DB, deps.Redis), + personalCustomerStore: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), + candidates: deps.Candidates, + purchaseValidation: deps.PurchaseValidation, + outbox: deps.Outbox, + auditWriter: deps.AuditWriter, + resume: deps.Resume, + observationEvents: deps.ObservationEvents, + priorityEvents: deps.PriorityEvents, + logger: logger, + now: time.Now, + } +} + +// today 返回当前上海自然日,作为触发日期与每日唯一键的统一口径。 +func (s *Service) today() time.Time { + return assetquery.Today(s.now()) +} + +// assetIdentifier 读取资产对外的可读标识,取不到时回退为资产 ID 文本。 +// 通知模板要求标识非空,因此绝不返回空串。 +func (s *Service) assetIdentifier(ctx context.Context, assetType string, assetID uint) string { + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + if card, err := s.iotCardStore.GetByID(ctx, assetID); err == nil && card.ICCID != "" { + return card.ICCID + } + case constants.AssetWalletResourceTypeDevice: + if device, err := s.deviceStore.GetByID(ctx, assetID); err == nil { + if device.VirtualNo != "" { + return device.VirtualNo + } + if device.IMEI != "" { + return device.IMEI + } + } + } + return strconv.FormatUint(uint64(assetID), 10) +} + +// packageName 读取套餐商品名称,取不到时回退为套餐 ID 文本。 +func (s *Service) packageName(ctx context.Context, packageID uint) string { + if packageID == 0 { + return "未知套餐" + } + if pkg, err := s.packageStore.GetByID(ctx, packageID); err == nil && pkg.PackageName != "" { + return pkg.PackageName + } + return strconv.FormatUint(uint64(packageID), 10) +} + +// loadPackagesByIDs 批量读取套餐商品,用于一次性取价与快照。 +func (s *Service) loadPackagesByIDs(ctx context.Context, packageIDs []uint) (map[uint]*model.Package, error) { + result := make(map[uint]*model.Package, len(packageIDs)) + if len(packageIDs) == 0 { + return result, nil + } + var packages []*model.Package + if err := s.db.WithContext(ctx).Where("id IN ?", packageIDs).Find(&packages).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费套餐商品失败") + } + for _, pkg := range packages { + result[pkg.ID] = pkg + } + return result, nil +} diff --git a/internal/application/notification/read.go b/internal/application/notification/read.go index df564d3..19c1356 100644 --- a/internal/application/notification/read.go +++ b/internal/application/notification/read.go @@ -219,6 +219,7 @@ func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB { constants.NotificationTypeExchangeShippingCreated, constants.NotificationTypeH5PopupRiskExchange, constants.NotificationTypeH5PopupOperation, + constants.NotificationTypeAssetAutoRenewalFailed, }, now, ) diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index e3b9696..89bb37c 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -287,6 +287,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { Package: admin.NewPackageHandler(svc.Package), PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord), PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate), + AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(svc.AssetAutoRenewal, validate), ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation), ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing), ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 5988661..5be0815 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -7,6 +7,7 @@ import ( agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" + assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold" distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal" @@ -28,6 +29,7 @@ import ( walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom" "github.com/break/junhong_cmp_fiber/internal/polling" + assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal" accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account" agentOpenAPISvc "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api" assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record" @@ -118,6 +120,7 @@ type services struct { PackageDailyRecord *packageSvc.DailyRecordService PackageCustomerView *packageSvc.CustomerViewService PackageTrafficAlertRule *packagetrafficalertapp.RuleService + AssetAutoRenewal *assetAutoRenewalApp.Service ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service ShopSeriesGrant *shopSeriesGrantSvc.Service @@ -509,6 +512,12 @@ func initServices(s *stores, deps *Dependencies) *services { PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger), PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger), PackageTrafficAlertRule: packagetrafficalertapp.NewRuleService(deps.DB, s.PackageTrafficAlert, auditWriter), + AssetAutoRenewal: assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{ + DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger, + Outbox: outbox.NewRepository(), AuditWriter: auditWriter, + PurchaseValidation: purchaseValidation, + Candidates: assetAutoRenewalQuery.NewQuery(deps.DB), + }), ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter), ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter), ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter), diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 8f93ff7..07f8ce4 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -83,6 +83,7 @@ type Handlers struct { ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler PhoneAssetAssociation *admin.PhoneAssetAssociationHandler PackageTrafficAlert *admin.PackageTrafficAlertHandler + AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler ClientWechat *app.ClientWechatHandler SuperAdmin *admin.SuperAdminHandler SystemConfig *admin.SystemConfigHandler diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index f4c6c52..dbdf7fe 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -1,9 +1,11 @@ package bootstrap import ( + assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal" auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold" @@ -11,6 +13,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling" walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" + assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal" "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device" @@ -176,6 +179,16 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService) // 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。 carrierThresholdService.SetCommander(carrierThresholdInfra.NewCardCommander(stopResumeService)) + // 资产钱包自动续费:复机执行同样复用既有停复机单一事实源,通知与复机都走公共 Outbox。 + assetAutoRenewalService := assetAutoRenewalApp.NewService(assetAutoRenewalApp.Dependencies{ + DB: deps.DB, Redis: deps.Redis, Logger: deps.Logger, + Outbox: cardObservationOutbox, AuditWriter: auditWriter, + PurchaseValidation: purchaseValidation, + Candidates: assetAutoRenewalQuery.NewQuery(deps.DB), + Resume: assetAutoRenewalInfra.NewCardCommander(stopResumeService), + ObservationEvents: observationSeriesEvents, + PriorityEvents: priorityEvents, + }) activationService.SetObservationSeriesEventWriter(observationSeriesEvents) activationService.SetPriorityEventWriter(priorityEvents) usageService.SetStopResumeCallback(stopResumeService) @@ -205,6 +218,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q CleanupService: cleanupService, StopResumeService: stopResumeService, CarrierThreshold: carrierThresholdService, + AssetAutoRenewal: assetAutoRenewalService, OrderExpirer: orderService, AssetPackageOrderCreator: orderService, DeviceBatchAllocator: deviceBatchAllocator, diff --git a/internal/handler/admin/asset_auto_renewal.go b/internal/handler/admin/asset_auto_renewal.go new file mode 100644 index 0000000..f0340bc --- /dev/null +++ b/internal/handler/admin/asset_auto_renewal.go @@ -0,0 +1,82 @@ +package admin + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal" + "github.com/break/junhong_cmp_fiber/internal/handler/validation" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// AssetAutoRenewalConfigHandler 资产钱包自动续费配置 Handler。 +// +// 路由组已有「仅超级管理员与平台账号」门禁,应用层仍会复核账号类型(ENG-AUTHZ-001), +// 因此代理、企业与个人客户即使绕过路由也无任何入口。 +type AssetAutoRenewalConfigHandler struct { + service *assetAutoRenewalApp.Service + validator *validator.Validate +} + +// NewAssetAutoRenewalConfigHandler 创建资产钱包自动续费配置 Handler。 +func NewAssetAutoRenewalConfigHandler(service *assetAutoRenewalApp.Service, validator *validator.Validate) *AssetAutoRenewalConfigHandler { + return &AssetAutoRenewalConfigHandler{service: service, validator: validator} +} + +// GetConfig 读取资产钱包自动续费配置。 +// GET /api/admin/asset-auto-renewal-config +func (h *AssetAutoRenewalConfigHandler) GetConfig(c *fiber.Ctx) error { + config, err := h.service.GetConfig(c.UserContext()) + if err != nil { + return err + } + return response.Success(c, toAssetAutoRenewalConfigResponse(config)) +} + +// UpdateConfig 保存资产钱包自动续费配置。 +// PUT /api/admin/asset-auto-renewal-config +func (h *AssetAutoRenewalConfigHandler) UpdateConfig(c *fiber.Ctx) error { + var req dto.UpdateAssetAutoRenewalConfigRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数格式不正确") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, validation.Message("保存自动续费配置参数不合法", &req, err)) + } + config, err := h.service.SaveConfig(c.UserContext(), assetAutoRenewalApp.ConfigRequest{ + Enabled: req.Enabled, + Scope: req.Scope, + PackageIDs: req.PackageIDs, + DaysBeforeExpiry: req.DaysBeforeExpiry, + }) + if err != nil { + return err + } + return response.Success(c, toAssetAutoRenewalConfigResponse(config)) +} + +// toAssetAutoRenewalConfigResponse 组装配置响应,枚举附加中文名称字段(ENG-DTO-001)。 +func toAssetAutoRenewalConfigResponse(config *assetAutoRenewalApp.ConfigView) dto.AssetAutoRenewalConfigResponse { + packageIDs := config.PackageIDs + if packageIDs == nil { + packageIDs = []uint{} + } + enabledName := "关闭" + if config.Enabled == constants.AssetAutoRenewalConfigEnabledOn { + enabledName = "开启" + } + return dto.AssetAutoRenewalConfigResponse{ + Enabled: config.Enabled, + Scope: config.Scope, + PackageIDs: packageIDs, + DaysBeforeExpiry: config.DaysBeforeExpiry, + ConfigVersion: config.ConfigVersion, + ScopeName: constants.GetAssetAutoRenewalScopeName(config.Scope), + EnabledName: enabledName, + Updater: config.Updater, + UpdatedAt: config.UpdatedAt.Format("2006-01-02 15:04:05"), + } +} diff --git a/internal/infrastructure/assetautorenewal/commander.go b/internal/infrastructure/assetautorenewal/commander.go new file mode 100644 index 0000000..da9d928 --- /dev/null +++ b/internal/infrastructure/assetautorenewal/commander.go @@ -0,0 +1,56 @@ +// Package assetautorenewal 提供资产钱包自动续费的停复机执行适配与异步任务入口。 +package assetautorenewal + +import ( + "context" + + assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal" + iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// CardCommander 把自动续费复机端口转发到既有停复机单一事实源。 +// +// 它只做类型与结果分类的转换:判定规则、重试、Integration Log、统一审计与观测序列全部由 +// internal/service/iot_card 提供,本适配器不复制任何规则,也不直接调用运营商接口。 +type CardCommander struct { + service *iotCardSvc.StopResumeService +} + +// NewCardCommander 创建自动续费复机执行适配器。 +func NewCardCommander(service *iotCardSvc.StopResumeService) *CardCommander { + return &CardCommander{service: service} +} + +// AutoRenewalResumeReady 判断该资产是否满足自动复机条件。 +func (c *CardCommander) AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error) { + if c == nil || c.service == nil { + return false, "", errCommanderUnavailable() + } + return c.service.AutoRenewalResumeReady(ctx, assetType, assetID) +} + +// ResumeAssetForAutoRenewal 执行复机并把结果转换为本用例的结果摘要。 +func (c *CardCommander) ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (assetAutoRenewalApp.ResumeOutcome, error) { + if c == nil || c.service == nil { + return assetAutoRenewalApp.ResumeOutcome{}, errCommanderUnavailable() + } + outcome, err := c.service.ResumeAssetForAutoRenewal(ctx, assetType, assetID) + return assetAutoRenewalApp.ResumeOutcome{ + Applied: outcome.Applied, IntegrationID: outcome.IntegrationID, + Result: outcome.Result, SafeReason: outcome.SafeReason, + }, err +} + +// QueryAutoRenewalResumeState 只查询运营商卡状态,供恢复扫描回填复机结果。 +func (c *CardCommander) QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (bool, bool, string, error) { + if c == nil || c.service == nil { + return false, false, "", errCommanderUnavailable() + } + return c.service.QueryAutoRenewalResumeState(ctx, assetType, assetID) +} + +// errCommanderUnavailable 返回复机执行端口未配置的稳定错误。 +func errCommanderUnavailable() error { + return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行能力未配置") +} diff --git a/internal/infrastructure/assetautorenewal/task.go b/internal/infrastructure/assetautorenewal/task.go new file mode 100644 index 0000000..6d64f72 --- /dev/null +++ b/internal/infrastructure/assetautorenewal/task.go @@ -0,0 +1,70 @@ +package assetautorenewal + +import ( + "context" + + "github.com/hibiken/asynq" + + assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// DailyScanTaskHandler 执行资产钱包自动续费的每日扫描任务。 +// +// 审计上下文固定为计划任务来源,使终态收敛、续费成功与失败事实都可按计划任务维度追溯。 +type DailyScanTaskHandler struct { + service *assetAutoRenewalApp.Service +} + +// NewDailyScanTaskHandler 创建自动续费每日扫描任务处理器。 +func NewDailyScanTaskHandler(service *assetAutoRenewalApp.Service) *DailyScanTaskHandler { + return &DailyScanTaskHandler{service: service} +} + +// Handle 执行一次每日扫描;只有扫描级失败才返回错误交既有任务重试。 +func (h *DailyScanTaskHandler) Handle(ctx context.Context, task *asynq.Task) error { + if h == nil || h.service == nil { + return errors.New(errors.CodeServiceUnavailable, "自动续费扫描任务未配置") + } + taskType := constants.TaskTypeAssetAutoRenewalScan + if task != nil && task.Type() != "" { + taskType = task.Type() + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorScheduledJob, ActorID: taskType, + ActorName: "资产钱包自动续费每日扫描计划任务", Source: constants.AuditSourceScheduler, + }) + _, err := h.service.RunDailyScan(ctx) + return err +} + +// RecoveryTaskHandler 执行资产钱包自动续费的复机结果恢复扫描任务。 +// +// 只查询运营商状态回填,绝不重复发起复机调用;只有最终确认失败才按通知契约投递通知。 +type RecoveryTaskHandler struct { + service *assetAutoRenewalApp.Service +} + +// NewRecoveryTaskHandler 创建自动续费复机结果恢复任务处理器。 +func NewRecoveryTaskHandler(service *assetAutoRenewalApp.Service) *RecoveryTaskHandler { + return &RecoveryTaskHandler{service: service} +} + +// Handle 扫描未收敛的复机子结果并只查询状态回填。 +func (h *RecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error { + if h == nil || h.service == nil { + return errors.New(errors.CodeServiceUnavailable, "自动续费复机结果恢复任务未配置") + } + taskType := constants.TaskTypeAssetAutoRenewalRecovery + if task != nil && task.Type() != "" { + taskType = task.Type() + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorScheduledJob, ActorID: taskType, + ActorName: "资产钱包自动续费结果恢复计划任务", Source: constants.AuditSourceScheduler, + }) + _, err := h.service.RecoverResumeResults(ctx) + return err +} diff --git a/internal/infrastructure/audit/asset_auto_renewal.go b/internal/infrastructure/audit/asset_auto_renewal.go new file mode 100644 index 0000000..1ed50e6 --- /dev/null +++ b/internal/infrastructure/audit/asset_auto_renewal.go @@ -0,0 +1,74 @@ +package audit + +import ( + "context" + "strconv" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// AssetAutoRenewalConfigAudit 是资产钱包自动续费配置保存的审计事实。 +// +// 前后值快照由调用方在保存事务内组装:Before 为保存前的开关、范围、集合、天数与版本, +// After 为保存后的对应值。审计写入与配置保存同事务,未注册时整体失败(fail-closed)。 +type AssetAutoRenewalConfigAudit struct { + OperatorID uint + OperationType string + Description string + BeforeData map[string]any + AfterData map[string]any + Result string + ErrorCode string + ErrorSummary string + RequestID string + CorrelationID string +} + +// WriteAssetAutoRenewalConfigChange 将自动续费配置变化转换为统一 Audit Event。 +func (w *Writer) WriteAssetAutoRenewalConfigChange(ctx context.Context, tx *gorm.DB, change AssetAutoRenewalConfigAudit) error { + operationType := change.OperationType + if operationType != "" && operationType != constants.AuditOperationAssetAutoRenewalConfigUpdate { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册") + } + action, ok := w.registry.ActionByOperation(constants.AuditOperationAssetAutoRenewalConfigUpdate) + if !ok { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册") + } + if change.OperatorID == 0 { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "自动续费配置审计操作者缺失") + } + result := change.Result + if result == "" { + result = constants.AuditResultSuccess + } + summary := change.Description + if summary == "" { + summary = "保存资产钱包自动续费配置" + } + configID := strconv.FormatUint(uint64(constants.AssetAutoRenewalConfigSingletonID), 10) + return w.Append(ctx, tx, AppendInput{ + ActionCode: action.Code, Summary: summary, + Actor: ActorInput{ + Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(change.OperatorID), 10), + Name: middleware.GetUsernameFromContext(ctx), + }, + Source: action.Source, RequestPath: contextString(middleware.GetRequestPathFromContext(ctx)), + RequestMethod: contextString(middleware.GetRequestMethodFromContext(ctx)), + IPAddress: contextString(middleware.GetIPFromContext(ctx)), UserAgent: contextString(middleware.GetUserAgentFromContext(ctx)), + ScopeType: constants.AuditScopePlatform, Result: result, + ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary, + RequestID: change.RequestID, CorrelationID: change.CorrelationID, + Resources: []ResourceInput{{ + Type: constants.AuditResourceAssetAutoRenewalConfig, ID: &configID, Key: configID, + DisplayName: constants.AssetAutoRenewalConfigDisplayName, + Relation: constants.AuditResourceRelationPrimary, + Role: constants.AuditResourceRoleAssetAutoRenewalConfigTarget, + BeforeData: change.BeforeData, AfterData: change.AfterData, + SubjectVisibility: constants.AuditSubjectInternalOnly, + }}, + }) +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index 31ca6db..d585ba0 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -436,9 +436,18 @@ func NewRegistry() *Registry { constants.AuditActionCommissionWithdrawalAttemptRejected, "提现提交被拒绝", constants.AuditResourceShop) qualificationSubmitRejected := distributionAction( constants.AuditActionWithdrawalQualificationSubmitRejected, "资格提交被拒绝", constants.AuditResourceShop) + // 资产钱包自动续费:配置保存是运营账号行为,续费与失败是系统任务行为,三者主资源不同。 + assetAutoRenewalConfigUpdated := packageConfigAction( + constants.AuditActionAssetAutoRenewalConfigUpdated, "保存资产钱包自动续费配置", + constants.AuditResourceAssetAutoRenewalConfig, constants.AuditRiskHigh) + assetAutoRenewalRenewed := assetAutoRenewalAction( + constants.AuditActionAssetAutoRenewalRenewed, "资产钱包自动续费完成", constants.AuditRiskHigh) + assetAutoRenewalFailed := assetAutoRenewalAction( + constants.AuditActionAssetAutoRenewalFailed, "资产钱包自动续费失败或跳过", constants.AuditRiskNormal) return &Registry{ actionsByOperation: map[string]ActionDefinition{ constants.AuditOperationSystemConfigUpdate: systemConfigUpdated, + constants.AuditOperationAssetAutoRenewalConfigUpdate: assetAutoRenewalConfigUpdated, constants.AuditOperationPaymentConfigCreate: paymentConfigCreated, constants.AuditOperationPaymentConfigUpdate: paymentConfigUpdated, constants.AuditOperationPaymentConfigDelete: paymentConfigDeleted, @@ -748,6 +757,9 @@ func NewRegistry() *Registry { constants.AuditActionWithdrawalQualificationInvalidated: qualificationInvalidated, constants.AuditActionCommissionWithdrawalAttemptRejected: withdrawalAttemptRejected, constants.AuditActionWithdrawalQualificationSubmitRejected: qualificationSubmitRejected, + constants.AuditActionAssetAutoRenewalConfigUpdated: assetAutoRenewalConfigUpdated, + constants.AuditActionAssetAutoRenewalRenewed: assetAutoRenewalRenewed, + constants.AuditActionAssetAutoRenewalFailed: assetAutoRenewalFailed, }, resources: map[string]ResourceDefinition{ constants.AuditResourceAccount: { @@ -822,6 +834,19 @@ func NewRegistry() *Registry { Type: constants.AuditResourceLogArchiveMonth, Name: "日志归档自然月", IdentityFields: []string{"month", "timezone", "range_start", "range_end"}, }, + constants.AuditResourceAssetAutoRenewalConfig: { + Type: constants.AuditResourceAssetAutoRenewalConfig, Name: "资产钱包自动续费配置", + IdentityFields: []string{"id", "enabled", "scope", "package_ids", "days_before_expiry", "config_version"}, + }, + constants.AuditResourceAssetAutoRenewalAttempt: { + Type: constants.AuditResourceAssetAutoRenewalAttempt, Name: "资产钱包自动续费尝试", + IdentityFields: []string{ + "id", "asset_type", "asset_id", "trigger_date", "status", "failure_reason", "skip_reason", + "current_usage_id", "current_package_id", "renew_package_id", "renew_price", + "wallet_id", "wallet_transaction_id", "deduct_amount", "balance_before", "balance_after", + "order_id", "order_no", "resume_status", "resume_integration_id", "attempt_seq", + }, + }, constants.AuditResourceDeviceBatchTask: { Type: constants.AuditResourceDeviceBatchTask, Name: "设备批量分配任务", IdentityFields: []string{"task_no", "operation_type"}, @@ -1168,6 +1193,23 @@ func customerAssetAdminAction(code, name string) ActionDefinition { } } +// assetAutoRenewalAction 是资产钱包自动续费系统动作的注册模板。 +// +// 续费与失败都由计划任务或 Worker 系统任务产生,主资源是本表新登记的自动续费尝试记录, +// 因此不设 AllowedActor/Source 单一来源,改用 AllowedOrigins 同时接受两个系统入口。 +func assetAutoRenewalAction(code, name, risk string) ActionDefinition { + return ActionDefinition{ + Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: risk, + PrimaryResource: constants.AuditResourceAssetAutoRenewalAttempt, RequireTransaction: true, + DefaultVisibility: constants.AuditSubjectInternalOnly, + AllowedVisibility: []string{constants.AuditSubjectInternalOnly}, + AllowedOrigins: []ActionOrigin{ + {Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler}, + {Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker}, + }, + } +} + func packageConfigAction(code, name, primaryResource, risk string) ActionDefinition { return ActionDefinition{ Code: code, Name: name, Category: constants.AuditCategoryConfiguration, Risk: risk, diff --git a/internal/infrastructure/notification/registry.go b/internal/infrastructure/notification/registry.go index efb4faf..0aeb344 100644 --- a/internal/infrastructure/notification/registry.go +++ b/internal/infrastructure/notification/registry.go @@ -157,6 +157,25 @@ func NewRegistry() *Registry { constants.NotificationRefTypePackageTrafficAlert: {}, }, }, + // 资产钱包自动续费失败:类别沿用 expiry,接收人含店铺业务员账号与个人客户, + // 资源引用只指向失败资产,正文不含任何 URL 或前端路由。 + constants.NotificationTypeAssetAutoRenewalFailed: { + Type: constants.NotificationTypeAssetAutoRenewalFailed, Category: constants.NotificationCategoryExpiry, + Severity: constants.NotificationSeverityWarning, + TitleTemplate: "套餐自动续费未完成", + BodyTemplate: "资产 {{.asset_identifier}} 的套餐 {{.package_name}} 自动续费未完成,原因:{{.failure_reason}},最终到期日期:{{.expiry_date}}。", + TemplateFields: map[string]struct{}{ + "asset_identifier": {}, "package_name": {}, "failure_reason": {}, "expiry_date": {}, + }, + RecipientKinds: map[string]struct{}{ + constants.NotificationRecipientKindAccount: {}, + constants.NotificationRecipientKindPersonalCustomer: {}, + }, + AllowedRefTypes: map[string]struct{}{ + constants.NotificationRefTypeIotCard: {}, + constants.NotificationRefTypeDevice: {}, + }, + }, }} } diff --git a/internal/model/asset_auto_renewal.go b/internal/model/asset_auto_renewal.go new file mode 100644 index 0000000..a03ad9a --- /dev/null +++ b/internal/model/asset_auto_renewal.go @@ -0,0 +1,99 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// AssetAutoRenewalConfig 是全局唯一一行自动续费配置的 PostgreSQL 持久化事实。 +// +// 主键恒为 1(数据库 CHECK 约束保证单行):配置只有一份全局生效值,不按店铺、企业或个人 +// 客户分范围。ConfigVersion 是配置版本而非乐观锁,保存事务内递增并供尝试记录冻结快照, +// 已产生的尝试记录保留原版本、不重算。PackageIDs 只在 Scope 为 specified 时非空。 +type AssetAutoRenewalConfig struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + Enabled int `gorm:"column:enabled;type:smallint;not null;default:0;comment:总开关 0-关闭 1-开启" json:"enabled"` + Scope string `gorm:"column:scope;type:varchar(16);not null;default:'all';comment:适用范围 all-全部主套餐 specified-指定主套餐" json:"scope"` + PackageIDs UintJSONBArray `gorm:"column:package_ids;type:jsonb;not null;default:'[]';comment:指定主套餐集合(仅 specified 范围非空)" json:"package_ids"` + DaysBeforeExpiry int `gorm:"column:days_before_expiry;type:integer;not null;default:15;comment:统一到期前天数(1-90)" json:"days_before_expiry"` + ConfigVersion int64 `gorm:"column:config_version;type:bigint;not null;default:1;comment:配置版本,保存事务内自增" json:"config_version"` + BaseModel `gorm:"embedded"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"` +} + +// TableName 返回自动续费配置表名。 +func (AssetAutoRenewalConfig) TableName() string { + return "tb_asset_auto_renewal_config" +} + +// AssetAutoRenewalAttempt 是一次自动续费尝试的 PostgreSQL 持久化事实。 +// +// 一行表达一个 (AssetType, AssetID, TriggerDate) 组合,由部分唯一索引保证每项资产每日至多 +// 一次尝试;Status 为终态(成功/失败/跳过)时该次尝试已收敛,仍为处理中表示占位后进程中断。 +// 资金、订单、复机与配置窗口字段全部是触发时冻结的快照,不随后续配置或资产变化重算。 +type AssetAutoRenewalAttempt struct { + gorm.Model + // AssetType 取值与资产钱包资源类型一致(iot_card / device),卡与设备分别计数。 + AssetType string `gorm:"column:asset_type;type:varchar(20);not null;comment:资产类型 iot_card-物联网卡 device-设备" json:"asset_type"` + AssetID uint `gorm:"column:asset_id;type:bigint;not null;comment:资产ID" json:"asset_id"` + // TriggerDate 是触发日的上海自然日,与资产类型、资产 ID 共同构成唯一键。 + TriggerDate time.Time `gorm:"column:trigger_date;type:date;not null;comment:触发日期(上海自然日)" json:"trigger_date"` + // Status 取值 constants.AssetAutoRenewalAttemptStatus*。 + Status int `gorm:"column:status;type:smallint;not null;default:1;comment:尝试状态 1-处理中 2-成功 3-失败 4-跳过" json:"status"` + // FailureReason 取值 constants.AssetAutoRenewalFailure*,成功与跳过时为空。 + FailureReason string `gorm:"column:failure_reason;type:varchar(32);not null;default:'';comment:失败原因 insufficient_balance-余额不足 not_renewable-不可续费 order_failed-订单失败 resume_failed-复机失败" json:"failure_reason"` + // FailureDetail 是可安全记录的失败说明,不写渠道报文、凭证或内部错误细节。 + FailureDetail string `gorm:"column:failure_detail;type:varchar(500);not null;default:'';comment:可安全展示的失败说明" json:"failure_detail"` + // SkipReason 取值 constants.AssetAutoRenewalSkip*,仅跳过态有值。 + SkipReason string `gorm:"column:skip_reason;type:varchar(32);not null;default:'';comment:跳过原因 manual_renewed-人工已完成续购 manual_order_pending-人工订单在途" json:"skip_reason"` + + // 触发时冻结的客户与店铺快照。 + CustomerID uint `gorm:"column:customer_id;type:bigint;not null;default:0;comment:触发时解析到的当前个人客户ID,0-无" json:"customer_id"` + ShopID *uint `gorm:"column:shop_id;type:bigint;comment:触发时资产所属店铺ID,NULL-无店铺" json:"shop_id"` + + // 触发时冻结的配置与窗口快照。 + ConfigVersion int64 `gorm:"column:config_version;type:bigint;not null;default:0;comment:触发时配置版本快照" json:"config_version"` + WindowDays int `gorm:"column:window_days;type:integer;not null;default:0;comment:触发时到期前天数快照" json:"window_days"` + FinalExpiresAt *time.Time `gorm:"column:final_expires_at;type:timestamptz;comment:触发时最终到期时间快照" json:"final_expires_at,omitempty"` + + // 当前主套餐与续购对象。 + CurrentUsageID uint `gorm:"column:current_usage_id;type:bigint;not null;default:0;comment:触发时当前主套餐使用记录ID" json:"current_usage_id"` + CurrentPackageID uint `gorm:"column:current_package_id;type:bigint;not null;default:0;comment:触发时当前套餐商品ID" json:"current_package_id"` + RenewPackageID uint `gorm:"column:renew_package_id;type:bigint;not null;default:0;comment:待续购套餐商品ID" json:"renew_package_id"` + RenewPrice int64 `gorm:"column:renew_price;type:bigint;not null;default:0;comment:执行时当前可售续费价(分)" json:"renew_price"` + + // 资金事实。 + WalletID uint `gorm:"column:wallet_id;type:bigint;not null;default:0;comment:扣款资产钱包ID" json:"wallet_id"` + // WalletTransactionID 即规格中的钱包流水号,指向 tb_asset_wallet_transaction.id。 + WalletTransactionID uint `gorm:"column:wallet_transaction_id;type:bigint;not null;default:0;comment:资产钱包流水标识(tb_asset_wallet_transaction.id)" json:"wallet_transaction_id"` + DeductAmount int64 `gorm:"column:deduct_amount;type:bigint;not null;default:0;comment:扣款金额(分)" json:"deduct_amount"` + BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;default:0;comment:扣款前钱包余额(分)" json:"balance_before"` + BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;default:0;comment:扣款后钱包余额(分)" json:"balance_after"` + + // 订单事实。 + OrderID uint `gorm:"column:order_id;type:bigint;not null;default:0;comment:续费订单ID" json:"order_id"` + OrderNo string `gorm:"column:order_no;type:varchar(64);not null;default:'';comment:续费订单号快照" json:"order_no"` + + // 复机事实。 + ResumeStatus int `gorm:"column:resume_status;type:smallint;not null;default:0;comment:复机状态 0-未评估 1-跳过 2-已投递 3-成功 4-失败 5-未知" json:"resume_status"` + // ResumeSubmittedAt 是复机执行提交认领时刻:消费者以「为空」条件更新取得至多一次的外部调用权。 + ResumeSubmittedAt *time.Time `gorm:"column:resume_submitted_at;type:timestamptz;comment:复机执行提交认领时刻" json:"resume_submitted_at,omitempty"` + // ResumeIntegrationID 记录复机 Gateway 调用的 Integration Log 标识,便于人工核对。 + ResumeIntegrationID string `gorm:"column:resume_integration_id;type:varchar(64);not null;default:'';comment:复机外部交互标识(Integration Log 标识)" json:"resume_integration_id"` + ResumeFailureReason string `gorm:"column:resume_failure_reason;type:varchar(500);not null;default:'';comment:可安全展示的复机失败原因" json:"resume_failure_reason"` + // ResumeAnomalyFlag 为 1 表示查询窗口超期仍无法确认,退出自动扫描转人工核对。 + ResumeAnomalyFlag int `gorm:"column:resume_anomaly_flag;type:smallint;not null;default:0;comment:复机异常标记 0-正常 1-需人工核对" json:"resume_anomaly_flag"` + + // 操作者与跨日尝试次数:本能力无人工处理入口,操作者恒为系统任务。 + OperatorType string `gorm:"column:operator_type;type:varchar(32);not null;default:'system_task';comment:操作者类型,恒为 system_task" json:"operator_type"` + OperatorID string `gorm:"column:operator_id;type:varchar(64);not null;default:'';comment:操作者标识,系统任务固定为计划任务类型" json:"operator_id"` + // AttemptSeq 是该资产截至本次尝试当日的跨日累计尝试次数。 + AttemptSeq int `gorm:"column:attempt_seq;type:integer;not null;default:1;comment:跨日尝试次数" json:"attempt_seq"` +} + +// TableName 返回自动续费尝试表名。 +func (AssetAutoRenewalAttempt) TableName() string { + return "tb_asset_auto_renewal_attempt" +} diff --git a/internal/model/dto/asset_auto_renewal_dto.go b/internal/model/dto/asset_auto_renewal_dto.go new file mode 100644 index 0000000..af9ed19 --- /dev/null +++ b/internal/model/dto/asset_auto_renewal_dto.go @@ -0,0 +1,39 @@ +package dto + +// AssetAutoRenewalConfigResponse 是资产钱包自动续费全局配置的读取响应。 +type AssetAutoRenewalConfigResponse struct { + Enabled int `json:"enabled" description:"总开关 0-关闭 1-开启"` + // Scope 取值 all-全部主套餐 specified-指定主套餐。 + Scope string `json:"scope" description:"适用范围 all-全部主套餐 specified-指定主套餐"` + // PackageIDs 仅在指定范围时非空,元素为可售主套餐商品 ID。 + PackageIDs []uint `json:"package_ids" description:"指定主套餐商品ID集合,范围为主套餐全部时为空数组"` + // DaysBeforeExpiry 是统一到期前天数,触发窗口为最终到期剩余天数闭区间 0 至该值。 + DaysBeforeExpiry int `json:"days_before_expiry" description:"统一到期前天数,取值 1 至 90"` + // ConfigVersion 每次保存递增,尝试记录只保留触发时版本快照。 + ConfigVersion int64 `json:"config_version" description:"配置版本,每次保存递增"` + // ScopeName 是适用范围的中文名称。 + ScopeName string `json:"scope_name" description:"适用范围中文名称"` + // EnabledName 是总开关的中文名称。 + EnabledName string `json:"enabled_name" description:"总开关中文名称"` + // Updater 是最近保存的操作者账号 ID。 + Updater uint `json:"updater" description:"最近保存的操作者账号ID"` + // UpdatedAt 是最近保存时间。 + UpdatedAt string `json:"updated_at" description:"最近保存时间"` +} + +// UpdateAssetAutoRenewalConfigRequest 是保存自动续费全局配置的请求。 +// +// required:"true" 与 enum:"..." 是**文档契约标签**(供 OpenAPI 反射,见 pkg/openapi 的 Reflector 与 +// internal/model/dto/asset_dto.go:386 的既有用法),不参与运行时校验;运行时校验仍由 validate tag 与 +// internal/handler/admin/asset_auto_renewal.go 的 validator.Struct 承担。 +// description 一律在首个中文逗号处收住:internal/handler/validation 的 fieldDescription 会在此截断, +// 使校验提示只取到字段名(如「适用范围」)而不是整段枚举说明;枚举取值逐字取自 pkg/constants。 +type UpdateAssetAutoRenewalConfigRequest struct { + Enabled int `json:"enabled" validate:"oneof=0 1" enum:"0,1" description:"总开关,取值 0-关闭 1-开启"` + // Scope 只能选择全部主套餐或指定主套餐;指定范围时 PackageIDs 必须非空。 + Scope string `json:"scope" validate:"required,oneof=all specified" required:"true" enum:"all,specified" description:"适用范围,取值 all-全部主套餐 specified-指定主套餐"` + // PackageIDs 只能选择当前可售主套餐;范围为主套餐全部时必须留空。 + PackageIDs []uint `json:"package_ids" description:"指定主套餐商品ID集合,仅指定范围时填写"` + // DaysBeforeExpiry 是统一到期前天数,上限 90。 + DaysBeforeExpiry int `json:"days_before_expiry" validate:"required,min=1,max=90" required:"true" description:"统一到期前天数,取值 1 至 90"` +} diff --git a/internal/model/types.go b/internal/model/types.go index ac19de8..225c485 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -30,3 +30,29 @@ func (a *StringJSONBArray) Scan(value any) error { } return json.Unmarshal(b, a) } + +// UintJSONBArray 用于将 []uint 与 PostgreSQL jsonb 列互转 +// 读写时通过 Scan/Value 完成序列化,空切片序列化为 [] +type UintJSONBArray []uint + +// Value 写入数据库时序列化为 JSON +func (a UintJSONBArray) Value() (driver.Value, error) { + if a == nil { + return "[]", nil + } + return json.Marshal(a) +} + +// Scan 从数据库读取时反序列化 +func (a *UintJSONBArray) Scan(value any) error { + if value == nil { + *a = UintJSONBArray{} + return nil + } + b, ok := value.([]byte) + if !ok { + *a = UintJSONBArray{} + return nil + } + return json.Unmarshal(b, a) +} diff --git a/internal/query/assetautorenewal/query.go b/internal/query/assetautorenewal/query.go new file mode 100644 index 0000000..7980fcd --- /dev/null +++ b/internal/query/assetautorenewal/query.go @@ -0,0 +1,524 @@ +// Package assetautorenewal 提供资产钱包自动续费的只读候选与资格事实查询。 +// +// 本包只读:候选资产的最终到期推算完全复用 internal/query/packageexpiry 的既有口径 +// (ResolveBatch/Calculate),当前主套餐取法与既有临期列表同序(priority、created_at、id 升序取第一条), +// 本包不重新推算到期、也不修改任何状态。 +package assetautorenewal + +import ( + "context" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "gorm.io/gorm" +) + +var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +// Candidate 是一项最终到期进入触发窗口的自动续费候选资产。 +type Candidate struct { + AssetType string + AssetID uint + Identifier string + ShopID *uint + // CustomerID 是触发时解析到的当前个人客户 ID,0 表示该资产当前没有绑定个人客户。 + CustomerID uint + // CurrentUsageID 与 CurrentPackageID 是当前主套餐使用记录与其套餐商品。 + CurrentUsageID uint + CurrentPackageID uint + Generation int + // FinalExpiresAt 与 DaysUntilFinalExpiry 来自既有最终到期推算结果(含待生效主套餐顺延)。 + FinalExpiresAt time.Time + DaysUntilFinalExpiry int + // HasPendingMainPackage 表示该资产已存在待生效主套餐(未退款、无主套餐归属)。 + // 它同时表达「人工已完成续购」与「不叠加周期」两个不变式。 + HasPendingMainPackage bool +} + +// Query 查询自动续费候选资产与资格事实。 +type Query struct { + db *gorm.DB + expiry *packageexpiry.Query + now func() time.Time +} + +// NewQuery 创建自动续费候选查询。 +func NewQuery(db *gorm.DB) *Query { + return &Query{db: db, expiry: packageexpiry.NewQuery(db), now: time.Now} +} + +// WithDB 返回绑定指定事务的只读视图。 +// +// 供续费事务在锁内重读资格事实复用:查询口径不变,只把读取句柄换成调用方事务。 +func (q *Query) WithDB(db *gorm.DB) *Query { + if db == nil { + return q + } + return &Query{db: db, expiry: packageexpiry.NewQuery(db), now: q.now} +} + +type assetCandidate struct { + AssetType string + AssetID uint + Identifier string + ShopID *uint +} + +// Candidates 返回最终到期进入 [0, windowDays] 闭区间的候选资产。 +// +// 窗口按上海自然日比较,只接受推算结果为明确值(exact)的资产;已过期(剩余天数为负)、 +// 无有效主套餐、待激活或数据异常的资产一律不进入候选。资产的个人客户与店铺在触发时解析并冻结。 +// 候选按主键分批解析(每批 candidateBatchSize),使单条 SQL 的参数个数与单批中间结果有界。 +func (q *Query) Candidates(ctx context.Context, windowDays int) ([]Candidate, error) { + if q == nil || q.db == nil { + return nil, errors.New(errors.CodeInternalError, "自动续费候选查询未配置") + } + if err := validateWindowDays(windowDays); err != nil { + return nil, err + } + results := make([]Candidate, 0) + for _, assetType := range []string{constants.AssetWalletResourceTypeIotCard, constants.AssetWalletResourceTypeDevice} { + lastID := uint(0) + for { + page, err := q.assetCandidatePage(ctx, assetType, windowDays, lastID, candidateBatchSize) + if err != nil { + return nil, err + } + if len(page) == 0 { + break + } + resolved, err := q.resolve(ctx, page, windowDays) + if err != nil { + return nil, err + } + results = append(results, resolved...) + lastID = page[len(page)-1].AssetID + if len(page) < candidateBatchSize { + break + } + } + } + return results, nil +} + +// Candidate 读取单项资产的候选资格事实;不在窗口内或资格不足时返回 (nil, nil)。 +// +// 供续费事务在锁内重读使用:读取句柄由调用方事务提供,重读结果与后续写入处于同一隔离视图。 +// windowDays 必须传入当前配置值——窗口是触发条件而不是资格不变式,用常量上限会让「人工已完成续购 +// 把最终到期推远」被误判为「不在窗口」。 +func (q *Query) Candidate(ctx context.Context, assetType string, assetID uint, windowDays int) (*Candidate, error) { + if q == nil || q.db == nil { + return nil, errors.New(errors.CodeInternalError, "自动续费候选查询未配置") + } + if assetType != constants.AssetWalletResourceTypeIotCard && assetType != constants.AssetWalletResourceTypeDevice { + return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") + } + if err := validateWindowDays(windowDays); err != nil { + return nil, err + } + assets, err := q.assetsByIDs(ctx, assetType, []uint{assetID}) + if err != nil { + return nil, err + } + resolved, err := q.resolve(ctx, assets, windowDays) + if err != nil { + return nil, err + } + if len(resolved) == 0 { + return nil, nil + } + return &resolved[0], nil +} + +// MainUsageState 是一项资产在锁后重读时的主套餐资格事实。 +// +// HasPendingMainPackage 是资格不变式(人工已完成续购 / 不叠加周期),MustCheckBeforeWindow 的语义是: +// 它必须先于窗口判定被检查,否则「人工已把最终到期推远」会被窗口判定误判为失败。 +type MainUsageState struct { + CurrentUsageID uint + CurrentPackageID uint + Generation int + HasPendingMainPackage bool +} + +// MainUsageStateOf 读取单项资产的待生效主套餐与当前主套餐事实,不做任何窗口判定。 +func (q *Query) MainUsageStateOf(ctx context.Context, assetType string, assetID uint) (MainUsageState, error) { + state := MainUsageState{} + if q == nil || q.db == nil { + return state, errors.New(errors.CodeInternalError, "自动续费候选查询未配置") + } + usages, err := q.mainUsages(ctx, assetType, []uint{assetID}) + if err != nil { + return state, err + } + items := usages[assetID] + if len(items) == 0 { + return state, nil + } + state.CurrentUsageID = items[0].ID + state.CurrentPackageID = items[0].PackageID + state.Generation = items[0].Generation + for _, usage := range items { + if usage.Status == constants.PackageUsageStatusPending { + state.HasPendingMainPackage = true + break + } + } + return state, nil +} + +// validateWindowDays 校验到期前天数落在配置允许范围内。 +func validateWindowDays(windowDays int) error { + if windowDays < constants.AssetAutoRenewalMinDaysBeforeExpiry || windowDays > constants.AssetAutoRenewalMaxDaysBeforeExpiry { + return errors.New(errors.CodeInvalidParam, "自动续费到期前天数超出允许范围") + } + return nil +} + +// OpenManualMainPackageOrder 判断该资产是否存在未关闭的个人资产钱包主套餐订单。 +// +// 未关闭指订单仍为待支付;主套餐订单指订单存在包类型为正式套餐的明细快照。 +// 该查询表达「手动续购优先」的第二个条件:人工订单在途时自动任务必须跳过。 +func (q *Query) OpenManualMainPackageOrder(ctx context.Context, walletID uint, assetType string, assetID uint) (bool, error) { + if q == nil || q.db == nil { + return false, errors.New(errors.CodeInternalError, "自动续费候选查询未配置") + } + if walletID == 0 || assetID == 0 { + return false, nil + } + var assetColumn string + var orderType string + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + assetColumn, orderType = "iot_card_id", model.OrderTypeSingleCard + case constants.AssetWalletResourceTypeDevice: + assetColumn, orderType = "device_id", model.OrderTypeDevice + default: + return false, errors.New(errors.CodeInvalidParam, "资产类型无效") + } + var count int64 + err := q.db.WithContext(ctx).Model(&model.Order{}). + Where("payment_status = ? AND buyer_type = ? AND order_type = ?", model.PaymentStatusPending, model.BuyerTypePersonal, orderType). + Where("asset_wallet_reservation_wallet_id = ? AND "+assetColumn+" = ?", walletID, assetID). + Where(`EXISTS ( + SELECT 1 FROM tb_order_item oi + WHERE oi.order_id = tb_order.id AND oi.deleted_at IS NULL AND oi.package_type = ? + )`, constants.PackageTypeFormal). + Count(&count).Error + if err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询资产在途人工订单失败") + } + return count > 0, nil +} + +// candidateBatchSize 是候选解析的单批上限:与恢复扫描同量级,使 IN 参数个数与单批中间结果有界。 +// 该值只约束「一批」,不截断候选结果集——每批按主键升序推进,直到本批不足一批为止。 +const candidateBatchSize = constants.AssetAutoRenewalRecoveryBatchSize + +// assetCandidatePage 按主键游标取一页候选资产(跨卡与设备统一按 id 升序推进)。 +func (q *Query) assetCandidatePage(ctx context.Context, assetType string, windowDays int, afterID uint, limit int) ([]assetCandidate, error) { + cutoff := dateInShanghai(q.now()).AddDate(0, 0, windowDays+1) + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + return q.cardCandidatePage(ctx, cutoff, afterID, limit) + case constants.AssetWalletResourceTypeDevice: + return q.deviceCandidatePage(ctx, cutoff, afterID, limit) + default: + return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") + } +} + +func (q *Query) cardCandidatePage(ctx context.Context, cutoff time.Time, afterID uint, limit int) ([]assetCandidate, error) { + var rows []model.IotCard + if err := q.db.WithContext(ctx).Model(&model.IotCard{}). + Select("id, iccid, shop_id"). + Where("is_standalone = ?", true). + Where("id > ?", afterID). + Where(expiringAssetExistsClause("iot_card_id"), []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, cutoff). + Order("id ASC").Limit(limit). + Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费卡候选失败") + } + results := make([]assetCandidate, 0, len(rows)) + for _, row := range rows { + results = append(results, assetCandidate{ + AssetType: constants.AssetWalletResourceTypeIotCard, AssetID: row.ID, Identifier: row.ICCID, ShopID: row.ShopID, + }) + } + return results, nil +} + +func (q *Query) deviceCandidatePage(ctx context.Context, cutoff time.Time, afterID uint, limit int) ([]assetCandidate, error) { + var rows []model.Device + if err := q.db.WithContext(ctx).Model(&model.Device{}). + Select("id, virtual_no, imei, shop_id"). + Where("id > ?", afterID). + Where(expiringAssetExistsClause("device_id"), []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, cutoff). + Order("id ASC").Limit(limit). + Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费设备候选失败") + } + results := make([]assetCandidate, 0, len(rows)) + for _, row := range rows { + identifier := row.VirtualNo + if identifier == "" { + identifier = row.IMEI + } + results = append(results, assetCandidate{ + AssetType: constants.AssetWalletResourceTypeDevice, AssetID: row.ID, Identifier: identifier, ShopID: row.ShopID, + }) + } + return results, nil +} + +// expiringAssetExistsClause 是候选预筛:资产存在未退款主套餐且已有生效/已用完记录临近窗口。 +// 窗口天数可配置,因此不能复用临期列表绑定 15 天常量候选查询;到期口径本身仍由 packageexpiry 推算。 +func expiringAssetExistsClause(assetColumn string) string { + return `EXISTS ( + SELECT 1 FROM tb_package_usage pu + WHERE pu.` + assetColumn + ` = tb_` + assetTableName(assetColumn) + `.id AND pu.deleted_at IS NULL + AND pu.master_usage_id IS NULL AND pu.refund_id IS NULL + AND pu.status IN ? AND pu.expires_at IS NOT NULL AND pu.expires_at < ? + )` +} + +func assetTableName(assetColumn string) string { + if assetColumn == "device_id" { + return "device" + } + return "iot_card" +} + +func (q *Query) assetsByIDs(ctx context.Context, assetType string, assetIDs []uint) ([]assetCandidate, error) { + if len(assetIDs) == 0 { + return nil, nil + } + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + var rows []model.IotCard + if err := q.db.WithContext(ctx).Model(&model.IotCard{}).Select("id, iccid, shop_id"). + Where("id IN ? AND is_standalone = ?", assetIDs, true).Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费卡失败") + } + results := make([]assetCandidate, 0, len(rows)) + for _, row := range rows { + results = append(results, assetCandidate{ + AssetType: constants.AssetWalletResourceTypeIotCard, AssetID: row.ID, Identifier: row.ICCID, ShopID: row.ShopID, + }) + } + return results, nil + case constants.AssetWalletResourceTypeDevice: + var rows []model.Device + if err := q.db.WithContext(ctx).Model(&model.Device{}).Select("id, virtual_no, imei, shop_id"). + Where("id IN ?", assetIDs).Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费设备失败") + } + results := make([]assetCandidate, 0, len(rows)) + for _, row := range rows { + identifier := row.VirtualNo + if identifier == "" { + identifier = row.IMEI + } + results = append(results, assetCandidate{ + AssetType: constants.AssetWalletResourceTypeDevice, AssetID: row.ID, Identifier: identifier, ShopID: row.ShopID, + }) + } + return results, nil + default: + return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") + } +} + +func (q *Query) resolve(ctx context.Context, assets []assetCandidate, windowDays int) ([]Candidate, error) { + if len(assets) == 0 { + return nil, nil + } + grouped := map[string][]assetCandidate{ + constants.AssetWalletResourceTypeIotCard: {}, + constants.AssetWalletResourceTypeDevice: {}, + } + for _, asset := range assets { + grouped[asset.AssetType] = append(grouped[asset.AssetType], asset) + } + results := make([]Candidate, 0, len(assets)) + for _, assetType := range []string{constants.AssetWalletResourceTypeIotCard, constants.AssetWalletResourceTypeDevice} { + items := grouped[assetType] + if len(items) == 0 { + continue + } + ids := make([]uint, 0, len(items)) + identifiers := make(map[uint]assetCandidate, len(items)) + for _, item := range items { + ids = append(ids, item.AssetID) + identifiers[item.AssetID] = item + } + estimates, err := q.expiry.ResolveBatch(ctx, assetType, ids) + if err != nil { + return nil, err + } + usages, err := q.mainUsages(ctx, assetType, ids) + if err != nil { + return nil, err + } + customers, err := q.customerBindings(ctx, assetType, ids) + if err != nil { + return nil, err + } + for _, id := range ids { + item := identifiers[id] + candidate, ok := buildCandidate(assetType, item, estimates[id], usages[id], customers[id], windowDays) + if ok { + results = append(results, candidate) + } + } + } + return results, nil +} + +// buildCandidate 按窗口与资格口径装配候选;任一条不满足即返回 ok=false。 +func buildCandidate( + assetType string, + asset assetCandidate, + estimate dto.PackageExpiryEstimate, + usages []*model.PackageUsage, + customerID uint, + windowDays int, +) (Candidate, bool) { + if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact { + return Candidate{}, false + } + if estimate.DaysUntilFinalExpiry == nil || estimate.EstimatedFinalExpiresAt == nil { + return Candidate{}, false + } + days := *estimate.DaysUntilFinalExpiry + if days < 0 || days > windowDays { + return Candidate{}, false + } + if len(usages) == 0 { + return Candidate{}, false + } + current := usages[0] + pending := false + for _, usage := range usages { + if usage.Status == constants.PackageUsageStatusPending { + pending = true + break + } + } + return Candidate{ + AssetType: assetType, AssetID: asset.AssetID, Identifier: asset.Identifier, ShopID: asset.ShopID, + CustomerID: customerID, CurrentUsageID: current.ID, CurrentPackageID: current.PackageID, + Generation: current.Generation, FinalExpiresAt: *estimate.EstimatedFinalExpiresAt, + DaysUntilFinalExpiry: days, HasPendingMainPackage: pending, + }, true +} + +// mainUsages 按临期口径读取每项资产的未退款主套餐使用记录:仅主套餐(无主套餐归属)、未退款, +// 状态为待生效/生效中/已用完,按 priority、created_at、id 升序,第一条即当前主套餐。 +func (q *Query) mainUsages(ctx context.Context, assetType string, assetIDs []uint) (map[uint][]*model.PackageUsage, error) { + results := make(map[uint][]*model.PackageUsage, len(assetIDs)) + if len(assetIDs) == 0 { + return results, nil + } + column, ok := assetIDColumn(assetType) + if !ok { + return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") + } + var usages []*model.PackageUsage + if err := q.db.WithContext(ctx). + Where(column+" IN ?", assetIDs). + Where("master_usage_id IS NULL AND refund_id IS NULL"). + Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). + Order("priority ASC, created_at ASC, id ASC"). + Find(&usages).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费主套餐失败") + } + for _, usage := range usages { + assetID := usage.IotCardID + if assetType == constants.AssetWalletResourceTypeDevice { + assetID = usage.DeviceID + } + results[assetID] = append(results[assetID], usage) + } + return results, nil +} + +func assetIDColumn(assetType string) (string, bool) { + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + return "iot_card_id", true + case constants.AssetWalletResourceTypeDevice: + return "device_id", true + default: + return "", false + } +} + +// customerBindings 批量解析资产当前绑定的个人客户:卡按虚拟号或 ICCID 关联, +// 设备按虚拟号或 IMEI 关联,口径与既有临期提醒接收人解析一致。 +func (q *Query) customerBindings(ctx context.Context, assetType string, assetIDs []uint) (map[uint]uint, error) { + results := make(map[uint]uint, len(assetIDs)) + if len(assetIDs) == 0 { + return results, nil + } + var rows []struct { + AssetID uint + CustomerID uint + } + var query *gorm.DB + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + query = q.db.WithContext(ctx).Raw(` + SELECT c.id AS asset_id, b.customer_id + FROM tb_iot_card c + JOIN tb_personal_customer_device b ON c.virtual_no <> '' AND b.virtual_no = c.virtual_no + WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = ? + UNION + SELECT c.id AS asset_id, b.customer_id + FROM tb_iot_card c + JOIN tb_personal_customer_iccid b ON b.iccid IN (c.iccid_19, c.iccid_20) + WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = ? + `, assetIDs, constants.StatusEnabled, assetIDs, constants.StatusEnabled) + case constants.AssetWalletResourceTypeDevice: + query = q.db.WithContext(ctx).Raw(` + SELECT d.id AS asset_id, b.customer_id + FROM tb_device d + JOIN tb_personal_customer_device b ON b.virtual_no = d.virtual_no + WHERE d.id IN ? AND d.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = ? + UNION + SELECT d.id AS asset_id, b.customer_id + FROM tb_device d + JOIN tb_personal_customer_device b ON d.imei <> '' AND b.virtual_no = d.imei + WHERE d.id IN ? AND d.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = ? + `, assetIDs, constants.StatusEnabled, assetIDs, constants.StatusEnabled) + default: + return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") + } + if err := query.Scan(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询自动续费资产个人客户失败") + } + for _, row := range rows { + if row.CustomerID == 0 { + continue + } + if _, exists := results[row.AssetID]; !exists { + results[row.AssetID] = row.CustomerID + } + } + return results, nil +} + +// dateInShanghai 把时间归一到东八区当日零点,与既有最终到期推算使用同一时区口径。 +func dateInShanghai(value time.Time) time.Time { + local := value.In(shanghaiLocation) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation) +} + +// Today 返回当前上海自然日,供触发日期与每日幂等键复用。 +func Today(now time.Time) time.Time { + return dateInShanghai(now) +} diff --git a/internal/query/notification/query.go b/internal/query/notification/query.go index f58ae97..66044ba 100644 --- a/internal/query/notification/query.go +++ b/internal/query/notification/query.go @@ -197,6 +197,7 @@ func personalNotificationScope(db *gorm.DB, customerID uint, now time.Time) *gor constants.NotificationTypeExchangeShippingCreated, constants.NotificationTypeH5PopupRiskExchange, constants.NotificationTypeH5PopupOperation, + constants.NotificationTypeAssetAutoRenewalFailed, }, now, ) diff --git a/internal/routes/admin.go b/internal/routes/admin.go index ad92730..8a0510d 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -106,6 +106,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.PackageTrafficAlert != nil { registerPackageTrafficAlertRoutes(authGroup, handlers.PackageTrafficAlert, doc, basePath) } + if handlers.AssetAutoRenewal != nil { + registerAssetAutoRenewalRoutes(authGroup, handlers.AssetAutoRenewal, doc, basePath) + } if handlers.ShopPackageBatchAllocation != nil { registerShopPackageBatchAllocationRoutes(authGroup, handlers.ShopPackageBatchAllocation, doc, basePath) } diff --git a/internal/routes/asset_auto_renewal.go b/internal/routes/asset_auto_renewal.go new file mode 100644 index 0000000..29495d3 --- /dev/null +++ b/internal/routes/asset_auto_renewal.go @@ -0,0 +1,43 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// registerAssetAutoRenewalRoutes 注册资产钱包自动续费配置的读写路由。 +// 沿用超管/平台路由组级 gate 先例:代理、企业与个人客户账号一律 403,无任何读取或修改入口。 +func registerAssetAutoRenewalRoutes(router fiber.Router, handler *admin.AssetAutoRenewalConfigHandler, doc *openapi.Generator, basePath string) { + group := router.Group("", func(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) + } + return c.Next() + }) + + path := basePath + "/asset-auto-renewal-config" + + Register(group, doc, path, "GET", "", handler.GetConfig, RouteSpec{ + Summary: "查询资产钱包自动续费配置", + Description: "返回唯一一份全局配置的总开关、适用范围、指定主套餐集合、到期前天数与配置版本;仅超级管理员与平台账号可访问", + Tags: []string{"资产钱包"}, + Output: new(dto.AssetAutoRenewalConfigResponse), + Auth: true, + }) + + Register(group, doc, path, "PUT", "", handler.UpdateConfig, RouteSpec{ + Summary: "保存资产钱包自动续费配置", + Description: "指定范围时集合必须非空且只能选择当前可售主套餐;保存记录操作者与前后值快照并递增配置版本,只影响后续扫描", + Tags: []string{"资产钱包"}, + Body: new(dto.UpdateAssetAutoRenewalConfigRequest), + Output: new(dto.AssetAutoRenewalConfigResponse), + Auth: true, + }) +} diff --git a/internal/service/iot_card/auto_renewal.go b/internal/service/iot_card/auto_renewal.go new file mode 100644 index 0000000..bbd601e --- /dev/null +++ b/internal/service/iot_card/auto_renewal.go @@ -0,0 +1,296 @@ +package iot_card + +import ( + "context" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// AutoRenewalResumeOutcome 是资产钱包自动续费成功后一次复机调用可安全记录的结果摘要。 +// +// 它只承载可靠任务回填所需的事实:是否真的发起过复机、Integration Log 标识、结果分类与可安全 +// 展示的原因,不携带渠道报文原文、凭证或内部错误细节。 +type AutoRenewalResumeOutcome struct { + // Applied 为 false 表示本次未满足可复机条件,没有发起任何复机调用。 + Applied bool + // IntegrationID 是本次复机 Gateway 调用的 Integration Log 标识,未发起时为空。 + IntegrationID string + // Result 取值 constants.AuditResultSuccess / Failed / Unknown。 + Result string + // SafeReason 是可安全记录的不复机原因或失败原因,成功时为空。 + SafeReason string +} + +// AutoRenewalResumeReady 判断续费成功后的资产是否满足自动复机条件。 +// +// 判定规则全部复用既有单一来源(风险网关扩展、可轮询停因、通道阈值锁、有效主套餐、流量未耗尽、 +// 实名),不复制任何规则;本入口与既有「若已停机则复机」入口的区别只在于:它明确区分 +// 「条件不成立」与「已发起复机」,因此可作为可靠任务的结果来源。 +// 卡资产按该卡自身判定;设备资产按设备下因轮询原因停机的卡逐个判定,任一卡满足即返回可复机。 +func (s *StopResumeService) AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error) { + cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID) + if err != nil { + return false, "", err + } + if len(cards) == 0 { + return false, "该资产当前没有可恢复停机的卡", nil + } + reason := "" + for _, card := range cards { + ready, cardReason, readyErr := s.autoRenewalCardReady(ctx, card) + if readyErr != nil { + return false, "", readyErr + } + if ready { + return true, "", nil + } + if reason == "" { + reason = cardReason + } + } + if reason == "" { + reason = "该资产当前没有可恢复停机的卡" + } + return false, reason, nil +} + +// ResumeAssetForAutoRenewal 在可复机判定成立时执行复机,并返回结果分类。 +// +// 复用既有复机重试、Integration Log、统一审计与观测序列:成功时在同一事务写回 network_status=online、 +// resumed_at,并清除可轮询停因。判定不成立时返回 Applied=false 且不发起任何调用; +// 判定成立但调用失败或结果未知时,续费事实不回滚,结果交可靠任务与恢复扫描收敛。 +func (s *StopResumeService) ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (AutoRenewalResumeOutcome, error) { + cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID) + if err != nil { + return AutoRenewalResumeOutcome{}, err + } + reason := "该资产当前没有可恢复停机的卡" + applied := false + outcome := AutoRenewalResumeOutcome{Result: constants.AuditResultSuccess} + for _, card := range cards { + ready, cardReason, readyErr := s.autoRenewalCardReady(ctx, card) + if readyErr != nil { + return AutoRenewalResumeOutcome{}, readyErr + } + if !ready { + if reason == "该资产当前没有可恢复停机的卡" && cardReason != "" { + reason = cardReason + } + continue + } + applied = true + cardOutcome, resumeErr := s.autoRenewalResumeCard(ctx, card) + if cardOutcome.IntegrationID != "" { + outcome.IntegrationID = cardOutcome.IntegrationID + } + switch cardOutcome.Result { + case constants.AuditResultFailed: + outcome.Result = constants.AuditResultFailed + outcome.SafeReason = cardOutcome.SafeReason + case constants.AuditResultUnknown: + if outcome.Result == constants.AuditResultSuccess { + outcome.Result = constants.AuditResultUnknown + outcome.SafeReason = cardOutcome.SafeReason + } + } + if resumeErr != nil { + s.logger.Warn("自动续费成功后复机未完成", + zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), + zap.Uint("card_id", card.ID), zap.Error(resumeErr)) + } + if outcome.Result == constants.AuditResultFailed { + break + } + } + if !applied { + return AutoRenewalResumeOutcome{Applied: false, Result: constants.AuditResultSuccess, SafeReason: reason}, nil + } + outcome.Applied = true + return outcome, nil +} + +// QueryAutoRenewalResumeState 只查询运营商卡状态,供恢复扫描回填自动续费的复机结果。 +// +// 不发起任何复机调用:只做状态查询,并按既有网关状态映射规则给出是否已复机。 +// 查询失败把 known 置 false 并返回错误,调用方必须按「仍未确认」处理,不得判为失败终态。 +func (s *StopResumeService) QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (bool, bool, string, error) { + cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID) + if err != nil { + return false, false, "", err + } + if len(cards) == 0 { + return false, false, "", errors.New(errors.CodeNotFound, "该资产当前没有可查询的卡") + } + if s.gatewayClient == nil { + return false, false, "", errors.New(errors.CodeInternalError, "Gateway 未配置,无法查询卡状态") + } + integrationID := "" + online := true + for _, card := range cards { + attempt, startErr := s.startCardCommandAttempt(ctx, card, constants.IntegrationOperationGatewayNetwork, + constants.CardObservationSceneAssetAutoRenewalResumeRecovery, cardCommandSeriesKey(ctx), 1) + if startErr != nil { + return false, false, integrationID, startErr + } + integrationID = attempt.log.IntegrationID + response, callErr := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) + if callErr != nil { + if completeErr := s.completeCardCommandAttempt(ctx, attempt, callErr, false); completeErr != nil { + s.logger.Error("终结自动续费复机状态查询 Integration Log 失败", + zap.String("integration_id", attempt.log.IntegrationID), zap.Error(completeErr)) + } + return false, false, integrationID, callErr + } + if completeErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); completeErr != nil { + s.logger.Error("终结自动续费复机状态查询 Integration Log 失败", + zap.String("integration_id", attempt.log.IntegrationID), zap.Error(completeErr)) + return false, false, integrationID, completeErr + } + status, known := carddomain.MapGatewayNetworkStatus(response.CardStatus, response.Extend) + if !known { + return false, false, integrationID, nil + } + if status != constants.NetworkStatusOnline { + online = false + } + } + return online, true, integrationID, nil +} + +// autoRenewalResumeCards 解析自动续费资产对应的待复机卡:卡资产取自身,设备资产取该设备下 +// 因轮询原因停机的绑定卡(复用既有设备卡查询,不另立绑定关系口径)。 +func (s *StopResumeService) autoRenewalResumeCards(ctx context.Context, assetType string, assetID uint) ([]*model.IotCard, error) { + if assetID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "自动续费复机资产无效") + } + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + card, err := s.iotCardStore.GetByID(ctx, assetID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费复机卡事实失败") + } + return []*model.IotCard{card}, nil + case constants.AssetWalletResourceTypeDevice: + cards, err := s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, assetID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备待复机卡失败") + } + return cards, nil + default: + return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") + } +} + +// autoRenewalCardReady 判断单卡是否满足自动复机条件;返回的 reason 是可安全记录的不满足原因。 +func (s *StopResumeService) autoRenewalCardReady(ctx context.Context, card *model.IotCard) (bool, string, error) { + if card == nil || card.ID == 0 { + return false, "", errors.New(errors.CodeInvalidParam, "自动续费复机卡无效") + } + if card.NetworkStatus == constants.NetworkStatusOnline { + return false, "卡当前已在线,无需复机", nil + } + if isRiskGatewayExtend(card.GatewayExtend) { + return false, "网关扩展状态为风险停机或已销户,不自动复机", nil + } + if !isPollingStopReason(card.StopReason) { + return false, "停因不属于可轮询复机范围,不自动复机", nil + } + blocked, err := s.channelThresholdBlocked(ctx, card) + if err != nil { + return false, "", err + } + if blocked { + return false, "卡当前计费周期持有通道阈值停机锁,不自动复机", nil + } + hasPackage, err := s.hasValidPackage(ctx, card) + if err != nil { + return false, "", err + } + if !hasPackage { + return false, "无有效主套餐,不自动复机", nil + } + exhausted, err := s.isTrafficExhausted(ctx, card) + if err != nil { + return false, "", err + } + if exhausted { + return false, "套餐流量已耗尽,不自动复机", nil + } + realnameOK, err := s.isRealnameOK(ctx, card) + if err != nil { + return false, "", err + } + if !realnameOK { + return false, "实名要求未满足,不自动复机", nil + } + return true, "", nil +} + +// autoRenewalResumeCard 对单卡执行自动续费后的复机并返回结果分类。 +// +// 复用既有每卡复机分布式锁(constants.RedisCardResumeLockKey,与 resumeSingleCard 同一把锁与 TTL): +// 认领字段只保证「同一尝试至多一次」,跨链路(轮询/套餐激活/流量重置 vs 自动续费)仍可能同时复机, +// 该锁是既有链路共用的幂等闸门。抢不到锁表示另一条链路正在复机,本次不重复调用运营商,按未发起处理。 +func (s *StopResumeService) autoRenewalResumeCard(ctx context.Context, card *model.IotCard) (AutoRenewalResumeOutcome, error) { + if s.redis != nil { + lockKey := constants.RedisCardResumeLockKey(card.ID) + locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result() + if lockErr != nil { + return AutoRenewalResumeOutcome{}, errors.Wrap(errors.CodeRedisError, lockErr, "获取复机分布式锁失败") + } + if !locked { + return AutoRenewalResumeOutcome{ + Applied: false, Result: constants.AuditResultSuccess, + SafeReason: "该卡正在被其他链路复机,本次不重复调用", + }, nil + } + defer func() { _ = s.redis.Del(ctx, lockKey) }() + } + actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动续费成功后恢复 IoT 卡网络" + attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary) + if err != nil { + return AutoRenewalResumeOutcome{ + Applied: true, IntegrationID: integrationID, Result: cardCommandAuditResult(err), + SafeReason: "Gateway 复机请求未成功", + }, err + } + fields := map[string]any{ + "network_status": constants.NetworkStatusOnline, + "resumed_at": time.Now(), + } + // 判定已限定停因属于可轮询复机范围,复机成功后清除停因,与既有轮询复机口径一致。 + if isPollingStopReason(card.StopReason) { + fields["stop_reason"] = "" + } + if err := s.updateCardAndAppendNetworkSeries(ctx, card, fields, + constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil { + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil { + s.logger.Error("终结自动续费复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr)) + } + s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown, + attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err) + // 运营商已成功但本地回写失败:按结果未知交恢复扫描查询确认,续费事实不回滚。 + return AutoRenewalResumeOutcome{ + Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultUnknown, + SafeReason: "本地状态回写失败,结果待核对", + }, err + } + s.reschedulePolling(ctx, card.ID) + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil { + return AutoRenewalResumeOutcome{ + Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess, + }, errors.Wrap(errors.CodeDatabaseError, logErr, "终结自动续费复机 Integration Log 失败") + } + s.logger.Info("自动续费成功后复机完成", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID)) + return AutoRenewalResumeOutcome{ + Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess, + }, nil +} diff --git a/internal/store/postgres/asset_auto_renewal_store.go b/internal/store/postgres/asset_auto_renewal_store.go new file mode 100644 index 0000000..75d0c6b --- /dev/null +++ b/internal/store/postgres/asset_auto_renewal_store.go @@ -0,0 +1,314 @@ +package postgres + +import ( + "context" + stderrors "errors" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// autoRenewalAttemptConstraint 是自动续费尝试的部分唯一索引名,用于把 23505 精确识别为「当日已尝试」。 +// 部分唯一索引不能与 GORM OnConflict 组合(谓词未声明时无法命中,见 KNOWN-ISSUE-001), +// 因此占位写入使用显式插入 + 23505 识别。 +const autoRenewalAttemptConstraint = "uq_asset_auto_renewal_attempt_key" + +// shanghaiLocation 是自动续费使用的东八区口径:触发日期与跨日比较都以该时区的自然日为准, +// 与候选查询、尝试唯一键的日期来源保持一致。 +var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +// AssetAutoRenewalConfigStore 是单行自动续费配置的数据访问层。 +// +// 配置只有一行(主键恒为 1):读取按主键取,保存必须在调用方事务内先取行锁再写回, +// 使并发保存串行化而不是静默覆盖。 +type AssetAutoRenewalConfigStore struct { + db *gorm.DB +} + +// NewAssetAutoRenewalConfigStore 创建自动续费配置 Store。 +func NewAssetAutoRenewalConfigStore(db *gorm.DB) *AssetAutoRenewalConfigStore { + return &AssetAutoRenewalConfigStore{db: db} +} + +// Get 读取唯一一行自动续费配置。 +func (s *AssetAutoRenewalConfigStore) Get(ctx context.Context) (*model.AssetAutoRenewalConfig, error) { + var config model.AssetAutoRenewalConfig + if err := s.db.WithContext(ctx).First(&config, constants.AssetAutoRenewalConfigSingletonID).Error; err != nil { + return nil, err + } + return &config, nil +} + +// LockInTx 在调用方事务内对配置行加锁读取,作为并发保存的串行化点。 +func (s *AssetAutoRenewalConfigStore) LockInTx(ctx context.Context, tx *gorm.DB) (*model.AssetAutoRenewalConfig, error) { + var config model.AssetAutoRenewalConfig + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + First(&config, constants.AssetAutoRenewalConfigSingletonID).Error; err != nil { + return nil, err + } + return &config, nil +} + +// SaveInTx 在调用方事务内写回配置的开关、范围、集合、天数、版本与操作者。 +func (s *AssetAutoRenewalConfigStore) SaveInTx(ctx context.Context, tx *gorm.DB, config *model.AssetAutoRenewalConfig, operatorID uint) error { + result := tx.WithContext(ctx).Model(&model.AssetAutoRenewalConfig{}). + Where("id = ?", constants.AssetAutoRenewalConfigSingletonID). + Updates(map[string]any{ + "enabled": config.Enabled, + "scope": config.Scope, + "package_ids": config.PackageIDs, + "days_before_expiry": config.DaysBeforeExpiry, + "config_version": config.ConfigVersion, + "updater": operatorID, + "updated_at": time.Now(), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New(errors.CodeInternalError, "自动续费配置行不存在") + } + return nil +} + +// AssetAutoRenewalAttemptStore 是自动续费尝试记录的数据访问层。 +// +// 占位写入与失败/跳过终态各走独立短事务;成功终态与续费事实同事务更新。当日重复尝试由唯一键 +// 冲突表达,终态写入一律以记录仍处于允许该终态的状态为条件(ENG-CONC-001)。 +type AssetAutoRenewalAttemptStore struct { + db *gorm.DB +} + +// NewAssetAutoRenewalAttemptStore 创建自动续费尝试 Store。 +func NewAssetAutoRenewalAttemptStore(db *gorm.DB) *AssetAutoRenewalAttemptStore { + return &AssetAutoRenewalAttemptStore{db: db} +} + +// Load 按主键读取尝试记录。 +func (s *AssetAutoRenewalAttemptStore) Load(ctx context.Context, id uint) (*model.AssetAutoRenewalAttempt, error) { + var attempt model.AssetAutoRenewalAttempt + if err := s.db.WithContext(ctx).First(&attempt, id).Error; err != nil { + return nil, err + } + return &attempt, nil +} + +// CountByAsset 统计该资产截至触发日的尝试条数,用于跨日尝试次数。 +// +// 触发日期是 DATE 列:比较必须走显式日期参数(?::date),不得把 time.Time 直接与东八区零点比较, +// 否则会因时区换算差一天。 +func (s *AssetAutoRenewalAttemptStore) CountByAsset(ctx context.Context, assetType string, assetID uint, triggerDate time.Time) (int64, error) { + var count int64 + if err := s.db.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}). + Where("asset_type = ? AND asset_id = ? AND trigger_date <= ?::date", assetType, assetID, shanghaiDateValue(triggerDate)). + Count(&count).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计跨日自动续费尝试次数失败") + } + return count, nil +} + +// CreatePlaceholder 写入当次尝试占位。 +// +// 唯一键冲突(该资产当日已有尝试)返回 (false, nil),由调用方跳过该资产;其余错误原样返回。 +// 单条 INSERT 本身就是原子的,因此这里**不使用显式事务包裹**:既有的保存点写法 +// (internal/application/carrierthreshold/lock_store.go 的 createLockInTx)是为了在**已经处于 +// 事务中**插入时隔离冲突;本方法要么独立执行、要么由调用方决定事务边界,没有需要隔离的外层事务。 +// 反过来,若在事务闭包内吞掉 23505 后 return nil,GORM 会去提交一个已被 PostgreSQL 中止的事务 +// (ErrTxCommitRollback),「当日已尝试」这一可观察结果就永远不会成立。 +func (s *AssetAutoRenewalAttemptStore) CreatePlaceholder(ctx context.Context, attempt *model.AssetAutoRenewalAttempt) (bool, error) { + result := s.db.WithContext(ctx).Create(attempt) + if result.Error != nil { + if isAutoRenewalAttemptConflict(result.Error) { + return false, nil + } + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "写入自动续费尝试占位失败") + } + return result.RowsAffected == 1, nil +} + +// FinalizeInTx 在调用方事务内以「记录仍非终态」为条件写入终态。 +// 成功终态与续费事实同事务,因此本方法只接受调用方事务,不自行开启事务。 +func (s *AssetAutoRenewalAttemptStore) FinalizeInTx(ctx context.Context, tx *gorm.DB, attemptID uint, updates map[string]any) (bool, error) { + return finalizeAttempt(ctx, tx, attemptID, updates) +} + +// Finalize 以独立短事务写入失败/跳过终态。 +// +// 该短事务与已回滚的续费事务不共用连接或事务(ENG-TX-001 例外),条件更新依据尝试记录仍非终态。 +func (s *AssetAutoRenewalAttemptStore) Finalize(ctx context.Context, attemptID uint, updates map[string]any) (bool, error) { + updated := false + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result, err := finalizeAttempt(ctx, tx, attemptID, updates) + if err != nil { + return err + } + updated = result + return nil + }) + if err != nil { + return false, err + } + return updated, nil +} + +func finalizeAttempt(ctx context.Context, tx *gorm.DB, attemptID uint, updates map[string]any) (bool, error) { + if len(updates) == 0 { + return false, errors.New(errors.CodeInvalidParam, "自动续费终态更新内容为空") + } + updates["updated_at"] = time.Now() + result := tx.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}). + Where("id = ? AND status = ?", attemptID, constants.AssetAutoRenewalAttemptStatusProcessing). + Updates(updates) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "写入自动续费终态失败") + } + return result.RowsAffected == 1, nil +} + +// ConvergeUnfinished 收敛触发日期早于指定自然日且仍未终态的尝试记录。 +// +// 占位后进程中断会让记录停在处理中;该记录既不能被当日重试,也不能永久悬空, +// 因此由后续扫描收敛为「失败 + interrupted」:failure_reason=interrupted 不属于四类通知原因, +// 收敛过程不发送任何通知(一个从未进入资金事务的占位行不该产生「订单失败」通知), +// 也不回写任何资金、订单与复机字段。触发日期是 DATE 列,比较走显式日期参数(?::date), +// 与「当日不重试」使用同一日期口径。返回收敛条数。 +func (s *AssetAutoRenewalAttemptStore) ConvergeUnfinished(ctx context.Context, before time.Time) (int64, error) { + result := s.db.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}). + Where("status = ? AND trigger_date < ?::date", constants.AssetAutoRenewalAttemptStatusProcessing, shanghaiDateValue(before)). + Updates(map[string]any{ + "status": constants.AssetAutoRenewalAttemptStatusFailed, + "failure_reason": constants.AssetAutoRenewalFailureInterrupted, + "failure_detail": "上次执行在占位后中断,未进入资金事务即结束;本记录仅作中断收敛,当日不重试", + "updated_at": time.Now(), + }) + if result.Error != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, result.Error, "收敛自动续费非终态尝试失败") + } + return result.RowsAffected, nil +} + +// shanghaiDateValue 把时间格式化为东八区自然日字符串,供 DATE 列比较使用。 +func shanghaiDateValue(value time.Time) string { + return value.In(shanghaiLocation).Format("2006-01-02") +} + +// ClaimResumeSubmission 以「已投递且尚未提交」为条件认领复机执行权。 +// +// 认领成功即取得至多一次的外部调用权;重复投递的 Outbox 事件不会产生第二次调用。 +func (s *AssetAutoRenewalAttemptStore) ClaimResumeSubmission(ctx context.Context, attemptID uint, now time.Time) (bool, error) { + result := s.db.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}). + Where("id = ? AND resume_status = ? AND resume_submitted_at IS NULL", + attemptID, constants.AssetAutoRenewalResumeStatusRequested). + Updates(map[string]any{"resume_submitted_at": now, "updated_at": now}) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "认领自动续费复机执行权失败") + } + return result.RowsAffected == 1, nil +} + +// MarkResumeOutcomeInTx 在调用方事务内条件回写复机结果,只接受记录仍处于期望状态时写入。 +// +// 与失败通知、失败审计同事务:三者要么一起提交,要么一起回滚,避免出现「终态已写、通知永久丢失」。 +func (s *AssetAutoRenewalAttemptStore) MarkResumeOutcomeInTx( + ctx context.Context, + tx *gorm.DB, + attemptID uint, + expectedStatuses []int, + status int, + integrationID, failureReason string, +) (bool, error) { + return markResumeOutcome(ctx, tx, attemptID, expectedStatuses, status, integrationID, failureReason) +} + +// MarkResumeOutcome 条件回写复机结果(单条 UPDATE 自带原子性,无需显式事务)。 +// +// 供只需要回填、无需同时投递通知的恢复扫描使用。 +func (s *AssetAutoRenewalAttemptStore) MarkResumeOutcome( + ctx context.Context, + attemptID uint, + expectedStatuses []int, + status int, + integrationID, failureReason string, +) (bool, error) { + return markResumeOutcome(ctx, s.db, attemptID, expectedStatuses, status, integrationID, failureReason) +} + +// markResumeOutcome 是复机结果条件回写的唯一实现。 +// integrationID 与 failureReason 只在非空时覆盖,避免未知结果把已记录的外部交互标识清空。 +func markResumeOutcome( + ctx context.Context, + handle *gorm.DB, + attemptID uint, + expectedStatuses []int, + status int, + integrationID, failureReason string, +) (bool, error) { + updates := map[string]any{"resume_status": status, "updated_at": time.Now()} + if integrationID != "" { + updates["resume_integration_id"] = integrationID + } + if failureReason != "" { + updates["resume_failure_reason"] = failureReason + } + if status == constants.AssetAutoRenewalResumeStatusSucceeded { + updates["resume_failure_reason"] = "" + } + result := handle.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}). + Where("id = ? AND resume_status IN ?", attemptID, expectedStatuses). + Updates(updates) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "回写自动续费复机结果失败") + } + return result.RowsAffected == 1, nil +} + +// MarkResumeAnomaly 把超过查询窗口仍无法确认的复机结果标记为需人工核对,退出自动扫描。 +func (s *AssetAutoRenewalAttemptStore) MarkResumeAnomaly(ctx context.Context, attemptID uint, reason string) (bool, error) { + result := s.db.WithContext(ctx).Model(&model.AssetAutoRenewalAttempt{}). + Where("id = ? AND resume_anomaly_flag = ?", attemptID, 0). + Updates(map[string]any{ + "resume_anomaly_flag": 1, + "resume_failure_reason": reason, + "updated_at": time.Now(), + }) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记自动续费复机异常失败") + } + return result.RowsAffected == 1, nil +} + +// ScanUnresolvedResumes 扫描仍需按只读查询收敛的复机子结果。 +// +// 范围只有两类:结果未知(需查询确认)与已提交但超过查询窗口仍未回写(进程中断)。 +// 已标记异常的记录退出扫描,不长期重复查询同一笔无法收敛的结果。 +func (s *AssetAutoRenewalAttemptStore) ScanUnresolvedResumes(ctx context.Context, now time.Time, limit int) ([]model.AssetAutoRenewalAttempt, error) { + if limit <= 0 { + limit = constants.AssetAutoRenewalRecoveryBatchSize + } + staleBefore := now.Add(-constants.AssetAutoRenewalResumeQueryWindow) + var attempts []model.AssetAutoRenewalAttempt + if err := s.db.WithContext(ctx). + Where("resume_anomaly_flag = ?", 0). + Where("resume_status = ? OR (resume_status = ? AND resume_submitted_at IS NOT NULL AND resume_submitted_at <= ?)", + constants.AssetAutoRenewalResumeStatusUnknown, + constants.AssetAutoRenewalResumeStatusRequested, staleBefore). + Order("id ASC").Limit(limit).Find(&attempts).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描未收敛的自动续费复机结果失败") + } + return attempts, nil +} + +// isAutoRenewalAttemptConflict 判断错误是否为尝试唯一键冲突,即「该资产当日已尝试」。 +func isAutoRenewalAttemptConflict(err error) bool { + var pgErr *pgconn.PgError + if !stderrors.As(err, &pgErr) { + return false + } + return pgErr.Code == "23505" && pgErr.ConstraintName == autoRenewalAttemptConstraint +} diff --git a/internal/store/postgres/asset_wallet_store.go b/internal/store/postgres/asset_wallet_store.go index 137b9e5..a1b3714 100644 --- a/internal/store/postgres/asset_wallet_store.go +++ b/internal/store/postgres/asset_wallet_store.go @@ -9,6 +9,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/redis/go-redis/v9" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // AssetWalletStore 资产钱包数据访问层 @@ -61,6 +62,18 @@ func (s *AssetWalletStore) CreateWithTx(ctx context.Context, tx *gorm.DB, wallet return tx.WithContext(ctx).Create(wallet).Error } +// LockByIDWithTx 在调用方事务内按主键加行锁读取资产钱包。 +// +// 供需要在扣款前串行化同一资产钱包的写用例复用:锁定行后重读余额与状态, +// 再走既有 DeductBalanceWithTx 的乐观版本条件更新。本方法只加行锁,不修改任何字段与既有约束。 +func (s *AssetWalletStore) LockByIDWithTx(ctx context.Context, tx *gorm.DB, id uint) (*model.AssetWallet, error) { + var wallet model.AssetWallet + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&wallet, id).Error; err != nil { + return nil, err + } + return &wallet, nil +} + // DeductBalanceWithTx 扣款(带事务,使用乐观锁) func (s *AssetWalletStore) DeductBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64, version int) error { // 使用乐观锁,检查可用余额是否充足 diff --git a/migrations/000229_add_asset_auto_renewal_config.down.sql b/migrations/000229_add_asset_auto_renewal_config.down.sql new file mode 100644 index 0000000..6b88b55 --- /dev/null +++ b/migrations/000229_add_asset_auto_renewal_config.down.sql @@ -0,0 +1,18 @@ +-- 回滚资产钱包自动续费全局配置表,与 up 严格成对。 +-- +-- 不可逆说明(ENG-MIG-001 例外条件):本迁移的 down 会删除 tb_asset_auto_renewal_config 与 +-- 其中的总开关、适用范围、指定套餐集合、到期前天数与配置版本。这些配置无法由数据库自身重建, +-- 只能依据 tb_audit_event 中资产钱包自动续费配置的前后值快照人工复核, +-- 因此 down 只在配置已无留存需求时执行。 +-- +-- 守卫:存在仍处于开启状态的配置行时直接阻断回滚,避免静默丢弃「已开启自动续费」的运营意图 +-- (关闭状态下删除只丢失关闭事实,风险可接受)。 + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tb_asset_auto_renewal_config WHERE enabled = 1 LIMIT 1) THEN + RAISE EXCEPTION '自动续费总开关仍处于开启状态,拒绝回滚以避免静默丢弃已生效的续费配置'; + END IF; +END $$; + +DROP TABLE IF EXISTS tb_asset_auto_renewal_config; diff --git a/migrations/000229_add_asset_auto_renewal_config.up.sql b/migrations/000229_add_asset_auto_renewal_config.up.sql new file mode 100644 index 0000000..cdc52d7 --- /dev/null +++ b/migrations/000229_add_asset_auto_renewal_config.up.sql @@ -0,0 +1,57 @@ +-- 资产钱包自动续费:新增全局单行配置表 tb_asset_auto_renewal_config。 +-- 背景:运营需要「资产钱包余额充足时在最终到期前自动续购当前有效主套餐」的总开关、适用范围与 +-- 到期前天数的统一配置。既有仓库没有任何单行配置表(000225 的 H5 弹窗配置是多行加优先级), +-- 因此本迁移新建一张被 CHECK 约束到单行(id 恒为 1)的全局配置表,并预置一行默认关闭。 +-- +-- 设计选择: +-- 1. 主键恒为 1:本能力只有一份全局生效配置,不按店铺、企业或个人客户分范围。单行约束由 +-- CHECK (id = 1) 与预置行共同保证,应用层保存走同一行的行锁串行化,不新增第二行。 +-- 2. enabled 默认 0 且严格 0/1:既有「启停新增语义使用 0=禁用、1=启用」的口径(ENG-STATE-001)。 +-- 默认关闭,保证迁移上线后不会立即产生续购订单。 +-- 3. scope 只允许 all/specified;两种范围的集合互斥关系由 CHECK 兜住: +-- specified 时 package_ids 必须非空,all 时必须为空数组。半配置(指定范围但集合为空)会被 +-- 解释为「谁都不符合范围」而静默不续购,属于必须由 Schema 兜住的风险。 +-- 4. days_before_expiry 限制 1..90:与规格的「正整数,上限 90」一致,0 天窗口会让提前续购失去意义。 +-- 5. config_version 是配置版本而非乐观锁:保存事务内自增,供尝试记录冻结快照追溯; +-- 并发保护用单行事务锁(SELECT ... FOR UPDATE),不用 version 条件更新(ENG-CONC-001 面向余额与状态机)。 +-- 6. creator/updater 记录维护账号 ID,系统写入为 0;本表没有软删列,配置行不删除。 +-- 7. 不使用数据库外键,package_ids 中的套餐由应用层显式校验。 + +CREATE TABLE tb_asset_auto_renewal_config ( + id BIGINT PRIMARY KEY, + enabled SMALLINT NOT NULL DEFAULT 0, + scope VARCHAR(16) NOT NULL DEFAULT 'all', + package_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + days_before_expiry INTEGER NOT NULL DEFAULT 15, + config_version BIGINT NOT NULL DEFAULT 1, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_asset_auto_renewal_config_singleton CHECK (id = 1), + CONSTRAINT ck_asset_auto_renewal_config_enabled CHECK (enabled IN (0, 1)), + CONSTRAINT ck_asset_auto_renewal_config_scope CHECK (scope IN ('all', 'specified')), + CONSTRAINT ck_asset_auto_renewal_config_package_ids CHECK (jsonb_typeof(package_ids) = 'array'), + CONSTRAINT ck_asset_auto_renewal_config_scope_packages CHECK ( + (scope = 'specified' AND jsonb_array_length(package_ids) >= 1) + OR (scope = 'all' AND jsonb_array_length(package_ids) = 0) + ), + CONSTRAINT ck_asset_auto_renewal_config_window CHECK (days_before_expiry BETWEEN 1 AND 90), + CONSTRAINT ck_asset_auto_renewal_config_version CHECK (config_version >= 1) +); + +COMMENT ON TABLE tb_asset_auto_renewal_config IS '资产钱包自动续费全局配置,主键恒为 1 的单行表,只影响后续扫描'; +COMMENT ON COLUMN tb_asset_auto_renewal_config.id IS '主键,恒为 1(CHECK 约束保证单行)'; +COMMENT ON COLUMN tb_asset_auto_renewal_config.enabled IS '总开关 0-关闭 1-开启;关闭后不再创建新的尝试与订单,既有尝试记录与通知保留'; +COMMENT ON COLUMN tb_asset_auto_renewal_config.scope IS '适用范围 all-全部主套餐 specified-指定主套餐'; +COMMENT ON COLUMN tb_asset_auto_renewal_config.package_ids IS '指定主套餐商品ID集合(jsonb 数组);scope=specified 时非空,scope=all 时为空数组'; +COMMENT ON COLUMN tb_asset_auto_renewal_config.days_before_expiry IS '统一到期前天数(正整数,1-90),触发窗口为最终到期剩余天数闭区间 0 至该值'; +COMMENT ON COLUMN tb_asset_auto_renewal_config.config_version IS '配置版本,每次保存事务内自增;尝试记录只保留触发时版本快照,不重算'; +COMMENT ON COLUMN tb_asset_auto_renewal_config.creator IS '创建人账号ID,系统写入为 0'; +COMMENT ON COLUMN tb_asset_auto_renewal_config.updater IS '最近更新人账号ID,系统写入为 0'; +COMMENT ON COLUMN tb_asset_auto_renewal_config.created_at IS '创建时间'; +COMMENT ON COLUMN tb_asset_auto_renewal_config.updated_at IS '最近更新时间'; + +-- 预置唯一一行且默认关闭:读取侧按主键 1 读取,缺失即视为部署异常。 +INSERT INTO tb_asset_auto_renewal_config (id, enabled, scope, package_ids, days_before_expiry, config_version) +VALUES (1, 0, 'all', '[]'::jsonb, 15, 1); diff --git a/migrations/000230_add_asset_auto_renewal_attempt.down.sql b/migrations/000230_add_asset_auto_renewal_attempt.down.sql new file mode 100644 index 0000000..5f6b70b --- /dev/null +++ b/migrations/000230_add_asset_auto_renewal_attempt.down.sql @@ -0,0 +1,28 @@ +-- 回滚资产钱包自动续费尝试记录表,与 up 严格成对。 +-- 删除顺序与 up 的创建顺序严格倒序:先删索引与唯一键,再删表(表内 CHECK 约束随表一并移除, +-- 不留残余约束与索引)。 +-- +-- 不可逆说明(ENG-MIG-001 例外条件):本迁移的 down 会删除 tb_asset_auto_renewal_attempt 与 +-- 其中的每日尝试、资金扣款、续费订单与复机结果快照。已发生的续费事实仍可通过 tb_order、 +-- tb_payment、tb_asset_wallet_transaction、tb_package_usage 与 tb_audit_event 复核, +-- 但「该资产当日是否已尝试/被跳过/复机结果」无法由数据库自身重建, +-- 因此 down 只在上述尝试事实已无留存需求时执行。 +-- +-- 守卫:存在仍处于处理中(占位后未收敛)的尝试行时直接阻断回滚,避免在未收敛状态下删除记录, +-- 使次日扫描无法把中断的尝试收敛为终态。 + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tb_asset_auto_renewal_attempt WHERE deleted_at IS NULL AND status = 1 LIMIT 1) THEN + RAISE EXCEPTION '存在仍处于处理中的自动续费尝试记录,拒绝回滚以避免丢失未收敛的尝试事实'; + END IF; +END $$; + +LOCK TABLE tb_asset_auto_renewal_attempt IN ACCESS EXCLUSIVE MODE; + +DROP INDEX IF EXISTS idx_asset_auto_renewal_attempt_asset; +DROP INDEX IF EXISTS idx_asset_auto_renewal_attempt_resume_unresolved; +DROP INDEX IF EXISTS idx_asset_auto_renewal_attempt_unfinished; +DROP INDEX IF EXISTS uq_asset_auto_renewal_attempt_key; + +DROP TABLE IF EXISTS tb_asset_auto_renewal_attempt; diff --git a/migrations/000230_add_asset_auto_renewal_attempt.up.sql b/migrations/000230_add_asset_auto_renewal_attempt.up.sql new file mode 100644 index 0000000..86f3f1f --- /dev/null +++ b/migrations/000230_add_asset_auto_renewal_attempt.up.sql @@ -0,0 +1,172 @@ +-- 资产钱包自动续费:新增尝试记录表 tb_asset_auto_renewal_attempt。 +-- 背景:自动续费需要「每项资产每天至多一次尝试」的强约束,以及可追溯的资金、订单、复机与配置 +-- 快照。既有 tb_recharge_order.auto_purchase_status 只表达一个充值单的成功/失败,无法承载 +-- 「按资产按日唯一」「占位后中断可收敛」「复机结果分类」三类事实,因此新建本表。 +-- +-- 设计选择: +-- 1. 唯一键为 (asset_type, asset_id, trigger_date) 的部分唯一索引:trigger_date 是上海自然日, +-- 同一资产同一自然日至多一条尝试,「每天至多一次」由唯一冲突保证而不是靠应用层判断。 +-- 索引必须是部分唯一索引(WHERE deleted_at IS NULL):CREATE TABLE 的表约束不支持 WHERE 谓词, +-- 而软删感知要求谓词使软删行不占用键位。该索引不与任何 GORM OnConflict 组合 +-- (部分唯一索引谓词未声明时 OnConflict 无法命中,见 KNOWN-ISSUE-001), +-- 应用层以「显式插入 + 23505 识别」表达「当日已尝试」。 +-- 2. status 表达尝试生命周期:1-处理中(占位) 2-成功 3-失败 4-跳过。占位行写入后进程中断会 +-- 停在处理中,由后续扫描把「触发日期早于当日且非终态」的行按 +-- status=3 + failure_reason='interrupted' 收敛(见第 3 点),当日不重试。 +-- 生命周期状态按 ENG-STATE-001 使用整数编码。 +-- 3. failure_reason 是「尝试记录的失败归类」,只承载实际进入执行后得出的失败: +-- insufficient_balance-余额不足 not_renewable-不可续费 order_failed-订单失败, +-- 外加 interrupted-中断收敛(见下)。复机失败不写 failure_reason:按设计决策 11, +-- 复机结果只落 resume_status/resume_failure_reason,避免同一事实两处表达。 +-- 规格的「失败原因枚举固定为四类」(余额不足/不可续费/订单失败/复机失败)是**通知口径**: +-- 前三类由尝试失败产生通知,第四类由复机失败(resume_status=4)产生通知, +-- 复用同一常量作为通知原因标识,但绝不回写 failure_reason。 +-- interrupted 是**非通知值**:占位后进程中断的记录由后续扫描收敛为 +-- status=3 + failure_reason='interrupted',表达规格要求的「收敛为中断或未知」。 +-- 该值不对应任何真实执行失败,因此通知投递判定显式排除它,收敛过程也不发送任何通知 +-- (一个从未进入资金事务的占位行发「订单失败」通知是错的)。 +-- skip_reason 取值 manual_renewed-人工已完成续购 manual_order_pending-人工订单在途。 +-- 4. resume_status 表达复机子结果:0-未评估 1-跳过 2-已投递 3-成功 4-失败 5-未知。 +-- 已投递未提交、已提交待确认与未知都属于未收敛,恢复扫描只查询已确认结果回填,绝不重复发起复机。 +-- resume_submitted_at 与 resume_anomaly_flag 照抄第 13 项既有形态: +-- internal/domain/carrierthreshold/command.go 的 SubmissionQueryWindow/SubmissionExpired、 +-- internal/application/carrierthreshold/cycle.go:274-296 的「窗口内继续等待,超期标记异常退出自动扫描」、 +-- internal/application/carrierthreshold/lock_store.go 的 markAnomaly, +-- 以及 migrations/000227_add_carrier_traffic_threshold.up.sql 的 anomaly_flag 列与 +-- idx_carrier_traffic_threshold_lock_unresolved 部分索引(谓词含 anomaly_flag = 0)。 +-- 本表以同样方式实现「有界查询窗口 + 超期退出自动扫描」,不新增异常记录页、不自动删除记录。 +-- 5. trigger_date 是 DATE 列:应用层与扫描的一切跨日比较必须用显式日期参数 +-- (trigger_date < ?::date),不得把 time.Time 直接与东八区零点比较——DATE 读回后是当日零点, +-- 直接比较会因时区换算差一天。「当日不重试」与「收敛历史非终态」使用同一日期口径。 +-- 6. 资金与订单字段全部是触发时冻结的快照:wallet_transaction_id 即规格中的钱包流水号, +-- 指向 tb_asset_wallet_transaction.id;扣款前/后余额用于人工核对资金事实。 +-- 跳过态与失败态的行这些字段保持 0/空。 +-- 7. 配置与窗口快照(config_version、window_days、final_expires_at)只记录触发时的值, +-- 配置变更不影响已产生的尝试记录,不重算。 +-- 8. operator_type/operator_id 恒为系统任务(无人工处理入口);attempt_seq 记录该资产截至本次 +-- 尝试当日的跨日累计尝试次数。 +-- 9. 不存凭证、令牌或个人敏感信息(ENG-LOG-001);不使用数据库外键,关联由应用层显式校验。 + +CREATE TABLE tb_asset_auto_renewal_attempt ( + id BIGSERIAL PRIMARY KEY, + asset_type VARCHAR(20) NOT NULL, + asset_id BIGINT NOT NULL, + trigger_date DATE NOT NULL, + status SMALLINT NOT NULL DEFAULT 1, + failure_reason VARCHAR(32) NOT NULL DEFAULT '', + failure_detail VARCHAR(500) NOT NULL DEFAULT '', + skip_reason VARCHAR(32) NOT NULL DEFAULT '', + customer_id BIGINT NOT NULL DEFAULT 0, + shop_id BIGINT, + config_version BIGINT NOT NULL DEFAULT 0, + window_days INTEGER NOT NULL DEFAULT 0, + final_expires_at TIMESTAMPTZ, + current_usage_id BIGINT NOT NULL DEFAULT 0, + current_package_id BIGINT NOT NULL DEFAULT 0, + renew_package_id BIGINT NOT NULL DEFAULT 0, + renew_price BIGINT NOT NULL DEFAULT 0, + wallet_id BIGINT NOT NULL DEFAULT 0, + wallet_transaction_id BIGINT NOT NULL DEFAULT 0, + deduct_amount BIGINT NOT NULL DEFAULT 0, + balance_before BIGINT NOT NULL DEFAULT 0, + balance_after BIGINT NOT NULL DEFAULT 0, + order_id BIGINT NOT NULL DEFAULT 0, + order_no VARCHAR(64) NOT NULL DEFAULT '', + resume_status SMALLINT NOT NULL DEFAULT 0, + resume_submitted_at TIMESTAMPTZ, + resume_integration_id VARCHAR(64) NOT NULL DEFAULT '', + resume_failure_reason VARCHAR(500) NOT NULL DEFAULT '', + resume_anomaly_flag SMALLINT NOT NULL DEFAULT 0, + operator_type VARCHAR(32) NOT NULL DEFAULT 'system_task', + operator_id VARCHAR(64) NOT NULL DEFAULT '', + attempt_seq INTEGER NOT NULL DEFAULT 1, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT ck_asset_auto_renewal_attempt_asset_type CHECK (asset_type IN ('iot_card', 'device')), + CONSTRAINT ck_asset_auto_renewal_attempt_status CHECK (status IN (1, 2, 3, 4)), + CONSTRAINT ck_asset_auto_renewal_attempt_failure_reason CHECK ( + failure_reason IN ('', 'insufficient_balance', 'not_renewable', 'order_failed', 'interrupted') + ), + CONSTRAINT ck_asset_auto_renewal_attempt_skip_reason CHECK ( + skip_reason IN ('', 'manual_renewed', 'manual_order_pending') + ), + CONSTRAINT ck_asset_auto_renewal_attempt_resume_status CHECK (resume_status IN (0, 1, 2, 3, 4, 5)), + CONSTRAINT ck_asset_auto_renewal_attempt_resume_anomaly CHECK (resume_anomaly_flag IN (0, 1)), + CONSTRAINT ck_asset_auto_renewal_attempt_operator_type CHECK (operator_type = 'system_task'), + CONSTRAINT ck_asset_auto_renewal_attempt_amounts CHECK ( + renew_price >= 0 AND deduct_amount >= 0 AND balance_before >= 0 AND balance_after >= 0 + ), + -- 语义列不允许出现「无因失败」或「有因成功」:状态与原因必须成对。 + -- 处理中与成功不得带任何原因;失败必须带失败归类;跳过必须带跳过原因且不得带失败归类。 + CONSTRAINT ck_asset_auto_renewal_attempt_reason_shape CHECK ( + (status IN (1, 2) AND failure_reason = '' AND skip_reason = '') + OR (status = 3 AND failure_reason IN ('insufficient_balance', 'not_renewable', 'order_failed', 'interrupted') AND skip_reason = '') + OR (status = 4 AND failure_reason = '' AND skip_reason IN ('manual_renewed', 'manual_order_pending')) + ) +); + +CREATE UNIQUE INDEX uq_asset_auto_renewal_attempt_key + ON tb_asset_auto_renewal_attempt (asset_type, asset_id, trigger_date) + WHERE deleted_at IS NULL; + +-- 终态收敛按「触发日期早于当日且仍为处理中」扫描。 +CREATE INDEX idx_asset_auto_renewal_attempt_unfinished + ON tb_asset_auto_renewal_attempt (status, trigger_date) + WHERE deleted_at IS NULL; + +-- 复机恢复扫描只看未收敛的复机子结果:已投递未提交(事件已写但尚未执行)、 +-- 已提交待确认(进程中断)与结果未知都必须继续按只读查询收敛。 +CREATE INDEX idx_asset_auto_renewal_attempt_resume_unresolved + ON tb_asset_auto_renewal_attempt (resume_status, resume_submitted_at) + WHERE deleted_at IS NULL AND resume_anomaly_flag = 0 + AND (resume_status = 5 OR (resume_status = 2 AND resume_submitted_at IS NOT NULL)); + +-- 跨日尝试次数按资产累计。 +CREATE INDEX idx_asset_auto_renewal_attempt_asset ON tb_asset_auto_renewal_attempt (asset_type, asset_id); + +COMMENT ON TABLE tb_asset_auto_renewal_attempt IS '资产钱包自动续费尝试记录,一项资产一个上海自然日至多一条,承载资金/订单/复机/配置快照'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.id IS '主键'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.asset_type IS '资产类型 iot_card-物联网卡 device-设备,与资产钱包资源类型一致'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.asset_id IS '资产ID(tb_iot_card.id 或 tb_device.id)'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.trigger_date IS '触发日期(上海自然日),与资产类型、资产ID 共同构成唯一键'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.status IS '尝试状态 1-处理中 2-成功 3-失败 4-跳过'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.failure_reason IS '尝试失败归类 insufficient_balance-余额不足 not_renewable-不可续费 order_failed-订单失败 interrupted-占位后中断收敛;成功与跳过时为空;复机失败只落 resume_status/resume_failure_reason;interrupted 不属于四类通知原因,不触发通知'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.failure_detail IS '可安全展示的失败说明,不写渠道报文、凭证或内部错误细节'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.skip_reason IS '跳过原因 manual_renewed-人工已完成续购 manual_order_pending-人工订单在途'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.customer_id IS '触发时解析到的当前个人客户ID,0-无当前个人客户'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.shop_id IS '触发时资产所属店铺ID,NULL-资产无店铺归属'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.config_version IS '触发时配置版本快照,不随配置变更重算'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.window_days IS '触发时到期前天数快照'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.final_expires_at IS '触发时最终到期时间快照'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.current_usage_id IS '触发时当前主套餐使用记录ID'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.current_package_id IS '触发时当前套餐商品ID'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.renew_package_id IS '待续购套餐商品ID(与当前套餐商品相同)'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.renew_price IS '执行时该渠道当前可售续费价(分)'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.wallet_id IS '扣款资产钱包ID;跳过与失败时为 0'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.wallet_transaction_id IS '钱包流水号(tb_asset_wallet_transaction.id);跳过与失败时为 0'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.deduct_amount IS '扣款金额(分);跳过与失败时为 0'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.balance_before IS '扣款前钱包余额(分)'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.balance_after IS '扣款后钱包余额(分)'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.order_id IS '续费订单ID;跳过与失败时为 0'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.order_no IS '续费订单号快照;跳过与失败时为空'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.resume_status IS '复机状态 0-未评估 1-跳过 2-已投递 3-成功 4-失败 5-未知;已投递与未知由恢复扫描收敛'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.resume_submitted_at IS '复机执行提交认领时刻;由消费者以「为空」条件更新取得至多一次的外部调用权,恢复扫描据此判断查询窗口'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.resume_integration_id IS '复机外部交互标识(Gateway 调用的 Integration Log 标识),便于人工核对'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.resume_failure_reason IS '可安全展示的复机失败原因,不写渠道报文原文'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.resume_anomaly_flag IS '复机异常标记 0-正常 1-查询窗口超期仍无法确认,退出自动扫描转人工核对'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.operator_type IS '操作者类型,恒为 system_task(本能力无人工处理入口)'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.operator_id IS '操作者标识,取触发时的计划任务类型'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.attempt_seq IS '跨日尝试次数(该资产截至本次触发日的累计尝试次数)'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.creator IS '创建人账号ID,系统写入为 0'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.updater IS '最近更新人账号ID,系统写入为 0'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.created_at IS '创建时间'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.updated_at IS '最近更新时间'; +COMMENT ON COLUMN tb_asset_auto_renewal_attempt.deleted_at IS '软删除时间,正常流程不删除,仅数据清理使用'; + +COMMENT ON INDEX uq_asset_auto_renewal_attempt_key IS '自动续费尝试唯一键(软删感知):一项资产一个上海自然日至多一条,冲突即当日已尝试'; +COMMENT ON INDEX idx_asset_auto_renewal_attempt_unfinished IS '终态收敛扫描索引:触发日期早于当日的非终态记录'; +COMMENT ON INDEX idx_asset_auto_renewal_attempt_resume_unresolved IS '复机恢复扫描索引:已投递与未知两类未收敛复机结果'; +COMMENT ON INDEX idx_asset_auto_renewal_attempt_asset IS '跨日尝试次数按资产累计索引'; diff --git a/openspec/changes/add-asset-wallet-auto-renewal/design.md b/openspec/changes/add-asset-wallet-auto-renewal/design.md deleted file mode 100644 index 69dae54..0000000 --- a/openspec/changes/add-asset-wallet-auto-renewal/design.md +++ /dev/null @@ -1,31 +0,0 @@ -## Context - -套餐续购、资产钱包和复机已有独立事实;自动续费仅编排这些既有能力,不能把复机失败当作资金失败。 - -## Decisions - -- 保存全局配置和按资产/日期的尝试记录,用唯一约束保证每日一次。 -- Worker 按资产锁重读资格、当前售价和余额,以既有订单/钱包事务完成续购;人工订单通过同一锁优先。 -- 成功后以可靠任务调用复机,单独记录结果;通知使用既有事件去重。 - -## 配置、扫描与执行契约 - -### 配置维护 - -- `GET /asset-auto-renewal-config` 与 `PUT /asset-auto-renewal-config` 仅超级管理员、平台用户。配置为单例:`enabled`、`scope`(`all_main_packages`/`specified_main_packages`)、`package_ids`(指定范围时非空且只能是可售主套餐)、`days_before_expiry`(正整数)。保存时记录操作者、前后快照和时间;代理、企业、个人客户无读取或修改入口。 -- 配置变更只影响后续扫描,已产生的尝试记录不重算;关闭开关后 Worker 不创建新尝试或订单。 - -### 每日扫描与尝试 - -- Worker 在上海自然日按资产扫描,先用 `(asset_id, attempt_date)` 唯一记录占位,确保每资产每天至多一次尝试。仅选择存在当前有效主套餐、套餐未到期、最终到期时间进入 `days_before_expiry` 窗口、套餐在配置范围且资产钱包正常的资产;加油包、已到期套餐、流量阈值事件均不是触发源。 -- Worker 对每项候选锁定资产、当前主套餐、资产钱包和当日尝试,再次读取配置、到期时间、当前可售续费价与人工订单。人工成功续购已产生时,标记跳过且不扣款;余额不足或套餐不可续费时记录失败原因、不建订单、不扣款。 - -### 续费、通知与复机 - -- 合格项复用既有资产钱包订单/套餐生效事务,以执行时当前续费价扣同一资产钱包可用余额,创建同套餐商品续购订单、钱包流水和套餐使用事实;任一步失败整体回滚资金、订单和套餐,并写失败尝试。成功后当日不再处理该资产。 -- 余额不足、不可续费、订单失败和复机失败均使用客户/业务员/日期/原因类型幂等键投递最多一条通知;接收人只在事件创建时解析,业务员不存在时不阻断续费或尝试记录。 -- 续费成功后仅当资产处于可恢复停机且运营商状态不是风险停机或已销户时投递可靠复机任务。复机成功更新既有状态;失败/未知保存执行结果并走既有恢复,不回滚钱包扣款、订单、套餐生效或续费成功事实。 - -## Migration Plan - -新增成对迁移;隔离库验证范围、窗口、每日去重、价格、余额、手动并发、复机和 up/down/up。 \ No newline at end of file diff --git a/openspec/changes/add-asset-wallet-auto-renewal/proposal.md b/openspec/changes/add-asset-wallet-auto-renewal/proposal.md deleted file mode 100644 index d6de51c..0000000 --- a/openspec/changes/add-asset-wallet-auto-renewal/proposal.md +++ /dev/null @@ -1,25 +0,0 @@ -## Scope - -- 迭代编号:`AUG26-010`。 - -## Why - -客户容易遗漏套餐续费;资产钱包余额充足时应在到期前自动续购,但不得与手动购买、停复机或钱包资金事实混淆。 - -## What Changes - -- 新增全局自动续费范围和最终到期前天数配置。 -- 每资产每天一次从同资产钱包按当前续费价续购。 -- 手动优先,失败通知,成功后条件复机且复机失败不回滚续费。 - -## Capabilities - -### New Capabilities -- `asset-auto-renewal`: 资产钱包自动续费。 - -### Modified Capabilities -- 无。 - -## Impact - -影响套餐、资产钱包、订单、任务、运营商复机、通知和 Schema。 \ No newline at end of file diff --git a/openspec/changes/add-asset-wallet-auto-renewal/specs/asset-auto-renewal/spec.md b/openspec/changes/add-asset-wallet-auto-renewal/specs/asset-auto-renewal/spec.md deleted file mode 100644 index ffaf99f..0000000 --- a/openspec/changes/add-asset-wallet-auto-renewal/specs/asset-auto-renewal/spec.md +++ /dev/null @@ -1,25 +0,0 @@ -## Purpose - -在套餐最终到期前的受控窗口内,仅以同一资产钱包余额自动续购当前有效主套餐,并使扣款、套餐生效和复机失败具有明确且可恢复的边界。 - -## ADDED Requirements - -### Requirement: 自动续费配置、权限与频率 -仅超级管理员和平台用户 SHALL 查看或修改全局自动续费开关、适用全部或指定主套餐及统一到期前 N 天;每次新增、修改、启用、停用必须记录操作者、修改前后值和时间。系统 SHALL 对已保存的有效配置执行续费:仅对当前有效主套餐、尚未到期、进入最终到期前窗口的资产处理;不得按流量阈值触发。每项资产每天最多尝试一次,成功后停止,套餐到期后不再自动尝试。 - -续费 MUST 仅扣该资产钱包可用余额,续购同一套餐商品,价格取执行时当前渠道可售续费价。余额不足或套餐不可续费时,不创建订单、不扣款,并向当前个人客户和资产所属店铺当时有效业务员各创建每日至多一条通知。 - -#### Scenario: 无权限修改配置 -- **WHEN** 代理、企业或个人客户请求修改自动续费配置 -- **THEN** 系统拒绝请求且不改变配置或产生执行任务 - -#### Scenario: 窗口内余额不足 -- **WHEN** 合格资产进入自动续费窗口但资产钱包余额不足 -- **THEN** 系统记录当日尝试失败且不扣款,并向当前客户和有效业务员各投递一次通知 - -### Requirement: 并发、成功与复机 -手动续购 SHALL 优先于自动续费。自动任务必须锁定并重读资产、套餐和钱包;发现人工已成功续购时跳过,避免重复扣款。成功续费后,仅当资产为可恢复停机且运营商状态不是风险停机或已销户时,系统调用既有复机;复机失败不得回滚已成功的订单、套餐或钱包扣款,必须保存失败结果并通知客户和业务员。 - -#### Scenario: 手动续购并发成功 -- **WHEN** 自动任务锁定后发现同一资产已由人工成功续购 -- **THEN** 自动任务不创建第二笔订单、不扣款,并结束本次尝试 diff --git a/openspec/changes/add-asset-wallet-auto-renewal/tasks.md b/openspec/changes/add-asset-wallet-auto-renewal/tasks.md deleted file mode 100644 index 328359f..0000000 --- a/openspec/changes/add-asset-wallet-auto-renewal/tasks.md +++ /dev/null @@ -1,9 +0,0 @@ -## 1. 配置与执行 -- [ ] 1.1 追踪套餐最终到期、续购价格、资产钱包、手动订单、停复机和通知链路。 -- [ ] 1.2 新增配置、每日尝试/结果的成对迁移、模型、唯一约束和管理接口。 -- [ ] 1.3 实现每日扫描、资格判断、资产锁、当前价格订单与同钱包扣款,保证手动优先。 - -## 2. 副作用与验证 -- [ ] 2.1 实现余额/不可续费通知、成功后的条件复机、复机失败记录和通知;不得回滚续费。 -- [ ] 2.2 更新路由/OpenAPI。 -- [ ] 2.3 隔离库验证窗口、每日一次、并发、资金、通知、复机及 up/down/up;运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-asset-wallet-auto-renewal --strict` 和 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file diff --git a/openspec/changes/add-asset-wallet-auto-renewal/.openspec.yaml b/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/.openspec.yaml similarity index 100% rename from openspec/changes/add-asset-wallet-auto-renewal/.openspec.yaml rename to openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/.openspec.yaml diff --git a/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/design.md b/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/design.md new file mode 100644 index 0000000..8ca8200 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/design.md @@ -0,0 +1,160 @@ +## Context + +动机与边界见 `proposal.md`。本设计只记录塑造实现方式的现状约束: + +- 续购是既有能力的编排:套餐最终到期推算、个人套餐购买校验与取价、资产钱包扣款、订单/支付/钱包流水、套餐使用记录激活、站内通知 Outbox、运营商停复机、统一审计已各自成立。 +- `internal/task/auto_purchase.go:208-318` 是全库唯一把「钱包扣款 → 订单与明细 → 支付记录 → 钱包流水 → 套餐激活 → 佣金与卡观测 Outbox → 审计」闭合在一个 GORM 事务里的先例。 +- 到期口径唯一来源为 `internal/query/packageexpiry/query.go` 的 `ResolveBatch:70` / `Calculate:158`;当前主套餐取法唯一先例为 `internal/query/packageexpiry/list.go:222` 的 `loadFinalUsages`。 +- 可售与价格唯一来源为 `internal/service/purchase_validation`(`ValidatePersonalCardPurchase:64` / `ValidatePersonalDevicePurchase:116` → `validatePackages:162` → `loadRenewablePackageIDs:279`);handler 层 `internal/handler/app/client_asset.go:311` 的续费价是同一应用层分支的拷贝。 +- 资产钱包扣款只有乐观版本锁(`internal/store/postgres/asset_wallet_store.go:65`),人工下单侧预占为 `internal/service/order/service.go:1606`;`pkg/constants/wallet.go:205` 的资产钱包分布式锁常量全库无调用。人工购买锁分裂为 `order:create:lock:*`(`internal/service/order/service.go:242`)与 `client:purchase:lock:*`(`internal/service/client_order/service.go:213`)两个命名空间。 +- 可靠副作用的既有形态为 Outbox 事件 + 消费者 + 独立恢复扫描(`internal/application/carrierthreshold/event.go:39/74/121`、`internal/infrastructure/carrierthreshold/task.go:52`);`internal/service/order/service.go:2141` 的支付后复机是即发即弃 goroutine,不满足本项要求。 +- 站内通知类型为代码内受控注册(`internal/infrastructure/notification/registry.go`),个人客户可见性由两处类型白名单决定(`internal/application/notification/read.go:216-222`、`internal/query/notification/query.go:190-199`);统一审计动作为 fail-closed 注册(`internal/infrastructure/audit/writer.go:838`)。 + +## Goals / Non-Goals + +**Goals:** + +- 让「提前续购」在资金、套餐、通知与复机四个面上各自闭合,且每个失败都能在数据上被定位与恢复。 +- 复用既有单一事实源(到期推算、购买校验与价格、钱包扣款、套餐激活、通知、审计),只在缺失处新增最小结构。 + +**Non-Goals:** + +- 不新增停机判据、不停机开关或复机判据;不改造人工路径的锁实现与既有自动购包实现。 +- 不建补偿/退款路径、记录页与异常记录页;不叠加多周期;不建当日重试。 + +## Decisions + +### 1. 不实现「有余额就不停机」 + +理由: + +1. PRD §2.11 未确认 `111.md` §16.1 的「有余额自动续费就不停机」,且 PRD 领域语言明确要求把续费订单、钱包扣款、运营商停复机与轮询同步区分开——该措辞本身是被要求拆分的对象。 +2. 既有机制已产生等价结果:窗口内提前续购后新主套餐为待生效(`internal/task/auto_purchase.go:636-665`),到期由既有接续链生效,轮询在「有有效主套餐且流量未耗尽」时自动复机。服务不中断来自**提前续购**,不来自改停机判据。 +3. 实现该措辞等于让未支付资金决定不停机,并绕过风险停机/销户判定与通道阈值锁。 + +规格以禁止性条款固定(见 `specs/asset-auto-renewal/spec.md` 的「不得因钱包余额跳过停机判定」);实现不新增该路径上的任何代码。 + +### 2. 续费事务单事务闭合,不建补偿路径 + +钱包扣款(`:220`)、订单(`:228`)、订单明细、支付记录 `status=paid`、钱包流水(`:254-270`)、套餐激活(`:274`)、佣金与卡观测 Outbox、审计(`:317`)在 `internal/task/auto_purchase.go:208-318` 同一个 `db.Transaction` 内闭合;任一步失败整体回滚,因此不存在「扣款已提交而套餐未生成」的中间态。本项沿用该形态:续费成功、尝试记录置成功与成功审计同事务;失败/跳过事实与失败审计按 ENG-TX-001 例外走独立短事务(先例 `markAutoPurchaseFailedIfFinalRetry:399-412`),条件更新依据尝试记录仍处于非终态。 + +外部副作用一律出事务走 Outbox(ENG-OUTBOX-001),事务内不持有外部 I/O(ENG-TX-001)。因此**不设补偿、不设退款、不做异常记录页**:异常只表达为「失败原因 + 尝试记录」。 + +### 3. 单周期与「人工已完成」由同一不变式保证 + +资格不变式:该资产**不存在待生效主套餐**(`status=待生效` 且无主套餐归属且未退款)。 + +- 只续购一个周期:`HasCurrentMainPackageForQueue`(`internal/service/package/addon_main_package.go:43/72`)决定新主套餐是否排队,`activateMainPackage` 在存在当前主套餐时把新记录写成待生效——不变式使续购最多领先一个周期。 +- 人工已完成即跳过:人工成功续购必经套餐激活写入待生效记录,自动任务重读即跳过。 + +**备选与否决**:仅用「最终到期时间是否出窗口」判定不可行——周期长度不大于窗口天数的套餐会在同一窗口内反复续购(例如 7 天周期、窗口 15 天,续购后最终到期仍在窗口内),既违反单周期也不满足「人工已完成即跳过」的稳定性。 + +### 4. 每日一次与尝试状态机 + +``` +扫描开始 + ├─ 收敛:触发日期 < 今日 且非终态 → 中断/未知 (独立短事务) + └─ 逐资产: + 候选(窗口/范围/资格) + ├─ 未进入执行 ────────────────→ 不建记录、不构成尝试 + └─ 进入执行 → 占位写入(唯一键冲突 = 当日已尝试 → 跳过) + ├─ 跳过(人工已完成 / 人工订单在途)→ 终态 skipped(独立短事务,不通知) + ├─ 失败(余额不足 / 不可续费) → 终态 failed(独立短事务 + 通知 Outbox) + ├─ 失败(订单失败,事务回滚) → 终态 failed(独立短事务 + 通知 Outbox) + └─ 成功 → 续费事务内终态 succeeded(+ 条件复机 Outbox) +``` + +- 尝试 = 执行级记录:未进入执行不建行(避免与库内全量资产量级绑定),因此「每天至多一次」的唯一键只约束真正进入执行的资产。 +- 终态收敛保证占位后中断不会让资产永久停在非终态;当日不重试,次日再评估(PRD 每日一次、不引入重试机制)。 +- 单资产失败在用例内捕获并落终态后继续;只有扫描级失败(候选读取、数据库不可用)返回错误交既有任务重试,重试对已占位资产天然幂等。 +- 多实例调度器重复入队安全:唯一键 + 执行事务内的行锁共同收敛。 + +### 5. 窗口与候选来源 + +- 口径:`Calculate`(`query.go:158`)——主套餐、未退款、未删除、状态为生效/已用完且至多一条,后续待生效主套餐按时间顺延;只接受推算结果为明确值,按上海自然日(`dateInShanghai`,固定东八区)比较;窗口闭区间 `0 ≤ 剩余天数 ≤ N`,剩余天数为负不尝试。**不得另写到期推算。** +- 候选来源:既有临期候选查询绑定固定 15 天窗口(`list.go:17`)并叠加数据范围(`applyStrictShopScope:279`),N 可配置时不得复用该常量。本项新增按窗口参数化的候选查询,口径仍调用同一推算函数,且**不改变既有临期列表行为**。 + +### 6. 资格、价格与资产范围 + +- 当前主套餐与续购对象:与 `loadFinalUsages:222` 同口径取第一条,续购对象为其套餐商品。 +- 范围:全部主套餐,或指定主套餐且当前主套餐商品在配置集合内;保存时校验只能选择当前可售主套餐,运行时**不因后来下架而拒绝**(交由续费豁免判定)。 +- 价格与可售:复用应用层购买校验与价格策略(个人卡/设备购买校验、续费豁免下架、生效零售价),禁止依赖 handler 层续费价实现。 +- 渠道店铺:取资产所属店铺,为空即平台价(与 `validateCardPurchase:86` 解析一致)。 +- **资产范围:独立卡与设备;绑定设备的卡排除**,与人工入口对绑定卡的拒绝口径(`validateCardPurchase:82`)一致,避免自动比人工更宽松。 +- 不可续费来源归一为四类(商品被禁用、范围外或渠道下架且不满足续费豁免、生效零售价低于成本价、不在可购买范围或未关联套餐系列)+**钱包状态异常归因**:资产钱包状态非「正常」时按「不可续费」落失败尝试,不新增第五类原因枚举,通知语义为「当前条件不允许自动续购」。 + +### 7. 并发与锁序 + +- **锁序**:执行事务内先锁资产钱包行,后锁资产载体行(`lockPackageCarrier` 的 `SELECT ... FOR UPDATE`,`internal/task/auto_purchase.go:761`)。人工下单路径同样先冻结钱包、后激活套餐,锁序一致可避免与人工事务的锁序反转死锁。 +- 锁后重读:最终到期、当前主套餐、待生效主套餐、可售续费价、可用余额;重读结果决定执行或跳过。 +- **残留竞态与代数说明**:自动与人工共享的序列化只有数据库行锁。人工下单(`client_order/service.go:213` 持 Redis 锁)与支付(`PayOrder:1416`)是两次请求,下单即冻结钱包(`order/service.go:1272`)。若自动事务先提交,人工支付**仍会成功**——人工支付只要求「余额足够且冻结额足够」(`order/service.go:1928-1930`),而自动只扣可用余额、不侵占冻结额(`asset_wallet_store.go:65` 的 `balance - frozen_balance >= amount`)。因此该竞态的结果不是同一笔资金重复扣减,而是**同一周期产生两笔已支付续购**;本设计以「存在未关闭(待支付)的个人资产钱包主套餐订单即跳过」消除该情形,窗口由既有订单超时释放预占保证有界。 +- 不接线未使用的资产钱包锁常量,不统一人工路径的两个 Redis 锁命名空间,不重构既有自动购包实现与人工锁路径。 + +### 8. 复机落点与结果分类 + +照抄第 13 项形态,**不复用其锁表**: + +| 落点 | 内容 | +| --- | --- | +| 可复机判定(新增导出入口) | 非风险停机/销户 + 停因为可轮询复机 + 复机前置条件(有效主套餐、流量未耗尽、实名)+ 不受通道阈值锁限制;判定规则复用既有单一来源,不复制 | +| 执行(新增导出入口,返回结果分类) | 复用既有复机重试、Integration Log 与统一审计,返回成功/失败/未知分类 | +| Outbox 事件与消费者 | 与续费事务同事务写事件;消费者条件认领后执行,回写尝试记录的复机状态、外部交互号与失败原因 | +| 恢复扫描(新增周期任务) | 只查询回填未知结果,不重复发起;终态收敛同批完成 | +| 通知 | 仅最终失败或确认失败时按通知契约投递 | + +两点事实: + +1. **不得直接调用既有「若已停机则复机」入口**:它对持锁、已开机、非轮询停因、条件不满足四类跳过都以成功返回(`internal/service/iot_card/stop_resume_service.go:565`),无法区分结果,不能作为尝试记录的结果来源。 +2. **既有判定函数为包内私有**,跨包使用须新增导出入口,不得复制规则。 + +**现实观察(实现与验收都需按此预期)**:资格要求存在未过期的当前主套餐,而续购总把新主套餐写为待生效,因此复机前置条件多数不成立、复机多为跳过;真正会触发的只有「读取资格后、执行事务前当前主套餐刚好过期」这一窄窗口(此时新主套餐直接生效)。跳过不发通知。 + +### 9. 通知注册与个人客户可见性 + +- 新增一个受控通知类型与一份注册表定义:类别 `expiry`、级别 `warning`、接收人含账号与个人客户、允许引用的资源类型覆盖资产与卡/设备,模板字段含资产标识、当前套餐、续费套餐、原因与最终到期日。 +- **必须同时加入个人客户通知类型白名单的两处**(`internal/application/notification/read.go:216-222` 与 `internal/query/notification/query.go:190-199`),否则 H5 列表与未读数静默不可见。 +- **因类别为 `expiry`,通知事件必须携带业务到期时间**,否则投递因参数非法失败(`internal/application/notification/delivery.go:284-290`)。 +- 幂等键内嵌资产类型与资产 ID、上海自然日、原因类型与接收人;复机失败沿用该次尝试的日期键。由「每资产每天至多一次尝试」直接推出「每资产每天至多一条失败通知」。 +- 店铺通知复用既有卡/设备详情跳转目标,不新增前端目标类型(不做记录页)。 +- 接收人解析复用既有绑定解析与店铺解析(业务员为空解析为空列表,不阻断投递流程)。 + +### 10. 配置存储、权限与审计 + +- **使用新增单行配置表,不使用系统配置键值表。** 先例引用纠正:H5 弹窗配置表是多行加优先级(`migrations/000225`),不是单行先例,仓库当前没有单行配置表先例。 +- 表约束到单行(主键恒为 1),迁移预置一行且默认关闭;字段为开关、范围(全部/指定)、指定套餐集合、到期前天数(上限 90)、配置版本、创建人与更新人与时间;指定范围时集合非空,全部范围时集合为空。 +- 配置版本在保存事务内自增,供尝试记录快照追溯;并发保护用单行事务锁,不用乐观锁(ENG-CONC-001 面向余额与状态机,配置用行锁即可)。 +- 权限:路由组级门禁仅超级管理员与平台账号(先例 `internal/routes/package_traffic_alert.go:15-23`),并在应用层复核账号类型(ENG-AUTHZ-001)。 +- 审计:保存写前后值快照,走统一审计并与保存同事务;**必须注册审计动作与资源常量及注册表定义**——审计写入 fail-closed,未注册会使保存事务整体失败。 +- 变更语义:只影响后续扫描,尝试记录保留原配置版本快照、不重算;关闭后当日不再创建新尝试或订单。 + +### 11. 尝试记录字段与枚举 + +唯一键(资产类型、资产 ID、触发日期)。字段:客户与店铺快照、配置版本与窗口快照、当前主套餐使用记录与当前商品、待续购商品与执行时价格、钱包标识与流水号与扣款金额与扣款前后余额、订单标识与订单号、尝试状态、失败原因、跳过原因、复机状态与复机外部交互号与失败原因、操作者类型与 ID(恒为系统任务)、跨日尝试次数、时间戳。 + +枚举(与规格共用同一份语义字面量): + +- 尝试状态:处理中 / 成功 / 失败 / 跳过(生命周期状态按 ENG-STATE-001 以整数编码,顺序与语义如上)。 +- 失败原因:余额不足 / 不可续费 / 订单失败 / 复机失败(复机失败记录在复机字段,不复用失败原因字段)。 +- 跳过原因:人工已完成续购 / 人工订单在途。 +- 复机状态:未评估 / 跳过 / 已投递 / 成功 / 失败 / 未知。 + +不存凭证、令牌或个人敏感信息(ENG-LOG-001)。 + +### 执行契约 + +- **配置维护**:`GET /asset-auto-renewal-config` 与 `PUT /asset-auto-renewal-config` 仅超级管理员与平台账号;保存记录操作者、前后快照与时间;代理、企业、个人客户无读取或修改入口。配置变更只影响后续扫描,已产生尝试记录不重算;关闭开关后不再创建新尝试或订单。 +- **每日扫描与尝试**:Worker 在上海自然日执行一次,先收敛历史非终态尝试,再按资产扫描候选;对每项候选以独立短事务占位,占位成功后执行;执行失败与跳过各以独立短事务写终态,失败按原因投递通知。 +- **续费与副作用**:同一事务内闭合资金、订单、套餐与成功审计;成功后按条件写复机 Outbox;复机结果由消费者与恢复扫描回填,失败或未知永不回滚续费事实。 + +## Risks / Trade-offs + +- [自动与人工在同一资产上并发] → 共享序列化只有数据库行锁;以「人工已完成」与「人工订单在途」两个跳过条件消除同周期两笔已支付续购,残留窗口由订单超时释放预占限制;极端交叉下人工下单可能因钱包版本冲突失败并需重试(与既有两个并发下单情形相同)。 +- [复机几乎总是跳过] → 属既有套餐排队语义的必然结果,不做额外补偿;规格与尝试记录显式区分「跳过」与「失败」,跳过不通知。 +- [尝试记录只覆盖进入执行的资产] → 换取与全量资产解耦的记录量级;「每天至多一次」的唯一键仍严格成立。 +- [配置单行表无乐观锁] → 保存串行化依赖单行事务锁;冲突表现为保存等待而非静默覆盖。 +- [新增通知类型可能静默不可见] → 规格要求两处白名单同时更新,并在验证中检查 H5 列表与未读数可见性。 +- [审计未注册导致保存整体失败] → 属 fail-closed 的预期行为,任务中显式要求先注册动作与资源定义再接入。 + +## Migration Plan + +新增成对迁移:单行自动续费配置表(预置一行且默认关闭)与尝试记录表(唯一键与状态字段)。验证在 `junhong_cmp_test` 与测试 Redis 库执行迁移 up/down/up,并只清理本 Change 自己创建的 fixture;生产迁移按生产运行说明由维护者手工执行,不由本 Change 自动执行。 diff --git a/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/proposal.md b/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/proposal.md new file mode 100644 index 0000000..a4cc4b7 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/proposal.md @@ -0,0 +1,40 @@ +## Scope + +- 迭代编号:`AUG26-010`。 +- 权威口径:`docs/product/2026-08-迭代-PRD-讨论稿.md` §2.11;`111.md` §16(PRD-08-012)仅用于追溯,未被 §2.11 确认的「不停机」、补偿/退款与异常记录页不实施。 + +## Why + +客户容易遗漏套餐续费,套餐到期会中断服务;资产钱包余额充足时应在最终到期前自动续购当前有效主套餐,且不得与手动续购、钱包资金事实或运营商停复机混为同一个动作。 + +## What Changes + +- 新增全局单例自动续费配置:总开关、适用全部或指定主套餐、到期前统一天数;仅超级管理员与平台账号可维护,保存记录操作者与前后值快照。 +- 每日按上海自然日扫描独立卡与设备,对存在当前有效主套餐、最终到期进入窗口、不存在待生效主套餐且不存在在途人工订单的资产,以执行时当前渠道可售续费价从同一资产钱包可用余额续购一个周期的同一套餐商品。 +- 每项资产每天至多一次尝试(含失败与跳过),由尝试记录唯一键保证;尝试记录保留资金、订单、复机与配置快照字段,并收敛中断留下的非终态记录。 +- 失败按四类原因(余额不足、不可续费、订单失败、复机失败)向当前个人客户与资产所属店铺当时有效业务员各投递每日至多一条站内通知;业务员不存在不阻断续费与尝试记录。 +- 续费成功后仅在资产处于可恢复停机且运营商状态不是风险停机或已销户时投递可靠复机任务;复机失败或未知保存结果并走既有恢复,不回滚续费事实。 +- 手动续购优先:执行前重新读取资格事实,人工已完成续购或存在在途人工订单时跳过。 + +## 非目标 + +- 不实现「有余额就不停机」的特殊逻辑与任何运行时开关,不改变既有停机、复机判据。 +- 不建补偿或退款路径;不做自动续费记录页与异常记录页。 +- 不叠加多个续购周期(一个尝试只产生一个周期)。 +- 不做当日重试(当天失败次日再评估),不按流量阈值或流量即将耗尽触发。 +- 配置不按店铺、企业或个人客户分范围。 +- 不改动人工路径的 Redis 锁命名空间,不接线未使用的资产钱包锁,不重构既有充值后自动购包实现与人工锁路径。 + +## Capabilities + +### New Capabilities + +- `asset-auto-renewal`: 资产钱包自动续费。 + +### Modified Capabilities + +- 无。 + +## Impact + +影响套餐购买校验与价格策略、套餐最终到期推算、资产钱包与钱包流水、订单与支付、异步任务与调度、Outbox、站内通知类型注册与个人客户可见性、统一审计注册、运营商复机、路由与 OpenAPI 文档,以及新增两处 Schema(单行配置表与尝试记录表)。 diff --git a/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/specs/asset-auto-renewal/spec.md b/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/specs/asset-auto-renewal/spec.md new file mode 100644 index 0000000..b181b44 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/specs/asset-auto-renewal/spec.md @@ -0,0 +1,166 @@ +## Purpose + +在套餐最终到期前的受控窗口内,仅以同一资产钱包可用余额自动续购当前有效主套餐,并使配置、每日尝试、资金闭合、通知与复机失败各自具有明确且可恢复的边界。 + +## ADDED Requirements + +### Requirement: 自动续费配置与权限 + +系统 SHALL 只维护一份有效的全局自动续费配置,包含总开关、适用范围(全部主套餐或指定主套餐)、指定主套餐集合与到期前统一天数(正整数,上限 90)。仅超级管理员与平台账号 SHALL 能读取或修改配置;代理、企业、个人客户 MUST NOT 有任何读取或修改入口,无权限与目标不存在 MUST NOT 形成可枚举差异。每次保存 SHALL 记录操作者、修改前后值快照与时间,并与配置保存同事务生效。指定范围时集合 MUST 非空且仅能选择当前可售主套餐;全部范围时集合 MUST 为空。每次保存 SHALL 递增配置版本;尝试记录 SHALL 保留触发时的配置版本快照。配置变更只影响后续扫描:已产生的尝试记录 MUST NOT 重算;关闭总开关后 MUST NOT 创建新的尝试或订单,既有尝试记录与通知保留。 + +#### Scenario: 无权限读取或修改配置 + +- **WHEN** 代理、企业或个人客户请求读取或修改自动续费配置 +- **THEN** 系统拒绝请求、配置不变,且不产生任何执行任务或通知 + +#### Scenario: 关闭总开关后不再执行 + +- **WHEN** 总开关关闭后执行每日扫描 +- **THEN** 系统不创建新的尝试记录、不创建订单、不扣款,既有尝试记录与通知保留 + +#### Scenario: 保存配置只影响后续扫描 + +- **WHEN** 管理员修改到期前天数或适用范围 +- **THEN** 系统记录操作者、前后值与时间并递增配置版本,已产生的尝试记录保持原配置版本快照且不重算 + +### Requirement: 每日扫描与续购资格 + +系统 SHALL 按上海自然日执行自动续费扫描,处理对象为尚未到期的当前主套餐(主套餐记录、未退款、未删除、状态为生效或已用完,按优先级与创建时间取第一条),续购对象为其套餐商品。触发窗口 SHALL 为闭区间:最终到期剩余天数大于等于 0 且小于等于配置的到期前天数;剩余天数 SHALL 按上海自然日计算(固定东八区、无夏令时),并复用既有最终到期推算口径(含待生效主套餐顺延)。最终到期推算结果为「无有效主套餐」「等待激活」或「数据异常」时 MUST NOT 处理;剩余天数为负(已过期)时 MUST NOT 尝试。资产范围 SHALL 为独立卡与设备;已绑定设备的卡 MUST NOT 处理(与个人客户购买入口口径一致)。除窗口条件外,续购资格 MUST 同时满足:该资产当前不存在待生效主套餐(未退款、无主套餐归属且状态为待生效),当前主套餐商品在配置范围内(全部范围时不受此限),且该资产不存在未关闭(待支付)的个人资产钱包主套餐订单。系统 MUST NOT 按流量阈值或流量即将耗尽触发。 + +#### Scenario: 窗口闭区间边界 + +- **WHEN** 资产最终到期剩余天数分别为 0、等于配置天数、等于配置天数加一 +- **THEN** 剩余天数为 0 与等于配置天数时进入执行,大于配置天数时不处理 + +#### Scenario: 已存在待生效主套餐 + +- **WHEN** 资产已存在待生效主套餐且当前主套餐进入窗口 +- **THEN** 系统跳过该资产,不扣款、不创建订单,并在尝试记录标记跳过 + +#### Scenario: 无明确最终到期或已过期 + +- **WHEN** 资产无有效主套餐、最终到期推算为等待激活或数据异常,或最终到期已过期 +- **THEN** 系统不进入执行,不创建尝试记录、不扣款、不通知 + +#### Scenario: 已绑定设备的卡不自动续费 + +- **WHEN** 卡已绑定设备 +- **THEN** 系统只在设备维度按设备自身套餐判断续费,不对该卡执行自动续费 + +### Requirement: 每日一次尝试与尝试记录终态收敛 + +系统 SHALL 以(资产类型、资产 ID、触发日期)唯一约束保证每项资产每天至多一次尝试;资产类型取值域 SHALL 与资产钱包资源类型一致,卡与设备分别计数。尝试 SHALL 是执行级记录:只有进入执行并得出终态的资产才创建记录(成功、失败或跳过);未进入窗口或资格不足而未进入执行的资产 MUST NOT 创建记录、不构成尝试。扫描 SHALL 先以独立短事务写入当次尝试占位;唯一冲突即视为当日已尝试并跳过该资产。当天失败 MUST NOT 重试,次日再评估;MUST NOT 建立当日重试机制。跨日尝试次数 SHALL 以尝试序号字段累计。占位后进程中断留下的非终态记录 SHALL 由后续扫描收敛:触发日期早于当日的非终态记录 MUST 先被收敛为中断或未知,且当日不重试。扫描任务级失败 SHALL 返回错误交由既有任务重试机制重试;单个资产执行失败 MUST NOT 使扫描任务失败。 + +#### Scenario: 当日已存在尝试记录 + +- **WHEN** 该资产当日已存在成功、失败或跳过的尝试记录 +- **THEN** 唯一约束冲突后该资产被跳过,当日不再尝试、不重复扣款 + +#### Scenario: 占位后进程中断 + +- **WHEN** 尝试占位写入后进程中断,记录停留在非终态,且当日扫描已结束 +- **THEN** 次日扫描先将该记录收敛为中断或未知,且该资产当日至多仍只尝试一次 + +#### Scenario: 单资产失败不影响同批其他资产 + +- **WHEN** 同一批扫描中某个资产执行失败 +- **THEN** 系统记录该资产失败原因并继续处理其余资产,扫描任务本身不因该资产失败而失败 + +### Requirement: 资金、价格与单事务闭合 + +自动续费 MUST 仅扣该续费资产钱包的可用余额(余额减冻结余额),MUST NOT 使用其他资产钱包、代理主钱包或外部支付渠道。续购价格 SHALL 取执行时该资产所属店铺渠道的当前可售续费价;资产无所属店铺时取平台价。续购 MUST 为同一套餐商品且 MUST 只产生一个周期的购买,MUST NOT 在同一窗口内连续叠加多个周期。钱包扣款、订单与订单明细、支付记录、钱包流水、套餐生效事实与成功审计 MUST 在同一事务内闭合;任一步失败 MUST 整体回滚,MUST NOT 产生「扣款已提交而套餐未生成」或其他部分成功状态。因此系统 MUST NOT 提供补偿或退款路径,MUST NOT 建立自动续费异常记录页。当前条件不允许自动续购时,系统 MUST NOT 创建订单、MUST NOT 扣款,只记录失败原因并投递通知。不可续费至少覆盖:套餐商品被禁用;当前渠道下架且不满足续费豁免;生效零售价低于成本价;不在可购买范围或资产未关联套餐系列;资产钱包当前不可用于扣款同样记入不可续费。 + +#### Scenario: 可用余额不足 + +- **WHEN** 资产钱包可用余额小于执行时当前可售续费价 +- **THEN** 系统不创建订单、不扣款,记录失败原因余额不足并投递通知 + +#### Scenario: 套餐不可续费 + +- **WHEN** 套餐商品被禁用,或当前渠道已下架且不满足续费豁免,或生效零售价低于成本价 +- **THEN** 系统不创建订单、不扣款,记录失败原因不可续费并投递通知 + +#### Scenario: 成功续费的资金与套餐事实同时可见 + +- **WHEN** 自动续费成功 +- **THEN** 续费订单、订单明细、已支付支付记录、钱包扣款流水(含扣款前后余额)与套餐使用记录在同一事务提交后同时可见,续购价格为该渠道执行时当前可售续费价 + +### Requirement: 失败通知与接收人 + +系统 SHALL 向当前个人客户与资产所属店铺当时有效业务员各创建站内通知,同一资产同一上海自然日同一原因对同一接收人至多一条。失败原因枚举 SHALL 固定为四类:余额不足、不可续费、订单失败、复机失败。通知幂等键 SHALL 内嵌资产类型与资产 ID、上海自然日、原因类型与接收人。复机失败通知 SHALL 沿用该次尝试的日期键,使同一尝试只通知一次且不跨日新增。资产所属店铺当时无有效业务员时 MUST NOT 阻断续费、失败记录或客户通知;资产无店铺归属时只创建客户通知。 + +#### Scenario: 余额不足的双接收人各一条 + +- **WHEN** 合格资产进入窗口但钱包可用余额不足,且资产所属店铺存在有效业务员 +- **THEN** 系统为该客户与该业务员各创建一条余额不足通知,当日重复扫描不再新增 + +#### Scenario: 业务员不存在 + +- **WHEN** 资产所属店铺当时没有有效业务员 +- **THEN** 系统不阻断续费流程与尝试记录,只创建客户通知 + +#### Scenario: 复机失败通知只发一次 + +- **WHEN** 续费成功但复机失败或结果未知,且恢复确认最终失败 +- **THEN** 系统以该次尝试的日期键创建复机失败通知,同一尝试只通知一次且不跨日新增 + +### Requirement: 成功后的可靠复机 + +续费成功后,仅当资产处于可恢复停机状态且运营商状态不是风险停机或已销户时,系统 SHALL 通过可靠异步投递触发既有复机能力,MUST NOT 使用提交后即发即弃的调用。复机失败或结果未知时,系统 MUST 保存执行结果(含外部交互标识与失败原因)并交由既有恢复机制查询确认,MUST NOT 回滚已提交的订单、套餐生效、钱包扣款或续费成功事实。复机未投递(条件不成立)时 MUST NOT 创建复机失败通知。 + +#### Scenario: 复机条件不成立 + +- **WHEN** 续费成功但资产不满足可恢复停机条件,或运营商状态为风险停机或已销户 +- **THEN** 系统记录复机跳过、不触发复机调用、不发送通知,续费事实保持不变 + +#### Scenario: 复机结果未知 + +- **WHEN** 复机调用后本地状态回写失败 +- **THEN** 系统保存结果未知与外部交互标识,由恢复扫描查询确认最终结果,续费订单、套餐与钱包事实不变 + +#### Scenario: 复机失败不回滚续费 + +- **WHEN** 复机最终失败 +- **THEN** 系统保留订单、套餐生效与钱包扣款事实,记录失败原因并投递复机失败通知 + +### Requirement: 尝试记录与可追溯字段 + +每次进入执行的尝试 SHALL 保留可追溯记录,至少包含:资产类型与资产 ID 与触发日期;触发时解析的当前个人客户与资产所属店铺快照;触发时配置版本与窗口快照;当前主套餐使用记录与当前套餐商品;待续购套餐商品与执行时续费价;钱包标识、钱包流水号、扣款金额与扣款前后余额;续费订单标识与订单号;尝试状态;失败原因;跳过原因;复机状态、复机外部交互标识与复机失败原因;操作者类型与标识(无人工处理入口,恒为系统任务);跨日尝试次数;时间戳。尝试状态 SHALL 为处理中、成功、失败、跳过四者之一。跳过原因 SHALL 至少覆盖人工已完成续购与人工订单在途。复机状态 SHALL 为未评估、跳过、已投递、成功、失败、未知之一。上述状态与原因枚举 SHALL 在规格与实现常量间共用同一份语义字面量。记录 MUST NOT 保存凭证、令牌或个人敏感信息。 + +#### Scenario: 成功尝试的可追溯内容 + +- **WHEN** 自动续费成功并按条件投递复机 +- **THEN** 尝试记录包含扣款金额与扣款前后余额、钱包流水号、续费订单号、执行时续费价与复机状态 + +#### Scenario: 跳过尝试的可追溯内容 + +- **WHEN** 资产因已存在待生效主套餐或存在在途人工订单被跳过 +- **THEN** 尝试记录状态为跳过并写明对应跳过原因,且不含订单号与扣款金额 + +### Requirement: 手动续购优先 + +自动任务 MUST 在提交资金事实前重新读取资格事实;人工续购已完成(存在待生效主套餐)时必须跳过,且 MUST NOT 产生第二笔订单或扣款。同一资产存在未关闭(待支付)的个人资产钱包主套餐订单时,自动任务 MUST 跳过并记录跳过原因人工订单在途,MUST NOT 发送通知。手动续购优先的可观察定义:同一资产同一周期 MUST NOT 因自动任务产生两笔已支付续购。 + +#### Scenario: 人工成功续购后自动跳过 + +- **WHEN** 自动任务重新读取时发现该资产已由人工成功续购 +- **THEN** 自动任务不创建第二笔订单、不扣款,并标记跳过 + +#### Scenario: 人工订单在途 + +- **WHEN** 自动任务执行时该资产存在未关闭的个人资产钱包主套餐订单 +- **THEN** 自动任务跳过该资产、不扣款、不创建订单且不发送通知 + +### Requirement: 不得因钱包余额跳过停机判定 + +系统 MUST NOT 因资产钱包余额充足而跳过停机判定,MUST NOT 为此新增任何运行时开关或配置项,MUST NOT 改变既有停机与复机判据。服务不中断 SHALL 只由「窗口内提前续购使新主套餐待生效、到期由既有接续链生效、轮询在存在有效主套餐且流量未耗尽时自动复机」产生。 + +#### Scenario: 余额充足但命中既有停机条件 + +- **WHEN** 资产钱包可用余额充足且资产命中既有停机条件(无有效套餐或流量用尽) +- **THEN** 系统仍按既有规则执行停机判定,不因余额跳过停机,且不存在可开启的不停机开关 + +#### Scenario: 续购成功不立即改变停机状态 + +- **WHEN** 续购成功且新主套餐按既有规则为待生效 +- **THEN** 系统不因续费成功提前改变停机状态,复机仍由既有接续与轮询判定决定 diff --git a/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/tasks.md b/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/tasks.md new file mode 100644 index 0000000..4296d40 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-asset-wallet-auto-renewal/tasks.md @@ -0,0 +1,68 @@ +## 1. 配置基座 + +- [x] 1.1 新增单行自动续费配置表成对迁移:主键恒为 1、开关、范围(全部/指定)、指定套餐集合、到期前天数(上限 90)、配置版本、创建人与更新人与时间;指定范围集合非空、全部范围集合为空;迁移预置一行且默认关闭 +- [x] 1.2 新增配置模型与读写 Store:读取单行、保存时以单行事务锁串行化并在保存事务内自增配置版本 +- [x] 1.3 实现配置读写接口与 DTO:范围枚举校验、指定范围只能选择当前可售主套餐、保存写入操作者与前后值快照 +- [x] 1.4 注册路由与路由组级门禁(仅超级管理员与平台账号),并在应用层复核账号类型;代理、企业、个人客户无入口 +- [x] 1.5 新增审计动作、操作与资源常量并在统一审计注册表登记定义(含操作者、来源、主资源与事务要求) +- [x] 1.6 同步 `cmd/api/docs.go` 与 `cmd/gendocs/main.go` 占位并运行 `go run cmd/gendocs/main.go` 核对路由与文档一致 +- [x] 1.7 落实变更语义:配置变更只影响后续扫描,尝试记录只保留触发时配置版本快照、不做重算;关闭开关后当日不再创建尝试或订单 + +## 2. 尝试记录表 + +- [x] 2.1 新增尝试记录表成对迁移:唯一键(资产类型、资产 ID、触发日期)、状态与原因字段、复机状态与外部交互号字段、资金与订单字段、配置与窗口快照字段、客户与店铺快照字段、操作者与跨日尝试次数字段 +- [x] 2.2 新增模型与常量:尝试状态(处理中/成功/失败/跳过)、失败原因(余额不足/不可续费/订单失败/复机失败)、跳过原因(人工已完成续购/人工订单在途)、复机状态(未评估/跳过/已投递/成功/失败/未知),字面量与规格一致 +- [x] 2.3 实现占位写入:扫描开始时以独立短事务写入当次尝试,唯一冲突即视为当日已尝试并跳过该资产 +- [x] 2.4 实现终态写入:成功在续费事务内更新;失败与跳过在独立短事务条件更新,不复用已回滚事务的连接或事务 +- [x] 2.5 实现单资产失败隔离:用例内捕获失败、落终态并继续处理其余资产,仅扫描级失败返回错误交既有任务重试 + +## 3. 候选与资格 + +- [x] 3.1 新增按窗口参数化的候选查询,复用既有最终到期推算口径(含待生效主套餐顺延),不改变既有临期列表行为 +- [x] 3.2 实现资格判定:独立卡与设备、最终到期剩余天数为闭区间 0 至配置天数、按上海自然日比较、只接受明确推算结果、已过期不处理 +- [x] 3.3 实现当前主套餐与续购对象取法:与既有临期口径同序取第一条主套餐,续购对象为其套餐商品 +- [x] 3.4 实现范围判定:全部主套餐,或当前主套餐商品在指定集合内(运行时不因后来下架而拒绝,交由续费豁免判定) +- [x] 3.5 实现跳过判定:不存在待生效主套餐为资格前置;人工已完成续购与存在未关闭(待支付)个人资产钱包主套餐订单时跳过并记跳过原因,不发通知 +- [x] 3.6 复用应用层购买校验与价格策略取得可续费判定与执行时续费价(含续费豁免下架、生效零售价与成本价比较),禁止依赖 handler 层续费价实现 + +## 4. 执行事务 + +- [x] 4.1 新增每日扫描任务与调度注册(上海时区每日一次),审计上下文固定为计划任务与 Worker 来源 +- [x] 4.2 执行事务内先锁资产钱包行、后锁资产载体行,锁后重读最终到期、当前主套餐、待生效主套餐、可售续费价与可用余额 +- [x] 4.3 同一事务内闭合:按可用余额扣款、续购订单与明细、已支付支付记录、钱包流水、套餐使用记录(按既有规则排队)、佣金与卡观测 Outbox、统一审计、尝试记录置成功 +- [x] 4.4 实现失败路径:不建订单、不扣款,按失败原因落终态并投递通知;订单失败随事务回滚后在独立短事务落失败事实 +- [x] 4.5 实现条件复机:按可复机判定投递复机 Outbox;条件不成立记录复机跳过且不投递、不通知 +- [x] 4.6 边界遵守:不重构既有充值后自动购包实现与人工锁路径,不接线未使用的资产钱包锁常量,不统一人工路径的 Redis 锁命名空间,事务体在自有用例内实现 + +## 5. 通知 + +- [x] 5.1 注册受控通知类型与注册表定义:类别 expiry、级别 warning、接收人含账号与个人客户、允许引用资源类型、模板字段 +- [x] 5.2 将新类型加入个人客户通知类型白名单的两处(通知列表与未读数范围),确保 H5 可见 +- [x] 5.3 事件必带业务到期时间(类别为 expiry 时缺失会因参数非法投递失败) +- [x] 5.4 实现双接收人与幂等键:个人客户与资产所属店铺、键内嵌资产类型与资产 ID、上海自然日、原因与接收人;业务员为空不阻断续费与尝试记录;资产无店铺只发客户通知 +- [x] 5.5 落实每日至多一条:同一资产同一自然日同一原因同一接收人只一条;复机失败沿用该次尝试的日期键,同一尝试只通知一次、不跨日新增 + +## 6. 复机可靠投递、恢复扫描与终态收敛 + +- [x] 6.1 新增导出的可复机判定与执行入口(返回成功/失败/未知分类),判定规则复用既有单一来源,不复制规则、不直接调用对跳过也返回成功的既有入口 +- [x] 6.2 新增复机 Outbox 事件类型与消费者:条件认领后执行,回写尝试记录的复机状态、外部交互号与失败原因 +- [x] 6.3 新增恢复扫描周期任务:只查询回填未知结果,不重复发起复机调用;仅最终失败按通知契约投递 +- [x] 6.4 实现终态收敛:后续扫描先把触发日期早于当日且非终态的尝试记录收敛为中断或未知,且当日不重试 + +## 7. 验证 + +- [x] 7.1 在 `junhong_cmp_test` 与测试 Redis 库执行迁移 up/down/up,只清理本 Change 自己创建的 fixture,不重置测试库 +- [x] 7.2 关闭总开关后扫描不产生新尝试与订单,既有记录保留 +- [x] 7.3 窗口闭区间上下界与上海零点边界:剩余天数为 0 与等于配置天数进入执行,大于配置天数不处理 +- [x] 7.4 已存在待生效主套餐时跳过(同时覆盖人工已完成续购与不叠加周期) +- [x] 7.5 已有当日尝试(成功/失败/跳过)时唯一冲突跳过,当日不重复尝试 +- [x] 7.6 可用余额不足(含冻结占用后)记余额不足,客户与业务员各一条通知 +- [x] 7.7 商品被禁用、渠道下架且不满足续费豁免、价格异常、钱包状态异常均记不可续费且不建订单不扣款 +- [x] 7.8 人工成功续购后自动重读跳过,不产生第二笔订单 +- [x] 7.9 人工待支付订单存在时跳过且不发送通知 +- [x] 7.10 成功路径订单、支付记录、钱包流水与套餐使用记录在同一事务提交后同时可见,续费价等于同渠道人工续费价 +- [x] 7.11 复机条件不成立时为跳过、无通知,续费事实不受影响 +- [x] 7.12 复机本地回写失败记未知并保留外部交互号,恢复扫描回填;仅确认失败才通知 +- [x] 7.13 占位后中断由次日扫描收敛为中断或未知,且当日不重试 +- [x] 7.14 并发交叉验证:同一订单不重复扣款,同周期不产生两笔已支付续购 +- [x] 7.15 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-asset-wallet-auto-renewal --strict`、`openspec doctor --json`;自动化测试按项目决策为 N/A diff --git a/openspec/specs/asset-auto-renewal/spec.md b/openspec/specs/asset-auto-renewal/spec.md new file mode 100644 index 0000000..c3f4982 --- /dev/null +++ b/openspec/specs/asset-auto-renewal/spec.md @@ -0,0 +1,168 @@ +# 资产钱包自动续费当前行为 + +## Purpose + +在套餐最终到期前的受控窗口内,仅以同一资产钱包可用余额自动续购当前有效主套餐,并使配置、每日尝试、资金闭合、通知与复机失败各自具有明确且可恢复的边界。 + +## Requirements + +### Requirement: 自动续费配置与权限 + +系统 SHALL 只维护一份有效的全局自动续费配置,包含总开关、适用范围(全部主套餐或指定主套餐)、指定主套餐集合与到期前统一天数(正整数,上限 90)。仅超级管理员与平台账号 SHALL 能读取或修改配置;代理、企业、个人客户 MUST NOT 有任何读取或修改入口,无权限与目标不存在 MUST NOT 形成可枚举差异。每次保存 SHALL 记录操作者、修改前后值快照与时间,并与配置保存同事务生效。指定范围时集合 MUST 非空且仅能选择当前可售主套餐;全部范围时集合 MUST 为空。每次保存 SHALL 递增配置版本;尝试记录 SHALL 保留触发时的配置版本快照。配置变更只影响后续扫描:已产生的尝试记录 MUST NOT 重算;关闭总开关后 MUST NOT 创建新的尝试或订单,既有尝试记录与通知保留。 + +#### Scenario: 无权限读取或修改配置 + +- **WHEN** 代理、企业或个人客户请求读取或修改自动续费配置 +- **THEN** 系统拒绝请求、配置不变,且不产生任何执行任务或通知 + +#### Scenario: 关闭总开关后不再执行 + +- **WHEN** 总开关关闭后执行每日扫描 +- **THEN** 系统不创建新的尝试记录、不创建订单、不扣款,既有尝试记录与通知保留 + +#### Scenario: 保存配置只影响后续扫描 + +- **WHEN** 管理员修改到期前天数或适用范围 +- **THEN** 系统记录操作者、前后值与时间并递增配置版本,已产生的尝试记录保持原配置版本快照且不重算 + +### Requirement: 每日扫描与续购资格 + +系统 SHALL 按上海自然日执行自动续费扫描,处理对象为尚未到期的当前主套餐(主套餐记录、未退款、未删除、状态为生效或已用完,按优先级与创建时间取第一条),续购对象为其套餐商品。触发窗口 SHALL 为闭区间:最终到期剩余天数大于等于 0 且小于等于配置的到期前天数;剩余天数 SHALL 按上海自然日计算(固定东八区、无夏令时),并复用既有最终到期推算口径(含待生效主套餐顺延)。最终到期推算结果为「无有效主套餐」「等待激活」或「数据异常」时 MUST NOT 处理;剩余天数为负(已过期)时 MUST NOT 尝试。资产范围 SHALL 为独立卡与设备;已绑定设备的卡 MUST NOT 处理(与个人客户购买入口口径一致)。除窗口条件外,续购资格 MUST 同时满足:该资产当前不存在待生效主套餐(未退款、无主套餐归属且状态为待生效),当前主套餐商品在配置范围内(全部范围时不受此限),且该资产不存在未关闭(待支付)的个人资产钱包主套餐订单。系统 MUST NOT 按流量阈值或流量即将耗尽触发。 + +#### Scenario: 窗口闭区间边界 + +- **WHEN** 资产最终到期剩余天数分别为 0、等于配置天数、等于配置天数加一 +- **THEN** 剩余天数为 0 与等于配置天数时进入执行,大于配置天数时不处理 + +#### Scenario: 已存在待生效主套餐 + +- **WHEN** 资产已存在待生效主套餐且当前主套餐进入窗口 +- **THEN** 系统跳过该资产,不扣款、不创建订单,并在尝试记录标记跳过 + +#### Scenario: 无明确最终到期或已过期 + +- **WHEN** 资产无有效主套餐、最终到期推算为等待激活或数据异常,或最终到期已过期 +- **THEN** 系统不进入执行,不创建尝试记录、不扣款、不通知 + +#### Scenario: 已绑定设备的卡不自动续费 + +- **WHEN** 卡已绑定设备 +- **THEN** 系统只在设备维度按设备自身套餐判断续费,不对该卡执行自动续费 + +### Requirement: 每日一次尝试与尝试记录终态收敛 + +系统 SHALL 以(资产类型、资产 ID、触发日期)唯一约束保证每项资产每天至多一次尝试;资产类型取值域 SHALL 与资产钱包资源类型一致,卡与设备分别计数。尝试 SHALL 是执行级记录:只有进入执行并得出终态的资产才创建记录(成功、失败或跳过);未进入窗口或资格不足而未进入执行的资产 MUST NOT 创建记录、不构成尝试。扫描 SHALL 先以独立短事务写入当次尝试占位;唯一冲突即视为当日已尝试并跳过该资产。当天失败 MUST NOT 重试,次日再评估;MUST NOT 建立当日重试机制。跨日尝试次数 SHALL 以尝试序号字段累计。占位后进程中断留下的非终态记录 SHALL 由后续扫描收敛:触发日期早于当日的非终态记录 MUST 先被收敛为中断或未知,且当日不重试。扫描任务级失败 SHALL 返回错误交由既有任务重试机制重试;单个资产执行失败 MUST NOT 使扫描任务失败。 + +#### Scenario: 当日已存在尝试记录 + +- **WHEN** 该资产当日已存在成功、失败或跳过的尝试记录 +- **THEN** 唯一约束冲突后该资产被跳过,当日不再尝试、不重复扣款 + +#### Scenario: 占位后进程中断 + +- **WHEN** 尝试占位写入后进程中断,记录停留在非终态,且当日扫描已结束 +- **THEN** 次日扫描先将该记录收敛为中断或未知,且该资产当日至多仍只尝试一次 + +#### Scenario: 单资产失败不影响同批其他资产 + +- **WHEN** 同一批扫描中某个资产执行失败 +- **THEN** 系统记录该资产失败原因并继续处理其余资产,扫描任务本身不因该资产失败而失败 + +### Requirement: 资金、价格与单事务闭合 + +自动续费 MUST 仅扣该续费资产钱包的可用余额(余额减冻结余额),MUST NOT 使用其他资产钱包、代理主钱包或外部支付渠道。续购价格 SHALL 取执行时该资产所属店铺渠道的当前可售续费价;资产无所属店铺时取平台价。续购 MUST 为同一套餐商品且 MUST 只产生一个周期的购买,MUST NOT 在同一窗口内连续叠加多个周期。钱包扣款、订单与订单明细、支付记录、钱包流水、套餐生效事实与成功审计 MUST 在同一事务内闭合;任一步失败 MUST 整体回滚,MUST NOT 产生「扣款已提交而套餐未生成」或其他部分成功状态。因此系统 MUST NOT 提供补偿或退款路径,MUST NOT 建立自动续费异常记录页。当前条件不允许自动续购时,系统 MUST NOT 创建订单、MUST NOT 扣款,只记录失败原因并投递通知。不可续费至少覆盖:套餐商品被禁用;当前渠道下架且不满足续费豁免;生效零售价低于成本价;不在可购买范围或资产未关联套餐系列;资产钱包当前不可用于扣款同样记入不可续费。 + +#### Scenario: 可用余额不足 + +- **WHEN** 资产钱包可用余额小于执行时当前可售续费价 +- **THEN** 系统不创建订单、不扣款,记录失败原因余额不足并投递通知 + +#### Scenario: 套餐不可续费 + +- **WHEN** 套餐商品被禁用,或当前渠道已下架且不满足续费豁免,或生效零售价低于成本价 +- **THEN** 系统不创建订单、不扣款,记录失败原因不可续费并投递通知 + +#### Scenario: 成功续费的资金与套餐事实同时可见 + +- **WHEN** 自动续费成功 +- **THEN** 续费订单、订单明细、已支付支付记录、钱包扣款流水(含扣款前后余额)与套餐使用记录在同一事务提交后同时可见,续购价格为该渠道执行时当前可售续费价 + +### Requirement: 失败通知与接收人 + +系统 SHALL 向当前个人客户与资产所属店铺当时有效业务员各创建站内通知,同一资产同一上海自然日同一原因对同一接收人至多一条。失败原因枚举 SHALL 固定为四类:余额不足、不可续费、订单失败、复机失败。通知幂等键 SHALL 内嵌资产类型与资产 ID、上海自然日、原因类型与接收人。复机失败通知 SHALL 沿用该次尝试的日期键,使同一尝试只通知一次且不跨日新增。资产所属店铺当时无有效业务员时 MUST NOT 阻断续费、失败记录或客户通知;资产无店铺归属时只创建客户通知。 + +#### Scenario: 余额不足的双接收人各一条 + +- **WHEN** 合格资产进入窗口但钱包可用余额不足,且资产所属店铺存在有效业务员 +- **THEN** 系统为该客户与该业务员各创建一条余额不足通知,当日重复扫描不再新增 + +#### Scenario: 业务员不存在 + +- **WHEN** 资产所属店铺当时没有有效业务员 +- **THEN** 系统不阻断续费流程与尝试记录,只创建客户通知 + +#### Scenario: 复机失败通知只发一次 + +- **WHEN** 续费成功但复机失败或结果未知,且恢复确认最终失败 +- **THEN** 系统以该次尝试的日期键创建复机失败通知,同一尝试只通知一次且不跨日新增 + +### Requirement: 成功后的可靠复机 + +续费成功后,仅当资产处于可恢复停机状态且运营商状态不是风险停机或已销户时,系统 SHALL 通过可靠异步投递触发既有复机能力,MUST NOT 使用提交后即发即弃的调用。复机失败或结果未知时,系统 MUST 保存执行结果(含外部交互标识与失败原因)并交由既有恢复机制查询确认,MUST NOT 回滚已提交的订单、套餐生效、钱包扣款或续费成功事实。复机未投递(条件不成立)时 MUST NOT 创建复机失败通知。 + +#### Scenario: 复机条件不成立 + +- **WHEN** 续费成功但资产不满足可恢复停机条件,或运营商状态为风险停机或已销户 +- **THEN** 系统记录复机跳过、不触发复机调用、不发送通知,续费事实保持不变 + +#### Scenario: 复机结果未知 + +- **WHEN** 复机调用后本地状态回写失败 +- **THEN** 系统保存结果未知与外部交互标识,由恢复扫描查询确认最终结果,续费订单、套餐与钱包事实不变 + +#### Scenario: 复机失败不回滚续费 + +- **WHEN** 复机最终失败 +- **THEN** 系统保留订单、套餐生效与钱包扣款事实,记录失败原因并投递复机失败通知 + +### Requirement: 尝试记录与可追溯字段 + +每次进入执行的尝试 SHALL 保留可追溯记录,至少包含:资产类型与资产 ID 与触发日期;触发时解析的当前个人客户与资产所属店铺快照;触发时配置版本与窗口快照;当前主套餐使用记录与当前套餐商品;待续购套餐商品与执行时续费价;钱包标识、钱包流水号、扣款金额与扣款前后余额;续费订单标识与订单号;尝试状态;失败原因;跳过原因;复机状态、复机外部交互标识与复机失败原因;操作者类型与标识(无人工处理入口,恒为系统任务);跨日尝试次数;时间戳。尝试状态 SHALL 为处理中、成功、失败、跳过四者之一。跳过原因 SHALL 至少覆盖人工已完成续购与人工订单在途。复机状态 SHALL 为未评估、跳过、已投递、成功、失败、未知之一。上述状态与原因枚举 SHALL 在规格与实现常量间共用同一份语义字面量。记录 MUST NOT 保存凭证、令牌或个人敏感信息。 + +#### Scenario: 成功尝试的可追溯内容 + +- **WHEN** 自动续费成功并按条件投递复机 +- **THEN** 尝试记录包含扣款金额与扣款前后余额、钱包流水号、续费订单号、执行时续费价与复机状态 + +#### Scenario: 跳过尝试的可追溯内容 + +- **WHEN** 资产因已存在待生效主套餐或存在在途人工订单被跳过 +- **THEN** 尝试记录状态为跳过并写明对应跳过原因,且不含订单号与扣款金额 + +### Requirement: 手动续购优先 + +自动任务 MUST 在提交资金事实前重新读取资格事实;人工续购已完成(存在待生效主套餐)时必须跳过,且 MUST NOT 产生第二笔订单或扣款。同一资产存在未关闭(待支付)的个人资产钱包主套餐订单时,自动任务 MUST 跳过并记录跳过原因人工订单在途,MUST NOT 发送通知。手动续购优先的可观察定义:同一资产同一周期 MUST NOT 因自动任务产生两笔已支付续购。 + +#### Scenario: 人工成功续购后自动跳过 + +- **WHEN** 自动任务重新读取时发现该资产已由人工成功续购 +- **THEN** 自动任务不创建第二笔订单、不扣款,并标记跳过 + +#### Scenario: 人工订单在途 + +- **WHEN** 自动任务执行时该资产存在未关闭的个人资产钱包主套餐订单 +- **THEN** 自动任务跳过该资产、不扣款、不创建订单且不发送通知 + +### Requirement: 不得因钱包余额跳过停机判定 + +系统 MUST NOT 因资产钱包余额充足而跳过停机判定,MUST NOT 为此新增任何运行时开关或配置项,MUST NOT 改变既有停机与复机判据。服务不中断 SHALL 只由「窗口内提前续购使新主套餐待生效、到期由既有接续链生效、轮询在存在有效主套餐且流量未耗尽时自动复机」产生。 + +#### Scenario: 余额充足但命中既有停机条件 + +- **WHEN** 资产钱包可用余额充足且资产命中既有停机条件(无有效套餐或流量用尽) +- **THEN** 系统仍按既有规则执行停机判定,不因余额跳过停机,且不存在可开启的不停机开关 + +#### Scenario: 续购成功不立即改变停机状态 + +- **WHEN** 续购成功且新主套餐按既有规则为待生效 +- **THEN** 系统不因续费成功提前改变停机状态,复机仍由既有接续与轮询判定决定 diff --git a/pkg/constants/asset_auto_renewal.go b/pkg/constants/asset_auto_renewal.go new file mode 100644 index 0000000..8fa662f --- /dev/null +++ b/pkg/constants/asset_auto_renewal.go @@ -0,0 +1,167 @@ +package constants + +import "time" + +// 资产钱包自动续费配置适用范围。 +const ( + // AssetAutoRenewalScopeAll 表示配置适用于全部主套餐。 + AssetAutoRenewalScopeAll = "all" + // AssetAutoRenewalScopeSpecified 表示配置只适用于指定主套餐集合。 + AssetAutoRenewalScopeSpecified = "specified" +) + +// 资产钱包自动续费配置的结构约束。 +const ( + // AssetAutoRenewalConfigSingletonID 是单行配置表的主键,恒为 1。 + AssetAutoRenewalConfigSingletonID = 1 + // AssetAutoRenewalConfigEnabledOff 表示总开关关闭。 + AssetAutoRenewalConfigEnabledOff = 0 + // AssetAutoRenewalConfigEnabledOn 表示总开关开启。 + AssetAutoRenewalConfigEnabledOn = 1 + // AssetAutoRenewalMinDaysBeforeExpiry 是到期前天数的最小值。 + AssetAutoRenewalMinDaysBeforeExpiry = 1 + // AssetAutoRenewalMaxDaysBeforeExpiry 是到期前天数的上限。 + AssetAutoRenewalMaxDaysBeforeExpiry = 90 +) + +// 资产钱包自动续费尝试状态(ENG-STATE-001:生命周期状态使用整数编码)。 +const ( + // AssetAutoRenewalAttemptStatusProcessing 表示尝试已占位、执行尚未得出终态。 + AssetAutoRenewalAttemptStatusProcessing = 1 + // AssetAutoRenewalAttemptStatusSucceeded 表示本次尝试已完成续费。 + AssetAutoRenewalAttemptStatusSucceeded = 2 + // AssetAutoRenewalAttemptStatusFailed 表示本次尝试以失败终态结束。 + AssetAutoRenewalAttemptStatusFailed = 3 + // AssetAutoRenewalAttemptStatusSkipped 表示本次尝试因人工优先而跳过。 + AssetAutoRenewalAttemptStatusSkipped = 4 +) + +// 资产钱包自动续费通知原因,固定为四类。 +// +// 前三类由尝试失败产生通知(写入尝试记录的 failure_reason); +// 第四类复机失败只由复机子结果(resume_status=失败)产生通知, +// 绝不回写尝试记录的 failure_reason——复机结果只在复机字段表达,同一事实不两处表达。 +const ( + // AssetAutoRenewalFailureInsufficientBalance 表示可用余额不支持续购。 + AssetAutoRenewalFailureInsufficientBalance = "insufficient_balance" + // AssetAutoRenewalFailureNotRenewable 表示当前条件不允许自动续购。 + AssetAutoRenewalFailureNotRenewable = "not_renewable" + // AssetAutoRenewalFailureOrderFailed 表示续购事务本身失败并整体回滚。 + AssetAutoRenewalFailureOrderFailed = "order_failed" + // AssetAutoRenewalFailureResumeFailed 表示复机最终确认失败(只作为通知原因,不写 failure_reason)。 + AssetAutoRenewalFailureResumeFailed = "resume_failed" +) + +// 资产钱包自动续费尝试记录中允许出现的失败归类。 +// +// 取值域是通知原因中的前三类加上中断收敛值:失败归类只描述「实际进入执行后得出的失败」, +// 因此不含复机失败。interrupted 是占位后进程中断的收敛表示,不属于四类通知原因, +// 由 IsAssetAutoRenewalNotifiableFailureReason 显式排除,收敛过程不发送任何通知。 +const ( + // AssetAutoRenewalFailureInterrupted 表示占位写入后进程中断,由后续扫描收敛。 + AssetAutoRenewalFailureInterrupted = "interrupted" +) + +// IsAssetAutoRenewalNotifiableFailureReason 判断该失败原因是否触发站内通知。 +// 未登记的原因一律不通知(fail-closed),避免把收敛产生的 interrupted 误当执行失败通知出去。 +func IsAssetAutoRenewalNotifiableFailureReason(reason string) bool { + switch reason { + case AssetAutoRenewalFailureInsufficientBalance, AssetAutoRenewalFailureNotRenewable, + AssetAutoRenewalFailureOrderFailed, AssetAutoRenewalFailureResumeFailed: + return true + default: + return false + } +} + +// 资产钱包自动续费跳过原因。 +const ( + // AssetAutoRenewalSkipManualRenewed 表示人工已完成续购(已存在待生效主套餐)。 + AssetAutoRenewalSkipManualRenewed = "manual_renewed" + // AssetAutoRenewalSkipManualOrderPending 表示该资产存在未关闭的人工主套餐订单。 + AssetAutoRenewalSkipManualOrderPending = "manual_order_pending" +) + +// 资产钱包自动续费尝试的复机状态。 +const ( + // AssetAutoRenewalResumeStatusSkipped 表示可复机条件不成立,未投递复机。 + AssetAutoRenewalResumeStatusSkipped = 1 + // AssetAutoRenewalResumeStatusRequested 表示已可靠投递复机请求,等待结果。 + AssetAutoRenewalResumeStatusRequested = 2 + // AssetAutoRenewalResumeStatusSucceeded 表示复机已确认成功。 + AssetAutoRenewalResumeStatusSucceeded = 3 + // AssetAutoRenewalResumeStatusFailed 表示复机已确认失败。 + AssetAutoRenewalResumeStatusFailed = 4 + // AssetAutoRenewalResumeStatusUnknown 表示复机结果未知,等待恢复扫描确认。 + AssetAutoRenewalResumeStatusUnknown = 5 +) + +// 资产钱包自动续费尝试的操作者类型:本能力无人工处理入口,恒为系统任务。 +const ( + // AssetAutoRenewalOperatorTypeSystemTask 表示系统任务操作者。 + AssetAutoRenewalOperatorTypeSystemTask = "system_task" +) + +// 资产钱包自动续费尝试的批次与查询约束。 +const ( + // AssetAutoRenewalRecoveryBatchSize 是恢复扫描单批处理条数上限。 + AssetAutoRenewalRecoveryBatchSize = 200 + // AssetAutoRenewalConfigDisplayName 是配置资源的展示名。 + AssetAutoRenewalConfigDisplayName = "资产钱包自动续费配置" +) + +// 资产钱包自动续费的可靠事件类型。 +const ( + // OutboxEventTypeAssetAutoRenewalResumeRequested 表示续费成功后按条件触发的可靠复机请求。 + OutboxEventTypeAssetAutoRenewalResumeRequested = "asset_auto_renewal.resume.requested" +) + +// 资产钱包自动续费复机子任务的查询窗口。 +const ( + // AssetAutoRenewalResumeQueryWindow 是复机子任务自提交起允许自动查询确认的窗口。 + // 语义:复机事件进入已投递/已提交后,恢复扫描只查询运营商状态回填;超过该窗口仍无法确认的 + // 尝试会标记异常并转人工核对,不再重复查询同一笔无法收敛的结果,也不回滚任何续费事实。 + AssetAutoRenewalResumeQueryWindow = 30 * time.Minute +) + +// GetAssetAutoRenewalScopeName 返回自动续费适用范围的中文名称。 +func GetAssetAutoRenewalScopeName(scope string) string { + switch scope { + case AssetAutoRenewalScopeAll: + return "全部主套餐" + case AssetAutoRenewalScopeSpecified: + return "指定主套餐" + default: + return "未知" + } +} + +// GetAssetAutoRenewalFailureReasonName 返回自动续费失败原因的中文名称。 +func GetAssetAutoRenewalFailureReasonName(reason string) string { + switch reason { + case AssetAutoRenewalFailureInsufficientBalance: + return "余额不足" + case AssetAutoRenewalFailureNotRenewable: + return "不可续费" + case AssetAutoRenewalFailureOrderFailed: + return "订单失败" + case AssetAutoRenewalFailureResumeFailed: + return "复机失败" + case AssetAutoRenewalFailureInterrupted: + return "中断收敛" + default: + return "未知" + } +} + +// GetAssetAutoRenewalSkipReasonName 返回自动续费跳过原因的中文名称。 +func GetAssetAutoRenewalSkipReasonName(reason string) string { + switch reason { + case AssetAutoRenewalSkipManualRenewed: + return "人工已完成续购" + case AssetAutoRenewalSkipManualOrderPending: + return "人工订单在途" + default: + return "未知" + } +} diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index fcca93a..3809bf1 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -556,6 +556,12 @@ const ( AuditActionPackageTrafficAlertRuleDisabled = "package_traffic_alert_rule.disable" // AuditActionPackageTrafficAlertTriggered 表示扫描命中阈值时创建套餐真流量达量预警事实。 AuditActionPackageTrafficAlertTriggered = "package_traffic_alert.trigger" + // AuditActionAssetAutoRenewalConfigUpdated 表示保存受控的资产钱包自动续费配置。 + AuditActionAssetAutoRenewalConfigUpdated = "asset_auto_renewal.config.updated" + // AuditActionAssetAutoRenewalRenewed 表示自动续费在同一事务内完成续购并置尝试成功。 + AuditActionAssetAutoRenewalRenewed = "asset_auto_renewal.renewed" + // AuditActionAssetAutoRenewalFailed 表示自动续费以失败或跳过终态结束。 + AuditActionAssetAutoRenewalFailed = "asset_auto_renewal.failed" // AuditActionNotificationDelivered 表示 Outbox 消费后实际生成站内通知。 AuditActionNotificationDelivered = "notification.deliver" // AuditActionNotificationRead 表示单条通知首次标记已读。 @@ -634,6 +640,8 @@ const ( AuditActionPermissionDeleted = "permission.delete" // AuditOperationSystemConfigUpdate 表示系统配置旧接缝传入的操作类型。 AuditOperationSystemConfigUpdate = "system_config_update" + // AuditOperationAssetAutoRenewalConfigUpdate 表示资产钱包自动续费配置旧接缝传入的操作类型。 + AuditOperationAssetAutoRenewalConfigUpdate = "asset_auto_renewal_config_update" // AuditOperationPaymentConfigCreate 表示创建支付连接配置。 AuditOperationPaymentConfigCreate = "payment_config_create" // AuditOperationPaymentConfigUpdate 表示更新支付连接配置。 @@ -731,6 +739,10 @@ const ( AuditResourcePackageTrafficAlertRule = "package_traffic_alert_rule" // AuditResourcePackageTrafficAlert 表示套餐真流量达量预警事实资源。 AuditResourcePackageTrafficAlert = "package_traffic_alert" + // AuditResourceAssetAutoRenewalConfig 表示资产钱包自动续费全局配置资源。 + AuditResourceAssetAutoRenewalConfig = "asset_auto_renewal_config" + // AuditResourceAssetAutoRenewalAttempt 表示资产钱包自动续费尝试记录资源。 + AuditResourceAssetAutoRenewalAttempt = "asset_auto_renewal_attempt" // AuditResourceNotification 表示站内通知资源。 AuditResourceNotification = "notification" // AuditResourceNotificationReadBatch 表示通知批量已读资源。 @@ -879,6 +891,16 @@ const ( AuditResourceRolePackageTrafficAlertRuleTarget = "package_traffic_alert_rule_target" // AuditResourceRolePackageTrafficAlertTarget 表示套餐真流量达量预警事实资源。 AuditResourceRolePackageTrafficAlertTarget = "package_traffic_alert_target" + // AuditResourceRoleAssetAutoRenewalConfigTarget 表示资产钱包自动续费配置主资源。 + AuditResourceRoleAssetAutoRenewalConfigTarget = "asset_auto_renewal_config_target" + // AuditResourceRoleAssetAutoRenewalAttemptTarget 表示本次自动续费尝试记录主资源。 + AuditResourceRoleAssetAutoRenewalAttemptTarget = "asset_auto_renewal_attempt_target" + // AuditResourceRoleAssetAutoRenewalWallet 表示本次自动续费实际扣款的资产钱包。 + AuditResourceRoleAssetAutoRenewalWallet = "asset_auto_renewal_wallet" + // AuditResourceRoleAssetAutoRenewalWalletTransaction 表示本次自动续费产生的资产钱包流水。 + AuditResourceRoleAssetAutoRenewalWalletTransaction = "asset_auto_renewal_wallet_transaction" + // AuditResourceRoleAssetAutoRenewalOrder 表示本次自动续费产生的续费订单。 + AuditResourceRoleAssetAutoRenewalOrder = "asset_auto_renewal_order" // AuditResourceRoleRetentionMonth 表示留存清理目标自然月。 AuditResourceRoleRetentionMonth = "retention_month" // AuditResourceRoleNotificationTarget 表示本次写操作的通知资源。 diff --git a/pkg/constants/card_observation.go b/pkg/constants/card_observation.go index 21e6945..feab2f8 100644 --- a/pkg/constants/card_observation.go +++ b/pkg/constants/card_observation.go @@ -157,12 +157,14 @@ const ( CardObservationSceneBusinessResume = "business_resume" // 业务复机成功 // CardObservationSceneCarrierThresholdRecovery 是通道阈值恢复扫描的只读状态查询场景。 CardObservationSceneCarrierThresholdRecovery = "carrier_threshold_recovery" - CardObservationScenePackageChanged = "package_changed" // 购包或套餐激活成功 - CardObservationSceneDeviceSwitchCard = "device_switch_card" // 设备切卡成功 - CardObservationSceneDeviceSwitchMode = "device_switch_mode" // 设备切卡模式成功 - CardObservationSceneDeviceReboot = "device_reboot" // 设备重启成功 - CardObservationSceneDeviceReset = "device_reset" // 设备恢复出厂成功 - CardObservationSceneDeviceSetWiFi = "device_set_wifi" // 设备 WiFi 设置成功 + // CardObservationSceneAssetAutoRenewalResumeRecovery 是资产钱包自动续费复机结果恢复扫描的只读状态查询场景。 + CardObservationSceneAssetAutoRenewalResumeRecovery = "asset_auto_renewal_resume_recovery" + CardObservationScenePackageChanged = "package_changed" // 购包或套餐激活成功 + CardObservationSceneDeviceSwitchCard = "device_switch_card" // 设备切卡成功 + CardObservationSceneDeviceSwitchMode = "device_switch_mode" // 设备切卡模式成功 + CardObservationSceneDeviceReboot = "device_reboot" // 设备重启成功 + CardObservationSceneDeviceReset = "device_reset" // 设备恢复出厂成功 + CardObservationSceneDeviceSetWiFi = "device_set_wifi" // 设备 WiFi 设置成功 ) // 卡实名状态变更 Outbox 事件 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 485a263..b62d48f 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -101,6 +101,10 @@ const ( // 运营商通道流量阈值任务类型(由 Asynq Scheduler 调度) TaskTypeCarrierThresholdCycle = "carrier_threshold:cycle" // 通道阈值周期处理:跨期解锁与条件复机 TaskTypeCarrierThresholdRecovery = "carrier_threshold:recovery" // 通道阈值停复机结果恢复扫描 + + // 资产钱包自动续费任务类型(由 Asynq Scheduler 调度) + TaskTypeAssetAutoRenewalScan = "asset_auto_renewal:scan" // 每日自动续费扫描:终态收敛、候选判定与续购执行 + TaskTypeAssetAutoRenewalRecovery = "asset_auto_renewal:recovery" // 自动续费复机结果恢复扫描 ) // 用户状态常量 @@ -330,6 +334,9 @@ func QueueForTaskType(taskType string) string { case TaskTypeCarrierThresholdCycle, TaskTypeCarrierThresholdRecovery: // 与退款恢复同队列:每分钟轻量扫描,复用既有已监听队列,不新增队列与权重。 return QueueDefault + case TaskTypeAssetAutoRenewalScan, TaskTypeAssetAutoRenewalRecovery: + // 与套餐临期扫描同队列:轻量只读扫描与收敛,复用既有已监听队列,不新增队列与权重。 + return QueueDataCleanup default: return QueueDefault } diff --git a/pkg/constants/notification.go b/pkg/constants/notification.go index cac9c03..c68c032 100644 --- a/pkg/constants/notification.go +++ b/pkg/constants/notification.go @@ -48,6 +48,8 @@ const ( NotificationTypeH5PopupOperation = "h5.popup.operation" // NotificationTypePackageTrafficAlert 表示套餐真流量达量预警。 NotificationTypePackageTrafficAlert = "package.traffic.alert" + // NotificationTypeAssetAutoRenewalFailed 表示资产钱包自动续费失败(含复机失败)。 + NotificationTypeAssetAutoRenewalFailed = "asset.auto_renewal.failed" // NotificationRefTypeSystemConfig 表示系统配置资源引用。 NotificationRefTypeSystemConfig = "system_config" diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index f500fd9..c2f82b5 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -83,6 +83,7 @@ func BuildDocHandlers() *bootstrap.Handlers { ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(nil), PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(nil, nil), PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil), + AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(nil, nil), ClientWechat: app.NewClientWechatHandler(nil, nil, nil), SuperAdmin: admin.NewSuperAdminHandler(nil), SystemConfig: admin.NewSystemConfigHandler(nil, nil), diff --git a/pkg/queue/types.go b/pkg/queue/types.go index 78e32c0..581acb8 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -4,6 +4,7 @@ import ( "context" agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + assetautorenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" carrierthresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold" priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling" @@ -78,7 +79,10 @@ type WorkerServices struct { StopResumeService packagepkg.StopResumeCallback // 停复机服务,用于注入 Scheduler // CarrierThreshold 是运营商通道流量阈值的唯一用例实例:达量判定、持锁拒绝、停复机消费者与 // 两个计划任务共用它,使直接执行的成功与恢复确认的成功走同一回写路径。 - CarrierThreshold *carrierthresholdApp.Service + CarrierThreshold *carrierthresholdApp.Service + // AssetAutoRenewal 是资产钱包自动续费的唯一用例实例:每日扫描、复机消费者与恢复扫描共用它, + // 使直接执行的成功与恢复确认的成功走同一回写路径。 + AssetAutoRenewal *assetautorenewalApp.Service OrderExpirer OrderExpirer // 订单超时取消服务(接口类型,避免循环依赖) AssetPackageOrderCreator task.AssetPackageBatchOrderCreator // 批量订购复用后台单笔订单规则 DeviceBatchAllocator task.DeviceBatchAllocationExecutor // 设备CSV批量分配复用现有业务规则