feat(导出时间筛选): AUG26-014 统一时间筛选与临期导出,归档并同步主 Spec 与证据链
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m40s
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user