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)down_revision = "phase83_001"(migrations/versions/phase83_001_seat_seq_counter.py:24 revision 原文;grep -rn "down_revision.*phase83_001" migrations/versions/ 0 命中 → 它是叶子)34ec94466f0c / phase79_001 / phase83_001(alembic heads 实测)。批次五只接 phase83_001 这一支,不做 merge heads(历史债,不在本批范围)app/models/user.py:46 写着 phone = Column(String(20), unique=True, ...),但生产 / test 库实际是 phase74_001 建的三个带条件唯一索引(uq_users_phone_h5 / _store / _parent,9-24 生产 pg_indexes 实查)。迁移必须手写,禁止 autogenerate,否则会生成错误的全表唯一约束phase48a_002 → #730-#733 必须先上生产(硬依赖不变)| 变更 | 定义 | 老数据 |
|---|---|---|
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:老号零感知) |
organizations 现有列清单(app/models/organization.py:11-59)无任何前缀字段;全仓 grep -rn "subseat_prefix\|seat_prefix\|org_prefix" app/ migrations/ 0 命中 → 均为新增git ls-files 均为 0:migrations/versions/phase83_002_subseat_prefix_and_phone_login.py、app/models/org_parent_seat_counter.pyseat_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)generate_seats :261-359,家长 / 门店分叉 :305-317(seat_type == _PARENT_SEAT_KIND 才走 _alloc_parent_usernames)_alloc_seq_batch :186-210(pg_insert(SeatSeqCounter)...on_conflict_do_update(...).returning(last_seq) 原子批量)_alloc_parent_usernames :213-255,拼装 :239 f"{seq:06d}{period}",批量 username.in_() 查重、最多 5 轮:319-334:must_change_password=True、seat_type、seat_source(不传则走模型默认 platform);不设 phoneOrganization.subseat_prefix:HTTPException(409, detail="该机构还没设置子席位账号前缀,请先到『机构管理 → 更多 → 子席位前缀』里设置")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_maxlast_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:237-238):后面建号失败时这一批号段作废不回收(:347-348 注释),下一批接着往后排,号码出现空档属正常(极少,只在建号撞车时发生)f"{prefix}{seq:06d}"(例 BF100001)。保留批量 username.in_() 查重 + 轮数上限(username 全表唯一 user.py:33,手工建的后台账号理论上可能撞)phone_login_enabled=True(决策 3:新席位开)_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 补)「开过号」= org_parent_seat_counter 有该机构的行且 last_seq > 100000。锁定后前缀不许改(决策 1)。
app/api/admin_org_subseat_prefix.py)app/api/admin_brand_code.py 的写法:router = APIRouter(prefix="/api/admin/orgs", ...)(admin_brand_code.py:41)、require_role("admin")app/main.py:49 import 列表 + include_router(照 :233 品牌码写法)| 方法 | 路径 | 行为 |
|---|---|---|
| GET | /api/admin/orgs/{org_id}/subseat-prefix |
返回 {subseat_prefix, locked} |
| PUT | /api/admin/orgs/{org_id}/subseat-prefix |
body {subseat_prefix};strip().upper() 后校验 |
^[A-Z]{1,6}$ → 422 "只能填 1-6 个英文字母"409 "已开过子席位,前缀不能再改"IntegrityError 兜底) → 409 "前缀 BF 已被其他机构使用,请换一个"GET /api/v1/admin/orgs:app/api/api_v1.py list_orgs(:288-289),返回模型 OrgItem(:225-261)加 subseat_prefix: Optional[str]GET /api/h5/store-card(app/api/h5_store_card.py:229)加 subseat_prefix_set: bool(门店端只需要知道有没有,不给前缀值)| 入口 | 现状 | 改 |
|---|---|---|
后台开子席位 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)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 自检已读代码)
请求体加两个可选字段:scene: Literal["chat","store","tablet","parent"] = "chat"、chosen_username: Optional[str] = None
^1\d{10}$)→ 手机号分支(9-24 深夜实查:生产 173 个 / test 303 个席位里账号长得像手机号的 0 个,这个分支不会误伤任何现有账号):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)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)409 {code:"multiple_accounts", candidates:[{username, seat_type}]}(结构照抄找回密码 seat-forgot-password-sheet.tsx:82-86 消费的那个),前端让选后带 chosen_username 重提交,后端只在这两个候选里取seat.username,不用手机号:冷却按 LoginAttempt.username 数次数(auth_service.py:139-150),用账号记才能和同一个号用账号输错的次数算在一起;手机号只进审计 detail 且打码(红线 2)strip() 后先原样精确查(和现在 :231 一样);查不到、且输入里有小写字母,再按 upper() 查一次(决策 2:只改这个接口,/api/auth/login 一字不动)。⚠️ 不能整体转大写再查:9-24 实查生产 173 个席位里有 12 个门店号的账号带小写字母(test 303 个里 18 个;家长号 0 个),整体转大写这 12 个人当场登不进(全表账号只差大小写的撞车:生产 / test 均 0 组 → 转大写兜底不会串号)。前端也不许替用户转大写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,不收手机号)→ 没手机号 → 手机号查不到、账号又被拒 → 彻底登不进。带上这条,这种号继续用账号登store / tablet 而查到家长号、或 scene 为 parent 而查到门店号 → 401(决策 14:门店专用页只找门店号,家长专用页只找家长号)seat_auth.py:400 note_login_success(username))改成按输入值、seat.username、seat.phone(有才撤)各撤一次:现在只按输入值撤,改成手机号登录后,之前用账号输错挂起的告警撤不掉,180 秒后误报(alert_notifier.py:84-98 按 key 删)phone_login_enabled: bool(前端初始化弹窗据此决定写法,见 9.2)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,不是挂载点)
/api/seat/wx-identity(seat_auth.py:463-537)现返回 wx_identity_token / nickname / avatar / seat_username / h5_user_*;seat_username 来自 lookup_seat_username_by_wx(unionid, openid, db)(:510,不传 return_all 即默认 False),命中多条 / 停用 / 无身份时为 None(seat_service.py:736-741)bound_seats: [{seat_type, login_by: "account"|"phone", fill: <账号或手机号>}],在 wx-identity 端点里另写一条查询算出来:条件照抄 lookup_seat_username_by_wx(role='org_seat'、is_active、seat_bound_unionid / seat_bound_openid 任一命中),多取 seat_type / phone_login_enabled / must_change_password / phone;不改 lookup_seat_username_by_wx 本身——它的 return_all=True 被找回密码候选列表(seat_auth.py:815)共用,改返回结构就打坏候选(红线 3)。seat_type 为 NULL 按门店(照 seat_service.py:154 的 or_(User.seat_type.is_(None), User.seat_type == "store") 惯例;9-24 深夜生产 / test 实查 NULL 均 0 个)。login_by = phone 当且仅当 phone_login_enabled and not must_change_password and phoneseat_username 保持原语义不动(被找回密码入口 seat-login-sheet.tsx:402 与自动填账号共用)seat_auth.py:789-869){"username": seat.username}(:870),前端回填账号(seat-login-sheet.tsx:415-430 setUsername(u);平板 :386-401)login_phone:该席位 login_by = phone 时给手机号,否则 null;前端有 login_phone 就回填手机号(补充 1:不然新号回填账号会被拒)must_change_password 置 false(:851),手机号不变 → 新号找回后照常手机号登录seat_auth.py:706-773)contact_phone 可选(:733-738);请求体没有区分首登 / 设置页的字段seat.must_change_password 为真(第一次设密码、刚被重置、品牌码家长首次设置)时 contact_phone 必填,缺 → 422 "请填写手机号"(决策 6,后端锁);设置页改密(must_change_password 为假)照旧可不传。顺序:「必填」判断放在查出 seat(:741-745)之后单独判;:731-738 现有格式校验原位不动(只在传了时校验格式)409 "该手机号已被其他账号使用"(:756-759),弹窗原样显示后端 detail(seat-change-password-sheet.tsx:91)——长官 9-24 定:提示文案够用、不改(手机号栏标题 9-24 晚另改为统一「手机号」,见 9.2)| 事实 | 出处 |
|---|---|
扫码建号走 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 |
改:
brand_code_service.py:110 那行 → 新扫码家长保持 must_change_password=True(推翻原「永远免密」设计,决策 11);phone_login_enabled=True 由第三节统一给must_change_password(brand_code_public.py:31 响应模型同步加)ActivationRecord(admin_brand_code.py:128-142)加 family_seat_phone;_bulk_enrich_family_seats(:183-239)顺带取 users.phoneGET .../brand-code/activations(:423-436)与导出 GET .../brand-code/export(:670-681)都带手机号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 → 后端零改动_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 空)+ 一个结算孤儿门店号,跑清理,前者还在、后者被删| 位置 | 手机端 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,新号在平板上退回「席位账号」写法
chat/components/seat-change-password-sheet.tsx):153;phoneLabel :37(isParent ? "家长手机号" : "门店联系电话",isParent 来自 :36 getSeatType());底部 :277-284「设置后,管理员给您的初始密码立即失效,以后请用您自己设的这个密码登录」;props :22-32(onSuccess / collectPhone / onClose);弹窗现在就不显示账号(全文件只有 :163「为保护账号安全…」一处提到"账号"),决策 7「不显示账号」无需改动:37 phoneLabel 三元式,家长版 / 门店版、新号 / 老号一律显示「手机号」;:36 isParent 随之无用删除;:17 import 里的 getSeatType 本文件别处 0 处引用,一并删(孤儿清理)。老号首登弹窗这一栏也跟着改名。测试 web/app/h5/chat/components/__tests__/seat-change-password-sheet.spec.tsx:93-95 原断言「门店联系电话」改为「手机号」,并加家长身份下也显示「手机号」的用例phoneLogin?: boolean(默认 false = 老号,除手机号栏标题外文案一字不变)、variant?: "default" | "brand_code"phoneLogin 为真:手机号标题旁加「用于登录」小标签;底部改「设置后,管理员给您的初始密码立即失效,以后请用手机号 + 这个密码登录」(第 4 / 11 块)variant="brand_code":底部只留「以后请用手机号 + 这个密码登录」,标题不变(第 7 块)phoneLogin={data.phone_login_enabled};品牌码拦截传 phoneLogin variant="brand_code"h5/settings/components/ParentAccountCard.tsx:272 写死 collectPhone={false}。品牌码家长还没设密码(9.3 的标志为真)时从设置页点「修改密码」→ 按 7.3 后端要手机号 → 422「请填写手机号」,弹窗里却没有手机号栏,卡死。改:标志为真时传 collectPhone phoneLogin variant="brand_code",设完清标志;标志为假照旧 collectPhone={false}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。
| 位置 | 现状 | 改(方案图块号) |
|---|---|---|
机构列表「更多」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 条已核)。
admin/orgs/[id]/brand-code/page.tsx):590-600 在「用户」后加「手机号」列(空值「—」,样式照 parent-seats/page.tsx:971「家长手机」列):634-652 在「停用 · 延期」后加「重置密码」(样式照 parent-seats/page.tsx:1035;family_seat_user_id == null 时与现有两个按钮一样置灰)parent-seats/page.tsx:1110 确认文案与 :334 卡片(账号 + 新密码;长官 9-24 定:卡片照旧给账号)[id]/brand-code/page.tsx:374 现在只弹「操作失败,请稍后重试」,改成显示后端原因(detail),这样没前缀被第五节 409 拦下时能看到「请先设置前缀」。生产现有品牌码 0 个、上线后开品牌码必须先设前缀且前缀不能清空,只有 test 旧数据会碰到;本页不加任何预先提示(长官 9-24 定)seat-login-sheet.tsx:198、平板 tablet/components/seat-login.tsx:172 setRememberedSeatCredentials(username.trim(), password));下次打开本机记住的优先于微信带入(手机端 :90-108,注释「本机记住的优先」;平板 :75 起同款);初始化弹窗设完密码只更新记住的密码、不动账号(seat-change-password-sheet.tsx:95-99,注释「用户名不变故不动」)resolveLoginFailureHint 清掉记住的内容——每个新号下次需要重新登录时(退出登录、登录过期等)第一次都要报错一次phoneLogin 为真且设密码成功时,记住的账号一并改成刚填的手机号(键 seat_remember_username,与 :99 写密码同处);② 两份登录框挂载时,记住的账号与本框写法不符(「手机号」写法下不像手机号 / 「席位账号」写法下像手机号)就不用它,改走 bound_seats 自动填(红线 4:两份同步);③ 品牌码家长走 9.3 设完密码同 ①| 类 | 文件 | 内容 |
|---|---|---|
| 后端新增 | 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 行)提了两件:
seat_auth.py:510 不带 return_all → 退回手输」:批次五不做按微信选账号。登录框写法按「这个微信登过哪些号」定;选账号框只在「同一手机号的门店新号 + 家长新号、密码一样」时出。生产同一微信绑 ≥2 个家长号 = 0(9-24 seatops 实查)。扫码绑定要「多账号先选账号」请用 lookup_seat_username_by_wx(return_all=True),批次五不改它的语义chat/page.tsx 拆出来」:批次五不拆,只在 chat/page.tsx 动两处小改:9.1 第 7 条(OAuth 回跳多存一个字段)与 9.3(所列功能入口开头各加一行拦截)。首页改版开工前以批次五合入 test 后的版本为基线再拆phone_login_enabled=true、org_parent_seat_counter 有行、organizations.subseat_prefix 非空数parent(6.3)main.py:139,做法见 6.2 第 1 步parent_seat_service.py:103-108 显式 seat_source != "brand_code",API 层 source 只放 platform / store),品牌码家长只在品牌码页管理,不存在这个问题