From 8d65b26e96939e677ad2b5feb973a55e8729d08f Mon Sep 17 00:00:00 2001 From: break Date: Thu, 23 Jul 2026 13:14:43 +0900 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=B5=84=E4=BA=A7=E9=A2=84?= =?UTF-8?q?=E8=AE=A1=E6=9C=80=E7=BB=88=E5=88=B0=E6=9C=9F=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + docs/ur46-estimated-final-expiry/功能总结.md | 27 +++ internal/bootstrap/handlers.go | 5 + internal/handler/app/client_asset.go | 2 +- internal/model/dto/asset_dto.go | 1 + internal/model/dto/client_asset_dto.go | 1 + internal/model/dto/device_dto.go | 1 + internal/model/dto/iot_card_dto.go | 1 + internal/model/dto/package_expiry_dto.go | 12 + internal/query/packageexpiry/query.go | 215 ++++++++++++++++++ internal/query/packageexpiry/query_test.go | 167 ++++++++++++++ internal/service/asset/service.go | 32 +++ internal/service/device/service.go | 75 +++--- internal/service/iot_card/service.go | 17 ++ ..._add_package_expiry_query_indexes.down.sql | 3 + ...64_add_package_expiry_query_indexes.up.sql | 14 ++ pkg/constants/package_expiry.go | 25 ++ 17 files changed, 570 insertions(+), 32 deletions(-) create mode 100644 docs/ur46-estimated-final-expiry/功能总结.md create mode 100644 internal/model/dto/package_expiry_dto.go create mode 100644 internal/query/packageexpiry/query.go create mode 100644 internal/query/packageexpiry/query_test.go create mode 100644 migrations/000164_add_package_expiry_query_indexes.down.sql create mode 100644 migrations/000164_add_package_expiry_query_indexes.up.sql create mode 100644 pkg/constants/package_expiry.go diff --git a/README.md b/README.md index af59d86..d31074a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ 后台设备列表 `GET /api/admin/devices` 支持按 `virtual_no`、`imei`、设备名称、归属状态、激活状态、店铺、套餐系列等条件筛选。 +### 资产预计套餐到期时间 + +后台资产详情、卡/设备列表与 C 端资产信息统一返回主套餐队列连续使用后的预计最终到期时间。该值实时根据当前套餐、排队套餐和购买时计时快照计算;当前套餐自身到期时间仅保留在套餐明细中。 + ### 三种客户类型 | 客户类型 | 业务特点 | 典型场景 | 钱包归属 | diff --git a/docs/ur46-estimated-final-expiry/功能总结.md b/docs/ur46-estimated-final-expiry/功能总结.md new file mode 100644 index 0000000..ff5dbaf --- /dev/null +++ b/docs/ur46-estimated-final-expiry/功能总结.md @@ -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 迁移回滚。 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index e5a5296..a513ff9 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -9,6 +9,7 @@ import ( pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" 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" pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling" rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order" @@ -19,6 +20,10 @@ import ( func initHandlers(svc *services, deps *Dependencies) *Handlers { 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) packageStore := postgres.NewPackageStore(deps.DB) shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB) diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index 2ea0c57..8438308 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -154,6 +154,7 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error { phone, _ := middleware.GetCustomerPhone(c) resp := &dto.AssetInfoResponse{ + PackageExpiryEstimate: resolved.Asset.PackageExpiryEstimate, BoundPhone: phone, AssetType: resolved.Asset.AssetType, AssetID: resolved.Asset.AssetID, @@ -423,7 +424,6 @@ func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error { return response.Success(c, resp) } - func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) { switch assetType { case "card": diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index a89f242..5844d57 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -4,6 +4,7 @@ import "time" // AssetResolveResponse 统一资产解析响应 type AssetResolveResponse struct { + PackageExpiryEstimate AssetType string `json:"asset_type" description:"资产类型:card 或 device"` AssetID uint `json:"asset_id" description:"资产数据库ID"` Identifier string `json:"identifier,omitempty" description:"解析时使用的标识符"` diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index 0e44ae8..68ded1d 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -14,6 +14,7 @@ type AssetInfoRequest struct { // AssetInfoResponse B1 资产信息响应 // 根据 asset_type 不同,设备专属字段或卡专属字段会分别填充(另一侧为零值/omit) type AssetInfoResponse struct { + PackageExpiryEstimate // === 登录用户信息 === BoundPhone string `json:"bound_phone" description:"当前登录用户绑定的手机号"` diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index 1029835..7d7e889 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -25,6 +25,7 @@ type ListDeviceRequest struct { } type DeviceResponse struct { + PackageExpiryEstimate ID uint `json:"id" description:"设备ID"` VirtualNo string `json:"virtual_no" description:"设备虚拟号/别名"` IMEI string `json:"imei" description:"设备IMEI"` diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index a9d878d..dfd2bb4 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -29,6 +29,7 @@ type ListStandaloneIotCardRequest struct { } type StandaloneIotCardResponse struct { + PackageExpiryEstimate ID uint `json:"id" description:"卡ID"` ICCID string `json:"iccid" description:"ICCID"` VirtualNo string `json:"virtual_no" description:"卡虚拟号(用于客服查找资产)"` diff --git a/internal/model/dto/package_expiry_dto.go b/internal/model/dto/package_expiry_dto.go new file mode 100644 index 0000000..d1b8e56 --- /dev/null +++ b/internal/model/dto/package_expiry_dto.go @@ -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)"` +} diff --git a/internal/query/packageexpiry/query.go b/internal/query/packageexpiry/query.go new file mode 100644 index 0000000..71c9a0d --- /dev/null +++ b/internal/query/packageexpiry/query.go @@ -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) +} diff --git a/internal/query/packageexpiry/query_test.go b/internal/query/packageexpiry/query_test.go new file mode 100644 index 0000000..0dd4ba4 --- /dev/null +++ b/internal/query/packageexpiry/query_test.go @@ -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 = ¤tExpiry + return []*model.PackageUsage{u} + }(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDate: ¤tExpiry, wantDays: intPointer(8), + }, + { + name: "多段队列按下一时刻接续", usages: func() []*model.PackageUsage { + current := newUsage(1, constants.PackageUsageStatusActive, 1) + current.ExpiresAt = ¤tExpiry + 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 = ¤tExpiry + 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 = ¤tExpiry + 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 = ¤tExpiry + 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 } diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index cedce31..59fdc89 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -13,6 +13,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/model" "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" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -29,6 +30,11 @@ type IotCardRefresher interface { RefreshCardDataFromGateway(ctx context.Context, iccid string) error } +// PackageExpiryResolver 查询资产的套餐最终到期推算结果。 +type PackageExpiryResolver interface { + Resolve(ctx context.Context, assetType string, assetID uint) (dto.PackageExpiryEstimate, error) +} + // Service 资产查询与操作服务 type Service struct { db *gorm.DB @@ -47,6 +53,12 @@ type Service struct { gatewayClient *gateway.Client assetIdentifierStore *postgres.AssetIdentifierStore assetAuditService assetAuditSvc.OperationLogger + packageExpiryQuery PackageExpiryResolver +} + +// SetPackageExpiryQuery 注入套餐最终到期查询,供资产详情统一投影使用。 +func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) { + s.packageExpiryQuery = query } // New 创建资产服务实例 @@ -85,6 +97,7 @@ func New( gatewayClient: gatewayClient, assetIdentifierStore: assetIdentifierStore, 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) + if err := s.fillPackageExpiryEstimate(ctx, resp, constants.AssetTypeDevice, device.ID); err != nil { + return nil, err + } activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID}) 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) + if err := s.fillPackageExpiryEstimate(ctx, resp, constants.AssetTypeIotCard, card.ID); err != nil { + return nil, err + } // 查 shop 名称 s.fillShopName(ctx, resp) @@ -349,6 +368,19 @@ func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveRes 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 填充当前世代流量汇总字段。 // 仅在 includeUsageSummary=true 时被调用,无套餐时返回 0.0(非 null)。 func (s *Service) fillUsageSummary(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) { diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 38ea715..2ecc2df 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -11,6 +11,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/model" "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/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -20,21 +21,27 @@ import ( ) type Service struct { - db *gorm.DB - redis *redis.Client - deviceStore *postgres.DeviceStore - deviceSimBindingStore *postgres.DeviceSimBindingStore - iotCardStore *postgres.IotCardStore - shopStore *postgres.ShopStore - assetAllocationRecordStore *postgres.AssetAllocationRecordStore - shopPackageAllocationStore *postgres.ShopPackageAllocationStore - shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore - packageSeriesStore *postgres.PackageSeriesStore - gatewayClient *gateway.Client - assetIdentifierStore *postgres.AssetIdentifierStore - assetAuditService AssetAuditService - enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore - enterpriseStore *postgres.EnterpriseStore + db *gorm.DB + redis *redis.Client + deviceStore *postgres.DeviceStore + deviceSimBindingStore *postgres.DeviceSimBindingStore + iotCardStore *postgres.IotCardStore + shopStore *postgres.ShopStore + assetAllocationRecordStore *postgres.AssetAllocationRecordStore + shopPackageAllocationStore *postgres.ShopPackageAllocationStore + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore + packageSeriesStore *postgres.PackageSeriesStore + gatewayClient *gateway.Client + assetIdentifierStore *postgres.AssetIdentifierStore + assetAuditService AssetAuditService + enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore + enterpriseStore *postgres.EnterpriseStore + packageExpiryQuery *packageexpiry.Query +} + +// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。 +func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) { + s.packageExpiryQuery = query } func New( @@ -55,21 +62,22 @@ func New( enterpriseStore *postgres.EnterpriseStore, ) *Service { return &Service{ - db: db, - redis: rds, - deviceStore: deviceStore, - deviceSimBindingStore: deviceSimBindingStore, - iotCardStore: iotCardStore, - shopStore: shopStore, - assetAllocationRecordStore: assetAllocationRecordStore, - shopPackageAllocationStore: shopPackageAllocationStore, - shopSeriesAllocationStore: shopSeriesAllocationStore, - packageSeriesStore: packageSeriesStore, - gatewayClient: gatewayClient, - assetIdentifierStore: assetIdentifierStore, - assetAuditService: assetAuditService, - enterpriseDeviceAuthStore: enterpriseDeviceAuthStore, - enterpriseStore: enterpriseStore, + db: db, + redis: rds, + deviceStore: deviceStore, + deviceSimBindingStore: deviceSimBindingStore, + iotCardStore: iotCardStore, + shopStore: shopStore, + assetAllocationRecordStore: assetAllocationRecordStore, + shopPackageAllocationStore: shopPackageAllocationStore, + shopSeriesAllocationStore: shopSeriesAllocationStore, + packageSeriesStore: packageSeriesStore, + gatewayClient: gatewayClient, + assetIdentifierStore: assetIdentifierStore, + assetAuditService: assetAuditService, + enterpriseDeviceAuthStore: enterpriseDeviceAuthStore, + 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 { 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) seriesMap := s.loadSeriesNames(ctx, devices) - deviceIDs := s.extractDeviceIDs(devices) bindingCounts, err := s.getBindingCounts(ctx, deviceIDs) if err != nil { 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)) for _, device := range devices { item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses) + item.PackageExpiryEstimate = expiryEstimates[device.ID] if eid, ok := deviceEnterpriseMap[device.ID]; ok { item.AuthorizedEnterpriseID = &eid item.AuthorizedEnterpriseName = enterpriseNameMap[eid] diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index b3316a9..5077ae1 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -9,6 +9,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/model" "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" "github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -73,6 +74,12 @@ type Service struct { assetAuditService AssetAuditService enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore enterpriseStore *postgres.EnterpriseStore + packageExpiryQuery *packageexpiry.Query +} + +// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。 +func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) { + s.packageExpiryQuery = query } func New( @@ -98,6 +105,7 @@ func New( gatewayClient: gatewayClient, logger: logger, assetAuditService: assetAuditService, + packageExpiryQuery: packageexpiry.NewQuery(db), } } @@ -278,6 +286,14 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot if err != nil { 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) //TODO 这里不对,现在已经快照了,这里如果还这样处理明显是浪费的 @@ -318,6 +334,7 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot list := make([]*dto.StandaloneIotCardResponse, 0, len(cards)) for _, card := range cards { item := s.toStandaloneResponse(card, shopMap, seriesMap) + item.PackageExpiryEstimate = expiryEstimates[card.ID] if eid, ok := cardAuthMap[card.ID]; ok { item.AuthorizedEnterpriseID = &eid item.AuthorizedEnterpriseName = enterpriseNameMap[eid] diff --git a/migrations/000164_add_package_expiry_query_indexes.down.sql b/migrations/000164_add_package_expiry_query_indexes.down.sql new file mode 100644 index 0000000..21dfafb --- /dev/null +++ b/migrations/000164_add_package_expiry_query_indexes.down.sql @@ -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; diff --git a/migrations/000164_add_package_expiry_query_indexes.up.sql b/migrations/000164_add_package_expiry_query_indexes.up.sql new file mode 100644 index 0000000..8642bba --- /dev/null +++ b/migrations/000164_add_package_expiry_query_indexes.up.sql @@ -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); diff --git a/pkg/constants/package_expiry.go b/pkg/constants/package_expiry.go new file mode 100644 index 0000000..dd99049 --- /dev/null +++ b/pkg/constants/package_expiry.go @@ -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 "未知" + } +}