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

@@ -340,6 +340,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
h.SetObservationSeriesDispatcher(svc.ObservationSeries)
h.SetPackageExpiryQuery(packageExpiry)
h.SetPackageExpiryQueue(deps.QueueClient)
h.SetExportTaskService(svc.ExportTask)
h.SetValidator(validate)
return h
}(),
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),

View File

@@ -27,7 +27,10 @@ func (s *AgentRechargeDataSource) Scene() string {
// Count 统计代理充值导出行数。
func (s *AgentRechargeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
query, err := s.applyFilters(s.baseQuery(ctx), params)
if err != nil {
return 0, err
}
if err := query.Count(&total).Error; err != nil {
return 0, err
}
@@ -49,7 +52,11 @@ func (s *AgentRechargeDataSource) Fetch(ctx context.Context, params ExportParams
}
var items []agentRechargeExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
filtered, err := s.applyFilters(s.baseQuery(ctx), params)
if err != nil {
return nil, err
}
query := filtered.
Select(`
r.recharge_no,
r.amount,
@@ -121,7 +128,7 @@ func (s *AgentRechargeDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("tb_agent_recharge_record AS r").Where("r.deleted_at IS NULL")
}
func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportParams) (*gorm.DB, error) {
query = applyExportShopScope(query, params, "r.shop_id")
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where("r.shop_id = ?", shopID)
@@ -129,13 +136,17 @@ func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportPara
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("r.status = ?", status)
}
if start, ok := filterTime(params.Filters, "start_date"); ok {
query = query.Where("r.created_at >= ?", start)
start, end, err := strictTimeRange(params.Filters)
if err != nil {
return nil, err
}
if end, ok := filterEndDate(params.Filters, "end_date"); ok {
query = query.Where("r.created_at <= ?", end)
if start != nil {
query = query.Where("r.created_at >= ?", *start)
}
return query
if end != nil {
query = query.Where("r.created_at <= ?", *end)
}
return query, nil
}
type agentRechargeExportRow struct {

View File

@@ -30,12 +30,20 @@ func (s *CommissionRecordDataSource) Scene() string {
// Count 统计原佣金与回溯明细的合并行数。
func (s *CommissionRecordDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
original, err := s.originalBranch(ctx, params)
if err != nil {
return 0, err
}
var originalTotal int64
if err := s.originalBranch(ctx, params).Count(&originalTotal).Error; err != nil {
if err := original.Count(&originalTotal).Error; err != nil {
return 0, err
}
clawback, err := s.clawbackBranch(ctx, params)
if err != nil {
return 0, err
}
var clawbackTotal int64
if err := s.clawbackBranch(ctx, params).Count(&clawbackTotal).Error; err != nil {
if err := clawback.Count(&clawbackTotal).Error; err != nil {
return 0, err
}
return int(originalTotal + clawbackTotal), nil
@@ -56,9 +64,17 @@ func (s *CommissionRecordDataSource) Fetch(ctx context.Context, params ExportPar
return [][]string{}, nil
}
original, err := s.originalBranch(ctx, params)
if err != nil {
return nil, err
}
clawback, err := s.clawbackBranch(ctx, params)
if err != nil {
return nil, err
}
union := s.db.WithContext(ctx).
Raw("SELECT * FROM (?) AS ledger_original UNION ALL SELECT * FROM (?) AS ledger_clawback",
s.originalBranch(ctx, params), s.clawbackBranch(ctx, params))
original, clawback)
var items []commissionRecordExportRow
query := s.db.WithContext(ctx).Table("(?) AS ledger", union).
Select(`
@@ -108,7 +124,7 @@ func (s *CommissionRecordDataSource) Fetch(ctx context.Context, params ExportPar
}
// originalBranch 构造原佣金导出分支:自带场景筛选与数据范围。
func (s *CommissionRecordDataSource) originalBranch(ctx context.Context, params ExportParams) *gorm.DB {
func (s *CommissionRecordDataSource) originalBranch(ctx context.Context, params ExportParams) (*gorm.DB, error) {
query := s.db.WithContext(ctx).Table("tb_commission_record AS c").
Where("c.deleted_at IS NULL").
Joins("LEFT JOIN tb_order o ON c.order_id = o.id AND o.deleted_at IS NULL").
@@ -119,11 +135,11 @@ func (s *CommissionRecordDataSource) originalBranch(ctx context.Context, params
`c.released_at, c.created_at, NULL::bigint AS original_commission_id, ''::varchar AS refund_no, ` +
`NULL::boolean AS withdrawable`)
query = applyExportShopScope(query, params, "c.shop_id")
return applyCommissionExportFilters(query, params, "c.shop_id", "c.commission_source", "c.status", "o.order_no")
return applyCommissionExportFilters(query, params, "c.shop_id", "c.commission_source", "c.created_at", "c.status", "o.order_no")
}
// clawbackBranch 构造回溯明细导出分支:资产维度取原佣金关联的卡或设备,保持与原佣金同一口径。
func (s *CommissionRecordDataSource) clawbackBranch(ctx context.Context, params ExportParams) *gorm.DB {
func (s *CommissionRecordDataSource) clawbackBranch(ctx context.Context, params ExportParams) (*gorm.DB, error) {
query := s.db.WithContext(ctx).Table("tb_commission_clawback_record AS g").
Joins("LEFT JOIN tb_commission_record oc ON oc.id = g.original_commission_id").
Joins("LEFT JOIN tb_order o ON g.order_id = o.id AND o.deleted_at IS NULL").
@@ -134,7 +150,7 @@ func (s *CommissionRecordDataSource) clawbackBranch(ctx context.Context, params
`g.commission_source, g.amount, g.balance_after, g.status, ` +
`NULL::timestamp AS released_at, g.created_at, g.original_commission_id, g.refund_no, g.withdrawable`)
query = applyExportShopScope(query, params, "g.shop_id")
return applyCommissionExportFilters(query, params, "g.shop_id", "g.commission_source", "g.status", "g.order_no")
return applyCommissionExportFilters(query, params, "g.shop_id", "g.commission_source", "g.created_at", "g.status", "g.order_no")
}
// 导出分支来源标识与后台列表保持一致,便于导出结果与列表逐行核对。
@@ -144,7 +160,8 @@ const (
)
// applyCommissionExportFilters 把佣金明细导出的筛选条件应用到单个分支。
func applyCommissionExportFilters(query *gorm.DB, params ExportParams, shopColumn, sourceColumn, statusColumn, orderNoColumn string) *gorm.DB {
// 时间范围按各分支自身的创建时间列做闭区间比较,覆盖原佣金与回溯明细两条分支。
func applyCommissionExportFilters(query *gorm.DB, params ExportParams, shopColumn, sourceColumn, timeColumn, statusColumn, orderNoColumn string) (*gorm.DB, error) {
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where(shopColumn+" = ?", shopID)
}
@@ -157,7 +174,17 @@ func applyCommissionExportFilters(query *gorm.DB, params ExportParams, shopColum
if orderNo, ok := filterString(params.Filters, "order_no"); ok {
query = query.Where(orderNoColumn+" = ?", orderNo)
}
return query
start, end, err := strictTimeRange(params.Filters)
if err != nil {
return nil, err
}
if start != nil {
query = query.Where(timeColumn+" >= ?", *start)
}
if end != nil {
query = query.Where(timeColumn+" <= ?", *end)
}
return query, nil
}
// commissionRecordExportRow 是佣金明细导出的合并行投影,金额一律保持分。

View File

@@ -27,7 +27,10 @@ func (s *ExchangeDataSource) Scene() string {
// Count 统计换货记录导出行数。
func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
query, err := s.applyFilters(s.baseQuery(ctx), params)
if err != nil {
return 0, err
}
if err := query.Count(&total).Error; err != nil {
return 0, err
}
@@ -49,7 +52,11 @@ func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, off
}
var items []exchangeExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
filtered, err := s.applyFilters(s.baseQuery(ctx), params)
if err != nil {
return nil, err
}
query := filtered.
Select(`
e.exchange_no,
e.flow_type,
@@ -104,7 +111,7 @@ func (s *ExchangeDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("tb_exchange_order AS e").Where("e.deleted_at IS NULL")
}
func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) (*gorm.DB, error) {
query = applyExportShopScope(query, params, "e.shop_id")
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("e.status = ?", status)
@@ -114,13 +121,17 @@ func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) *
}
query = applyExchangeAssetKeyword(query, "old", filterValue(params.Filters, "old_asset_keyword"))
query = applyExchangeAssetKeyword(query, "new", filterValue(params.Filters, "new_asset_keyword"))
if start, ok := filterTime(params.Filters, "created_at_start"); ok {
query = query.Where("e.created_at >= ?", start)
start, end, err := strictTimeRange(params.Filters)
if err != nil {
return nil, err
}
if end, ok := filterTime(params.Filters, "created_at_end"); ok {
query = query.Where("e.created_at <= ?", end)
if start != nil {
query = query.Where("e.created_at >= ?", *start)
}
return query
if end != nil {
query = query.Where("e.created_at <= ?", *end)
}
return query, nil
}
func applyExchangeAssetKeyword(query *gorm.DB, side, keyword string) *gorm.DB {

View File

@@ -0,0 +1,229 @@
package exporter
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiryquery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ExpiringAssetDataSource 临期资产导出数据源。
//
// 粒度为一行对应一项资产,取该资产当前生效主套餐的最终到期时间与剩余天数,加油包不单独成行。
// 候选预筛、最终到期推算与行序全部复用 internal/query/packageexpiry 的列表实现,不另写第二套到期口径。
// 店铺、业务员与用户组按导出执行时的当前归属补充,结果仍受任务创建时冻结的可见店铺范围约束。
type ExpiringAssetDataSource struct {
db *gorm.DB
expiryQ *packageexpiryquery.Query
}
// NewExpiringAssetDataSource 创建临期资产导出数据源。
func NewExpiringAssetDataSource(db *gorm.DB) *ExpiringAssetDataSource {
return &ExpiringAssetDataSource{db: db, expiryQ: packageexpiryquery.NewQuery(db)}
}
// Scene 返回导出场景编码。
func (s *ExpiringAssetDataSource) Scene() string {
return constants.ExportTaskSceneExpiringAsset
}
// Count 统计临期资产导出行数,与列表同一筛选下的行集合一致。
func (s *ExpiringAssetDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
items, err := s.items(ctx, params)
if err != nil {
return 0, err
}
return len(items), nil
}
// Headers 返回临期资产导出表头,列序与 111.md §18.1 逐列一致。
func (s *ExpiringAssetDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return []string{
"店铺", "业务员", "用户组", "资产类型", "设备类型", "设备型号", "资产标识", "当前套餐", "到期时间", "剩余天数",
}, nil
}
// Fetch 按 offset/limit 查询临期资产导出数据。
func (s *ExpiringAssetDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
items, err := s.items(ctx, params)
if err != nil {
return nil, err
}
if offset >= len(items) {
return [][]string{}, nil
}
end := offset + limit
if end > len(items) {
end = len(items)
}
page := items[offset:end]
shopIDs := make([]uint, 0, len(page))
deviceIDs := make([]uint, 0, len(page))
for _, item := range page {
if item.ShopID != nil {
shopIDs = append(shopIDs, *item.ShopID)
}
if item.AssetType == constants.AssetTypeDevice {
deviceIDs = append(deviceIDs, item.AssetID)
}
}
shops, err := s.loadShopOwners(ctx, normalizeUintSlice(shopIDs))
if err != nil {
return nil, err
}
ownerIDs := make([]uint, 0, len(shops))
for _, shop := range shops {
if shop.BusinessOwnerAccountID != nil {
ownerIDs = append(ownerIDs, *shop.BusinessOwnerAccountID)
}
}
ownerIDs = normalizeUintSlice(ownerIDs)
ownerNames, err := loadAccountNames(ctx, s.db, ownerIDs)
if err != nil {
return nil, err
}
groupNames, err := businessUserGroupNames(ctx, s.db, ownerIDs)
if err != nil {
return nil, err
}
devices, err := s.loadDeviceAttributes(ctx, normalizeUintSlice(deviceIDs))
if err != nil {
return nil, err
}
rows := make([][]string, 0, len(page))
for _, item := range page {
var shopName, ownerName, groupName string
if item.ShopID != nil {
shop := shops[*item.ShopID]
shopName = shop.ShopName
if shop.BusinessOwnerAccountID != nil {
ownerName = ownerNames[*shop.BusinessOwnerAccountID]
groupName = currentOwnerGroupName(groupNames, shop.BusinessOwnerAccountID)
}
}
var deviceType, deviceModel string
if item.AssetType == constants.AssetTypeDevice {
device := devices[item.AssetID]
deviceType, deviceModel = device.DeviceType, device.DeviceModel
}
rows = append(rows, []string{
shopName,
ownerName,
groupName,
assetTypeName(item.AssetType),
deviceType,
deviceModel,
item.Identifier,
item.PackageName,
formatOptionalTime(item.EstimatedFinalExpiresAt),
formatRemainingDaysValue(item.DaysUntilFinalExpiry),
})
}
return rows, nil
}
// items 复用列表同一候选预筛与最终到期推算,数据范围来自任务创建时冻结的可见店铺范围。
func (s *ExpiringAssetDataSource) items(ctx context.Context, params ExportParams) ([]dto.ExpiringAssetItem, error) {
filter, err := s.listFilter(params)
if err != nil {
return nil, err
}
scope := func(query *gorm.DB) *gorm.DB {
return applyExportShopScope(query, params, "shop_id")
}
return s.expiryQ.ListAllWithScope(ctx, scope, filter)
}
// listFilter 把任务冻结的筛选快照转换为列表同一口径的筛选条件。
func (s *ExpiringAssetDataSource) listFilter(params ExportParams) (packageexpiryquery.ListFilter, error) {
filter := packageexpiryquery.ListFilter{
AssetType: filterValue(params.Filters, "asset_type"),
Keyword: filterValue(params.Filters, "keyword"),
}
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
filter.ShopID = &shopID
}
if packageID, ok := filterUint(params.Filters, "package_id"); ok {
filter.PackageID = &packageID
}
if daysMin, ok := filterInt(params.Filters, "days_min"); ok {
filter.DaysMin = &daysMin
}
if daysMax, ok := filterInt(params.Filters, "days_max"); ok {
filter.DaysMax = &daysMax
}
start, end, err := strictTimeRange(params.Filters)
if err != nil {
return packageexpiryquery.ListFilter{}, err
}
filter.StartTime, filter.EndTime = start, end
return filter, nil
}
func (s *ExpiringAssetDataSource) loadShopOwners(ctx context.Context, shopIDs []uint) (map[uint]expiringAssetShop, error) {
result := make(map[uint]expiringAssetShop, len(shopIDs))
if len(shopIDs) == 0 {
return result, nil
}
var rows []expiringAssetShop
if err := s.db.WithContext(ctx).Table("tb_shop").
Select("id, shop_name, business_owner_account_id").
Where("id IN ? AND deleted_at IS NULL", shopIDs).
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期导出店铺当前归属失败")
}
for _, row := range rows {
result[row.ID] = row
}
return result, nil
}
func (s *ExpiringAssetDataSource) loadDeviceAttributes(ctx context.Context, deviceIDs []uint) (map[uint]expiringAssetDevice, error) {
result := make(map[uint]expiringAssetDevice, len(deviceIDs))
if len(deviceIDs) == 0 {
return result, nil
}
var rows []expiringAssetDevice
if err := s.db.WithContext(ctx).Table("tb_device").
Select("id, device_type, device_model").
Where("id IN ?", deviceIDs).
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期导出设备类型与型号失败")
}
for _, row := range rows {
result[row.ID] = row
}
return result, nil
}
// expiringAssetShop 是执行时当前店铺归属投影。
type expiringAssetShop struct {
ID uint `gorm:"column:id"`
ShopName string `gorm:"column:shop_name"`
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
}
// expiringAssetDevice 是执行时当前设备类型与型号投影。
type expiringAssetDevice struct {
ID uint `gorm:"column:id"`
DeviceType string `gorm:"column:device_type"`
DeviceModel string `gorm:"column:device_model"`
}
// formatRemainingDaysValue 输出列表同一最终到期推算给出的剩余上海自然日天数;无法精确推算时为空。
func formatRemainingDaysValue(days *int) string {
if days == nil {
return ""
}
return strconv.Itoa(*days)
}

View File

@@ -148,11 +148,15 @@ func (s *OrderDataSource) applyFilters(ctx context.Context, query *gorm.DB, para
if sellerShopID, ok := filterUint(params.Filters, "seller_shop_id"); ok {
query = query.Where("o.seller_shop_id = ?", sellerShopID)
}
if start, ok := filterTime(params.Filters, "start_time"); ok {
query = query.Where("o.created_at >= ?", start)
start, end, err := strictTimeRange(params.Filters)
if err != nil {
return nil, err
}
if end, ok := filterTime(params.Filters, "end_time"); ok {
query = query.Where("o.created_at <= ?", end)
if start != nil {
query = query.Where("o.created_at >= ?", *start)
}
if end != nil {
query = query.Where("o.created_at <= ?", *end)
}
if buyerPhone, ok := filterString(params.Filters, "buyer_phone"); ok {
query = query.Where("o.buyer_phone = ?", buyerPhone)

View File

@@ -0,0 +1,60 @@
package exporter
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// businessUserGroupNames 按执行时当前业务员账号批量推导业务用户组名称。
// 用户组不落在店铺库表上,按既有实时推导读取,多个组按排序拼接;达量预警与临期导出共用同一口径。
func businessUserGroupNames(ctx context.Context, db *gorm.DB, ownerIDs []uint) (map[uint]string, error) {
result := make(map[uint]string, len(ownerIDs))
if len(ownerIDs) == 0 {
return result, nil
}
var rows []struct {
AccountID uint `gorm:"column:account_id"`
GroupName string `gorm:"column:group_name"`
}
if err := db.WithContext(ctx).Table("tb_business_user_group_member AS m").
Select("m.account_id, g.name AS group_name").
Joins("JOIN tb_business_user_group AS g ON g.id = m.business_user_group_id AND g.deleted_at IS NULL").
Where("m.account_id IN ? AND m.deleted_at IS NULL", ownerIDs).
Order("m.account_id ASC, g.sort_order ASC, g.id ASC").
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员业务用户组失败")
}
for _, row := range rows {
if existing := result[row.AccountID]; existing != "" {
result[row.AccountID] = existing + "、" + row.GroupName
continue
}
result[row.AccountID] = row.GroupName
}
return result, nil
}
// loadAccountNames 按账号 ID 批量读取账号名称,供导出侧执行时当前归属补充。
func loadAccountNames(ctx context.Context, db *gorm.DB, accountIDs []uint) (map[uint]string, error) {
result := make(map[uint]string, len(accountIDs))
if len(accountIDs) == 0 {
return result, nil
}
var rows []struct {
ID uint `gorm:"column:id"`
Username string `gorm:"column:username"`
}
if err := db.WithContext(ctx).Table("tb_account").
Select("id, username").
Where("id IN ? AND deleted_at IS NULL", accountIDs).
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账号名称失败")
}
for _, row := range rows {
result[row.ID] = row.Username
}
return result, nil
}

View File

@@ -39,7 +39,11 @@ func (s *PackageTrafficAlertDataSource) Count(ctx context.Context, params Export
return 0, err
}
var total int64
if err := s.applyFilters(s.baseQuery(ctx, params), params).Count(&total).Error; err != nil {
query, err := s.applyFilters(s.baseQuery(ctx, params), params)
if err != nil {
return 0, err
}
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
@@ -64,7 +68,11 @@ func (s *PackageTrafficAlertDataSource) Fetch(ctx context.Context, params Export
return nil, err
}
var items []packageTrafficAlertExportRow
query := s.applyFilters(s.baseQuery(ctx, params), params).
filtered, err := s.applyFilters(s.baseQuery(ctx, params), params)
if err != nil {
return nil, err
}
query := filtered.
Select(`
a.asset_type,
a.asset_identifier_snapshot,
@@ -144,8 +152,9 @@ func (s *PackageTrafficAlertDataSource) baseQuery(ctx context.Context, params Ex
}
// applyFilters 应用导出筛选快照。
// 筛选口径与列表一致,都作用在触发快照列上;时间范围按触发时间闭区间解析
func (s *PackageTrafficAlertDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
// 筛选口径与列表一致,都作用在触发快照列上;时间范围按触发时间闭区间解析
// 冻结值一律按统一严格解析器解析,非法值返回错误由调用方落任务失败。
func (s *PackageTrafficAlertDataSource) applyFilters(query *gorm.DB, params ExportParams) (*gorm.DB, error) {
if packageID, ok := filterUint(params.Filters, "package_id"); ok {
query = query.Where("a.package_id = ?", packageID)
}
@@ -167,16 +176,20 @@ func (s *PackageTrafficAlertDataSource) applyFilters(query *gorm.DB, params Expo
query = query.Where("a.threshold_percent_snapshot = ?",
packagetrafficalert.NormalizeThresholdPercent(threshold))
}
if startTime, ok := filterTime(params.Filters, "start_time"); ok {
query = query.Where("a.triggered_at >= ?", startTime.UTC())
startTime, endTime, err := strictTimeRange(params.Filters)
if err != nil {
return nil, err
}
if endTime, ok := filterTime(params.Filters, "end_time"); ok {
query = query.Where("a.triggered_at <= ?", endTime.UTC())
if startTime != nil {
query = query.Where("a.triggered_at >= ?", *startTime)
}
if endTime != nil {
query = query.Where("a.triggered_at <= ?", *endTime)
}
if status, ok := filterInt(params.Filters, "notification_status"); ok {
query = applyAlertNotificationStatusFilter(query, status)
}
return query
return query, nil
}
// applyAlertNotificationStatusFilter 按通知投递结果筛选,口径与读侧列表一致。
@@ -206,7 +219,6 @@ func applyAlertNotificationStatusFilter(query *gorm.DB, status int) *gorm.DB {
// 用户组不落在店铺库表上,按既有实时推导读取,多个组按排序拼接。
func (s *PackageTrafficAlertDataSource) loadBusinessUserGroupNames(ctx context.Context,
items []packageTrafficAlertExportRow) (map[uint]string, error) {
result := make(map[uint]string)
ownerIDs := make([]uint, 0, len(items))
seen := make(map[uint]struct{}, len(items))
for _, item := range items {
@@ -219,29 +231,7 @@ func (s *PackageTrafficAlertDataSource) loadBusinessUserGroupNames(ctx context.C
seen[*item.CurrentOwnerID] = struct{}{}
ownerIDs = append(ownerIDs, *item.CurrentOwnerID)
}
if len(ownerIDs) == 0 {
return result, nil
}
var rows []struct {
AccountID uint `gorm:"column:account_id"`
GroupName string `gorm:"column:group_name"`
}
if err := s.db.WithContext(ctx).Table("tb_business_user_group_member AS m").
Select("m.account_id, g.name AS group_name").
Joins("JOIN tb_business_user_group AS g ON g.id = m.business_user_group_id AND g.deleted_at IS NULL").
Where("m.account_id IN ? AND m.deleted_at IS NULL", ownerIDs).
Order("m.account_id ASC, g.sort_order ASC, g.id ASC").
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员业务用户组失败")
}
for _, row := range rows {
if existing := result[row.AccountID]; existing != "" {
result[row.AccountID] = existing + "、" + row.GroupName
continue
}
result[row.AccountID] = row.GroupName
}
return result, nil
return businessUserGroupNames(ctx, s.db, ownerIDs)
}
// packageTrafficAlertExportRow 是预警导出的一行原始投影。

View File

@@ -38,6 +38,7 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
NewExchangeDataSource(db),
NewCommissionRecordDataSource(db),
NewPackageTrafficAlertDataSource(db),
NewExpiringAssetDataSource(db),
)
}
@@ -75,7 +76,8 @@ func IsSupportedScene(scene string) bool {
constants.ExportTaskSceneRefund,
constants.ExportTaskSceneExchange,
constants.ExportTaskSceneCommissionRecord,
constants.ExportTaskScenePackageTrafficAlert:
constants.ExportTaskScenePackageTrafficAlert,
constants.ExportTaskSceneExpiringAsset:
return true
default:
return false

View File

@@ -0,0 +1,122 @@
package exporter
import (
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// 受影响导出场景统一使用的冻结时间筛选键。
const (
exportTimeFilterStartKey = "start_time"
exportTimeFilterEndKey = "end_time"
)
// timeFilterScenes 是纳入统一时间筛选契约的导出场景。
// 未列入的场景保持既有宽松解析,见设计文档「已知差异登记」。
// 达量预警的列表与创建入口仍接受全局宽松时间格式并由创建期归一为 UTC RFC3339 秒级串,
// 但执行期同样只按冻结值严格解析,冻结值非法时任务落失败。
var timeFilterScenes = map[string]struct{}{
constants.ExportTaskSceneExchange: {},
constants.ExportTaskSceneAgentRecharge: {},
constants.ExportTaskSceneOrder: {},
constants.ExportTaskSceneCommissionRecord: {},
constants.ExportTaskSceneExpiringAsset: {},
constants.ExportTaskScenePackageTrafficAlert: {},
}
// legacyTimeFilterKeys 是受影响场景必须拒绝的旧时间筛选键。
// 键存在且非空时一律拒绝,避免旧前端静默丢条件后导出全量数据。
var legacyTimeFilterKeys = map[string][]string{
constants.ExportTaskSceneExchange: {"created_at_start", "created_at_end"},
constants.ExportTaskSceneAgentRecharge: {"start_date", "end_date"},
}
// NormalizeTaskTimeFilters 在创建导出任务时按场景校验并规范化时间边界。
// 规范化结果为 UTC RFC3339 秒级字符串,随筛选快照一并冻结;
// 非法格式、旧参数键与开始晚于结束一律在创建期拒绝。
func NormalizeTaskTimeFilters(scene string, query map[string]interface{}) error {
if !isTimeFilterScene(scene) {
return nil
}
filters, ok := query["filters"].(map[string]interface{})
if !ok {
return nil
}
if err := rejectLegacyTimeFilterKeys(scene, filters); err != nil {
return err
}
start, end, err := parseFrozenTimeRange(filters)
if err != nil {
return err
}
if start != nil {
filters[exportTimeFilterStartKey] = utils.FormatTimeFilterValue(*start)
}
if end != nil {
filters[exportTimeFilterEndKey] = utils.FormatTimeFilterValue(*end)
}
return nil
}
// ValidateTaskTimeFilters 校验导出任务筛选快照中的时间边界,创建期与执行期共用。
// 非法值(含变更前遗留任务的旧格式冻结值)返回错误,由调用方把任务落为失败并写安全失败摘要,
// 不得忽略该条件后放行全量数据。
func ValidateTaskTimeFilters(scene string, filters map[string]any) error {
if !isTimeFilterScene(scene) {
return nil
}
if err := rejectLegacyTimeFilterKeys(scene, filters); err != nil {
return err
}
_, _, err := parseFrozenTimeRange(filters)
return err
}
// strictTimeRange 解析任务冻结的 start_time/end_time 闭区间,供受影响场景构造执行期筛选。
// 冻结值必须是创建期规范化后的带时区 RFC3339 秒级字符串,任一端缺省表示该端不限。
func strictTimeRange(filters map[string]any) (*time.Time, *time.Time, error) {
return parseFrozenTimeRange(filters)
}
func isTimeFilterScene(scene string) bool {
_, ok := timeFilterScenes[scene]
return ok
}
func parseFrozenTimeRange(filters map[string]any) (*time.Time, *time.Time, error) {
start, err := frozenTimeFilterValue(filters, exportTimeFilterStartKey)
if err != nil {
return nil, nil, err
}
end, err := frozenTimeFilterValue(filters, exportTimeFilterEndKey)
if err != nil {
return nil, nil, err
}
return utils.ParseTimeRange(start, end)
}
func frozenTimeFilterValue(filters map[string]any, key string) (string, error) {
value, ok := filters[key]
if !ok || value == nil {
return "", nil
}
text, ok := value.(string)
if !ok {
return "", utils.TimeFilterFormatError(key)
}
return text, nil
}
func rejectLegacyTimeFilterKeys(scene string, filters map[string]any) error {
for _, key := range legacyTimeFilterKeys[scene] {
text, exists := filters[key].(string)
if !exists || text == "" {
continue
}
return errors.New(errors.CodeInvalidParam, key+" 已废弃,请改用 start_time 与 end_time")
}
return nil
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// AgentRechargeHandler 代理预充值 Handler
@@ -190,14 +191,21 @@ func (h *AgentRechargeHandler) PaymentVoucherOCR(c *fiber.Ctx) error {
// GET /api/admin/agent-recharges
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
var req dto.AgentRechargeListRequest
if err := rejectLegacyTimeParams(c, "start_date", "end_date"); err != nil {
return err
}
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
list, total, err := h.service.List(c.UserContext(), &req)
list, total, err := h.service.List(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -7,9 +7,11 @@ import (
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/hibiken/asynq"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
@@ -23,6 +25,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
"go.uber.org/zap"
)
@@ -40,6 +43,13 @@ type AssetHandler struct {
observationSeries cardObservationApp.BestEffortSeriesDispatcher
packageExpiryQuery *packageExpiryQuery.Query
packageExpiryTrigger func(context.Context) error
assetExportService AssetExportTaskCreator
validator *validator.Validate
}
// AssetExportTaskCreator 定义创建导出任务的用例,供临期导出受控入口调用。
type AssetExportTaskCreator interface {
CreateTask(ctx context.Context, req *dto.CreateExportTaskRequest) (*dto.CreateExportTaskResponse, error)
}
// SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。
@@ -52,6 +62,16 @@ func (h *AssetHandler) SetPackageExpiryQuery(query *packageExpiryQuery.Query) {
h.packageExpiryQuery = query
}
// SetExportTaskService 注入导出任务创建用例,供临期资产导出受控入口使用。
func (h *AssetHandler) SetExportTaskService(creator AssetExportTaskCreator) {
h.assetExportService = creator
}
// SetValidator 注入请求参数校验器,供新增的受控入口按 DTO 规则校验并返回字段级中文提示。
func (h *AssetHandler) SetValidator(v *validator.Validate) {
h.validator = v
}
// SetPackageExpiryQueue 注入套餐临期提醒任务队列。
func (h *AssetHandler) SetPackageExpiryQueue(client *queue.Client) {
if client == nil {
@@ -138,6 +158,9 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
// GET /api/admin/expiring-assets
func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
var request dto.ExpiringAssetListRequest
if err := rejectLegacyTimeParams(c, "expires_from", "expires_to"); err != nil {
return err
}
if err := c.QueryParser(&request); err != nil {
logger.GetAppLogger().Warn("临期资产列表参数解析失败",
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
@@ -146,7 +169,11 @@ func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
if h.packageExpiryQuery == nil {
return errors.New(errors.CodeInternalError, "套餐临期查询未配置")
}
result, err := h.packageExpiryQuery.List(c.UserContext(), request)
startTime, endTime, err := utils.ParseTimeRange(request.StartTime, request.EndTime)
if err != nil {
return err
}
result, err := h.packageExpiryQuery.List(c.UserContext(), request, startTime, endTime)
if err != nil {
return err
}
@@ -155,6 +182,67 @@ func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
})
}
// ExportExpiring 创建临期资产异步导出任务。
// POST /api/admin/expiring-assets/export
// 只暴露受控入口;导出任务创建时冻结当页全部筛选、时间边界、操作者与可见店铺范围。
func (h *AssetHandler) ExportExpiring(c *fiber.Ctx) error {
var req dto.ExportExpiringAssetRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("导出临期资产参数不合法", &req, err))
}
if h.assetExportService == nil {
return errors.New(errors.CodeInternalError, "导出任务服务未配置")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
createRequest := dto.CreateExportTaskRequest{
Scene: constants.ExportTaskSceneExpiringAsset,
Format: req.Format,
Query: map[string]interface{}{"filters": expiringAssetExportFilters(req, startTime, endTime)},
}
result, err := h.assetExportService.CreateTask(c.UserContext(), &createRequest)
if err != nil {
return err
}
return response.Success(c, result)
}
// expiringAssetExportFilters 把临期导出请求转换为导出任务的筛选快照。
// 筛选集合与临期列表一致;时间边界在创建时规范化为 UTC RFC3339 秒级字符串后冻结。
func expiringAssetExportFilters(req dto.ExportExpiringAssetRequest, startTime, endTime *time.Time) map[string]interface{} {
filters := make(map[string]interface{})
if req.AssetType != "" {
filters["asset_type"] = req.AssetType
}
if req.Keyword != "" {
filters["keyword"] = req.Keyword
}
if req.ShopID != nil {
filters["shop_id"] = *req.ShopID
}
if req.PackageID != nil {
filters["package_id"] = *req.PackageID
}
if req.DaysMin != nil {
filters["days_min"] = *req.DaysMin
}
if req.DaysMax != nil {
filters["days_max"] = *req.DaysMax
}
if startTime != nil {
filters["start_time"] = utils.FormatTimeFilterValue(*startTime)
}
if endTime != nil {
filters["end_time"] = utils.FormatTimeFilterValue(*endTime)
}
return filters
}
// TriggerPackageExpiryReminder 手动提交每日临期提醒扫描任务。
// POST /api/admin/expiring-assets/reminder-scan
func (h *AssetHandler) TriggerPackageExpiryReminder(c *fiber.Ctx) error {

View File

@@ -9,6 +9,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
type AssetAllocationRecordHandler struct {
@@ -21,9 +22,16 @@ func NewAssetAllocationRecordHandler(service *assetAllocationRecordService.Servi
func (h *AssetAllocationRecordHandler) List(c *fiber.Ctx) error {
var req dto.ListAssetAllocationRecordRequest
if err := rejectLegacyTimeParams(c, "created_at_start", "created_at_end"); err != nil {
return err
}
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
@@ -35,7 +43,7 @@ func (h *AssetAllocationRecordHandler) List(c *fiber.Ctx) error {
}
}
result, err := h.service.List(ctx, &req, userShopID)
result, err := h.service.List(ctx, &req, startTime, endTime, userShopID)
if err != nil {
return err
}

View File

@@ -10,6 +10,7 @@ import (
enterpriseCardService "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
type AuthorizationHandler struct {
@@ -25,14 +26,18 @@ func (h *AuthorizationHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.ListRecords(c.UserContext(), enterpriseCardService.ListRecordsRequest{
EnterpriseID: req.EnterpriseID,
ICCID: req.ICCID,
AuthorizerType: req.AuthorizerType,
Status: req.Status,
StartTime: req.StartTime,
EndTime: req.EndTime,
StartTime: startTime,
EndTime: endTime,
Page: req.Page,
PageSize: req.PageSize,
})

View File

@@ -10,6 +10,7 @@ import (
commissionWithdrawalService "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// CommissionWithdrawalHandler 提现申请管理处理器
@@ -28,8 +29,12 @@ func (h *CommissionWithdrawalHandler) ListWithdrawalRequests(c *fiber.Ctx) error
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.ListWithdrawalRequests(c.UserContext(), &req)
result, err := h.service.ListWithdrawalRequests(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
type DeviceImportHandler struct {
@@ -70,8 +71,12 @@ func (h *DeviceImportHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.List(c.UserContext(), &req)
result, err := h.service.List(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -3,12 +3,14 @@ package admin
import (
"context"
"strconv"
"time"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
@@ -16,7 +18,7 @@ import (
// ExchangeLister 定义换货列表读取用例。
type ExchangeLister interface {
List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error)
List(ctx context.Context, req *dto.ExchangeListRequest, startTime, endTime *time.Time) (*dto.ExchangeListResponse, error)
}
// ExchangeHandler 处理后台换货管理接口。
@@ -53,6 +55,9 @@ func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
// GET /api/admin/exchanges
func (h *ExchangeHandler) List(c *fiber.Ctx) error {
var req dto.ExchangeListRequest
if err := rejectLegacyTimeParams(c, "created_at_start", "created_at_end"); err != nil {
return err
}
if err := c.QueryParser(&req); err != nil {
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
@@ -61,8 +66,12 @@ func (h *ExchangeHandler) List(c *fiber.Ctx) error {
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
data, err := h.listQuery.List(c.UserContext(), &req)
data, err := h.listQuery.List(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -9,6 +9,7 @@ import (
exportTaskService "github.com/break/junhong_cmp_fiber/internal/service/export_task"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// ExportTaskHandler 导出任务 Handler。
@@ -49,8 +50,12 @@ func (h *ExportTaskHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.ListTasks(c.UserContext(), &req)
result, err := h.service.ListTasks(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
type IotCardImportHandler struct {
@@ -56,8 +57,12 @@ func (h *IotCardImportHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.List(c.UserContext(), &req)
result, err := h.service.List(c.UserContext(), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// OrderHandler 后台订单处理器
@@ -91,6 +92,10 @@ func (h *OrderHandler) List(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
@@ -107,7 +112,7 @@ func (h *OrderHandler) List(c *fiber.Ctx) error {
buyerID = 0
}
orders, err := h.service.List(ctx, &req, buyerType, buyerID)
orders, err := h.service.List(ctx, &req, startTime, endTime, buyerType, buyerID)
if err != nil {
return err
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// ShopCommissionHandler 代理商资金管理 Handler
@@ -75,8 +76,12 @@ func (h *ShopCommissionHandler) ListWithdrawalRequests(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.ListShopWithdrawalRequests(c.UserContext(), uint(shopID), &req)
result, err := h.service.ListShopWithdrawalRequests(c.UserContext(), uint(shopID), &req, startTime, endTime)
if err != nil {
return err
}
@@ -96,8 +101,12 @@ func (h *ShopCommissionHandler) ListCommissionRecords(c *fiber.Ctx) error {
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
startTime, endTime, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return err
}
result, err := h.service.ListShopCommissionRecords(c.UserContext(), uint(shopID), &req)
result, err := h.service.ListShopCommissionRecords(c.UserContext(), uint(shopID), &req, startTime, endTime)
if err != nil {
return err
}

View File

@@ -0,0 +1,20 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// rejectLegacyTimeParams 拒绝受影响端点上已废弃的旧时间参数名。
// 契约要求被替换的旧参数在受影响端点被拒绝,不能因未知参数被忽略而静默返回全量;
// 只有空值等同未传,出现且非空即返回参数非法错误码。
func rejectLegacyTimeParams(c *fiber.Ctx, keys ...string) error {
for _, key := range keys {
if c.Query(key) == "" {
continue
}
return errors.New(errors.CodeInvalidParam, key+" 已废弃,请改用 start_time 与 end_time")
}
return nil
}

View File

@@ -137,8 +137,8 @@ type AgentRechargeListRequest struct {
ShopID *uint `json:"shop_id" query:"shop_id" description:"按店铺ID过滤"`
Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
RechargeSource string `json:"recharge_source" query:"recharge_source" validate:"omitempty,oneof=platform_offline agent_online" description:"按充值来源过滤 (platform_offline:平台线下代充, agent_online:代理在线自充)"`
StartDate string `json:"start_date" query:"start_date" description:"创建时间起始日期(YYYY-MM-DD)"`
EndDate string `json:"end_date" query:"end_date" description:"创建时间截止日期(YYYY-MM-DD)"`
StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
}
// AgentRechargeListResponse 代理充值记录列表响应

View File

@@ -4,17 +4,17 @@ import "time"
// ListAssetAllocationRecordRequest 分配记录列表请求
type ListAssetAllocationRecordRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
AllocationType string `json:"allocation_type" query:"allocation_type" validate:"omitempty,oneof=allocate recall" enum:"allocate,recall" description:"分配类型 (allocate:分配, recall:回收)"`
AssetType string `json:"asset_type" query:"asset_type" validate:"omitempty,oneof=iot_card device" enum:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=50" maxLength:"50" description:"资产标识符ICCID或设备号模糊查询"`
AllocationNo string `json:"allocation_no" query:"allocation_no" validate:"omitempty,max=50" maxLength:"50" description:"分配单号(精确匹配)"`
FromShopID *uint `json:"from_shop_id" query:"from_shop_id" description:"来源店铺ID"`
ToShopID *uint `json:"to_shop_id" query:"to_shop_id" description:"目标店铺ID"`
OperatorID *uint `json:"operator_id" query:"operator_id" description:"操作人ID"`
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
AllocationType string `json:"allocation_type" query:"allocation_type" validate:"omitempty,oneof=allocate recall" enum:"allocate,recall" description:"分配类型 (allocate:分配, recall:回收)"`
AssetType string `json:"asset_type" query:"asset_type" validate:"omitempty,oneof=iot_card device" enum:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"`
AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=50" maxLength:"50" description:"资产标识符ICCID或设备号模糊查询"`
AllocationNo string `json:"allocation_no" query:"allocation_no" validate:"omitempty,max=50" maxLength:"50" description:"分配单号(精确匹配)"`
FromShopID *uint `json:"from_shop_id" query:"from_shop_id" description:"来源店铺ID"`
ToShopID *uint `json:"to_shop_id" query:"to_shop_id" description:"目标店铺ID"`
OperatorID *uint `json:"operator_id" query:"operator_id" description:"操作人ID"`
StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
}
// AssetAllocationRecordResponse 分配记录响应

View File

@@ -9,8 +9,8 @@ type AuthorizationListReq struct {
ICCID string `json:"iccid" query:"iccid" description:"按ICCID模糊查询"`
AuthorizerType *int `json:"authorizer_type" query:"authorizer_type" description:"授权人类型2=平台3=代理"`
Status *int `json:"status" query:"status" description:"状态0=已回收1=有效"`
StartTime string `json:"start_time" query:"start_time" description:"授权时间起格式2006-01-02"`
EndTime string `json:"end_time" query:"end_time" description:"授权时间止(格式:2006-01-02"`
StartTime string `json:"start_time" query:"start_time" description:"授权时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"授权时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
}
type AuthorizationItem struct {

View File

@@ -7,8 +7,8 @@ type WithdrawalRequestListReq struct {
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:待审核, 2:已通过, 3:已拒绝, 4:已到账)"`
WithdrawalNo string `json:"withdrawal_no" query:"withdrawal_no" validate:"omitempty,max=50" maxLength:"50" description:"提现单号(精确查询)"`
ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称(模糊查询)"`
StartTime string `json:"start_time" query:"start_time" validate:"omitempty" description:"申请开始时间(格式:2006-01-02 15:04:05"`
EndTime string `json:"end_time" query:"end_time" validate:"omitempty" description:"申请结束时间(格式:2006-01-02 15:04:05"`
StartTime string `json:"start_time" query:"start_time" description:"申请时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"申请时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
}
// WithdrawalRequestItem 提现申请列表项

View File

@@ -29,13 +29,13 @@ type CreateDeviceBatchAllocationResponse struct {
}
type ListDeviceImportTaskRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
OperationType string `json:"operation_type" query:"operation_type" validate:"omitempty,oneof=import assign_shop assign_series recall" enum:"import,assign_shop,assign_series,recall" description:"任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列, recall:回收设备)"`
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
OperationType string `json:"operation_type" query:"operation_type" validate:"omitempty,oneof=import assign_shop assign_series recall" enum:"import,assign_shop,assign_series,recall" description:"任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列, recall:回收设备)"`
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"`
StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
}
type DeviceImportTaskResponse struct {

View File

@@ -15,14 +15,14 @@ type CreateExchangeRequest struct {
// ExchangeListRequest 换货单列表请求。
type ExchangeListRequest struct {
Page *int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize *int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
FlowType string `json:"flow_type" query:"flow_type" validate:"omitempty,oneof=shipping direct" enum:"shipping,direct" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" maxLength:"100" description:"旧资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN与新资产关键词按 AND 组合"`
NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" maxLength:"100" description:"新资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN与旧资产关键词按 AND 组合"`
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
Page *int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize *int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
FlowType string `json:"flow_type" query:"flow_type" validate:"omitempty,oneof=shipping direct" enum:"shipping,direct" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" maxLength:"100" description:"旧资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN与新资产关键词按 AND 组合"`
NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" maxLength:"100" description:"新资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN与旧资产关键词按 AND 组合"`
StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
}
// ExchangeShipRequest 换货发货请求。

View File

@@ -4,9 +4,9 @@ import "time"
// CreateExportTaskRequest 创建导出任务请求。
type CreateExportTaskRequest struct {
Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警)"`
Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产)"`
Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"`
Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象可选)"`
Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象可选);时间筛选固定使用 filters.start_time 与 filters.end_time取值必须为带显式时区的 RFC3339 秒级时间"`
}
// CreateExportTaskResponse 创建导出任务响应。
@@ -20,12 +20,12 @@ type CreateExportTaskResponse struct {
// ListExportTaskRequest 导出任务列表请求。
type ListExportTaskRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange commission_record package_traffic_alert expiring_asset" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货, commission_record:佣金明细, package_traffic_alert:套餐真流量达量预警, expiring_asset:临期资产)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"`
StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
}
// ExportTaskItem 导出任务列表项。

View File

@@ -101,13 +101,13 @@ type ImportIotCardResponse struct {
}
type ListImportTaskRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
CarrierID *uint `json:"carrier_id" query:"carrier_id" description:"运营商ID"`
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
CarrierID *uint `json:"carrier_id" query:"carrier_id" description:"运营商ID"`
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"`
StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
}
type ImportTaskResponse struct {

View File

@@ -19,19 +19,19 @@ type CreateAdminOrderRequest struct {
}
type OrderListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
PaymentStatus *int `json:"payment_status" query:"payment_status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
PaymentMethod string `json:"payment_method" query:"payment_method" validate:"omitempty,oneof=wallet wechat alipay offline" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付, offline:线下支付)"`
OrderType string `json:"order_type" query:"order_type" validate:"omitempty,oneof=single_card device" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
SellerShopID *uint `json:"seller_shop_id" query:"seller_shop_id" validate:"omitempty,min=1" minimum:"1" description:"所属代理商ID销售来源店铺ID"`
OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=30" maxLength:"30" description:"订单号(精确查询)"`
PurchaseRole string `json:"purchase_role" query:"purchase_role" validate:"omitempty,oneof=self_purchase purchased_by_parent purchased_by_platform purchase_for_subordinate" description:"订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买)"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
IsExpired *bool `json:"is_expired" query:"is_expired" description:"是否已过期 (true:已过期, false:未过期)"`
Identifier string `json:"identifier" query:"identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识符(支持 ICCID/VirtualNo/IMEI/SN/MSISDN按资产解析后查询对应订单"`
BuyerPhone string `json:"buyer_phone" query:"buyer_phone" validate:"omitempty,max=20" maxLength:"20" description:"买家手机号精确查询"`
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
PaymentStatus *int `json:"payment_status" query:"payment_status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
PaymentMethod string `json:"payment_method" query:"payment_method" validate:"omitempty,oneof=wallet wechat alipay offline" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付, offline:线下支付)"`
OrderType string `json:"order_type" query:"order_type" validate:"omitempty,oneof=single_card device" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
SellerShopID *uint `json:"seller_shop_id" query:"seller_shop_id" validate:"omitempty,min=1" minimum:"1" description:"所属代理商ID销售来源店铺ID"`
OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=30" maxLength:"30" description:"订单号(精确查询)"`
PurchaseRole string `json:"purchase_role" query:"purchase_role" validate:"omitempty,oneof=self_purchase purchased_by_parent purchased_by_platform purchase_for_subordinate" description:"订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买)"`
StartTime string `json:"start_time" query:"start_time" description:"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
IsExpired *bool `json:"is_expired" query:"is_expired" description:"是否已过期 (true:已过期, false:未过期)"`
Identifier string `json:"identifier" query:"identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识符(支持 ICCID/VirtualNo/IMEI/SN/MSISDN按资产解析后查询对应订单"`
BuyerPhone string `json:"buyer_phone" query:"buyer_phone" validate:"omitempty,max=20" maxLength:"20" description:"买家手机号精确查询"`
}
type PayOrderRequest struct {

View File

@@ -13,16 +13,30 @@ type PackageExpiryEstimate struct {
// ExpiringAssetListRequest 临期资产分页查询参数。
type ExpiringAssetListRequest struct {
AssetType string `query:"asset_type" validate:"omitempty,oneof=iot_card device" enums:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"`
Keyword string `query:"keyword" validate:"omitempty,max=100" description:"资产标识关键词,匹配卡 ICCID/MSISDN/虚拟号或设备虚拟号/IMEI"`
ShopID *uint `query:"shop_id" validate:"omitempty,gt=0" description:"店铺 ID只能缩小当前账号的数据权限范围"`
PackageID *uint `query:"package_id" validate:"omitempty,gt=0" description:"最终排队套餐 ID"`
DaysMin *int `query:"days_min" validate:"omitempty,min=0,max=15" description:"最小剩余上海自然日天数,范围 0 至 15"`
DaysMax *int `query:"days_max" validate:"omitempty,min=0,max=15" description:"最大剩余上海自然日天数,范围 0 至 15"`
ExpiresFrom string `query:"expires_from" validate:"omitempty,datetime=2006-01-02" description:"预计到期开始日期(上海自然日,格式 YYYY-MM-DD"`
ExpiresTo string `query:"expires_to" validate:"omitempty,datetime=2006-01-02" description:"预计到期结束日期(上海自然日,格式 YYYY-MM-DD"`
Page int `query:"page" validate:"omitempty,min=1" default:"1" description:"页码"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" default:"20" description:"每页数量,最大 100"`
AssetType string `query:"asset_type" validate:"omitempty,oneof=iot_card device" enum:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"`
Keyword string `query:"keyword" validate:"omitempty,max=100" description:"资产标识关键词,匹配卡 ICCID/MSISDN/虚拟号或设备虚拟号/IMEI"`
ShopID *uint `query:"shop_id" validate:"omitempty,gt=0" description:"店铺 ID只能缩小当前账号的数据权限范围"`
PackageID *uint `query:"package_id" validate:"omitempty,gt=0" description:"最终排队套餐 ID"`
DaysMin *int `query:"days_min" validate:"omitempty,min=0,max=15" description:"最小剩余上海自然日天数,范围 0 至 15"`
DaysMax *int `query:"days_max" validate:"omitempty,min=0,max=15" description:"最大剩余上海自然日天数,范围 0 至 15"`
StartTime string `query:"start_time" description:"最终到期时间起始(带时区的 RFC3339 秒级时间,按时刻闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `query:"end_time" description:"最终到期时间结束(带时区的 RFC3339 秒级时间,按时刻闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
Page int `query:"page" validate:"omitempty,min=1" default:"1" description:"页码"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" default:"20" description:"每页数量,最大 100"`
}
// ExportExpiringAssetRequest 创建临期资产导出任务请求。
// 筛选集合与临期资产列表一致,创建时冻结并由异步导出复用同一口径。
type ExportExpiringAssetRequest struct {
Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"`
AssetType string `json:"asset_type" validate:"omitempty,oneof=iot_card device" enum:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"`
Keyword string `json:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"资产标识关键词,匹配卡 ICCID/MSISDN/虚拟号或设备虚拟号/IMEI"`
ShopID *uint `json:"shop_id" validate:"omitempty,gt=0" description:"店铺 ID只能缩小当前账号的数据权限范围"`
PackageID *uint `json:"package_id" validate:"omitempty,gt=0" description:"最终排队套餐 ID"`
DaysMin *int `json:"days_min" validate:"omitempty,min=0,max=15" description:"最小剩余上海自然日天数,范围 0 至 15"`
DaysMax *int `json:"days_max" validate:"omitempty,min=0,max=15" description:"最大剩余上海自然日天数,范围 0 至 15"`
StartTime string `json:"start_time" description:"最终到期时间起始(带时区的 RFC3339 秒级时间,按时刻闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" description:"最终到期时间结束(带时区的 RFC3339 秒级时间,按时刻闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
}
// ExpiringAssetItem 临期资产列表项。

View File

@@ -94,8 +94,8 @@ type ShopWithdrawalRequestListReq struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量默认20最大100"`
WithdrawalNo string `json:"withdrawal_no" query:"withdrawal_no" validate:"omitempty,max=50" maxLength:"50" description:"提现单号(精确查询)"`
StartTime string `json:"start_time" query:"start_time" validate:"omitempty" description:"申请开始时间(格式:2006-01-02 15:04:05"`
EndTime string `json:"end_time" query:"end_time" validate:"omitempty" description:"申请结束时间(格式:2006-01-02 15:04:05"`
StartTime string `json:"start_time" query:"start_time" description:"申请时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"申请时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
}
// ShopWithdrawalRequestItem 代理商提现记录项
@@ -148,6 +148,8 @@ type ShopCommissionRecordListReq struct {
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=50" maxLength:"50" description:"ICCID模糊查询"`
VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=50" maxLength:"50" description:"设备虚拟号(模糊查询)"`
OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=50" maxLength:"50" description:"订单号(模糊查询)"`
StartTime string `json:"start_time" query:"start_time" description:"佣金明细创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00"`
EndTime string `json:"end_time" query:"end_time" description:"佣金明细创建时间结束(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-30T23:59:59+08:00"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=99" minimum:"1" maximum:"99" description:"佣金状态 (1:已冻结, 2:解冻中, 3:已发放, 4:已失效, 5:回溯, 99:待人工修正)"`
}

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) {

View File

@@ -14,7 +14,7 @@ func registerExportTaskRoutes(router fiber.Router, handler *admin.ExportTaskHand
Register(exportTasks, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建导出任务",
Description: "创建统一导出任务,支持场景 scene=device/iot_card/order 和格式 format=xlsx/csv。",
Description: "创建统一导出任务,支持场景 scene=device/iot_card/order/package/agent_wallet_transaction/agent_recharge/refund/exchange/commission_record/package_traffic_alert/expiring_asset 和格式 format=xlsx/csv。受影响场景的时间筛选固定为 query.filters.start_time 与 query.filters.end_time取值必须为带显式时区的 RFC3339 秒级时间,创建时规范化为 UTC 秒级字符串后冻结。",
Tags: []string{"导出任务"},
Input: new(dto.CreateExportTaskRequest),
Output: new(dto.CreateExportTaskResponse),

View File

@@ -19,6 +19,15 @@ func registerPackageExpiryRoutes(router fiber.Router, handler *admin.AssetHandle
Auth: true,
})
Register(router, doc, basePath, "POST", "/expiring-assets/export", handler.ExportExpiring, RouteSpec{
Summary: "创建临期资产导出任务",
Description: "受控导出入口:请求体复用临期资产列表的筛选集合(时间区间、剩余天数上下限、套餐、资产类型、关键字、店铺),创建时冻结筛选、时间边界、操作者与可见店铺范围。导出一行对应一项资产,取当前生效主套餐最终到期时间与剩余天数,加油包不单独成行;列序为店铺、业务员、用户组、资产类型、设备类型、设备型号、资产标识、当前套餐、到期时间、剩余天数。企业账号禁止调用。",
Tags: []string{"资产管理"},
Input: new(dto.ExportExpiringAssetRequest),
Output: new(dto.CreateExportTaskResponse),
Auth: true,
})
Register(router, doc, basePath, "POST", "/expiring-assets/reminder-scan", handler.TriggerPackageExpiryReminder, RouteSpec{
Summary: "手动触发每日临期提醒扫描",
Description: "仅超级管理员可调用。立即提交与每日 03:00 相同的每日临期提醒扫描任务:扫描最终到期时间可精确推算且剩余 0 至 15 个上海自然日的资产粉色8 至 15 天、紫色4 至 7 天、红色0 至 3 天)仅表示列表展示等级。任务异步执行并沿用通知防重,不生成临期列表快照。",

View File

@@ -501,7 +501,7 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeRespo
// List 分页查询充值订单列表
// GET /api/admin/agent-recharges
func (s *Service) List(ctx context.Context, req *dto.AgentRechargeListRequest) ([]*dto.AgentRechargeResponse, int64, error) {
func (s *Service) List(ctx context.Context, req *dto.AgentRechargeListRequest, startTime, endTime *time.Time) ([]*dto.AgentRechargeResponse, int64, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
@@ -525,11 +525,11 @@ func (s *Service) List(ctx context.Context, req *dto.AgentRechargeListRequest) (
case constants.AgentRechargeSourceAgentOnline:
query = query.Where("payment_method IN ?", []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay})
}
if req.StartDate != "" {
query = query.Where("created_at >= ?", req.StartDate+" 00:00:00")
if startTime != nil {
query = query.Where("created_at >= ?", *startTime)
}
if req.EndDate != "" {
query = query.Where("created_at <= ?", req.EndDate+" 23:59:59")
if endTime != nil {
query = query.Where("created_at <= ?", *endTime)
}
var total int64

View File

@@ -3,6 +3,7 @@ package asset_allocation_record
import (
"context"
"encoding/json"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -34,7 +35,7 @@ func New(
}
}
func (s *Service) List(ctx context.Context, req *dto.ListAssetAllocationRecordRequest, userShopID *uint) (*dto.ListAssetAllocationRecordResponse, error) {
func (s *Service) List(ctx context.Context, req *dto.ListAssetAllocationRecordRequest, startTime, endTime *time.Time, userShopID *uint) (*dto.ListAssetAllocationRecordResponse, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
@@ -71,11 +72,11 @@ func (s *Service) List(ctx context.Context, req *dto.ListAssetAllocationRecordRe
if req.OperatorID != nil {
filters["operator_id"] = *req.OperatorID
}
if req.CreatedAtStart != nil {
filters["created_at_start"] = *req.CreatedAtStart
if startTime != nil {
filters["start_time"] = *startTime
}
if req.CreatedAtEnd != nil {
filters["created_at_end"] = *req.CreatedAtEnd
if endTime != nil {
filters["end_time"] = *endTime
}
if userShopID != nil {

View File

@@ -49,7 +49,7 @@ func New(
}
}
func (s *Service) ListWithdrawalRequests(ctx context.Context, req *dto.WithdrawalRequestListReq) (*dto.WithdrawalRequestPageResult, error) {
func (s *Service) ListWithdrawalRequests(ctx context.Context, req *dto.WithdrawalRequestListReq, startTime, endTime *time.Time) (*dto.WithdrawalRequestPageResult, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
@@ -65,19 +65,8 @@ func (s *Service) ListWithdrawalRequests(ctx context.Context, req *dto.Withdrawa
filters := &postgres.WithdrawalRequestListFilters{
WithdrawalNo: req.WithdrawalNo,
Status: req.Status,
}
if req.StartTime != "" {
t, err := time.Parse("2006-01-02 15:04:05", req.StartTime)
if err == nil {
filters.StartTime = &t
}
}
if req.EndTime != "" {
t, err := time.Parse("2006-01-02 15:04:05", req.EndTime)
if err == nil {
filters.EndTime = &t
}
StartTime: startTime,
EndTime: endTime,
}
requests, total, err := s.commissionWithdrawalReqStore.List(ctx, opts, filters)

View File

@@ -170,7 +170,7 @@ func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.Create
}, nil
}
func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest) (*dto.ListDeviceImportTaskResponse, error) {
func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest, startTime, endTime *time.Time) (*dto.ListDeviceImportTaskResponse, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
@@ -195,11 +195,11 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.StartTime != nil {
filters["start_time"] = *req.StartTime
if startTime != nil {
filters["start_time"] = *startTime
}
if req.EndTime != nil {
filters["end_time"] = *req.EndTime
if endTime != nil {
filters["end_time"] = *endTime
}
tasks, total, err := s.importTaskStore.List(ctx, opts, filters)

View File

@@ -247,8 +247,8 @@ type ListRecordsRequest struct {
ICCID string
AuthorizerType *int
Status *int
StartTime string
EndTime string
StartTime *time.Time
EndTime *time.Time
Page int
PageSize int
}
@@ -294,24 +294,12 @@ func (s *AuthorizationService) ListRecords(ctx context.Context, req ListRecordsR
ICCID: req.ICCID,
AuthorizerType: req.AuthorizerType,
Status: req.Status,
StartTime: req.StartTime,
EndTime: req.EndTime,
Offset: (req.Page - 1) * req.PageSize,
Limit: req.PageSize,
}
if req.StartTime != "" {
t, err := parseDate(req.StartTime)
if err == nil {
opts.StartTime = &t
}
}
if req.EndTime != "" {
t, err := parseDate(req.EndTime)
if err == nil {
endTime := t.AddDate(0, 0, 1)
opts.EndTime = &endTime
}
}
results, total, err := s.authorizationStore.ListWithJoin(ctx, opts)
if err != nil {
return nil, err
@@ -523,7 +511,3 @@ func (s *AuthorizationService) recordRemarkFailure(ctx context.Context, record *
CardAuthorizations: []accessauditapp.EnterpriseCardAuthorizationChange{{Authorization: auth}},
}, originalErr)
}
func parseDate(dateStr string) (time.Time, error) {
return time.ParseInLocation("2006-01-02", dateStr, time.Local)
}

View File

@@ -73,6 +73,12 @@ func (s *Service) CreateTask(ctx context.Context, req *dto.CreateExportTaskReque
return nil, errors.New(errors.CodeInvalidParam, "导出格式不支持")
}
// 受影响场景的时间边界在创建期校验并规范化为 UTC RFC3339 秒级字符串后随筛选一起冻结,
// 执行期只按冻结值严格解析;旧参数键与非法格式一律在创建期拒绝。
if err := exporter.NormalizeTaskTimeFilters(req.Scene, req.Query); err != nil {
return nil, err
}
queryJSON := datatypes.JSON("{}")
if req.Query != nil {
raw, err := sonic.Marshal(req.Query)
@@ -173,7 +179,7 @@ func (s *Service) CreateTask(ctx context.Context, req *dto.CreateExportTaskReque
}
// ListTasks 查询导出任务列表。
func (s *Service) ListTasks(ctx context.Context, req *dto.ListExportTaskRequest) (*dto.ListExportTaskResponse, error) {
func (s *Service) ListTasks(ctx context.Context, req *dto.ListExportTaskRequest, startTime, endTime *time.Time) (*dto.ListExportTaskResponse, error) {
page := req.Page
if page <= 0 {
page = 1
@@ -194,11 +200,11 @@ func (s *Service) ListTasks(ctx context.Context, req *dto.ListExportTaskRequest)
if req.Status != nil {
filters["status"] = *req.Status
}
if req.StartTime != nil {
filters["start_time"] = *req.StartTime
if startTime != nil {
filters["start_time"] = *startTime
}
if req.EndTime != nil {
filters["end_time"] = *req.EndTime
if endTime != nil {
filters["end_time"] = *endTime
}
items, total, err := s.taskStore.List(ctx, &store.QueryOptions{

View File

@@ -150,7 +150,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe
}, nil
}
func (s *Service) List(ctx context.Context, req *dto.ListImportTaskRequest) (*dto.ListImportTaskResponse, error) {
func (s *Service) List(ctx context.Context, req *dto.ListImportTaskRequest, startTime, endTime *time.Time) (*dto.ListImportTaskResponse, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
@@ -175,11 +175,11 @@ func (s *Service) List(ctx context.Context, req *dto.ListImportTaskRequest) (*dt
if req.BatchNo != "" {
filters["batch_no"] = req.BatchNo
}
if req.StartTime != nil {
filters["start_time"] = *req.StartTime
if startTime != nil {
filters["start_time"] = *startTime
}
if req.EndTime != nil {
filters["end_time"] = *req.EndTime
if endTime != nil {
filters["end_time"] = *endTime
}
tasks, total, err := s.importTaskStore.List(ctx, opts, filters)

View File

@@ -1297,7 +1297,7 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.OrderResponse, error)
return s.buildOrderResponse(ctx, order, items), nil
}
func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType string, buyerID uint) (*dto.OrderListResponse, error) {
func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, startTime, endTime *time.Time, buyerType string, buyerID uint) (*dto.OrderListResponse, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
@@ -1338,11 +1338,11 @@ func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType
if req.SellerShopID != nil {
filters["seller_shop_id"] = *req.SellerShopID
}
if req.StartTime != nil {
filters["start_time"] = req.StartTime
if startTime != nil {
filters["start_time"] = *startTime
}
if req.EndTime != nil {
filters["end_time"] = req.EndTime
if endTime != nil {
filters["end_time"] = *endTime
}
if req.Identifier != "" {
resolvedIotCardID, resolvedDeviceID, err := s.resolveOrderListAssetIdentifier(ctx, req.Identifier)

View File

@@ -16,6 +16,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/utils"
"go.uber.org/zap"
"gorm.io/gorm"
)
@@ -73,7 +74,7 @@ func New(
// ListShopWithdrawalRequests 查询代理商提现记录
// GET /shops/:id/withdrawal-requests
func (s *Service) ListShopWithdrawalRequests(ctx context.Context, shopID uint, req *dto.ShopWithdrawalRequestListReq) (*dto.ShopWithdrawalRequestPageResult, error) {
func (s *Service) ListShopWithdrawalRequests(ctx context.Context, shopID uint, req *dto.ShopWithdrawalRequestListReq, startTime, endTime *time.Time) (*dto.ShopWithdrawalRequestPageResult, error) {
// 越权校验:平台人员可查所有,代理只能查自己和下级
if err := middleware.CanManageShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
@@ -99,19 +100,8 @@ func (s *Service) ListShopWithdrawalRequests(ctx context.Context, shopID uint, r
filters := &postgres.WithdrawalRequestListFilters{
ShopID: shopID,
WithdrawalNo: req.WithdrawalNo,
}
if req.StartTime != "" {
t, err := time.Parse("2006-01-02 15:04:05", req.StartTime)
if err == nil {
filters.StartTime = &t
}
}
if req.EndTime != "" {
t, err := time.Parse("2006-01-02 15:04:05", req.EndTime)
if err == nil {
filters.EndTime = &t
}
StartTime: startTime,
EndTime: endTime,
}
requests, total, err := s.commissionWithdrawalReqStore.ListByShopID(ctx, opts, filters)
@@ -249,7 +239,7 @@ func (s *Service) buildShopHierarchyPath(ctx context.Context, shop *model.Shop)
// ListShopCommissionRecords 查询代理商佣金明细
// GET /shops/:id/commission-records
// 原佣金与回溯明细按同一分页与排序口径合并返回,筛选与数据范围在合并前各自应用。
func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, req *dto.ShopCommissionRecordListReq) (*dto.ShopCommissionRecordPageResult, error) {
func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, req *dto.ShopCommissionRecordListReq, startTime, endTime *time.Time) (*dto.ShopCommissionRecordPageResult, error) {
// 越权校验:平台人员可查所有,代理只能查自己和下级
if err := middleware.CanManageShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
@@ -280,6 +270,8 @@ func (s *Service) ListShopCommissionRecords(ctx context.Context, shopID uint, re
ICCID: req.ICCID,
DeviceNo: req.VirtualNo,
OrderNo: req.OrderNo,
StartTime: formatLedgerTimeFilter(startTime),
EndTime: formatLedgerTimeFilter(endTime),
Status: req.Status,
}
@@ -445,6 +437,15 @@ func buildShopCommissionRecordItem(row *postgres.CommissionLedgerRow, shopNameMa
return item
}
// formatLedgerTimeFilter 把入口严格解析后的 UTC 瞬时转换为佣金明细列表的时间筛选值。
func formatLedgerTimeFilter(value *time.Time) *string {
if value == nil {
return nil
}
formatted := utils.FormatTimeFilterValue(*value)
return &formatted
}
// buildClawbackItems 把回溯摘要投影为明细项。
func buildClawbackItems(summaries []postgres.CommissionClawbackSummary) []dto.ShopCommissionClawbackItem {
if len(summaries) == 0 {

View File

@@ -78,10 +78,10 @@ func (s *AssetAllocationRecordStore) List(ctx context.Context, opts *store.Query
if operatorID, ok := filters["operator_id"].(uint); ok && operatorID > 0 {
query = query.Where("operator_id = ?", operatorID)
}
if createdAtStart, ok := filters["created_at_start"].(time.Time); ok {
if createdAtStart, ok := filters["start_time"].(time.Time); ok {
query = query.Where("created_at >= ?", createdAtStart)
}
if createdAtEnd, ok := filters["created_at_end"].(time.Time); ok {
if createdAtEnd, ok := filters["end_time"].(time.Time); ok {
query = query.Where("created_at <= ?", createdAtEnd)
}
if relatedShopIDs, ok := filters["related_shop_ids"].([]uint); ok && len(relatedShopIDs) > 0 {

View File

@@ -241,9 +241,11 @@ type CommissionRecordListFilters struct {
ICCID string
DeviceNo string
OrderNo string
StartTime *string
EndTime *string
Status *int
// StartTime/EndTime 为归一后的 UTC RFC3339 秒级时间字符串,按 created_at 列闭区间比较。
// 保留字符串形态与其他筛选一致,由调用方负责在入口处完成严格解析。
StartTime *string
EndTime *string
Status *int
}
type CommissionStats struct {

View File

@@ -344,7 +344,7 @@ func (s *EnterpriseCardAuthorizationStore) ListWithJoin(ctx context.Context, opt
args = append(args, *opts.StartTime)
}
if opts.EndTime != nil {
baseQuery += " AND a.authorized_at < ?"
baseQuery += " AND a.authorized_at <= ?"
args = append(args, *opts.EndTime)
}

View File

@@ -120,6 +120,12 @@ func (h *ExportDispatchHandler) HandleExportDispatch(ctx context.Context, task *
}
params := exporter.ParseExportParams(exportTask)
if err := exporter.ValidateTaskTimeFilters(exportTask.Scene, params.Filters); err != nil {
// 冻结值非法(含变更前遗留任务的旧格式值)必须落失败,不得忽略条件后放行全量数据。
_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, constants.ExportTaskInvalidTimeFilterMessage)
h.logger.Error("导出任务冻结的时间边界非法", zap.Uint("task_id", exportTask.ID), zap.Error(err))
return asynq.SkipRetry
}
headers, err := strategy.Headers(ctx, params)
if err != nil {
_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, "解析导出表头失败")