package packageexpiry import ( "context" "sort" "strings" "time" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" "gorm.io/gorm" ) const expiryWindowDays = 15 // ListResult 是临期资产 Query 的分页结果。 type ListResult struct { Items []dto.ExpiringAssetItem Total int64 Page int Size int Summary dto.ExpiringAssetSummary } type assetCandidate struct { AssetType string AssetID uint Identifier string ShopID *uint } // ListFilter 是临期资产同一口径的筛选条件,列表与导出共用。 // 时间边界由共享严格解析器解析为 UTC 瞬时,按最终到期时刻闭区间比较。 type ListFilter struct { AssetType string Keyword string ShopID *uint PackageID *uint DaysMin *int DaysMax *int StartTime *time.Time EndTime *time.Time } // ScopeApplier 在候选查询上应用数据范围。 // 列表使用请求上下文范围,导出使用任务创建时冻结的可见店铺范围。 type ScopeApplier func(query *gorm.DB) *gorm.DB // List 查询当前权限范围内的临期资产,并返回同口径数量汇总。 // 时间边界由调用方用共享严格解析器解析后传入,按最终到期时刻闭区间比较。 func (q *Query) List(ctx context.Context, request dto.ExpiringAssetListRequest, startTime, endTime *time.Time) (ListResult, error) { if q == nil || q.db == nil { return ListResult{}, errors.New(errors.CodeInternalError, "套餐临期查询未配置") } if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise { return ListResult{}, errors.New(errors.CodeForbidden, "企业账号无权查看临期资产列表") } request = normalizeListRequest(request) if err := validateListRequest(request); err != nil { return ListResult{}, err } filter := ListFilter{ AssetType: request.AssetType, Keyword: request.Keyword, ShopID: request.ShopID, PackageID: request.PackageID, DaysMin: request.DaysMin, DaysMax: request.DaysMax, StartTime: startTime, EndTime: endTime, } items, err := q.collect(ctx, contextShopScope(ctx), filter) if err != nil { return ListResult{}, err } summary := summarize(items) total := int64(len(items)) start := (request.Page - 1) * request.PageSize if start > len(items) { start = len(items) } end := start + request.PageSize if end > len(items) { end = len(items) } return ListResult{Items: items[start:end], Total: total, Page: request.Page, Size: request.PageSize, Summary: summary}, nil } // ListAllWithScope 按调用方给定的数据范围与筛选返回全部临期资产。 // 供异步导出复用列表同一候选预筛、最终到期推算与行序,不得另写第二套到期口径。 func (q *Query) ListAllWithScope(ctx context.Context, scope ScopeApplier, filter ListFilter) ([]dto.ExpiringAssetItem, error) { if q == nil || q.db == nil { return nil, errors.New(errors.CodeInternalError, "套餐临期查询未配置") } if err := validateListFilter(filter); err != nil { return nil, err } return q.collect(ctx, scope, filter) } // ReminderCandidates 查询当天全部临期资产,供每日通知任务复用。 func (q *Query) ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error) { items, err := q.collect(ctx, contextShopScope(ctx), ListFilter{}) if err != nil { return nil, err } return items, nil } func (q *Query) collect(ctx context.Context, scope ScopeApplier, filter ListFilter) ([]dto.ExpiringAssetItem, error) { candidates := make([]assetCandidate, 0) if filter.AssetType == "" || filter.AssetType == constants.AssetTypeIotCard { cards, err := q.findCardCandidates(ctx, scope, filter) if err != nil { return nil, err } candidates = append(candidates, cards...) } if filter.AssetType == "" || filter.AssetType == constants.AssetTypeDevice { devices, err := q.findDeviceCandidates(ctx, scope, filter) if err != nil { return nil, err } candidates = append(candidates, devices...) } items, err := q.resolveCandidates(ctx, candidates, filter) if err != nil { return nil, err } if err := q.fillShopNames(ctx, items); err != nil { return nil, err } sort.SliceStable(items, func(i, j int) bool { if items[i].IsPriority != items[j].IsPriority { return items[i].IsPriority } left, right := items[i].EstimatedFinalExpiresAt, items[j].EstimatedFinalExpiresAt if left != nil && right != nil && !left.Equal(*right) { return left.Before(*right) } if items[i].AssetType != items[j].AssetType { return items[i].AssetType < items[j].AssetType } return items[i].AssetID < items[j].AssetID }) return items, nil } func (q *Query) findCardCandidates(ctx context.Context, scope ScopeApplier, filter ListFilter) ([]assetCandidate, error) { var rows []model.IotCard query := q.db.WithContext(ctx).Model(&model.IotCard{}). Select("id, iccid, shop_id") query = scope(query) if filter.ShopID != nil { query = query.Where("shop_id = ?", *filter.ShopID) } if filter.Keyword != "" { keyword := "%" + strings.TrimSpace(filter.Keyword) + "%" query = query.Where("iccid ILIKE ? OR msisdn ILIKE ? OR virtual_no ILIKE ?", keyword, keyword, keyword) } query = query.Where(`EXISTS ( SELECT 1 FROM tb_package_usage pu WHERE pu.iot_card_id = tb_iot_card.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 < ? )`, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, dateInShanghai(q.now()).AddDate(0, 0, expiryWindowDays+1)) if err := query.Find(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期卡候选失败") } result := make([]assetCandidate, 0, len(rows)) for _, row := range rows { result = append(result, assetCandidate{AssetType: constants.AssetTypeIotCard, AssetID: row.ID, Identifier: row.ICCID, ShopID: row.ShopID}) } return result, nil } func (q *Query) findDeviceCandidates(ctx context.Context, scope ScopeApplier, filter ListFilter) ([]assetCandidate, error) { var rows []model.Device query := q.db.WithContext(ctx).Model(&model.Device{}). Select("id, virtual_no, imei, shop_id") query = scope(query) if filter.ShopID != nil { query = query.Where("shop_id = ?", *filter.ShopID) } if filter.Keyword != "" { keyword := "%" + strings.TrimSpace(filter.Keyword) + "%" query = query.Where("virtual_no ILIKE ? OR imei ILIKE ?", keyword, keyword) } query = query.Where(`EXISTS ( SELECT 1 FROM tb_package_usage pu WHERE pu.device_id = tb_device.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 < ? )`, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, dateInShanghai(q.now()).AddDate(0, 0, expiryWindowDays+1)) if err := query.Find(&rows).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期设备候选失败") } result := make([]assetCandidate, 0, len(rows)) for _, row := range rows { identifier := row.VirtualNo if identifier == "" { identifier = row.IMEI } result = append(result, assetCandidate{AssetType: constants.AssetTypeDevice, AssetID: row.ID, Identifier: identifier, ShopID: row.ShopID}) } return result, nil } func (q *Query) resolveCandidates(ctx context.Context, candidates []assetCandidate, filter ListFilter) ([]dto.ExpiringAssetItem, error) { groupedIDs := map[string][]uint{constants.AssetTypeIotCard: {}, constants.AssetTypeDevice: {}} for _, candidate := range candidates { groupedIDs[candidate.AssetType] = append(groupedIDs[candidate.AssetType], candidate.AssetID) } estimates := make(map[string]map[uint]dto.PackageExpiryEstimate, 2) finalUsages := make(map[string]map[uint]*model.PackageUsage, 2) for _, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} { var err error estimates[assetType], err = q.ResolveBatch(ctx, assetType, groupedIDs[assetType]) if err != nil { return nil, err } finalUsages[assetType], err = q.loadFinalUsages(ctx, assetType, groupedIDs[assetType]) if err != nil { return nil, err } } items := make([]dto.ExpiringAssetItem, 0, len(candidates)) for _, candidate := range candidates { estimate := estimates[candidate.AssetType][candidate.AssetID] usage := finalUsages[candidate.AssetType][candidate.AssetID] if usage == nil || estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.DaysUntilFinalExpiry == nil || estimate.EstimatedFinalExpiresAt == nil { continue } days := *estimate.DaysUntilFinalExpiry if days < 0 || days > expiryWindowDays || filter.DaysMin != nil && days < *filter.DaysMin || filter.DaysMax != nil && days > *filter.DaysMax { continue } // 按当前生效主套餐的最终到期时刻做闭区间比较,含两端。 finalExpiresAt := *estimate.EstimatedFinalExpiresAt if filter.StartTime != nil && finalExpiresAt.Before(*filter.StartTime) || filter.EndTime != nil && finalExpiresAt.After(*filter.EndTime) { continue } if filter.PackageID != nil && usage.PackageID != *filter.PackageID { continue } level, levelName := expiryLevel(days) items = append(items, dto.ExpiringAssetItem{ AssetType: candidate.AssetType, AssetID: candidate.AssetID, Identifier: candidate.Identifier, ShopID: candidate.ShopID, PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageName: usage.PackageName, PackageExpiryEstimate: estimate, ExpiryLevel: level, ExpiryLevelName: levelName, IsPriority: days <= 3, }) } return items, nil } func (q *Query) loadFinalUsages(ctx context.Context, assetType string, assetIDs []uint) (map[uint]*model.PackageUsage, error) { result := make(map[uint]*model.PackageUsage, len(assetIDs)) if len(assetIDs) == 0 { return result, nil } column, ok := packageUsageAssetColumn(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.AssetTypeDevice { assetID = usage.DeviceID } result[assetID] = usage } return result, nil } func (q *Query) fillShopNames(ctx context.Context, items []dto.ExpiringAssetItem) error { ids := make([]uint, 0) seen := make(map[uint]struct{}) for _, item := range items { if item.ShopID == nil { continue } if _, exists := seen[*item.ShopID]; !exists { seen[*item.ShopID] = struct{}{} ids = append(ids, *item.ShopID) } } if len(ids) == 0 { return nil } var shops []model.Shop if err := q.db.WithContext(ctx).Select("id, shop_name").Where("id IN ?", ids).Find(&shops).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询临期资产店铺名称失败") } names := make(map[uint]string, len(shops)) for _, shop := range shops { names[shop.ID] = shop.ShopName } for index := range items { if items[index].ShopID != nil { items[index].ShopName = names[*items[index].ShopID] } } return nil } // contextShopScope 返回请求上下文版店铺范围:只有代理账号被限制为下级店铺集合。 // 导出侧不使用该范围,改为按任务创建时冻结的可见店铺范围过滤。 func contextShopScope(ctx context.Context) ScopeApplier { return func(query *gorm.DB) *gorm.DB { if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent { return query } shopIDs := middleware.GetSubordinateShopIDs(ctx) if len(shopIDs) == 0 { return query.Where("1 = 0") } return query.Where("shop_id IN ?", shopIDs) } } func normalizeListRequest(request dto.ExpiringAssetListRequest) dto.ExpiringAssetListRequest { if request.Page <= 0 { request.Page = 1 } if request.PageSize <= 0 { request.PageSize = constants.DefaultPageSize } return request } func validateListRequest(request dto.ExpiringAssetListRequest) error { if request.PageSize > constants.MaxPageSize || request.Page < 1 { return errors.New(errors.CodeInvalidParam) } return validateListFilter(ListFilter{ AssetType: request.AssetType, DaysMin: request.DaysMin, DaysMax: request.DaysMax, }) } // validateListFilter 校验列表与导出共用的筛选条件,避免非法资产类型或天数范围静默产出空结果。 func validateListFilter(filter ListFilter) error { if filter.AssetType != "" && filter.AssetType != constants.AssetTypeIotCard && filter.AssetType != constants.AssetTypeDevice { return errors.New(errors.CodeInvalidParam) } if filter.DaysMin != nil && (*filter.DaysMin < 0 || *filter.DaysMin > expiryWindowDays) || filter.DaysMax != nil && (*filter.DaysMax < 0 || *filter.DaysMax > expiryWindowDays) { return errors.New(errors.CodeInvalidParam) } if filter.DaysMin != nil && filter.DaysMax != nil && *filter.DaysMin > *filter.DaysMax { return errors.New(errors.CodeInvalidParam, "最小剩余天数不能大于最大剩余天数") } return nil } func expiryLevel(days int) (string, string) { if days <= 3 { return "red", "红色" } if days <= 7 { return "purple", "紫色" } return "pink", "粉红色" } func summarize(items []dto.ExpiringAssetItem) dto.ExpiringAssetSummary { result := dto.ExpiringAssetSummary{WindowDays: expiryWindowDays, TotalCount: int64(len(items))} for _, item := range items { if item.AssetType == constants.AssetTypeIotCard { result.CardCount++ } else if item.AssetType == constants.AssetTypeDevice { result.DeviceCount++ } } return result }