技术方案:
docs/superpowers/plans/tech/phase83-batch4-bulk-open.mdv1.4(§0 P1-P14 为拍板全集,与本文冲突以 tech 为准)
效果图定稿:~/reports/subseat-records-mockup-r2-20260928.html(屏A 批量开号记录 / 屏B Excel / 屏C 明细入口+对齐 / 屏D 聚合对齐)·~/reports/subseat-mockup-v3-r2-20260928.html(屏1 开通弹窗 / 屏1b 余量不够 / 屏2 生成中 / 屏3 完成);图上「设计说明,不做进页面」的文字一律不许进页面
开工前 verify 原始输出:~/reports/phase83-batch4-impl-verify-20260928.md(30 维 · origin/test81962b75· 每维原样命令输出)
分支:代码从最新origin/test拉feature/phase83-batch4-impl(文档分支feature/phase83-batch4-bulk-open已随 MR#781/#792 进 test)
本文不写测试代码、不写完整实现代码。
| 子步骤 | 内容 | 状态 |
|---|---|---|
| 83.26 | 数据层:迁移 phase83_003(新表 seat_open_batches + users.seat_batch_id)+ 模型 |
⬜ |
| 83.27 | temp_password 模块(可逆加密临时密码) |
⬜ |
| 83.28 | seat_bulk_open_service(分块开号 / 后台批次 / 续开 / 告警)+ generate_seats 生命周期批量写 + 启动续开 |
⬜ |
| 83.29 | 接口:provision 改造 + 批次四端点(本机构列表 / 进度 / 导出 / 继续开)+ 余量校验 | ⬜ |
| 83.30 | 登录一处判断(seat_auth.py:218) |
⬜ |
| 83.31 | 品牌码两处修复(防并发重复建号 + 不做无用 argon2) | ⬜ |
| 83.32 | 前端:开通弹窗 + 批量开号记录 + 两表列对齐 | ⬜ |
| 83.33 | 总验收:回归 / build / test 环境 5 万真跑 / 清理 / 数据完整性 | ⬜ |
进度表状态只能由主会话手动改;agent 严禁触碰。
down_revision = "phase83_002"(生产唯一 head);手写,禁 autogenerate(原因同 phase83_002 迁移注释 :14-20);禁接 phase85_*password_utils.verify_password 本体一行不改;登录只改 seat_auth.py:218 一处;手机号路径 :332 :354 不动generate_seats 的其余 6 个调用点一行不改(admin_seats.py:126、internal_settlement.py:584/705/1650、store_subseat.py:97、brand_code_service.py:97 仅加一个关键字参数);新参数放签名最末、默认值保持原行为(internal_settlement 按位置传参,见 seat_service.py:268-270 注释)asyncio.to_thread 里跑且 write_only=True;不许照抄 admin_brand_code.py:503 _build_export_workbook(其 :566 是普通 Workbook())page.tsx:887-893 + GET /export admin_parent_seats.py:254)、重置密码 / 换绑 / 停用端点(:291/:302/:312)不动page.tsx:485-491)行为与形状不变JWT_SECRET_KEY 前必须先重锁临时密码(写进部署 README)81962b75)| 维 | 核什么 | 实测(摘要 · 原文见 verify 报告) |
|---|---|---|
| D1 | 基线漂移 | 3a75d611..81962b75 共 10 提交;方案引用的 14 个文件 git diff --stat 为空(原命令第 15 个写成了不存在的 app/core/jwt_local.py,自检补查真实路径 app/auth/jwt_local.py 也为空);对照:全仓同命令 9 files changed。注:本文所有行号都是在 81962b75 上直接读出来的,不依赖漂移结论 |
| D2 | 迁移挂点 | phase83_002.down_revision="phase83_001";phase85_001→002→003→004 链在 phase83_002 后;phase83_003 / seat_open_batches / seat_batch_id 在 migrations/ app/ web/app/ tests/ 范围内 0 命中(对照 phase83_002 命中 2 文件;docs/ 里有命中,是方案文档本身) |
| D3 | provision 端点 | admin_parent_seats.py:59-65 ProvisionIn(:64 le=50);:115-151 调 generate_seats → 打开 enable_parent_subseat(:142-149)→ 返回 {org_id, org_name, seats}(:151) |
| D4 | generate_seats 调用方 |
定义 seat_service.py:259;调用 7 处(见红线 3 + admin_parent_seats.py:134) |
| D5 | 建号字段 / 撞号 / 取号 | seat_service.py:303-312 前缀检查 + _alloc_parent_usernames;字段 :324-340;:348 commit;:349-358 IntegrityError→409;_alloc_org_seq_batch :181-210 越界 rollback→409 |
| D6 | 生命周期写入 | activity_log_service.py:26-33 schedule_emit(模块级 set 持引用);:194-225 emit_lifecycle(fresh session + 全捕 + log_incident);表 activity.py:105-122(org_id / subject_type / subject_id / event_type / detail) |
| D7 | 品牌码查建 | brand_code_service.py:207-209 先查后建;_provision_family_seat :80-115::97-103 调 generate_seats(其内 :348 已 commit)→ :108-113 写微信绑定再 commit |
| D8 | 登录 / 改密 | seat_auth.py:218 校验点;:545-557 家长无微信身份 403;:572 首登绑定;:615 写 last_login_at;:1051-1052 / :1160-1162 改密写 argon2 + must_change_password=False;seat_auth.py 内 old_password 0 命中(改密不校验旧密码) |
| D9 | 启动 / 告警 | main.py:326-327 startup,函数到 :395(:396 是 shutdown);incident_logger.py:15-23 log_incident(module, error_type, severity, task_id, org_id, user_ref, detail) |
| D10 | 模型 | org_parent_seat_counter.py:39-44 CHECK 100000~999999;user.py:34 password_hash String(255)、:84 last_login_at;模型注册照 models/__init__.py:95-97 |
| D11 | 前端位置 | 本次在 81962b75 上读了 :18 :148 :174-176 :352-400 :455-500 :813-821 :860-900 :974-983 :1140-1240;:40-44 :49 :1041 取自 9-28 v1.3 复核(page.tsx 在 3a75d611→81962b75 零改动,仍有效);两张表的单元格(td)行号未逐个列出,实施时按表头定位 |
| D12 | 回归测试 | 12 个文件路径与用例数已核(见 83.33;tech §14 两处路径已更正) |
| D13 | 新文件不存在 | temp_password.py / seat_bulk_open_service.py / seat_open_batch.py 均 0(对照 seat_service.py 1) |
| D14 | 前端下载先例 | page.tsx:354-366 doDownload(必须 appendChild 再 click);apiFetch 来自 @/lib/auth(:18) |
| D15 | 依赖 | requirements.txt:33 python-jose[cryptography]、:68 openpyxl==3.1.5 |
| D16 | 列表查询 | parent_seat_service.py:99-109 默认排除 brand_code;:132-142 按账号 / 手机 / 昵称搜 |
| D17 | 行内管控端点 | admin_parent_seats.py:291/302/312/322 |
| D18 | 密钥 | app/auth/jwt_local.py:24;seat_auth.py:40-45 已有 from app.auth.jwt_local import JWT_SECRET_KEY 先例 |
| D21 | verify_password 全部调用 |
7 处:internal_settlement.py:283、seat_auth.py:218/332/354、auth_service.py:213/418/443;临时号正常流程只会走到 :218 |
| D23 | 测试夹具 | 测试不跑 alembic(tests/conftest.py:18-30 只 create_all 一张表);tests/integration/conftest.py:18-34 nested_session 用 SAVEPOINT |
| D28 | last_login_at |
临时密码首登成功即写(seat_auth.py:615),早于改密 |
| D29 | 后台重置密码 | seat_service.py:455-479 写 argon2 + must_change_password=True,不动 last_login_at |
| # | 发现 | 处理 |
|---|---|---|
| N1 | tech v1.3 §11.1 的 pg_advisory_xact_lock 挡不住重复建号:事务锁在 generate_seats 的 :348 提交时就释放,而微信绑定在 brand_code_service.py:108-113 第二个事务才写 → 第二个请求在两次提交之间拿到锁、按 unionid 查不到 → 再建一个号 |
改为专用连接上的会话级 pg_advisory_lock,持锁跨越两次提交,finally 解锁(83.31) |
| N2 | 后台批次任务自己开 session,nested_session 的 SAVEPOINT 兜不住 → 测试数据会真落本地库 |
测试一律在 finally 里按 seat_batch_id / 测试机构清理 |
| N3 | 测试不跑 alembic(D23) | 83.26 迁移由主会话先在本地库 upgrade,之后各步测试才能跑 |
| N4 | doProvision 非 2xx 只弹 toast(page.tsx:473-481),没有弹窗内红字 |
83.32 新增弹窗内红字,仅 409 走红字 |
| N5 | 现 provision 会顺带打开机构的 enable_parent_subseat(:142-149) |
>10 走后台时,提交时同样打开 |
| N6 | 临时密码首登就写 last_login_at(D28) |
导出密码格判定顺序必须:先判 $tmpenc1$ → 再判 last_login_at |
| N7 | 发版时新旧容器可能短暂并存,两个进程续开同一批 → 可能超开 | 每块开始时 SELECT … FOR UPDATE 锁批次行、按 requested - created 重算本块数量 |
| N8 | 路径:jwt_local 在 app/auth/;test_seat_auth_jwt_middleware.py 在 tests/unit/;test_phase68_seat_initial_password.py 在 tests/integration/ |
tech 同步更正 |
| N9 | test 库 db-query 查 users 受 RLS 影响恒返 0(已踩过) |
83.33 的 users 计数不许用 db-query test 下结论,走 root 或页面 / 接口核对 |
文件:
- 新增:migrations/versions/phase83_003_seat_open_batches.py(revision="phase83_003",down_revision="phase83_002",手写)
- 新增:app/models/seat_open_batch.py(SeatOpenBatch)
- 修改:app/models/user.py(seat_ 列组内、created_by_seat_id :156 附近加 seat_batch_id)、app/models/__init__.py(照 :95-97 写法 import + __all__)
- 测试:tests/integration/test_phase83_batch4_bulk_open.py(新)
内容(以 tech §3 为准):
- 表列、CHECK 同 tech §3.1;status 四值 pending / running / done / failed
- 「同时只跑一批」:迁移里原生 SQL CREATE UNIQUE INDEX ux_seat_open_batches_one_active ON seat_open_batches ((true)) WHERE status IN ('pending','running');模型 __table_args__ 写同名同条件索引,两边一致
- ix_seat_open_batches_org_created (org_id, created_at DESC)
- users.seat_batch_id BigInteger NULL,FK seat_open_batches.id ON DELETE SET NULL,部分索引 WHERE seat_batch_id IS NOT NULL;老行全 NULL 不回填(业务上老号不属于任何批次,迁移里写明注释)
- downgrade 反序删除
requested_count 10 与 50001 被 CHECK 拒;非法 status 被拒;已有一条 pending 时再插 pending / running → IntegrityError,原批改 done 后可再插;老行 seat_batch_id 为 NULLalembic upgrade → downgrade → upgrade 各一次(贴输出 + alembic heads)文件: 新增 app/services/temp_password.py;测试 tests/unit/test_phase83_temp_password.py(新)
内容: tech §4 四个函数(seal / is_sealed / unseal / verify);密钥 HKDF 派生自 app/auth/jwt_local.py:24 JWT_SECRET_KEY;unseal / verify 任何异常都不抛(返回 None / False);verify 用 hmac.compare_digest。不新增依赖行(cryptography 由 requirements.txt:33 带入,agent 贴 python -c "import cryptography; print(cryptography.__version__)" 证明)。
is_sealed 对 argon2 串 / None / 空串为假;篡改密文 → unseal None、verify False;换密钥后 unseal None;串长 ≤255;$tmpenc1$ 串直接喂 password_utils.verify_password → False;5 万次 seal 耗时打印记录文件:
- 新增:app/services/seat_bulk_open_service.py
- 修改:app/services/activity_log_service.py(新增 emit_lifecycle_bulk,写法照 emit_lifecycle :194-225:fresh session + 一条批量 INSERT + 全捕 + log_incident;emit_lifecycle 本体不动)
- 修改:app/services/seat_service.py:360-363(逐号 schedule_emit → 一次 schedule_emit(emit_lifecycle_bulk(org_id, created_ids)))
- 修改:app/main.py startup_event(:327-395)末尾加续开,try/except 走 log_incident,不许拖垮启动
- 测试:tests/integration/test_phase83_batch4_bulk_open.py
seat_bulk_open_service 内容:
- CHUNK = 500
- 前缀检查:无前缀 → 409,文案与 seat_service.py:308 逐字一致
- 余量检查:读 org_parent_seat_counter.last_seq(无行视为 100000),count > 999999 - last_seq → 409「该机构只剩 N 个号可开」;_alloc_org_seq_batch 的越界 409 保留作兜底
- create_chunk(db, org, n, *, valid_days, batch_id=None)(单事务,调用方 commit):
1. 有 batch_id 时先 SELECT … FOR UPDATE 锁批次行,n = min(n, requested - created),n ≤ 0 直接返回空(N7)
2. seat_service._alloc_parent_usernames(db, org.id, org.subseat_prefix, n)(:213)
3. 每号 generate_seat_initial_password() → temp_password.seal()
4. 一条批量 INSERT users,字段对齐 seat_service.py:324-340(role='org_seat'、linked_org_id、is_active=True、must_change_password=True、seat_valid_days、seat_type='parent'、phone_login_enabled=True、seat_source 不传走 server_default、created_by_seat_id=None)+ seat_batch_id,按参数顺序返回 id(agent 先贴 SQLAlchemy 版本,用该版本支持的保序批量写法)
5. 同事务一条批量 INSERT account_lifecycle_event(subject_type='seat'、subject_id=str(id)、event_type='seat_added'、detail={})
6. 有 batch_id 时 created_count += 本块数
7. 返回 [{id, username, password}]
- submit_batch(db, org, count, valid_days, admin_id):前缀 → 余量 → INSERT 批次行 pending(撞 ux_seat_open_batches_one_active → rollback → 409「已有开号任务在进行」)→ 打开 enable_parent_subseat(N5)→ commit → spawn
- spawn(batch_id):asyncio.create_task(run_batch(id)),模块级 set 持强引用(照 activity_log_service.py:26-33)
- run_batch(batch_id):首次置 running + started_at;循环每块新开 session(async_session_maker)→ create_chunk → commit → await asyncio.sleep(0);本块返回空 → 置 done + finished_at。任何异常:rollback → 新 session 置 failed + error(截断 500 字)→ log_incident(module="seat_bulk_open", error_type="batch_failed", severity="error", org_id=…, detail={"batch_id", "created_count"})
- resume_pending_batches()(启动用):查 pending / running 的批次 → spawn
- resume_failed(db, batch_id):非 failed → 409「只有失败的批次可以继续开」;改 pending(撞索引 → 409「有一批正在生成,请稍后再继续」)→ commit → spawn;error 保留
seat_batch_id、password_hash 全以 $tmpenc1$ 开头、账号连号无重复、生命周期 2000 行、批次 done、created_count=2000last_seq 设到只剩 1000,直接插一条 requested=2000 的批次跑 run_batch → 第 3 块真撞计数上限 → failed、created_count=1000、日志里有 [INCIDENT] seat_bulk_opencreate_chunk 造数据)→ resume_failed → 补齐 2000 且无重复账号run_batch 并发跑同一批 → 总数正好 = requested(N7)last_seq=999990 提交 20 → 409 且不建批次行;无前缀 → 409generate_seats 开 3 个门店号 → 生命周期 3 行,只起 1 个后台任务seat_batch_id / 测试机构清理(N2)tests/integration/test_phase52_emit_points.py(10)、tests/unit/test_phase83_seat_seq_counter.py(11)文件: 修改 app/api/admin_parent_seats.py;测试同上文件
内容:
- :64 le=50 → le=50000
- provision(:115-151):先做前缀、余量检查(两条路径同一套文案);≤10 → create_chunk(db, org, count, valid_days=…) + 打开 enable_parent_subseat + commit,返回形状同 :151;>10 → submit_batch,返回 {org_id, org_name, batch_id, status:"pending"}
- GET /batches?org_id=&page=&page_size=(org_id 必填):本机构批次按 created_at 倒序,每行 id / created_at / requested_count / created_count / status / error / activated_count;另带 has_active(全局有 pending / running)。activated_count = 该批 password_hash 不以 $tmpenc1$ 开头且 last_login_at 非空
- GET /batches/{id}:单批同字段(前端 2 秒轮询)
- GET /batches/{id}/export:async 取该批 username / password_hash / last_login_at / created_at → asyncio.to_thread 内解密 + openpyxl write_only=True 生成 → Response(bytes);4 列 账号 / 密码 / 机构名称 / 开通日期(北京时间日期);密码格:is_sealed → 解密明文;否则 last_login_at 非空 →「已激活」;否则 →「已重置」(N6);按账号排序;任何状态可下载(失败批 = 下载已开部分);不落盘;文件名以效果图屏B为准
- POST /batches/{id}/resume → resume_failed
- 全部 require_role("admin")
$tmpenc1$;count=50001 → 422;count=20 → 返回 batch_id;缺 org_id → 422;列表只含本机构且倒序;导出 4 列与三种密码格(已激活:真走 /api/seat/login 首登 + 改密,造法照 test_seat_login_e2e.py;已重置:调 /{seat_id}/reset-password);续开三种 409;非管理员 403tests/integration/test_phase74_admin_parent_seats.py(26)文件: 修改 app/api/seat_auth.py:218(改调 _check_seat_password,逻辑见 tech §5)+ import temp_password;测试同上文件
must_change_password 已为 False 的 $tmpenc1$ 号 → 401;门店号带 $tmpenc1$ → 401;改密后旧临时密码 401、新密码成功;临时号不出现在手机号登录候选里tests/integration/test_seat_login_e2e.py(23)、tests/integration/test_phase83_batch5_seat_login.py(15)、tests/unit/test_seat_auth_jwt_middleware.py(13)、tests/integration/test_seat_expiry_e2e.py(12)、tests/integration/test_phase68_seat_initial_password.py(5)文件: 修改 app/services/brand_code_service.py(:207-209 查建段、:97-103 调用加参数)、app/services/seat_service.py(generate_seats 签名末尾 + :326);测试 tests/integration/test_phase83_batch4_brand_code.py(新)
内容:
- 防重复建号(N1):把 :207-209 抽成 _find_or_provision_family_seat;函数内用 app.database.engine 单开一个连接执行 pg_advisory_lock(hashtext('brand-activate:' || org_id || ':' || identity))(identity = unionid 优先,否则 openid;openid 已由 :196 保证非空),持锁跑「查 → 没有就建 + 写绑定」,finally pg_advisory_unlock 并关连接。不许用 pg_advisory_xact_lock
- 无用 argon2:generate_seats 末尾加 password_mode: Literal["argon2", "unusable"] = "argon2";unusable 时 password_hash = "$unusable$" + 随机串、返回的 password 为 None;brand_code_service.py:97-103 传 password_mode="unusable"(其只取 id,:104)
password_hash 以 $unusable$ 开头且 verify_password 为 False;门店 generate_seats 默认仍 argon2tests/integration/test_admin_brand_code.py(16)、tests/integration/test_brand_code_claim.py(9)、tests/integration/test_phase83_batch5_brand_code.py(7)文件: 修改 web/app/(dashboard)/admin/parent-seats/page.tsx;新增 批量开号记录弹窗组件(放同目录,agent 先 ls 确认文件名未被占用并回报);测试 web/__tests__/admin-parent-seats-bulk-open.test.tsx(新,写法照同目录 admin-parent-seats-*.test.tsx)
内容(文案、颜色逐字逐色以效果图为准,色值只许从现网扒):
- 开通弹窗::1206「数量(1-50)」→「数量(1-50000)」;:1212 max={50} → max={50000};数量 / 有效期行(:1204-1231)下方整行单行灰字「超过 10 个将在后台生成,完成后在子席位明细里下载」(样式照 :1199 灰字)
- doProvision(:460-499):409 → 弹窗内红字显示后端 detail,弹窗不关、按钮不置灰;改数量或机构时清红字;其他非 2xx 保持原 toast(:474-480);红字色值从 page.tsx 现有红色文字扒,扒不到就回报,不许自配
- >10 返回 batch_id → 关开通弹窗 → 打开该机构子席位明细 + 批量开号记录,轮询 GET /batches/{id} 每 2 秒到 done / failed(屏2 / 屏3)
- 子席位明细弹窗头部(:880-893):「导出明细」左边加「批量开号记录」,样式同 :890
- 批量开号记录弹窗:只显示本机构;开号时间倒序;全部列居中;状态「生成中 / 已完成 / 失败」徽章令牌从 STATE_LABEL :40-44 / DISABLED_BADGE :49 同类写法扒;失败行「继续开」+「下载已开部分」;has_active 为真时「继续开」置灰(样式照 :1041)+ 悬停「有其他批次正在生成,请稍后」
- 下载:apiFetch → res.blob() → 走现有 doDownload(:354-366)
- 列对齐:聚合表 :815-821 及对应单元格(机构左 / 数字与日期居中 / 操作右);明细表 :976-983 及对应单元格(账号、微信左 / 手机、来源、状态、开通-到期、绑定时间居中 / 行内管控右)
admin-parent-seats-badge-extend / -detail-filter-multi / -source-column / -subseat-prefix / parent-seats-xlsx / admin-menu-parent-seats;npm run build 必跑(贴 Route 表)tests/contract/test_app_import_smoke.py(4)pytest -v 真绿npm run buildscripts/verify-trigger-files.txt,本批改动命中则 bash scripts/verify.sh chat-e2ecreated_count = requested_count;生命周期行数 = 5 万;users 计数走 root 或页面 / 接口核对(N9)seat_batch_id 圈定;删除前由 root 先查 users 的外键依赖表逐表计数(含 3 张真机卡产生的登录审计、微信绑定等)→ root 删除 → 删后再查为 0,其他表行数与删前一致sudo release-to-prod --mr <N>,先核 commit_ids + changed_files)npm run build 通过