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

统一时间筛选:新增共享严格解析器 pkg/utils/time_range.go,只接受带显式时区的 RFC3339 秒级时间(拒绝小数秒、无时区、date-only、空格分隔、±hhmm、未补零、非法日期与越界偏移),闭区间含两端、归一为 UTC 瞬时,创建期与执行期共用同一份实现。

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

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

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

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

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

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

View File

@@ -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=0HTTP 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 0total 与缺省一致)**
```
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 含「start1s / ==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:59start1s、06:00:01end+1s、08:00:008 小时漂移探针)
导出 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 导出、表头、加油包**
fixture3 个资产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
(库内冻结行 69scope_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 第 112 项):
| 表 | 标记方式 | 造行数 | 用途 |
| --- | --- | --- | --- |
| `tb_shop` | `shop_name LIKE 'AUG26014%'` | 1 | 临期资产归属店铺(`parent_id=1`,供代理可见链路) |
| `tb_iot_card` | `iccid LIKE 'AUG26014%'` | 3 | 临期资产 |
| `tb_package_usage` | `package_name LIKE 'AUG26014%'` | 43 主套餐 + 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 第 1314 项):
| 表 | 标记方式 | 造行数 | 用途 |
| --- | --- | --- | --- |
| `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%'` | 32 条窗内 + 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:2617:37 | `d52be16` / 修复前二进制 | 仅作预演记录;本文件引用的有效证据全部为修复后批次 |
| 第一批§6.2 第 112 项) | 17:3917:47 | `d52be16` / `51c619f8…` | T7 实现尚未提交;当时 HEAD 尚不含维护者的路由门禁修复,故代理 HTTP 项为 403/1005 |
| 第二批§6.2 第 1314 项、§6.1 第 1517 行) | 18:0x18: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]'` = 07.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 <agent-token>" '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 <agent-token>" -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)

View File

@@ -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"
}
]

View File

@@ -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
}
}
]