feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
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:
99
internal/model/asset_auto_renewal.go
Normal file
99
internal/model/asset_auto_renewal.go
Normal file
@@ -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"
|
||||
}
|
||||
39
internal/model/dto/asset_auto_renewal_dto.go
Normal file
39
internal/model/dto/asset_auto_renewal_dto.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user