Files
junhong_cmp_fiber/internal/query/assetautorenewal/query.go
break d52be16802
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
2026-09-17 16:39:26 +08:00

525 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}