feat(导出时间筛选): AUG26-014 统一时间筛选与临期导出,归档并同步主 Spec 与证据链
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m40s

统一时间筛选:新增共享严格解析器 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 变更、无迁移、无运行时开关。
This commit is contained in:
2026-09-17 18:37:02 +08:00
parent e8ab1f471e
commit 62419d4b17
67 changed files with 2405 additions and 398 deletions

View File

@@ -34,7 +34,7 @@ func NewListQuery(db *gorm.DB) *ListQuery {
}
// List 按新旧资产关键词和其他列表条件查询换货单。
func (q *ListQuery) List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
func (q *ListQuery) List(ctx context.Context, req *dto.ExchangeListRequest, startTime, endTime *time.Time) (*dto.ExchangeListResponse, error) {
page := constants.DefaultPage
if req.Page != nil {
page = *req.Page
@@ -46,7 +46,7 @@ func (q *ListQuery) List(ctx context.Context, req *dto.ExchangeListRequest) (*dt
query := q.db.WithContext(ctx).Model(&model.ExchangeOrder{})
query = middleware.ApplyShopFilter(ctx, query)
query = applyListFilters(query, req)
query = applyListFilters(query, req, startTime, endTime)
var total int64
if err := query.Count(&total).Error; err != nil {
@@ -83,7 +83,7 @@ func (q *ListQuery) List(ctx context.Context, req *dto.ExchangeListRequest) (*dt
}
// applyListFilters 组装列表计数和数据查询共用的全部过滤条件。
func applyListFilters(query *gorm.DB, req *dto.ExchangeListRequest) *gorm.DB {
func applyListFilters(query *gorm.DB, req *dto.ExchangeListRequest, startTime, endTime *time.Time) *gorm.DB {
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
@@ -92,11 +92,11 @@ func applyListFilters(query *gorm.DB, req *dto.ExchangeListRequest) *gorm.DB {
}
query = applyAssetKeyword(query, oldAssetSide, req.OldAssetKeyword)
query = applyAssetKeyword(query, newAssetSide, req.NewAssetKeyword)
if req.CreatedAtStart != nil {
query = query.Where("created_at >= ?", *req.CreatedAtStart)
if startTime != nil {
query = query.Where("created_at >= ?", *startTime)
}
if req.CreatedAtEnd != nil {
query = query.Where("created_at <= ?", *req.CreatedAtEnd)
if endTime != nil {
query = query.Where("created_at <= ?", *endTime)
}
return query
}

View File

@@ -32,8 +32,26 @@ type assetCandidate struct {
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) (ListResult, error) {
// 时间边界由调用方用共享严格解析器解析后传入,按最终到期时刻闭区间比较。
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, "套餐临期查询未配置")
}
@@ -44,7 +62,17 @@ func (q *Query) List(ctx context.Context, request dto.ExpiringAssetListRequest)
if err := validateListRequest(request); err != nil {
return ListResult{}, err
}
items, err := q.collect(ctx, request)
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
}
@@ -61,32 +89,44 @@ func (q *Query) List(ctx context.Context, request dto.ExpiringAssetListRequest)
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, normalizeListRequest(dto.ExpiringAssetListRequest{}))
items, err := q.collect(ctx, contextShopScope(ctx), ListFilter{})
if err != nil {
return nil, err
}
return items, nil
}
func (q *Query) collect(ctx context.Context, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) {
func (q *Query) collect(ctx context.Context, scope ScopeApplier, filter ListFilter) ([]dto.ExpiringAssetItem, error) {
candidates := make([]assetCandidate, 0)
if request.AssetType == "" || request.AssetType == constants.AssetTypeIotCard {
cards, err := q.findCardCandidates(ctx, request)
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 request.AssetType == "" || request.AssetType == constants.AssetTypeDevice {
devices, err := q.findDeviceCandidates(ctx, request)
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, request)
items, err := q.resolveCandidates(ctx, candidates, filter)
if err != nil {
return nil, err
}
@@ -109,16 +149,16 @@ func (q *Query) collect(ctx context.Context, request dto.ExpiringAssetListReques
return items, nil
}
func (q *Query) findCardCandidates(ctx context.Context, request dto.ExpiringAssetListRequest) ([]assetCandidate, error) {
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 = applyStrictShopScope(ctx, query)
if request.ShopID != nil {
query = query.Where("shop_id = ?", *request.ShopID)
query = scope(query)
if filter.ShopID != nil {
query = query.Where("shop_id = ?", *filter.ShopID)
}
if request.Keyword != "" {
keyword := "%" + strings.TrimSpace(request.Keyword) + "%"
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 (
@@ -137,16 +177,16 @@ func (q *Query) findCardCandidates(ctx context.Context, request dto.ExpiringAsse
return result, nil
}
func (q *Query) findDeviceCandidates(ctx context.Context, request dto.ExpiringAssetListRequest) ([]assetCandidate, error) {
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 = applyStrictShopScope(ctx, query)
if request.ShopID != nil {
query = query.Where("shop_id = ?", *request.ShopID)
query = scope(query)
if filter.ShopID != nil {
query = query.Where("shop_id = ?", *filter.ShopID)
}
if request.Keyword != "" {
keyword := "%" + strings.TrimSpace(request.Keyword) + "%"
if filter.Keyword != "" {
keyword := "%" + strings.TrimSpace(filter.Keyword) + "%"
query = query.Where("virtual_no ILIKE ? OR imei ILIKE ?", keyword, keyword)
}
query = query.Where(`EXISTS (
@@ -169,7 +209,7 @@ func (q *Query) findDeviceCandidates(ctx context.Context, request dto.ExpiringAs
return result, nil
}
func (q *Query) resolveCandidates(ctx context.Context, candidates []assetCandidate, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) {
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)
@@ -187,10 +227,6 @@ func (q *Query) resolveCandidates(ctx context.Context, candidates []assetCandida
return nil, err
}
}
from, to, err := parseExpiryRange(request)
if err != nil {
return nil, err
}
items := make([]dto.ExpiringAssetItem, 0, len(candidates))
for _, candidate := range candidates {
estimate := estimates[candidate.AssetType][candidate.AssetID]
@@ -199,14 +235,15 @@ func (q *Query) resolveCandidates(ctx context.Context, candidates []assetCandida
continue
}
days := *estimate.DaysUntilFinalExpiry
if days < 0 || days > expiryWindowDays || request.DaysMin != nil && days < *request.DaysMin || request.DaysMax != nil && days > *request.DaysMax {
if days < 0 || days > expiryWindowDays || filter.DaysMin != nil && days < *filter.DaysMin || filter.DaysMax != nil && days > *filter.DaysMax {
continue
}
expiryDate := dateInShanghai(*estimate.EstimatedFinalExpiresAt)
if from != nil && expiryDate.Before(*from) || to != nil && expiryDate.After(*to) {
// 按当前生效主套餐的最终到期时刻做闭区间比较,含两端。
finalExpiresAt := *estimate.EstimatedFinalExpiresAt
if filter.StartTime != nil && finalExpiresAt.Before(*filter.StartTime) || filter.EndTime != nil && finalExpiresAt.After(*filter.EndTime) {
continue
}
if request.PackageID != nil && usage.PackageID != *request.PackageID {
if filter.PackageID != nil && usage.PackageID != *filter.PackageID {
continue
}
level, levelName := expiryLevel(days)
@@ -276,15 +313,19 @@ func (q *Query) fillShopNames(ctx context.Context, items []dto.ExpiringAssetItem
return nil
}
func applyStrictShopScope(ctx context.Context, query *gorm.DB) *gorm.DB {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return query
// 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)
}
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 {
@@ -301,42 +342,25 @@ func validateListRequest(request dto.ExpiringAssetListRequest) error {
if request.PageSize > constants.MaxPageSize || request.Page < 1 {
return errors.New(errors.CodeInvalidParam)
}
if request.AssetType != "" && request.AssetType != constants.AssetTypeIotCard && request.AssetType != constants.AssetTypeDevice {
return errors.New(errors.CodeInvalidParam)
}
if request.DaysMin != nil && (*request.DaysMin < 0 || *request.DaysMin > expiryWindowDays) || request.DaysMax != nil && (*request.DaysMax < 0 || *request.DaysMax > expiryWindowDays) {
return errors.New(errors.CodeInvalidParam)
}
if request.DaysMin != nil && request.DaysMax != nil && *request.DaysMin > *request.DaysMax {
return errors.New(errors.CodeInvalidParam, "最小剩余天数不能大于最大剩余天数")
}
_, _, err := parseExpiryRange(request)
return err
return validateListFilter(ListFilter{
AssetType: request.AssetType,
DaysMin: request.DaysMin,
DaysMax: request.DaysMax,
})
}
func parseExpiryRange(request dto.ExpiringAssetListRequest) (*time.Time, *time.Time, error) {
parse := func(value string) (*time.Time, error) {
if value == "" {
return nil, nil
}
result, err := time.ParseInLocation("2006-01-02", value, shanghaiLocation)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "到期日期格式无效")
}
return &result, nil
// validateListFilter 校验列表与导出共用的筛选条件,避免非法资产类型或天数范围静默产出空结果。
func validateListFilter(filter ListFilter) error {
if filter.AssetType != "" && filter.AssetType != constants.AssetTypeIotCard && filter.AssetType != constants.AssetTypeDevice {
return errors.New(errors.CodeInvalidParam)
}
from, err := parse(request.ExpiresFrom)
if err != nil {
return nil, nil, err
if filter.DaysMin != nil && (*filter.DaysMin < 0 || *filter.DaysMin > expiryWindowDays) || filter.DaysMax != nil && (*filter.DaysMax < 0 || *filter.DaysMax > expiryWindowDays) {
return errors.New(errors.CodeInvalidParam)
}
to, err := parse(request.ExpiresTo)
if err != nil {
return nil, nil, err
if filter.DaysMin != nil && filter.DaysMax != nil && *filter.DaysMin > *filter.DaysMax {
return errors.New(errors.CodeInvalidParam, "最小剩余天数不能大于最大剩余天数")
}
if from != nil && to != nil && from.After(*to) {
return nil, nil, errors.New(errors.CodeInvalidParam, "到期开始日期不能晚于结束日期")
}
return from, to, nil
return nil
}
func expiryLevel(days int) (string, string) {