# 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,并验证平台、代理、直属下级和系列授权的数据范围;不自动修改历史设备数据。 - 回滚应用时保留已经产生的任务、资产分配记录与审计事实;不得通过回滚删除已经完成的设备归属或系列变更。 ## 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。