# PRD：UR#49 设备 CSV 批量分配代理或套餐系列

Status: ready-for-agent

---

## Problem Statement

设备号不连续，现有按页面勾选、ID 列表、号段或筛选条件操作不适合运营人员手中已有的一批离散设备清单。七月需求需要通过上传文件异步完成两种批量操作：把设备分配给代理，或者为设备设置套餐系列。

原需求稿使用 Excel，但本需求只有一列设备标识。Excel 文件更大、解析依赖更重，也容易让人误以为可以在同一表中混合多个业务动作。用户决定本期只使用 CSV，同时要求新入口继承现有平台和代理权限，不得因旧稿流程图写了“平台员工”而收窄为平台专用。

## Solution

设备管理页提供“批量分配代理”和“批量分配套餐系列”两个独立 CSV 入口。前端先通过现有对象存储预签名能力直传单列 UTF-8 CSV，再分别向两个业务接口提交 `file_key` 与唯一目标 ID。接口创建批量任务并向 Asynq 仅发送结构化 `task_id`；Worker 下载 CSV、完整校验、去重后按每批 200 个标识批量查询并执行对应命令。

批量用例复用现有设备分配和系列绑定业务规则：平台分配平台库存设备，代理只能向直属下级分配自己名下设备；代理设置系列时只能操作自己名下设备并使用自己已获授权的系列。一个任务只能修改 `shop_id` 或 `series_id` 之一，不能同时修改两者。

## User Stories

1. 作为平台员工，我希望上传一列离散设备号，将平台库存设备批量分配给目标代理。
2. 作为代理，我希望用相同入口将自己名下设备批量分配给直属下级代理。
3. 作为平台或代理，我希望上传设备清单批量设置套餐系列，并沿用现有系列权限。
4. 作为运营人员，我希望使用体积小、结构清晰的 CSV，不需要上传 Excel。
5. 作为运营人员，我希望看到任务进度、成功数、幂等数和逐行失败原因，并能修正失败行后重新提交。
6. 作为审计人员，我希望批量任务、实际设备归属变更、绑定卡同步和系列变更都有可追溯记录。

## Implementation Decisions

### 两个独立业务命令

- 批量分配代理：`POST /api/admin/devices/batch-assign-shop`。
- 批量分配套餐系列：`POST /api/admin/devices/batch-assign-series`。
- 两个接口复用同一任务基础设施，但路径和 DTO 独立；请求中不得接受另一个命令的目标字段，也不增加能同时设置代理和系列的 `operation_type` 通用入口。
- `batch-assign-shop` 只允许更新设备归属及现有用例规定的绑定卡归属、设备状态和分配记录，不修改 `series_id`。
- `batch-assign-series` 只允许更新 `series_id`，不修改 `shop_id`、设备归属状态或绑定卡归属。
- 现有 `POST /api/admin/devices/allocate` 和 `PATCH /api/admin/devices/series-binding` 继续服务页面选择、ID 列表、号段或筛选操作；CSV 接口是新增入口，但底层必须收口到相同业务规则，不能复制一套宽松逻辑。

### CSV 契约

- 本需求只接受 `.csv`，不接受 `.xlsx`、`.xls` 或把 Excel 改扩展名后的文件；不维护 CSV 与 Excel 两套解析器。
- 文件编码固定为 UTF-8，可带 UTF-8 BOM；换行允许 LF 或 CRLF。其他编码明确拒绝并提示使用模板重新保存。
- 模板由前端作为静态资源发布，后端不增加模板下载接口。建议文件名为 `设备批量分配模板-v1.csv`。
- CSV 只有一列，固定表头为 `设备号`；每个数据行一个设备标识，支持设备 `virtual_no` 或 IMEI 精确匹配，不支持模糊匹配、号段、SN 或卡 ICCID。
- 标识去除首尾空白后参与匹配，最大 100 字符；空值、控制字符、公式前缀以及已经变成科学计数法或小数形式的长数字逐行失败，系统不得尝试还原猜测。
- 模板需提示用户不要用会改写长数字的格式保存；前端预览也必须按字符串展示。后端最终仍以收到的原始字符串严格校验。
- 单文件最大 10MB、最多 1000 个数据行。先完整完成结构与行数校验，再进行任何业务写入，避免解析到一半才发现文件整体非法。
- 表头缺失、列数不为 1、CSV 引号结构损坏、非法编码、文件过大或超过 1000 行属于文件级错误，任务直接失败且不处理任何设备。
- 数据行中的空值、非法标识和未找到设备属于行级失败。文件内重复标识保留第一次出现，后续重复行记录“文件内设备号重复”，不重复计为成功。
- 解析使用 Go 标准库 `encoding/csv`；本任务不需要 Excel 解析依赖。错误消息和失败明细必须使用中文。

### 对象存储上传

- 复用 `POST /api/admin/storage/upload-url`，新增受控用途 `device_batch_allocation`，前缀限定在设备批量分配目录，默认 Content-Type 为 `text/csv`，只允许生成 `.csv` Key。
- 前端取得预签名 URL 后直接把 CSV 上传对象存储，再调用业务接口提交 `file_key`；业务接口不使用 `multipart/form-data` 接收文件字节。
- 创建任务前校验 `file_key` 非空、属于该受控用途前缀、扩展名正确且对象存在。Worker 下载后再次校验实际大小、编码和 CSV 内容，不能信任扩展名或客户端 Content-Type。
- 本地临时路径和文件字节均不得写入数据库或 Asynq 载荷。Worker 如需临时文件，必须使用存储层受控临时目录并以 `defer` 清理。
- 源文件清理沿用统一对象存储生命周期；清理失败不得改变已经确定的业务任务结果，但需记录告警。

### 创建任务接口

- 分配代理请求：

```json
{
  "file_key": "device-batch-allocations/2026/07/21/example.csv",
  "shop_id": 123
}
```

- 分配系列请求：

```json
{
  "file_key": "device-batch-allocations/2026/07/21/example.csv",
  "series_id": 45
}
```

- `shop_id` 和 `series_id` 均为大于 0 的必填目标。CSV 新入口不承担回收设备或清除系列；这些动作继续走现有明确接口。
- 提交时先校验操作者身份、目标资源和目标权限，再创建任务。目标不存在或无权限使用统一安全错误，不能通过批量接口探测其他店铺或系列。
- 成功响应返回 `task_id`、`task_no`、`operation_type` 和任务状态。此时只代表任务已成功入队，不代表设备已经分配成功。
- 如果数据库创建任务成功但入队失败，将任务标记为失败并返回统一系统错误；不得留下永久“处理中”的假任务。

### 任务模型与状态

- 新建独立 `tb_device_batch_allocation_task`，不复用语义不同的设备导入任务或导出任务表，也不建立数据库外键。
- 任务至少保存：任务号、源文件 Key、`operation_type`、目标 ID、操作者账号 ID、操作者类型与店铺快照、总数、已处理数、成功数、幂等数、失败数、状态、失败明细、错误摘要、开始时间和完成时间。
- `operation_type` 固定为 `assign_shop|assign_series`。
- 状态复用全局异步任务 int 生命周期枚举：`1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消`，响应必须同时返回 `status_name`。本需求没有取消入口，但保留状态码 5；部分成功不是状态，由成功数、幂等数和失败数表达。
- 行级业务失败不把整个任务标为系统失败；CSV 被完整处理后，即使全部行都因业务原因失败，任务仍是“已完成”并通过 `fail_count` 和明细表达结果。文件级解析错误、存储错误或不可恢复的基础设施错误才是“失败”。
- `total_count = success_count + fail_count`，`success_count` 包含实际变更与同目标幂等成功；`idempotent_count` 是成功数的子集。重复 CSV 行计入失败，避免汇总数字无法对齐上传行数。
- 失败明细最多 1000 条，与单文件行数上限一致；每项返回原始行号、脱敏或安全的输入设备号和中文原因。不得把数据库错误或其他租户资源信息原样返回。

### 任务查询契约

- `GET /api/admin/devices/batch-allocation/{task_id}` 返回任务号、命令、目标摘要、状态与名称、总数、已处理数、成功数、幂等数、失败数、进度、失败明细及起止时间。
- 进度只在 `total_count > 0` 时按 `processed_count / total_count` 计算；解析尚未完成时返回 0，终态固定 100。
- 代理只能查询自己创建的任务；平台账号按现有设备批量管理权限查询平台范围任务，超级管理员可排障查看全部。无权与不存在使用相同错误语义。
- 前端轮询到完成或失败即停止；失败明细直接在页面展示，不要求另建结果文件下载接口。

### 权限与数据范围

- 用户已确认 CSV 入口继承现有权限，旧稿中的“平台员工”只是示意，不是平台专用限制。
- `assign_shop`：
  - 平台/超级管理员只能把平台库存设备分配给现有权限允许的目标店铺。
  - 代理只能把自己店铺名下的设备分配给直属下级店铺，不能分给自己、旁系、上级或跨层级店铺。
  - 已经属于目标店铺的设备按幂等成功，主要用于安全恢复 Worker 重试；不重复写归属记录。
  - 已经属于其他店铺的设备逐行失败并提示先按现有流程回收，不能由批量任务直接横向转移。
- `assign_series`：
  - 平台/超级管理员沿用现有系列绑定范围。
  - 代理只能操作自己店铺名下的设备，并且目标系列必须是该代理当前有效授权的系列。
  - 已经是目标系列按幂等成功；绑定其他系列时沿用现有系列变更规则更新目标值。
- Worker 不能只信任提交时快照。执行前重新加载操作者账号、账号状态、当前店铺关系、目标资源和系列授权；账号已禁用或权限已撤销时，任务失败或对应行安全拒绝，不继续使用过期权限。
- 重新构造后台任务上下文后仍使用现有 GORM 数据范围与业务校验。未命中和无权操作在行级使用统一“设备不存在或无权限”语义，防止上传清单探测其他代理设备。

### 批量查询与执行

- 去重后的设备标识按每批最多 200 条，用 `virtual_no IN (...) OR imei IN (...)` 批量查询；禁止为每行调用一次 `GetByIdentifier` 形成 N+1。
- 同一输入若因历史脏数据命中多台设备，逐行失败为“设备标识不唯一”，不能使用 `First` 任取一台。实现前应核查 IMEI 重复数据，不能假设当前模型未声明的唯一性。
- 每批先构造标识到设备的唯一映射，再一次性加载目标店铺、系列授权、绑定卡和其他业务校验所需数据。
- 业务规则复用现有 Application 用例，但需提取可接受预加载设备批次的内部命令，不能让 Worker 直接执行裸 `UPDATE tb_device` 绕过绑定卡同步、直属关系、系列授权和审计。
- `assign_shop` 每个写批次在同一 PostgreSQL 事务内更新符合条件的设备、绑定卡归属与状态，并创建资产分配记录；任一数据库写失败则该批事务回滚并由任务机制安全重试。
- `assign_series` 对符合条件的设备批量条件更新 `series_id`，并记录统一审计；不需要更新绑定卡。
- Worker 重试时重新读取当前状态：已到目标值的行按幂等成功，不重复写资产分配记录或重复审计；未到目标值的行继续处理。
- Asynq 载荷必须使用结构体 `{"task_id":...}`，不得预先序列化为 `[]byte` 传给 `EnqueueTask`。

### 并发、一致性与审计

- 设备归属和系列更新必须使用带期望当前值的条件更新。Worker 查询后若设备被其他请求改变，当前行返回冲突或按最新状态重新判断，不能覆盖并发操作。
- `assign_shop` 的设备、绑定卡与资产分配记录必须同事务提交；不能出现设备已到下级但绑定卡仍留在上级的中间成功。
- 批量任务允许部分成功，但单个写批次中的基础设施错误不得提交一半。行级业务失败在写事务前分离并记录。
- 创建任务、任务终态和实际批量命令均写统一 Audit Event；实际归属变化继续写 `AssetAllocationRecord`。审计至少包含任务号、命令、目标、源文件 Key、安全摘要、总数、成功/幂等/失败数和操作者。
- 行级失败原因不得把其他店铺名称、内部 SQL 或底层错误暴露给无权操作者。源文件中的设备标识按公共审计脱敏规则处理。

### 前端交互

- 设备管理页提供两个独立按钮和弹框：“批量分配代理”“批量分配套餐系列”。单个弹框只显示对应目标选择器、CSV 模板下载、文件上传和提交按钮。
- 代理的店铺选择器只展示直属下级；系列选择器只展示自己当前有效授权系列。平台沿用现有目标数据范围。
- 文件选择只接受 `.csv`，上传前展示文件名、大小和前几行文本预览；预览不能把长设备号转换成数字。
- 提交前展示命令名称、目标代理或系列以及 CSV 数据行数；上传和提交期间禁止重复点击。
- 任务提交后轮询详情，展示待处理、处理中、已完成、失败，及总数、成功数、幂等数、失败数和失败原因。
- 部分成功必须明确展示，不能把任务整体标成失败而隐藏已完成的设备。用户修正失败行后可新建任务重试。

### 发布与回滚

- 上线前检查目标环境 Redis、PostgreSQL、Asynq Worker 和对象存储均可用，并用真实开发环境完成 CSV 上传、异步执行和任务查询联调。
- 前后端同批增加 `device_batch_allocation` 上传用途、两个任务创建接口和任务详情。Worker 未部署前不得开放前端入口。
- 发布前核查重复 IMEI，并验证平台、代理、直属下级和系列授权的数据范围；不自动修改历史设备数据。
- 回滚应用时保留已经产生的任务、资产分配记录与审计事实；不得通过回滚删除已经完成的设备归属或系列变更。

## 公共能力发布依赖

- 最终 05 号发布票阻塞于公共基础 12 号票和全局审计 19 号票；Worker、监控和消费者未就绪前不得开放前端入口。
- 批量分配任务复用公共五态，逐行失败留在业务任务明细；任务创建、人工操作和资产归属变化进入 Audit Event，可靠事件登记版本与消费者幂等键。

## Testing Decisions

- CSV 解析测试覆盖 UTF-8、UTF-8 BOM、LF、CRLF、中文表头、引号、空行、空值、额外列、非法引号、非 UTF-8、控制字符、公式前缀、科学计数法、超长标识、1000/1001 行和 10MB 边界。
- 上传测试覆盖受控 purpose、`.csv` 扩展名、错误 Content-Type、伪装 Excel、越权或其他用途 `file_key`、对象不存在和 Worker 下载失败。
- 权限测试覆盖平台库存分配、平台非库存拒绝、代理分配自己设备给直属下级、分给自己/旁系/上级/跨级拒绝，以及旧稿“平台员工”不造成代理入口缺失。
- 系列测试覆盖平台代理范围、代理自己设备、代理无授权系列、设备不属于自己、同系列幂等、从其他系列切换和不修改 `shop_id`。
- 归属一致性测试验证实际分配会同步所有绑定卡归属与状态、创建分配记录，并在任一写入失败时整批事务回滚。
- 批量查询测试验证每 200 条查询、无 N+1、虚拟号与 IMEI 精确匹配、未找到、无权限、跨字段重复命中和历史重复 IMEI不会任取设备。
- 汇总测试验证重复行、逐行失败、实际成功和幂等成功满足 `total=success+fail`，并正确计算 `idempotent_count` 与进度。
- 任务状态测试覆盖待处理、处理中、部分成功后完成、全部业务失败后完成、文件级失败、入队失败、Worker 崩溃重试和终态不重复执行。
- 并发测试覆盖同设备同时分配、系列同时变更、查询后状态被改变，以及条件更新不会覆盖并发结果。
- Asynq 测试验证载荷是结构体并且只包含 `task_id`，不包含文件字节、临时路径或敏感上下文。
- HTTP 集成测试穿过 Fiber 认证、对象存储、Application、真实 PostgreSQL、真实 Redis、Asynq Worker 和统一响应，验证平台与代理完整链路。
- 前端验收覆盖两个独立 CSV 弹框、静态模板、长数字文本预览、目标选择权限、上传失败、轮询、部分成功与失败明细。

## Out of Scope

- 不支持 Excel，也不维护 CSV/Excel 双格式兼容。
- 不在同一任务同时分配代理和套餐系列。
- 不用 CSV 执行设备回收、横向转移或清除套餐系列。
- 不支持号段、模糊匹配、SN 或卡 ICCID；这些不属于本期单列设备清单契约。
- 不新增后端模板下载接口或失败结果文件下载接口。
- 不改变现有页面勾选、ID 列表、号段和筛选操作入口。
- 不绕过代理直属下级、设备归属和系列授权规则。

## Further Notes

- 当前同步 `AllocateDevices` 已支持平台代理分配、设备与绑定卡归属同事务更新；当前 `BatchSetSeriesBinding` 已支持平台代理系列权限。这些是新 CSV 用例的业务权威，不应由 Worker 重写。
- 当前设备任意标识查询使用 `First` 且 IMEI 没有模型唯一约束；批量实现必须显式检测多命中，避免脏数据导致错误分配。
- 当前对象存储上传用途把 `iot_import` 写死为 Excel Content-Type；本需求需要单独增加 CSV 用途，不能继续复用该错误类型。
- 用户已明确确认：新批量入口继承平台代理现有权限，并用 CSV 取代原稿 Excel。
