feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s

- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
This commit is contained in:
2026-09-17 16:39:26 +08:00
parent 70e6b186df
commit d52be16802
54 changed files with 5164 additions and 97 deletions

View File

@@ -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 --all35 passed/0 failed、openspec doctor --jsonhealthy=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 --all35 passed/0 failed、openspec doctor --jsonhealthy=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=216 与 -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 --all35 passed/0 failed、openspec doctor --jsonhealthy=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.txttrigger_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 --all35 passed/0 failed、openspec doctor --jsonhealthy=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 --all35 passed/0 failed、openspec doctor --jsonhealthy=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 --all35 passed/0 failed、openspec doctor --jsonhealthy=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 --all35 passed/0 failed、openspec doctor --jsonhealthy=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 --all35 passed/0 failed、openspec doctor --jsonhealthy=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.txt3 个独立进程并发触发同一资产仅 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 --all35 passed/0 failed、openspec doctor --jsonhealthy=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
}
}
]