# 需求14:导出功能 > 状态:待评审 > 复用现有 ExportTask 体系(`tb_export_task` + Asynq),不为每个业务模块新增一套导出路由。 --- ## 导出模块总览 | 编号 | 模块 | 新增/修改 | |------|------|---------| | EXPD-001~003 | IoT卡导出 | 新增套餐名称、使用流量、剩余流量字段 | | 6.8.2 | 代理资金概况-预充值钱包流水导出 | **全新** | | 6.8.3 | 套餐列表导出 | **全新** | | 6.8.4 | 退款管理退款列表导出 | **全新** | | 6.8.5 | 换货管理导出 | **全新** | | 6.8.6 | 代理充值导出 | **全新**(去掉"支付通道"字段) | | 需求22 | 临期资产列表导出 | **全新**,`scene=expiring_asset` | --- ## 现有导出体系说明 系统已有异步导出框架(`internal/exporter/`): - `tb_export_task` 表记录导出任务 - 导出逻辑通过 `DataSource` 接口实现,每个场景一个文件(如 `iot_card_scene.go`) - `registry.go` 的 `NewDefaultRegistry()` 统一注册所有场景 - Asynq Worker 根据任务里的 `scene` 字段,从 Registry 取对应 DataSource 执行 - 前端轮询任务状态后下载 ```mermaid sequenceDiagram actor User as 后台用户 participant Web as 前端 participant API as ExportTask API participant DB as PostgreSQL participant Worker as Asynq Worker participant Storage as 对象存储 User->>Web: 选择导出字段并确认 Web->>API: POST /api/admin/export-tasks API->>API: 计算数据范围和字段权限交集 API->>DB: 保存查询、范围和字段快照 API-->>Web: task_id API->>Worker: 入队导出任务 Worker->>DB: 按 scene 分片查询 Worker->>Storage: 上传导出文件 Worker->>DB: 更新进度和 download file_key Web->>API: 轮询 GET /api/admin/export-tasks/{id} API-->>Web: 状态、进度、download_url ``` 新增导出模块需要: 1. 在 `pkg/constants/constants.go` 新增 `ExportTaskSceneXxx` 场景常量 2. 在 `internal/exporter/` 新建 `xxx_scene.go`,实现 `DataSource` 接口(`Scene()`/`Count()`/`Headers()`/`Fetch()`) 3. 在 `registry.go` 的 `NewDefaultRegistry()` 中注册,并更新 `IsSupportedScene()` 4. 扩展统一 `CreateExportTaskRequest.Scene` 的允许值,通过 `POST /api/admin/export-tasks` 创建任务 5. 在字段目录中注册稳定字段 key、中文表头、默认选择和所需导出权限 统一创建请求扩展为: ```go type CreateExportTaskRequest struct { Scene string `json:"scene" validate:"required" description:"导出场景"` Format string `json:"format" validate:"required,oneof=xlsx csv" description:"导出格式"` Query map[string]any `json:"query,omitempty" description:"与列表一致的筛选条件"` Fields []string `json:"fields" validate:"required,min=1" description:"申请导出的字段key"` } ``` 新增场景:`agent_wallet_transaction`、`package`、`refund`、`exchange`、`agent_recharge`、`expiring_asset`。DTO description 和 `pkg/constants/` 必须同步维护。 --- ## EXPD-001~003:IoT卡导出字段新增 **修改文件**:`internal/exporter/iot_card_scene.go` 在 `Headers()` 末尾追加三列,`Fetch()` 的 `Select` 追加字段,`iotCardExportRow` 追加字段: ```go // Headers() 新增 "套餐名称", "使用流量(MB)", "剩余流量(MB)" // baseQuery() 或 Fetch() 新增 JOIN LEFT JOIN LATERAL ( SELECT pu.package_id, pu.data_usage_mb, pu.data_limit_mb, p.package_name FROM tb_package_usage pu JOIN tb_package p ON p.id = pu.package_id AND p.deleted_at IS NULL WHERE pu.iot_card_id = c.id AND pu.status = 1 AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL LIMIT 1 ) AS pkg ON TRUE // iotCardExportRow 新增 PackageName string `gorm:"column:package_name"` DataUsageMB int64 `gorm:"column:data_usage_mb"` DataLimitMB int64 `gorm:"column:data_limit_mb"` ``` 剩余流量 = `DataLimitMB - DataUsageMB`(在行转换时计算) --- ## 6.8.2:代理资金概况-预充值钱包流水导出 **新增场景**:`scene=agent_wallet_transaction` 支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。 导出字段映射: | 字段 | 数据来源 | |------|---------| | 店铺名称 | JOIN `tb_shop` | | 交易类型 | `transaction_type`(充值/扣款/退款等,中文化) | | 交易金额 | `amount / 100` 转元 | | 状态 | `status` 中文化 | | 资产类型 | `asset_type` 中文化 | | 资产标识 | `asset_identifier` | | 交易时间 | `created_at` | | 交易前金额 | `balance_before / 100` | | 交易后金额 | `balance_after / 100` | | 购买套餐名称 | 新数据读取 `tb_order_item.package_name` 不可变快照;历史缺失时才回退 `metadata.package_name`,禁止关联当前套餐名称 | | 操作人 | JOIN `tb_account`(`creator` 字段关联 `tb_account.id`,取 `username`) | | 交易 ID | `id` | | 关联业务订单号 | `reference_id` 对应的单号(JOIN 对应表) | | 交易渠道/支付方式 | `metadata` 中 `payment_method` 字段 | --- ## 6.8.3:套餐列表导出 **新增场景**:`scene=package` 支持现有套餐列表筛选条件。 导出字段(按实际列举的 25 个稳定字段 Key): ```go type PackageExportRow struct { PackageCode string `xlsx:"套餐编码"` PackageName string `xlsx:"套餐名称"` SeriesName string `xlsx:"套餐系列名称"` // JOIN tb_package_series PackageType string `xlsx:"套餐类型"` // formal/addon 中文化 DurationMonths int `xlsx:"套餐时长(月)"` DurationDaysDesc string `xlsx:"套餐时长说明"` // 剩余天数说明 CalendarType string `xlsx:"套餐周期类型"` DurationDays int `xlsx:"套餐天数"` RealDataMB int64 `xlsx:"真流量额度(MB)"` VirtualDataMB int64 `xlsx:"虚流量额度(MB)"` EnableVirtualData string `xlsx:"是否启用虚流量"` // 是/否 VirtualRatio float64 `xlsx:"虚流量比例"` DataResetCycle string `xlsx:"流量重置周期"` ExpiryBase string `xlsx:"到期时间基准"` CostPrice string `xlsx:"成本价(元)"` // 分→元 SuggestedRetailPrice string `xlsx:"建议售价(元)"` PriceConfigStatus string `xlsx:"价格配置状态"` Status string `xlsx:"状态"` ShelfStatus string `xlsx:"上架状态"` IsGift string `xlsx:"是否赠送套餐"` CreatorID uint `xlsx:"创建人ID"` UpdaterID uint `xlsx:"更新人ID"` CreatedAt string `xlsx:"创建时间"` UpdatedAt string `xlsx:"更新时间"` DeletedAt string `xlsx:"删除时间"` } ``` --- ## 6.8.4:退款管理退款列表导出 **新增场景**:`scene=refund` 导出字段(去掉“退款到账方式”,审批信息使用动态摘要,不固定具体节点): ```go type RefundExportRow struct { RefundNo string `xlsx:"退款单号"` ShopName string `xlsx:"代理店铺名称"` PaymentOrderNo string `xlsx:"关联的支付订单号"` AssetType string `xlsx:"资产类型"` AssetIdentifier string `xlsx:"资产标识"` PackageName string `xlsx:"套餐名称"` OriginalAmount string `xlsx:"原订单金额"` ActualAmount string `xlsx:"实收金额"` RefundableAmount string `xlsx:"可退金额"` AppliedAmount string `xlsx:"申请退款金额"` ActualRefundAmount string `xlsx:"实际退款金额"` Status string `xlsx:"状态"` RefundReason string `xlsx:"退款原因"` Remark string `xlsx:"备注"` ApprovalSource string `xlsx:"审批来源"` ApprovalStatus string `xlsx:"审批状态"` ProcessingStatus string `xlsx:"退款处理状态"` CurrentNodeName string `xlsx:"当前审批节点"` ApprovalRecords string `xlsx:"审批记录"` AppliedAt string `xlsx:"退款申请时间"` CompletedAt string `xlsx:"退款完成时间"` SubmitterName string `xlsx:"提交人"` VoucherURLs string `xlsx:"退款凭证"` } ``` `ApprovalRecords` 格式示例:`业务审核:张三(通过,同意,附件2个);财务审核:李四(待处理)`。导出只记录附件数量,不导出对象存储 URL 或 file_key。`ProcessingStatus` 区分待人工退款、代理钱包回退处理中、已完成和处理失败。数据源按本批业务单的 `approval_instance_id` 批量查询审批任务、审批人和附件计数,禁止逐行查询。历史终态单据没有流程实例时,`ApprovalSource` 输出“历史审批”,审批记录仅使用原业务审批字段和审计日志,不伪造多节点记录。 --- ## 6.8.5:换货管理导出 **新增场景**:`scene=exchange` ```go type ExchangeExportRow struct { ExchangeNo string `xlsx:"换货单号"` ExchangeType string `xlsx:"换货类型"` ExchangeReason string `xlsx:"换货原因"` ProblemDesc string `xlsx:"问题描述"` OldAssetType string `xlsx:"旧资产类型"` OldAssetIdentifier string `xlsx:"旧资产标识符"` NewAssetIdentifier string `xlsx:"新资产标识符"` ReceiverName string `xlsx:"收货人姓名"` ReceiverPhone string `xlsx:"收货人电话"` ReceiverAddress string `xlsx:"收货地址"` ExpressCompany string `xlsx:"快递公司"` TrackingNo string `xlsx:"快递单号"` Status string `xlsx:"状态"` CreatorName string `xlsx:"创建人"` CreatedAt string `xlsx:"创建时间"` } ``` --- ## 6.8.6:代理充值导出 **新增场景**:`scene=agent_recharge` 去掉"支付通道"字段(需求文档中明确去掉),保留其他字段: ```go type AgentRechargeExportRow struct { RechargeNo string `xlsx:"充值单号"` ShopName string `xlsx:"店铺名称"` RechargeType string `xlsx:"充值类型"` RechargeAmount string `xlsx:"充值金额"` ActualAmount string `xlsx:"实付金额"` BalanceBefore string `xlsx:"充值前余额"` BalanceAfter string `xlsx:"充值后余额"` Status string `xlsx:"状态"` PaymentMethod string `xlsx:"支付方式"` // 去掉支付通道 OperationRemark string `xlsx:"运营备注"` RejectReason string `xlsx:"驳回原因"` CreatedAt string `xlsx:"创建时间"` PaidAt string `xlsx:"支付时间"` CompletedAt string `xlsx:"完成时间"` SubmitterName string `xlsx:"提交人"` ApprovalSource string `xlsx:"审批来源"` ApprovalStatus string `xlsx:"审批状态"` CurrentNodeName string `xlsx:"当前审批节点"` ApprovalRecords string `xlsx:"审批记录"` VoucherURLs string `xlsx:"支付凭证"` Remark string `xlsx:"备注"` } ``` 充值导出的审批记录格式和批量查询规则与退款导出一致。 --- ## 前端对接(通用模式) 各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。 调用流程: 1. 点击“导出”后请求当前账号在该场景可导出的字段目录。 2. 弹框只展示后端允许的字段,默认勾选 `default_selected=true` 的字段。 3. 提交 `scene + format + query + fields` 到 `POST /api/admin/export-tasks`。 4. 返回 `task_id` 后,前端轮询 `GET /api/admin/export-tasks/{id}` 直到终态。 5. `status=3` 时下载 `download_url`;失败时展示任务错误摘要,禁止自动重复创建任务。 (与现有导出体系完全一致,复用现有前端导出 Hook) --- ## 导出字段权限 需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。 评审结论:本迭代必须实现角色级导出字段配置,不作为后续预留。 该要求涉及真流量、成本价、身份材料等敏感数据,不能以“本期先全量导出”代替。字段权限必须由后端强制执行,前端复选框只负责展示。 ### 1. 字段目录 每个场景在代码中注册稳定字段 key: ```go // ExportFieldDefinition 导出字段定义 type ExportFieldDefinition struct { Key string Header string DefaultSelected bool Required bool } ``` 示例: ```text scene=package package_code 套餐编码 默认选择 package_name 套餐名称 默认选择 real_data_mb 真流量额度 敏感字段 virtual_data_mb 虚流量额度 默认选择 cost_price 成本价 敏感字段 ``` 表头中文可以调整,但 `scene + field_key` 一经发布不得随意改名,否则历史角色配置会失效。 ### 2. 角色字段授权表 ```sql CREATE TABLE tb_role_export_field_permission ( id BIGSERIAL PRIMARY KEY, role_id BIGINT NOT NULL, scene VARCHAR(50) NOT NULL, field_key VARCHAR(100) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), creator BIGINT NOT NULL DEFAULT 0 ); CREATE UNIQUE INDEX idx_role_export_field_permission ON tb_role_export_field_permission(role_id, scene, field_key); ``` 账号拥有多个角色时,字段权限按 RBAC 常规规则取并集;超级管理员拥有全部字段。数据行范围仍使用现有数据权限快照,字段权限不能扩大店铺或企业数据范围。 ### 3. 权限 API ```text GET /api/admin/export-fields?scene=package ``` 返回当前账号可选择的字段: ```json { "scene": "package", "fields": [ { "key": "package_code", "header": "套餐编码", "default_selected": true, "required": true } ] } ``` 角色管理: ```text GET /api/admin/roles/{role_id}/export-fields PUT /api/admin/roles/{role_id}/export-fields ``` 更新请求按场景提交字段 key 列表,后端校验字段存在并记录审计日志。 ### 4. 创建任务时的权限快照 创建任务时计算: ```text resolved_fields = requested_fields ∩ role_allowed_fields ∩ scene_supported_fields ``` - 必选字段由后端自动补齐。 - 交集为空时拒绝创建任务。 - `resolved_fields` 和对应 `resolved_headers` 写入任务的 `query_json`,Worker 不重新根据后来变化的角色权限扩大字段。 - Worker 只按照快照字段输出,未知字段直接失败,不允许静默回退到全量字段。 ### 5. 前端角色配置 - 角色编辑页增加“导出字段权限”页签,按场景分组展示复选框。 - 普通列表的导出弹框只显示当前账号被授权的字段。 - 敏感字段可以增加“敏感”标记,但标记不能替代后端权限。 - 用户取消所有可选字段时禁用提交按钮,并提示至少选择一个字段。 --- ## 查询与性能约束 - 退款和充值审批记录必须先按本批 `approval_instance_id` 批量查询,再在内存按实例分组,禁止逐行查审批任务。 - 关联业务单号、套餐名称和操作人必须使用批量 JOIN 或批量查询,禁止 N+1。 - DataSource 的 Count 和 Fetch 必须使用同一份权限过滤和查询条件。 - 动态字段不代表动态拼接不受控 SQL;字段 key 必须通过服务端白名单映射到固定查询列。 --- ## 发布与回滚 本需求随七月迭代停机发布: 1. 维护窗口内先执行角色字段权限表迁移,并初始化现有角色的最小可用字段集。 2. 同一发布窗口部署场景常量、DataSource、管理 API、Worker 和前端字段选择/角色配置页面。 3. 开放访问前验证普通角色、敏感字段角色和超级管理员的字段集合及实际导出文件。 4. 回滚时可关闭新增场景入口,但保留任务、文件和字段权限历史。 禁止在角色权限尚未初始化时默认放开全部敏感字段;无法解析权限时应拒绝导出并记录错误。