# 需求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. 回滚时可关闭新增场景入口，但保留任务、文件和字段权限历史。

禁止在角色权限尚未初始化时默认放开全部敏感字段；无法解析权限时应拒绝导出并记录错误。
