All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m17s
- 每日扫描不再给个人客户写临期通知,店铺通知不变 - H5 资产信息在当前资产剩余 0~15 天且当天首次进入时返回 expiry_reminder,并为该客户、该资产写一条临期通知(Redis 去重,失败不占键) - 个人通知列表、未读数、全部已读中临期提醒只处理当前资产(按 ref_key) - OpenSpec Change scope-expiry-reminder-to-current-asset:6/9,临期 fixture 端到端验证与每日扫描验证待测试部署后完成
388 lines
14 KiB
Go
388 lines
14 KiB
Go
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
|
||
}
|