交付出件箱

← 返回出件箱

Phase 83 批次四 · 子席位批量开号(最多 5 万)+ 下载记录 + 品牌码两处修复 · 技术方案

版本 v1.4 · 2026-09-28 · 状态:长官已认可,代码未动;v1.4 = 写 impl 前 30 维复核(origin/test 81962b75,原文 ~/reports/phase83-batch4-impl-verify-20260928.md)后更正 §11.1 锁方案与两处路径
基线:origin/test @ f44699c2(下文所有 文件:行 均在该提交上逐条核对过);9-28 晚在 origin/test 3a75d611 复核:方案引用的 11 个应用文件与 f44699c2 零差异,行号全部有效(§16b)
评估与证据:~/reports/gift-card-plan-a-evaluation-20260928.md(长官版)· ~/reports/gift-card-bulk-open-verify-20260928.md(18 维证据)
取代:旧批次四 D1-D4(礼品卡 5 万号 · 同步开号方案)


0. 长官拍板(9-28 · 不再讨论)

# 拍板
P1 走方案 A:提前开号、一卡一号;不做二维码公用入口、不做单号共用
P2 只动平台后台「子席位管理」;门店端(store_subseat)、品牌码业务流程、已开出的老账号、「重置密码」「换绑」一律不动
P3 不新增按钮:现有「开通子席位」数量上限 50 → 50000,自选数量
P4 单次 ≤10 个:沿用现有凭证弹框当场显示账号密码;>10 个:后台生成,进「批量开号记录」下载 Excel(9-18 定案门槛)
P5 号挂开号时所选机构;临时密码保留,可反复补下载;家长激活后该行显示「已激活」、不再给密码
P6 临时密码只做可逆加密(不做 argon2),5 万个约 10 秒
P7 共用的 verify_password 一行不改;只在家长子席位账号登录那一处加判断
P8 品牌码两处老毛病一起修:①同一微信并发重复建号 ②建号时同步 argon2 拖慢全站
P9 上线顺序:先 test 验收,再生产;机构前缀由长官提前设好
P10 Excel 沿用 9-18 定稿 4 列(账号/密码/机构名称/开通日期),不加状态列;已激活行密码格写「已激活」,后台重置过且家长未登录的写「已重置」
P11 批量开号记录按机构看:入口在该机构「子席位明细」弹窗「导出明细」左边,只显示本机构批次,不要机构筛选/机构列;列表按开号时间倒序
P12 失败批次行内「继续开」:从断点接着开,不重不漏;另有批次生成中时置灰;失败批可「下载已开部分」
P13 开号前拦:机构剩余号不够(本期新增)→ 点「确认开通」后弹窗内红字提示,不建批次;没设前缀的提示现网已有(page.tsx:1196 选完机构即显示 + seat_service.py:308 409),本期不动
P14 test 5 万压测用单独压测机构;验收后按 seat_batch_id 圈定,交 root 删除(dev2 在 test 只读)

1. 现状(物理取证 · 与方案直接相关的)

# 事实 出处
F1 平台开家长号单次上限 50 app/api/admin_parent_seats.py:64 count: int = Field(1, ge=1, le=50)
F2 开通端点返回明文账号密码列表 admin_parent_seats.py:115-151(return {"org_id":…, "seats": seats})+ seat_service.py:332 results.append({"id":…, "username":…, "password": temp_pw})
F3 底层建号硬顶 100 app/services/seat_service.py:290 count 必须 1-100
F4 无前缀不许开家长号 seat_service.py:305 if not org.subseat_prefix: → 409
F5 循环内逐个同步 argon2 seat_service.py:326 password_hash=hash_password(temp_pw);参数 password_utils.py:14-20 t=3/64MiB/p=4
F6 生产实测 argon2 60.4ms/个 → 5 万≈50 分钟 9-28 seatops remote prod 内存计时(不读数据、不写库);容器 cpu_count=4
F7 🔴 每建一个号起一个后台任务、各开一个新连接写生命周期 seat_service.py:361-363 循环 schedule_emit(emit_lifecycle(...));activity_log_service.py:207 async with async_session_maker();引擎 database.py:25 poolclass=NullPool;生产 max_connections=50(db-prod 实查)→ 5 万号=5 万个并发连接,打爆全站
F8 后端单 worker deploy/Dockerfile.api:67 --workers 1;生产 compose 无 celery worker
F9 首登强制改密 seat_service.py:331 must_change_password=True;改密后置 False:seat_auth.py:1052(seat_change_password)/ :1162(seat_forgot_password)
F10 共用校验函数,失败即 False password_utils.py:47-52 try: return _pwd_context.verify(...) except Exception: return False
F11 账号登录校验点 seat_auth.py:218(_seat_login_resolve_account);手机号登录 :332 :354 的候选要求 must_change_password.is_(False)(:298)→ 未改过密码的号进不了手机号登录
F12 家长号账号登录必须带微信身份、首登绑定微信 seat_auth.py:551 wx_identity_required_parent;:572 if not bound_any 绑定 / :589 非本人 403
F13 编号:前缀 ^[A-Z]{1,6}$ + 6 位,每机构 100000~999999 organization.py:62-65 CHECK;org_parent_seat_counter.py:39-43 CHECK;批量取号 seat_service.py:181 _alloc_org_seq_batch / :213 _alloc_parent_usernames
F14 子席位导出:后端出 JSON、浏览器拼 xlsx、每机构硬顶 2 万、无密码列 parent_seat_service.py:33 EXPORT_MAX_ROWS = 20000;page.tsx:399 表头;web/lib/xlsx.ts buildXlsx。5 万行 jsonable_encoder 在事件循环里冻结 1.6~3.8s(本机实测)→ 不能复用这条链路导 5 万
F15 品牌码建号先查后建无锁 brand_code_service.py:58 _find_existing_family_seat → :208-209 判空才 _provision_family_seat;文件内 FOR UPDATE / IntegrityError 0 命中(对照:seat_service.py 7 处);users.seat_bound_unionid 非唯一
F16 品牌码建号走 generate_seats(同步 argon2),生成的密码直接丢弃 brand_code_service.py:80-113,:104 只取 created[0]["id"];家长首次进站自设密码(前端 setBrandCodePasswordSetupRequired)
F17 迁移:部署跑 alembic upgrade heads;生产唯一 head=phase83_002;test 有 4 个 head(34ec94466f0c / phase50_seat_wx_binding / phase79_001 / phase85_004)(9-28 复核:origin/test 3a75d611 新增 phase85_002/003/004 接在 phase85_001 后;生产 head 仍 phase83_002) deploy/auto-update.sh:249-252;db-prod SELECT version_num FROM alembic_version → phase83_002
F18 密钥:JWT_SECRET_KEY 启动必需 ≥64 字节;cryptography 已随 python-jose[cryptography] 安装;全仓 Fernet/HKDF 0 命中 app/auth/jwt_local.py:24;main.py:16;requirements.txt:33
F19 启动钩子是旧式 @app.on_event("startup") app/main.py:326-329
F20 子席位管理默认排除品牌码号 parent_seat_service.py:103-108

2. 总体设计

                 ┌──────────── 子席位管理「开通子席位」 count=1..50000 ────────────┐
                 │                                                                │
         count ≤ 10(同步)                                              count > 10(后台批次)
                 │                                                                │
   bulk_open.create_chunk(n)                              建 seat_open_batches 行(pending)→ 立刻返回 batch_id
   → 当场返回 [{username,password}]                        → asyncio 后台任务:每 500 个一块 create_chunk
   (凭证弹框,同现在)                                       → 每块一个事务提交 + 进度回写 → done / failed(告警)
                                                                                  │
                                                          「批量开号记录」:进度 / 下载 Excel / 按机构看(入口在子席位明细)

create_chunk(org, n, batch_id?) 一块做的事(单事务):
1. _alloc_parent_usernames(db, org_id, prefix, n) 取 n 个号(复用批次三逻辑,含撞号兜底)
2. 每个号 generate_seat_initial_password()(6 位数字,沿用)→ temp_password.seal() 得 $tmpenc1$<Fernet 密文> 直接写入 password_hash
3. 一条 INSERT … VALUES (…), (…) 批量写 users(seat_type='parent'、seat_source='platform'、must_change_password=true、seat_valid_days、seat_batch_id)
4. 同一事务一条批量 INSERT 写 account_lifecycle_event(seat_added)—— 不再每号起一个后台任务
5. 批次行 created_count += n,commit


3. 数据层(迁移 phase83_003)

🔴 down_revision = "phase83_002"(生产唯一 head · F17)。禁止接 phase85_*(企微,未上生产):接它会让生产 upgrade heads 找不到祖先而失败。test 上会因此多出一个 head,部署用 heads 可正常处理。

3.1 新表 seat_open_batches(开号批次)

列 类型 说明
id BigInteger PK
org_id Integer FK organizations.id ON DELETE RESTRICT 开到哪个机构
requested_count Integer NOT NULL CHECK 11..50000 只收 >10 的批次(P4)
created_count Integer NOT NULL default 0 已开数量(进度 / 断点续开)
valid_days Integer NULL 同 ProvisionIn.valid_days
status String(12) NOT NULL CHECK in (pending, running, done, failed)
error Text NULL 失败原因(截断)
created_by_admin_id Integer FK users.id ON DELETE SET NULL
created_at / started_at / finished_at TIMESTAMPTZ

3.2 users 加列

3.3 不改的

users.password_hash 仍是 String(255) 可空(user.py:34);$tmpenc1$ + Fernet 密文实测长度 110 < 255。


4. 临时密码(新模块 app/services/temp_password.py)

PREFIX = "$tmpenc1$"
_key = base64.urlsafe_b64encode(HKDF(SHA256, length=32, salt=None,
        info=b"doctor-ai-brain/seat-temp-password/v1").derive(JWT_SECRET_KEY.encode()))
def seal(plain: str) -> str            # PREFIX + Fernet(_key).encrypt(plain)
def is_sealed(h: str | None) -> bool   # h 以 PREFIX 开头
def unseal(h: str) -> str | None       # 解密失败返回 None(不抛)
def verify(plain: str, h: str) -> bool # hmac.compare_digest(unseal(h) or "", plain);任何异常 → False

5. 登录(只改一处)

app/api/seat_auth.py:218(_seat_login_resolve_account):

# 改前
if seat is None or not verify_password(body.password, seat.password_hash or ""):
# 改后
if seat is None or not _check_seat_password(seat, body.password):

def _check_seat_password(seat, plain) -> bool:
    h = seat.password_hash or ""
    if temp_password.is_sealed(h):
        # 只认:家长号 + 还没改过密码;其余一律按密码错
        if seat.seat_type != "parent" or not seat.must_change_password:
            return False
        return temp_password.verify(plain, h)   # 内部 try/except → False
    return verify_password(plain, h)            # 原路径,原样

6. 后台批次任务(新模块 app/services/seat_bulk_open_service.py)


7. 接口

方法 路径 说明
POST /api/admin/parent-seats/provision 改:count 上限 50 → 50000。≤10 同步返回 {seats:[{username,password}]}(形状不变,前端兼容);>10 返回 {batch_id, status:"pending"}
GET /api/admin/parent-seats/batches 本机构批量开号记录分页(org_id 必填,入口来自该机构子席位明细):时间/数量/已开/状态/已激活数
GET /api/admin/parent-seats/batches/{id} 单批进度(前端轮询,2s 一次)
GET /api/admin/parent-seats/batches/{id}/export 生成 Excel 并下载(见 §8);任意状态可下载,失败批 = 下载已开部分
POST /api/admin/parent-seats/batches/{id}/resume 失败批次继续开(§6 · P12)

全部 require_role("admin")(同文件现有写法)。router prefix 沿用 admin_parent_seats.py:42。


8. 导出 Excel


9. 作废(v1.1 长官定:本期不做整批作废)


10. 修隐患:生命周期埋点批量写(F7)


11. 品牌码两处修复(P8)

11.1 同一微信并发重复建号(F15)

activate_brand_code(brand_code_service.py:173)把 :207-209 查建段抽成 _find_or_provision_family_seat,在专用连接上加会话级建议锁,持锁跑「查 → 没有就建 + 写绑定」,finally 解锁并关连接:

SELECT pg_advisory_lock(hashtext('brand-activate:' || :org_id || ':' || :identity))
-- … 查建 …
SELECT pg_advisory_unlock(hashtext('brand-activate:' || :org_id || ':' || :identity))

🔴 v1.4 更正:v1.3 写的 pg_advisory_xact_lock 无效 —— 事务锁在 generate_seats 的 seat_service.py:348 提交时就释放,而微信绑定在 brand_code_service.py:108-113 第二个事务才写,第二个请求会在两次提交之间拿到锁、按 unionid 查不到而再建一个号。
identity = unionid 优先,否则 openid。同一人两个并发请求串行化:第二个等第一个提交后再查,必然命中已建的号。不同人互不阻塞。

11.2 建号时的无用 argon2(F16)

generate_seats 加关键字参数 password_mode: Literal["argon2", "unusable"] = "argon2":
- 默认 argon2 → 门店端、其它所有调用方行为不变
- _provision_family_seat(:80)传 unusable:password_hash 写 $unusable$<随机串>(passlib 无法识别 → verify_password 恒 False),省掉 60ms 同步 CPU;家长本来就看不到这个密码,首次进站自设密码(前端既有 setBrandCodePasswordSetupRequired),改密端点不校验旧密码 → 流程不受影响


12. 前端


13. 长官已定(9-28 · v1.1)

# 问题 结论
D1 整批作废 本期不做;单张停用用现有「停用」(§9)
D3 Excel / 筛选 / 继续开 / 开号前拦 / 压测清理 见 §0 P10-P14(9-28 晚定)
D2 批量开号记录检索 按机构看,不做机构筛选(入口在该机构子席位明细里,天然带机构);单张卡按卡号在子席位管理现有搜索框查(admin_parent_seats.py:162 q)

→ §7 删除 POST /batches/{id}/void;§15 83.29 不含作废;§14 测试第 7 条删除。


14. 测试(禁 mock · 真 PG)

新增(integration)
1. 批量开 2000 个:行数 / seat_batch_id / $tmpenc1$ 前缀 / 生命周期行数 = 2000 / 批次 done
2. 断点续开:第 3 块抛异常 → failed + 告警;重置 pending 后续开 → 总数正好 = requested,无重复账号
3. 同时只跑一批:第二个 >10 提交 → 409
4. 临时密码登录:对 → 通过并绑微信;错 → 401;改密后旧临时密码 → 401;$tmpenc1$ 串直接喂 verify_password → False
5. 门店号 / 老家长号 / 管理员登录回归不变
6. 导出:4 列;密码格 未激活给密码 / 已激活「已激活」/ 重置未登录「已重置」;5 万行耗时与峰值内存记录
8. 品牌码:同一微信并发 2 个激活请求 → 只 1 个号;_provision_family_seat 写 $unusable$ 且不调 argon2
9. generate_seats 默认参数下门店开号结果与改前一致;生命周期批量写行数正确
10. 继续开:failed 批 resume → 补齐到 requested,无重复账号;另一批 running 时 resume → 409;非 failed resume → 409
11. 余量:counter last_seq=999990 时提交 20 个 → 409 且不建批次行;无前缀 → 409

回归:test_phase74_admin_parent_seats.py(26)· test_seat_login_e2e.py(23)· test_phase83_batch5_seat_login.py(15)· tests/unit/test_seat_auth_jwt_middleware.py(13)· test_admin_brand_code.py(16)· test_brand_code_claim.py(9)· test_phase83_batch5_brand_code.py(7)· test_phase52_emit_points.py(10)· test_phase83_seat_seq_counter.py(11)· tests/integration/test_phase68_seat_initial_password.py(5)· test_seat_expiry_e2e.py(12)· tests/contract/test_app_import_smoke.py

test 环境验收:在单独压测机构真开 5 万个(计时 + 期间登录延迟抽测)→ 下载 Excel → 抽 3 个号微信真机首登 → 改密 → 再下载显示已激活 → 子席位管理按卡号搜到并单张停用 → 从该机构子席位明细进批量开号记录 → 品牌码扫码回归 → 清理(P14):按 seat_batch_id 先 SELECT 计数(users / 生命周期 / 批次行 / 3 张真机卡的登录与微信绑定)→ 交 root 执行删除 → 再 SELECT 为 0,且其他表行数与删前一致


15. 子步骤(impl 另文展开)

子步骤 内容
83.26 迁移 phase83_003(新表 + users.seat_batch_id)
83.27 temp_password 模块 + 单测
83.28 seat_bulk_open_service(create_chunk / run_batch / 续开 / 告警)+ generate_seats 生命周期批量写
83.29 接口:provision 改造 + batches 四个端点(本机构列表 / 进度 / 导出 / 继续开)+ 提交时余量校验
83.30 登录一处判断(seat_auth.py:218)
83.31 品牌码两处修复(建议锁 + unusable 密码)
83.32 前端:开通弹窗 + 批量开号记录
83.33 test 环境 5 万真跑验收 + 全回归

16a. 物理 verify 证据(15 维 · origin/test f44699c2 上 git grep -n / git show | sed -n,或 db-prod / 实测)

维 核什么 结果(原样) 对照组 / 备注
V1 开号上限 admin_parent_seats.py:64: count: int = Field(1, ge=1, le=50);seat_service.py:290: detail="count 必须 1-100" —
V2 同步 argon2 seat_service.py:326: password_hash=hash_password(temp_pw),;生产计时 60.4ms/个(cpu_count=4) 同轮 OWASP 档 22.7ms,证明计时有效
V3 生命周期逐号开连接 seat_service.py:363: schedule_emit(emit_lifecycle(org_id, "seat", str(sid), "seat_added", {}));activity_log_service.py:207: async with async_session_maker() as session:;database.py:25 poolclass=NullPool;生产 max_conn=50 —
V4 登录只一处要改 seat_auth.py:218: if seat is None or not verify_password(body.password, seat.password_hash or ""):;:298: User.must_change_password.is_(False), :332/:354 为手机号路径
V5 失败即拒 password_utils.py:49-52 try/except → return False —
V6 首登改密置 False seat_auth.py:1052 seat.must_change_password = False(seat_change_password)/ :1162(seat_forgot_password) —
V7 迁移挂点 生产 alembic_version = phase83_002;auto-update.sh:252 alembic upgrade heads;test heads = 34ec94466f0c / phase50_seat_wx_binding / phase79_001 / phase85_004(9-28 复核:origin/test 3a75d611 新增 phase85_002/003/004 接在 phase85_001 后;生产 head 仍 phase83_002);phase85_001.down_revision="phase83_002" 脚本解析 105 个迁移文件
V8 编号容量 / 前缀 org_parent_seat_counter.py:41 "last_seq BETWEEN 100000 AND 999999";organization.py:63 "subseat_prefix ~ '^[A-Z]{1,6}$'";seat_service.py:305 if not org.subseat_prefix: —
V9 现有导出扛不住 5 万 parent_seat_service.py:33 EXPORT_MAX_ROWS = 20000;page.tsx:399 表头无密码列;5 万行 jsonable_encoder 冻结事件循环 1.6~3.8s(本机实测,心跳采样 0) to_thread 包裹后心跳恢复
V10 品牌码无防并发 brand_code_service.py 内 with_for_update/FOR UPDATE/IntegrityError 0 命中 对照:seat_service.py IntegrityError 7 处
V11 品牌码密码丢弃 brand_code_service.py:104 seat_id = created[0]["id"];:208-209 判空才 _provision_family_seat —
V12 密钥与加密库 jwt_local.py:24 JWT_SECRET_KEY = os.getenv(...);main.py:16 不足 64 字节 abort;requirements.txt:33 python-jose[cryptography]==3.3.0;全仓 Fernet/HKDF 0 命中 对照:同命令查 JWT_SECRET_KEY = os.getenv 命中
V13 单张停用 / 检索已有 admin_parent_seats.py:291 @router.patch("/{seat_id}/status");:156-157 org_id/org_ids;:162 q;parent_seat_service.py:137 User.username.ilike(like) —
V14 表容量 本地 PG 事务内真插 5 万行后 ROLLBACK(前后 46654 行):+20.94MB,登录 0.024/0.047ms,统计/导出/搜索 14.8~26.3ms 生产 users 188 行、库 41MB
V15 编号未占用 docs 中 Phase 83.2[6-9] / 83.3x 0 命中;git log --grep phase83.2[6-9] 0 条 impl 最大 83.22;提交记录有 83.25

16b. v1.2/v1.3 新增声明物理复核(15 维 · origin/test 3a75d611)

原始命令与输出见 ~/reports/phase83-batch4-verify-v13-20260928.md。

维度 方案声明 实测 结论
D1 基线 f44699c2,应用代码/迁移零漂移 origin/test 领先 f44699c2 共 22 commits;方案引用的 10 个应用文件 git diff --stat 全部为空(零改动);migrations/versions/ 新增 3 个文件(phase85_002/003/004) 偏差(局部):应用代码零漂移(所有 file:line 引用有效);migrations 目录有新增,牵动 D2
D2 生产 head=phase83_002;test 4 个 head(34ec94466f0c / phase50_seat_wx_binding / phase79_001 / phase85_001) 生产 SELECT version_num FROM alembic_version = phase83_002(✅ 与方案完全一致);test 实测 4 个 head = 34ec94466f0c / phase50_seat_wx_binding / phase79_001 / phase85_004(不是 phase85_001) 偏差:test 第 4 个 head 已从 phase85_001 漂到 phase85_004(因 D1 新增的 3 个 phase85_00{2,3,4} 迁移文件接龙上去了);head 数量仍是 4,不影响方案红线①(down_revision 仍只能接生产 head phase83_002),但方案原文这行需要更新引用值
D3 「导出明细」按钮在 page.tsx:887-893 按钮文本行在 :892,整个 <button> 块 885-893,与声明范围重叠/基本一致 成立
D4 明细表 th :976-983;聚合表 th :815-821 逐行核对,两处 th 行号完全一致(976-983 共 8 个 th;815-821 共 7 个 th) 成立(精确)
D5 开通弹窗:w-[420px] :1148 / 副标题 :1152 / 占位符 :1166 / 数量标签 :1206 / max={50} :1212 / 有效期 :1219 逐行核对全部精确命中同一行号 成立(精确)
D6 前缀提示块 :1190-1205;seat_service.py 无前缀 409 :305/:308;provSelectedOrg.subseat_prefix 来源 前缀提示实际块 1192-1203(在声明范围 1190-1205 内,略窄)成立;seat_service.py:305 if not org.subseat_prefix: / :308 detail 文案精确命中;数据源确认来自 allOrgs state(page.tsx:178,类型含 subseat_prefix),由 api_v1.py:260 OrgItem.subseat_prefix schema 字段 + :400 subseat_prefix=org.subseat_prefix 组装下发(机构列表接口) 成立
D7 counter CHECK org_parent_seat_counter.py:39-43;_alloc_org_seq_batch seat_service.py:181-212 CHECK 语句实际在 :41(在声明区间内);函数体实际 :181-210(声明 181-212,多出 2 行空行,覆盖有效) 成立
D8 告警:incident_logger.py log_incident / 告警推送链路 log_incident 定义在 incident_logger.py:15,精确命中方案 §6 引用;另查到 app/services/alert_notifier.py:328 def push_alert(...) 存在(方案正文未直接引用该函数,仅供确认告警链路物理存在) 成立
D9 部分唯一索引(postgresql_where)有先例可循 命中 3 个文件(027a_add_vlm_columns_and_4l_switch.py、phase29_001_create_auth_tables.py ×3、phase48a_001_brand_code.py);对照组 create_index 28 处命中,证明查法有效 成立
D10 新接口尚不存在(batches/resume 0 命中);router prefix :42;现有 le=50 :64 batches/resume 在 admin_parent_seats.py 均 0 命中(对照组 provision 3 命中,证明查法有效,非误查);router = APIRouter(prefix="/api/admin/parent-seats") 精确命中 :42;count: int = Field(1, ge=1, le=50) 精确命中 :64 成立(精确)
D11 状态徽章无「失败」;STATE_LABEL/DISABLED_BADGE :40-49;disabled 按钮样式 :1041 STATE_LABEL 定义于 :40-44(in_use/pending/expired 三态),DISABLED_BADGE 在 :49,均在声明范围内,确认无「失败」态;disabled 按钮 className 精确命中 :1041 成立
D12 main.py startup 事件 :326/:327 @app.on_event("startup") 精确在 :326,async def startup_event(): 精确在 :327 成立(精确)
D13 Excel 依赖 openpyxl;admin_brand_code.py 存在可参考的 _build_export_workbook,新导出计划用 write_only=True requirements.txt:68 openpyxl==3.1.5 已装;_build_export_workbook 定义在 admin_brand_code.py:503,调用在 :767;该函数内部用的是 wb = Workbook()(:566),不是 write_only=True 信息性发现(非方案声明矛盾):方案 §8 说的是新写的批量导出函数要用 write_only=True,并未声称 _build_export_workbook 已经这样做;实测确认现有品牌码导出走的是普通 Workbook(),write_only 是本期要新引入的写法,不是复用既有 write_only 代码
D14 dev2 有 db-/test-inspect 相关 sudo 权限 sudo -n -l 显示 NOPASSWD:db-grant/db-prod/db-query/test-inspect 四个均有;sudo db-grant --help 正常返回用法(仅对 test 库) 成立
D15 generate_seats 逐号 emit 隐患仍在 seat_service.py:361-363 import ... schedule_emit 在 :361,for sid in created_ids: 在 :362,schedule_emit(emit_lifecycle(...)) 在 :363,精确命中 成立(精确),F7 隐患物理确认仍未修

偏差与处理:
1. test head 变化 → 已按第 1 条改,红线不变
2. admin_brand_code.py _build_export_workbook(:503,内部 :566 wb = Workbook())用普通 Workbook() 非 write_only;批次四导出是新写函数,必须 write_only=True,不照抄该函数


16. 红线

  1. 迁移 down_revision 只能是 phase83_002
  2. verify_password 本体不改;登录只改 seat_auth.py:218 一处
  3. 门店端(store_subseat.py)与品牌码业务流程不改;generate_seats 新参数默认值必须保持原行为
  4. 5 万导出不许走 JSON → 浏览器链路;不许在事件循环里同步跑 openpyxl
  5. 不许每个号起一个后台任务 / 开一个连接
  6. 更换 JWT_SECRET_KEY 前必须先重锁临时密码