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:
524
internal/query/assetautorenewal/query.go
Normal file
524
internal/query/assetautorenewal/query.go
Normal 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 是触发时解析到的当前个人客户 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)
|
||||
}
|
||||
@@ -197,6 +197,7 @@ func personalNotificationScope(db *gorm.DB, customerID uint, now time.Time) *gor
|
||||
constants.NotificationTypeExchangeShippingCreated,
|
||||
constants.NotificationTypeH5PopupRiskExchange,
|
||||
constants.NotificationTypeH5PopupOperation,
|
||||
constants.NotificationTypeAssetAutoRenewalFailed,
|
||||
},
|
||||
now,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user