Files
junhong_cmp_fiber/internal/query/packageexpiry/list.go
break 62419d4b17
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m40s
feat(导出时间筛选): AUG26-014 统一时间筛选与临期导出,归档并同步主 Spec 与证据链
统一时间筛选:新增共享严格解析器 pkg/utils/time_range.go,只接受带显式时区的 RFC3339 秒级时间(拒绝小数秒、无时区、date-only、空格分隔、±hhmm、未补零、非法日期与越界偏移),闭区间含两端、归一为 UTC 瞬时,创建期与执行期共用同一份实现。

端点改造(13 个入口):IoT 卡导入任务、设备导入任务、导出任务列表、订单列表参数名不变仅收紧解析;换货、分配记录、代理充值、临期列表改名 start_time/end_time(旧参数名显式拒绝);提现记录两处删除解析失败静默跳过,非法参数一律 1001;授权记录由起始闭结束开改为闭区间含两端;临期列表改按当前生效主套餐最终到期时刻比较,保留剩余天数上下限与既有粗放窗口。

临期导出新建:新场景 expiring_asset 与受控入口 POST /api/admin/expiring-assets/export,复用列表候选预筛与最终到期推算,一行一资产、加油包不单独成行,列序与 111 §18.1 逐列一致,店铺/业务员/用户组按执行时当前归属补充且不超出创建时冻结范围。

佣金明细导出新增按创建时间闭区间筛选(原佣金与回溯两条分支各自创建时间列),记录粒度、列定义与余额口径不变。

冻结与遗留任务:创建期把筛选与时间边界规范化为 UTC RFC3339 秒级串写入既有 query_json,无新列无迁移;执行期只按冻结值严格解析,非法冻结值在任何分片与文件动作前落任务失败并写安全摘要,不放行全量;重试沿用原快照。达量预警导出执行期同样纳入严格解析(其入口契约、列定义与触发快照口径不变)。

归档 add-export-time-filter-standards 并新建主 Spec openspec/specs/export-time-filter/spec.md,同步 requirement-evidence.json 与入口能力矩阵,README 导出场景清单更新为 11 个场景。

验证:junhong_cmp_test + 本地隔离 Redis(DB7,测试部署共享队列 DB6 未被占用)实跑 85 PASS / 0 FAIL(接受/拒绝集合、区间与顺序语义、列表与导出同筛选行集一致、代理 HTTP 全链路与范围冻结、遗留旧格式任务安全失败、列与余额口径回归、表头逐字),门禁 gofmt/go build/gendocs 两次一致/openspec validate/doctor/context-health 全绿;无 Schema 变更、无迁移、无运行时开关。
2026-09-17 18:37:02 +08:00

387 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
}
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
}