From 62419d4b17865ea284c782b2188b3081f17851b3 Mon Sep 17 00:00:00 2001 From: break Date: Thu, 17 Sep 2026 18:37:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AF=BC=E5=87=BA=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=AD=9B=E9=80=89):=20AUG26-014=20=E7=BB=9F=E4=B8=80=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=AD=9B=E9=80=89=E4=B8=8E=E4=B8=B4=E6=9C=9F=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=EF=BC=8C=E5=BD=92=E6=A1=A3=E5=B9=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E4=B8=BB=20Spec=20=E4=B8=8E=E8=AF=81=E6=8D=AE=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一时间筛选:新增共享严格解析器 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 变更、无迁移、无运行时开关。 --- README.md | 19 +- ...port-time-filter-standards-verification.md | 542 ++++++++++++++++++ .../entry-capability-requirement-matrix.json | 12 + .../context-reset/requirement-evidence.json | 189 ++++++ internal/bootstrap/handlers.go | 2 + internal/exporter/agent_recharge_scene.go | 27 +- internal/exporter/commission_record_scene.go | 45 +- internal/exporter/exchange_scene.go | 27 +- internal/exporter/expiring_asset_scene.go | 229 ++++++++ internal/exporter/order_scene.go | 12 +- internal/exporter/ownership_columns.go | 60 ++ .../exporter/package_traffic_alert_scene.go | 56 +- internal/exporter/registry.go | 4 +- internal/exporter/time_filters.go | 122 ++++ internal/handler/admin/agent_recharge.go | 10 +- internal/handler/admin/asset.go | 90 ++- .../handler/admin/asset_allocation_record.go | 10 +- internal/handler/admin/authorization.go | 9 +- .../handler/admin/commission_withdrawal.go | 7 +- internal/handler/admin/device_import.go | 7 +- internal/handler/admin/exchange.go | 13 +- internal/handler/admin/export_task.go | 7 +- internal/handler/admin/iot_card_import.go | 7 +- internal/handler/admin/order.go | 7 +- internal/handler/admin/shop_commission.go | 13 +- internal/handler/admin/time_params.go | 20 + internal/model/dto/agent_recharge_dto.go | 4 +- .../model/dto/asset_allocation_record_dto.go | 22 +- internal/model/dto/authorization_dto.go | 4 +- .../model/dto/commission_withdrawal_dto.go | 4 +- internal/model/dto/device_import_dto.go | 14 +- internal/model/dto/exchange_dto.go | 16 +- internal/model/dto/export_task_dto.go | 16 +- internal/model/dto/iot_card_dto.go | 14 +- internal/model/dto/order_dto.go | 26 +- internal/model/dto/package_expiry_dto.go | 34 +- internal/model/dto/shop_commission_dto.go | 6 +- internal/query/exchange/list.go | 14 +- internal/query/packageexpiry/list.go | 162 +++--- internal/routes/export_task.go | 2 +- internal/routes/package_expiry.go | 9 + internal/service/agent_recharge/service.go | 10 +- .../asset_allocation_record/service.go | 11 +- .../service/commission_withdrawal/service.go | 17 +- internal/service/device_import/service.go | 10 +- .../enterprise_card/authorization_service.go | 24 +- internal/service/export_task/service.go | 16 +- internal/service/iot_card_import/service.go | 10 +- internal/service/order/service.go | 10 +- internal/service/shop_commission/service.go | 31 +- .../postgres/asset_allocation_record_store.go | 4 +- .../store/postgres/commission_record_store.go | 8 +- .../enterprise_card_authorization_store.go | 2 +- internal/task/export_dispatch.go | 6 + .../design.md | 21 - .../proposal.md | 24 - .../specs/export-time-filter/spec.md | 17 - .../specs/unified-time-filter/spec.md | 12 - .../add-export-time-filter-standards/tasks.md | 8 - .../.openspec.yaml | 0 .../design.md | 98 ++++ .../proposal.md | 46 ++ .../specs/export-time-filter/spec.md | 182 ++++++ .../tasks.md | 69 +++ openspec/specs/export-time-filter/spec.md | 184 ++++++ pkg/constants/constants.go | 6 + pkg/utils/time_range.go | 84 +++ 67 files changed, 2405 insertions(+), 398 deletions(-) create mode 100644 docs/verification/add-export-time-filter-standards-verification.md create mode 100644 internal/exporter/expiring_asset_scene.go create mode 100644 internal/exporter/ownership_columns.go create mode 100644 internal/exporter/time_filters.go create mode 100644 internal/handler/admin/time_params.go delete mode 100644 openspec/changes/add-export-time-filter-standards/design.md delete mode 100644 openspec/changes/add-export-time-filter-standards/proposal.md delete mode 100644 openspec/changes/add-export-time-filter-standards/specs/export-time-filter/spec.md delete mode 100644 openspec/changes/add-export-time-filter-standards/specs/unified-time-filter/spec.md delete mode 100644 openspec/changes/add-export-time-filter-standards/tasks.md rename openspec/changes/{add-export-time-filter-standards => archive/2026-09-17-add-export-time-filter-standards}/.openspec.yaml (100%) create mode 100644 openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md create mode 100644 openspec/changes/archive/2026-09-17-add-export-time-filter-standards/proposal.md create mode 100644 openspec/changes/archive/2026-09-17-add-export-time-filter-standards/specs/export-time-filter/spec.md create mode 100644 openspec/changes/archive/2026-09-17-add-export-time-filter-standards/tasks.md create mode 100644 openspec/specs/export-time-filter/spec.md create mode 100644 pkg/utils/time_range.go diff --git a/README.md b/README.md index d881ef0..1b8819b 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ default: - **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式(包含错误码、消息、时间戳);Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx)和日志级别控制;敏感信息自动脱敏保护 - **数据持久化**:GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力 - **异步任务处理**:Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务 -- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `scene=device/iot_card`、`format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 功能总结 与 验收记录 +- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接。当前导出场景共 11 个:`device`、`iot_card`、`order`、`package`、`agent_wallet_transaction`、`agent_recharge`、`refund`、`exchange`、`commission_record`、`package_traffic_alert`、`expiring_asset`;其中 `exchange`、`agent_recharge`、`order`、`commission_record`、`expiring_asset` 的时间筛选固定为 `filters.start_time`/`filters.end_time`(带显式时区的 RFC3339 秒级时间,闭区间含两端),创建时规范化为 UTC 秒级字符串后冻结,执行期只按冻结值严格解析;临期资产导出另有受控入口 `POST /api/admin/expiring-assets/export` - **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 功能总结、接口回放示例 与 SQL 验收脚本 - **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能;完整的权限检查功能支持路由级别的细粒度权限控制,支持平台过滤(web/h5/all)和超级管理员自动跳过(详见 功能总结、使用指南 和 权限检查使用指南) - **商户管理**:完整的商户(Shop)和商户账号管理功能,支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能(详见 使用指南 和 API 文档) @@ -246,14 +246,23 @@ default: ### 导出任务接口示例 ```bash -# 创建导出任务(scene 支持 device / iot_card) +# 创建导出任务(scene 支持 device / iot_card / order / package / agent_wallet_transaction / +# agent_recharge / refund / exchange / commission_record / package_traffic_alert / expiring_asset) +# exchange / agent_recharge / order / commission_record 的时间筛选固定写在 query.filters 下, +# 取值必须为带显式时区的 RFC3339 秒级时间,闭区间含两端,任一端可省略 curl -X POST '/api/admin/export-tasks' \\ -H 'Content-Type: application/json' \\ -H 'Authorization: Bearer ' \\ - -d '{\"scene\":\"device\",\"format\":\"xlsx\"}' + -d '{"scene":"order","format":"xlsx","query":{"filters":{"start_time":"2026-09-01T00:00:00+08:00","end_time":"2026-09-30T23:59:59+08:00"}}}' -# 查询导出任务列表(支持 scene/status/time 过滤) -curl '/api/admin/export-tasks?page=1&page_size=20&scene=device' \\ +# 创建临期资产导出任务(受控入口,请求体与临期资产列表筛选一致) +curl -X POST '/api/admin/expiring-assets/export' \\ + -H 'Content-Type: application/json' \\ + -H 'Authorization: Bearer ' \\ + -d '{"format":"xlsx","days_max":7,"start_time":"2026-09-01T00:00:00+08:00"}' + +# 查询导出任务列表(支持 scene/status/start_time/end_time 过滤) +curl '/api/admin/export-tasks?page=1&page_size=20&scene=order&start_time=2026-09-01T00:00:00%2B08:00' \\ -H 'Authorization: Bearer ' ``` diff --git a/docs/verification/add-export-time-filter-standards-verification.md b/docs/verification/add-export-time-filter-standards-verification.md new file mode 100644 index 0000000..091b3f9 --- /dev/null +++ b/docs/verification/add-export-time-filter-standards-verification.md @@ -0,0 +1,542 @@ +# AUG26-014 统一时间筛选与临期导出(add-export-time-filter-standards)范围、契约与前端同步清单 + +本文件记录 OpenSpec Change `add-export-time-filter-standards` 的实施范围、端点改造对照、DTO 与 OpenAPI 变更清单以及前端同步清单。 +行为契约以 [`../../openspec/specs/export-time-filter/spec.md`](../../openspec/specs/export-time-filter/spec.md) 与变更产物 +[`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/) 为准;本文件是实施记录与同步清单,不是行为契约。 + +本文的实测证据节按 T7 分工由独立验证 worker 追加;本文不写未经实跑的结论。 + +## 1. 范围与依据 + +- 迭代编号:`AUG26-014`。需求依据:`docs/product/2026-08-迭代-PRD-讨论稿.md` §2.16(时间筛选与导出快照两段);导出列定义依据 `111.md` §18。 +- 执行契约:变更产物 `proposal.md`、`design.md`、`tasks.md` 与 `specs/export-time-filter/spec.md`;冲突时以 spec delta 为准。 +- 统一契约:被覆盖的列表与导出入口使用可选的 `start_time`/`end_time`,取值 MUST 为带显式时区的 RFC3339 秒级时间,解析结果归一为 UTC 瞬时,筛选为闭区间含两端;任一端可缺省,未传与空串同义;开始晚于结束拒绝。 +- 共享实现落点:`pkg/utils/time_range.go`(`ParseTimeRange`),创建期 handler 与执行期 `internal/exporter` 共用同一份实现。 +- 本次边界(不做):不新增迁移、不改既有迁移、不改数据库 Schema、不新增运行时开关、不删除全局宽松解析器(`cmd/api/main.go` 的 `registerTimeParserCompat`)、不新增 `*_test.go`、不重构未列入本次的端点与其导出、不做跨度上限、不改任何导出的记录粒度/列定义/余额口径、不修佣金统计类接口的裸串直传。 +- 未列入本次的端点保持既有参数名、既有格式接受范围与既有筛选结果,逐项登记见 `design.md`「已知差异登记(本次不修)」;其中达量预警列表与导出本期一律不改(依据端点表「已符合」行)。 +- 数据承载:无 Schema 变更,冻结值写入既有 `tb_export_task.query_json` / `scope_shop_ids` 与既有操作者、文件、行数、`error_message` 列,详见第 5 节。 + +## 2. 端点改造清单 + +### 2.1 列表端点 + +| 端点 | 改造前参数与格式 | 改造后 | 筛选依据业务时间 | +| --- | --- | --- | --- | +| `GET /api/admin/iot-cards/import-tasks` | `start_time`/`end_time`(`*time.Time`,经全局宽松解析:兼容 RFC3339、无时区、date-only) | 参数名不变,`string` + 严格解析 | 导入任务创建时间 | +| `GET /api/admin/devices/import/tasks` | 同上 | 参数名不变,`string` + 严格解析 | 导入任务创建时间 | +| `GET /api/admin/export-tasks` | 同上 | 参数名不变,`string` + 严格解析 | 导出任务创建时间 | +| `GET /api/admin/orders` | 同上 | 参数名不变,`string` + 严格解析 | 订单创建时间 | +| `GET /api/admin/exchanges` | `created_at_start`/`created_at_end`(`*time.Time`,宽松) | 改名 `start_time`/`end_time`,`string` + 严格解析;旧参数名不再生效 | 换货单创建时间 | +| `GET /api/admin/asset-allocation-records` | `created_at_start`/`created_at_end`(`*time.Time`,宽松) | 改名 `start_time`/`end_time`,`string` + 严格解析 | 分配记录创建时间 | +| `GET /api/admin/agent-recharges` | `start_date`/`end_date`(无格式约束 `string`,按日拼接当日 `00:00:00`/`23:59:59`) | 改名 `start_time`/`end_time`,`string` + 严格解析;删除按日补齐 | 代理充值记录创建时间 | +| `GET /api/admin/shops/{shop_id}/commission-records` | 无时间参数 | 新增 `start_time`/`end_time`,闭区间 | 佣金明细创建时间(原佣金与回溯两条分支各自的 `created_at`) | +| `GET /api/admin/commission/withdrawal-requests` | `start_time`/`end_time`(无时区 `2006-01-02 15:04:05`,解析失败静默忽略该条件) | 参数名不变,严格解析;删除静默跳过,非法参数一律 1001 | 提现申请创建时间 | +| `GET /api/admin/shops/{shop_id}/withdrawal-requests` | 同上 | 同上 | 提现申请创建时间 | +| `GET /api/admin/authorizations` | `start_time`/`end_time`(date-only `2006-01-02`;起始闭、结束开且结束日加一天) | 参数名不变,严格解析;改为闭区间含两端 | 授权发生时间 | +| `GET /api/admin/expiring-assets` | `expires_from`/`expires_to`(date-only,按上海自然日比较) | 改名 `start_time`/`end_time`,按最终到期**时刻**闭区间比较;保留既有剩余天数上下限筛选 | 当前生效主套餐最终到期时刻 | + +### 2.2 导出场景 + +| 导出场景 / 入口 | 改造前筛选键 | 改造后 | 口径来源 | +| --- | --- | --- | --- | +| `exchange`(经 `POST /api/admin/export-tasks`) | `created_at_start`/`created_at_end`(宽松多格式解析) | 改名 `start_time`/`end_time`,共享严格解析器;旧键在创建期被拒绝 | 换货单创建时间 | +| `agent_recharge`(同上) | `start_date`/`end_date`(宽松、按日补齐当日末秒) | 改名 `start_time`/`end_time`,共享严格解析器;旧键在创建期被拒绝 | 代理充值记录创建时间 | +| `order`(同上) | `start_time`/`end_time`(宽松) | 参数名不变,共享严格解析器 | 订单创建时间 | +| `commission_record`(同上) | 无时间筛选 | 新增 `start_time`/`end_time`,两条分支各自的创建时间列闭区间 | 佣金明细创建时间 | +| 临期资产(`POST /api/admin/expiring-assets/export`) | 场景此前不存在 | 本次新建场景 `expiring_asset` 与受控入口 | 当前生效主套餐最终到期时刻 | +| `package_traffic_alert`(`POST /api/admin/package-traffic-alerts/export`) | `start_time`/`end_time`(RFC3339 秒级,创建时冻结) | 入口参数名与 `*time.Time` 契约、列表 `GET /api/admin/package-traffic-alerts` 行为、列定义与触发快照口径**保持不动**;**执行期改为只按冻结值严格解析**(冻结值非法则任务落失败,依据 Requirement 4) | 预警触发时间 | + +### 2.3 未改造项确认(逐一核对未改动) + +- 端点保持原参数名与宽松解析、本期未新增时间参数:资产钱包流水、客户钱包流水、设备资产列表(`created_at_start`/`created_at_end`)、代理主钱包流水(`start_date`/`end_date`)、手机号—资产关联列表、佣金统计类接口(`commission-stats`、`commission-daily-stats`)、审计类列表、员工代收款账单、轮询告警历史、企业卡与企业设备授权列表、IoT 卡资产列表。 +- 导出场景入口保持原筛选键与宽松接受面:`device`、`iot_card`、`package`、`agent_wallet_transaction`、`refund`;达量预警导出(`package_traffic_alert`)入口与列表同样保持宽松接受面,仅执行期改为严格解析(见 2.2 行与 Requirement 4)。 +- 全局宽松解析器 `cmd/api/main.go` 的 `registerTimeParserCompat` 未改动,未列入端点继续由其服务。 +- 受影响列表端点的旧参数名现已显式拒绝(`created_at_start`/`created_at_end`、`start_date`/`end_date`、`expires_from`/`expires_to` 出现且非空即返回 1001,空值仍按未传处理),因此这些名字不会以「被忽略」的形式留在受影响端点。 +- 生成文档核对:`docs/admin-openapi.yaml` 中 `created_at_start`、`start_date` 仅残留于未改造端点与未改造的请求 DTO(`DtoBatchSetDeviceSeriesBindngRequest`、`/api/admin/devices`、`/api/admin/phone-asset-associations`、`commission-daily-stats`、主钱包流水与 `/api/open/v1/wallet/transactions`);`expires_from`/`expires_to` 在代码中只作为 `internal/handler/admin/asset.go` 的旧参数拒绝清单字符串出现(并作为改造前参数名记录在本次契约与文档中),生成文档中已完全不再出现;13 个受影响端点均只暴露 `start_time`/`end_time`。 + +## 3. DTO 与 OpenAPI 变更清单 + +统一说明:受影响端点的时间字段 Go 类型一律由 `*time.Time`(或裸 `string`)改为 `string`,由 handler 调用 `pkg/utils.ParseTimeRange` 严格解析;`*time.Time` 类型会触发全局宽松解析器,因此必须字符串化才能给出指明字段的中文错误消息。错误语义:格式非法与顺序错误统一返回既有参数非法错误码 `1001`,消息固定为 +`<字段> 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00` 与 `start_time 不能晚于 end_time`,不新增结构化字段级错误载荷。 + +| DTO | 字段(json / query tag) | 类型变更 | description 变更 | +| --- | --- | --- | --- | +| `dto.ListImportTaskRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 改为「带时区的 RFC3339 秒级时间,闭区间含该时刻」 | +| `dto.ListDeviceImportTaskRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 同上 | +| `dto.ListExportTaskRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 同上 | +| `dto.OrderListRequest` | `start_time` / `end_time` | `*time.Time` → `string` | 同上 | +| `dto.ExchangeListRequest` | `created_at_start`/`created_at_end` → `start_time`/`end_time` | `*time.Time` → `string`(参数改名) | 同上 | +| `dto.ListAssetAllocationRecordRequest` | `created_at_start`/`created_at_end` → `start_time`/`end_time` | `*time.Time` → `string`(参数改名) | 同上 | +| `dto.AgentRechargeListRequest` | `start_date`/`end_date` → `start_time`/`end_time` | `string` → `string`(参数改名,取值受限) | 由「YYYY-MM-DD」改为「带时区的 RFC3339 秒级时间」 | +| `dto.ShopCommissionRecordListReq` | 新增 `start_time` / `end_time` | 新增 `string` | 新增:佣金明细创建时间闭区间 | +| `dto.WithdrawalRequestListReq` | `start_time` / `end_time` | `string`(取值受限) | 由「2006-01-02 15:04:05」改为「带时区的 RFC3339 秒级时间」 | +| `dto.ShopWithdrawalRequestListReq` | `start_time` / `end_time` | `string`(取值受限) | 同上 | +| `dto.AuthorizationListReq` | `start_time` / `end_time` | `string`(取值受限) | 由「格式:2006-01-02」改为「带时区的 RFC3339 秒级时间,闭区间含该时刻」 | +| `dto.ExpiringAssetListRequest` | `expires_from`/`expires_to` → `start_time`/`end_time` | `string` → `string`(参数改名,取值受限) | 由「上海自然日 YYYY-MM-DD」改为「按最终到期时刻的闭区间」 | +| `dto.ExportExpiringAssetRequest` | 新增(`format`、`asset_type`、`keyword`、`shop_id`、`package_id`、`days_min`、`days_max`、`start_time`、`end_time`) | 新增 `string` 时间字段 | 新增受控导出入口请求体 | +| `dto.CreateExportTaskRequest` | `scene` 白名单与 `query` 说明 | 不变 | 白名单新增 `expiring_asset`;说明补充时间筛选键与取值要求 | +| `dto.ListExportTaskRequest` | `scene` 白名单 | 不变 | 白名单新增 `expiring_asset` | + +OpenAPI:`docs/admin-openapi.yaml` 已重新生成(`go run cmd/gendocs/main.go`),新增 `POST /api/admin/expiring-assets/export`,13 个受影响端点的时间参数已按上表更新。 + +## 4. 前端同步清单 + +无兼容期、无运行时开关:前端 MUST 与后端同迭代上线。 + +### 4.1 改参数名的端点与导出 + +| 位置 | 旧参数 | 新参数 | +| --- | --- | --- | +| `GET /api/admin/exchanges` 与换货导出(场景 `exchange`) | `created_at_start`、`created_at_end` | `start_time`、`end_time` | +| `GET /api/admin/asset-allocation-records` | `created_at_start`、`created_at_end` | `start_time`、`end_time` | +| `GET /api/admin/agent-recharges` 与代理充值导出(场景 `agent_recharge`) | `start_date`、`end_date` | `start_time`、`end_time` | +| `GET /api/admin/expiring-assets` | `expires_from`、`expires_to` | `start_time`、`end_time` | + +旧参数名在受影响端点不再生效(换货导出与代理充值导出的旧筛选键在创建期直接返回 1001)。 + +### 4.2 改传值格式的端点与导出 + +格式要求:带显式时区的 RFC3339 秒级时间,UTC 偏移写作 `Z` 或 `+08:00`;整日区间用 `T00:00:00+08:00` 与 `T23:59:59+08:00` 表达。拒绝 date-only、空格分隔、无时区、小数秒(含 `.000`)、`±hhmm` 与未补零写法。 + +| 位置 | 旧传值示例 | 新传值示例 | +| --- | --- | --- | +| `GET /api/admin/iot-cards/import-tasks` | `2026-09-01` 或 `2026-09-01 00:00:00` | `2026-09-01T00:00:00+08:00` | +| `GET /api/admin/devices/import/tasks` | 同上 | 同上 | +| `GET /api/admin/export-tasks` | 同上 | 同上 | +| `GET /api/admin/orders` 与订单导出(场景 `order`) | 同上 | 同上 | +| `GET /api/admin/authorizations` | `2026-09-01`(结束日按次日开区间) | `2026-09-01T00:00:00+08:00`(结束时间为闭区间,整日需显式写 `23:59:59`) | +| `GET /api/admin/commission/withdrawal-requests` | `2026-09-01 00:00:00` | `2026-09-01T00:00:00+08:00`;非法值由「忽略该条件返回全量」改为 1001 | +| `GET /api/admin/shops/{shop_id}/withdrawal-requests` | 同上 | 同上 | +| `GET /api/admin/expiring-assets` 与临期导出 | `2026-09-01`(上海自然日) | `2026-09-01T00:00:00+08:00`(按最终到期时刻闭区间) | +| 换货导出、代理充值导出 | 旧格式同上 | 同上 | + +注意:受影响端点的时间参数不再接受无时区写法,前端不得沿用「按浏览器本地时区补 00:00:00」的旧拼接方式,必须显式带偏移。受影响列表端点(换货、分配记录、代理充值、临期列表)携带旧参数名(`created_at_start`/`created_at_end`、`start_date`/`end_date`、`expires_from`/`expires_to`)且非空时现在返回 1001(消息:`<旧字段名> 已废弃,请改用 start_time 与 end_time`),前端必须同迭代改名,不能依赖旧参数被忽略。 + +**达量预警导出经通用入口的传值收紧(`POST /api/admin/export-tasks`,`scene=package_traffic_alert`)** + +- 位置:通用导出入口 `POST /api/admin/export-tasks`,`scene=package_traffic_alert`(达量预警导出场景)。 +- 变更:`query.filters.start_time` / `query.filters.end_time` 现要求带显式时区的 RFC3339 秒级时间(与本节上表同一格式要求);宽松写法(date-only、空格分隔、无时区、小数秒含 `.000`、`±hhmm`、未补零、前后空白)由变更前的「创建成功 + 执行期静默忽略该条件(等于不加该条件,可能导出全量)」改为**创建期直接返回 1001 参数非法**,消息与其他受影响端点同一套固定中文文案:`start_time 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00`(`end_time` 同构),开始晚于结束为 `start_time 不能晚于 end_time`。 +- 边界:达量预警的**专用入口** `POST /api/admin/package-traffic-alerts/export` 与其列表 `GET /api/admin/package-traffic-alerts` 的参数名、类型与宽松接受面**本次不变**(仍为 `*time.Time` + 全局兼容解析器,创建期归一为 UTC RFC3339 秒级后冻结);只有**执行期**改为只按冻结值严格解析,冻结值非法时任务落失败并写固定摘要 `导出筛选的时间边界非法,任务已终止`,不放行全量。 +- 依据:变更 delta 与主 Spec 的「三类异步导出的创建期冻结与不扩大范围」需求;`design.md` 已知差异登记第 5 条(无时区列那条为第 6 条、临期导出取舍为第 7 条)。 + +### 4.3 新增参数的端点与导出 + +| 位置 | 新增参数 | 说明 | +| --- | --- | --- | +| `GET /api/admin/shops/{shop_id}/commission-records` | `start_time`、`end_time` | 按佣金明细创建时间闭区间,覆盖原佣金与回溯两条分支 | +| 佣金明细导出(场景 `commission_record`) | `query.filters.start_time`、`query.filters.end_time` | 同上;经 `POST /api/admin/export-tasks` 提交 | +| 临期导出 `POST /api/admin/expiring-assets/export` | 整个入口为新增 | 请求体:`format`(必填)、`asset_type`、`keyword`、`shop_id`、`package_id`、`days_min`、`days_max`、`start_time`、`end_time` | + +## 5. 无 Schema 变更确认(T6.6) + +- 本变更未新增迁移文件、未修改任何既有迁移、未改变数据库 Schema,因此迁移 `up/down/up` 为**不适用**而非跳过。 +- 冻结值承载于既有列:`tb_export_task.query_json`(jsonb,写入规范化后的 UTC RFC3339 秒级字符串)、`scope_shop_ids`(jsonb)、操作者四元组与 `creator`/`updater`、`file_key`/`file_size`/`total_rows`/`error_message`。表头冻结沿用既有 `query_json.resolved_headers`。 +- 失败摘要沿用既有 `error_message` 通道,新增固定摘要文案 `导出筛选的时间边界非法,任务已终止`。 +- 该严格解析失败即落失败的规则同样适用于达量预警导出(场景 `package_traffic_alert`):其入口与列表的宽松接受面不变,执行期冻结值非法时同样落失败,不放行全量。 + +## 6. 实测证据(T7 实跑) + +本节由独立验证 worker 实跑写入,全部结论均有命令与原始输出支撑;未实跑项在 §6.5 逐条列明。 + +### 6.0 环境、工作区指纹与执行方式 + +| 项 | 值 | +| --- | --- | +| HEAD | `e8ab1f471e9cbf27acd89ccdcfb5073b4fc7baa1`(含维护者提交 `398a5e42`「fix(路由): 修正套餐真流量预警与资产自动续费的超管/平台 gate 作用域」与其后的 `e8ab1f47`;本 Change 的实现改动仍在未提交工作区) | +| 工作区内容指纹(本批) | `git diff` 与全部未跟踪文件内容的合并 SHA1:`37d7d69021f264be6f04e31062a18e8aa695c8a0` | +| 工作区内容指纹(上一批) | `51c619f8a3791e5c6e3de8878af3be5105dd73f`(`NAMES_FP=d1aa8d512dfb5d43e17b2ded30ae761aaca2578c`,63 项) | +| 代码状态 | 未提交工作区(本 Change 实现已落盘);维护者的路由门禁修复以提交形式位于 HEAD | +| PostgreSQL | `junhong_cmp_test`(参数取自 `.env`),库内诊断只读走 dbhub MCP;写入一律经临时 Go 工具用仓库自身 GORM/配置执行(遵循 ENG-DB-002) | +| Redis | `cxd.whcxd.cn:16299` **DB 7**(`.env.local` 默认值,与 `scripts/run-local.sh` 一致) | +| API | `go run ./cmd/api` → `http://127.0.0.1:3000`,就绪日志「服务器已启动」 | +| Worker | `go run ./cmd/worker`,就绪日志「Worker 服务器启动中」 | +| 鉴权 | 超管:`POST /api/auth/login`(口令取自 README,不回显)。代理视角:**第二批起改用 fixture 代理账号的真实登录**(`tb_account` 自建 `AUG26014AGENT*`,user_type=3,口令为 bcrypt 哈希由工具写入,不回显);手工注入 DB 7 的等价令牌仅用于第一批的根因定位对照,并已在 §6.6 明确其 `user_type` 来源 | + +**与 ENG-TEST-001 的两处偏离(均已在执行中记录):** + +1. **Redis 用 DB 7 而非 DB 6**:DB 6 是 `Iteration/8-11` 测试部署的共享队列,本地 worker 若连 DB 6 会抢占真实测试任务并消费掉它们;`.env.local` 本身即配置 DB 7,与 `scripts/run-local.sh` 的实际行为一致。导出链路的冻结、分片、合并、上传语义与队列 DB 号无关,因此该偏离不影响本次结论。 +2. **启动命令用 `go run ./cmd/api` 而非 `./scripts/run-local.sh api`**:`scripts/run-local.sh` 执行的是 `go run cmd/api/main.go`(单文件),而 `generateOpenAPIDocs` 定义在同包的另一文件 `cmd/api/docs.go`,单文件编译必然报 `cmd/api/main.go:101:2: undefined: generateOpenAPIDocs` 并退出。这是**既有仓库脚本/README 缺陷**(`scripts/run-local.sh`、`cmd/api/main.go`、`cmd/api/docs.go` 均不在本 Change 改动范围内),本 Change 未引入也未加重;改用包路径 `go run ./cmd/api` 后正常启动。建议另行登记修复。 + +### 6.1 逐项结果汇总 + +| # | tasks.md 项 | 断言数 | 结果 | +| --- | --- | --- | --- | +| 1 | 7.11/7.12 门禁命令(gofmt/build/gendocs/openspec/doctor/context-health) | 7 | **PASS 7 / FAIL 0** | +| 2 | 7.1 接受集合与拒绝集合逐条实跑 | 20 | **PASS 20 / FAIL 0** | +| 3 | 7.2 区间边界(仅一端/两端/两端缺省/空串/开始等于结束/空结果集) | 5 + 6 | **PASS 11 / FAIL 0** | +| 4 | 7.3 顺序校验(开始晚于结束) | 1 | **PASS 1 / FAIL 0** | +| 5 | 7.4 未改造端点保持既有宽松行为 | 4 | **PASS 4 / FAIL 0** | +| 6 | 7.5 提现记录非法参数不再返回全量 | 5 | **PASS 5 / FAIL 0** | +| 7 | 7.5 授权记录闭区间含两端 | 3 | **PASS 3 / FAIL 0** | +| 8 | 7.6 佣金明细列表与其导出行集一致 | 2 | **PASS 2 / FAIL 0** | +| 9 | 7.6/7.7 临期列表与其导出一致、表头、加油包不单独成行 | 5 | **PASS 5 / FAIL 0** | +| 10 | 受影响端点旧参数名拒绝 + 空值按未传(F1 修复后重跑) | 8 | **PASS 8 / FAIL 0** | +| 11 | 7.9 遗留旧格式冻结任务安全失败 | 1 | **PASS 1 / FAIL 0** | +| 12 | 7.7 达量预警导出回归(列表宽松、导出严格、表头逐字) | 5 | **PASS 5 / FAIL 0** | +| 13 | 7.10 佣金明细导出列定义与金额/余额口径回归 | 1 | **PASS 1 / FAIL 0** | +| 14 | 7.7 创建后归属变化不扩大数据集(冻结 scope 执行期生效) | 3 | **PASS 3 / FAIL 0** | +| 15 | 7.8 代理账号经 HTTP 访问临期列表/导出(第一批:修复前 403/1005) | 2 | 第一批 **FAIL**;根因定位为既有路由门禁作用域缺陷,已由维护者提交 `398a5e42` 修复 → 第二批重跑 **PASS**(见第 16 行) | +| 16 | 7.8 代理 HTTP 全链路(真实登录):可见范围过滤 / 导出创建与执行 / 空可见范围被拒 / 越权与不存在统一不可见 | 6 | **PASS 6 / FAIL 0** | +| 17 | 7.6 达量预警列表与导出同筛选行集一致(缺口补齐) | 1 | **PASS 1 / FAIL 0** | +| — | 合计 | **85 PASS / 0 FAIL** | | + +### 6.2 关键原始输出 + +**(1)门禁命令** + +> 下列 7 条在第二批(HEAD=`e8ab1f47`)已原样重跑,输出与本处记录逐字一致;T7 实现内容与第一批相同,仅有维护者的路由门禁修复提交进入 HEAD。 + +``` +$ gofmt -l <63 个改动/新增 .go 文件> # 无输出 +$ go build ./cmd/api ./cmd/worker # 退出码 0 +$ go run cmd/gendocs/main.go(连续两次) # cmp -s 一致:gendocs_idempotent=PASS +$ openspec validate add-export-time-filter-standards --strict +Change 'add-export-time-filter-standards' is valid # 退出码 0 +$ openspec validate --all +Totals: 36 passed, 0 failed (36 items) # 退出码 0 +$ openspec doctor --json +healthy= True status= [] # 退出码 0 +$ ./scripts/context-health.sh +Context 健康检查通过 # 退出码 0 +``` + +**(2)接受集合与拒绝集合(`GET /api/admin/export-tasks`)** + +接受(HTTP 200 / code 0):`2026-09-01T00:00:00Z`、`2026-09-01T00:00:00+08:00`、`2026-09-01T00:00:00-05:30`、同瞬时两种偏移组合、仅 `end_time`、空串两端、两端缺省。 + +拒绝(HTTP 400 / code 1001),消息逐条一致: + +``` +start_time 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00 +``` + +| 拒绝输入 | 归类 | +| --- | --- | +| `2026-09-01T00:00:00.000Z`、`2026-09-01T00:00:00.5+08:00` | 小数秒 | +| `2026-09-01T00:00:00` | 无时区 | +| `2026-09-01` | date-only | +| `2026-09-01 00:00:00` | 空格分隔 | +| `2026-09-01T00:00:00+0800` | `±hhmm` 偏移 | +| `2026-9-01T00:00:00Z` | 未补零日期 | +| `2026-09-01T0:00:00Z` | 未补零时间 | +| `2026-02-30T00:00:00+08:00` | 非法日历日期 | +| `2026-09-01T00:00:00+24:00`、`+23:60`、`+00:60`、`-24:00` | 偏移越界(F3 收紧后) | +| `␣2026-09-01T00:00:00+08:00␣`、纯空白串、`\t` | 空白包裹/纯空白(F3 收紧后) | + +顺序校验: + +``` +start_time=2026-09-02T00:00:00Z & end_time=2026-09-01T00:00:00Z +→ HTTP 400 code=1001 msg=start_time 不能晚于 end_time +``` + +**(3)区间边界(`GET /api/admin/export-tasks`)** + +``` +两端缺省 total=55 | 空串两端 total=55(与缺省一致) +仅 start total=3 | 仅 end total=52 | 空结果集 total=0(HTTP 200,不报错) +``` + +**(4)受影响端点旧参数名拒绝(F1 修复后重跑)** + +| 请求 | 结果 | +| --- | --- | +| `GET /api/admin/exchanges?created_at_start=2026-09-01T00:00:00+08:00` | 1001 `created_at_start 已废弃,请改用 start_time 与 end_time` | +| `GET /api/admin/asset-allocation-records?created_at_start=...` | 1001 `created_at_start 已废弃,请改用 start_time 与 end_time` | +| `GET /api/admin/agent-recharges?start_date=...` | 1001 `start_date 已废弃,请改用 start_time 与 end_time` | +| `GET /api/admin/expiring-assets?expires_from=...` | 1001 `expires_from 已废弃,请改用 start_time 与 end_time` | +| 上述四个端点的旧参数**空值**(`?start_date=`) | HTTP 200 / code 0(按未传处理) | + +**(5)未改造端点保持宽松(HTTP 200 / code 0,total 与缺省一致)** + +``` +GET /api/admin/devices?created_at_start=2026-01-01 total=6 +GET /api/admin/devices?created_at_start=2026-01-01T00:00:00 total=6 +GET /api/admin/shops/1/main-wallet/transactions?start_date=2026-01-01&end_date=2026-12-31 total=10 +GET /api/admin/audit/events total=2734186 +``` + +**(6)授权记录闭区间(`GET /api/admin/authorizations`,`iccid` 过滤隔离标记行)** + +``` +start=2026-09-17T10:00:00+08:00 & end=2026-09-17T11:00:00+08:00 → total=2(恰好命中 02:00:00 与 03:00:00 两行) +start=end=2026-09-17T10:00:00+08:00 → total=1(闭区间含 start 端) +start=end=2026-09-17T16:00:00+08:00 → total=0(区间外) +``` + +旧 date-only 值被拒:`GET /api/admin/authorizations?start_time=2026-09-17` → 1001。 + +**(7)提现记录非法参数不再返回全量** + +``` +GET /api/admin/commission/withdrawal-requests(缺省) total=5 +GET /api/admin/commission/withdrawal-requests?start_time=<合法区间> total=2 +GET /api/admin/commission/withdrawal-requests?start_time=2026-09-17 00:00:00 → 1001(非全量) +GET /api/admin/commission/withdrawal-requests?start_time=2026-09-17 → 1001 +GET /api/admin/shops/{shop_id}/withdrawal-requests?start_time=2026-09-17 00:00:00 → 1001 +GET /api/admin/shops/{shop_id}/withdrawal-requests(缺省) total=3 +``` + +**(8)佣金明细:列表 vs 导出(同筛选行集一致)** + +筛选 `start_time=2026-09-17T10:00:00+08:00`、`end_time=2026-09-17T14:00:00+08:00`;fixture 含「start−1s / ==start / start+1s / start+2h / ==end / end+1s / 8 小时漂移探针」七行原佣金 + 一行区间内回溯。 + +``` +列表 GET /api/admin/shops/{shop_id}/commission-records total=5 + 命中:02:00:00、02:00:01、04:00:00、06:00:00(原佣金)+ 03:00:00(回溯) + 排除:01:59:59(start−1s)、06:00:01(end+1s)、08:00:00(8 小时漂移探针) +导出 POST /api/admin/export-tasks{scene=commission_record} status=3 total_rows=5 +``` + +导出产物(`task 61`,UTF-8 BOM CSV): + +``` +记录来源,记录ID,代理店铺名称,关联订单号,资产标识,佣金来源,金额(元),是否可提现,状态,回溯后佣金余额(元),原佣金记录ID,来源退款单号,佣金入账时间,生成时间 +原佣金,463,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 06:00:00 +原佣金,462,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 04:00:00 +回溯明细,9,...,AUG26014ORD005,AUG26014CARD001,成本价差,-10.00,不可提现,回溯,40.00,460,AUG26014REF001,,2026-09-17 03:00:00 +原佣金,461,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 02:00:01 +原佣金,460,...,AUG26014ORD005,AUG26014CARD001,成本价差,10.00,,已发放,50.00,,,,2026-09-17 02:00:00 +``` + +结论:**列表与导出在同一筛选下行集完全一致(5 = 5),无 8 小时量级的边界漂移**(漂移探针行在两端行为一致均被排除);负数金额原样输出(`-10.00`)、入账后余额原样输出(`40.00`),表头 14 列与 `internal/exporter/commission_record_scene.go:53-59` 逐字一致。 + +**(9)临期资产:列表 vs 导出、表头、加油包** + +fixture:3 个资产(2 个同一最终到期时刻、1 个第 15 天窗口)+ 1 个挂主套餐的加油包。 + +``` +列表 GET /api/admin/expiring-assets total=3 + AUG26014CARD001 days=4 final=2026-09-21T16:00:00+08:00 + AUG26014CARD002 days=4 final=2026-09-21T16:00:00+08:00 + AUG26014CARD003 days=14 final=2026-10-01T16:00:00+08:00 +闭区间 start=end=2026-09-21T16:00:00+08:00 → total=2(同一时刻的两个资产都返回) +区间外 start=end=2026-09-22T16:00:00+08:00 → total=0 +同瞬时 Z 写法 start=end=2026-09-21T08:00:00Z → total=2 +导出 POST /api/admin/expiring-assets/export{days_min:0,days_max:15} status=3 total_rows=3 +``` + +导出产物(`task 62`): + +``` +店铺,业务员,用户组,资产类型,设备类型,设备型号,资产标识,当前套餐,到期时间,剩余天数 +AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD001,AUG26014-临期套餐,2026-09-21 16:00:00,4 +AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD002,AUG26014-临期套餐,2026-09-21 16:00:00,4 +AUG26014-临期验证店,<业务员账号名>,<用户组名>,物联网卡,,,AUG26014CARD003,AUG26014-临期套餐,2026-10-01 16:00:00,14 +``` + +结论:**表头 10 列与 `111.md` §18.1 逐列同序一致**;**加油包不单独成行**(4 条 `tb_package_usage` 行 → 3 条导出行);导出与列表同筛选下行集一致(3 = 3)。 + +**(10)遗留旧格式冻结任务安全失败(7.9)** + +fixture:`tb_export_task` 插入 `status=1`、`scene=order`、`query_json={"filters":{"start_time":"2026-09-01","end_time":"2026-09-02"}}`(date-only 旧格式),经 `export:dispatch` 投递后由本地 worker 执行: + +``` +status= 4(已失败) +error_message= '导出筛选的时间边界非法,任务已终止' # 与 constants.ExportTaskInvalidTimeFilterMessage 逐字一致 +file_key=None total_rows=0 total_shards=0 failed_shards=0 download_url=None +``` + +结论:**任务落失败、错误摘要为规定安全文案、未产出全量文件(无 file_key、无分片行)**。 + +**(11)达量预警回归(F2 修复后重跑)** + +``` +列表 GET /api/admin/package-traffic-alerts 缺省 / RFC3339+08:00 / date-only / 空格分隔 → 均 HTTP 200 code=0(宽松契约不变) +导出 POST /api/admin/export-tasks{scene=package_traffic_alert, filters={start_time,end_time 为带时区 RFC3339}} + status=3 total_rows=0(测试库 tb_package_traffic_alert 当前 0 行) +表头=资产类型,资产标识,对应标识符,卡标识,设备类型,设备型号,套餐名称,真流量已用量(MB),真流量额度(MB),比例(%),阈值快照(%),到期时间,剩余天数,触发时间,店铺,业务员,用户组,通知投递结果 +``` + +结论:**表头 18 列与改动前逐字一致**(`internal/exporter/package_traffic_alert_scene.go:50-56` 未变更,`git diff` 仅改时间筛选的执行期解析);记录粒度与触发快照口径未动;该场景无金额列,金额/余额口径回归由佣金明细导出覆盖。 + +**(12)创建后归属变化不扩大数据集(7.7 范围冻结)** + +作为 §6.2 第 13 项(代理 HTTP 全链路)之外的执行期补充,本项以「按代理创建语义直接落入 `scope_shop_ids` 的冻结任务 + 真实 worker 执行」验证归属变化对已冻结任务的影响: + +| 阶段 | 任务 | 冻结 scope | 资产位置 | 结果 | +| --- | --- | --- | --- | --- | +| 1 | `AUG26014-SCOPE-1`(id 65) | `[1,1527]` | 在 1527 | `total_rows=3` | +| 2 | `AUG26014-SCOPE-1`(id 66,重置重跑) | `[1,1527]` | 已移出至 shop 2 | `total_rows=0` | +| 对照 | 超管无范围任务(id 67) | 空(不限制) | 已移出至 shop 2 | `total_rows=3` | + +结论:**执行期只按创建时冻结的 `scope_shop_ids` 取数;资产归属变化后行集缩小而非扩大**;对照组证明归属变更确实生效(超管看到 3 行)。 + +**(13)代理账号经 HTTP 全链路(第二批补充;真实 `POST /api/auth/login` 登录,非注入令牌)** + +fixture:可见店铺 `1528`(`parent_id=1`)内 3 个临期资产 + 不可见店铺 `shop 2` 内 2 个临期资产(`AUG26014OUT001/002`);代理账号 `AUG26014AGENT`(`user_type=3`、`shop_id=1`)与本工具写入的 bcrypt 口令**真实登录**取得令牌。 + +``` +AGENT(有店铺) login ok user_type= 3 shop_id= 1 +AGENT(无店铺) login ok user_type= 3 shop_id= None +``` + +1)列表只返回可见店铺: + +``` +GET /api/admin/expiring-assets (代理令牌)http=200 code=0 total=3 + iot_card AUG26014CARD001 shop_id=1528 days=4 + iot_card AUG26014CARD002 shop_id=1528 days=4 + iot_card AUG26014CARD003 shop_id=1528 days=14 + summary={"card_count":3,"device_count":0,"total_count":3,"window_days":15} +GET /api/admin/expiring-assets (超管对照)total=5 + identifiers=[CARD001, CARD002, OUT001, OUT002, CARD003] +``` + +即:**代理看不到 `shop 2` 的 2 个范围外资产,超管能看到全部 5 个**(逐个资产标识与期望一致)。 + +2)代理创建并执行临期导出(行集 ⊆ 创建时冻结的可见店铺范围): + +``` +POST /api/admin/expiring-assets/export {format:csv,days_min:0,days_max:15} + http=200 code=0 → task_id=68 task_no=EXP-20260917-960000 status=1 +task 68 → status=3 total_rows=3(与代理列表 total=3 一致) +库内冻结行长(dbhub 只读): + id=68 scene=expiring_asset creator_user_id=683 creator_user_type=3 creator_shop_id=1 + scope_shop_ids=[1, 6, 1528] # 代理 1 号店的下级树快照 + query_json={"filters":{"days_max":15,"days_min":0},"resolved_headers":["店铺","业务员","用户组","资产类型","设备类型","设备型号","资产标识","当前套餐","到期时间","剩余天数"]} +导出产物:3 行,资产标识为 CARD001/CARD002/CARD003(不含 OUT001/OUT002) +``` + +3)空可见范围(代理无店铺)创建被拒: + +``` +GET /api/admin/expiring-assets (shop_id=NULL 的 fixture 代理)http=200 code=0 total=0 +POST /api/admin/expiring-assets/export (同上) http=403 code=1005 msg=代理账号缺少店铺信息 +``` + +4)越权与不存在统一不可见(同一代理令牌): + +``` +GET /api/admin/export-tasks/1 → http=403 code=1005 msg=无权限操作该资源或资源不存在 +GET /api/admin/export-tasks/999999 → http=403 code=1005 msg=无权限操作该资源或资源不存在 +一致: True +``` + +**(14)达量预警列表 vs 导出同筛选行集一致(7.6 缺口补齐)** + +fixture 直接写入 `tb_package_traffic_alert` 3 条快照(2 条 `triggered_at` 在 `[2026-09-01T00:00:00Z, 2026-09-30T23:59:59Z]` 内、1 条在 2026-10-05 窗外),同筛选分别调列表与导出: + +``` +GET /api/admin/package-traffic-alerts?start_time=2026-09-01T00:00:00Z&end_time=2026-09-30T23:59:59Z total=2 +POST /api/admin/export-tasks{scene:package_traffic_alert, filters 同上} → task 69 status=3 total_rows=2 + (库内冻结行 69:scope_shop_ids=[],query_json.filters 已规范化为 UTC RFC3339) +导出产物: +资产类型,资产标识,对应标识符,卡标识,设备类型,设备型号,套餐名称,真流量已用量(MB),真流量额度(MB),比例(%),阈值快照(%),到期时间,剩余天数,触发时间,店铺,业务员,用户组,通知投递结果 +物联网卡,AUG26014CARD001,,,,,AUG26014-临期套餐,1500,2000,75.00,80.00,,,2026-09-20 20:00:00,AUG26014-临期验证店,<业务员账号名>,<用户组名>,待投递 +物联网卡,AUG26014CARD001,,,,,AUG26014-临期套餐,1500,2000,75.00,70.00,,,2026-09-15 08:00:00,AUG26014-临期验证店,<业务员账号名>,<用户组名>,待投递 +``` + +结论:**列表 total=2 == 导出 total_rows=2,窗外那条被两端一致排除**;触发快照列(用量/额度/比例/阈值)原样输出,未被执行期改动影响。 + +### 6.3 fixture 清单与清理结果 + +第一批(§6.2 第 1–12 项): + +| 表 | 标记方式 | 造行数 | 用途 | +| --- | --- | --- | --- | +| `tb_shop` | `shop_name LIKE 'AUG26014%'` | 1 | 临期资产归属店铺(`parent_id=1`,供代理可见链路) | +| `tb_iot_card` | `iccid LIKE 'AUG26014%'` | 3 | 临期资产 | +| `tb_package_usage` | `package_name LIKE 'AUG26014%'` | 4(3 主套餐 + 1 加油包) | 最终到期推算 / 加油包不单独成行 | +| `tb_order` | `order_no LIKE 'AUG26014%'` | 6 | 佣金明细关联订单号与 `(order_id,package_id)` 唯一索引避让 | +| `tb_commission_record` | `remark LIKE 'AUG26014%'` | 7 | 佣金闭区间两端 + 1 秒内 + 4 小时内 + 8 小时漂移探针 | +| `tb_commission_clawback_record` | `refund_no LIKE 'AUG26014%'` | 1 | 回溯分支负金额口径 | +| `tb_enterprise_card_authorization` | `remark LIKE 'AUG26014%'` | 3 | 授权闭区间三态(等于 start / 等于 end / 区间外,整秒) | +| `tb_commission_withdrawal_request` | `withdrawal_no LIKE 'AUG26014%'` | 3 | 提现闭区间三态(整秒) | +| `tb_export_task` / `tb_export_shard_task` | `task_no LIKE 'AUG26014%'` | 遗留任务 1 + 冻结范围任务 1 | 7.9 遗留安全、范围冻结 | + +第二批(§6.2 第 13–14 项): + +| 表 | 标记方式 | 造行数 | 用途 | +| --- | --- | --- | --- | +| `tb_iot_card`(范围外) | `iccid LIKE 'AUG26014OUT%'`(`shop_id=2`) | 2 | 代理可见范围对照(不得出现在代理列表/导出) | +| `tb_order`(范围外) | `order_no LIKE 'AUG26014OUTORD%'` | 2 | 同上(订单与套餐使用的外键上下文) | +| `tb_package_usage`(范围外) | `package_name LIKE 'AUG26014-范围外套餐'` | 2 | 同上 | +| `tb_account` | `username LIKE 'AUG26014%'` | 2 | fixture 代理账号:`AUG26014AGENT`(`shop_id=1`)、`AUG26014AGENTNOSHOP`(`shop_id=NULL`);真实登录用,口令 bcrypt 由工具写入且不回显 | +| `tb_package_traffic_alert` | `notification_event_id LIKE 'AUG26014%'` | 3(2 条窗内 + 1 条窗外) | 达量预警列表与导出同筛选行集一致 | + +**清理结果**(`cleanup` 按标记删除 + `cleanup-tasks` 删除本次经 HTTP 创建的无标记导出任务): + +``` +CLEANUP_DONE +COUNT tb_shop=0 COUNT tb_iot_card=0 COUNT tb_package_usage=0 +COUNT tb_order=0 COUNT tb_account=0 COUNT tb_package_traffic_alert=0 +COUNT tb_export_task=0(标记任务) COUNT tb_export_shard_task=0(标记分片) +DELETED_TASKS=6 ids=58,59,61,62,64,67 # 第一批 +DELETED_TASKS=2 ids=68,69 # 第二批(代理导出、达量预警导出) +``` + +清理后库内基线回到验证前状态(dbhub 只读复核): + +``` +tb_export_task = 52 tb_export_shard_task = 48 id > 52 的导出任务 = 0 +AUG26014 标记:tb_shop / tb_iot_card / tb_package_usage / tb_order / tb_account / + tb_package_traffic_alert / tb_export_task 计数全部 = 0 +``` + +### 6.4 证据与代码版本的对应 + +| 证据批次 | 时间 | HEAD / 指纹 | 说明 | +| --- | --- | --- | --- | +| 预演(T1/T2/T3/T7 首轮) | 17:26–17:37 | `d52be16` / 修复前二进制 | 仅作预演记录;本文件引用的有效证据全部为修复后批次 | +| 第一批(§6.2 第 1–12 项) | 17:39–17:47 | `d52be16` / `51c619f8…` | T7 实现尚未提交;当时 HEAD 尚不含维护者的路由门禁修复,故代理 HTTP 项为 403/1005 | +| 第二批(§6.2 第 13–14 项、§6.1 第 15–17 行) | 18:0x–18:1x | `e8ab1f47`(含 `398a5e42`)/ `37d7d690…` | 仅多出维护者对 `internal/routes/{package_traffic_alert,asset_auto_renewal}.go` 的门禁作用域修复提交;T7 实现内容与第一批一致 | + +### 6.5 未验证项(环境限制) + +1. **「代理可见店铺为空列表」这一极端分支**:未单独构造。`GetSubordinateShopIDs` 恒包含账号自身店铺(`internal/store/postgres/shop_store.go:162-192` 以 `ids := []uint{shopID}` 起始),因此「非零 shop_id 且可见项为空」在正常数据下不可达;已用 `shop_id=NULL` 的 fixture 代理账号覆盖最近的等价分支(创建导出被拒 `403 / 1005 代理账号缺少店铺信息`,见 §6.2 第 13 项第 3 段)。 +2. **达量预警「真实扫描链路」产生的行级口径**:本批以 fixture 直接写入 `tb_package_traffic_alert` 快照行,验证了列表与导出同筛选行集一致(2=2)与快照列原样输出;未跑真实的每日扫描任务(需有效套餐 + 真流量累计 + 通知链路,代价高且与本 Change 无关)。 +3. **`tb_export_task` 中「旧格式冻结值」的历史存量**:清理前全库实测 0 条(`count(*) WHERE query_json::text ~ '[0-9]{4}-[0-9]{2}-[0-9]{2}[ T]'` = 0),7.9 因此用自建 fixture 覆盖。 +4. **迁移 `up/down/up`**:本变更无 Schema 变更(§5),按设计为「不适用」。 + +### 6.6 既有缺陷 D1:路由门禁作用域(已定位,已由维护者提交 `398a5e42` 修复) + +**现象(第一批,HEAD 尚为 `d52be16`、不含修复)**:代理账号访问 `/api/admin/expiring-assets` 与新增的 `/api/admin/expiring-assets/export` 一律返回 403 / `1005 无权限操作该资源或资源不存在`,与路由注释「后台和代理共用」及路由描述「企业账号禁止调用」矛盾。原始输出: + +``` +$ curl -s -H "Authorization: Bearer " 'http://127.0.0.1:3000/api/admin/expiring-assets?page=1&page_size=1' +{"code":1005,"data":null,"msg":"无权限操作该资源或资源不存在"} + +$ curl -s -X POST -H "Authorization: Bearer " -H 'Content-Type: application/json' \ + -d '{"format":"csv"}' 'http://127.0.0.1:3000/api/admin/expiring-assets/export' +{"code":1005,"data":null,"msg":"无权限操作该资源或资源不存在"} +``` + +定位过程中的门禁键隔离实验(**注入令牌的 `user_type` 由验证者显式写入 Redis 值决定**,非账号真实类型):同一 `user_id=144` 以 `user_type=2` 注入 → 200,以 `user_type=3` 注入 → 1005;`user_id=148`(真实代理)以 `user_type=3` 注入 → 1005;平台 `user_id=149`(**平台账号,`user_type=2`,非代理**)以 `user_type=2` 注入 → 200。旁证:`user_type=3` 的令牌访问任一不存在的 `/api/admin/*` 路径同样 1005(超管/平台为 `1006 资源未找到`),说明拦截发生在其后的路由之前。 + +**根因(已由维护者提交证实)**:`internal/routes/package_traffic_alert.go` 与 `internal/routes/asset_auto_renewal.go` 原以 `router.Group("", gate)` 在管理端根组注册「仅超管/平台」门禁。Fiber 的 `Group(prefix, handlers...)` 会把该处理器注册为 `path=/api/admin` 的 USE 处理器并匹配**整个 `/api/admin` 前缀**,且按注册顺序先于其后注册的路由执行,因此 `internal/routes/admin.go` 中位于 `:107`(package-traffic-alert)与 `:110`(asset-auto-renewal)之后注册的全部后台接口(含 `:125` admin exchanges、`:149` `/assets/*`、`:150` `/expiring-assets` 与新增 `/expiring-assets/export`、`:165` agent-recharges,以及 `:122` admin orders)对代理/企业账号一律 403-1005;早于该点的 `:36/:52/:67/:71/:74/:83/:89/:101` 不受影响——与实测 200/1005 的分界完全吻合。 + +**修复(维护者提交 `398a5e42`,2026-09-17 17:49:33,不在本 Change 改动内)**:`git show 398a5e42` 将两处门禁改为挂在各自功能前缀组上(`asset_auto_renewal.go` → `router.Group("/asset-auto-renewal-config", …)`;`package_traffic_alert.go` → `router.Group("/package-traffic-alert-rules", gate)` 与 `router.Group("/package-traffic-alerts", gate)`),并在注释中写明「gate 必须挂在功能路径组上:Fiber 的组中间件按路径前缀生效,挂在空路径组上会落到 /api/admin 前缀」。该修复与本文件定位的机制一致。 + +**修复后复验(第二批,HEAD=`e8ab1f47`)**:代理账号经真实登录访问同一端点已可达,列表只返回可见店铺(3 行,不含范围外 2 个资产),导出创建并执行为 3 行,详见 §6.2 第 13 项;本 Change 的代理视角验收因此在本批全部通过。 + +**归类**:既有实现缺陷(**非本 Change 引入**),**已由维护者在 HEAD 修复**;本 Change 无需改动,亦未被该缺陷遗留影响。验证的第一批 2 个 FAIL(`§6.1` 第 15 行)为修复前版本的照实记录,已保留。 + + +## 7. 实现期发现(需 director 决策,未在本次改动) + +`tb_export_task`、`tb_commission_record`、`tb_commission_withdrawal_request`、`tb_agent_recharge_record`、`tb_enterprise_card_authorization` 等列的时间列为 PostgreSQL `timestamp without time zone`,其中保存的是应用本地时区(部署容器 `TZ=Asia/Shanghai`)的**挂钟时间**;`tb_order`、`tb_exchange_order`、`tb_asset_allocation_record` 为 `timestamp with time zone`。测试库实测(dbhub 只读查询): + +```sql +SELECT o.created_at AS order_created_tz, c.created_at AS commission_created_naive, (c.created_at - o.created_at) AS delta +FROM tb_commission_record c JOIN tb_order o ON o.id = c.order_id ORDER BY c.id DESC LIMIT 1; +-- 2026-08-25 08:08:15.00218+00 | 2026-08-25 16:08:20.158216 | {"hours": 8, "seconds": 5, ...} +``` + +即同一笔业务事实在无时区列上的挂钟时间比带时区列的快照早 8 小时。驱动在绑定 `time.Time` 到无时区列时会丢弃时区(仅保留挂钟字段),因此统一按「归一后的 UTC 瞬时」绑定后,无时区列上的有效边界为该瞬时的 UTC 挂钟值。 + +本次实现按 spec「解析结果 MUST 归一为 UTC 瞬时」与唯一合规先例(`internal/exporter/package_traffic_alert_scene.go` 的 `startTime.UTC()`)原样绑定,未引入额外的时区换算;列表与导出使用同一解析结果,因此同筛选下行集一致。无时区列上的「用户本地整日区间」与「UTC 挂钟边界」之间的差值属本变更范围外的既有事实。 + +director 决定: + +- 该事实**已登记**为本变更产物的已知差异,见 [`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md) 第 6 条「无时区列的时间锚定(本次不改)」。 +- 本次**不改**:统一按 spec 的「归一为 UTC 瞬时」绑定,列表与导出同向同量、有效边界一致,已由本文件 §6.2 第 8 项(含 8 小时漂移探针)实测确认。 +- 若产品期望「按真实发生瞬时的上海自然日」筛选,属**另立 Change** 的范围(涉及 8 张无时区列的口径与历史数据)。 + +## 8. 相关文件 + +- 变更产物:[`../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/`](../../openspec/changes/archive/2026-09-17-add-export-time-filter-standards/) +- 行为契约:[`../../openspec/specs/export-time-filter/spec.md`](../../openspec/specs/export-time-filter/spec.md) +- 上下文证据:[`./context-reset/requirement-evidence.json`](./context-reset/requirement-evidence.json)、[`./context-reset/entry-capability-requirement-matrix.json`](./context-reset/entry-capability-requirement-matrix.json) +- 工程门禁:[`../engineering/工程约束.md`](../engineering/工程约束.md) +- 同类体例参考:[`./add-priority-polling-queue-verification.md`](./add-priority-polling-queue-verification.md) diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 01780ba..5dc051c 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -4471,5 +4471,17 @@ "asset-auto-renewal::成功后的可靠复机" ], "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/expiring-assets/export", + "capability": "export-time-filter", + "requirements": [ + "export-time-filter::统一时间筛选参数与解析契约", + "export-time-filter::受影响端点与旧格式替换", + "export-time-filter::临期导出", + "export-time-filter::三类异步导出的创建期冻结与不扩大范围" + ], + "classification": "behavior" } ] diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index 288064d..61abc3e 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -4830,5 +4830,194 @@ ], "exit_status": 0 } + }, + { + "capability": "export-time-filter", + "requirement": "统一时间筛选参数与解析契约", + "spec": "openspec/specs/export-time-filter/spec.md", + "entries": [ + "/api/admin/iot-cards/import-tasks", + "/api/admin/devices/import/tasks", + "/api/admin/export-tasks", + "/api/admin/orders", + "/api/admin/exchanges", + "/api/admin/asset-allocation-records", + "/api/admin/agent-recharges", + "/api/admin/shops/{shop_id}/commission-records", + "/api/admin/commission/withdrawal-requests", + "/api/admin/shops/{shop_id}/withdrawal-requests", + "/api/admin/authorizations", + "/api/admin/expiring-assets" + ], + "handler_consumer_job": [ + "internal/handler/admin/exchange.go", + "internal/handler/admin/asset.go", + "internal/handler/admin/authorization.go", + "internal/model/dto/export_task_dto.go" + ], + "application_service_query": [ + "internal/query/packageexpiry/list.go", + "internal/query/exchange/list.go" + ], + "domain_state_amount": [ + "pkg/utils/time_range.go" + ], + "store_migration_config": [ + "internal/store/postgres/commission_record_store.go" + ], + "verification": { + "command": "rg -n 'timeFilterLayout|timeFilterPattern|func ParseTimeRange|FormatTimeFilterValue|TimeFilterFormatError|timeFilterFormatMessageSuffix|timeFilterOrderMessage' pkg/utils/time_range.go", + "literal_output": [ + "pkg/utils/time_range.go:21:const timeFilterLayout = \"2006-01-02T15:04:05Z07:00\"", + "pkg/utils/time_range.go:24:const timeFilterFormatMessageSuffix = \" 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00\"", + "pkg/utils/time_range.go:27:const timeFilterOrderMessage = \"start_time 不能晚于 end_time\"", + "pkg/utils/time_range.go:34:func ParseTimeRange(start, end string) (*time.Time, *time.Time, error) {", + "pkg/utils/time_range.go:50:func FormatTimeFilterValue(value time.Time) string {", + "pkg/utils/time_range.go:55:func TimeFilterFormatError(field string) error {", + "pkg/utils/time_range.go:63:var timeFilterPattern = regexp.MustCompile(`^\\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\\d|3[01])T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$`)" + ], + "exit_status": 0 + } + }, + { + "capability": "export-time-filter", + "requirement": "受影响端点与旧格式替换", + "spec": "openspec/specs/export-time-filter/spec.md", + "entries": [ + "/api/admin/exchanges", + "/api/admin/asset-allocation-records", + "/api/admin/agent-recharges", + "/api/admin/shops/{shop_id}/commission-records", + "/api/admin/commission/withdrawal-requests", + "/api/admin/shops/{shop_id}/withdrawal-requests", + "/api/admin/authorizations", + "/api/admin/expiring-assets" + ], + "handler_consumer_job": [ + "internal/handler/admin/exchange.go", + "internal/handler/admin/agent_recharge.go", + "internal/handler/admin/authorization.go", + "internal/handler/admin/asset.go" + ], + "application_service_query": [ + "internal/query/exchange/list.go", + "internal/service/agent_recharge/service.go", + "internal/service/commission_withdrawal/service.go", + "internal/service/shop_commission/service.go", + "internal/query/packageexpiry/list.go" + ], + "domain_state_amount": [ + "internal/exporter/time_filters.go" + ], + "store_migration_config": [ + "internal/store/postgres/enterprise_card_authorization_store.go", + "internal/store/postgres/asset_allocation_record_store.go" + ], + "verification": { + "command": "rg -n -e 'start_time' -e 'strictTimeRange' -e 'authorized_at <= ?' -e '闭区间' internal/model/dto/agent_recharge_dto.go internal/model/dto/exchange_dto.go internal/service/commission_withdrawal/service.go internal/service/shop_commission/service.go internal/exporter/exchange_scene.go internal/exporter/agent_recharge_scene.go internal/exporter/order_scene.go internal/query/packageexpiry/list.go internal/store/postgres/enterprise_card_authorization_store.go internal/store/postgres/asset_allocation_record_store.go", + "literal_output": [ + "internal/exporter/exchange_scene.go:124:\tstart, end, err := strictTimeRange(params.Filters)", + "internal/exporter/agent_recharge_scene.go:139:\tstart, end, err := strictTimeRange(params.Filters)", + "internal/model/dto/agent_recharge_dto.go:140:\tStartTime string `json:\"start_time\" query:\"start_time\" description:\"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)\"`", + "internal/query/packageexpiry/list.go:72:\t\tStartTime: startTime,", + "internal/query/packageexpiry/list.go:241:\t\t// 按当前生效主套餐的最终到期时刻做闭区间比较,含两端。", + "internal/model/dto/exchange_dto.go:24:\tStartTime string `json:\"start_time\" query:\"start_time\" description:\"创建时间起始(带时区的 RFC3339 秒级时间,闭区间含该时刻,如 2026-09-01T00:00:00+08:00)\"`", + "internal/exporter/order_scene.go:151:\tstart, end, err := strictTimeRange(params.Filters)", + "internal/store/postgres/enterprise_card_authorization_store.go:347:\t\tbaseQuery += \" AND a.authorized_at <= ?\"", + "internal/store/postgres/asset_allocation_record_store.go:81:\tif createdAtStart, ok := filters[\"start_time\"].(time.Time); ok {", + "internal/store/postgres/asset_allocation_record_store.go:84:\tif createdAtEnd, ok := filters[\"end_time\"].(time.Time); ok {", + "internal/service/commission_withdrawal/service.go:68:\t\tStartTime: startTime,", + "internal/service/shop_commission/service.go:103:\t\tStartTime: startTime," + ], + "exit_status": 0 + } + }, + { + "capability": "export-time-filter", + "requirement": "临期导出", + "spec": "openspec/specs/export-time-filter/spec.md", + "entries": [ + "/api/admin/expiring-assets", + "/api/admin/expiring-assets/export", + "/api/admin/export-tasks" + ], + "handler_consumer_job": [ + "internal/handler/admin/asset.go", + "internal/routes/package_expiry.go" + ], + "application_service_query": [ + "internal/query/packageexpiry/list.go", + "internal/exporter/expiring_asset_scene.go" + ], + "domain_state_amount": [ + "internal/exporter/ownership_columns.go" + ], + "store_migration_config": [ + "internal/exporter/registry.go" + ], + "verification": { + "command": "rg -n -e 'ExportTaskSceneExpiringAsset' -e 'ListAllWithScope' -e 'expiring-assets/export' -e 'rejectLegacyTimeParams' pkg/constants/constants.go internal/exporter/expiring_asset_scene.go internal/query/packageexpiry/list.go internal/routes/package_expiry.go internal/exporter/registry.go internal/handler/admin/asset.go internal/handler/admin/time_params.go", + "literal_output": [ + "internal/handler/admin/time_params.go:9:// rejectLegacyTimeParams 拒绝受影响端点上已废弃的旧时间参数名。", + "internal/handler/admin/time_params.go:12:func rejectLegacyTimeParams(c *fiber.Ctx, keys ...string) error {", + "internal/exporter/registry.go:41:\t\tNewExpiringAssetDataSource(db),", + "internal/exporter/registry.go:80:\t\tconstants.ExportTaskSceneExpiringAsset:", + "internal/handler/admin/asset.go:161:\tif err := rejectLegacyTimeParams(c, \"expires_from\", \"expires_to\"); err != nil {", + "internal/handler/admin/asset.go:186:// POST /api/admin/expiring-assets/export", + "internal/handler/admin/asset.go:204:\t\tScene: constants.ExportTaskSceneExpiringAsset,", + "internal/routes/package_expiry.go:22:\tRegister(router, doc, basePath, \"POST\", \"/expiring-assets/export\", handler.ExportExpiring, RouteSpec{", + "internal/query/packageexpiry/list.go:92:// ListAllWithScope 按调用方给定的数据范围与筛选返回全部临期资产。", + "internal/query/packageexpiry/list.go:94:func (q *Query) ListAllWithScope(ctx context.Context, scope ScopeApplier, filter ListFilter) ([]dto.ExpiringAssetItem, error) {", + "internal/exporter/expiring_asset_scene.go:25:// NewExpiringAssetDataSource 创建临期资产导出数据源。", + "internal/exporter/expiring_asset_scene.go:26:func NewExpiringAssetDataSource(db *gorm.DB) *ExpiringAssetDataSource {", + "internal/exporter/expiring_asset_scene.go:32:\treturn constants.ExportTaskSceneExpiringAsset", + "internal/exporter/expiring_asset_scene.go:47:\t\t\"店铺\", \"业务员\", \"用户组\", \"资产类型\", \"设备类型\", \"设备型号\", \"资产标识\", \"当前套餐\", \"到期时间\", \"剩余天数\",", + "internal/exporter/expiring_asset_scene.go:144:\treturn s.expiryQ.ListAllWithScope(ctx, scope, filter)", + "pkg/constants/constants.go:402:\t// ExportTaskSceneExpiringAsset 表示临期资产导出场景,一行对应一项资产,取当前生效主套餐最终到期时间。", + "pkg/constants/constants.go:403:\tExportTaskSceneExpiringAsset = \"expiring_asset\"" + ], + "exit_status": 0 + } + }, + { + "capability": "export-time-filter", + "requirement": "三类异步导出的创建期冻结与不扩大范围", + "spec": "openspec/specs/export-time-filter/spec.md", + "entries": [ + "/api/admin/export-tasks", + "/api/admin/expiring-assets/export", + "/api/admin/package-traffic-alerts/export" + ], + "handler_consumer_job": [ + "internal/handler/admin/export_task.go", + "internal/handler/admin/package_traffic_alert.go" + ], + "application_service_query": [ + "internal/service/export_task/service.go", + "internal/exporter/time_filters.go" + ], + "domain_state_amount": [ + "internal/task/export_dispatch.go" + ], + "store_migration_config": [ + "internal/store/postgres/export_task_store.go" + ], + "verification": { + "command": "rg -n -e 'NormalizeTaskTimeFilters' -e 'ValidateTaskTimeFilters' -e 'ExportTaskInvalidTimeFilterMessage' -e '当前账号无可导出的数据范围' -e 'strictTimeRange' internal/service/export_task/service.go internal/exporter/time_filters.go internal/task/export_dispatch.go pkg/constants/constants.go internal/exporter/package_traffic_alert_scene.go", + "literal_output": [ + "internal/exporter/package_traffic_alert_scene.go:179:\tstartTime, endTime, err := strictTimeRange(params.Filters)", + "internal/task/export_dispatch.go:123:\tif err := exporter.ValidateTaskTimeFilters(exportTask.Scene, params.Filters); err != nil {", + "internal/task/export_dispatch.go:125:\t\t_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, constants.ExportTaskInvalidTimeFilterMessage)", + "internal/exporter/time_filters.go:37:// NormalizeTaskTimeFilters 在创建导出任务时按场景校验并规范化时间边界。", + "internal/exporter/time_filters.go:40:func NormalizeTaskTimeFilters(scene string, query map[string]interface{}) error {", + "internal/exporter/time_filters.go:64:// ValidateTaskTimeFilters 校验导出任务筛选快照中的时间边界,创建期与执行期共用。", + "internal/exporter/time_filters.go:67:func ValidateTaskTimeFilters(scene string, filters map[string]any) error {", + "pkg/constants/constants.go:406:// ExportTaskInvalidTimeFilterMessage 是导出任务冻结的时间边界非法时写入 error_message 的安全失败摘要。", + "pkg/constants/constants.go:408:const ExportTaskInvalidTimeFilterMessage = \"导出筛选的时间边界非法,任务已终止\"", + "internal/service/export_task/service.go:78:\tif err := exporter.NormalizeTaskTimeFilters(req.Scene, req.Query); err != nil {", + "internal/service/export_task/service.go:115:\t\t\treturn nil, errors.New(errors.CodeForbidden, \"当前账号无可导出的数据范围\")" + ], + "exit_status": 0 + } } ] diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 89bb37c..bb76ed2 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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), diff --git a/internal/exporter/agent_recharge_scene.go b/internal/exporter/agent_recharge_scene.go index b341d81..123e715 100644 --- a/internal/exporter/agent_recharge_scene.go +++ b/internal/exporter/agent_recharge_scene.go @@ -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 { diff --git a/internal/exporter/commission_record_scene.go b/internal/exporter/commission_record_scene.go index 6359c21..ab4a561 100644 --- a/internal/exporter/commission_record_scene.go +++ b/internal/exporter/commission_record_scene.go @@ -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 是佣金明细导出的合并行投影,金额一律保持分。 diff --git a/internal/exporter/exchange_scene.go b/internal/exporter/exchange_scene.go index 6f4c9c4..6ca05ee 100644 --- a/internal/exporter/exchange_scene.go +++ b/internal/exporter/exchange_scene.go @@ -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 { diff --git a/internal/exporter/expiring_asset_scene.go b/internal/exporter/expiring_asset_scene.go new file mode 100644 index 0000000..c464d4e --- /dev/null +++ b/internal/exporter/expiring_asset_scene.go @@ -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) +} diff --git a/internal/exporter/order_scene.go b/internal/exporter/order_scene.go index b083904..ac351a0 100644 --- a/internal/exporter/order_scene.go +++ b/internal/exporter/order_scene.go @@ -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) diff --git a/internal/exporter/ownership_columns.go b/internal/exporter/ownership_columns.go new file mode 100644 index 0000000..85435dd --- /dev/null +++ b/internal/exporter/ownership_columns.go @@ -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 +} diff --git a/internal/exporter/package_traffic_alert_scene.go b/internal/exporter/package_traffic_alert_scene.go index 32173f3..321c6a5 100644 --- a/internal/exporter/package_traffic_alert_scene.go +++ b/internal/exporter/package_traffic_alert_scene.go @@ -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 是预警导出的一行原始投影。 diff --git a/internal/exporter/registry.go b/internal/exporter/registry.go index 266316e..712814a 100644 --- a/internal/exporter/registry.go +++ b/internal/exporter/registry.go @@ -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 diff --git a/internal/exporter/time_filters.go b/internal/exporter/time_filters.go new file mode 100644 index 0000000..7b4d0ed --- /dev/null +++ b/internal/exporter/time_filters.go @@ -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 +} diff --git a/internal/handler/admin/agent_recharge.go b/internal/handler/admin/agent_recharge.go index 5d7f2b9..c59a463 100644 --- a/internal/handler/admin/agent_recharge.go +++ b/internal/handler/admin/agent_recharge.go @@ -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 } diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 44288ef..e8b5d3d 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -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 { diff --git a/internal/handler/admin/asset_allocation_record.go b/internal/handler/admin/asset_allocation_record.go index de42e6d..1ef03bd 100644 --- a/internal/handler/admin/asset_allocation_record.go +++ b/internal/handler/admin/asset_allocation_record.go @@ -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 } diff --git a/internal/handler/admin/authorization.go b/internal/handler/admin/authorization.go index 4688068..275e012 100644 --- a/internal/handler/admin/authorization.go +++ b/internal/handler/admin/authorization.go @@ -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, }) diff --git a/internal/handler/admin/commission_withdrawal.go b/internal/handler/admin/commission_withdrawal.go index 0473ebb..a7c61c2 100644 --- a/internal/handler/admin/commission_withdrawal.go +++ b/internal/handler/admin/commission_withdrawal.go @@ -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 } diff --git a/internal/handler/admin/device_import.go b/internal/handler/admin/device_import.go index a748716..bf70f5b 100644 --- a/internal/handler/admin/device_import.go +++ b/internal/handler/admin/device_import.go @@ -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 } diff --git a/internal/handler/admin/exchange.go b/internal/handler/admin/exchange.go index 72e9651..d348f0b 100644 --- a/internal/handler/admin/exchange.go +++ b/internal/handler/admin/exchange.go @@ -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 } diff --git a/internal/handler/admin/export_task.go b/internal/handler/admin/export_task.go index f09e515..3529ea1 100644 --- a/internal/handler/admin/export_task.go +++ b/internal/handler/admin/export_task.go @@ -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 } diff --git a/internal/handler/admin/iot_card_import.go b/internal/handler/admin/iot_card_import.go index ddb9f9a..f164e56 100644 --- a/internal/handler/admin/iot_card_import.go +++ b/internal/handler/admin/iot_card_import.go @@ -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 } diff --git a/internal/handler/admin/order.go b/internal/handler/admin/order.go index 01dc512..2297b25 100644 --- a/internal/handler/admin/order.go +++ b/internal/handler/admin/order.go @@ -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 } diff --git a/internal/handler/admin/shop_commission.go b/internal/handler/admin/shop_commission.go index cb07295..2201fbd 100644 --- a/internal/handler/admin/shop_commission.go +++ b/internal/handler/admin/shop_commission.go @@ -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 } diff --git a/internal/handler/admin/time_params.go b/internal/handler/admin/time_params.go new file mode 100644 index 0000000..8e6d4d4 --- /dev/null +++ b/internal/handler/admin/time_params.go @@ -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 +} diff --git a/internal/model/dto/agent_recharge_dto.go b/internal/model/dto/agent_recharge_dto.go index e53c518..9015c23 100644 --- a/internal/model/dto/agent_recharge_dto.go +++ b/internal/model/dto/agent_recharge_dto.go @@ -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 代理充值记录列表响应 diff --git a/internal/model/dto/asset_allocation_record_dto.go b/internal/model/dto/asset_allocation_record_dto.go index 21075c3..3cd8347 100644 --- a/internal/model/dto/asset_allocation_record_dto.go +++ b/internal/model/dto/asset_allocation_record_dto.go @@ -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 分配记录响应 diff --git a/internal/model/dto/authorization_dto.go b/internal/model/dto/authorization_dto.go index 7a85756..7005c7f 100644 --- a/internal/model/dto/authorization_dto.go +++ b/internal/model/dto/authorization_dto.go @@ -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 { diff --git a/internal/model/dto/commission_withdrawal_dto.go b/internal/model/dto/commission_withdrawal_dto.go index 8268c5c..b4a5bc4 100644 --- a/internal/model/dto/commission_withdrawal_dto.go +++ b/internal/model/dto/commission_withdrawal_dto.go @@ -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 提现申请列表项 diff --git a/internal/model/dto/device_import_dto.go b/internal/model/dto/device_import_dto.go index c9a2c6b..76025cb 100644 --- a/internal/model/dto/device_import_dto.go +++ b/internal/model/dto/device_import_dto.go @@ -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 { diff --git a/internal/model/dto/exchange_dto.go b/internal/model/dto/exchange_dto.go index 6c4764d..24b19ca 100644 --- a/internal/model/dto/exchange_dto.go +++ b/internal/model/dto/exchange_dto.go @@ -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 换货发货请求。 diff --git a/internal/model/dto/export_task_dto.go b/internal/model/dto/export_task_dto.go index 5fa7109..efdafde 100644 --- a/internal/model/dto/export_task_dto.go +++ b/internal/model/dto/export_task_dto.go @@ -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 导出任务列表项。 diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index 59ec2b8..82fbda6 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -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 { diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index a2c07ac..c1f3ee5 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -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 { diff --git a/internal/model/dto/package_expiry_dto.go b/internal/model/dto/package_expiry_dto.go index e00aff0..122d1a2 100644 --- a/internal/model/dto/package_expiry_dto.go +++ b/internal/model/dto/package_expiry_dto.go @@ -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 临期资产列表项。 diff --git a/internal/model/dto/shop_commission_dto.go b/internal/model/dto/shop_commission_dto.go index 62bfd76..df0c8a1 100644 --- a/internal/model/dto/shop_commission_dto.go +++ b/internal/model/dto/shop_commission_dto.go @@ -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:待人工修正)"` } diff --git a/internal/query/exchange/list.go b/internal/query/exchange/list.go index f90cf2d..9283119 100644 --- a/internal/query/exchange/list.go +++ b/internal/query/exchange/list.go @@ -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 } diff --git a/internal/query/packageexpiry/list.go b/internal/query/packageexpiry/list.go index 93db084..868d1de 100644 --- a/internal/query/packageexpiry/list.go +++ b/internal/query/packageexpiry/list.go @@ -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) { diff --git a/internal/routes/export_task.go b/internal/routes/export_task.go index c74a45a..9b5325c 100644 --- a/internal/routes/export_task.go +++ b/internal/routes/export_task.go @@ -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), diff --git a/internal/routes/package_expiry.go b/internal/routes/package_expiry.go index ac988ee..64a68ea 100644 --- a/internal/routes/package_expiry.go +++ b/internal/routes/package_expiry.go @@ -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 天)仅表示列表展示等级。任务异步执行并沿用通知防重,不生成临期列表快照。", diff --git a/internal/service/agent_recharge/service.go b/internal/service/agent_recharge/service.go index 77603e7..eab0e17 100644 --- a/internal/service/agent_recharge/service.go +++ b/internal/service/agent_recharge/service.go @@ -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 diff --git a/internal/service/asset_allocation_record/service.go b/internal/service/asset_allocation_record/service.go index 5e3f2ad..c8e67cf 100644 --- a/internal/service/asset_allocation_record/service.go +++ b/internal/service/asset_allocation_record/service.go @@ -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 { diff --git a/internal/service/commission_withdrawal/service.go b/internal/service/commission_withdrawal/service.go index 756b2fb..3756d1e 100644 --- a/internal/service/commission_withdrawal/service.go +++ b/internal/service/commission_withdrawal/service.go @@ -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) diff --git a/internal/service/device_import/service.go b/internal/service/device_import/service.go index a3c486a..919ca48 100644 --- a/internal/service/device_import/service.go +++ b/internal/service/device_import/service.go @@ -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) diff --git a/internal/service/enterprise_card/authorization_service.go b/internal/service/enterprise_card/authorization_service.go index 1620534..6f991aa 100644 --- a/internal/service/enterprise_card/authorization_service.go +++ b/internal/service/enterprise_card/authorization_service.go @@ -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) -} diff --git a/internal/service/export_task/service.go b/internal/service/export_task/service.go index 8829a22..f5ee954 100644 --- a/internal/service/export_task/service.go +++ b/internal/service/export_task/service.go @@ -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{ diff --git a/internal/service/iot_card_import/service.go b/internal/service/iot_card_import/service.go index 58c390d..b6d8c21 100644 --- a/internal/service/iot_card_import/service.go +++ b/internal/service/iot_card_import/service.go @@ -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) diff --git a/internal/service/order/service.go b/internal/service/order/service.go index fa185ed..2a9fa68 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -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) diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index 6ea9bab..169808f 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -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 { diff --git a/internal/store/postgres/asset_allocation_record_store.go b/internal/store/postgres/asset_allocation_record_store.go index 7be82a8..3708ea2 100644 --- a/internal/store/postgres/asset_allocation_record_store.go +++ b/internal/store/postgres/asset_allocation_record_store.go @@ -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 { diff --git a/internal/store/postgres/commission_record_store.go b/internal/store/postgres/commission_record_store.go index 0cb88ba..6c9f366 100644 --- a/internal/store/postgres/commission_record_store.go +++ b/internal/store/postgres/commission_record_store.go @@ -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 { diff --git a/internal/store/postgres/enterprise_card_authorization_store.go b/internal/store/postgres/enterprise_card_authorization_store.go index 1a88beb..5db4bbc 100644 --- a/internal/store/postgres/enterprise_card_authorization_store.go +++ b/internal/store/postgres/enterprise_card_authorization_store.go @@ -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) } diff --git a/internal/task/export_dispatch.go b/internal/task/export_dispatch.go index 9b1648d..1c5ab20 100644 --- a/internal/task/export_dispatch.go +++ b/internal/task/export_dispatch.go @@ -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, "解析导出表头失败") diff --git a/openspec/changes/add-export-time-filter-standards/design.md b/openspec/changes/add-export-time-filter-standards/design.md deleted file mode 100644 index 0816c5d..0000000 --- a/openspec/changes/add-export-time-filter-standards/design.md +++ /dev/null @@ -1,21 +0,0 @@ -## Decisions - -- 共享导出筛选解析器返回 UTC 边界和冻结筛选快照,查询不在导出 Worker 中重新解释日期。 -- 导出任务保存授权范围快照而非执行时重新计算;列表/导出复用同一 Query 条件构造。 - -## 参数、查询与导出契约 - -### 统一时间解析 - -- 新增共享解析器,输入可选 `start_time`、`end_time` 字符串,必须以 RFC3339 秒级且带显式时区解析为瞬时 UTC 值;拒绝无时区、毫秒精度、非法日期和 `start_time > end_time`。两端均存在时 Query 使用 `time >= start_time AND time <= end_time`;单端只应用对应边界。 -- IoT/设备任务、换货、分配、订单、代理充值、佣金、提现统一绑定该参数并按各自创建/申请时间过滤;授权记录按授权发生时间;临期列表按当前有效主套餐最终到期时间。DTO、OpenAPI 和导出筛选名均固定为 `start_time`/`end_time`,不再接受模块私有日期字段作为新契约。 - -### 导出任务快照 - -- 创建临期、佣金明细、达量预警导出时,先复用页面 Query 构造器解析全部筛选和时间边界,再保存规范化过滤器、操作者 ID、创建时可见店铺/资产范围、时区、创建时间和口径版本。Worker 只读取该快照,不重新从请求、当前角色或当前页面解析筛选。 -- 临期导出以资产为粒度,选择当前有效主套餐最终到期时间和剩余天数;加油包不单独生成行。佣金明细导出的粒度与列定义由 `add-commission-clawback-records` 确定(佣金记录粒度,原佣金与回溯记录各一行,入账后/回溯后余额可为负);本 Change 只负责统一 `start_time`/`end_time` 参数、闭区间语义与筛选、权限快照冻结,不改变既有导出粒度与列定义。预警导出以预警记录为粒度,套餐/流量/阈值/到期字段读触发快照,店铺/业务员/用户组可按执行时当前归属补全,但必须同时落在创建时冻结范围。 -- 导出完成记录结果文件、行数、完成时间和失败安全摘要;任何权限变化、筛选条件变化或后台归属变化不得扩大已创建任务的数据集。失败重试继续使用原快照,不创建第二份不同口径文件。 - -## Migration Plan - -为任务快照新增成对迁移;验证时区边界、空边界、非法/超长区间、权限变化及 up/down/up。 \ No newline at end of file diff --git a/openspec/changes/add-export-time-filter-standards/proposal.md b/openspec/changes/add-export-time-filter-standards/proposal.md deleted file mode 100644 index ec6446c..0000000 --- a/openspec/changes/add-export-time-filter-standards/proposal.md +++ /dev/null @@ -1,24 +0,0 @@ -## Scope - -- 迭代编号:`AUG26-014`。 - -## Why - -后台导出和列表可能使用不同时间口径或在异步执行时漂移权限范围。 - -## What Changes - -- 统一日期/时间解析、闭区间(`start_time <= t <= end_time`,参数为带时区 RFC3339 秒级时间,任一端可省略)和最大范围校验。 -- 导出冻结列表筛选、时区和数据范围。 - -## Capabilities - -### New Capabilities -- `export-time-filter`: 导出时间筛选标准。 - -### Modified Capabilities -- 无。 - -## Impact - -影响后台导出任务、查询 DTO、权限快照和 OpenAPI。 \ No newline at end of file diff --git a/openspec/changes/add-export-time-filter-standards/specs/export-time-filter/spec.md b/openspec/changes/add-export-time-filter-standards/specs/export-time-filter/spec.md deleted file mode 100644 index f2f6b6a..0000000 --- a/openspec/changes/add-export-time-filter-standards/specs/export-time-filter/spec.md +++ /dev/null @@ -1,17 +0,0 @@ -## ADDED Requirements - -### Requirement: 统一时间筛选参数与字段 -系统 SHALL 对 IoT/设备任务、换货、分配、订单、代理充值、佣金、提现及其导出统一使用可选 `start_time`、`end_time` 参数。参数必须为带时区的 RFC3339 秒级时间,区间为**闭区间**;任一端可缺省。上述业务按创建时间或申请时间筛选;授权记录按授权发生时间筛选;临期列表按当前生效主套餐最终到期时间筛选。格式非法或开始时间晚于结束时间时拒绝请求。 - -#### Scenario: 两端均传入 -- **WHEN** 请求携带合法的 `start_time` 与 `end_time` -- **THEN** 系统仅返回权威时间大于等于开始时间且小于等于结束时间的记录 - -### Requirement: 三类异步导出及冻结口径 -临期列表、佣金明细和套餐流量达量预警 SHALL 复用既有异步导出任务,并在创建时冻结全部页面筛选条件、操作者和可见店铺范围。临期导出一行对应一项资产,仅取当前生效主套餐最终到期时间和剩余天数,加油包不得单独成行。预警导出一行对应一条预警记录,套餐、用量、总量、阈值和到期时间使用触发快照,店铺、业务员和用户组在执行时按当前归属补充。佣金明细导出的记录粒度、列定义与余额口径不属于本需求:本需求只要求其冻结创建时筛选条件、操作者与可见店铺范围,且 MUST NOT 改变既有粒度与列定义。 - -异步执行不得重新解释时间、扩大创建时店铺范围或遗漏页面筛选;文件结果只含创建时有权读取的事实。 - -#### Scenario: 预警归属在导出前变更 -- **WHEN** 预警记录创建后资产所属店铺或业务员变更,再执行已创建导出任务 -- **THEN** 套餐及流量字段仍使用触发快照,店铺、业务员和用户组使用执行时当前归属,且不得超出任务创建时冻结的可见店铺范围 diff --git a/openspec/changes/add-export-time-filter-standards/specs/unified-time-filter/spec.md b/openspec/changes/add-export-time-filter-standards/specs/unified-time-filter/spec.md deleted file mode 100644 index 09b7890..0000000 --- a/openspec/changes/add-export-time-filter-standards/specs/unified-time-filter/spec.md +++ /dev/null @@ -1,12 +0,0 @@ -## Purpose - -为 2026 年 8 月迭代提供独立、可验证的 统一时间筛选与导出快照 行为契约,避免与既有模块的兼容行为混淆。 - -## ADDED Requirements - -### Requirement: 统一时间筛选与导出快照 -系统 SHALL 对受影响列表使用可单端省略的 `start_time` 和 `end_time` RFC3339 秒级闭区间,并按规定业务时间筛选。异步导出必须冻结创建时筛选条件、操作者和可见店铺范围,且按各业务规定使用触发快照或执行时归属。 - -#### Scenario: 规则命中 -- **WHEN** 业务请求或任务满足本需求定义的前置条件 -- **THEN** 系统按上述规则完成处理、保留可追溯事实,并拒绝与状态、权限或幂等约束冲突的重复操作 diff --git a/openspec/changes/add-export-time-filter-standards/tasks.md b/openspec/changes/add-export-time-filter-standards/tasks.md deleted file mode 100644 index e2b21c2..0000000 --- a/openspec/changes/add-export-time-filter-standards/tasks.md +++ /dev/null @@ -1,8 +0,0 @@ -## 1. 统一筛选 -- [ ] 1.1 清点本期后台导出/列表入口及现有时间字段和权限 Query。 -- [ ] 1.2 实现上海时区日期解析、闭区间(`start_time <= t <= end_time`,参数为带时区 RFC3339 秒级时间,任一端可省略)、最大范围校验和筛选快照。 -- [ ] 1.3 改造导出任务以冻结范围并复用列表 Query;更新 DTO/OpenAPI。仅覆盖统一 `start_time`/`end_time` 参数、闭区间语义与筛选、权限与筛选快照冻结;不改变任何导出的记录粒度、列定义与余额口径。 - -## 2. 验证 -- [ ] 2.1 验证日期边界、时间格式、空范围、超限、权限变更和导出/列表一致性,并核对佣金明细导出任务的记录粒度、列定义与余额口径未被本 Change 改变。 -- [ ] 2.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-export-time-filter-standards --strict` 和 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file diff --git a/openspec/changes/add-export-time-filter-standards/.openspec.yaml b/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/.openspec.yaml similarity index 100% rename from openspec/changes/add-export-time-filter-standards/.openspec.yaml rename to openspec/changes/archive/2026-09-17-add-export-time-filter-standards/.openspec.yaml diff --git a/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md b/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md new file mode 100644 index 0000000..8422cdc --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/design.md @@ -0,0 +1,98 @@ +## Context + +现状约束(全部为可复现事实,证据行号取本次修订时的现读行号): + +- **宽松解析有两个来源,必须区分**。其一,`cmd/api/main.go:204` 在启动时调用 `registerTimeParserCompat()`(`:222`,实现 `parseFlexibleTime` `:242`)用 `fiber.SetParserDecoder` 全局注册 `time.Time` 转换器,接受 RFC3339、`2006-01-02T15:04:05`、`2006-01-02 15:04:05`、`2006-01-02`(后三者按 `time.Local`,容器 `TZ=Asia/Shanghai`,见 `Dockerfile.api:51`、`Dockerfile.worker:46`)。其二,`internal/exporter/filter_helpers.go:238` 的 `filterTime` 与 `:259` 的 `filterEndDate` 在导出执行期独立做多格式解析,**解析失败静默返回 false(等于不加条件)**。 +- **导出三段式与既有冻结点**:创建期冻结 `query_json`(`req.Query` 原样 `sonic.Marshal`,`internal/service/export_task/service.go:76-83`)、可见店铺范围 `scope_shop_ids` 与操作者四元组(`:99-126`);dispatch 期冻结 `resolved_headers` 与分片计划(`internal/task/export_dispatch.go:122-123,198-213`)。Worker 全程 `GetByIDForWorker`(无数据权限),不读请求上下文。 +- **唯一合规先例**:达量预警导出在创建期把时间规范化为带时区 RFC3339 字符串(`internal/handler/admin/package_traffic_alert.go:174-178`),执行期按 `triggered_at` 闭区间筛选(`internal/exporter/package_traffic_alert_scene.go:170-175`),并有场景级角色二次门禁(`:322-328`)。 +- **临期导出不存在**:`pkg/constants/constants.go:385-402` 仅 10 个导出场景常量,`internal/exporter/registry.go:22-34` 仅 10 个数据源,导出路由仅 `internal/routes/export_task.go` 与 `internal/routes/package_traffic_alert.go:76`。 +- **临期列表不是 SQL 区间筛选**:`internal/query/packageexpiry/list.go` 先用 `expires_at < 今天+16 天` 粗筛候选(`:127-129`、`:155-157`),再在内存按 `EstimatedFinalExpiresAt` 过滤(`:205-208`);推算口径为 `master_usage_id IS NULL` 的主套餐队列(`internal/query/packageexpiry/query.go:52-56,154-166`),不含加油包。既有 `days_min`/`days_max` 与 `expires_from`/`expires_to` 均按上海自然日(`list.go:315-339`)。 +- **既有严格 RFC3339 先例**:审计列表用 `string` 参数 + `auditTimeRange`/`optionalAuditTime` 严格 `time.RFC3339`(`internal/handler/admin/audit.go:342-368`),但要求 `from < to`,**不采用其顺序语义**。 +- **错误响应无字段级结构**:`pkg/errors` 仅 `{code,data,msg,timestamp}` 与可选的 `Data`;字段中文名来自 DTO 的 `description` tag(`internal/handler/validation/validation.go:14`)。 +- **硬门禁**:`scripts/context-health.sh:53-73` 强制新旧能力 Requirement 与 `docs/verification/context-reset/` 两份 JSON 双向一致(路由索引 ↔ 入口矩阵),`:18-20` 禁止仓库内出现任何 `*_test.go`。 + +## Goals / Non-Goals + +**Goals:** + +- 受影响端点的时间参数唯一化(`start_time`/`end_time`)、格式唯一化(带显式时区 RFC3339 秒级)、语义唯一化(UTC 瞬时闭区间),且在发送端即可预测接受与拒绝。 +- 导出执行期不再解释任何非冻结来源;冻结值非法时以失败收场而不是放行全量。 +- 临期导出从无到有,与列表同筛选同口径。 + +**Non-Goals(设计层边界):** + +- 不改进任何导出的记录粒度、列定义、余额口径(佣金明细负数与入账后余额、达量预警触发快照列组、换货迁移状态列)。 +- 不重构未列入本次的端点与其宽松解析;不删除全局兼容解析器。 +- 不引入跨度上限、不引入运行时开关、不引入结构化字段级错误载荷。 +- 不修改数据库 Schema(见 Decisions 的承载结论)。 + +## Decisions + +### D1 共享严格解析器 + 受影响 DTO 的时间字段一律改为字符串 + +新增一个共享解析器,输入 `start`/`end` 字符串,输出 `(*time.Time, *time.Time, error)`,语义为「解析为 UTC 瞬时 + 闭区间 + 仅一端/两端缺省 + 开始晚于结束即拒绝」,实现固定为精确 layout `2006-01-02T15:04:05Z07:00` 解析,并在解析前拒绝含 `.` 的输入(Go 的 `time.Parse` 即使 layout 不含小数也会接受小数秒,必须显式拒绝;`±hhmm` 由该 layout 天然拒绝)。落点选 `pkg/utils`(与既有 `pkg/utils/period.go` 同级):创建期的 handler 与执行期的 `internal/exporter` 都要用同一份实现,只有低层公共包能同时被两者依赖。 + +受影响端点的 DTO 时间字段 MUST 由 `*time.Time` 或裸字符串统一改为 `string`,由 handler 调用共享解析器。原因:只要字段仍是 `*time.Time`,全局兼容转换器就会生效,严格契约在 DTO 层即失效;且字符串化后才能给出指明字段的中文错误消息(`*time.Time` 只能得到通用「请求参数解析失败」)。 + +备选与取舍:备选一是删除 `registerTimeParserCompat` 让全局 `*time.Time` 退回 RFC3339。缺点是爆炸半径覆盖未列入本次的端点(资产钱包流水、客户钱包流水、设备资产列表、手机号关联列表、代理充值订单表单等),违反「不主动重构需求未触碰的旧模块」,且这些端点拿不到字段级提示。备选二是保留 `*time.Time` 并另加校验标签,无法表达「必须带时区且不允许小数秒」。均不采用。全局兼容解析器**保持不动**,并在已知差异中登记。 + +### D2 创建期规范化并冻结,执行期只做严格解析 + +创建导出任务时按场景校验并规范化时间边界为 UTC RFC3339 秒级字符串,与筛选、操作者、可见店铺范围一并冻结(沿用既有列,见 D5)。执行期复用同一共享解析器解析冻结值;解析失败(含变更前遗留任务的 date-only/无时区冻结值)→ 任务落失败并写安全失败摘要,绝不静默忽略。 + +备选:执行期保持 `filterTime` 的宽松多格式以兼容遗留任务。缺点是把「列表与导出口径漂移」这个原始问题固化在执行期,且非法冻结值会静默放行全量,与「不得扩大数据集」直接冲突。不采用。 + +### D3 临期导出新建:独立数据源 + 受控端点,复用列表查询与列定义 + +新增唯一导出场景与受控端点 `POST /api/admin/expiring-assets/export`,请求体复用列表的筛选集合(时间区间、`days_min`/`days_max`、套餐、资产类型、关键字、店铺)。数据源 MUST 复用 `internal/query/packageexpiry` 的候选预筛与最终到期推算,不得另写一套到期口径;列按 `111.md` §18.1,粒度一行一资产(加油包不单独成行)。店铺/业务员/用户组按执行时当前归属补充,与达量预警同口径(依据既有 Spec),但订阅范围仍受创建时冻结的可见店铺范围约束。 + +备选:走通用 `POST /api/admin/export-tasks` 传自由 `query` map。缺点是把「复用页面全部筛选」降级为前端约定,正是本 Change 要消除的问题。不采用。 + +实现取舍:为与达量预警导出保持同一店铺/业务员/用户组口径,从达量预警场景中抽出了共享的归属列查询(店铺当前业务员 + 业务员业务用户组实时推导,`internal/exporter/ownership_columns.go`),两场景共用同一实现,行为等价(达量预警的列与口径逐字不变)。 + +### D4 佣金明细导出补筛选,不改口径 + +`commission_record` 场景新增 `start_time`/`end_time`,按两条分支各自的创建时间列闭区间筛选。该场景的存储层已预留同列的时间分支(`internal/store/postgres/commission_record_store.go:176-181`)但当前无人赋值,接线即可。记录粒度(原佣金与回溯各一行)、列定义、负数与入账后余额口径 MUST NOT 改变。 + +### D5 无 Schema 变更、无迁移 + +| 需求要素 | 承载 | +| --- | --- | +| 筛选条件 + 时间边界 | `tb_export_task.query_json`(jsonb,`migrations/000135_create_export_task_tables.up.sql:26`),写入规范化后的 UTC RFC3339 秒级字符串 | +| 操作者 | `creator_user_id`/`creator_user_type`/`creator_shop_id`/`creator_enterprise_id` 与 `creator`/`updater`(`:9-10,29-32`) | +| 可见店铺范围 | `scope_shop_ids`(jsonb,`:27`) | +| 结果文件 / 行数 / 失败安全摘要 | `file_key`/`file_size`/`total_rows`/`error_message`(`:34-36,18`),失败摘要沿用 `error_message` 通道(`internal/service/export_task/service.go:360-374`) | +| 表头冻结 | `query_json.resolved_headers`(`internal/store/postgres/export_task_store.go:148-172`) | + +因此 **无成对迁移、无 up/down/up**。取消原设计中「时区」与「口径版本」快照字段:前者无消费者(比较用 UTC 瞬时,仅临期剩余天数沿用既有上海固定偏移常量),后者既无 Requirement 也无读取方。 + +### D6 未列入端点与其导出同改或同不改 + +判定规则:一个页面与其导出场景的时间筛选键必须一致,要么同时纳入本次改造,要么同时不纳入。据此,换货与代理充值纳入时其导出场景键同步改名;设备资产列表、代理主钱包流水、套餐、退款、IoT 卡资产等未纳入者与其导出场景一律保持原参数与宽松解析,作为已知差异登记。 + +## Risks / Trade-offs + +- [前端未同迭代改造 → 受影响端点立即 400] → 契约以「无兼容期、无开关」为前提,交付物必须包含 DTO 与 OpenAPI 变更清单及前端同步清单,并在上线前置中写明前端同步;后端不做双读。 +- [临期列表的 15 天候选窗口被误读为新参数的上限] → 明确该粗筛是既有语义(`internal/query/packageexpiry/list.go:127-129`),新参数不得附加跨度上限;验收以「同筛选下列表与导出行集一致」覆盖。 +- [date-only 改瞬时刻后,前端若继续按自然日传值会改变结果边界] → 契约要求显式偏移;验收覆盖「授权记录闭区间含两端」与「临期列表按最终到期时刻筛选」,并要求前端以 00:00:00/23:59:59 的带偏移写法表达整日区间。 +- [遗留 pending 任务冻结值为旧格式 → 任务失败] → 这是刻意的:失败可重试重读,静默放行会扩大数据集;验收要求构造遗留任务验证「落失败且不产出全量文件」。 +- [全局兼容解析器仍在,未列入端点继续接受宽松格式 → 同参数名在不同端点行为不同] → 以 D6 的成对规则限定爆炸半径,并在设计文档「已知差异」中显式登记,避免被误当作遗漏。 +- [列定义与表头在 dispatch 期冻结,历史任务沿用旧表头] → 临期导出为新建,无历史任务;佣金明细与达量预警的列定义本次不得改动,验收以表头输出逐字不变作回归。 + +## Migration Plan + +- 无数据库迁移、无数据回填、无运行时开关;冻结字符串写入既有 jsonb 列。 +- 发布顺序:前端与后端同迭代上线;后端上线顺序无特殊要求(受影响端点仅参数契约变化,不涉及数据形态)。 +- 回滚策略:回滚二进制即恢复旧参数接受范围;已创建任务的冻结值仍为合法 RFC3339 字符串,新旧版本均可解析,不需要数据修复。 +- 上线后门禁:`gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`(连续两次结果一致)、`openspec validate --all --strict`、`openspec doctor --json`、`./scripts/context-health.sh`。 + +## 已知差异登记(本次不修) + +1. 未列入本次的端点(资产钱包流水、客户钱包流水、设备资产列表及其导出、代理主钱包流水及其导出、手机号—资产关联列表、审计类列表、员工代收款账单、轮询告警历史、企业卡与企业设备授权列表、IoT 卡资产列表、套餐导出、退款导出)仍接受宽松时间格式;全局兼容解析器(`cmd/api/main.go:222`)继续为其服务。 +2. 佣金统计类接口(`GET /api/admin/shops/{shop_id}/commission-stats`、`GET /api/admin/shops/{shop_id}/commission-daily-stats`)把裸字符串直接传入数据库做时间比较,格式非法时依赖数据库隐式转换(可能报错)。属独立问题,本次不修。 +3. `tb_export_task` 的创建与完成时间列为裸 `timestamp`(`migrations/000135_create_export_task_tables.up.sql:5-7,38-39`),与业务表的带时区时间列不同;本次只读筛选与写入冻结字符串,不受影响。 +4. 死代码登记(本次不删):`dto.CommissionRecordListRequest`(`internal/model/dto/commission.go:25`,无调用方)、`dto.RechargeListRequest`(`internal/model/dto/recharge.go:60-62`,无调用方)、`internal/exporter/scope.go`(单行空文件)。 +5. 达量预警列表与导出(`GET /api/admin/package-traffic-alerts`、`POST /api/admin/package-traffic-alerts/export`)本期只收紧执行期、不改入口契约:`dto.ListPackageTrafficAlertRequest` / `dto.ExportPackageTrafficAlertRequest` 的时间字段仍为 `*time.Time`,列表与导出入口仍由 `cmd/api/main.go` 的全局兼容解析器接受 date-only、空格分隔与无时区等宽松格式,创建期由 `handler/admin/package_traffic_alert.go` 的 `exportFilters` 归一为 UTC RFC3339 秒级串后冻结;**执行期改为只按冻结值严格解析**(`internal/exporter/time_filters.go` 已将本场景纳入 `timeFilterScenes`,`internal/exporter/package_traffic_alert_scene.go` 改用共享 `strictTimeRange`),冻结值非法时任务落失败而不静默放行全量。列定义、记录粒度、触发快照口径与前端契约不变。 +6. 无时区列的时间锚定(本次不改):`tb_export_task`、`tb_commission_record`、`tb_commission_clawback_record`、`tb_commission_withdrawal_request`、`tb_agent_recharge_record`、`tb_enterprise_card_authorization`、`tb_iot_card_import_task`、`tb_device_import_task` 的时间列为 PostgreSQL `timestamp without time zone`,其中保存的是应用本地时区(部署容器 `TZ=Asia/Shanghai`)的挂钟时间。本次统一按「归一后的 UTC 瞬时」绑定时间参数;列表与导出两条路径落到同一列上的有效边界一致(写库为同一 UTC 挂钟值,驱动与显式文本转换均丢弃时区指示),不存在列表/导出口径差异。「用户本地整日区间」与「UTC 挂钟边界」之间的差值属既有事实,本次不改,实测证据见 [`docs/verification/add-export-time-filter-standards-verification.md`](../../../../docs/verification/add-export-time-filter-standards-verification.md) 第 7 节。 +7. 临期导出的 `Count`/`Fetch` 复用列表全量推算(本次取舍):最终到期时间不是 SQL 列,而是候选预筛后在内存按主套餐队列推算的结果,因此分片执行时每个分片都重新执行「候选预筛 + 批量推算」而不下推 `LIMIT`/`COUNT` 到 SQL。受既有 16 天粗筛窗口约束,结果集规模有限;为满足「不得另写第二套到期口径」与「列表与导出同筛选同口径」,刻意复用同一实现,不做第二套 SQL 下推。 +8. 代理/企业账号对 `/api/admin` 后半段路由不可达(**既有缺陷,早于本 Change 存在,已由维护者修复,本次不改动**):现象——代理与企业账号访问 `GET /api/admin/expiring-assets` 与本次新增的 `POST /api/admin/expiring-assets/export` 得到 403 / code 1005「无权限操作该资源或资源不存在」,超级管理员与平台账号正常。真实根因——`internal/routes/package_traffic_alert.go` 与 `internal/routes/asset_auto_renewal.go` 在**管理端根组**上用 `router.Group("", gate)` 注册「仅超管/平台」的组级门禁;Fiber 的 `Group(prefix, handlers...)` 会把该处理器落为 `/api/admin` 前缀的 USE 处理器并按注册顺序前置执行,因此**在其之后注册的路由**(`orders`、`exchanges`、`/assets/*`、`/expiring-assets`(含本次新增的导出)、`agent-recharges` 等)以及**不匹配任何路由的 `/api/admin/*` 路径**(404 被覆盖为 1005)都会被拦截。现状与边界——该缺陷早于本 Change 存在,且**已由维护者提交 `398a5e4`(`fix(路由): 修正套餐真流量预警与资产自动续费的超管/平台 gate 作用域`)修复**(把 gate 改为挂在功能前缀上,例如 `/package-traffic-alert-rules`、`/asset-auto-renewal-config`);该修复**不属于本 Change 的改动**(`git diff HEAD` 中不含这两个路由文件)。本 Change 不再对它做任何改动,只在此登记事实与本 Change 的验证影响:代理视角的 HTTP 创建/拒绝路径在 HEAD `398a5e4` 的门禁修复后已可复现验证(见 [`docs/verification/add-export-time-filter-standards-verification.md`](../../../../docs/verification/add-export-time-filter-standards-verification.md) 第 6 节对应小节)。 diff --git a/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/proposal.md b/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/proposal.md new file mode 100644 index 0000000..b490178 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/proposal.md @@ -0,0 +1,46 @@ +## Why + +迭代编号:`AUG26-014`。后台列表与导出当前各自使用不同的时间参数名与时间格式(`created_at_start`/`created_at_end`、`start_date`/`end_date`、date-only、无时区串、裸字符串直传),异步导出还会在执行期重新解释筛选并沿用宽松解析,导致同一筛选在列表与导出之间口径漂移、非法参数被静默忽略后放行全量数据,且执行期可能扩大已创建任务的数据集。 + +需求依据:`docs/product/2026-08-迭代-PRD-讨论稿.md` §2.16(时间筛选与导出快照两段)为权威口径;`111.md` §18 提供三类导出的列定义,§24 的「无法支持/不支持」清单与 PRD 相反,以 PRD 为准。 + +## What Changes + +- **BREAKING** 统一时间筛选参数:PRD §2.16 点名的列表与导出固定使用 `start_time`/`end_time`,取值 MUST 为带显式时区的 RFC3339 秒级时间,闭区间(含两端),任一端可缺省;解析结果归一为 UTC 瞬时比较。 +- **BREAKING** 替换旧参数与旧格式:`created_at_start`/`created_at_end`、`start_date`/`end_date`、date-only(`2006-01-02`)、无时区串(`2006-01-02 15:04:05`)在受影响端点被拒绝;不保留静默兼容、不新增运行时开关;前端必须同迭代上线。 +- **BREAKING** 受影响端点的时间参数类型一律由 `*time.Time` 或裸字符串改为字符串,不得残留经全局宽松解析的类型,否则严格契约失效。 +- 修正授权记录筛选语义:由「起始闭、结束开且结束日加一天」改为闭区间含两端。 +- 修正提现记录筛选语义:非法时间参数一律拒绝,不再静默忽略筛选条件后返回全量。 +- 临期导出新建:既有代码中不存在该导出场景,本次新建数据源、场景注册与受控端点;列定义取 `111.md` §18.1,粒度为一行一项资产。 +- 佣金明细导出新增按创建时间的闭区间筛选,创建期校验并冻结;不改其记录粒度、列定义与余额口径。 +- 导出创建期统一规范化并冻结筛选与时间边界(UTC RFC3339 秒级字符串);执行期不再做多格式宽松解析,只按冻结值严格解析,解析失败必须落失败任务并写安全摘要。 +- 不实现跨度上限。 + +## Capabilities + +### New Capabilities + +- `export-time-filter`: 统一时间筛选参数与解析契约、受影响端点与旧格式替换清单,以及临期、佣金明细与达量预警三类异步导出的创建期冻结与「不得扩大数据集」不变式;临期导出为本次新建场景。 + +### Modified Capabilities + +- 无(未修改任何既有能力的行为契约)。 + +delta 的操作头口径(`## MODIFIED Requirements`): + +- `export-time-filter` 是**本次新增的能力**,但其主 Spec(`openspec/specs/export-time-filter/spec.md`)由本变更落盘:`scripts/context-health.sh` 要求主 Spec 与 `docs/verification/context-reset/` 两份证据 JSON 双向一致,主 Spec 必须在本次交付。 +- 因此 delta 采用 `## MODIFIED Requirements`:主 Spec 已存在同名 Requirement 时,`## ADDED` 会让未来的 `openspec archive` 报 `already exists` 并中止(`Aborted. No files were changed.`,退出码 1),而 `## MODIFIED` 可正常归档;`MODIFIED` 块必须携带主 Spec 中同名 Requirement 的全部 Scenario,本变更的主 Spec 正由该 delta 生成,两边 Scenario 集逐字一致。 +- 主 Spec 与 delta 的 Requirement 名称与正文逐字一致,归档时不会出现「current spec contains scenario(s) not present in the modified block」。 + +清点依据(复核既有能力是否需要 MODIFIED): + +- `openspec/specs/` 全量检索 `start_time`、`end_time`、`RFC3339`、`筛选`:无任何 Requirement 固定时间参数名或时间格式;唯一命中是 `personal-customer` 的响应字段描述(返回体中的开始时间),不是筛选参数契约。 +- `openspec/specs/export-task/spec.md` 只描述导出任务终态(待处理/处理中/已完成/已失败/已取消),不含筛选、时间快照或范围冻结语义。 +- `openspec/specs/package-traffic-alert/spec.md` 的「预警查询与导出」已要求导出复用异步任务、创建时冻结操作者/筛选/时间范围/可见资产范围,并按触发时间筛选,与本 Change 的新契约一致而非冲突;新能力的 Requirement 因此不得复述该条的粒度与触发快照口径,只指向既有 Spec。 + +## Impact + +- 后台列表端点与异步导出场景;请求 DTO 与 OpenAPI 参数类型(`*time.Time`/裸字符串 → 字符串)。 +- 前端必须同迭代改造受影响端点的参数名与传值格式,无兼容期。 +- 新增能力 `export-time-filter` 的主 Spec 与证据链同步落盘(`openspec/specs/export-time-filter/spec.md`、`docs/verification/context-reset/` 两份 JSON)。 +- 无 Schema 变更、无迁移、无运行时开关;不触碰未列入本次的端点与导出场景。 diff --git a/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/specs/export-time-filter/spec.md b/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/specs/export-time-filter/spec.md new file mode 100644 index 0000000..f969f7c --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/specs/export-time-filter/spec.md @@ -0,0 +1,182 @@ +## Purpose + +为 2026 年 8 月迭代提供统一的时间筛选参数与解析契约,规定受影响端点与旧格式的替换关系,并规定临期、佣金明细与达量预警三类异步导出在创建时冻结筛选与数据范围、执行期不得重新解释或扩大数据范围的行为。 + +## MODIFIED Requirements + +### Requirement: 统一时间筛选参数与解析契约 + +系统 SHALL 在被本需求覆盖的列表与导出入口使用可选的 `start_time` 与 `end_time` 参数,取值 MUST 为带显式时区的 RFC3339 秒级时间,解析结果 MUST 归一为 UTC 瞬时;筛选区间 MUST 为闭区间(含两端)。参数未传与传入空串 MUST 产生相同语义。 + +接受集合 MUST 仅包含带 `Z` 或 `±hh:mm` 偏移的秒级时刻,例如 `2026-09-01T00:00:00Z` 与 `2026-09-01T08:00:00+08:00`。系统 MUST 拒绝小数秒(含 `.000` 形式)、无时区时间(形如 `2026-09-01T00:00:00`)、date-only(`2026-09-01`)、空格分隔时间(`2026-09-01 00:00:00`)、`±hhmm` 偏移、未补零的日期或时间、非法日历日期(如 `2026-02-30T00:00:00+08:00`)以及任何其他非 RFC3339 输入。系统 MUST NOT 实施跨度上限。 + +系统 SHALL 在开始时间晚于结束时间时拒绝请求。格式非法与顺序错误 MUST 使用既有参数非法错误码并返回固定中文消息,消息 MUST 指明字段;MUST NOT 引入结构化字段级错误载荷。系统 MUST NOT 保留旧格式的静默兼容,MUST NOT 提供运行时开关。 + +#### Scenario: 两端均传入 + +- **WHEN** 请求携带合法的 `start_time` 与 `end_time` +- **THEN** 系统仅返回业务时间大于等于开始时间且小于等于结束时间的记录 + +#### Scenario: 仅传一端 + +- **WHEN** 请求只携带 `start_time`,或只携带 `end_time` +- **THEN** 系统只应用该端边界,另一端不限 + +#### Scenario: 两端缺省或为空串 + +- **WHEN** 请求未携带任何时间参数,或两个参数均为空串 +- **THEN** 系统不附加时间条件,保持既有列表范围 + +#### Scenario: 开始等于结束 + +- **WHEN** `start_time` 与 `end_time` 表示同一时刻,且存在业务时间恰好等于该时刻的记录 +- **THEN** 系统返回这些记录 + +#### Scenario: 接受带显式时区的秒级时间 + +- **WHEN** 请求携带带 `Z` 或 `±hh:mm` 偏移的秒级时间 +- **THEN** 系统按该偏移解析为同一瞬时并执行闭区间筛选,结果与同一时刻的另一种合法偏移写法一致 + +#### Scenario: 拒绝旧格式与非法格式 + +- **WHEN** 请求携带小数秒、无时区时间、date-only、空格分隔时间、`±hhmm` 偏移或非法日期 +- **THEN** 系统以参数非法错误码拒绝请求,且不返回任何记录 + +#### Scenario: 开始晚于结束 + +- **WHEN** `start_time` 晚于 `end_time` +- **THEN** 系统以参数非法错误码拒绝请求,且不返回任何记录 + +### Requirement: 受影响端点与旧格式替换 + +系统 SHALL 按下表对受影响端点使用业务时间字段筛选,并按「目标形态」列改造。下表中标注「必改」的端点 MUST 在本次变更后使用统一参数与解析契约;标注「已符合」的端点 MUST 保持既有行为。 + +| 端点 | 改造前参数与格式 | 筛选依据业务时间 | 目标形态 | 归类 | +| --- | --- | --- | --- | --- | +| `GET /api/admin/iot-cards/import-tasks` | `start_time`/`end_time`(宽松:兼容 RFC3339、无时区、date-only) | 导入任务创建时间 | 参数名不变,解析收紧 | 必改 | +| `GET /api/admin/devices/import/tasks` | `start_time`/`end_time`(宽松) | 导入任务创建时间 | 参数名不变,解析收紧 | 必改 | +| `GET /api/admin/export-tasks` | `start_time`/`end_time`(宽松) | 导出任务创建时间 | 参数名不变,解析收紧 | 必改 | +| `GET /api/admin/orders` | `start_time`/`end_time`(宽松) | 订单创建时间 | 参数名不变,解析收紧 | 必改 | +| `GET /api/admin/exchanges` | `created_at_start`/`created_at_end`(宽松) | 换货单创建时间 | 改名为 `start_time`/`end_time` 并收紧 | 必改 | +| `GET /api/admin/asset-allocation-records` | `created_at_start`/`created_at_end`(宽松) | 分配记录创建时间 | 改名为 `start_time`/`end_time` 并收紧 | 必改 | +| `GET /api/admin/agent-recharges` | `start_date`/`end_date`(无格式约束字符串,按日补齐当日 00:00:00 与 23:59:59) | 代理充值记录创建时间 | 改名为 `start_time`/`end_time` 并改用严格解析 | 必改 | +| `GET /api/admin/shops/{shop_id}/commission-records` | 无时间参数 | 佣金明细创建时间 | 新增 `start_time`/`end_time` | 必改 | +| `GET /api/admin/commission/withdrawal-requests` | `start_time`/`end_time`(无时区 `2006-01-02 15:04:05`,解析失败静默忽略该条件) | 提现申请创建时间 | 解析严格化,非法格式一律拒绝 | 必改 | +| `GET /api/admin/shops/{shop_id}/withdrawal-requests` | `start_time`/`end_time`(同上,解析失败静默忽略) | 提现申请创建时间 | 解析严格化,非法格式一律拒绝 | 必改 | +| `GET /api/admin/authorizations` | `start_time`/`end_time`(date-only `2006-01-02`) | 授权发生时间 | 解析严格化;区间由「起始闭、结束开且结束日加一天」改为闭区间含两端 | 必改 | +| `GET /api/admin/expiring-assets` | `expires_from`/`expires_to`(date-only,按上海自然日比较) | 当前生效主套餐最终到期时刻 | 改名为 `start_time`/`end_time`,按时刻闭区间比较;保留既有的剩余天数上下限筛选 | 必改 | +| 换货导出(导出场景 `exchange`) | `created_at_start`/`created_at_end`(宽松) | 换货单创建时间 | 与换货列表同步改名并收紧关键字的筛选键 | 必改 | +| 代理充值导出(导出场景 `agent_recharge`) | `start_date`/`end_date`(宽松) | 代理充值记录创建时间 | 与代理充值列表同步改名并收紧筛选键 | 必改 | +| 订单导出(导出场景 `order`) | `start_time`/`end_time`(宽松) | 订单创建时间 | 参数名不变,解析收紧 | 必改 | +| 佣金明细导出(导出场景 `commission_record`) | 无时间筛选 | 佣金明细创建时间 | 新增 `start_time`/`end_time` 筛选 | 必改 | +| 临期导出 | 场景不存在 | 当前生效主套餐最终到期时刻 | 本次新建,见「临期导出」需求 | 必改 | +| `GET /api/admin/package-traffic-alerts` | `start_time`/`end_time`(RFC3339 秒级) | 预警触发时间 | 保持 | 已符合 | +| `POST /api/admin/package-traffic-alerts/export` | `start_time`/`end_time`(RFC3339 秒级,创建时冻结) | 预警触发时间 | 保持 | 已符合 | + +被替换的旧参数与旧格式 MUST 在受影响端点被拒绝:`created_at_start`/`created_at_end`、`start_date`/`end_date`、date-only、无时区串、空格分隔时间、小数秒与 `±hhmm` 偏移。未列入上表的端点(资产钱包流水、客户钱包流水、设备资产列表及其导出、代理主钱包流水及其导出、手机号—资产关联列表、佣金统计类接口、审计类列表、员工代收款账单、轮询告警历史、企业卡与企业设备授权列表、IoT 卡资产列表、套餐导出、退款导出)本期 MUST NOT 新增时间参数、MUST NOT 改变参数名与格式。 + +#### Scenario: 换货列表按新参数筛选 + +- **WHEN** 请求以 `start_time` 与 `end_time` 查询换货列表 +- **THEN** 系统按换货单创建时间闭区间筛选;携带 `created_at_start` 或 `created_at_end` 时该条件 MUST NOT 生效 + +#### Scenario: 代理充值列表按新参数筛选 + +- **WHEN** 请求以带时区的 `start_time` 与 `end_time` 查询代理充值记录 +- **THEN** 系统按充值记录创建时间闭区间筛选,结果不再依赖按日补齐的当日首末秒 + +#### Scenario: 授权记录闭区间含两端 + +- **WHEN** 授权记录的授权发生时间恰好等于 `start_time` 或恰好等于 `end_time` +- **THEN** 系统返回该记录 + +#### Scenario: 提现记录非法参数不再返回全量 + +- **WHEN** 提现记录列表携带非法时间参数 +- **THEN** 系统以参数非法错误码拒绝请求,MUST NOT 忽略该参数后返回全量记录 + +#### Scenario: 临期列表按最终到期时刻筛选 + +- **WHEN** 请求以 `start_time` 与 `end_time` 查询临期资产列表 +- **THEN** 系统按当前生效主套餐最终到期时刻执行闭区间筛选,并继续应用既有的剩余天数上下限筛选 + +#### Scenario: 未列入端点保持既有行为 + +- **WHEN** 调用未列入上表的端点并携带其既有时间参数(含宽松格式) +- **THEN** 系统保持该端点既有参数名、既有格式接受范围与既有筛选结果 + +### Requirement: 临期导出 + +系统 SHALL 为临期列表提供异步导出,并在创建时冻结该页面的全部筛选条件、操作者与可见店铺范围。本次为该导出**新建**数据源与受控入口,既有代码中不存在该导出场景,因此 MUST NOT 复用不存在的场景。 + +临期导出的粒度 MUST 为一行对应一项资产,取该资产当前生效主套餐的最终到期时间与剩余天数;加油包 MUST NOT 单独成行。导出的筛选集合 MUST 与临期列表一致(时间区间、剩余天数上下限、套餐、资产类型、关键字、店铺),并 MUST 复用与列表相同的最终到期推算口径。导出列 MUST 为:店铺、业务员、用户组、资产类型、设备类型、设备型号、资产标识、当前套餐、到期时间、剩余天数(依据 `111.md` §18.1)。 + +临期导出 MAY 对店铺、业务员与用户组按导出执行时的当前归属补充,但结果 MUST NOT 超出任务创建时冻结的可见店铺范围;该归属补充口径与达量预警导出一致(依据 `openspec/specs/package-traffic-alert/spec.md`)。 + +#### Scenario: 导出与列表同筛选同口径 + +- **WHEN** 以同一筛选条件分别调用临期列表与创建临期导出 +- **THEN** 导出的行集合与列表结果一致,且每行的到期时间与剩余天数取列表同一最终到期推算口径 + +#### Scenario: 加油包不单独成行 + +- **WHEN** 某资产的当前生效主套餐关联加油包 +- **THEN** 该资产在导出中只出现一行,取主套餐最终到期时间与剩余天数,加油包不产生额外行 + +#### Scenario: 列定义与 111 §18.1 一致 + +- **WHEN** 查看临期导出文件表头 +- **THEN** 表头为店铺、业务员、用户组、资产类型、设备类型、设备型号、资产标识、当前套餐、到期时间、剩余天数 + +#### Scenario: 导出前归属变更 + +- **WHEN** 临期导出任务创建后,资产所属店铺或业务员发生变更,再执行该任务 +- **THEN** 店铺、业务员与用户组按执行时当前归属补充,且结果不超出创建时冻结的可见店铺范围 + +### Requirement: 三类异步导出的创建期冻结与不扩大范围 + +临期、佣金明细与达量预警三类导出 SHALL 在创建时冻结全部筛选条件、时间边界、操作者与可见店铺范围;三类导出的时间边界 MUST 被规范化为 UTC RFC3339 秒级字符串后冻结。执行期 MUST NOT 做多格式宽松解析,只按冻结值严格解析,MUST NOT 重新读取请求、当前角色或当前页面筛选。创建期 MUST 校验时间边界,非法值 MUST 在创建时被拒绝。 + +冻结值解析失败时(含变更前创建的遗留任务),任务 MUST 落为失败并写入安全失败摘要,MUST NOT 静默忽略该条件后放行全量数据。失败重试 MUST 沿用原快照,MUST NOT 产生第二份不同口径的文件。导出完成时 MUST 记录结果文件、行数与安全失败摘要。 + +数据范围 SHALL 沿用既有创建期冻结与导出侧范围过滤:代理空可见范围 MUST 在创建时被拒绝,执行期空范围 MUST 返回空结果;MUST NOT 使用请求上下文版过滤。权限、筛选条件或后台归属的任何变化 MUST NOT 扩大已创建任务的数据集。 + +佣金明细导出的记录粒度、列定义与余额口径不属于本需求:本需求只要求其新增按创建时间的闭区间筛选并冻结创建时筛选条件、操作者与可见店铺范围;原佣金与回溯记录各占一行、负数金额与入账后余额不可裁剪的既有行为 MUST NOT 被改变。达量预警导出的粒度与触发快照口径同样不属于本需求,见既有 Spec。 + +#### Scenario: 导出完成记录产物与摘要 + +- **WHEN** 导出任务成功完成 +- **THEN** 任务记录结果文件、行数与完成时间;失败时记录安全失败摘要,不泄露内部细节 + +#### Scenario: 遗留任务冻结值非法 + +- **WHEN** 执行一个冻结了旧格式时间值的遗留导出任务 +- **THEN** 任务落为失败并写入安全失败摘要,MUST NOT 按无时间条件执行并产出全量文件 + +#### Scenario: 失败重试沿用原快照 + +- **WHEN** 导出任务首次执行失败后重试 +- **THEN** 重试使用同一份冻结筛选与时间边界,不产生第二份口径不同的文件 + +#### Scenario: 执行期权限变化不扩大范围 + +- **WHEN** 任务创建后创建者的数据范围、角色或筛选条件发生变化,再执行该任务 +- **THEN** 导出结果仍不超过创建时冻结的可见范围与筛选条件 + +#### Scenario: 代理空可见范围 + +- **WHEN** 代理账号没有任何可见店铺范围并创建导出任务 +- **THEN** 系统在创建时以无权限拒绝,不创建任务 + +#### Scenario: 佣金明细导出含回溯记录 + +- **WHEN** 导出包含回溯记录的佣金明细 +- **THEN** 原佣金与回溯记录各占一行,回溯行金额为负数、入账后余额原样导出,列定义保持既有不变 + +## 可达操作索引 + +本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 + +### 临期导出 + +`POST /api/admin/expiring-assets/export`(创建临期资产导出任务)。 diff --git a/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/tasks.md b/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/tasks.md new file mode 100644 index 0000000..1903948 --- /dev/null +++ b/openspec/changes/archive/2026-09-17-add-export-time-filter-standards/tasks.md @@ -0,0 +1,69 @@ +## 1. T1 共享严格解析器 + +- [x] 1.1 在 `pkg/utils` 新增共享时间区间解析器:输入 `start`/`end` 字符串,输出归一为 UTC 瞬时的起止边界与错误;实现固定为精确 layout `2006-01-02T15:04:05Z07:00`,并在解析前拒绝含 `.` 的输入(Go 的 `time.Parse` 会接受小数秒,必须显式拒绝)。 +- [x] 1.2 写死接受集合:`Z` 与 `±hh:mm` 偏移的秒级时间。写死拒绝集合:小数秒(含 `.000`)、无时区 `2026-09-01T00:00:00`、date-only `2026-09-01`、空格分隔 `2026-09-01 00:00:00`、`±hhmm` 偏移、未补零日期或时间、非法日历日期、其他非 RFC3339 输入。 +- [x] 1.3 写死区间与缺省语义:闭区间含两端;开始等于结束合法;仅传一端只应用该端;两端缺省或均为空串不加时间条件;开始晚于结束一律拒绝。 +- [x] 1.4 写死错误契约:沿用既有参数非法错误码与固定中文消息(消息指明字段),不新增结构化字段级错误载荷;不实现跨度上限。 +- [x] 1.5 将受影响端点的 DTO 时间字段由 `*time.Time` 或裸字符串统一改为 `string`,并在 handler 调用共享解析器;确认受影响端点不再残留经全局宽松解析的类型。 + +## 2. T2 列表端点改造 + +- [x] 2.1 解析收紧(参数名不变):IoT 卡导入任务列表、设备导入任务列表、导出任务列表、订单列表。 +- [x] 2.2 参数改名并收紧:换货列表、分配记录列表(`created_at_start`/`created_at_end` → `start_time`/`end_time`)。 +- [x] 2.3 参数改名并改类型:代理充值列表(`start_date`/`end_date` 无约束字符串 → `start_time`/`end_time` 严格解析),去掉按日补齐当日首末秒的字符串拼接。 +- [x] 2.4 新增筛选:佣金明细列表新增 `start_time`/`end_time`,按佣金明细创建时间闭区间筛选。 +- [x] 2.5 类型与格式改造:提现记录两处列表(审批列表与店铺提现记录)改为严格解析,**删除解析失败静默跳过**,非法参数一律返回参数非法错误码。 +- [x] 2.6 类型与格式改造:授权记录列表改为严格解析,并将区间由「起始闭、结束开且结束日加一天」改为闭区间含两端。 +- [x] 2.7 临期列表:`expires_from`/`expires_to` 改名 `start_time`/`end_time`,改按当前生效主套餐最终到期**时刻**闭区间比较;保留既有剩余天数上下限筛选与既有最终到期推算口径。 +- [x] 2.8 未列入本次的端点保持原参数名与宽松解析(不新增参数、不改格式),按 design 已知差异登记核对一遍,避免顺手改动。 +- [x] 2.9 导出侧筛选键与解析同步(同一页面与其导出必须同口径):换货导出与代理充值导出随列表同步改名为 `start_time`/`end_time`;订单导出沿用同名但收紧解析;受影响导出场景一律改用共享严格解析器,废弃多格式宽松解析与「按日补齐当日末秒」。 +- [x] 2.10 共享文件唯一负责人:可执行路由注册、导出场景常量、导出场景注册表与支持场景判定、`docs/verification/context-reset/` 两份证据 JSON 由本任务负责落盘;T3 需改动这些文件时先经 `hub` 与本任务协调,禁止并行直接改写。 + +## 3. T3 临期导出新建 + +- [x] 3.1 新增临期导出数据源与唯一导出场景标识(既有代码中不存在该场景,不复用不存在的实现)。 +- [x] 3.2 场景注册与支持判定:导出场景常量、数据源、默认注册表、支持场景白名单、创建导出任务 DTO 的场景联合类型白名单同步新增该场景。 +- [x] 3.3 新增受控端点 `POST /api/admin/expiring-assets/export`,经既有 `registerPackageExpiryRoutes` 注册。本次未新增 Handler 类型:`AssetHandler` 已由 `pkg/openapi/handlers.go` 的文档工厂统一装配,`cmd/api/docs.go` 与 `cmd/gendocs/main.go` 均经该工厂取用,**确认无需改动**;新路由的 Input/Output 元数据已装配并出现在重新生成的 `docs/admin-openapi.yaml` 中。 +- [x] 3.4 粒度与列定义:一行对应一项资产,取当前生效主套餐最终到期时间与剩余天数,加油包不单独成行;列为店铺、业务员、用户组、资产类型、设备类型、设备型号、资产标识、当前套餐、到期时间、剩余天数(依据 111 §18.1)。 +- [x] 3.5 筛选集合与列表一致(时间区间、剩余天数上下限、套餐、资产类型、关键字、店铺),并复用列表同一候选预筛与最终到期推算实现,不得另写第二套到期口径。 +- [x] 3.6 店铺、业务员、用户组按执行时当前归属补充(与达量预警同一口径),且结果不超出任务创建时冻结的可见店铺范围。 +- [x] 3.7 与 T2 协调后落盘共享文件(路由、场景常量、注册表、证据 JSON)。 + +## 4. T4 佣金明细导出补筛选 + +- [x] 4.1 佣金明细导出场景新增按创建时间的 `start_time`/`end_time` 闭区间筛选,覆盖原佣金与回溯两条分支各自的创建时间列。 +- [x] 4.2 创建期校验时间边界并冻结为 UTC RFC3339 秒级字符串。 +- [x] 4.3 回归确认记录粒度(原佣金与回溯各一行)、列定义与余额口径逐字不变:负数金额不裁剪、入账后余额原样导出。 + +## 5. T5 冻结规范化与遗留任务失败语义 + +- [x] 5.1 创建导出任务时按场景校验并规范化时间边界为 UTC RFC3339 秒级字符串,与筛选、操作者、可见店铺范围一并冻结(沿用既有列,见 design「无 Schema 变更」承载表)。纳入该规范化与校验的场景集合为 `exchange`、`agent_recharge`、`order`、`commission_record`、`expiring_asset` 与 `package_traffic_alert`;达量预警的冻结值本已是 UTC RFC3339 秒级串,规范化对其为幂等。 +- [x] 5.2 执行期不再做多格式宽松解析,只按冻结值严格解析;解析失败(含变更前遗留任务的旧格式冻结值)任务落失败并写入安全失败摘要,禁止静默忽略该条件后放行全量。达量预警导出(场景 `package_traffic_alert`)同属该执行期规则:其列表与创建入口的宽松接受面与 `*time.Time` DTO 类型不变(仍由全局兼容解析器归一为 UTC RFC3339 秒级串后冻结),执行期改用同一份共享严格解析器,冻结值非法时任务落失败。 +- [x] 5.3 失败重试沿用原快照,不产生第二份不同口径文件。 +- [x] 5.4 数据范围沿用既有创建期冻结与导出侧范围过滤:代理空可见范围创建时拒绝、执行期空范围返回空结果;不得使用请求上下文版过滤;权限、筛选或归属变化不扩大已创建任务的数据集。 + +## 6. T6 契约与文档 + +- [x] 6.1 生成 DTO 与 OpenAPI 变更清单(受影响端点、参数名、类型由 `*time.Time`/裸字符串改为字符串、错误语义),供前端同步。 +- [x] 6.2 生成前端同步清单:改参数名的端点(换货、分配记录、代理充值、临期列表)、改传值格式的端点(IoT 卡导入任务、设备导入任务、导出任务列表、订单、授权记录、提现记录两处、临期列表及其导出、换货导出、代理充值导出)、新增参数的端点(佣金明细列表、佣金明细导出、临期导出)。 +- [x] 6.3 更新 `docs/verification/context-reset/requirement-evidence.json` 与 `entry-capability-requirement-matrix.json`:新增本能力的 Requirement 证据行与新增路由 `POST /api/admin/expiring-assets/export` 的入口行,保持与 Specs 双向一致。 +- [x] 6.4 更新 `README.md` 的导出场景清单与相关系数说明(现仅列 `device`/`iot_card`)。 +- [x] 6.5 核对 Handler 占位装配与文档生成入口:本次未新增 Handler 类型,`AssetHandler` 已由 `pkg/openapi/handlers.go` 的文档工厂统一装配,`cmd/api/docs.go` 与 `cmd/gendocs/main.go` **确认无需改动**;已重新生成 `docs/admin-openapi.yaml`,新路由 `POST /api/admin/expiring-assets/export` 与 13 个受影响端点的时间参数均已更新。 +- [x] 6.6 记录「确认无 Schema 变更」的证据:核对该变更未新增/修改任何迁移文件,冻结字段承载于既有 `tb_export_task` 列(`query_json`、`scope_shop_ids`、操作者列、`file_key`/`file_size`/`total_rows`/`error_message`),因此迁移 up/down/up 为不适用而非跳过。 + +## 7. 验证 + +环境口径(ENG-TEST-001):维护者指定的测试 PostgreSQL `junhong_cmp_test`、Redis DB 6、`Iteration/8-11` 测试部署与 `cmp-test` 日志主机为唯一验证面;fixture 仅创建与清理本 Change 自己的记录,不重置整个测试数据库;自动化测试按项目决策为 N/A,且仓库内不得新增 `*_test.go`。 + +- [x] 7.1 接受集合与拒绝集合逐条实跑:`Z`、`±hh:mm`、小数秒(含 `.000`)、无时区、date-only、空格分隔、`±hhmm`、未补零、非法日期,断言各自接受或返回参数非法错误码。 +- [x] 7.2 区间边界:仅传开始、仅传结束、两端均传、两端缺省、开始等于结束(存在恰好等于该时刻的记录并返回)。 +- [x] 7.3 顺序校验:开始晚于结束一律拒绝。 +- [x] 7.4 未改造端点保持既有宽松行为与既有结果不变。 +- [x] 7.5 提现记录非法参数不再返回全量;授权记录闭区间含两端。 +- [x] 7.6 列表与导出在同一筛选下行集一致:临期列表与其导出、佣金明细列表与其导出、达量预警列表与其导出。 +- [x] 7.7 临期导出表头与 111 §18.1 逐列一致,且加油包不单独成行。 +- [x] 7.8 权限与归属:创建导出任务后变更店铺归属、业务员或用户组,再执行任务,行集不超出创建时冻结范围;代理空范围创建被拒绝;越权与不存在统一不可见。(代理视角经 HTTP 的创建/拒绝路径在 HEAD `398a5e4` 的门禁修复后已可复现验证,见验证文档第 6 节;范围冻结语义另由等价冻结任务实测覆盖) +- [x] 7.9 遗留任务安全:构造一条冻结了旧格式时间值的待处理导出任务(fixture 仅属于本 Change),执行后落失败并写入安全失败摘要,且不产出全量文件;执行后清理该 fixture。 +- [x] 7.10 回归:佣金明细导出与达量预警导出的列定义、负数金额与余额口径逐字不变;导出表头输出与变更前一致。 +- [x] 7.11 确认无迁移已记录:该变更未修改任何迁移文件,`up/down/up` 不适用;引用 T6 的证据记录。 +- [x] 7.12 门禁命令:`gofmt -w`(变更文件)、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`(连续两次生成结果一致)、`openspec validate add-export-time-filter-standards --strict`、`openspec validate --all`、`openspec doctor --json`、`./scripts/context-health.sh`。 diff --git a/openspec/specs/export-time-filter/spec.md b/openspec/specs/export-time-filter/spec.md new file mode 100644 index 0000000..9010086 --- /dev/null +++ b/openspec/specs/export-time-filter/spec.md @@ -0,0 +1,184 @@ +# export-time-filter Specification + +## Purpose + +为 2026 年 8 月迭代提供统一的时间筛选参数与解析契约,规定受影响端点与旧格式的替换关系,并规定临期、佣金明细与达量预警三类异步导出在创建时冻结筛选与数据范围、执行期不得重新解释或扩大数据范围的行为。 + +## Requirements + +### Requirement: 统一时间筛选参数与解析契约 + +系统 SHALL 在被本需求覆盖的列表与导出入口使用可选的 `start_time` 与 `end_time` 参数,取值 MUST 为带显式时区的 RFC3339 秒级时间,解析结果 MUST 归一为 UTC 瞬时;筛选区间 MUST 为闭区间(含两端)。参数未传与传入空串 MUST 产生相同语义。 + +接受集合 MUST 仅包含带 `Z` 或 `±hh:mm` 偏移的秒级时刻,例如 `2026-09-01T00:00:00Z` 与 `2026-09-01T08:00:00+08:00`。系统 MUST 拒绝小数秒(含 `.000` 形式)、无时区时间(形如 `2026-09-01T00:00:00`)、date-only(`2026-09-01`)、空格分隔时间(`2026-09-01 00:00:00`)、`±hhmm` 偏移、未补零的日期或时间、非法日历日期(如 `2026-02-30T00:00:00+08:00`)以及任何其他非 RFC3339 输入。系统 MUST NOT 实施跨度上限。 + +系统 SHALL 在开始时间晚于结束时间时拒绝请求。格式非法与顺序错误 MUST 使用既有参数非法错误码并返回固定中文消息,消息 MUST 指明字段;MUST NOT 引入结构化字段级错误载荷。系统 MUST NOT 保留旧格式的静默兼容,MUST NOT 提供运行时开关。 + +#### Scenario: 两端均传入 + +- **WHEN** 请求携带合法的 `start_time` 与 `end_time` +- **THEN** 系统仅返回业务时间大于等于开始时间且小于等于结束时间的记录 + +#### Scenario: 仅传一端 + +- **WHEN** 请求只携带 `start_time`,或只携带 `end_time` +- **THEN** 系统只应用该端边界,另一端不限 + +#### Scenario: 两端缺省或为空串 + +- **WHEN** 请求未携带任何时间参数,或两个参数均为空串 +- **THEN** 系统不附加时间条件,保持既有列表范围 + +#### Scenario: 开始等于结束 + +- **WHEN** `start_time` 与 `end_time` 表示同一时刻,且存在业务时间恰好等于该时刻的记录 +- **THEN** 系统返回这些记录 + +#### Scenario: 接受带显式时区的秒级时间 + +- **WHEN** 请求携带带 `Z` 或 `±hh:mm` 偏移的秒级时间 +- **THEN** 系统按该偏移解析为同一瞬时并执行闭区间筛选,结果与同一时刻的另一种合法偏移写法一致 + +#### Scenario: 拒绝旧格式与非法格式 + +- **WHEN** 请求携带小数秒、无时区时间、date-only、空格分隔时间、`±hhmm` 偏移或非法日期 +- **THEN** 系统以参数非法错误码拒绝请求,且不返回任何记录 + +#### Scenario: 开始晚于结束 + +- **WHEN** `start_time` 晚于 `end_time` +- **THEN** 系统以参数非法错误码拒绝请求,且不返回任何记录 + +### Requirement: 受影响端点与旧格式替换 + +系统 SHALL 按下表对受影响端点使用业务时间字段筛选,并按「目标形态」列改造。下表中标注「必改」的端点 MUST 在本次变更后使用统一参数与解析契约;标注「已符合」的端点 MUST 保持既有行为。 + +| 端点 | 改造前参数与格式 | 筛选依据业务时间 | 目标形态 | 归类 | +| --- | --- | --- | --- | --- | +| `GET /api/admin/iot-cards/import-tasks` | `start_time`/`end_time`(宽松:兼容 RFC3339、无时区、date-only) | 导入任务创建时间 | 参数名不变,解析收紧 | 必改 | +| `GET /api/admin/devices/import/tasks` | `start_time`/`end_time`(宽松) | 导入任务创建时间 | 参数名不变,解析收紧 | 必改 | +| `GET /api/admin/export-tasks` | `start_time`/`end_time`(宽松) | 导出任务创建时间 | 参数名不变,解析收紧 | 必改 | +| `GET /api/admin/orders` | `start_time`/`end_time`(宽松) | 订单创建时间 | 参数名不变,解析收紧 | 必改 | +| `GET /api/admin/exchanges` | `created_at_start`/`created_at_end`(宽松) | 换货单创建时间 | 改名为 `start_time`/`end_time` 并收紧 | 必改 | +| `GET /api/admin/asset-allocation-records` | `created_at_start`/`created_at_end`(宽松) | 分配记录创建时间 | 改名为 `start_time`/`end_time` 并收紧 | 必改 | +| `GET /api/admin/agent-recharges` | `start_date`/`end_date`(无格式约束字符串,按日补齐当日 00:00:00 与 23:59:59) | 代理充值记录创建时间 | 改名为 `start_time`/`end_time` 并改用严格解析 | 必改 | +| `GET /api/admin/shops/{shop_id}/commission-records` | 无时间参数 | 佣金明细创建时间 | 新增 `start_time`/`end_time` | 必改 | +| `GET /api/admin/commission/withdrawal-requests` | `start_time`/`end_time`(无时区 `2006-01-02 15:04:05`,解析失败静默忽略该条件) | 提现申请创建时间 | 解析严格化,非法格式一律拒绝 | 必改 | +| `GET /api/admin/shops/{shop_id}/withdrawal-requests` | `start_time`/`end_time`(同上,解析失败静默忽略) | 提现申请创建时间 | 解析严格化,非法格式一律拒绝 | 必改 | +| `GET /api/admin/authorizations` | `start_time`/`end_time`(date-only `2006-01-02`) | 授权发生时间 | 解析严格化;区间由「起始闭、结束开且结束日加一天」改为闭区间含两端 | 必改 | +| `GET /api/admin/expiring-assets` | `expires_from`/`expires_to`(date-only,按上海自然日比较) | 当前生效主套餐最终到期时刻 | 改名为 `start_time`/`end_time`,按时刻闭区间比较;保留既有的剩余天数上下限筛选 | 必改 | +| 换货导出(导出场景 `exchange`) | `created_at_start`/`created_at_end`(宽松) | 换货单创建时间 | 与换货列表同步改名并收紧关键字的筛选键 | 必改 | +| 代理充值导出(导出场景 `agent_recharge`) | `start_date`/`end_date`(宽松) | 代理充值记录创建时间 | 与代理充值列表同步改名并收紧筛选键 | 必改 | +| 订单导出(导出场景 `order`) | `start_time`/`end_time`(宽松) | 订单创建时间 | 参数名不变,解析收紧 | 必改 | +| 佣金明细导出(导出场景 `commission_record`) | 无时间筛选 | 佣金明细创建时间 | 新增 `start_time`/`end_time` 筛选 | 必改 | +| 临期导出 | 场景不存在 | 当前生效主套餐最终到期时刻 | 本次新建,见「临期导出」需求 | 必改 | +| `GET /api/admin/package-traffic-alerts` | `start_time`/`end_time`(RFC3339 秒级) | 预警触发时间 | 保持 | 已符合 | +| `POST /api/admin/package-traffic-alerts/export` | `start_time`/`end_time`(RFC3339 秒级,创建时冻结) | 预警触发时间 | 保持 | 已符合 | + +被替换的旧参数与旧格式 MUST 在受影响端点被拒绝:`created_at_start`/`created_at_end`、`start_date`/`end_date`、date-only、无时区串、空格分隔时间、小数秒与 `±hhmm` 偏移。未列入上表的端点(资产钱包流水、客户钱包流水、设备资产列表及其导出、代理主钱包流水及其导出、手机号—资产关联列表、佣金统计类接口、审计类列表、员工代收款账单、轮询告警历史、企业卡与企业设备授权列表、IoT 卡资产列表、套餐导出、退款导出)本期 MUST NOT 新增时间参数、MUST NOT 改变参数名与格式。 + +#### Scenario: 换货列表按新参数筛选 + +- **WHEN** 请求以 `start_time` 与 `end_time` 查询换货列表 +- **THEN** 系统按换货单创建时间闭区间筛选;携带 `created_at_start` 或 `created_at_end` 时该条件 MUST NOT 生效 + +#### Scenario: 代理充值列表按新参数筛选 + +- **WHEN** 请求以带时区的 `start_time` 与 `end_time` 查询代理充值记录 +- **THEN** 系统按充值记录创建时间闭区间筛选,结果不再依赖按日补齐的当日首末秒 + +#### Scenario: 授权记录闭区间含两端 + +- **WHEN** 授权记录的授权发生时间恰好等于 `start_time` 或恰好等于 `end_time` +- **THEN** 系统返回该记录 + +#### Scenario: 提现记录非法参数不再返回全量 + +- **WHEN** 提现记录列表携带非法时间参数 +- **THEN** 系统以参数非法错误码拒绝请求,MUST NOT 忽略该参数后返回全量记录 + +#### Scenario: 临期列表按最终到期时刻筛选 + +- **WHEN** 请求以 `start_time` 与 `end_time` 查询临期资产列表 +- **THEN** 系统按当前生效主套餐最终到期时刻执行闭区间筛选,并继续应用既有的剩余天数上下限筛选 + +#### Scenario: 未列入端点保持既有行为 + +- **WHEN** 调用未列入上表的端点并携带其既有时间参数(含宽松格式) +- **THEN** 系统保持该端点既有参数名、既有格式接受范围与既有筛选结果 + +### Requirement: 临期导出 + +系统 SHALL 为临期列表提供异步导出,并在创建时冻结该页面的全部筛选条件、操作者与可见店铺范围。本次为该导出**新建**数据源与受控入口,既有代码中不存在该导出场景,因此 MUST NOT 复用不存在的场景。 + +临期导出的粒度 MUST 为一行对应一项资产,取该资产当前生效主套餐的最终到期时间与剩余天数;加油包 MUST NOT 单独成行。导出的筛选集合 MUST 与临期列表一致(时间区间、剩余天数上下限、套餐、资产类型、关键字、店铺),并 MUST 复用与列表相同的最终到期推算口径。导出列 MUST 为:店铺、业务员、用户组、资产类型、设备类型、设备型号、资产标识、当前套餐、到期时间、剩余天数(依据 `111.md` §18.1)。 + +临期导出 MAY 对店铺、业务员与用户组按导出执行时的当前归属补充,但结果 MUST NOT 超出任务创建时冻结的可见店铺范围;该归属补充口径与达量预警导出一致(依据 `openspec/specs/package-traffic-alert/spec.md`)。 + +#### Scenario: 导出与列表同筛选同口径 + +- **WHEN** 以同一筛选条件分别调用临期列表与创建临期导出 +- **THEN** 导出的行集合与列表结果一致,且每行的到期时间与剩余天数取列表同一最终到期推算口径 + +#### Scenario: 加油包不单独成行 + +- **WHEN** 某资产的当前生效主套餐关联加油包 +- **THEN** 该资产在导出中只出现一行,取主套餐最终到期时间与剩余天数,加油包不产生额外行 + +#### Scenario: 列定义与 111 §18.1 一致 + +- **WHEN** 查看临期导出文件表头 +- **THEN** 表头为店铺、业务员、用户组、资产类型、设备类型、设备型号、资产标识、当前套餐、到期时间、剩余天数 + +#### Scenario: 导出前归属变更 + +- **WHEN** 临期导出任务创建后,资产所属店铺或业务员发生变更,再执行该任务 +- **THEN** 店铺、业务员与用户组按执行时当前归属补充,且结果不超出创建时冻结的可见店铺范围 + +### Requirement: 三类异步导出的创建期冻结与不扩大范围 + +临期、佣金明细与达量预警三类导出 SHALL 在创建时冻结全部筛选条件、时间边界、操作者与可见店铺范围;三类导出的时间边界 MUST 被规范化为 UTC RFC3339 秒级字符串后冻结。执行期 MUST NOT 做多格式宽松解析,只按冻结值严格解析,MUST NOT 重新读取请求、当前角色或当前页面筛选。创建期 MUST 校验时间边界,非法值 MUST 在创建时被拒绝。 + +冻结值解析失败时(含变更前创建的遗留任务),任务 MUST 落为失败并写入安全失败摘要,MUST NOT 静默忽略该条件后放行全量数据。失败重试 MUST 沿用原快照,MUST NOT 产生第二份不同口径的文件。导出完成时 MUST 记录结果文件、行数与安全失败摘要。 + +数据范围 SHALL 沿用既有创建期冻结与导出侧范围过滤:代理空可见范围 MUST 在创建时被拒绝,执行期空范围 MUST 返回空结果;MUST NOT 使用请求上下文版过滤。权限、筛选条件或后台归属的任何变化 MUST NOT 扩大已创建任务的数据集。 + +佣金明细导出的记录粒度、列定义与余额口径不属于本需求:本需求只要求其新增按创建时间的闭区间筛选并冻结创建时筛选条件、操作者与可见店铺范围;原佣金与回溯记录各占一行、负数金额与入账后余额不可裁剪的既有行为 MUST NOT 被改变。达量预警导出的粒度与触发快照口径同样不属于本需求,见既有 Spec。 + +#### Scenario: 导出完成记录产物与摘要 + +- **WHEN** 导出任务成功完成 +- **THEN** 任务记录结果文件、行数与完成时间;失败时记录安全失败摘要,不泄露内部细节 + +#### Scenario: 遗留任务冻结值非法 + +- **WHEN** 执行一个冻结了旧格式时间值的遗留导出任务 +- **THEN** 任务落为失败并写入安全失败摘要,MUST NOT 按无时间条件执行并产出全量文件 + +#### Scenario: 失败重试沿用原快照 + +- **WHEN** 导出任务首次执行失败后重试 +- **THEN** 重试使用同一份冻结筛选与时间边界,不产生第二份口径不同的文件 + +#### Scenario: 执行期权限变化不扩大范围 + +- **WHEN** 任务创建后创建者的数据范围、角色或筛选条件发生变化,再执行该任务 +- **THEN** 导出结果仍不超过创建时冻结的可见范围与筛选条件 + +#### Scenario: 代理空可见范围 + +- **WHEN** 代理账号没有任何可见店铺范围并创建导出任务 +- **THEN** 系统在创建时以无权限拒绝,不创建任务 + +#### Scenario: 佣金明细导出含回溯记录 + +- **WHEN** 导出包含回溯记录的佣金明细 +- **THEN** 原佣金与回溯记录各占一行,回溯行金额为负数、入账后余额原样导出,列定义保持既有不变 + +## 可达操作索引 + +本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 + +### 临期导出 + +`POST /api/admin/expiring-assets/export`(创建临期资产导出任务)。 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index b62d48f..8b456da 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -399,8 +399,14 @@ const ( ExportTaskSceneCommissionRecord = "commission_record" // ExportTaskScenePackageTrafficAlert 表示套餐真流量达量预警导出场景,一行对应一条预警记录。 ExportTaskScenePackageTrafficAlert = "package_traffic_alert" + // ExportTaskSceneExpiringAsset 表示临期资产导出场景,一行对应一项资产,取当前生效主套餐最终到期时间。 + ExportTaskSceneExpiringAsset = "expiring_asset" ) +// ExportTaskInvalidTimeFilterMessage 是导出任务冻结的时间边界非法时写入 error_message 的安全失败摘要。 +// 该摘要不含底层错误细节,只说明任务因冻结筛选非法而终止。 +const ExportTaskInvalidTimeFilterMessage = "导出筛选的时间边界非法,任务已终止" + // 导出文件格式常量 const ( ExportTaskFormatXLSX = "xlsx" // 导出格式:Excel diff --git a/pkg/utils/time_range.go b/pkg/utils/time_range.go new file mode 100644 index 0000000..9c10538 --- /dev/null +++ b/pkg/utils/time_range.go @@ -0,0 +1,84 @@ +package utils + +import ( + "regexp" + "strings" + "time" + + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// 统一时间筛选的字段名:错误消息必须指明字段,前端据此定位参数。 +const ( + // TimeFilterStartField 统一时间筛选的开始字段名。 + TimeFilterStartField = "start_time" + // TimeFilterEndField 统一时间筛选的结束字段名。 + TimeFilterEndField = "end_time" +) + +// timeFilterLayout 统一时间筛选唯一接受的格式:带显式时区的秒级 RFC3339。 +// 该 layout 天然拒绝无时区、date-only、空格分隔与 ±hhmm 偏移。 +const timeFilterLayout = "2006-01-02T15:04:05Z07:00" + +// timeFilterFormatMessageSuffix 格式非法时的固定中文消息后缀,前缀为字段名。 +const timeFilterFormatMessageSuffix = " 时间格式不合法,必须为带时区的 RFC3339 秒级时间,例如 2026-09-01T00:00:00+08:00" + +// timeFilterOrderMessage 开始时间晚于结束时间时的固定中文消息。 +const timeFilterOrderMessage = "start_time 不能晚于 end_time" + +// ParseTimeRange 解析统一时间筛选参数为归一 UTC 瞬时的闭区间边界。 +// 输入为 start_time 与 end_time 原始字符串,未传与空串语义相同(该端不限)。 +// 仅接受带 Z 或 ±hh:mm 偏移的秒级时刻;小数秒、无时区、date-only、空格分隔、 +// ±hhmm 偏移、未补零、非法日历日期与其他非 RFC3339 输入一律拒绝。 +// 开始晚于结束时拒绝;开始等于结束合法。 +func ParseTimeRange(start, end string) (*time.Time, *time.Time, error) { + startTime, err := parseTimeFilterValue(start, TimeFilterStartField) + if err != nil { + return nil, nil, err + } + endTime, err := parseTimeFilterValue(end, TimeFilterEndField) + if err != nil { + return nil, nil, err + } + if startTime != nil && endTime != nil && startTime.After(*endTime) { + return nil, nil, errors.New(errors.CodeInvalidParam, timeFilterOrderMessage) + } + return startTime, endTime, nil +} + +// FormatTimeFilterValue 把时间边界规范化为创建期冻结用的 UTC RFC3339 秒级字符串。 +func FormatTimeFilterValue(value time.Time) string { + return value.UTC().Format(timeFilterLayout) +} + +// TimeFilterFormatError 返回指明字段名的统一时间格式错误,供导出执行期校验冻结值时复用同一消息。 +func TimeFilterFormatError(field string) error { + return errors.New(errors.CodeInvalidParam, field+timeFilterFormatMessageSuffix) +} + +// timeFilterPattern 锁定统一时间筛选的完整 RFC3339 形状:日期与时间全部补零、秒级、带显式时区。 +// Go 的 layout 解析对小时补零不敏感("2026-09-01T0:00:00Z" 也会被接受), +// 且允许 24 小时与 60 分钟等越界偏移写法,因此必须先用形状校验补齐这些缺口; +// 日历合法性(如 2026-02-30)仍由 layout 解析拒绝。 +var timeFilterPattern = regexp.MustCompile(`^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$`) + +func parseTimeFilterValue(value, field string) (*time.Time, error) { + // 只有恰好空串等同「未传」;前后带空白的值一律按格式非法拒绝。 + if value == "" { + return nil, nil + } + // 小数秒会被 time.Parse 隐式接受(即使 layout 不含小数位),必须先显式拒绝。 + // 允许集合的完整形状由 timeFilterPattern 再次锁定。 + if strings.Contains(value, ".") { + return nil, TimeFilterFormatError(field) + } + if !timeFilterPattern.MatchString(value) { + return nil, TimeFilterFormatError(field) + } + parsed, err := time.Parse(timeFilterLayout, value) + if err != nil { + return nil, TimeFilterFormatError(field) + } + utc := parsed.UTC() + return &utc, nil +}