// 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) }