交付出件箱

← 返回出件箱

Phase 83 批次五 · 登录改造 + 家长号新编码 · 技术方案

2026-09-24 · 阶段 3 architecture · 分支 feature/subseat-login-v2(基线 e6770bb7;origin/test 已到 498b8252,多出 3 个 MR 共 11 个文件,只动聊天后端与测试,与本方案 184 处代码引用 0 重叠 —— 9-24 深夜自检 D1 实测;开工第一步 rebase 到最新 origin/test)
需求口径唯一来源:~/reports/subseat-login-v2-plan-20260923.md §十三(16 条拍板 + 5 条红线 + 「9-24 补充 · 实施必看」8 条)
方案图定稿:~/reports/subseat-login-plan-v2-20260924.html(r12,16 块);自检报告:~/reports/subseat-login-mockup-selfcheck-20260924.md
本文所有 file:line 均为 9-24 物理 grep / sed 取证;9-24 深夜自检用脚本把 184 处引用逐条复核(180 处一致,4 处 ±1 行已校正),行号以基线 e6770bb7 为准。


一、改动总览

层 改什么 为什么(拍板号)
数据库 机构表加 subseat_prefix;新表「按机构计数」;席位表加 phone_login_enabled 决策 1 / 3
取号 家长号 = 前缀 + 6 位数字(机构内 100001 起);没前缀不许开 决策 1
后台接口 新增「子席位前缀」读写接口;机构列表 / 门店卡片带出前缀信息;开品牌码 / 重开品牌码校验前缀 决策 1
登录接口 新号(设好密码后)只认手机号、老号只认账号;大写处理;按页面限定门店号 / 家长号;同手机号两个新号密码一样才给候选;登录失败告警免推名单(main.py:139 拼 error_type、alert_notifier.py _NOT_ALERTABLE) 决策 2 / 4 / 5 / 8 / 9 / 14 / 15
微信反查 只加字段:本微信绑了哪些号、各自用什么登 决策 10 / 14,红线 3
改密 / 找回 首次设密码时手机号后端必填;自助找回成功后新号回填手机号 决策 6,补充 1
品牌码家长 新扫码家长也要设密码;激活返回带「要设密码」标志;点任意功能先弹设置框 决策 11
品牌码后台页 加手机号列 + 重置密码 决策 13
前端 登录框三种写法 + 自动填 + 选账号框;初始化弹窗「用于登录」;后台四处前缀提示 方案图第 1-16 块
结算清理 「没交付的孤儿席位」只删门店号 防本批改动后误删品牌码家长(8 节第 6 条)

不做:短信验证码(决策 12)、换绑(决策 13)、老号改格式(决策 1)、把登录从 chat/page.tsx 拆出去(与首页改版会话约定,见第十一节)。


二、数据库(一个迁移 phase83_002)

2.1 迁移挂载

2.2 三处结构变更

变更 定义 老数据
organizations.subseat_prefix VARCHAR(6) NULL;唯一索引 uq_organizations_subseat_prefix;CHECK subseat_prefix ~ '^[A-Z]{1,6}$' 全部 NULL(业务原因:前缀由平台管理员逐个机构手动设置,不回填)
新表 org_parent_seat_counter org_id INT PK REFERENCES organizations(id) ON DELETE CASCADE;last_seq INT NOT NULL DEFAULT 100000;CHECK last_seq BETWEEN 100000 AND 999999;updated_at TIMESTAMPTZ DEFAULT now() 空表(没开过新号的机构没有行)
users.phone_login_enabled BOOLEAN NOT NULL DEFAULT false(server_default) 全部 false = 老号(决策 3/5、红线 1:老号零感知)

2.3 老计数表

seat_seq_counter(phase83_001 建,app/models/seat_seq_counter.py 复合 PK (period, seat_kind))只被家长取号用(门店走 _gen_unique_username seat_service.py:129-170,不碰它)。批次五家长取号换成按机构计数后它不再被写入:表保留不删(删表要迁移、且无收益),_alloc_seq_batch 若成孤儿函数则同批删除,表的去留记为技术债。


三、取号(app/services/seat_service.py)

3.1 现状

3.2 改成

  1. 家长分支先读 Organization.subseat_prefix:
  2. 为空 → HTTPException(409, detail="该机构还没设置子席位账号前缀,请先到『机构管理 → 更多 → 子席位前缀』里设置")
  3. 取号照抄现有原子写法,换表:pg_insert(OrgParentSeatCounter).values(org_id=..., last_seq=100000 + count).on_conflict_do_update(index_elements=["org_id"], set_={"last_seq": OrgParentSeatCounter.last_seq + count}).returning(last_seq) → 区间 new_max-count+1 .. new_max
  4. 超上限靠 §2.2 的 CHECK(last_seq BETWEEN 100000 AND 999999):UPSERT 超限时 db.execute 直接抛 IntegrityError,不会返回 new_max,所以不写「执行后比较 new_max」的死代码;取号函数内部自己 try/except IntegrityError → await db.rollback() → HTTPException(409, "该机构家长账号编号已用完,请联系平台")。⚠️ 现有调用点 seat_service.py:307 在 try(:311)之外,外层 except IntegrityError(:343)兜不到,不在函数内捕获就是裸 500
  5. 沿用现有「取号后立即 commit 放锁」(:237-238):后面建号失败时这一批号段作废不回收(:347-348 注释),下一批接着往后排,号码出现空档属正常(极少,只在建号撞车时发生)
  6. 拼装:f"{prefix}{seq:06d}"(例 BF100001)。保留批量 username.in_() 查重 + 轮数上限(username 全表唯一 user.py:33,手工建的后台账号理论上可能撞)
  7. 所有新建席位(门店 + 家长,含品牌码)一律 phone_login_enabled=True(决策 3:新席位开)
  8. 门店号生成(_gen_unique_username)一行不改;internal_settlement.py:577/698/1643 三处调用均未传 seat_type → 开的是门店号,不受前缀影响;另有后台「生成席位」admin_seats.py:126(POST /api/admin/orgs/{org_id}/seats/generate)同样不传 seat_type → 门店号,也不受前缀影响(9-24 深夜自检 D7 补)

3.3 前缀锁定

「开过号」= org_parent_seat_counter 有该机构的行且 last_seq > 100000。锁定后前缀不许改(决策 1)。


四、前缀维护接口(新文件 app/api/admin_org_subseat_prefix.py)

方法 路径 行为
GET /api/admin/orgs/{org_id}/subseat-prefix 返回 {subseat_prefix, locked}
PUT /api/admin/orgs/{org_id}/subseat-prefix body {subseat_prefix};strip().upper() 后校验

五、开号 / 开品牌码守门

入口 现状 改
后台开子席位 admin_parent_seats.py:134(seat_type="parent") 只有机构不存在 404(:132) 靠第三节 generate_seats 的 409 即可,detail 原样透传
门店开家长号 store_subseat.py:79(seat_type="parent", seat_source="store") 有开关 403(:55-62) 调 generate_seats 前先查前缀,没有 → 409 "店里还没设置家长账号前缀,暂时不能开通,请联系平台管理员"(门店版文案,方案图第 15 块)
开启品牌码 admin_brand_code.py:306-346 open_brand_code 只校验机构存在,直接建码 + 打开 enable_brand_code 查前缀,没有 → 409 文案同后台开子席位
停用后重开 admin_brand_code.py:349-361 set_brand_code_status 直接改 is_enabled is_enabled=true 且没前缀 → 同上 409(防旧数据绕过)
品牌码扫码自动开号 brand_code_service.py:94-100 调 generate_seats 不另加:开品牌码已要求前缀,generate_seats 的 409 兜底

六、登录接口(app/api/seat_auth.py seat_login :152-457)

6.1 现状步骤(行号)

IP 限流 :183-224 → 查用户 + 冷却 :226-238(按 User.username == username 精确查 :228-235)→ 验密码 :241-255 → is_active :260-265 → org_code :267-284 → 机构停用 :299-304 → 席位到期 :306-312 → 家长必带微信身份 403 :314-330 → 微信绑定:非本人 403 :351-363 / 已绑未带 token :369-384 → 写设备 :386-396 → 审计 + 签发 :398-418 → 下发开关 :420-457(响应含 must_change_password :454)

失败告警:app/main.py:116 _LOGIN_PATHS 命中 /api/seat/login 且状态 ∈ {401,403,423,429} → schedule_login_fail_alert(main.py:163-164 → alert_notifier.py:111);免告警名单 alert_notifier.py:44 _NOT_ALERTABLE((module, error_type) 集合,:56 判断)。error_type 由 main.py:139 拼成 seat_login_{状态码}——手机号查无此人和密码错都是 seat_login_401,光靠状态码分不开;push_alert 先查 is_alertable(alert_notifier.py:338)再看 _force(:342),所以名单对登录告警有效(9-24 自检已读代码)

6.2 改成(只在「查用户」这一步前后加分支,后面步骤一律照走)

请求体加两个可选字段:scene: Literal["chat","store","tablet","parent"] = "chat"、chosen_username: Optional[str] = None

  1. 输入像手机号(^1\d{10}$)→ 手机号分支(9-24 深夜实查:生产 173 个 / test 303 个席位里账号长得像手机号的 0 个,这个分支不会误伤任何现有账号):
  2. 候选 = role='org_seat' 且 phone = 输入 且 phone_login_enabled 且 must_change_password = false,再按 scene 过滤(store / tablet 只要门店号;parent 只要家长号;chat 门店 + 家长都要)(门店号判定照现有惯例 or_(User.seat_type.is_(None), User.seat_type == "store"),seat_service.py:154);查询必须用 .scalars().all() 取全部(最多 2 条:门店一条 + 家长一条,分边唯一索引 phase74_001_parent_subseat_foundation.py:92-103),不许照抄本文件 :228-235 的 scalar_one_or_none()(2 条时抛 MultipleResultsFound → 500)
  3. 0 个 → 401 通用文案;不计冷却、不推告警(红线 5)。做法(9-24 自检读代码定):① 抛 401 前设 request.state.login_fail_kind = "phone_not_found";② main.py:139 算 error_type 时见到这个标记就加后缀 → seat_login_401_phone_not_found;③ _NOT_ALERTABLE 加 ("login_fail", "seat_login_401_phone_not_found")。日志照落,只是不推群。记账照现有「查无此账号」同款调 register_login_failure(None, 打码手机号, reason="phone_not_found"):没有席位行就不会冷却(auth_service.py:150 要 user is not None 才锁),但 login_attempts 照写,IP 限流(seat_auth.py:190-224)仍生效,防人拿一堆手机号乱试。审计里手机号打码(138****1234,红线 2)
  4. 1 个 → 就是它,往下照走
  5. 2 个(只可能是 chat 场景的门店新号 + 家长新号,因为同一边手机号唯一)→ 两个都验密码:
  6. 找到号以后的失败记账一律用 seat.username,不用手机号:冷却按 LoginAttempt.username 数次数(auth_service.py:139-150),用账号记才能和同一个号用账号输错的次数算在一起;手机号只进审计 detail 且打码(红线 2)
  7. 否则按账号:strip() 后先原样精确查(和现在 :231 一样);查不到、且输入里有小写字母,再按 upper() 查一次(决策 2:只改这个接口,/api/auth/login 一字不动)。⚠️ 不能整体转大写再查:9-24 实查生产 173 个席位里有 12 个门店号的账号带小写字母(test 303 个里 18 个;家长号 0 个),整体转大写这 12 个人当场登不进(全表账号只差大小写的撞车:生产 / test 均 0 组 → 转大写兜底不会串号)。前端也不许替用户转大写
  8. 查到的是「设好密码的新号」(phone_login_enabled and not must_change_password and phone 非空)→ 401 通用文案(新号只认手机号,决策 14)。⚠️ 必须带「phone 非空」,和 7.1 的 login_by = phone 判定一字不差:新号没设过密码就先走了自助找回(seat_auth.py:851 把 must_change 置 False,不收手机号)→ 没手机号 → 手机号查不到、账号又被拒 → 彻底登不进。带上这条,这种号继续用账号登
  9. 这类 401 照常推告警(红线 5 只免「手机号查无此人」,不扩大);误报靠第 3 条的撤告警解决
  10. scene 为 store / tablet 而查到家长号、或 scene 为 parent 而查到门店号 → 401(决策 14:门店专用页只找门店号,家长专用页只找家长号)
  11. 其余照原流程(老号、第一次登录的新号、刚被重置的新号都走账号)
  12. 两个分支之后的所有检查原样执行,尤其微信绑定 403(决策 15:手机号登录必须过这道锁,同时收掉「手机号当登录名安全降级」的顾虑)
  13. 登录成功撤告警(seat_auth.py:400 note_login_success(username))改成按输入值、seat.username、seat.phone(有才撤)各撤一次:现在只按输入值撤,改成手机号登录后,之前用账号输错挂起的告警撤不掉,180 秒后误报(alert_notifier.py:84-98 按 key 删)
  14. 响应加 phone_login_enabled: bool(前端初始化弹窗据此决定写法,见 9.2)

6.3 scene 怎么传

SeatLoginSheet 挂载点(grep -rn "<SeatLoginSheet" web/app):

挂载点 scene
chat/page.tsx:3378 chat(默认)
store-refractive/page.tsx:509、[patientId]/page.tsx:730、[patientId]/record/new/page.tsx:2485、[patientId]/record/[recordId]/page.tsx:1154 store
family/[id]/exam/new/page.tsx:1347 parent(9-24 自检已查:这页只给家长用,:665 不是家长席位就 router.replace("/h5/chat");入口在家长档案页 family/[id]/page.tsx:2516/2863。若用 chat,同号同密码会弹选账号框,选了门店号又被 :665 踢回首页,填了一半的检查单就丢了)
平板 tablet/page.tsx:713 <TabletSeatLogin> tablet(组件内写死)

(store-refractive/_components/create-patient-sheet.tsx:133 只是注释提到 SeatLoginSheet,不是挂载点)


七、微信反查 / 找回 / 改密

7.1 微信反查(红线 3:只加字段不改语义)

7.2 找回密码(seat_auth.py:789-869)

7.3 改密(seat_auth.py:706-773)


八、品牌码家长

事实 出处
扫码建号走 generate_seats(seat_type="parent", seat_source="brand_code") brand_code_service.py:94-100
建完立刻把 must_change_password 改回 False(设计为永远免密) brand_code_service.py:110,注释 :88-90
激活返回体没有 must_change_password brand_code_service.py:240-248
老用户重扫:按 unionid / openid 找到已有席位直接签发 token brand_code_service.py:205
鉴权中间件 / 刷新不看 must_change_password grep -rn must_change_password app/auth/ 0 命中;seat_auth.py 只在登录响应 :454 与改密 :750/:851 出现
扫码后跳 /h5/chat?login=seat,前端无品牌码专属分支,靠登录后 seat_type 分流 web/app/h5/brand-scan/page.tsx:99;chat/page.tsx:1179

改:

  1. 删掉 brand_code_service.py:110 那行 → 新扫码家长保持 must_change_password=True(推翻原「永远免密」设计,决策 11);phone_login_enabled=True 由第三节统一给
  2. 激活返回体加 must_change_password(brand_code_public.py:31 响应模型同步加)
  3. 前端在扫码落地时存下这个标志;点任意功能先弹设置框(第九节 9.3)
  4. 生产品牌码 0 个 / 开通 0 条(9-24 count + n_live_tup 双查)→ 不做存量回填;前提:批次五上线前生产不开品牌码
  5. 品牌码后台页(决策 13,依据见方案文档 §十三「决策 13 的依据」):
  6. ActivationRecord(admin_brand_code.py:128-142)加 family_seat_phone;_bulk_enrich_family_seats(:183-239)顺带取 users.phone
  7. 名单 GET .../brand-code/activations(:423-436)与导出 GET .../brand-code/export(:670-681)都带手机号
  8. 重置密码直接复用 POST /api/admin/parent-seats/{seat_id}/reset-password(admin_parent_seats.py:302-309);守门 get_parent_seat_or_404(parent_seat_service.py:280-289)只认 role/seat_type,不看 seat_source → 后端零改动
  9. ⚠️ 结算侧清理会误删品牌码家长(9-24 深夜自检补):_purge_undelivered_seats(internal_settlement.py:381,条件 :396-400,调用 :536 / :692)把「must_change_password=True 且 last_login_at IS NULL 且 id 在水位以上」的席位当没交付的孤儿直接删除;品牌码激活从不写 last_login_at(brand_code_service.py 0 处),本批改成 must_change_password=True 后,正在用的品牌码家长就符合这个条件。改:删除条件加 or_(User.seat_type.is_(None), User.seat_type == "store")——结算开通只建门店号(internal_settlement.py:577/698/1643 都不传 seat_type),只删门店号正好对上,家长号(后台开 / 门店开 / 品牌码)一律不碰。测试:水位以上造一个品牌码家长(must_change=True、last_login_at 空)+ 一个结算孤儿门店号,跑清理,前者还在、后者被删

九、前端

9.1 登录框(两份复制代码必须同步:红线 4)

位置 手机端 chat/components/seat-login-sheet.tsx 平板 tablet/components/seat-login.tsx
账号框占位符 :300 :272
密码占位符 :328-332 :300-304
微信带入 :97(预填)/ :201(写回) :82 / :174
手机号拦截 :118-121 :104-107
提交 body :129-136 :115-121
改密分支 :203-208,渲染 :222-224 :179,:197-199
找回入口 + 回填 :401-410 / :415-430 :372-381 / :386-401
错误行 :357-366 :329-338

改:
1. 读 bound_seats(新 sessionStorage 键,wx-oauth.ts:71-91 setWxIdentityToken 旁加存取),按本页 scene 过滤后定框的写法:没有 / 全是账号 → 「席位账号」;全是手机号 → 「手机号」;两种都有 → 「手机号或账号」(决策 14,方案图第 2 / 5 / 9 块)
2. 自动填:过滤后恰好一个号才填(手机号或账号),提示语跟着变(第 6 块);手机号框的密码占位符恒为「密码」
3. 手机号拦截(isPhoneLikeUsername)只在「席位账号」写法下生效;文案 seat-login-hints.ts:13-14 改成「席位账号不是手机号,请输入提供给您的账号」(第 16 块)
4. 提交 body 加 scene、chosen_username
5. 收到 409 multiple_accounts → 弹选账号框,照抄 seat-forgot-password-sheet.tsx:141-165 候选列表(账号 + 「门店版 / 家长版」,不显示店名,第 8 块);找回密码框本身一行不动(决策 16)
6. 找回成功回填:改 seat-forgot-password-sheet.tsx 三处——:23 onSuccess 签名加第三参 loginPhone?: string、:100 响应类型加 login_phone?: string、:106 传出 data.login_phone;两份登录框回填处(手机端 :415-430、平板 :386-401)有 loginPhone 就填手机号并切到「手机号」写法,没有照旧填账号。找回密码框的界面与候选列表一行不动(决策 16 指界面)
7. 微信授权回跳处把 bound_seats 一起存下——两处都要改(红线 4):手机端 chat/page.tsx:924-991(setWxIdentityToken 调用 :975-980)、平板 tablet/page.tsx:338-343(同款调用)。漏平板 → 平板登录框拿不到 bound_seats,新号在平板上退回「席位账号」写法

9.2 初始化弹窗(chat/components/seat-change-password-sheet.tsx)

9.3 品牌码家长「点任意功能先设密码」(chat/page.tsx)

入口处理函数(前端调查 F6):handleStart4L 定义 :1634(入口 :3108-3110、:2670-2690)、handleOpenHistory :1970(入口 :3122-3124、:3019-3021)、sendText :2012(发送 :3269-3270、回车 :3261-3263)、视光档案 router.push :3087-3089、查同款案例 :2670-2690。「也许想问」Q1-Q3(:2720-2725)只填输入框不发送 → 不拦,发送时由 sendText 拦。另有 2 个直接跳转的功能入口也要拦(9-24 深夜复核按显示条件确认):首页胶囊区是 isParentSeat ? (家长) : (其他)(:3013 / :3082),家长那一支里只有 4 个按钮——历史解读 :3021(handleOpenHistory,已在上面)、个人档案 router.push("/h5/family") :3037、近视的世界 window.location.href = "/myopia-world.html" :3053、关于赵阳智脑 :3069(只打开说明弹窗,不拦)。要补拦的就是 :3037 和 :3053。抗近领航 :3143、谣言粉碎机 :3161、近视的世界 :3179、视光档案 :3089 都在 :3082 之后的非家长分支,家长看不到,不用拦(上面列的视光档案 :3087-3089 同理,可不加)。设置页(侧栏 menu-sidebar.tsx:284)不拦——设置页「修改密码」已按 9.2 带手机号栏。

加一个 requireSeatPasswordSetup():家长席位且「要设密码」标志为真 → 弹 9.2 的弹窗(variant="brand_code")并 return;设完清标志。在上面这些处理函数 / onClick 开头各加一行调用。

标志存 localStorage,跟席位登录态同处(chat/lib/auth.ts:9-19 席位 token / 昵称都是 localStorage):扫码落地是整页跳转(brand-scan/page.tsx:99),只放内存会丢。清除点四处(逐一落到行):① 设密码成功后;② clearSeatToken()(chat/lib/auth.ts:585,登出 / 鉴权失效共 10 个文件都经过它)里加一行;③④ 两份登录框密码登录成功处,紧跟 setSeatToken(data.access_token) 之后(手机端 seat-login-sheet.tsx:192、平板 tablet/components/seat-login.tsx:168),防换人登录被残留标志误拦。⚠️ 不许放进 setSeatToken() 本体:token 刷新 auth.ts:682 也调它,放进去品牌码家长一刷新就绕过设密码(激活返回 ActivateResponse brand_code_public.py:30-36 不带账号、前端也没存当前账号,所以不做「标志存账号」)
⚠️ chat/page.tsx 是 chat-e2e 守门文件(协议 C 第 14 项),提交前必须 bash scripts/verify.sh chat-e2e。

9.4 后台 / 门店端四处前缀提示

位置 现状 改(方案图块号)
机构列表「更多」admin/orgs/page.tsx:1291-1365 头图 / 重新生成凭证 / 停用 / 删除 第一项加「子席位前缀」→ 小弹框(GET / PUT 第四节接口,三种报错原样显示)(第 12 块)
开启品牌码确认框 orgs/page.tsx:1368-1410,确认函数 :1074-1091 不看前缀;失败只 window.alert("开启品牌码失败,请稍后重试")(:1083) OrgItem(:46-77)加 subseat_prefix;没前缀 → 框内橙字提示 + 「确认开启」置灰(第 14 块)
开通子席位弹窗 admin/parent-seats/page.tsx:1127-1216 选中机构 state provOrg(:170);allOrgs 只存 {id,name}(:177,映射 :445 裁掉了别的字段) allOrgs 类型与映射加 subseat_prefix;没前缀 → 提示 + 「确认开通」置灰;有前缀 → 灰字「账号前缀:BF,新开的号接着往下排」(第 13 块)
门店开家长号 h5/settings/components/SeatStoreCard.tsx:922-1003,底部按钮 :1364-1385 数据源 /api/h5/store-card(:95,类型 :39-58)无前缀信息 类型加 subseat_prefix_set;为假 → 橙色提示「店里还没设置家长账号前缀,暂时不能开通,请联系平台管理员」+ 「确认开通」置灰(第 15 块;橙色写法照同文件 :1354-1358,那块本身是另一个提示,不复用)

橙字提示色值:text-amber-600(#d97706),出处 admin/orgs/page.tsx:121(方案图附录第 21 条已核)。

9.5 品牌码后台页(admin/orgs/[id]/brand-code/page.tsx)

9.6 记住密码(两份登录框 + 初始化弹窗 · 9-24 深夜自检补)


十、测试(协议:no-mock,真 PG / 真后端)

类 文件 内容
后端新增 tests/integration/test_phase83_batch5_login.py(新) 前缀接口三种报错 + 锁定;按机构取号从 100001 连号、两机构互不影响、用完报错;四个守门入口;手机号登录(单号 / 查无此人不冷却不告警 / 刚重置的号不认手机号 / 老号不认手机号 / 新号不认账号 / 门店 + 家长同号不同密码 / 同密码给候选 → 选定);scene 限定;大写只在席位登录、且先原样精确查(带小写字母的老门店号照常能登);scene parent 只找家长号;新号没设密码先走找回(没手机号)仍能用账号登;手机号 / 账号两种写法登录成功都撤掉挂起告警;微信绑定 403 在手机号分支生效;改密手机号必填;找回回填字段;wx-identity bound_seats;品牌码激活 must_change_password;名单手机号列;结算清理不删品牌码家长(见 8 节第 6 条)
后端改写 tests/unit/test_phase83_seat_seq_counter.py(8 用例) 家长号格式相关用例按新规则重写;门店相关用例保留当回归
后端回归 test_seat_login_e2e.py(23) test_seat_forgot_password_e2e.py(5) test_admin_brand_code.py(15) test_phase74_admin_parent_seats.py(25) test_seat_expiry_e2e.py(12) test_phase68_ip_throttle.py(8) test_login_fail_grace.py(7) test_alert_not_alertable_types.py(5) 全跑,失败逐条判是否预存
前端改写 seat-login-hints.spec.ts(:198 文案断言)、seat-login-sheet-phone-hint.spec.tsx(2) 文案 + 「只在席位账号写法下拦」;seat-change-password-sheet.spec.tsx 加「phoneLogin 设完密码后记住的账号 = 手机号」用例;两份登录框加「记住的账号与框写法不符时不预填」用例
前端回归 seat-login-sheet-wx.spec.tsx(9) phase68-seat-login.spec.tsx(7,两端共用) tablet/.../seat-login.spec.tsx(8) seat-change-password-sheet.spec.tsx(12) wx-oauth.spec.ts(10) admin-orgs-*.test.tsx admin-parent-seats-*.test.tsx admin-org-brand-code-seat-actions.test.tsx seat-store-card-branding.test.tsx 全跑
构建 npm run build 必跑(9-21 事故:dev / vitest / tsc 都查不出的导出问题只有 build 查得出)
守门 bash scripts/verify.sh chat-e2e 动了 chat/page.tsx 必跑
业务验收 test 环境 Playwright 真点 按方案图 16 块逐块对照;数据完整性 SQL:新建号的 phone_login_enabled、前缀、计数表逐列非空

预存失败先存档:test_phase74_admin_parent_seats.py 已知 3 个预存失败(测试里家长登录没带微信身份,被 seat_auth.py:314-330 403)。动手前先在基线跑一遍,把 3 个用例名 + 失败原因原文存档;本批改了登录分支,改完逐条比对失败原因有没有变。这 3 个用例本批顺手修(测试里补微信身份),修后 25 个全绿

找回密码组件 seat-forgot-password-sheet.tsx 没有任何测试(前端调查 F12):本批只改回调多传一个字段、界面不动;新建 web/app/h5/chat/components/__tests__/seat-forgot-password-sheet.spec.tsx 补一个最小用例(响应带 login_phone 时 onSuccess 收到第三参)


十一、与首页改版会话的约定

首页改版总方案(~/reports/CLOUD-SESSION-HANDOFF-20260924-home-master.md 第 26-28 行)提了两件:

  1. 「一个微信多个家长号,主登录 seat_auth.py:510 不带 return_all → 退回手输」:批次五不做按微信选账号。登录框写法按「这个微信登过哪些号」定;选账号框只在「同一手机号的门店新号 + 家长新号、密码一样」时出。生产同一微信绑 ≥2 个家长号 = 0(9-24 seatops 实查)。扫码绑定要「多账号先选账号」请用 lookup_seat_username_by_wx(return_all=True),批次五不改它的语义
  2. 「谁把登录从 chat/page.tsx 拆出来」:批次五不拆,只在 chat/page.tsx 动两处小改:9.1 第 7 条(OAuth 回跳多存一个字段)与 9.3(所列功能入口开头各加一行拦截)。首页改版开工前以批次五合入 test 后的版本为基线再拆

十二、上线清单

  1. 730-#733 先上生产(迁移依赖 phase83_001)

  2. 批次五上线前生产不开品牌码(现 0 个)
  3. 要继续开家长号的机构先在后台设好前缀(老家长照常用,只是开新号会被提示拦下)
  4. 前后端同一批上线(红线 2)
  5. 上线后 SQL 核:新开号 phone_login_enabled=true、org_parent_seat_counter 有行、organizations.subseat_prefix 非空数

十三、本方案已知没验的

  1. 品牌码家长被踢下线后页面具体显示什么——没在真机走过,实施时真机验
  2. ~~exam/new 登录框算哪个 scene~~ → 9-24 自检已定 parent(6.3)
  3. ~~登录失败 error_type 怎么推导~~ → 9-24 自检已读 main.py:139,做法见 6.2 第 1 步
  4. 生产 12 个 / test 18 个带小写字母的门店号只数了数量、没看账号原文;实施后在 test 挑一个真登一次,证明「先原样精确查」没把它们弄坏
  5. ~~品牌码家长在「家长子席位」后台页会显示「待绑定」~~ 9-24 深夜撤回(我查漏了):该页列表已排除品牌码家长(parent_seat_service.py:103-108 显式 seat_source != "brand_code",API 层 source 只放 platform / store),品牌码家长只在品牌码页管理,不存在这个问题