实现资产预计最终到期时间

This commit is contained in:
2026-07-23 13:14:43 +09:00
parent 5dacef57fa
commit 8d65b26e96
17 changed files with 570 additions and 32 deletions

View File

@@ -26,6 +26,10 @@
后台设备列表 `GET /api/admin/devices` 支持按 `virtual_no``imei`、设备名称、归属状态、激活状态、店铺、套餐系列等条件筛选。 后台设备列表 `GET /api/admin/devices` 支持按 `virtual_no``imei`、设备名称、归属状态、激活状态、店铺、套餐系列等条件筛选。
### 资产预计套餐到期时间
后台资产详情、卡/设备列表与 C 端资产信息统一返回主套餐队列连续使用后的预计最终到期时间。该值实时根据当前套餐、排队套餐和购买时计时快照计算;当前套餐自身到期时间仅保留在套餐明细中。
### 三种客户类型 ### 三种客户类型
| 客户类型 | 业务特点 | 典型场景 | 钱包归属 | | 客户类型 | 业务特点 | 典型场景 | 钱包归属 |

View File

@@ -0,0 +1,27 @@
# UR46 资产预计最终到期时间
## 实现范围
后台资产解析、卡列表、设备列表和 C 端资产信息统一返回“预计套餐到期时间”。结果由 `internal/query/packageexpiry` 实时计算,不维护资产级到期快照。
## 计算口径
- 只纳入未软删除、未退款、状态为待生效/生效中/已用完的主套餐;加油包、已过期和已失效记录不参与。
-`priority ASC, created_at ASC, id ASC` 稳定排序,当前套餐真实到期时间为起点,排队套餐从前段结束后的下一时刻接续。
- 新记录只使用购买时计时快照;完整空快照的历史记录才回退套餐当前条款,部分或非法快照返回数据异常。
- 按上海自然日返回剩余天数,只有精确结果且剩余 0 至 15 天时标记临期。
## 接口字段
以下响应统一包含 `estimated_final_expires_at``days_until_final_expiry``expiry_estimate_status``expiry_estimate_status_name``is_expiring`
- `GET /api/admin/assets/resolve/:identifier`
- `GET /api/admin/iot-cards`
- `GET /api/admin/devices`
- `GET /api/c/v1/asset/info`
非精确状态的日期与剩余天数字段固定返回 `null`。状态分别为 `exact``waiting_activation``none``invalid_data`
## 性能与迁移
列表使用每页资产 ID 的一次批量 Query不逐资产查询套餐。迁移 `000164_add_package_expiry_query_indexes` 为卡和设备主套餐队列读取增加部分索引,可通过对应 down 迁移回滚。

View File

@@ -9,6 +9,7 @@ import (
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order" clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling" pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order" rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
@@ -19,6 +20,10 @@ import (
func initHandlers(svc *services, deps *Dependencies) *Handlers { func initHandlers(svc *services, deps *Dependencies) *Handlers {
validate := validator.New() validate := validator.New()
packageExpiry := packageExpiryQuery.NewQuery(deps.DB)
svc.Asset.SetPackageExpiryQuery(packageExpiry)
svc.IotCard.SetPackageExpiryQuery(packageExpiry)
svc.Device.SetPackageExpiryQuery(packageExpiry)
assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis) assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis)
packageStore := postgres.NewPackageStore(deps.DB) packageStore := postgres.NewPackageStore(deps.DB)
shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB) shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB)

View File

@@ -154,6 +154,7 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
phone, _ := middleware.GetCustomerPhone(c) phone, _ := middleware.GetCustomerPhone(c)
resp := &dto.AssetInfoResponse{ resp := &dto.AssetInfoResponse{
PackageExpiryEstimate: resolved.Asset.PackageExpiryEstimate,
BoundPhone: phone, BoundPhone: phone,
AssetType: resolved.Asset.AssetType, AssetType: resolved.Asset.AssetType,
AssetID: resolved.Asset.AssetID, AssetID: resolved.Asset.AssetID,
@@ -423,7 +424,6 @@ func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error {
return response.Success(c, resp) return response.Success(c, resp)
} }
func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) { func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType { switch assetType {
case "card": case "card":

View File

@@ -4,6 +4,7 @@ import "time"
// AssetResolveResponse 统一资产解析响应 // AssetResolveResponse 统一资产解析响应
type AssetResolveResponse struct { type AssetResolveResponse struct {
PackageExpiryEstimate
AssetType string `json:"asset_type" description:"资产类型card 或 device"` AssetType string `json:"asset_type" description:"资产类型card 或 device"`
AssetID uint `json:"asset_id" description:"资产数据库ID"` AssetID uint `json:"asset_id" description:"资产数据库ID"`
Identifier string `json:"identifier,omitempty" description:"解析时使用的标识符"` Identifier string `json:"identifier,omitempty" description:"解析时使用的标识符"`

View File

@@ -14,6 +14,7 @@ type AssetInfoRequest struct {
// AssetInfoResponse B1 资产信息响应 // AssetInfoResponse B1 资产信息响应
// 根据 asset_type 不同,设备专属字段或卡专属字段会分别填充(另一侧为零值/omit // 根据 asset_type 不同,设备专属字段或卡专属字段会分别填充(另一侧为零值/omit
type AssetInfoResponse struct { type AssetInfoResponse struct {
PackageExpiryEstimate
// === 登录用户信息 === // === 登录用户信息 ===
BoundPhone string `json:"bound_phone" description:"当前登录用户绑定的手机号"` BoundPhone string `json:"bound_phone" description:"当前登录用户绑定的手机号"`

View File

@@ -25,6 +25,7 @@ type ListDeviceRequest struct {
} }
type DeviceResponse struct { type DeviceResponse struct {
PackageExpiryEstimate
ID uint `json:"id" description:"设备ID"` ID uint `json:"id" description:"设备ID"`
VirtualNo string `json:"virtual_no" description:"设备虚拟号/别名"` VirtualNo string `json:"virtual_no" description:"设备虚拟号/别名"`
IMEI string `json:"imei" description:"设备IMEI"` IMEI string `json:"imei" description:"设备IMEI"`

View File

@@ -29,6 +29,7 @@ type ListStandaloneIotCardRequest struct {
} }
type StandaloneIotCardResponse struct { type StandaloneIotCardResponse struct {
PackageExpiryEstimate
ID uint `json:"id" description:"卡ID"` ID uint `json:"id" description:"卡ID"`
ICCID string `json:"iccid" description:"ICCID"` ICCID string `json:"iccid" description:"ICCID"`
VirtualNo string `json:"virtual_no" description:"卡虚拟号(用于客服查找资产)"` VirtualNo string `json:"virtual_no" description:"卡虚拟号(用于客服查找资产)"`

View File

@@ -0,0 +1,12 @@
package dto
import "time"
// PackageExpiryEstimate 资产全部有效主套餐连续使用后的最终到期推算结果。
type PackageExpiryEstimate struct {
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at" description:"预计套餐到期时间,无法精确推算时为 null"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"距预计套餐到期的上海自然日天数,无法精确推算时为 null"`
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"预计套餐到期推算状态 (exact:可精确推算, waiting_activation:待激活后起算, none:无参与套餐, invalid_data:数据异常)"`
ExpiryEstimateStatusName string `json:"expiry_estimate_status_name" description:"预计套餐到期推算状态名称(中文)"`
IsExpiring bool `json:"is_expiring" description:"是否临期(仅精确推算且剩余 0 至 15 个上海自然日时为 true"`
}

View File

@@ -0,0 +1,215 @@
// Package packageexpiry 提供资产主套餐最终到期时间的统一只读推算。
package packageexpiry
import (
"context"
"sort"
"time"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"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)
// Query 查询单个或批量资产的套餐最终到期时间。
type Query struct {
db *gorm.DB
now func() time.Time
}
// NewQuery 创建套餐最终到期查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db, now: time.Now}
}
// Resolve 查询单个资产的最终到期推算结果。
func (q *Query) Resolve(ctx context.Context, assetType string, assetID uint) (dto.PackageExpiryEstimate, error) {
results, err := q.ResolveBatch(ctx, assetType, []uint{assetID})
if err != nil {
return dto.PackageExpiryEstimate{}, err
}
return results[assetID], nil
}
// ResolveBatch 批量查询同类型资产的最终到期推算结果,避免列表场景逐资产读取。
func (q *Query) ResolveBatch(ctx context.Context, assetType string, assetIDs []uint) (map[uint]dto.PackageExpiryEstimate, error) {
results := make(map[uint]dto.PackageExpiryEstimate, len(assetIDs))
if len(assetIDs) == 0 {
return results, nil
}
column, ok := packageUsageAssetColumn(assetType)
if !ok {
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
usages := make([]*model.PackageUsage, 0)
if err := q.db.WithContext(ctx).
Where(column+" IN ?", assetIDs).
Where("master_usage_id IS NULL").
Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Where("refund_id IS NULL").
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐最终到期记录失败")
}
packages, err := q.loadFallbackPackages(ctx, usages)
if err != nil {
return nil, err
}
grouped := make(map[uint][]*model.PackageUsage, len(assetIDs))
for _, usage := range usages {
assetID := usage.IotCardID
if assetType == constants.AssetTypeDevice {
assetID = usage.DeviceID
}
grouped[assetID] = append(grouped[assetID], usage)
}
for _, assetID := range assetIDs {
results[assetID] = Calculate(grouped[assetID], packages, q.now())
}
return results, nil
}
func (q *Query) loadFallbackPackages(ctx context.Context, usages []*model.PackageUsage) (map[uint]*model.Package, error) {
ids := make([]uint, 0)
seen := make(map[uint]struct{})
for _, usage := range usages {
if !isEmptyHistoricalTerms(usage) {
continue
}
if _, exists := seen[usage.PackageID]; !exists {
seen[usage.PackageID] = struct{}{}
ids = append(ids, usage.PackageID)
}
}
packages := make(map[uint]*model.Package, len(ids))
if len(ids) == 0 {
return packages, nil
}
var rows []*model.Package
if err := q.db.WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史套餐计时条款失败")
}
for _, pkg := range rows {
packages[pkg.ID] = pkg
}
return packages, nil
}
// Calculate 使用已批量读取的记录和历史套餐回退数据计算最终到期时间。
func Calculate(usages []*model.PackageUsage, packages map[uint]*model.Package, now time.Time) dto.PackageExpiryEstimate {
eligible := make([]*model.PackageUsage, 0, len(usages))
for _, usage := range usages {
if usage == nil || usage.DeletedAt.Valid || usage.RefundID != nil || usage.MasterUsageID != nil {
continue
}
if usage.Status == constants.PackageUsageStatusPending || usage.Status == constants.PackageUsageStatusActive || usage.Status == constants.PackageUsageStatusDepleted {
eligible = append(eligible, usage)
}
}
if len(eligible) == 0 {
return newEstimate(constants.PackageExpiryEstimateStatusNone, nil, now)
}
sort.SliceStable(eligible, func(i, j int) bool {
if eligible[i].Priority != eligible[j].Priority {
return eligible[i].Priority < eligible[j].Priority
}
if !eligible[i].CreatedAt.Equal(eligible[j].CreatedAt) {
return eligible[i].CreatedAt.Before(eligible[j].CreatedAt)
}
return eligible[i].ID < eligible[j].ID
})
currentIndex := -1
for i, usage := range eligible {
if usage.Status == constants.PackageUsageStatusActive || usage.Status == constants.PackageUsageStatusDepleted {
if currentIndex >= 0 || usage.ExpiresAt == nil {
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
currentIndex = i
}
}
if currentIndex < 0 {
first := eligible[0]
terms, ok := resolveTerms(first, packages)
if !ok {
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
if first.PendingRealnameActivation || terms.ExpiryBase == constants.PackageExpiryBaseFromActivation {
return newEstimate(constants.PackageExpiryEstimateStatusWaitingActivation, nil, now)
}
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
for i := 0; i < currentIndex; i++ {
if eligible[i].Status == constants.PackageUsageStatusPending {
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
}
cursor := eligible[currentIndex].ExpiresAt.In(shanghaiLocation)
for _, usage := range eligible[currentIndex+1:] {
if usage.Status != constants.PackageUsageStatusPending {
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
terms, ok := resolveTerms(usage, packages)
if !ok {
return newEstimate(constants.PackageExpiryEstimateStatusInvalidData, nil, now)
}
start := cursor.Add(time.Second)
cursor = packagepkg.CalculateExpiryTime(terms.CalendarType, start, terms.DurationMonths, terms.DurationDays).In(shanghaiLocation)
}
return newEstimate(constants.PackageExpiryEstimateStatusExact, &cursor, now)
}
func resolveTerms(usage *model.PackageUsage, packages map[uint]*model.Package) (packagedomain.TermsSnapshot, bool) {
terms := packagedomain.TermsSnapshotFromUsage(usage)
if terms.IsValid() {
return terms, true
}
if !isEmptyHistoricalTerms(usage) {
return packagedomain.TermsSnapshot{}, false
}
pkg := packages[usage.PackageID]
terms, err := packagedomain.ResolveTermsSnapshot(pkg, nil)
return terms, err == nil
}
func isEmptyHistoricalTerms(usage *model.PackageUsage) bool {
// UR55 的 000163 迁移已在测试环境生效:触发器拒绝新记录写入空快照,
// 因此整组空值只可能是迁移前遗留记录;任意部分空值仍按异常处理。
return usage.ExpiryBaseSnapshot == "" && usage.CalendarTypeSnapshot == "" && usage.DurationMonthsSnapshot == 0 && usage.DurationDaysSnapshot == 0
}
func packageUsageAssetColumn(assetType string) (string, bool) {
switch assetType {
case constants.AssetTypeIotCard, constants.AssetResolveTypeCard:
return "iot_card_id", true
case constants.AssetTypeDevice:
return "device_id", true
default:
return "", false
}
}
func newEstimate(status string, expiresAt *time.Time, now time.Time) dto.PackageExpiryEstimate {
result := dto.PackageExpiryEstimate{ExpiryEstimateStatus: status, ExpiryEstimateStatusName: constants.GetPackageExpiryEstimateStatusName(status)}
if status != constants.PackageExpiryEstimateStatusExact || expiresAt == nil {
return result
}
days := dateInShanghai(*expiresAt).Sub(dateInShanghai(now))
daysUntil := int(days.Hours() / 24)
result.EstimatedFinalExpiresAt = expiresAt
result.DaysUntilFinalExpiry = &daysUntil
result.IsExpiring = daysUntil >= 0 && daysUntil <= 15
return result
}
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)
}

View File

@@ -0,0 +1,167 @@
package packageexpiry
import (
"context"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/testutil"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"gorm.io/gorm"
)
func TestCalculate(t *testing.T) {
location := time.FixedZone("Asia/Shanghai", 8*60*60)
now := time.Date(2026, 7, 23, 12, 0, 0, 0, location)
currentExpiry := time.Date(2026, 7, 31, 23, 59, 59, 0, location)
createdAt := time.Date(2026, 7, 1, 12, 0, 0, 0, location)
newUsage := func(id uint, status int, priority int) *model.PackageUsage {
return &model.PackageUsage{
Model: modelBase(id, createdAt.Add(time.Duration(id)*time.Second)),
PackageID: id,
Status: status,
Priority: priority,
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation,
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
DurationDaysSnapshot: 3,
}
}
tests := []struct {
name string
usages []*model.PackageUsage
packages map[uint]*model.Package
wantStatus string
wantDate *time.Time
wantDays *int
}{
{name: "无套餐", wantStatus: constants.PackageExpiryEstimateStatusNone},
{
name: "当前套餐", usages: func() []*model.PackageUsage {
u := newUsage(1, constants.PackageUsageStatusActive, 1)
u.ExpiresAt = &currentExpiry
return []*model.PackageUsage{u}
}(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDate: &currentExpiry, wantDays: intPointer(8),
},
{
name: "多段队列按下一时刻接续", usages: func() []*model.PackageUsage {
current := newUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
queued.DurationDaysSnapshot = 3
return []*model.PackageUsage{current, queued}
}(), wantStatus: constants.PackageExpiryEstimateStatusExact,
wantDate: timePointer(time.Date(2026, 8, 4, 23, 59, 59, 0, location)), wantDays: intPointer(12),
},
{
name: "自然月月末", usages: func() []*model.PackageUsage {
current := newUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
queued.CalendarTypeSnapshot = constants.PackageCalendarTypeNaturalMonth
queued.DurationDaysSnapshot = 0
queued.DurationMonthsSnapshot = 1
return []*model.PackageUsage{current, queued}
}(), wantStatus: constants.PackageExpiryEstimateStatusExact,
wantDate: timePointer(time.Date(2026, 9, 30, 23, 59, 59, 0, location)), wantDays: intPointer(69),
},
{
name: "等待实名激活", usages: func() []*model.PackageUsage {
u := newUsage(1, constants.PackageUsageStatusPending, 1)
u.PendingRealnameActivation = true
return []*model.PackageUsage{u}
}(), wantStatus: constants.PackageExpiryEstimateStatusWaitingActivation,
},
{
name: "历史空快照回退", usages: func() []*model.PackageUsage {
current := newUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
queued.ExpiryBaseSnapshot, queued.CalendarTypeSnapshot, queued.DurationDaysSnapshot = "", "", 0
return []*model.PackageUsage{current, queued}
}(), packages: map[uint]*model.Package{2: {ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 3}},
wantStatus: constants.PackageExpiryEstimateStatusExact,
},
{
name: "非法快照", usages: func() []*model.PackageUsage {
current := newUsage(1, constants.PackageUsageStatusActive, 1)
current.ExpiresAt = &currentExpiry
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
queued.CalendarTypeSnapshot = "bad"
return []*model.PackageUsage{current, queued}
}(), wantStatus: constants.PackageExpiryEstimateStatusInvalidData,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Calculate(tt.usages, tt.packages, now)
if got.ExpiryEstimateStatus != tt.wantStatus {
t.Fatalf("状态错误want=%s got=%s", tt.wantStatus, got.ExpiryEstimateStatus)
}
if tt.wantDate != nil && (got.EstimatedFinalExpiresAt == nil || !got.EstimatedFinalExpiresAt.Equal(*tt.wantDate)) {
t.Fatalf("到期时间错误want=%v got=%v", tt.wantDate, got.EstimatedFinalExpiresAt)
}
if tt.wantDays != nil && (got.DaysUntilFinalExpiry == nil || *got.DaysUntilFinalExpiry != *tt.wantDays) {
t.Fatalf("剩余天数错误want=%v got=%v", *tt.wantDays, got.DaysUntilFinalExpiry)
}
if tt.wantStatus != constants.PackageExpiryEstimateStatusExact && (got.EstimatedFinalExpiresAt != nil || got.DaysUntilFinalExpiry != nil) {
t.Fatalf("非精确状态必须返回 null%+v", got)
}
})
}
}
// TestQueryResolveBatchUsesOneBatchLoad 验证一页资产只通过批量读取计算,不逐卡查询套餐。
func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
location := time.FixedZone("Asia/Shanghai", 8*60*60)
now := time.Date(2026, 7, 23, 12, 0, 0, 0, location)
assetIDs := make([]uint, 0, 100)
for i := 1; i <= 100; i++ {
assetID := uint(970000 + i)
assetIDs = append(assetIDs, assetID)
expiresAt := now.AddDate(0, 0, i%16)
usage := &model.PackageUsage{
OrderID: assetID,
OrderNo: "UR46-BATCH",
PackageID: assetID,
UsageType: constants.AssetTypeIotCard,
IotCardID: assetID,
DataLimitMB: 1,
Status: constants.PackageUsageStatusActive,
Priority: 1,
ActivatedAt: &now,
ExpiresAt: &expiresAt,
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation,
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
DurationDaysSnapshot: 30,
DurationMonthsSnapshot: 0,
}
if err := tx.Create(usage).Error; err != nil {
t.Fatalf("创建批量套餐使用记录失败:%v", err)
}
}
query := NewQuery(tx)
query.now = func() time.Time { return now }
results, err := query.ResolveBatch(context.Background(), constants.AssetTypeIotCard, assetIDs)
if err != nil {
t.Fatalf("批量查询失败:%v", err)
}
if len(results) != len(assetIDs) {
t.Fatalf("批量结果数量错误want=%d got=%d", len(assetIDs), len(results))
}
for _, assetID := range assetIDs {
if results[assetID].ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {
t.Fatalf("资产 %d 应得到精确结果:%+v", assetID, results[assetID])
}
}
}
func modelBase(id uint, createdAt time.Time) gorm.Model {
return gorm.Model{ID: id, CreatedAt: createdAt}
}
func intPointer(value int) *int { return &value }
func timePointer(value time.Time) *time.Time { return &value }

View File

@@ -13,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -29,6 +30,11 @@ type IotCardRefresher interface {
RefreshCardDataFromGateway(ctx context.Context, iccid string) error RefreshCardDataFromGateway(ctx context.Context, iccid string) error
} }
// PackageExpiryResolver 查询资产的套餐最终到期推算结果。
type PackageExpiryResolver interface {
Resolve(ctx context.Context, assetType string, assetID uint) (dto.PackageExpiryEstimate, error)
}
// Service 资产查询与操作服务 // Service 资产查询与操作服务
type Service struct { type Service struct {
db *gorm.DB db *gorm.DB
@@ -47,6 +53,12 @@ type Service struct {
gatewayClient *gateway.Client gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore assetIdentifierStore *postgres.AssetIdentifierStore
assetAuditService assetAuditSvc.OperationLogger assetAuditService assetAuditSvc.OperationLogger
packageExpiryQuery PackageExpiryResolver
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供资产详情统一投影使用。
func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) {
s.packageExpiryQuery = query
} }
// New 创建资产服务实例 // New 创建资产服务实例
@@ -85,6 +97,7 @@ func New(
gatewayClient: gatewayClient, gatewayClient: gatewayClient,
assetIdentifierStore: assetIdentifierStore, assetIdentifierStore: assetIdentifierStore,
assetAuditService: assetAuditService, assetAuditService: assetAuditService,
packageExpiryQuery: packageexpiry.NewQuery(db),
} }
} }
@@ -246,6 +259,9 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model.
// 查当前主套餐 // 查当前主套餐
s.fillPackageInfo(ctx, resp, "device", device.ID) s.fillPackageInfo(ctx, resp, "device", device.ID)
if err := s.fillPackageExpiryEstimate(ctx, resp, constants.AssetTypeDevice, device.ID); err != nil {
return nil, err
}
activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID}) activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID})
if err != nil { if err != nil {
@@ -312,6 +328,9 @@ func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotC
// 查当前主套餐 // 查当前主套餐
s.fillPackageInfo(ctx, resp, "iot_card", card.ID) s.fillPackageInfo(ctx, resp, "iot_card", card.ID)
if err := s.fillPackageExpiryEstimate(ctx, resp, constants.AssetTypeIotCard, card.ID); err != nil {
return nil, err
}
// 查 shop 名称 // 查 shop 名称
s.fillShopName(ctx, resp) s.fillShopName(ctx, resp)
@@ -349,6 +368,19 @@ func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveRes
resp.EnableVirtualData = usage.EnableVirtualDataSnapshot resp.EnableVirtualData = usage.EnableVirtualDataSnapshot
} }
// fillPackageExpiryEstimate 使用统一 Query 投影资产的最终套餐到期,不允许查询失败降级为无套餐。
func (s *Service) fillPackageExpiryEstimate(ctx context.Context, resp *dto.AssetResolveResponse, assetType string, assetID uint) error {
if s.packageExpiryQuery == nil {
return errors.New(errors.CodeInternalError, "套餐最终到期查询未初始化")
}
estimate, err := s.packageExpiryQuery.Resolve(ctx, assetType, assetID)
if err != nil {
return err
}
resp.PackageExpiryEstimate = estimate
return nil
}
// fillUsageSummary 填充当前世代流量汇总字段。 // fillUsageSummary 填充当前世代流量汇总字段。
// 仅在 includeUsageSummary=true 时被调用,无套餐时返回 0.0(非 null // 仅在 includeUsageSummary=true 时被调用,无套餐时返回 0.0(非 null
func (s *Service) fillUsageSummary(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) { func (s *Service) fillUsageSummary(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) {

View File

@@ -11,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
"github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -20,21 +21,27 @@ import (
) )
type Service struct { type Service struct {
db *gorm.DB db *gorm.DB
redis *redis.Client redis *redis.Client
deviceStore *postgres.DeviceStore deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore iotCardStore *postgres.IotCardStore
shopStore *postgres.ShopStore shopStore *postgres.ShopStore
assetAllocationRecordStore *postgres.AssetAllocationRecordStore assetAllocationRecordStore *postgres.AssetAllocationRecordStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
packageSeriesStore *postgres.PackageSeriesStore packageSeriesStore *postgres.PackageSeriesStore
gatewayClient *gateway.Client gatewayClient *gateway.Client
assetIdentifierStore *postgres.AssetIdentifierStore assetIdentifierStore *postgres.AssetIdentifierStore
assetAuditService AssetAuditService assetAuditService AssetAuditService
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseStore *postgres.EnterpriseStore enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) {
s.packageExpiryQuery = query
} }
func New( func New(
@@ -55,21 +62,22 @@ func New(
enterpriseStore *postgres.EnterpriseStore, enterpriseStore *postgres.EnterpriseStore,
) *Service { ) *Service {
return &Service{ return &Service{
db: db, db: db,
redis: rds, redis: rds,
deviceStore: deviceStore, deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore, deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore, iotCardStore: iotCardStore,
shopStore: shopStore, shopStore: shopStore,
assetAllocationRecordStore: assetAllocationRecordStore, assetAllocationRecordStore: assetAllocationRecordStore,
shopPackageAllocationStore: shopPackageAllocationStore, shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore, shopSeriesAllocationStore: shopSeriesAllocationStore,
packageSeriesStore: packageSeriesStore, packageSeriesStore: packageSeriesStore,
gatewayClient: gatewayClient, gatewayClient: gatewayClient,
assetIdentifierStore: assetIdentifierStore, assetIdentifierStore: assetIdentifierStore,
assetAuditService: assetAuditService, assetAuditService: assetAuditService,
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore, enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
enterpriseStore: enterpriseStore, enterpriseStore: enterpriseStore,
packageExpiryQuery: packageexpiry.NewQuery(db),
} }
} }
@@ -150,10 +158,14 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
if err != nil { if err != nil {
return nil, err return nil, err
} }
deviceIDs := s.extractDeviceIDs(devices)
expiryEstimates, err := s.packageExpiryQuery.ResolveBatch(ctx, constants.AssetTypeDevice, deviceIDs)
if err != nil {
return nil, err
}
shopMap := s.loadShopData(ctx, devices) shopMap := s.loadShopData(ctx, devices)
seriesMap := s.loadSeriesNames(ctx, devices) seriesMap := s.loadSeriesNames(ctx, devices)
deviceIDs := s.extractDeviceIDs(devices)
bindingCounts, err := s.getBindingCounts(ctx, deviceIDs) bindingCounts, err := s.getBindingCounts(ctx, deviceIDs)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -184,6 +196,7 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
list := make([]*dto.DeviceResponse, 0, len(devices)) list := make([]*dto.DeviceResponse, 0, len(devices))
for _, device := range devices { for _, device := range devices {
item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses) item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses)
item.PackageExpiryEstimate = expiryEstimates[device.ID]
if eid, ok := deviceEnterpriseMap[device.ID]; ok { if eid, ok := deviceEnterpriseMap[device.ID]; ok {
item.AuthorizedEnterpriseID = &eid item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid] item.AuthorizedEnterpriseName = enterpriseNameMap[eid]

View File

@@ -9,6 +9,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -73,6 +74,12 @@ type Service struct {
assetAuditService AssetAuditService assetAuditService AssetAuditService
enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore
enterpriseStore *postgres.EnterpriseStore enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) {
s.packageExpiryQuery = query
} }
func New( func New(
@@ -98,6 +105,7 @@ func New(
gatewayClient: gatewayClient, gatewayClient: gatewayClient,
logger: logger, logger: logger,
assetAuditService: assetAuditService, assetAuditService: assetAuditService,
packageExpiryQuery: packageexpiry.NewQuery(db),
} }
} }
@@ -278,6 +286,14 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
if err != nil { if err != nil {
return nil, err return nil, err
} }
cardIDs := make([]uint, 0, len(cards))
for _, card := range cards {
cardIDs = append(cardIDs, card.ID)
}
expiryEstimates, err := s.packageExpiryQuery.ResolveBatch(ctx, constants.AssetTypeIotCard, cardIDs)
if err != nil {
return nil, err
}
shopMap := s.loadShopNames(ctx, cards) shopMap := s.loadShopNames(ctx, cards)
//TODO 这里不对,现在已经快照了,这里如果还这样处理明显是浪费的 //TODO 这里不对,现在已经快照了,这里如果还这样处理明显是浪费的
@@ -318,6 +334,7 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
list := make([]*dto.StandaloneIotCardResponse, 0, len(cards)) list := make([]*dto.StandaloneIotCardResponse, 0, len(cards))
for _, card := range cards { for _, card := range cards {
item := s.toStandaloneResponse(card, shopMap, seriesMap) item := s.toStandaloneResponse(card, shopMap, seriesMap)
item.PackageExpiryEstimate = expiryEstimates[card.ID]
if eid, ok := cardAuthMap[card.ID]; ok { if eid, ok := cardAuthMap[card.ID]; ok {
item.AuthorizedEnterpriseID = &eid item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid] item.AuthorizedEnterpriseName = enterpriseNameMap[eid]

View File

@@ -0,0 +1,3 @@
-- 回滚资产最终套餐到期实时投影读取索引。
DROP INDEX IF EXISTS idx_package_usage_expiry_device_queue;
DROP INDEX IF EXISTS idx_package_usage_expiry_iot_card_queue;

View File

@@ -0,0 +1,14 @@
-- 为资产最终套餐到期的实时投影提供稳定的主套餐队列读取索引。
CREATE INDEX idx_package_usage_expiry_iot_card_queue
ON tb_package_usage (iot_card_id, priority ASC, created_at ASC, id ASC)
WHERE deleted_at IS NULL
AND master_usage_id IS NULL
AND refund_id IS NULL
AND status IN (0, 1, 2);
CREATE INDEX idx_package_usage_expiry_device_queue
ON tb_package_usage (device_id, priority ASC, created_at ASC, id ASC)
WHERE deleted_at IS NULL
AND master_usage_id IS NULL
AND refund_id IS NULL
AND status IN (0, 1, 2);

View File

@@ -0,0 +1,25 @@
package constants
// 套餐最终到期推算状态常量。
const (
PackageExpiryEstimateStatusExact = "exact" // 可精确推算
PackageExpiryEstimateStatusWaitingActivation = "waiting_activation" // 待激活后起算
PackageExpiryEstimateStatusNone = "none" // 无参与套餐
PackageExpiryEstimateStatusInvalidData = "invalid_data" // 数据异常
)
// GetPackageExpiryEstimateStatusName 返回套餐最终到期推算状态的中文名称。
func GetPackageExpiryEstimateStatusName(status string) string {
switch status {
case PackageExpiryEstimateStatusExact:
return "可精确推算"
case PackageExpiryEstimateStatusWaitingActivation:
return "待激活后起算"
case PackageExpiryEstimateStatusNone:
return "无套餐"
case PackageExpiryEstimateStatusInvalidData:
return "数据异常"
default:
return "未知"
}
}