版本 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/test3a75d611复核:方案引用的 11 个应用文件与 f44699c2 零差异,行号全部有效(§16b)
评估与证据:~/reports/gift-card-plan-a-evaluation-20260928.md(长官版)·~/reports/gift-card-bulk-open-verify-20260928.md(18 维证据)
取代:旧批次四 D1-D4(礼品卡 5 万号 · 同步开号方案)
| # | 拍板 |
|---|---|
| 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 只读) |
| # | 事实 | 出处 |
|---|---|---|
| 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 |
┌──────────── 子席位管理「开通子席位」 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
phase83_003)🔴 down_revision = "phase83_002"(生产唯一 head · F17)。禁止接 phase85_*(企微,未上生产):接它会让生产 upgrade heads 找不到祖先而失败。test 上会因此多出一个 head,部署用 heads 可正常处理。
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 |
CREATE UNIQUE INDEX ux_seat_open_batches_one_active ON seat_open_batches ((true)) WHERE status IN ('pending','running') → 第二个并发提交在 INSERT 处 IntegrityError → 409「已有开号任务在进行」(P 优化 5)(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 NULLusers.password_hash 仍是 String(255) 可空(user.py:34);$tmpenc1$ + Fernet 密文实测长度 110 < 255。
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
JWT_SECRET_KEY 派生(F18),不新增环境变量、不用 root 配JWT_SECRET_KEY 前,必须先跑迁移脚本(用旧密钥 unseal → 新密钥 seal)重锁所有 $tmpenc1$ 行;否则未激活卡既登录不了、也补下载不了。写进部署 READMEapp/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) # 原路径,原样
verify_password 本体不动(P7);管理员 / 结算 / h5 / 门店 / 老家长号的登录代码不动:332 :354 不动:候选要求 must_change_password=False(F11),临时号进不去$tmpenc1$ 的串若误进 verify_password,passlib 识别不了 → except → False(F10),绝不误放行:551 要求微信身份 → :572 绑定微信 → 前端弹改密 → seat_change_password(:1002)写标准 argon2 + must_change_password=False(:1052)→ $tmpenc1$ 前缀消失 = 已激活admin_parent_seats.py:302,不改)app/services/seat_bulk_open_service.py)subseat_prefix 非空(F4,开号前就 409,不等跑到一半)+ 余量校验(P13):读 org_parent_seat_counter.last_seq(无行视为 100000),count > 999999 - last_seq → 409「该机构只剩 N 个号可开」;_alloc_org_seq_batch(seat_service.py:181)内越界 IntegrityError→409 保留作兜底→ INSERT 批次行(撞「一次只跑一批」索引 → 409)→ commit → asyncio.create_task(run_batch(id)),任务对象放模块级 set 持强引用(同 activity_log_service.schedule_emit 写法,防 GC)run_batch:status=running → 循环 create_chunk(min(500, 剩余)),每块独立 session + 事务;块间 await asyncio.sleep(0) 让出事件循环 → status=donestatus=failed + error → log_incident(module="seat_bulk_open", error_type="batch_failed", severity="error", org_id=…, detail={batch_id, created_count})(incident_logger.py:15)startup_event(main.py:327)里查 status IN ('pending','running') 的批次,重新 create_task(run_batch);续开从 requested_count - created_count 继续。块是原子的(一块要么全进要么全不进),因此不重不漏;因重启中断而取走未用的号段会跳号(批次三已接受跳号)POST /batches/{id}/resume 仅 status=failed 可调(否则 409)→ UPDATE 为 pending(撞「一次只跑一批」部分唯一索引 → 409「有一批正在生成,请稍后再继续」)→ commit → create_task(run_batch),从 requested_count - created_count 接着开;error 字段保留上次原因| 方法 | 路径 | 说明 |
|---|---|---|
| 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。
openpyxl write_only=True,放 asyncio.to_thread 里生成(本机 5 万行 8.3s、峰值 44MB),Response(bytes) 返回is_sealed → unseal 出临时密码;否则 last_login_at 非空 → 「已激活」;否则 → 「已重置」(后台重置过、家长还没登录)admin_parent_seats.py:291 @router.patch("/{seat_id}/status")(子席位管理行内「停用/启用」);批次开出的号 seat_source='platform',本来就出现在子席位管理列表里,可按卡号检索(admin_parent_seats.py:162 q + parent_seat_service.py:137 User.username.ilike)seat_batch_id 的停用接口即可,数据层已留好 seat_batch_idgenerate_seats 也改:seat_service.py:361-363 的「每号一个 schedule_emit」→ 改为「一个 schedule_emit,内部一条批量 INSERT」。原因:现有上限 100 已超过 max_connections=50,是现存隐患;行为不变(同样是 commit 成功后写、失败不影响开号),门店端开号(≤20)与品牌码(=1)只是少开连接,业务行为零变化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。同一人两个并发请求串行化:第二个等第一个提交后再查,必然命中已建的号。不同人互不阻塞。
generate_seats 加关键字参数 password_mode: Literal["argon2", "unusable"] = "argon2":
- 默认 argon2 → 门店端、其它所有调用方行为不变
- _provision_family_seat(:80)传 unusable:password_hash 写 $unusable$<随机串>(passlib 无法识别 → verify_password 恒 False),省掉 60ms 同步 CPU;家长本来就看不到这个密码,首次进站自设密码(前端既有 setBrandCodePasswordSetupRequired),改密端点不校验旧密码 → 流程不受影响
web/app/(dashboard)/admin/parent-seats/page.tsxmax={50}(:1212)→ max={50000},标签「数量(1-50)」(:1206)→「数量(1-50000)」,>10 时提示「超过 10 个将在后台生成,完成后在子席位明细里下载」(放在数量/有效期行下方占满整行、单行显示;生成中/完成态文案指向该机构『子席位明细 → 批量开号记录』);选完机构后现网已有的「账号前缀:XX,新开的号接着往下排」(:1199 附近)保持不动doProvision(:460):≤10 走原 setCred 弹框(:485);>10 收到 batch_id → 打开「批量开号记录」并轮询进度seat-login-hints.ts:25-27)| # | 问题 | 结论 |
|---|---|---|
| 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 条删除。
新增(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,且其他表行数与删前一致
| 子步骤 | 内容 |
|---|---|
| 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 万真跑验收 + 全回归 |
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 |
原始命令与输出见 ~/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,不照抄该函数
down_revision 只能是 phase83_002verify_password 本体不改;登录只改 seat_auth.py:218 一处store_subseat.py)与品牌码业务流程不改;generate_seats 新参数默认值必须保持原行为JWT_SECRET_KEY 前必须先重锁临时密码