交付出件箱

← 返回出件箱

Phase 83 批次五 · 技术方案自检报告(15 维物理 verify)

2026-09-24 深夜 · 对象:docs/superpowers/plans/tech/phase83-batch5-login-overhaul.md(分支 feature/subseat-login-v2)
自检后修订已提交 a5d27dac(本地,未 push)。全部证据是脚本 / 命令的原始输出,附在文末,没有转述。
9-24 深夜复核更正(长官追问后逐条重核):初稿有 2 处说错 1 处说得不准,都已改——①「品牌码家长在家长子席位页显示待绑定」错:该页列表本就排除品牌码家长,已撤回;②「漏拦 4 个入口」错:实为 2 个(个人档案、近视的世界),抗近领航 / 谣言粉碎机家长看不到;③「新号回来第一次登录报错」不准:准确说是「下次需要重新登录时」(退出或登录过期),平时免登不会碰到。其余结论逐条重核成立。
环境标签:云机代码只读 grep + 生产 / test 库只读 SELECT(只出数量,无账号 / 手机号原文)。不是业务验收,业务验收要去 test 环境。

一、结论

二、15 维结果总表

# 维度 做法 结果
D0 基线 git log / merge-base 分支 HEAD b6577372,基线 e6770bb7,origin/test 498b8252
D1 基线差异 × 方案文件 base→test 变更文件 ∩ 方案 184 处引用的文件 11 个变更文件,与方案重叠 0(对照组:变更集非空)
D2 行号取证 脚本逐条读取 file:line 区间,用正则匹配预期内容 180/184 PASS;4 处 ±1(seat_service :347→:347-348、brand-code 页 :373→:374、:510 实为默认参数、:736-741 是 docstring 列举)——已改
D3 NEW 文件不存在 git ls-files 5 个新文件全 0(对照组 admin_brand_code.py = 1)
D4 NEW 名字不撞名 全仓 grep 10 个新名字全 0;multiple_accounts 3 处是复用找回密码现有结构(本就要复用);phone_not_found 1 处是无关测试函数名(对照组 must_change_password = 63)
D5 引用符号存在 grep def / export 26 个全部存在;另发现 generate_seats 同名端点 admin_seats.py:116 → 引出 D7 第 4 个门店开号入口
D6 迁移链 alembic heads 官方命令 3 个 head(34ec94466f0c / phase79_001 / phase83_001),phase83_001 是叶子,phase83_002 不存在 ✅(自写解析器误报第 4 个 head,已用官方命令纠正)
D7 入口穷举 grep 所有调用方 generate_seats 调用方比方案多 1 个(admin_seats.py:126,建门店号,不受前缀影响)——已补;登录 / 改密 / 找回 / 微信回跳 / 刷新调用方与方案一致
D8 must_change_password 全仓读者 grep app + web 席位侧只在登录响应用;发现结算清理按它判孤儿(见三-1)
D9 品牌码家长绕过入口 grep router.push / location.href 漏拦 2 个入口(见三-3;初稿误写 4 个,已更正)
D10 门店号 seat_type 为 NULL 代码惯例 + 实查 代码惯例 or_(seat_type IS NULL, ='store');生产 / test 实查 NULL 均 0 个——已写明照惯例
D11 测试清单 文件存在 + 用例数 9 个后端 + 6 个前端用例数全部一致(phase68 用 it.each,宽口径 7 ✅)
D12 口径一致性 方案文档 vs tech 关键词 一致;决策 7「不显示账号」:弹窗现在就不显示账号(全文件只有 :163 一处提到“账号”)——已注明
D13 红线覆盖 grep 红线 2/3/4/5 有;红线 1 补上引用
D14 数据事实(生产 / test) seatops 只读通道 count 见四,方案里的数字全部复核一致
D15 线索追查 读代码上下文 发现「记住密码」冲突(见三-2)

三、新发现的问题(都已写进方案 + impl 验收项)

🔴 1. 结算侧清理会误删正在用的品牌码家长

🔴 2. 「记住密码」让每个新号下次重新登录时第一次都报错

🟡 3. 品牌码家长「先设密码」漏拦 2 个入口(初稿误写 4 个,已更正)

孩子的个人档案(:3037)、近视的世界(:3053)两个入口家长看得到,原方案没拦,已补。复核更正:初稿写成「漏拦 4 个」,把抗近领航 :3143、谣言粉碎机 :3161 也算进去了——复核显示条件后发现它们在 isParentSeat ? … : … 的非家长分支(:3082 之后),家长看不到,不用拦。

🟡 4. 「这个微信登过哪些号」要另写查询

不能改 lookup_seat_username_by_wx:它的 return_all=True 被找回密码候选列表(seat_auth.py:815)共用,改了会打坏候选列表(红线 3)。

🟡 5. 第 4 个门店开号入口

后台「生成席位」admin_seats.py:126 原来没写进方案。它建的是门店号,不受前缀影响;新号照常 phone_login_enabled=True。

~~ℹ️ 顺带发现:品牌码家长在「家长子席位」页显示「待绑定」~~(已撤回)

长官指出后复查:该页列表本来就排除了品牌码家长(parent_seat_service.py:103-108 显式 seat_source != "brand_code"),品牌码家长只在品牌码页管理。是我只看了状态判定、没看列表过滤,查漏了。

四、生产 / test 数据复核(seatops 只读通道 · 只出数量)

项 生产 test 方案里的说法
席位总数 173(家长 20 / 门店 153) 303(家长 65 / 门店 238) 173 / 303 ✅
账号带小写字母 门店 12 门店 18 12 / 18 ✅
账号长得像手机号 0 0 新补:手机号分支不会误伤任何现有账号
账号只差大小写的撞车 0 组 0 组 新补:转大写兜底不会串号
seat_type 为 NULL 0 0 新补
有手机号的席位 家长 6 / 门店 111 家长 7 / 门店 26 —
同一手机号挂 ≥2 个席位 2 组 2 组 都是老号,批次五上线后仍按账号登,不受影响
机构带 prefix 的列 / users 带 phone_login 的列 无 / 无 无 / 无 都是新增 ✅
alembic 版本 phase48a_002 3 个 head #730-#733 要先上生产 ✅
手机号唯一索引 h5 / parent / store 三个 同左 迁移必须手写 ✅
品牌码 / 开通记录 0 / 0 1 / 2 上线前生产不开品牌码 ✅

附录 A · 自检脚本全量输出(/tmp/b5verify.out 原文)


======== D0 基线 ========
b6577372 docs(phase83): 批次五 tech 开工前复审修订(前后端两路对抗审查 · 主会话逐条复核)
feature/subseat-login-v2
0
merge-base: e6770bb7 Merge branch 'refs/heads/fix/org-service-rumor-param' into 'refs/heads/test'. Code review title: docs(org-service): 标注 enable_rumor_buster 跨文件联动约束. Code review Link: https://codeup.aliyun.com/69f0e93a7b6e0a0129631e95/doctor-ai-brain/change/734
origin/test: 498b8252 Merge branch 'refs/heads/claude/objective-rosalind-51220c' into 'refs/heads/test'. Code review title: fix(chat): 流式生成器外层兜底归还请求级 DB 连接(异常/aclose/断开路径). Code review Link: https://codeup.aliyun.com/69f0e93a7b6e0a0129631e95/doctor-ai-brain/change/740

======== D1 基线差异 × tech 涉及文件(期望 0 重叠) ========
base→test 变更文件数: 11
与 tech 锚点文件重叠: 0 个
对照组(变更集里确有文件): ['app/api/h5_chat.py', 'app/services/brain_gateway.py', 'app/services/org_service.py']

======== D2 行号取证(tech 每条 file:line · PASS=该区间命中预期内容) ========
PASS migrations/versions/phase83_001_seat_seq_counter.py:24-24 | revision = "phase83_001"
PASS migrations/versions/phase74_001_parent_subseat_foundation.py:92-103 | "CREATE UNIQUE INDEX uq_users_phone_h5 ON users(phone) "
PASS app/models/user.py:33-33 | username = Column(String(64), unique=True, nullable=True, index=True)
PASS app/models/user.py:46-46 | phone = Column(String(20), unique=True, nullable=True, index=True)
PASS app/models/organization.py:11-59 | class Organization(Base):
PASS app/services/seat_service.py:129-170 | async def _gen_unique_username(org_id: int, db: AsyncSession) -> str:
PASS app/services/seat_service.py:186-210 | async def _alloc_seq_batch(
PASS app/services/seat_service.py:213-255 | async def _alloc_parent_usernames(db: AsyncSession, count: int) -> list[str]:
PASS app/services/seat_service.py:237-238 | await db.commit()
PASS app/services/seat_service.py:239-239 | candidates = [f"{seq:06d}{period}" for seq in seq_range]
PASS app/services/seat_service.py:261-261 | async def generate_seats(
PASS app/services/seat_service.py:305-317 | if seat_type == _PARENT_SEAT_KIND:
PASS app/services/seat_service.py:307-307 | parent_usernames = await _alloc_parent_usernames(db, count)
PASS app/services/seat_service.py:311-311 | try:
PASS app/services/seat_service.py:319-334 | must_change_password=True,  # 功能2(7-07·v2):新席位首登强制改密(改 v1「无自助改密流」)
PASS app/services/seat_service.py:343-343 | except IntegrityError:
FAIL app/services/seat_service.py:347-347 rx=不回收|浪费 | 实际首行: # 回滚整批(本请求内尚未提交的席位全部作废,计数表已 commit 的号段允许被 | ±60 行命中 [348]
PASS app/services/seat_service.py:721-721 | async def lookup_seat_username_by_wx(
FAIL app/services/seat_service.py:736-741 rx=return None | 实际首行: 返回 None 的四种情形: | ±60 行命中 []
PASS app/services/seat_service.py:762-766 | if return_all:
PASS app/services/brand_code_service.py:94-100 | created = await seat_service.generate_seats(
PASS app/services/brand_code_service.py:110-110 | seat.must_change_password = False
PASS app/services/brand_code_service.py:88-90 | - must_change_password 显式置 False:本席位永远走微信免密登录,不存在"首登改密"
PASS app/services/brand_code_service.py:205-205 | seat = await _find_existing_family_seat(brand.org_id, unionid, openid, db)
PASS app/services/brand_code_service.py:240-248 | return {
PASS app/api/brand_code_public.py:30-36 | class ActivateResponse(BaseModel):
PASS app/api/admin_brand_code.py:41-41 | router = APIRouter(prefix="/api/admin/orgs", tags=["管理后台-机构-品牌码"])
PASS app/api/admin_brand_code.py:128-142 | class ActivationRecord(BaseModel):
PASS app/api/admin_brand_code.py:183-239 | async def _bulk_enrich_family_seats(seat_ids: list[int], db: AsyncSession) -> di
PASS app/api/admin_brand_code.py:306-346 | async def open_brand_code(
PASS app/api/admin_brand_code.py:349-361 | async def set_brand_code_status(
PASS app/api/admin_brand_code.py:423-436 | @router.get("/{org_id}/brand-code/activations", response_model=ActivationListRes
PASS app/api/admin_brand_code.py:670-681 | @router.get("/{org_id}/brand-code/export")
PASS app/api/admin_parent_seats.py:132-132 | raise HTTPException(status_code=404, detail="机构不存在")
PASS app/api/admin_parent_seats.py:134-134 | seats = await seat_service.generate_seats(
PASS app/api/admin_parent_seats.py:302-309 | @router.post("/{seat_id}/reset-password")
PASS app/services/parent_seat_service.py:280-289 | async def get_parent_seat_or_404(seat_id: int, db: AsyncSession) -> User:
PASS app/api/store_subseat.py:55-62 | status_code=status.HTTP_403_FORBIDDEN,
PASS app/api/store_subseat.py:79-79 | seats = await seat_service.generate_seats(
PASS app/api/api_v1.py:225-261 | class OrgItem(BaseModel):
PASS app/api/api_v1.py:288-289 | @router.get("/admin/orgs", response_model=List[OrgItem])
PASS app/api/h5_store_card.py:229-229 | @router.get("/store-card")
PASS app/main.py:49-49 | from app.api import annotations, community, wecom, auth, knowledge_b1, api_key_m
PASS app/main.py:233-233 | app.include_router(admin_brand_code.router, tags=["Phase 48A 批1 · admin 品牌码管理"])
PASS app/main.py:116-116 | _LOGIN_PATHS = {"/api/seat/login": "seat_login", "/api/auth/login": "auth_login"
PASS app/main.py:139-139 | error_type = f"{_LOGIN_PATHS[path]}_{exc.status_code}"
PASS app/main.py:163-164 | from app.services.alert_notifier import schedule_login_fail_alert
PASS app/services/alert_notifier.py:44-44 | _NOT_ALERTABLE: frozenset[tuple[str, str]] = frozenset({
PASS app/services/alert_notifier.py:56-56 | return (module, error_type) not in _NOT_ALERTABLE
PASS app/services/alert_notifier.py:84-98 | async def note_login_success(username: str | None) -> None:
PASS app/services/alert_notifier.py:111-111 | async def schedule_login_fail_alert(
PASS app/services/alert_notifier.py:338-338 | if not is_alertable(module, error_type):
PASS app/services/alert_notifier.py:342-342 | if is_deferred(module) and not _force:
PASS app/services/auth_service.py:139-150 | select(func.count()).select_from(LoginAttempt).where(
PASS app/services/auth_service.py:150-150 | if attempts >= COOLDOWN_THRESHOLD and user is not None:
PASS app/api/seat_auth.py:152-152 | async def seat_login(
PASS app/api/seat_auth.py:183-224 | ipaddress.ip_address(ip)
PASS app/api/seat_auth.py:228-235 | User.username == username,
PASS app/api/seat_auth.py:228-235 | ).scalar_one_or_none()
PASS app/api/seat_auth.py:231-231 | User.username == username,
PASS app/api/seat_auth.py:241-255 | if seat is None or not verify_password(body.password, seat.password_hash or ""):
PASS app/api/seat_auth.py:260-265 | # ── Step 3: is_active check ──
PASS app/api/seat_auth.py:267-284 | # ── Step 4: org_code 一致性(seat.linked_org_id 对应 org.org_code == 请求 org_code) ──
PASS app/api/seat_auth.py:299-304 | # ── Step 4.5: 机构人工停用 check(D1 org-expiry-removal:去掉机构级时间到期·只看 is_active 开关) ──
PASS app/api/seat_auth.py:306-312 | # ── Step 4.6: 席位级到期 check(seat_expires_at < now → 拒登录) ──
PASS app/api/seat_auth.py:314-330 | # 未带 wx_identity_token 一律 403 —— 前端 seat-login-hints 撞此 detail 特征串
PASS app/api/seat_auth.py:318-318 | if not body.wx_identity_token and (seat.seat_type or "store") == "parent":
PASS app/api/seat_auth.py:350-363 | detail="登录失败,请席位本人登录,感谢",
PASS app/api/seat_auth.py:369-384 | # 安全硬化:已绑微信的席位必须带 wx_identity_token · 防绕过 openid 绑定。
PASS app/api/seat_auth.py:386-396 | # ── Step 5+6: 写 current_device_id(仅记录最近登录设备 · 多设备共存,不踢旧) + last_login_at ──
PASS app/api/seat_auth.py:398-418 | # ── Step 7: 签 token(带 device_id + device_epoch) + 写 login_attempt(success) + 审计
PASS app/api/seat_auth.py:400-400 | await note_login_success(username)
PASS app/api/seat_auth.py:420-457 | org_features = {
PASS app/api/seat_auth.py:454-454 | "must_change_password": bool(seat.must_change_password),
PASS app/api/seat_auth.py:463-537 | @router.post("/seat/wx-identity", response_model=WxIdentityResponse)
PASS app/api/seat_auth.py:490-490 | unionid = wx.get("unionid")  # 公众号未绑开放平台时为 None · 正常
FAIL app/api/seat_auth.py:510-510 rx=return_all | 实际首行: seat_username = await lookup_seat_username_by_wx(unionid, openid, db) | ±60 行命中 []
PASS app/api/seat_auth.py:706-706 | async def seat_change_password(
PASS app/api/seat_auth.py:731-738 | if body.contact_phone is None:
PASS app/api/seat_auth.py:741-745 | seat = (
PASS app/api/seat_auth.py:750-750 | seat.must_change_password = False
PASS app/api/seat_auth.py:756-759 | raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该手机号已被其他账号使用")
PASS app/api/seat_auth.py:788-789 | @router.post("/seat/forgot-password")
PASS app/api/seat_auth.py:851-851 | seat.must_change_password = False
PASS app/api/seat_auth.py:870-870 | return {"username": seat.username}
PASS app/api/internal_settlement.py:577-577 | created = await generate_seats(
PASS app/api/internal_settlement.py:698-698 | created = await generate_seats(org_id, payload.seat_count, session, payload.vali
PASS app/api/internal_settlement.py:1643-1643 | created = await generate_seats(
PASS web/app/h5/chat/components/seat-login-sheet.tsx:97-97 | const wxSeat = getWxIdentitySeatUsername();
PASS web/app/h5/chat/components/seat-login-sheet.tsx:118-121 | if (isPhoneLikeUsername(username)) {
PASS web/app/h5/chat/components/seat-login-sheet.tsx:129-136 | body: JSON.stringify({
PASS web/app/h5/chat/components/seat-login-sheet.tsx:192-192 | setSeatToken(data.access_token);
PASS web/app/h5/chat/components/seat-login-sheet.tsx:201-201 | setWxIdentitySeatUsername(username.trim());
PASS web/app/h5/chat/components/seat-login-sheet.tsx:203-208 | if (data.must_change_password) {
PASS web/app/h5/chat/components/seat-login-sheet.tsx:222-224 | return <SeatChangePasswordSheet onSuccess={() => onSuccess(pendingOrgFeatures)} 
PASS web/app/h5/chat/components/seat-login-sheet.tsx:300-300 | placeholder="席位账号"
PASS web/app/h5/chat/components/seat-login-sheet.tsx:328-332 | placeholder={
PASS web/app/h5/chat/components/seat-login-sheet.tsx:357-366 | {(error || cooldownLeft > 0) && (
PASS web/app/h5/chat/components/seat-login-sheet.tsx:401-410 | 忘记密码?点此自助重置
PASS web/app/h5/chat/components/seat-login-sheet.tsx:402-402 | {getWxIdentitySeatUsername() ? (
PASS web/app/h5/chat/components/seat-login-sheet.tsx:415-430 | setUsername(u);
PASS web/app/h5/tablet/components/seat-login.tsx:82-82 | const wxSeat = getWxIdentitySeatUsername();
PASS web/app/h5/tablet/components/seat-login.tsx:104-107 | if (isPhoneLikeUsername(username)) {
PASS web/app/h5/tablet/components/seat-login.tsx:115-121 | body: JSON.stringify({
PASS web/app/h5/tablet/components/seat-login.tsx:168-168 | setSeatToken(data.access_token);
PASS web/app/h5/tablet/components/seat-login.tsx:174-174 | setWxIdentitySeatUsername(username.trim());
PASS web/app/h5/tablet/components/seat-login.tsx:179-179 | if (data.must_change_password) {
PASS web/app/h5/tablet/components/seat-login.tsx:197-199 | return <SeatChangePasswordSheet onSuccess={() => onSuccess(pendingOrgFeatures)} 
PASS web/app/h5/tablet/components/seat-login.tsx:272-272 | placeholder="席位账号"
PASS web/app/h5/tablet/components/seat-login.tsx:300-304 | placeholder={
PASS web/app/h5/tablet/components/seat-login.tsx:329-338 | {(error || cooldownLeft > 0) && (
PASS web/app/h5/tablet/components/seat-login.tsx:372-381 | 忘记密码?点此自助重置
PASS web/app/h5/tablet/components/seat-login.tsx:386-401 | setUsername(u);
PASS web/app/h5/chat/components/seat-forgot-password-sheet.tsx:23-23 | onSuccess: (username: string, newPassword: string) => void;
PASS web/app/h5/chat/components/seat-forgot-password-sheet.tsx:82-86 | if (resp.status === 409 && j?.detail?.code === "multiple_accounts") {
PASS web/app/h5/chat/components/seat-forgot-password-sheet.tsx:100-100 | const data = (await resp.json()) as { username?: string };
PASS web/app/h5/chat/components/seat-forgot-password-sheet.tsx:106-106 | onSuccess(data.username, pw1);
PASS web/app/h5/chat/components/seat-forgot-password-sheet.tsx:141-165 | {candidates.length > 0 && (
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:17-17 | import { authHeaders, getSeatType,
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:22-32 | interface SeatChangePasswordSheetProps {
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:36-36 | const isParent = getSeatType() === "parent";
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:37-37 | const phoneLabel = isParent ? "家长手机号" : "门店联系电话";
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:91-91 | setError(detail || "设置失败,请重试");
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:153-153 | <div className="mt-1 text-[12.5px] text-emerald-50/90">首次登录 · 请设置您的密码</div>
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:277-284 | <span className="font-semibold text-gray-700">立即失效</span>
PASS web/app/h5/chat/components/__tests__/seat-change-password-sheet.spec.tsx:93-95 | it("渲染门店联系电话输入框", () => {
PASS web/app/h5/chat/lib/wx-oauth.ts:71-91 | export function setWxIdentityToken(
PASS web/app/h5/chat/lib/auth.ts:9-19 | // Phase 46.8 · 席位密码登录:access_token + device_id 持久化 localStorage
PASS web/app/h5/chat/lib/auth.ts:555-555 | export function setSeatToken(token: string): void {
PASS web/app/h5/chat/lib/auth.ts:585-585 | export function clearSeatToken(): void {
PASS web/app/h5/chat/lib/auth.ts:682-682 | setSeatToken(data.access_token);
PASS web/app/h5/chat/lib/seat-login-hints.ts:13-14 | "席位账号不是手机号,请输入提供给您的10位数字账号";
PASS web/app/h5/chat/page.tsx:924-991 | setWxIdentityToken(
PASS web/app/h5/chat/page.tsx:975-980 | setWxIdentityToken(
PASS web/app/h5/chat/page.tsx:1179-1179 | setIsParentSeat(getSeatType() === "parent");
PASS web/app/h5/chat/page.tsx:1634-1634 | const handleStart4L = useCallback(() => {
PASS web/app/h5/chat/page.tsx:1970-1970 | const handleOpenHistory = useCallback(async () => {
PASS web/app/h5/chat/page.tsx:2012-2012 | const sendText = useCallback(async (rawText?: string) => {
PASS web/app/h5/chat/page.tsx:2670-2690 | handleStart4L();
PASS web/app/h5/chat/page.tsx:2720-2725 | setInputText(text);
PASS web/app/h5/chat/page.tsx:3019-3021 | onClick={handleOpenHistory}
PASS web/app/h5/chat/page.tsx:3087-3089 | onClick={() => router.push("/h5/store-refractive")}
PASS web/app/h5/chat/page.tsx:3108-3110 | onClick={handleStart4L}
PASS web/app/h5/chat/page.tsx:3122-3124 | onClick={handleOpenHistory}
PASS web/app/h5/chat/page.tsx:3261-3263 | if (e.key === "Enter") void sendText();
PASS web/app/h5/chat/page.tsx:3269-3270 | onClick={() => void sendText()}
PASS web/app/h5/chat/page.tsx:3378-3378 | <SeatLoginSheet
PASS web/app/h5/tablet/page.tsx:338-343 | setWxIdentityToken(
PASS web/app/h5/tablet/page.tsx:713-713 | <TabletSeatLogin
PASS web/app/h5/brand-scan/page.tsx:99-99 | router.replace("/h5/chat?login=seat");
PASS web/app/h5/store-refractive/page.tsx:509-509 | <SeatLoginSheet
PASS web/app/h5/store-refractive/[patientId]/page.tsx:730-730 | <SeatLoginSheet
PASS web/app/h5/store-refractive/[patientId]/record/new/page.tsx:2485-2485 | <SeatLoginSheet
PASS web/app/h5/store-refractive/[patientId]/record/[recordId]/page.tsx:1154-1154 | <SeatLoginSheet
PASS web/app/h5/store-refractive/_components/create-patient-sheet.tsx:133-133 | // 401 续期失败 / 403 → 父级(page.tsx)原地弹 <SeatLoginSheet>,本组件不直接处理跳转
PASS web/app/h5/family/[id]/exam/new/page.tsx:1347-1347 | <SeatLoginSheet orgCode={null} onSuccess={() => setAuthGateOpen(false)} />
PASS web/app/h5/family/[id]/exam/new/page.tsx:663-666 | if (!getSeatToken() || getSeatType() !== "parent") {
PASS web/app/h5/family/[id]/page.tsx:2516-2516 | router.push(`/h5/family/${pid}/exam/new`);
PASS web/app/h5/family/[id]/page.tsx:2863-2863 | router.push(`/h5/family/${pid}/exam/new?rid=${rid}`);
PASS web/app/h5/settings/components/ParentAccountCard.tsx:272-272 | collectPhone={false}
PASS web/app/h5/settings/components/SeatStoreCard.tsx:39-58 | interface StoreCardData {
PASS web/app/h5/settings/components/SeatStoreCard.tsx:95-95 | const r = await fetch(`${API_BASE}/api/h5/store-card`, { headers: authHeaders() 
PASS web/app/h5/settings/components/SeatStoreCard.tsx:922-1003 | ? "开通席位"
PASS web/app/h5/settings/components/SeatStoreCard.tsx:1354-1358 | (text-[#9a4a00] bg-[#fff8ed] border-[#fde7c7])· 方案红线:详情页不放管控按钮 */}
PASS web/app/h5/settings/components/SeatStoreCard.tsx:1364-1385 | {busy ? "开通中…" : `确认开通 ${count} 个`}
PASS web/app/(dashboard)/admin/orgs/page.tsx:46-77 | interface OrgItem {
PASS web/app/(dashboard)/admin/orgs/page.tsx:121-121 | <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0" />
PASS web/app/(dashboard)/admin/orgs/page.tsx:1074-1091 | if (!brandCodeConfirm) return;
PASS web/app/(dashboard)/admin/orgs/page.tsx:1083-1083 | window.alert("开启品牌码失败,请稍后重试");
PASS web/app/(dashboard)/admin/orgs/page.tsx:1291-1365 | {/* 9-10 修回退:重新生成凭证/停用启用/删除机构收进「更多」下拉(fixed 定位·见 menuOpen) */}
PASS web/app/(dashboard)/admin/orgs/page.tsx:1368-1410 | {brandCodeConfirm.confirming ? "开启中…" : "确认开启"}
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:170-170 | const [provOrg, setProvOrg] = useState("");
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:177-177 | const [allOrgs, setAllOrgs] = useState<{ id: number; name: string }[]>([]);
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:334-334 | setCred({ seats: [{ username: b.username, password: b.password }], kind: kind ==
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:445-445 | setAllOrgs(rows.map((o: { id: number; name: string }) => ({ id: o.id, name: o.na
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:971-971 | <th className="px-4 py-3 font-medium">家长手机</th>
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:1035-1035 | <button onClick={() => setConfirmAction({ seat: s, kind: "reset" })} className="
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:1110-1110 | : { title: <>重置 <code className="font-mono text-blue-700">{seat.username}</code>
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:1127-1216 | {/* 开通子席位弹窗 */}
PASS web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:358-378 | const handleToggleEnabled = async () => {
FAIL web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:373-373 rx=操作失败,请稍后重试 | 实际首行: if (resp.ok) await loadSummary(); | ±60 行命中 [374]
PASS web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:461-464 | disabled={!summary || toggling}
PASS web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:590-600 | <th className="px-6 py-4 font-medium">用户</th>
PASS web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:634-652 | 停用

合计 184 条:PASS 180 / FAIL 4

======== D3 NEW 声明 · 新文件必须不存在(git ls-files 期望 0) ========
migrations/versions/phase83_002_subseat_prefix_and_phone_login.py → 0
app/models/org_parent_seat_counter.py → 0
app/api/admin_org_subseat_prefix.py → 0
tests/integration/test_phase83_batch5_login.py → 0
web/app/h5/chat/components/__tests__/seat-forgot-password-sheet.spec.tsx → 0
对照组 已存在文件 app/api/admin_brand_code.py → 1

======== D4 NEW 名字 · 全仓代码 0 命中(撞名检查) ========
subseat_prefix               0
phone_login_enabled          0
org_parent_seat_counter      0
OrgParentSeatCounter         0
login_fail_kind              0
phone_not_found              1
bound_seats                  0
login_phone                  0
chosen_username              0
requireSeatPasswordSetup     0
subseat-prefix               0
multiple_accounts            3
对照组 must_change_password → 63

======== D5 符号存在(tech 引用的函数/类) ========
def generate_seats                            app/services/seat_service.py:261:async def generate_seats(
app/api/admin_seats.py:116:async def generate_seats(
def _alloc_parent_usernames                   app/services/seat_service.py:213:async def _alloc_parent_usernames(db: AsyncSession, count: int) -> list[str]:
def _alloc_seq_batch                          app/services/seat_service.py:186:async def _alloc_seq_batch(
def lookup_seat_username_by_wx                app/services/seat_service.py:721:async def lookup_seat_username_by_wx(
def register_login_failure                    app/services/auth_service.py:112:async def register_login_failure(
def note_login_success                        app/services/alert_notifier.py:84:async def note_login_success(username: str | None) -> None:
def push_alert                                app/services/alert_notifier.py:326:async def push_alert(
def is_alertable                              app/services/alert_notifier.py:54:def is_alertable(module: str, error_type: str) -> bool:
_NOT_ALERTABLE                                app/services/alert_notifier.py:44:_NOT_ALERTABLE: frozenset[tuple[str, str]] = frozenset({
app/services/alert_notifier.py:56:    return (module, error_type) not in _NOT_ALERTABLE
def get_parent_seat_or_404                    app/services/parent_seat_service.py:280:async def get_parent_seat_or_404(seat_id: int, db: AsyncSession) -> Us
def open_brand_code                           app/api/admin_brand_code.py:307:async def open_brand_code(
def set_brand_code_status                     app/api/admin_brand_code.py:350:async def set_brand_code_status(
def activate_brand_code                       app/services/brand_code_service.py:171:async def activate_brand_code(brand_token: str, code: str, db: AsyncSes
def list_orgs                                 app/api/api_v1.py:289:async def list_orgs(
app/api/internal_settlement.py:1145:async def list_orgs_for_mobile(
class OrgItem                                 app/api/api_v1.py:225:class OrgItem(BaseModel):
_PARENT_SEAT_KIND =                           app/services/seat_service.py:176:_PARENT_SEAT_KIND = "parent"
export function setWxIdentityToken            web/app/h5/chat/lib/wx-oauth.ts:71:export function setWxIdentityToken(
export function clearSeatToken                web/app/h5/chat/lib/auth.ts:585:export function clearSeatToken(): void {
export function setSeatToken                  web/app/h5/chat/lib/auth.ts:555:export function setSeatToken(token: string): void {
export function isPhoneLikeUsername           web/app/h5/chat/lib/seat-login-hints.ts:21:export function isPhoneLikeUsername(username: string): boolean {
export function getSeatType                   web/app/h5/chat/lib/auth.ts:620:export function getSeatType(): string {
export function SeatChangePasswordSheet       web/app/h5/chat/components/seat-change-password-sheet.tsx:34:export function SeatChangePasswordSheet({ onSucce
export function SeatLoginSheet                web/app/h5/chat/components/seat-login-sheet.tsx:52:export function SeatLoginSheet({ orgCode, kicked = false, o
function handleStart4L\|const handleStart4L   web/app/h5/chat/page.tsx:1634:  const handleStart4L = useCallback(() => {
const handleOpenHistory\|function handleOpenHistory web/app/h5/tablet/pane/panes/report-pane.tsx:408:  const handleOpenHistory = useCallback(async () => {
web/app/h5/chat/page.tsx:1970:  const handleOpenHistory = useCallback(async () => {
const sendText\|function sendText             web/app/h5/tablet/pane/panes/chat-pane.tsx:585:  const sendText = useCallback(async (rawText?: string) => {
web/app/h5/chat/page.tsx:2012:  const sendText = useCallback(async (rawText?: string) => {

register_login_failure 签名:
112:async def register_login_failure(
113-    user: Optional[User],
114-    username: str,
115-    ip: str,
116-    user_agent: str,

main.py 130-145(error_type 拼接处能否拿到 request):
    try:
        path = request.url.path
        if exc.status_code >= 500:
            await log_incident(
                module="http_5xx",
                error_type=f"http_{exc.status_code}",
                severity="error",
                detail={"method": request.method, "path": path, "detail": str(exc.detail)[:160]},
            )
        elif path in _LOGIN_PATHS and exc.status_code in _LOGIN_FAIL_STATUSES:
            raw_username = getattr(request.state, "login_username", None)
            error_type = f"{_LOGIN_PATHS[path]}_{exc.status_code}"
            detail = {
                "username": _mask_username(raw_username),
                "ip": request.client.host if request.client else None,
                "reason": str(exc.detail)[:120],
            }
            # 9-07 长官令:带上机构 + 昵称,否则掩码用户名指向几十个人、告警无法落到具体的人。
            # 端点只在「密码已验过」之后才挂这两个值,所以猜密码类失败自然不带。

======== D6 迁移链(head 必须 3 个且 phase83_001 是叶子) ========
迁移文件数: 103 | heads: ['34ec94466f0c', 'phase50_seat_wx_binding', 'phase79_001', 'phase83_001']
phase83_001 被谁接: 无(叶子)
phase83_002 已存在? False

======== D7 入口穷举(tech 列的入口是否全) ========
-- generate_seats( 调用方
app/services/seat_service.py:7:- generate_seats(org_id, count, db) → [{username, password(明文 · 仅此次)}]
app/services/seat_service.py:259:# 46.5 generate_seats(POST /api/admin/orgs/{id}/seats/generate)
app/services/brand_code_service.py:94:    created = await seat_service.generate_seats(
app/api/admin_seats.py:126:    seats = await seat_service.generate_seats(
app/api/admin_parent_seats.py:123:    - 业务层复用 seat_service.generate_seats(seat_type='parent' 末位参),不另造轮子
app/api/admin_parent_seats.py:134:    seats = await seat_service.generate_seats(
app/api/store_subseat.py:79:    seats = await seat_service.generate_seats(
app/api/internal_settlement.py:577:        created = await generate_seats(
app/api/internal_settlement.py:698:        created = await generate_seats(org_id, payload.seat_count, session, payload.valid_days)
app/api/internal_settlement.py:1643:    created = await generate_seats(
-- seat_type="parent" 建号处
app/services/brand_code_service.py:98:        seat_type="parent",
app/api/admin_parent_seats.py:139:        seat_type="parent",
app/api/store_subseat.py:84:        seat_type="parent",
-- /api/seat/login 前端调用
web/app/h5/tablet/components/seat-login.tsx
web/app/h5/chat/components/seat-login-sheet.tsx
web/app/h5/chat/lib/auth.ts
-- <SeatLoginSheet 挂载
web/app/h5/store-refractive/[patientId]/page.tsx:730:        <SeatLoginSheet
web/app/h5/store-refractive/[patientId]/record/[recordId]/page.tsx:1154:        <SeatLoginSheet
web/app/h5/store-refractive/[patientId]/record/new/page.tsx:2485:        <SeatLoginSheet
web/app/h5/store-refractive/_components/create-patient-sheet.tsx:133:  // 401 续期失败 / 403 → 父级(page.tsx)原地弹 <SeatLoginSheet>,本组件不直接处理跳转
web/app/h5/store-refractive/page.tsx:509:        <SeatLoginSheet
web/app/h5/family/[id]/exam/new/page.tsx:1347:          <SeatLoginSheet orgCode={null} onSuccess={() => setAuthGateOpen(false)} />
web/app/h5/chat/page.tsx:3378:        <SeatLoginSheet
-- setWxIdentityToken( 调用
web/app/h5/tablet/page.tsx:338:            setWxIdentityToken(
web/app/h5/chat/page.tsx:975:            setWxIdentityToken(
-- <SeatChangePasswordSheet 挂载
web/app/h5/settings/components/ParentAccountCard.tsx:271:        <SeatChangePasswordSheet
web/app/h5/tablet/components/seat-login.tsx:198:    return <SeatChangePasswordSheet onSuccess={() => onSuccess(pendingOrgFeatures)} />;
web/app/h5/chat/components/seat-login-sheet.tsx:223:    return <SeatChangePasswordSheet onSuccess={() => onSuccess(pendingOrgFeatures)} />;
-- /api/seat/change-password 调用
web/app/h5/chat/components/seat-change-password-sheet.tsx:10:// 提交:POST /api/seat/change-password(authHeaders 带 seat Bearer){new_password}
web/app/h5/chat/components/seat-change-password-sheet.tsx:75:      const resp = await fetch(`${API_BASE}/api/seat/change-password`, {
-- /api/seat/forgot-password 调用
web/app/h5/chat/components/seat-forgot-password-sheet.tsx:7:// 提交:POST /api/seat/forgot-password {wx_identity_token, new_password}(公开端点 · 无登录态)
web/app/h5/chat/components/seat-forgot-password-sheet.tsx:67:      const resp = await fetch(`${API_BASE}/api/seat/forgot-password`, {
-- SeatForgotPasswordSheet 挂载
web/app/h5/tablet/components/seat-login.tsx:387:        <SeatForgotPasswordSheet
web/app/h5/chat/components/seat-login-sheet.tsx:416:        <SeatForgotPasswordSheet
-- clearSeatToken 调用文件数
10
-- setSeatToken( 调用
web/app/h5/tablet/components/seat-login.tsx:6:// 复用 lib(import·非复制):getSeatDeviceId/setSeatToken(auth) + getWxIdentityToken/getWxIdentityDisplay(wx-oauth)
web/app/h5/tablet/components/seat-login.tsx:168:      setSeatToken(data.access_token);
web/app/h5/brand-scan/page.tsx:87:        setSeatToken(data.access_token);
web/app/h5/chat/components/seat-login-sheet.tsx:14://   · 成功 → setSeatToken(access_token)(localStorage) → onSuccess(org_features) 关弹窗
web/app/h5/chat/components/seat-login-sheet.tsx:192:      setSeatToken(data.access_token);
web/app/h5/chat/lib/auth.ts:682:    setSeatToken(data.access_token);
-- brand-code/status 前端调用
web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:11: *  - PATCH /api/admin/orgs/{org_id}/brand-code/status  body {is_enabled} → 停用/启用
web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:368:      const resp = await apiFetch(`/api/admin/orgs/${orgId}/brand-code/status`, {
-- 品牌码激活前端调用
web/app/h5/brand-scan/page.tsx:10://   → POST /api/public/brand-code/activate {brand_token, code} → 后端认人/建号/开通记录
web/app/h5/brand-scan/page.tsx:69:        const resp = await fetch(`${apiBase}/api/public/brand-code/activate`, {

======== D8 must_change_password 全仓读者(品牌码家长改为 True 后会不会在别处被拦) ========
app/services/h5_user_service.py:74:        must_change_password=False,
app/services/h5_user_service.py:115:        must_change_password=False,
app/services/user_mgmt_service.py:52:        "must_change_password": u.must_change_password,
app/services/user_mgmt_service.py:126:    4. INSERT user · must_change_password=True · password_changed_at=NULL(强制首次改密)
app/services/user_mgmt_service.py:150:        must_change_password=True,
app/services/user_mgmt_service.py:265:    """admin 重置 · 同 create_user · 但 user 已存在 · 生成新临时密码 · must_change_password=Tr
app/services/user_mgmt_service.py:278:    user.must_change_password = True
app/services/seat_service.py:94:        "must_change_password": u.must_change_password,
app/services/seat_service.py:326:                must_change_password=True,  # 功能2(7-07·v2):新席位首登强制改密(改 v1「无自助改密�
app/services/seat_service.py:466:    seat.must_change_password = True
app/services/seat_service.py:505:    seat.must_change_password = True
app/services/brand_code_service.py:88:    - must_change_password 显式置 False:本席位永远走微信免密登录,不存在"首登改密"
app/services/brand_code_service.py:110:    seat.must_change_password = False
app/services/auth_service.py:15:- login(username, password, ip, user_agent, db) → {access_token, refresh_token, must_change_password}
app/services/auth_service.py:201:    返回: {access_token, refresh_token, must_change_password}
app/services/auth_service.py:228:    must_change = bool(user.must_change_password)
app/services/auth_service.py:264:        "must_change_password": must_change,
app/services/auth_service.py:401:    5. 写 password_history + 更新 user · 写 audit + 清 must_change_password
app/services/auth_service.py:453:    user.must_change_password = False
app/models/user.py:74:    must_change_password = Column(
app/api/auth.py:36:    must_change_password: bool
app/api/admin_seats.py:72:    must_change_password: bool = False
app/api/me_router.py:38:    must_change_password: bool
app/api/me_router.py:86:        must_change_password=bool(user.must_change_password),
app/api/seat_auth.py:106:    must_change_password: bool = False
app/api/seat_auth.py:454:        "must_change_password": bool(seat.must_change_password),
app/api/seat_auth.py:702:#   长官定:只改密码(不改用户名)· 密码「6 位数字即可」· 改完清 must_change_password
app/api/seat_auth.py:717:    3. 更新 password_hash(argon2) + must_change_password=False + password_changed_at=now + phone
app/api/seat_auth.py:750:    seat.must_change_password = False
app/api/seat_auth.py:851:    seat.must_change_password = False
app/api/internal_settlement.py:109:    must_change_password=True,首次登录仍强制改密。
app/api/internal_settlement.py:215:        must_change_password=True,
app/api/internal_settlement.py:250:        "must_change_password": True,
app/api/internal_settlement.py:287:        account.must_change_password = False
app/api/internal_settlement.py:293:            "must_change_password": False,
app/api/internal_settlement.py:299:    account.must_change_password = True
app/api/internal_settlement.py:306:        "must_change_password": True,
app/api/internal_settlement.py:387:    must_change_password=True AND last_login_at IS NULL 的孤儿,绝不误删在用席位;
app/api/internal_settlement.py:399:                User.must_change_password == True,  # noqa: E712
app/api/admin_users.py:43:    must_change_password: bool
app/api/admin_users.py:191:    """D10 重置临时密码 · must_change_password=True · 返新明文一次性"""
-- 前端:
web/app/channel/minicup/_components/channel-h5-shell.tsx:33:  must_change_password: boolean;
web/app/channel/minicup/_components/channel-h5-shell.tsx:353:      if (data.role !== "channel" || !data.channel_id || data.must_change_password) {
web/app/channel/minicup/_components/channel-h5-shell.tsx:415:      if (data.must_change_password) {
web/app/(dashboard)/admin/users/[id]/page.tsx:37:  must_change_password: boolean;
web/app/(dashboard)/admin/users/page.tsx:30:  must_change_password: boolean;
web/app/(dashboard)/admin/users/page.tsx:184:                      {u.must_change_password && (
web/app/(dashboard)/admin/users/page.tsx:205:                    <StatusBadge locked={locked} mustChange={u.must_change_password} isActive={u.is_activ
web/app/(dashboard)/admin/orgs/[id]/seats/page.tsx:92:  must_change_password?: boolean;
web/app/(dashboard)/settings/account/page.tsx:19:  must_change_password: boolean;
web/app/h5/tablet/components/seat-login.tsx:161:        must_change_password?: boolean;
web/app/h5/tablet/components/seat-login.tsx:179:      if (data.must_change_password) {
web/app/h5/chat/components/seat-login-sheet.tsx:181:        must_change_password?: boolean;
web/app/h5/chat/components/seat-login-sheet.tsx:203:      if (data.must_change_password) {
web/app/h5/chat/components/seat-change-password-sheet.tsx:3:// 触发:席位登录成功后 · 后端返 must_change_password=true → 由登录组件
web/app/login/page.tsx:13: *     · must_change_password=true → router.push('/change-password')
web/app/login/page.tsx:128:        must_change_password: boolean;
web/app/login/page.tsx:133:      if (data.must_change_password) {
-- app/auth/ 下(期望 0): 0 | 对照组 app/auth/ 有 .py: 5

======== D9 品牌码家长绕过入口(菜单 / 其他页能不能不经 chat/page 拦截点就用功能) ========
284:                router.push("/h5/settings");
-- chat/page.tsx 里 router.push 全部:
2678:                        router.push("/h5/find-similar");
3037:                      onClick={() => router.push("/h5/family")}
3089:                onClick={() => router.push("/h5/store-refractive")}
3143:                onClick={() => router.push("/h5/eye-nav")}
3161:                onClick={() => router.push("/h5/rumor-buster")}
3338:                router.push("/h5/settings");

======== D10 seat_type 为 NULL 的门店号(scene 过滤必须把 NULL 当门店) ========
123:    seat_type = Column(
132:    # 后台「全部来源」筛选就能区分两条开通路径。仅 seat_type='parent' 有业务意义。
app/services/seat_service.py:154:                or_(User.seat_type.is_(None), User.seat_type == "store"),
app/services/seat_service.py:176:_PARENT_SEAT_KIND = "parent"
app/services/seat_service.py:236:        seq_range = await _alloc_seq_batch(db, _PARENT_SEAT_KIND, remaining, period)
app/services/seat_service.py:306:    if seat_type == _PARENT_SEAT_KIND:
app/services/seat_service.py:315:                if seat_type == _PARENT_SEAT_KIND
app/services/seat_service.py:408:            or_(User.seat_type.is_(None), User.seat_type == "store")
app/services/seat_service.py:570:                                or_(User.seat_type.is_(None), User.seat_type == "store")
-- tech 是否写了 NULL 当门店: 3
-- getSeatType 默认值:

/** 席位类型:'parent'=家长子席位 · 'store'/null=门店(默认 · 老会话无此键也归 store) */
export function getSeatType(): string {
  if (typeof window === "undefined") return "store";
  try {
    return window.localStorage.getItem(SEAT_TYPE_STORAGE_KEY) || "store";
  } catch {
    return "store";
  }
}

/** 家长可见模块名单(store 席位恒 [] · 门店端不消费此名单) */
export function getSeatVisibleFeatures(): string[] {

======== D11 测试清单(文件存在 + 用例数) ========
test_seat_login_e2e.py                   tech= 23 实测=23 ✅ tests/integration/test_seat_login_e2e.py
test_seat_forgot_password_e2e.py         tech=  5 实测=5 ✅ tests/integration/test_seat_forgot_password_e2e.py
test_admin_brand_code.py                 tech= 15 实测=15 ✅ tests/integration/test_admin_brand_code.py
test_phase74_admin_parent_seats.py       tech= 25 实测=25 ✅ tests/integration/test_phase74_admin_parent_seats.py
test_seat_expiry_e2e.py                  tech= 12 实测=12 ✅ tests/integration/test_seat_expiry_e2e.py
test_phase68_ip_throttle.py              tech=  8 实测=8 ✅ tests/integration/test_phase68_ip_throttle.py
test_login_fail_grace.py                 tech=  7 实测=7 ✅ tests/test_login_fail_grace.py
test_alert_not_alertable_types.py        tech=  5 实测=5 ✅ tests/test_alert_not_alertable_types.py
test_phase83_seat_seq_counter.py         tech=  8 实测=8 ✅ tests/unit/test_phase83_seat_seq_counter.py
seat-login-sheet-wx.spec.tsx             tech=  9 实测=9 ✅ web/app/h5/chat/components/__tests__/seat-login-sheet-wx.spec.tsx
phase68-seat-login.spec.tsx              tech=  7 实测=0 ❌ web/app/h5/tablet/components/__tests__/phase68-seat-login.spec.tsx
seat-change-password-sheet.spec.tsx      tech= 12 实测=12 ✅ web/app/h5/chat/components/__tests__/seat-change-password-sheet.spec.tsx
wx-oauth.spec.ts                         tech= 10 实测=10 ✅ web/app/h5/chat/lib/__tests__/wx-oauth.spec.ts
seat-login-sheet-phone-hint.spec.tsx     tech=  2 实测=2 ✅ web/app/h5/chat/components/__tests__/seat-login-sheet-phone-hint.spec.tsx
tablet seat-login.spec.tsx tech=8 实测= 8 web/app/h5/tablet/components/__tests__/seat-login.spec.tsx
seat-login-hints.spec.ts:198 →     expect(PHONE_LIKE_USERNAME_MESSAGE).toBe("席位账号不是手机号,请输入提供给您的10位数字账号"); web/app/h5/chat/lib/__tests__/seat-login-hints.spec.ts
admin-orgs-*.test.tsx                         3 个文件
admin-parent-seats-*.test.tsx                 2 个文件
admin-org-brand-code-seat-actions.test.tsx    1 个文件
seat-store-card-branding.test.tsx             1 个文件
verify.sh chat-e2e 存在: 9 | 触发清单含 chat/page.tsx: 1

======== D12 口径一致性(方案文档 §十三 vs tech) ========
100001                    方案文档   2 | tech   3
1-6|1~6                   方案文档   1 | tech   1
门店版                       方案文档   4 | tech   3
统一叫「手机号」                  方案文档   2 | tech   1
不显示账号|不再显示账号              方案文档   1 | tech   0
短信                        方案文档   9 | tech   1
换绑                        方案文档   3 | tech   1
请席位本人登录                   方案文档   1 | tech   0
只认手机号                     方案文档   2 | tech   2
只认账号                      方案文档   1 | tech   1
tech 引用的决策号: [1, 2, 3, 6, 10, 11, 12, 13, 14, 15, 16]
方案文档表格行号: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
方案文档有、tech 没引用: [4, 5, 7, 8, 9]

======== D13 红线覆盖(tech 引用了哪些红线) ========
tech 提到的红线号: [2, 3, 4, 5]

======== D14 数据事实 · test 库(db-query test · 只读) ========
        relname         | n_live_tup 
------------------------+------------
 brand_code_activations |          2
 brand_codes            |          1
 organizations          |         88
 seat_seq_counter       |          1
 users                  |        388
(5 rows)

(注:结果最多 1000 行 · 超出请加更严格 WHERE / LIMIT)
 org_seat_rows 
---------------
             0
(1 row)

(注:结果最多 1000 行 · 超出请加更严格 WHERE / LIMIT)
 column_name 
-------------
(0 rows)

(注:结果最多 1000 行 · 超出请加更严格 WHERE / LIMIT)
       indexname       
-----------------------
 uq_users_phone_h5
 uq_users_phone_store
 uq_users_phone_parent
(3 rows)

(注:结果最多 1000 行 · 超出请加更严格 WHERE / LIMIT)
-- 品牌码相关表名(models):
app/models/brand_code.py:31:    __tablename__ = "brand_codes"
app/models/brand_code.py:78:    __tablename__ = "brand_code_activations"

[stderr] grep: app/models/__pycache__/share_ai.cpython-310.pyc: binary file matches

======== D15 追查线索 ========
-- alembic heads(官方命令 · 只读脚本目录):
34ec94466f0c (head)
phase79_001 (head)
phase83_001 (head)
-- phase50_seat_wx_binding 的 revision 声明与谁接它:
migrations/versions/phase50_seat_expiry.py:4:Revises: phase50_seat_wx_binding
migrations/versions/phase50_seat_expiry.py:11:down_revision = phase50_seat_wx_binding(dev/test 链 phase50 都是 head · 单头延续)。
migrations/versions/phase50_seat_expiry.py:20:down_revision = "phase50_seat_wx_binding"
migrations/versions/phase50_seat_wx_binding.py:3:Revision ID: phase50_seat_wx_binding
migrations/versions/phase50_seat_wx_binding.py:28:revision = "phase50_seat_wx_binding"
-- auth.ts 调 /api/seat/login 的上下文:
21:const SEAT_REMEMBER_USERNAME_KEY = "seat_remember_username";
22:const SEAT_REMEMBER_PASSWORD_KEY = "seat_remember_password";
262: * (席位登录无 `?t=` temp-exchange · 开关改由 /api/seat/login 响应下发 · 镜像 temp_link)
674:    const resp = await fetch(`${API_BASE}/api/seat/refresh`, {
724:    window.localStorage.setItem(SEAT_REMEMBER_USERNAME_KEY, username);
725:    window.localStorage.setItem(SEAT_REMEMBER_PASSWORD_KEY, password);
736:      username: window.localStorage.getItem(SEAT_REMEMBER_USERNAME_KEY) ?? "",
737:      password: window.localStorage.getItem(SEAT_REMEMBER_PASSWORD_KEY) ?? "",
748:    window.localStorage.removeItem(SEAT_REMEMBER_USERNAME_KEY);
749:    window.localStorage.removeItem(SEAT_REMEMBER_PASSWORD_KEY);
  try {
    return {
      nickname: window.localStorage.getItem(SEAT_DISPLAY_NICKNAME_KEY),
      avatar: window.localStorage.getItem(SEAT_DISPLAY_AVATAR_KEY),
    };
  } catch {
    return { nickname: null, avatar: null };
  }
}

/** 用 refresh_token 静默续 access(access 30min 过期时调) · 实现"7天免登"。
 *
 * 成功(refresh 有效 + 机构/席位未到期)→ 写新 access → 返 true(调用方带新 token 重试原请求)
 * 失败(无 refresh / refresh 过期401 / 机构到期403 / 单设备踢401)→ 返 false(调用方走重登录 or 过期门)
 */
export async function refreshSeatToken(): Promise<boolean> {
  const refresh = getSeatRefreshToken();
  if (!refresh) return false;
  try {
    const resp = await fetch(`${API_BASE}/api/seat/refresh`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ refresh_token: refresh }),
    });
    if (!resp.ok) return false; // 401=refresh失效/被踢 · 403=机构/席位到期 → 续期失败
    const data = await resp.json();
    if (!data?.access_token) return false;
    setSeatToken(data.access_token);
    return true;
  } catch {
    return false; // 网络抖动 → 当续期失败 · 调用方兜底
  }
}

/** 获取/生成本设备 device_id(localStorage UUID · 首访生成 · 后续复用)
 *
 * 单设备被动踢:device_id 是"这台设备"的稳定标识 · 同一浏览器多次登录复用同一 id ·
 * 换浏览器/清缓存 = 新设备(新 id) → 后端 current_device_id 覆盖 → 旧设备被踢。
 */
export function getSeatDeviceId(): string {
  if (typeof window === "undefined") return "";
  try {
    let id = window.localStorage.getItem(SEAT_DEVICE_ID_KEY);
    if (!id) {
      id =
        typeof crypto !== "undefined" && crypto.randomUUID
-- auth.ts 导出的登录相关函数:
565:export function getSeatRefreshToken(): string | null {
575:export function setSeatRefreshToken(token: string): void {
670:export async function refreshSeatToken(): Promise<boolean> {
721:export function setRememberedSeatCredentials(username: string, password: string): void {
732:export function getRememberedSeatCredentials(): { username: string; password: string } {
745:export function clearRememberedSeatCredentials(): void {
-- phone_not_found 现有命中:
tests/test_miniapp_profile.py:90:async def test_unbind_phone_not_found():
-- admin_seats.py 生成接口(第 4 个门店开号入口?):
# ============================================================================
@router.post(
    "/admin/orgs/{org_id}/seats/generate", response_model=SeatGenerateResponse
)
async def generate_seats(
    org_id: int,
    body: SeatGenerateRequest,
    admin: dict = Depends(require_role("admin")),
    db: AsyncSession = Depends(get_db),
):
    """批量生成 N 个席位 · 返明文凭证列表(仅此次 · 前端 Blob 下 CSV)

    valid_days 留空=永久不到期 · 传则各席位 seat_expires_at = now + days。
    """
    seats = await seat_service.generate_seats(
        org_id=org_id,
        count=body.count,
        db=db,
        valid_days=body.valid_days,
        experience_store_name=body.experience_store_name,
    )
-- 记住密码(remember)在登录框:
web/app/h5/chat/components/seat-login-sheet.tsx:32:  getRememberedSeatCredentials,
web/app/h5/chat/components/seat-login-sheet.tsx:33:  setRememberedSeatCredentials,
web/app/h5/chat/components/seat-login-sheet.tsx:34:  clearRememberedSeatCredentials,
web/app/h5/chat/components/seat-login-sheet.tsx:91:    const { username: ru, password: rp } = getRememberedSeatCredentials();
web/app/h5/chat/components/seat-login-sheet.tsx:159:        if (hint.clearRemembered) {
web/app/h5/chat/components/seat-login-sheet.tsx:160:          clearRememberedSeatCredentials();
web/app/h5/chat/components/seat-login-sheet.tsx:198:      setRememberedSeatCredentials(username.trim(), password);
web/app/h5/tablet/components/seat-login.tsx:18:  getRememberedSeatCredentials,
web/app/h5/tablet/components/seat-login.tsx:19:  setRememberedSeatCredentials,
web/app/h5/tablet/components/seat-login.tsx:20:  clearRememberedSeatCredentials,
web/app/h5/tablet/components/seat-login.tsx:76:    const { username: ru, password: rp } = getRememberedSeatCredentials();
web/app/h5/tablet/components/seat-login.tsx:140:        if (hint.clearRemembered) {
web/app/h5/tablet/components/seat-login.tsx:141:          clearRememberedSeatCredentials();
web/app/h5/tablet/components/seat-login.tsx:172:      setRememberedSeatCredentials(username.trim(), password);

附录 B · 线索追查原文(/tmp/b5leads.out)

== L1 清理函数调用方 / 同类判孤儿条件 ==
381:async def _purge_undelivered_seats(
399:                User.must_change_password == True,  # noqa: E712
400:                User.last_login_at.is_(None),
536:        await _purge_undelivered_seats(session, record.org_id, record.seat_id_watermark)
692:        await _purge_undelivered_seats(session, org_id, record.seat_id_watermark)
== L2 internal_settlement.py:150-180 ==
# access_token / refresh_token —— 那是能代表用户身份继续调微信接口的凭据,
# 绝不透传给结算侧或前端。
# ────────────────────────────────────────────────────�
class WechatOAuthExchangePayload(BaseModel):
    code: str = Field(min_length=1)
    # 批 E-3:门店服务页进页面**静默**取 openid(snsapi_base),拿不到也不需要昵称头像。
    # 默认 True 保持批 B 签约授权(snsapi_userinfo)行为不变。
    with_userinfo: bool = True


@router.post("/wechat/oauth-exchange")
async def internal_wechat_oauth_exchange(payload: WechatOAuthExchangePayload) -> dict[str, Any]:
    from app.services.wechat_oauth_service import exchange_code

    code = payload.code.strip()
    if not code:
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="code 不能为空")

    info = await exchange_code(code, with_userinfo=payload.with_userinfo)
    return {
        "openid": info.get("openid"),
        "unionid": info.get("unionid"),
        "nickname": info.get("nickname"),
        "headimgurl": info.get("headimgurl"),
    }


# ────────────────────────────────────────────────────�
# 2. POST /orgs/{org_id}/activate-provision —— 2026-07-30 已下线,整段删除。
#    出处:phase64-contract/REQUIREMENTS-v2.2.md:97-105「上线顺序」三步 +
#    CORE-REPLY-FINAL.md:128-130 第 11 问「你们切换并通知后核心才下旧路径」承诺。
== L3 全仓按 last_login_at 为空判断的地方 ==
app/services/parent_seat_service.py:65:        User.last_login_at.is_(None),
app/api/internal_settlement.py:387:    must_change_password=True AND last_login_at IS NULL 的孤儿,绝不误删在用席位;
app/api/internal_settlement.py:400:                User.last_login_at.is_(None),
== L4 品牌码激活签发 token 的两条路(老用户 / 新用户) ==
            module="brand_code_activate",
            error_type="brand_code_activate_no_openid",
            detail={"raw": str(wx)[:200]},
        )
        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="微信身份异常(缺 openid),请重试")

    nickname = wx.get("nickname")
    avatar = wx.get("headimgurl")

    seat = await _find_existing_family_seat(brand.org_id, unionid, openid, db)
    if seat is None:
        seat = await _provision_family_seat(brand.org_id, unionid, openid, nickname, avatar, db)
    else:
        # 命中已有席位 → 刷新展示信息(同 seat_wx_identity"本人"分支的做法)
        seat.wechat_nickname = nickname
        seat.wechat_avatar = avatar
        await db.commit()
        await db.refresh(seat)

    await _record_activation(brand.org_id, unionid, openid, nickname, avatar, seat.id, db)

    access, refresh = _issue_seat_tokens(seat)

    # org_features 下发(镜像 app/api/seat_auth.py:420-438 seat_login 同款字段,
    # 供前端 applySeatOrgFeatures 消费 · 未重构 seat_auth.py 避免触碰红线函数)
    cfg = await db.scalar(select(OrgBrainConfig).where(OrgBrainConfig.org_id == seat.linked_org_id))
    org_features = {
        "enable_4l_report": cfg.enable_4l_report if cfg else False,
        "enable_find_similar": cfg.enable_find_similar if cfg else True,
        "enable_anti_myopia_plan": cfg.enable_anti_myopia_plan if cfg else True,
        "enable_eye_nav": cfg.enable_eye_nav if cfg else True,
        "enable_rumor_buster": cfg.enable_rumor_buster if cfg else True,
        "enable_myopia_world": cfg.enable_myopia_world if cfg else True,
        "show_expiry_countdown": cfg.show_expiry_countdown if cfg else False,
        "enable_user_registration": cfg.enable_user_registration if cfg else False,
        "enable_refractive_record": cfg.enable_refractive_record if cfg else False,
        "seat_expires_at": seat.seat_expires_at.isoformat() if seat.seat_expires_at else None,
    }

    # late import 防循环 import(seat_auth 顶部未 import 本模块 · 单向依赖)
    from app.api.seat_auth import _parent_visible_features

    visible_features = await _parent_visible_features(db)

    return {
        "access_token": access,
        "refresh_token": refresh,
        "nickname": nickname,
        "avatar": avatar,
        "seat_type": "parent",
        "org_features": org_features,
        "visible_features": visible_features,
    }
== L5 登录框「记住密码」预填与写入 (手机端) ==
      setCooldownLeft((s) => s - 1);
      if (cooldownLeft === 1) setError(""); // 归零瞬间清掉冷却报错
    }, 1000);
    return () => clearTimeout(t);
  }, [cooldownLeft]);

  useEffect(() => {
    const { username: ru, password: rp } = getRememberedSeatCredentials();
    if (ru) {
      // 本机记住的优先:能存下来说明这台设备上本人成功登录过,比微信反查更贴合"上次怎么用的"
      setUsername(ru);
    } else {
      // 换设备时 localStorage 是空的 —— 此时凭微信身份把账号带出来,免手输 10 位数字
      const wxSeat = getWxIdentitySeatUsername();
      if (wxSeat) {
        setUsername(wxSeat);
        setAutoFilledFromWx(true);
      }
    }
    if (rp) {
      setPassword(rp);
      prefilledPwRef.current = rp;
      setHasPrefilledPw(true);
    }
  }, []);

  const canSubmit =
    username.trim().length > 0 && password.length > 0 && !loading && cooldownLeft === 0;

---
        // Phase 68.5 · 判定交给两端共用的纯函数(seat-login-hints.ts):
        //   · 401 且提交的就是挂载时预填的那串 → 本地存的已作废 → 清 localStorage + 清空输入框
        //   · 其余情形维持改造前行为(detail 优先 · 403/422 各自兜底)
        const hint = resolveLoginFailureHint({
          status: resp.status,
          detail,
          submittedPassword: password,
          prefilledPassword: prefilledPwRef.current,
        });
        if (hint.clearRemembered) {
          clearRememberedSeatCredentials();
          prefilledPwRef.current = "";
          setHasPrefilledPw(false);
        }
        if (hint.clearPasswordInput) setPassword("");
        // bug2 · 429 冷却:起倒计时(归零自动恢复) · 文案由下方渲染层实时刷新秒数
---
        setLoading(false);
        return;
      }
      // 成功:存 access_token(localStorage)→ authHeaders() 后续自动带 Bearer
      setSeatToken(data.access_token);
      // Phase 74 · 席位类型与名单跟 token 同时落(家长/门店同一登录窗 · 登录后分流)
      setSeatProfile(data.seat_type, data.visible_features);
      // ①7天免登:refresh_token 一起存 · access 30min 过期时静默续(refreshSeatToken)· 不再强制重登
      setSeatRefreshToken(data.refresh_token);
      // 记住凭证(用户名+密码)· 下次弹登录框自动填 · 免二次输入
      setRememberedSeatCredentials(username.trim(), password);
      // bug2 浮窗修 A:登录成功=绑定已就位 · 写回反查账号(首绑当场的温浮窗
      // sessionStorage 还是"未绑"旧判定 · 不写回则忘记密码入口一直藏着)
      setWxIdentitySeatUsername(username.trim());
      // 功能2 · 首登需改密 → 弹改密窗(改完由改密窗回调进入)· 否则走原成功流
      if (data.must_change_password) {
        setPendingOrgFeatures(data.org_features);
        setPendingChangePw(true);
        setLoading(false);
        return;
      }
      setSuccess(true);
      // 成功瞬态后回调 page 关弹窗 + 进入对话(对齐 demo success 节奏) · 带 org_features 下发机构开关
      setTimeout(() => {
        onSuccess(data.org_features);
== L6 平板 记住密码 ==
      if (cooldownLeft === 1) setError(""); // 归零瞬间清掉冷却报错
    }, 1000);
    return () => clearTimeout(t);
  }, [cooldownLeft]);

  useEffect(() => {
    const { username: ru, password: rp } = getRememberedSeatCredentials();
    if (ru) {
      // 本机记住的优先:能存下来说明这台设备上本人成功登录过,比微信反查更贴合"上次怎么用的"
      setUsername(ru);
    } else {
      // 换设备时 localStorage 是空的 —— 此时凭微信身份把账号带出来,免手输 10 位数字
      const wxSeat = getWxIdentitySeatUsername();
      if (wxSeat) {
        setUsername(wxSeat);
        setAutoFilledFromWx(true);
      }
    }
    if (rp) {
      setPassword(rp);
      prefilledPwRef.current = rp;
== L7 hint.clearRemembered 来源 ==
43:  clearRemembered: boolean;
69:      clearRemembered: false,
88:      clearRemembered: false,
100:      clearRemembered: false,
116:      clearRemembered: true,
123:    clearRemembered: false,
== L8 改密弹窗成功后是否更新记住的密码 ==
11://   · 成功 → 同步更新功能1 记住的密码(localStorage seat_remember_password · 免下次自动填旧密码)
95:      // 成功:同步更新功能1 记住的密码(键对齐 chat/lib/auth.ts SEAT_REMEMBER_PASSWORD_KEY)
99:          window.localStorage.setItem("seat_remember_password", pw1);
对照组 login-sheet remember 命中数: 7
== L9 改密弹窗现在显示账号吗 ==
163:              为保护账号安全,请设置您专属的 6 位数字登录密码
== L10 chat/page.tsx 未拦截的功能入口及显示条件 ==
--- around 3037
                        className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
                        style={{ background: "linear-gradient(135deg,#eaf7f0,#daefe2)" }}
                      >
                        <History className="w-[11px] h-[11px] text-[#2fa37c]" />
                      </div>
                      <span className="text-[12px] font-semibold text-[#1f3a30] leading-none">历史解读</span>
                      <ArrowUpRight className="w-3 h-3 text-[#7a9285] -mr-0.5" />
                    </button>
                  )}
                  {parentVisible.includes("family") && (
                    <button
                      type="button"
                      onClick={() => router.push("/h5/family")}
                      className="flex items-center gap-1.5 pl-2 pr-2.5 py-1.5 bg-white border border-[#e3eee7] rounded-full hover:bg-[#f3faf6] active:
                    >
--- around 3143
                  <History className="w-[11px] h-[11px] text-[#2fa37c]" />
                </div>
                <span className="text-[12px] font-semibold text-[#1f3a30] leading-none">历史解读</span>
                <ArrowUpRight className="w-3 h-3 text-[#7a9285] -mr-0.5" />
              </button>
              </>
              )}
              {/* Phase 47.5(长官 6-12)· 抗近领航向导入口 · 找同款案例旁 · 点进 /h5/eye-nav 独立向导 */}
              {/* Phase 47(长官 6-15)· 机构开关 gate 抗近领航入口 · 关则不渲染 */}
              {featureFlags.enable_eye_nav && (
              <button
                type="button"
                onClick={() => router.push("/h5/eye-nav")}
                className="flex items-center gap-1.5 pl-2 pr-2.5 py-1.5 bg-white border border-[#e3eee7] rounded-full hover:bg-[#f3faf6] active:bg-[#e
              >
--- around 3161
                >
                  <Compass className="w-[11px] h-[11px] text-[#2fa37c]" />
                </div>
                <span className="text-[12px] font-semibold text-[#1f3a30] leading-none">抗近领航</span>
                <span className="text-[10px] text-white bg-[#2fa37c] px-1.5 rounded-full font-bold leading-[16px]">NEW</span>
                <ArrowUpRight className="w-3 h-3 text-[#7a9285] -mr-0.5" />
              </button>
              )}
              {/* Phase 75(长官 8-24)· 机构开关 gate 谣言粉碎机入口 · 与「近视的世界」同排 · 关则不渲染 */}
              {featureFlags.enable_rumor_buster && (
              <button
                type="button"
                onClick={() => router.push("/h5/rumor-buster")}
                className="flex items-center gap-1.5 pl-2 pr-2.5 py-1.5 bg-white border border-[#e3eee7] rounded-full hover:bg-[#f3faf6] active:bg-[#e
              >
--- around 3338
            onClose={() => setMenuOpen(false)}
            sessions={chatSessions}
            onNewSession={handleNewSession}
            onDeleteSession={handleDeleteSession}
            onSelectSession={handleSelectSession}
            showLogout
            userPhoneVerified={userPhoneVerified}
            userNickname={userNickname}
            userAvatar={userAvatar}
            onClickUserCard={() => {
              setMenuOpen(false);
              if (userPhoneVerified) {
                router.push("/h5/settings");
              }
              // 机构开启用户注册时 · 未注册点用户卡弹 RegisterSheet(关时维持 0 反应)
== L11 菜单侧栏 settings 入口上下文 ==
            </ul>
          )}
        </div>

        {/* Phase 61.7 · 席位专属「设置」入口(61.7-A:组件内自路由 · page.tsx 0 改动 · 0 触发 chat-e2e 工件)
            为什么不复用顶部用户卡:它走 onClickUserCard → page.tsx 判 userPhoneVerified,
            席位态该值为 false → 落到注册分支/0 反应 → 席位进不去设置页(名片维护无入口)。
            家长态不显示本入口 · 维持用户卡原路径 0 改动。 */}
        {isSeat && (
          <div className="px-4 pb-2 pt-1">
            <button
              type="button"
              onClick={() => {
                onClose();
                router.push("/h5/settings");
              }}
              className="w-full flex items-center px-3 py-3 bg-white rounded-xl border border-[#e3eee7] active:bg-[#f3faf6] transition"
              aria-label="settings"
            >
              <Settings className="w-[15px] h-[15px] text-[#2fa37c] shrink-0" />
              <span className="flex-1 ml-2.5 text-[13px] font-semibold text-[#1f2a24] text-left">
== L12 phase68-seat-login.spec.tsx 用例数(宽口径) ==
7
42:  it.each(RENDERERS)(
56:  it.each(RENDERERS)(
70:  it.each(RENDERERS)(
== L13 seat_service.py:345-349 ==
        # 并发下是未处理异常直接冒出裸 500)。批量分配 + 批量查重已过滤掉绝大多数
        # 碰撞,这里兜的是"查重之后、真正插入/提交之前"的极小概率 TOCTOU 竞态。
        # 回滚整批(本请求内尚未提交的席位全部作废,计数表已 commit 的号段允许被
        # 浪费不回收,客户端重试整批即可,不做部分成功的复杂补偿)。
        await db.rollback()
== L14 lookup_seat_username_by_wx 返回 None 段 ==
    (seat_auth.seat_login 的 Phase 50 绑定段)。换设备时 localStorage 是空的,
    账号框本要手输 10 位数字 —— 这里凭微信把它带出来。

    安全性:openid/unionid 由服务端用 OAuth code 换得(seat_wx_identity 的 docstring
    明确"禁止前端裸传"),前端伪造不了,故只会返回请求者本人绑的账号。

    返回 None 的四种情形:
      · unionid 与 openid **都为空** —— 必须先行短路。库里大量未激活席位的
        seat_bound_* 均为 NULL,不短路就可能把陌生席位当成"本人"带出去。
      · 没有任何席位绑定该身份
      · 命中多条(D5:业务上不应发生,但宁可让用户手输也不能猜错账号)
      · 命中的席位已停用
    """
    if not unionid and not openid:
        return [] if return_all else None

    conds = []
    if unionid:
        conds.append(User.seat_bound_unionid == unionid)
    if openid:
        conds.append(User.seat_bound_openid == openid)

    rows = (
        await db.execute(
            select(User.username, User.seat_type).where(
                User.role == "org_seat",
                User.is_active.is_(True),
                or_(*conds),
            )
        )
    ).all()

附录 C · 生产只读统计原文(/tmp/b5prod.out)

[org_seat 总数] [(173,)]
[seat_type 分布] [('parent', 20), ('store', 153)]
[seat_type×seat_source 分布] [('parent', 'platform', 20), ('store', 'platform', 153)]
[账号长得像手机号(^1\d{10}$) 按类型] []
[账号含小写字母 按类型] [('store', 12)]
[有手机号 按类型] [('parent', 6), ('store', 111)]
[同一手机号挂 >=2 个席位(组数)] [(2,)]
[全表账号只差大小写的撞车组数] [(0,)]
[机构总数] [(47,)]
[organizations 带 prefix 的列] []
[users 带 phone_login 的列] []
[alembic 版本] [('phase48a_002',)]
[users 上 phone 相关索引] [('uq_users_phone_h5',), ('uq_users_phone_parent',), ('uq_users_phone_store',)]
[品牌码表 brand_codes 行数] 0
[品牌码表 brand_code_activations 行数] 0
exit=0

附录 D · test 只读统计原文(/tmp/b5test.out)

[org_seat 总数] [(303,)]
[seat_type 分布] [('parent', 65), ('store', 238)]
[seat_type×seat_source 分布] [('parent', 'brand_code', 2), ('parent', 'platform', 21), ('parent', 'store', 42), ('store', 'platform', 238)]
[账号长得像手机号(^1\d{10}$) 按类型] []
[账号含小写字母 按类型] [('store', 18)]
[有手机号 按类型] [('parent', 7), ('store', 26)]
[同一手机号挂 >=2 个席位(组数)] [(2,)]
[全表账号只差大小写的撞车组数] [(0,)]
[机构总数] [(88,)]
[organizations 带 prefix 的列] []
[users 带 phone_login 的列] []
[alembic 版本] [('34ec94466f0c',), ('phase79_001',), ('phase83_001',)]
[users 上 phone 相关索引] [('uq_users_phone_h5',), ('uq_users_phone_parent',), ('uq_users_phone_store',)]
[品牌码表 brand_codes 行数] 1
[品牌码表 brand_code_activations 行数] 2
exit=0