更新一下

This commit is contained in:
2026-07-21 15:26:07 +09:00
parent 2823ff13bf
commit 4902a02c87
32 changed files with 4138 additions and 294 deletions

View File

@@ -0,0 +1,286 @@
# PRDUR#42 统一导出场景与角色字段权限
Status: ready-for-agent
---
## Problem Statement
当前系统已有 `tb_export_task + DataSource + Asynq` 异步导出框架,但只支持 `device``iot_card``order` 三个场景,导出列由各 DataSource 固定返回,角色无法控制可导出的字段。本期还需要补齐钱包流水、套餐、退款、换货、代理充值和临期资产导出,并扩展卡导出字段。
导出字段权限必须是后端权威,但不应再引入“用户创建任务时选择字段”这一层:用户只决定导出哪个列表、使用什么格式和哪些查询条件;实际导出列完全由代码支持字段目录与账号角色授权决定。字段授权只控制列,不能扩大账号原有页面、接口或数据行范围。
退款、充值业务凭证及审批附件可能是图片,也可能是普通文件,不能嵌入 CSV/XLSX。对象存储 Bucket 当前为私有,现有预签名 URL 只有短期有效期,也不能把对象 Key 或公开 Bucket 地址写入导出文件。导出需要提供长期稳定、每次访问重新鉴权的业务附件链接。
## Solution
保留现有导出任务、分片、文件合并、对象存储和下载流程,为每个 Scene 建立代码字段目录,并新增角色—场景—字段授权表。
创建任务时,后端根据当前账号的有效角色计算字段授权并集,与代码字段目录求交集,得到全部最终字段;前端不传 `fields`,也不展示导出列选择器。超级管理员拥有代码目录内全部字段。普通账号最终字段为空时不创建零列任务。
任务创建时一次性保存最终字段、实际表头、查询条件和数据权限快照。Worker 只消费快照,角色权限之后增加或收回都不会改变已经创建的文件列。
退款和充值导出的凭证及审批附件以长期稳定的后台前端落地页 URL 表示。落地页使用当前登录态调用后端附件解析接口;后端重新校验该业务单的数据权限,再为私有对象生成短期预签名地址。导出文件不包含文件内容、对象 Key、企微 `media_id` 或永久公开地址。
## User Stories
1. 作为运营人员,我只需按当前列表查询条件发起导出,不需要再次选择字段。
2. 作为角色管理员,我可以按导出场景配置一个角色能够导出的字段,并通过空列表收回该场景全部字段。
3. 作为拥有多个有效角色的账号,我可以导出这些角色授权字段的并集,但只能导出原本有权查看的数据行。
4. 作为超级管理员,我可以使用全部代码支持字段并在停机窗口内为普通角色完成权限配置。
5. 作为退款或充值数据的合法查看者,我可以从历史导出文件打开长期稳定的附件入口;打开时仍需登录且仍需具备当前数据权限。
6. 作为维护人员,我希望导出重试、分片和恢复始终使用任务创建时的字段、表头和权限快照,不因实时角色变化出现不同列或越权。
## Implementation Decisions
### 最终字段规则
- 创建请求不包含 `fields`。最终字段固定为:
```text
resolved_fields = 当前账号有效角色的字段授权并集
∩ 当前 Scene 的代码支持字段目录
```
- 超级管理员不读取角色字段授权表,直接获得当前 Scene 代码目录中的全部字段。
- 普通账号的有效角色解析必须复用现有账号权限服务的角色解析语义,包括账号角色与店铺角色的既有继承/回退规则;不得为导出另造一套角色归属算法。
- 多个有效角色的字段权限取并集。数据库中已经失效、软删除或不属于该 Scene 代码目录的授权记录不能生效。
- 代码目录只注册产品允许被导出的业务字段。密码、密钥、Token、对象存储 Key、企微 `media_id`、内部配置原文等确定不能导出的内容不进入目录,因此无法通过角色配置放开。
- 对已经进入代码目录的字段,不再按“敏感字段”等主观分类追加第二层业务限制;但字段授权不能突破来源业务本身按主体定义的信息可见性。角色字段配置是可见业务字段能否导出的最高依据,不是业务详情权限的替代品。
- `resolved_fields` 按代码目录的固定顺序输出,不能受角色记录插入顺序影响。
- 普通账号权限解析失败时返回错误,不能回退全字段;解析成功但最终字段为空时拒绝创建任务,提示“当前角色未配置该场景的导出字段”。
- 不再存在 `default_selected`、`required` 或后端自动补列。获授权字段全部导出,未授权字段全部不导出。
### 场景访问权与数据行权限
- 字段授权只控制列不授予菜单、按钮、API 或业务场景访问权。账号仍需先通过现有路由权限和对应列表/业务场景的访问校验。
- 保持当前导出服务允许的账号类型和数据范围,不借 UR#42 新增企业账号导出能力;后续如要开放企业导出应另行定义 Scene 的企业数据范围。
- 代理继续使用任务创建时保存的 `ScopeShopIDs`;平台代理和各层级代理的数据范围继续复用现有 GORM 权限及店铺层级语义。请求中的 `shop_id` 等筛选只能缩小范围,不能扩大范围。
- `Count` 与 `Fetch` 必须使用完全相同的业务筛选和权限范围;不能先 Count 全量、再在 Fetch 中过滤,也不能在 Worker 中读取实时登录上下文。
### 字段目录
- 每个字段定义至少包含稳定 `key`、中文展示 `label`、表头解析器和行值解析器。普通字段通常对应一个表头;动态组合字段允许一个字段 key 展开为多个实际表头。
- `scene + field_key` 一经发布就是角色配置契约,不得仅因中文文案调整而改 key。
- 代码目录与 DataSource 注册必须来自同一个 Registry避免 API 显示可授权字段但 Worker 不支持,或 Worker 能导出未进入权限目录的字段。
- 现有动态卡槽列作为一个逻辑字段组授权:
- `device.bound_cards` 展开为每个实际卡槽的 ICCID、接入号、运营商、使用流量和卡状态
- `order.bound_cards` 展开为每个实际卡槽的接入号和 ICCID。
- 动态字段组的实际列数在任务创建时依据同一查询条件和数据权限解析,并写入 `resolved_headers`;字段权限只控制整个字段组,不按“第几个卡槽”拆出易失效的权限 key。
各场景稳定字段目录如下;具体中文状态必须调用 `pkg/constants/` 的现有枚举名称函数,不能在导出源中另写一套枚举:
| Scene | 稳定字段 key |
|-------|---------------|
| `device` | `virtual_no`、`imei`、`device_name`、`shop_name`、`series_name`、`device_model`、`bound_cards`、`total_usage_mb`、`active_package_activated_at`、`active_package_expires_at`、`active_package_name`、`wallet_balance` |
| `iot_card` | `iccid`、`msisdn`、`device_virtual_no`、`carrier_name`、`shop_name`、`device_name`、`realname_status`、`first_realname_at`、`network_status`、`package_name`、`data_usage_mb`、`remaining_data_mb` |
| `order` | `order_no`、`shop_name`、`asset_identifier`、`package_names`、`seller_cost_price`、`actual_paid_amount`、`payment_status`、`payment_method`、`quantity`、`created_at`、`third_party_trade_no`、`bound_cards` |
| `agent_wallet_transaction` | `shop_name`、`transaction_type`、`amount`、`status`、`asset_type`、`asset_identifier`、`created_at`、`balance_before`、`balance_after`、`package_name`、`operator_name`、`transaction_id`、`business_order_no`、`payment_method` |
| `package` | `package_code`、`package_name`、`series_name`、`package_type`、`duration_months`、`duration_description`、`calendar_type`、`duration_days`、`real_data_mb`、`virtual_data_mb`、`virtual_data_enabled`、`virtual_ratio`、`data_reset_cycle`、`expiry_base`、`cost_price`、`suggested_retail_price`、`price_config_status`、`status`、`shelf_status`、`is_gift`、`creator_id`、`updater_id`、`created_at`、`updated_at`、`deleted_at` |
| `refund` | `refund_no`、`shop_name`、`payment_order_no`、`asset_type`、`asset_identifier`、`package_name`、`original_amount`、`actual_received_amount`、`refundable_amount`、`requested_refund_amount`、`actual_refund_amount`、`status`、`refund_reason`、`remark`、`approval_source`、`approval_status`、`processing_status`、`current_node_name`、`approval_records`、`applied_at`、`completed_at`、`submitter_name`、`voucher_links`、`approval_attachment_links` |
| `exchange` | `exchange_no`、`exchange_type`、`exchange_reason`、`problem_description`、`old_asset_type`、`old_asset_identifier`、`new_asset_identifier`、`receiver_name`、`receiver_phone`、`receiver_address`、`express_company`、`tracking_no`、`status`、`submitter_name`、`created_at` |
| `agent_recharge` | `recharge_no`、`shop_name`、`recharge_type`、`recharge_amount`、`actual_amount`、`balance_before`、`balance_after`、`status`、`payment_method`、`operation_remark`、`reject_reason`、`created_at`、`paid_at`、`completed_at`、`submitter_name`、`approval_source`、`approval_status`、`current_node_name`、`approval_records`、`voucher_links`、`approval_attachment_links`、`remark` |
| `expiring_asset` | `asset_type`、`asset_identifier`、`shop_name`、`package_name`、`estimated_final_expires_at`、`days_until_final_expiry`、`expiry_level`、`data_limit_mb`、`data_usage_mb`、`remaining_data_mb` |
- `package` 保持评审确认的 25 个字段 key。软删除套餐是否进入结果仍与套餐列表当前查询条件一致当列表不返回软删除数据时`deleted_at` 列为空,但字段契约仍保留。
- `agent_recharge` 保留“支付方式” `payment_method`,明确不注册或导出“支付通道” `payment_channel`。
- 钱包流水的套餐名称优先读取订单项不可变 `package_name` 快照;仅历史缺失时回退流水 `metadata.package_name`,禁止关联当前可修改套餐名。
- 金额数据库与内部查询使用分,导出统一格式化为带两位小数的元;流量统一使用 MB并避免剩余量出现因计算误差导致的负零。
### 字段权限数据模型与迁移
- 新增 `tb_role_export_field_permission`,至少包含项目统一主键/审计时间、`role_id`、`scene`、`field_key`、`creator`、`updater`;不建立数据库外键或 GORM 关联标签。
- 对未软删除记录建立 `(role_id, scene, field_key)` 唯一索引,并建立按 `role_id + scene` 查询的索引。
- 迁移不为任何普通角色初始化字段权限,也不根据现有菜单权限猜测默认列。
- 发布采用已确认的短暂停机流程部署数据库、API、Worker 和前端后,业务使用超级管理员为普通角色逐场景配置字段;完成配置与抽样验证后再恢复普通用户访问。
- 超级管理员不依赖本表,所以即使表为空也能打开角色配置和执行全字段导出。
- 回滚数据库结构前必须确保新 API/Worker 已回滚并处理或终止使用 `resolved_fields` 的待执行任务,不能让旧 Worker 把缺失字段快照理解为全字段。
### 字段权限 API
#### 查询当前账号最终字段
```text
GET /api/admin/export-fields?scene={scene}
```
- 返回当前账号在该 Scene 最终会导出的字段,不是“可供用户勾选”的候选集。
- 成功响应结构:
```json
{
"scene": "package",
"fields": [
{"key": "package_code", "label": "套餐编码"},
{"key": "package_name", "label": "套餐名称"}
]
}
```
- 普通账号没有字段授权时返回 `200` 和空 `fields`,便于前端隐藏/禁用导出按钮;这不代表账号获得场景访问权。
- Scene 不存在返回统一参数错误;角色权限解析失败返回服务错误,不能伪装成空列表。
#### 查询与保存角色字段
```text
GET /api/admin/roles/{role_id}/export-fields
PUT /api/admin/roles/{role_id}/export-fields
```
- GET 返回代码支持的全部 Scene 及该角色当前保存的字段 key供角色管理页分组展示。
- PUT 请求体固定为:
```json
{
"scene": "package",
"field_keys": ["package_code", "package_name"]
}
```
- PUT 是该角色单个 Scene 的全量替换;`field_keys=[]` 合法,表示收回该角色该 Scene 的全部字段。
- 后端验证角色存在且操作者有权管理该角色、Scene 已注册、每个 key 属于该 Scene未知 key 整单拒绝,不进行部分保存。重复 key 去重后保存。
- 全量替换在一个 PostgreSQL 事务内完成,成功后写全局 Audit Event记录角色、Scene、变更前后字段集合和操作者审计失败则权限变更回滚。
- 角色字段管理沿用角色管理的后台权限,不因能够查询自己的 `export-fields` 就允许修改角色。
### 创建任务 API 与快照
```text
POST /api/admin/export-tasks
```
请求体固定为:
```json
{
"scene": "refund",
"format": "csv",
"query": {
"filters": {
"status": 1,
"shop_id": 10
}
}
}
```
- `format` 继续支持 `csv|xlsx`;用户对批量导入选择 CSV 不改变导出同时支持两种格式的现有契约。
- `query.filters` 使用对应列表的同名、同类型、同边界筛选条件,各条件按 AND 组合。分页参数不进入导出筛选,导出所有匹配行。
- 请求体即使额外传入历史设计的 `fields` 也不能影响结果DTO/OpenAPI 不再声明该字段。实现按项目 JSON 解析既有兼容策略处理未知字段,但绝不能读取它决定列。
- Service 在写 `tb_export_task` 前完成:账号/场景访问校验、数据权限范围快照、角色字段解析、最终字段解析和实际表头解析。任何一步失败都不创建任务、不入队。
- `query_json` 保存原始规范化 `filters`、`resolved_fields`、`resolved_headers`;现有权限字段继续保存于任务专用快照列。表头和字段必须同时非空且映射一致。
- 任务创建与 dispatch 入队仍沿用现有失败闭环Asynq 载荷必须传 `{task_id}` struct禁止预序列化为 `[]byte`。
- Worker 不重新查询角色字段权限,也不能在缺失/解析失败时回退 DataSource 全字段。新版本 Worker 遇到没有 `resolved_fields` 的遗留待执行任务必须安全失败;停机发布前应先盘点并处理旧待处理/处理中任务。
### DataSource 与异步执行
- 复用现有 `DataSource.Scene/Count/Headers/Fetch` 和 dispatch → shard → finalize 流程,不另建导出队列。
- `ExportParams` 增加任务快照的 `ResolvedFields`,并继续携带 `ResolvedHeaders`、`Filters`、`ScopeShopIDs`、账号类型和创建店铺。
- `Headers` 只按 `ResolvedFields` 生成实际表头;动态字段组允许查询同一数据范围确定展开数量。任务创建后所有分片和 finalize 只使用已保存表头。
- `Fetch` 只查询并输出已解析字段,输出顺序与 `ResolvedHeaders` 严格一致。即使框架会兜底补齐/截断DataSource 也不得依赖该兜底掩盖字段错位。
- 每个 Scene 的 `Count` 和 `Fetch` 共享一个筛选构造函数JOIN 可能放大行数时必须显式保持“一条业务实体一行”的语义。
- `Fetch` 使用稳定排序,通常为业务表 `id ASC`;临期场景使用 UR#33 已确认的“03 天优先、最终到期升序、资产类型、资产 ID”稳定排序。
- 退款、充值的提交人和审批摘要复用 UR#44/企微审批 Query按当前分片业务 ID/实例 ID 批量读取;附件引用也批量读取,禁止逐行或逐附件 N+1。
- `device`、`order` 的动态卡槽组及新增字段权限改造不能改变未授权字段之外的现有行语义、筛选语义和金额/时间格式。
### 各新增/扩展场景规则
- `iot_card`:新增当前生效主套餐名称、已用流量、剩余流量;一个卡只输出一行。生效主套餐按 `status + master_usage_id + priority + id` 稳定选取,不能由 JOIN 产生重复卡行。剩余流量按权威额度减已用量计算。
- `iot_card` 新增 `final_expiry_within_days=30` 筛选,只接受本期定义的 30 天窗口:复用 UR#46 最终到期 Query按 `Asia/Shanghai` 自然日包含剩余 `030` 天,排除已过期和不可预计资产。它独立于 UR#33 页面 `015` 天临期定义。
- `agent_wallet_transaction`:与代理主钱包流水列表的筛选和店铺范围一致;交易 ID 是钱包流水 ID关联业务单号按流水引用类型批量解析。
- `package`:与套餐列表的筛选、可见范围和软删除语义一致,输出评审确定的 25 个稳定字段。
- `refund`:与退款列表的筛选、数据权限、提交人、审批来源、审批状态、本地处理状态一致;平台/超级管理员可按业务权限输出审批记录摘要和审批附件链接;代理即使角色获授相同字段 key也只能输出审批结论和自身业务资料`current_node_name`、`approval_records`、`approval_attachment_links` 不得包含平台内部审批人、意见或审批附件。业务退款凭证仍按退款查看权限输出。
- `exchange`:与换货列表筛选及数据权限一致;本期换货不接企微审批,不伪造审批列。
- `agent_recharge`:与代理充值列表筛选和数据权限一致;在线充值 `approval_source=none`,平台员工线下充值按企微/历史规则输出摘要;包含支付方式但不包含支付通道,另行输出业务支付凭证链接和审批附件链接。
- `expiring_asset`:完全复用 UR#33 `GET /api/admin/expiring-assets` 的 `015` 天定义、筛选、排序和数据范围,不以普通卡列表的 30 天筛选代替。
### 受保护的长期附件链接
- 覆盖旧评审“审批附件只输出数量”的结论:`refund` 和 `agent_recharge` 可分别配置 `voucher_links` 与 `approval_attachment_links` 字段,输出实际附件入口。审批摘要中的“附件 N 个”仍可保留为可读摘要,不与链接列冲突。
- `approval_attachment_links` 受来源业务的主体可见性约束:仅平台账号或超级管理员在具备对应业务查看权限且角色获授字段时输出;代理即使其角色配置包含该字段,也必须输出空值,且不能通过稳定引用解析平台内部审批附件。`voucher_links` 仍按代理已有退款业务资料权限处理。
- 附件文件继续存放在私有 Bucket严禁把 `file_key`、企微 `media_id`、当前预签名 URL或公开 Bucket URL写入导出文件。
- 每个本地附件拥有不可变、不可枚举的稳定 `attachment_ref`,并关联 `biz_type + biz_id + attachment_kind + private file_key + filename`。不建立数据库外键。
- 若企微公共审批能力已经提供等价的不可变本地附件模型UR#42 必须直接复用;不得再建仅供导出的第二套附件表。退款/充值业务凭证和企微审批附件都必须能够解析为同一种稳定引用。
- 现有退款、充值 JSONB 凭证在迁移/停机准备阶段建立稳定引用;后续新建或替换凭证时在同一业务事务维护引用。替换只改变业务单当前附件集合,不让已有 `attachment_ref` 改指另一文件。
- 企微审批人上传的附件必须在企微详情同步阶段保存到本地私有对象存储并建立稳定引用;临时 `media_id` 不能作为永久下载来源。历史记录确实没有本地附件事实时输出空,不伪造链接。
- 导出文件中的地址是后台前端落地页绝对 URL例如
```text
{admin_frontend_base_url}/export-attachments/{attachment_ref}
```
需要新增受控部署配置 `frontend.admin_base_url`(环境变量 `JUNHONG_FRONTEND_ADMIN_BASE_URL`)。当最终字段包含附件链接而该配置缺失/非法时,创建任务失败,不能生成相对地址或错误链接。
- 一个单元格有多个附件时,按附件创建顺序输出 `文件名: URL`使用换行分隔CSV 必须正确引用含换行单元格XLSX 开启单元格换行。文件名缺失时使用“附件1、附件2……”稳定展示。
- 前端落地页读取后台当前登录态;未登录先进入登录流程,并在登录成功后返回该附件页。因为后台 API 使用 Bearer Token导出文件不能直接依赖浏览器自动给 API URL 添加 Authorization。
- 落地页调用:
```text
GET /api/admin/attachments/{attachment_ref}/download-url
```
后端按附件关联的退款单、充值单或企微审批业务单重新执行当前账号的数据权限、业务查看权限和附件种类可见性校验,成功后返回短期 `download_url`、`expires_at`、`file_name`。代理可访问其业务范围内的退款业务凭证,但不得解析企微内部审批附件;前端随后打开该短期地址。
- 无权与不存在使用统一错误,防止探测附件引用;账号角色或数据范围后来被收回后,即使持有旧导出文件也不能下载。
- 附件落地页链接长期稳定不等于文件永久公开。对象删除、业务记录不可见或本地附件同步失败时必须展示明确失败状态,不得降级暴露 Key。
- 当某任务获授权导出审批附件链接而匹配记录存在尚未完成本地保存的企微附件时,任务应明确失败并提示先完成审批附件同步后重试,不能输出必然失效的伪链接。
### 前端
- 各对应列表页保留一个导出入口,只提交当前 Scene、格式和当前列表查询条件不展示字段复选框。
- 页面可在加载时调用 `GET /api/admin/export-fields?scene=`。空字段时禁用导出按钮并提示“当前角色未配置该场景的导出字段”;后端仍做相同校验。
- 角色管理页按 Scene 展示代码字段目录并全量保存该角色授权;允许清空某个 Scene。保存成功后重新读取服务端结果不做仅前端生效的乐观权限状态。
- 导出任务状态、进度、失败原因、取消和下载继续复用现有任务页面与轮询规则;失败时不自动重复创建任务。
- 新增受保护附件落地页,覆盖登录恢复、加载、无权限、文件不存在、对象存储失败和成功跳转状态。
### 架构与依赖
- 字段目录、角色字段解析和角色配置属于权限/Application 边界;导出列表与字段投影属于 Query/DataSource 边界,不创建无业务价值的聚合根。
- 角色字段全量替换是简单写事务脚本;成功事务写统一 Audit Event。
- 本需求复用而不重新实现UR#46 最终到期 Query、UR#33 临期 Query、UR#44 提交人/审批摘要,以及企微审批公共能力的审批实例和本地附件镜像。
- 若上述依赖未落地,对应 Scene 不得用临时简化查询或外部短期 URL冒充完成可以先实现不依赖它们的场景但整项 UR#42 验收必须覆盖全部 Scene。
### 发布与回滚
- 停机前盘点 `tb_export_task` 中待处理/处理中任务,完成、取消或明确失败后再切换,避免新旧 Worker 对 `query_json` 快照格式理解不同。
- 停机发布数据库迁移、后端、Worker 和前端超级管理员验证全字段目录、角色保存、任务创建、CSV/XLSX 和附件落地页。
- 业务在停机窗口内配置普通角色字段权限并按平台、不同层级代理抽样验证行列权限;不执行任何自动授权数据迁移。
- 恢复服务后监控权限解析失败、零字段拒绝、各 Scene 任务失败、附件本地化缺失及 Count/Fetch 行数差异。
- 回滚时先停止新任务,处理新版本未完成任务,再回滚 Worker/API/前端和数据库;已经生成的导出文件仍按附件落地页的当前鉴权访问。
## Testing Decisions
- 字段权限单元/集成测试覆盖超级管理员全目录、单角色、多角色并集、账号角色与店铺角色既有解析规则、软删除/禁用角色关联、空授权、陈旧字段 key 和权限存储异常。
- API 测试覆盖 `GET /export-fields` 的全部/部分/空结果,角色 GET/PUT 全量替换、清空、重复 key、未知 Scene、未知字段、越权管理角色及审计失败回滚。
- 创建任务测试验证请求不含 `fields`、伪造 `fields` 不影响结果、零字段不建任务、权限解析失败不回退、`resolved_fields/resolved_headers` 与权限/范围在入队前已经快照。
- 验证创建任务后增加或收回角色字段不会改变该任务Worker 遇到缺失/损坏字段快照安全失败,不导出全字段。
- 对每个 Scene 验证代码目录、表头、行值一一对齐;只授予单字段、多个离散字段和动态 `bound_cards` 字段组时均不串列。
- 每个 Scene 的 Count/Fetch 使用同一筛选和权限范围;对平台、上级代理、下级代理、越权 `shop_id` 和 0/1/多条结果验证总数及文件行数。
- 查询性能测试使用 100 行一页/分片,确认钱包业务单号、提交人、审批摘要、附件、卡套餐和临期数据均为批量查询,无逐行或逐附件 N+1代表性 SQL 执行 `EXPLAIN ANALYZE`。
- `iot_card` 覆盖无套餐、一个生效主套餐、多个异常候选、剩余流量、预计最终到期 0/30/31 天、已过期和不可预计状态。
- `expiring_asset` 覆盖 UR#33 的 0/3/4/7/8/15/16 天边界、03 天置顶和稳定分页。
- 金额、流量、时间和所有 int 状态字段验证中文名称来自 constantsCSV 与 XLSX 展示一致。
- 附件测试覆盖图片、PDF/Word/Excel 等普通文件、多附件换行、中文/特殊字符文件名、CSV 引用、XLSX 换行、业务凭证和企微审批附件。
- 附件安全测试验证导出文件不含 `file_key/media_id/预签名 URL`;未登录可登录后返回,当前有权可获取短期地址,权限后来收回、越权店铺、引用不存在和对象缺失均不能下载。
- 主体可见性测试验证同一退款在平台视角可按字段权限导出审批摘要和审批附件链接,代理视角即使角色获授这些字段也不能得到审批人、内部意见或审批附件链接;已知审批附件 `attachment_ref` 的代理仍无法解析。
- 附件替换测试验证旧 `attachment_ref` 不改指新文件,新导出只列当前业务附件,旧导出链接仍指原文件但每次访问重新校验当前业务权限。
- 企微附件未本地化时验证任务明确失败;完成同步后重试可生成有效稳定链接。
- 异步流程测试覆盖空结果文件、单分片、多分片、分片重试、取消、finalize、CSV/XLSX、对象存储失败和恢复最终文件只有一个表头且列顺序固定。
- HTTP 集成测试穿过 Fiber、认证、Application/Query、GORM、真实开发 PostgreSQL/Redis 和 Asynq 可控接缝,使用 `.env.local` 的开发环境配置但不在日志/测试产物中输出任何密钥。
- 前端验收覆盖无字段禁用、按角色显示实际导出列、查询条件透传、任务刷新恢复、失败原因、下载、附件登录恢复和无权状态。
## Out of Scope
- 不让用户在每次导出时选择字段,不接受前端 `fields[]` 作为列权限来源。
- 不建设字段级数据脱敏、按单字段追加“敏感/非敏感”判断或基于角色名称猜测权限。
- 不通过字段授权新增菜单、接口或数据行访问权。
- 不把对象存储改为公开 Bucket不生成永不过期的对象存储签名 URL也不把附件二进制嵌入 CSV/XLSX。
- 不为企业账号新增导出范围。
- 不替换现有导出任务、Asynq 分片、对象存储和最终下载框架。
- 不在本需求改变退款、充值、换货、套餐、钱包或临期业务状态机。
## Further Notes
- 本规格覆盖标准评审稿中两项旧口径:
1. `scene + format + query + fields`、用户勾选字段、`default_selected/required` 改为仅提交 `scene + format + query`,后端按角色字段权限自动导出全部授权列;
2. “审批附件只输出数量”改为业务凭证和审批附件均可通过字段权限导出受保护的长期业务链接,审批摘要仍可同时保留附件数量。
- 当前代码的创建 DTO 本来就没有 `fields`,因此第一项主要是冻结后续实现方向,不需要兼容已上线的字段选择客户端。
- 当前对象存储明确是私有 Bucket现有预签名下载 URL 有效期有限;“永久 URL”在本规格中专指稳定业务落地页地址任何实际文件访问都必须重新鉴权并换取短期对象地址。
- `GET /api/admin/storage/batch-download-urls` 只按 `file_key` 生成临时地址,不能直接作为导出附件入口,因为它既暴露内部 Key也没有按退款/充值业务单重新校验数据权限。