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

@@ -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 是触发时解析到的当前个人客户 ID0 表示该资产当前没有绑定个人客户。
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)
}

View File

@@ -197,6 +197,7 @@ func personalNotificationScope(db *gorm.DB, customerID uint, now time.Time) *gor
constants.NotificationTypeExchangeShippingCreated,
constants.NotificationTypeH5PopupRiskExchange,
constants.NotificationTypeH5PopupOperation,
constants.NotificationTypeAssetAutoRenewalFailed,
},
now,
)