交付出件箱

← 返回出件箱

【批次四】子席位批量开号(最多 5 万)+ 批量开号记录 + 品牌码两处修复(2026-09-28 · 代码未动)

技术方案:docs/superpowers/plans/tech/phase83-batch4-bulk-open.md v1.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/test 81962b75 · 每维原样命令输出)
分支:代码从最新 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 严禁触碰。

实施红线(违反即返工)

  1. 迁移 down_revision = "phase83_002"(生产唯一 head);手写,禁 autogenerate(原因同 phase83_002 迁移注释 :14-20);禁接 phase85_*
  2. password_utils.verify_password 本体一行不改;登录只改 seat_auth.py:218 一处;手机号路径 :332 :354 不动
  3. 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 注释)
  4. 5 万导出禁走「JSON → 浏览器拼 xlsx」;openpyxl 只许在 asyncio.to_thread 里跑且 write_only=True;不许照抄 admin_brand_code.py:503 _build_export_workbook(其 :566 是普通 Workbook())
  5. 禁止每个号起一个后台任务 / 开一个连接
  6. 现有「导出明细」(page.tsx:887-893 + GET /export admin_parent_seats.py:254)、重置密码 / 换绑 / 停用端点(:291/:302/:312)不动
  7. ≤10 个时的凭证弹框(page.tsx:485-491)行为与形状不变
  8. 更换 JWT_SECRET_KEY 前必须先重锁临时密码(写进部署 README)

开工前 verify 结论(30 维 · origin/test 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

🔴 本次 verify 新发现(tech 未写或写错 · 已同步改 tech v1.4)

# 发现 处理
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 或页面 / 接口核对

Phase 83.26: 数据层

文件:
- 新增: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 反序删除

Phase 83.27: 临时密码模块

文件: 新增 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__)" 证明)。

Phase 83.28: 批次服务 + 生命周期批量写 + 启动续开

文件:
- 新增: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 保留

Phase 83.29: 接口

文件: 修改 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")

Phase 83.30: 登录一处判断

文件: 修改 app/api/seat_auth.py:218(改调 _check_seat_password,逻辑见 tech §5)+ import temp_password;测试同上文件

Phase 83.31: 品牌码两处修复

文件: 修改 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)

Phase 83.32: 前端

文件: 修改 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 及对应单元格(账号、微信左 / 手机、来源、状态、开通-到期、绑定时间居中 / 行内管控右)

Phase 83.33: 总验收

【验收标准】(批次四)

📌 本批验收标准里验不了的(如实标注)