Files
junhong_cmp_fiber/internal/query/packageexpiry/list.go
邹进 0f1ea5bc3e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m17s
fix: 第1批 M9 套餐临期提醒按资产、扫码进入时触发
- 每日扫描不再给个人客户写临期通知,店铺通知不变
- H5 资产信息在当前资产剩余 0~15 天且当天首次进入时返回 expiry_reminder,并为该客户、该资产写一条临期通知(Redis 去重,失败不占键)
- 个人通知列表、未读数、全部已读中临期提醒只处理当前资产(按 ref_key)
- OpenSpec Change scope-expiry-reminder-to-current-asset:6/9,临期 fixture 端到端验证与每日扫描验证待测试部署后完成
2026-10-12 00:15:27 +09:00

388 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// ExpiryLevel 返回临期高亮等级与中文名称:≤3 红、≤7 紫、其余粉红。
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
}