业务方案:device-admin-plan-20260929.md · 效果图:device-admin-mockup-20260929.html(长官 9-30 已认可)
本文只写「怎么做」,代码 0 行。长官点头后再拆施工步骤(impl)进仓库docs/superpowers/plans/。
Phase 号用 88:impl/tech 目录里只有 81/82/83/87;所有分支的提交信息里「phase88」0 次(对照 phase87 有 26 次)。
原始输出:~/reports/device-admin-tech-verify-raw-20260930.txt。T1-T16 由脚本直接落盘,末尾「主会话复核」一段是我亲自跑的。
| # | 查什么 | 结论 | 证据 |
|---|---|---|---|
| T1 | Phase 号 | 88 没人用 | 见上;flywheel-progress.md 最大到 87(:34) |
| T2 | 数据库迁移接在哪 | test 库有 4 个头(34ec94466f0c / phase79_001 / phase83_003 / phase85_007);生产库只有 1 个头 phase83_003;目前没有迁移接在 phase83_003 后面 | R1、R6、R9;alembic.ini:8 |
| T2b | 部署时怎么跑迁移 | 自动部署跑 alembic upgrade heads(复数),多个头也能过 |
deploy/auto-update.sh:249-252 |
| T3 | 后台接口写法 | APIRouter(prefix="/api/admin/...") + Depends(require_role("admin")) + Depends(get_db);列表返回 {items,total},用 offset/limit 分页;main.py 里逐个 include_router |
admin_parent_seats.py:48,220;main.py:46,182 |
| T4 | 模型写法 | from app.models.database import Base;新模型必须在 app/models/__init__.py 登记 |
seat_open_batch.py:25;models/init.py:97,108 |
| T5 | 两步导入先例 | 现有 excel_import 预览后把解析结果交给前端,确认时前端原样传回,服务端不再校验。本件不能照抄这一点,确认时必须重新校验 | excel_import.py:84,124 |
| T6 | Excel 导出 | openpyxl write_only + BytesIO + Response;中文文件名用 filename*=UTF-8'' 编码 |
admin_parent_seats.py:284,325,356-359 |
| T7 | 二维码 / 打包 | qrcode==8.0、pillow==11.0.0、openpyxl==3.1.5 都已装;全仓没有用过 zipfile,打包要新写(Python 自带) | requirements.txt:25,62,68 |
| T8 | 菜单登记 | 三处必须一起改:menu_registry.py(key + api_prefixes)、AdminConsoleShell 的 navGroups、HREF_TO_MENU_KEY。漏一处,管理员也看不到菜单 | menu_registry.py:25;AdminConsoleShell.tsx:45,127-154 |
| T9 | 后台前端写法 | "use client" + apiFetch(@/lib/auth);日期范围组件 DateRangePicker 可以直接用 |
parent-seats/page.tsx:1,18,21,222;date-range-picker.tsx:36 |
| T10 | 机构名规整 | 没有现成的全角半角(NFKC)规整函数,要新写;organizations.name 没有唯一约束(业务方案 V7 已证实) | 0 命中 + 对照组;organization.py:20 |
| T11 | 家长显示字段 | users.wechat_nickname(:42)、wechat_avatar(:43)、phone(:47)、seat_type(:130)、linked_org_id(:59)都有 | app/models/user.py |
| T12 | 表名冲突 | 已有 device_data / device_type_config / wecom_agent_devices;下面 4 张新表的名字都不冲突 | 91 个表名全部列出 |
| T13 | 扫码网址先例 | 品牌码写的是 f"{host}/h5/brand-scan?bt={code_token}",host 取自请求头,不是配置项 |
admin_brand_code.py:50,66 |
| T14 | 测试先例 | 连真 PG 的 fixture 叫 db_session;后台子席位的集成测试可以照着写 |
tests/conftest.py:45;tests/integration/test_phase74_admin_parent_seats.py |
| T15 | 操作审计 | 没有通用的「后台操作审计」;解绑的人、时间、原因记在本件自己的绑定记录表里就够 | audit.py:23 记的是登录审计 |
| T16 | 大小写不敏感的唯一 | 仓库里没有先例(lower( 和 citext 都 0 命中,对照 create_index 有 114 处)→ 本件新建 lower(...) 函数唯一索引 |
见落盘 T16 |
verify 改变了什么
1. 迁移写 down_revision = "phase83_003",这是生产唯一的头,test 上也有。不能把 test 的 4 个头合并:34ec94466f0c 是不上生产的孤头,合进来以后生产会找不到它(device_epoch_seat_kick.py:24-28 原文有警告)。
2. 导入点「确认」时,服务端必须把整批重新校验一遍,不信前端传回的结果(T5 先例在这里有漏洞)。
3. 二维码网址照品牌码从请求头取域名(长官 D-T1 定):测试下载是测试网址,正式下载是正式网址;测试环境下载的码不要拿去印。
4. 大小写不敏感、机构名规整都没有先例,本件新写,放进一个小工具文件。
phase88_001)| 表 | 是什么 | 主要字段 | 约束 |
|---|---|---|---|
device_asset_types |
设备类型 | id、name(50)、is_active、created_by、created_at、updated_at | lower(name) 唯一 |
device_assets |
设备(类型 + 名称) | id、type_id → types(有设备时不能删)、name(100)、is_active、created_by、时间戳 | lower(name) 全平台唯一 |
device_codes |
唯一码 | id、code(64,原样保存)、asset_id → assets(有码时不能删)、org_id → organizations(下放门店,被引用时不能删)、qr_token(唯一,随机)、imported_at、created_by、updated_at | lower(code) 唯一;qr_token 唯一 |
device_code_bindings |
绑定记录(连历史) | id、code_id、user_id → users、account_org_id(绑定那一刻账号归属机构的快照)、bind_store_org_id(绑定时关联门店,可空)、bound_at、bound_via、unbound_at、unbind_by_user_id、unbind_role(parent/admin)、unbind_reason、unbind_note | 一个码同时只能有一条没解绑的记录:UNIQUE(code_id) WHERE unbound_at IS NULL |
secrets.token_urlsafe(12)(96 位),照品牌码的写法(admin_brand_code.py:368)新文件 app/api/admin_device_assets.py,前缀 /api/admin/device-assets,全部接口 require_role("admin")。
| 功能 | 接口 | 规则 |
|---|---|---|
| 类型:列表 / 新增 / 改名 | GET、POST /types,PATCH /types/{id} |
重名报错,不分大小写 |
| 类型:停用 / 启用 | POST /types/{id}/disable、/enable |
停用后:这个类型下所有设备的未绑定码状态变「已停用」、扫码不能绑;已绑的照常用;新增设备选不到它;设备页该类型的设备旁标「类型已停用」;设备自身状态不改,重新启用即全部恢复 |
| 类型:删除 | DELETE /types/{id} |
下面有设备 → 409 |
| 设备:列表 / 新增 / 编辑 | GET、POST /assets,PATCH /assets/{id} |
类型只能选正常的;名称全平台不重名 |
| 设备:停用 / 启用 / 删除 | POST /assets/{id}/disable、/enable,DELETE /assets/{id} |
下面有唯一码 → 删除返回 409 |
| 设备:导入 | GET /assets/template,POST /assets/import/preview、/assets/import/confirm |
2 列;类型不存在、设备已存在、表里重复,都列出来 |
| 唯一码:列表 | GET /codes |
筛选:类型、设备、门店、导入时间范围、状态、唯一码搜索;分页;带绑定家长(头像、昵称、手机) |
| 唯一码:新增 / 编辑 / 删除 | POST /codes,PATCH /codes/{id},DELETE /codes/{id} |
编辑:已绑定的只能改门店;删除:绑过就返回 409 |
| 唯一码:详情 | GET /codes/{id} |
基本信息 + 当前绑定 + 全部绑定历史 |
| 平台解绑 | POST /codes/{id}/unbind |
原因必选(退货 / 换新 / 误绑 / 转让 / 其他),说明可不填;记下操作人 |
| 唯一码:导入 | GET /codes/template,POST /codes/import/preview、/codes/import/confirm |
4 列;门店营业执照名称先规整再精确比对;确认时整批重新校验,在一个事务里写入 |
| 导出清单 | GET /codes/export |
跟当前筛选一致;比列表多导出:导入时间、账号归属机构、绑定时关联门店 |
| 二维码 | GET /codes/{id}/qr.png,POST /codes/qr-zip |
服务端生成,不走公开的 /api/qr;压缩包里每张图的文件名是「唯一码.png」,图下方印唯一码 |
两步导入怎么传数据
- 预览:服务端返回每一行的检查结果
- 确认:前端只传回「原始行 + 管理员给门店选的机构」,服务端把全部校验重新跑一遍
- 这期间如果有变化(比如别人刚导了同一个码),就把冲突的行退回,不写入
两个人同时操作怎么办
- 两个人同时导同一批码:靠 lower(code) 唯一索引兜底,后提交的那一批报冲突
- 两个家长同时扫同一个码(第 3 件):靠「一个码只能有一条没解绑的记录」这条唯一索引兜底
机构名规整:新写 app/services/device_asset_utils.py。先去掉首尾空格,再做 NFKC(全角转半角),然后比对。一家都对不上,或者对上两家以上(重名),都交给管理员从下拉框里选。
模板:用 openpyxl 生成。
- 第 1 行:合并单元格,写填写说明,浅橙色底
- 第 2 行:表头
- 第 3 行:示例
- 唯一码那一列设成文本格式
- 导入时从第 2 行表头开始读,按表头名字找列,不按列的位置
- 唯一码去掉首尾空格后原样保存;中间有空格、或者单元格被 Excel 存成了数字(开头的 0 可能已经丢了),都算「不能导」,提示改成文本再传
- 一次最多 10000 行、文件不超过 5MB;预览只回传各类行数 + 有问题的行,不把整张表送回前端
二维码打包(已实测 · 已优化):云机(2 核 / 3.4G,不是生产机器)。
- 优化前:1000 张 16 秒。拆开量,慢在「算二维码图案」(每张 9.7 毫秒,因为要把 8 种图案都试一遍挑最好的)
- 优化做法:
- 固定用第 0 种图案,不再 8 种都试
- 随机码从 24 字节缩到 12 字节(96 位,照样猜不到),二维码格子更少
- 存成黑白图,压缩档调低;压缩包不再二次压缩(PNG 本身已经压过)
- 优化后:1000 张 2.4 秒,每张 1.4KB,压缩包 1.45MB;开两个进程也只快到 2.2 秒,所以不开
- 能不能扫:程序抽 100 张,全部识别出正确网址(这是程序识别,发 test 后要用手机真扫几张)
- 所以直接在请求里生成,不做后台任务
- 超过 2000 张系统自动分包(长官 9-30 定):服务端每次最多生成 2000 张;按唯一码排序、用「第几包」切,前端按每 2000 张一包依次请求、依次保存,文件名「二维码_第N包.zip」,弹框显示「正在生成第 N 包,共 M 包」;5000 张约 12 秒
- 每包整包生成完才保存,不会有半个文件;下载只读,不改数据;二维码随机码导入时就存进库,重下出来的图完全一样
- 没下完就关弹框或刷新页面,先提示「还有 N 包没下完,确定要关闭吗?」(长官 9-30 定);真关了就重新下载,不做断点续下
- 效果图里「86 / 128」进度条改成「第 N 包 / 共 M 包」
- 不到 2000 张:只有一个包,文件名「二维码.zip」,弹框只显示「正在生成…」转圈,几秒下好,不显示第几包
web/app/(dashboard)/admin/device-mgmt/)| 文件 | 内容 |
|---|---|
page.tsx |
标题 + 三个页签(唯一码 / 设备 / 设备类型) |
codes-tab.tsx |
唯一码清单:筛选放在表格卡片头、勾选、批量下载、状态三种颜色 + 已停用的悬停提示 |
assets-tab.tsx、types-tab.tsx |
设备、设备类型两个页签 |
import-dialog.tsx |
两步导入弹框,唯一码和设备共用(把列的定义传进去) |
qr-download-dialog.tsx、code-drawer.tsx(宽 480)、confirm-dialog.tsx |
下载二维码、详情抽屉、停用 / 删除 / 解绑确认 |
web/app/h5/device-scan/page.tsx(新) |
扫码落地页(D-T2):只显示「请在赵阳智脑里扫码绑定」,不调接口;第 3 件再换成真正的绑定流程 |
platform.device_assets(api_prefixes 填 /api/admin/device-assets)/admin/device-mgmt/admin/devices、/admin/device-types 和 LEGACY_SHELL_PREFIXES 都不碰db_session),不 mock。重点测这些:next build,本项目踩过 dev 不报错、build 才报的坑| # | 定了什么 |
|---|---|
| D-T1 | 二维码网址照品牌码的做法:在哪个环境下载,就是哪个环境的网址(测试是测试网址,正式是正式网址),方便测试。注意:测试环境下载的码不要拿去印 |
| D-T2 | 本件先做一个简单落地页,只写「请在赵阳智脑里扫码绑定」,能验证扫码跳转通了就行 |
| D-T3 | 一个码只能绑一个家长;一个家长可以绑多个码(不同设备) |
| # | 定了什么 |
|---|---|
| D-T4 | 唯一码列表操作栏对齐:每行都给「删除」留位置,不能删的空着 |
| D-T5 | 导出清单带家长完整手机号(不打码) |
| D-T6 | 设备管理只给平台管理员用(require_role("admin"));门店、达人看设备是第 2 件,在门店手机/平板上看 |