交付出件箱

← 返回出件箱

子席位账号与登录体系 v2 · 方案

2026-09-23 · 基线 origin/test e6770bb7 · 全部结论已 grep 实证(file:line 附在每条后面)
起因:长官 9-23 提出三个方向 —— ①账号加字母前缀 ②手机号也能登录 ③品牌码家长补发账号密码


一句话

三件事能做,但有 4 个反直觉的事实必须先纠正,否则方案会建在错的前提上。纠正完之后,三件事其实能共用同一套改造,不是三个独立工程。


零、先纠正 4 个事实(这几条直接改变方案形状)

事实 1 · 撞号已经修了,加前缀解决的是另一个问题

批次三(MR #733)已经落了通用批量查重 + max_rounds=5 重试 + IntegrityError → 409,撞号有兜底。
加字母前缀的真实价值不是"修 bug",而是把两个号池在结构上变成不可能重叠 —— 兜底逻辑从"每次开号都要跑查重"变成"永远不会撞"。方向是对的,但要点名它是加固,不是救火。

事实 2 · 品牌码家长现在不是"过期就进不去"

web/app/h5/chat/page.tsx:861 分支:品牌码家长 isSeatWxMode=true,token 过期不弹密码框,而是渲染微信重授权页,点"确认"重走 OAuth 就能回来(activate_brand_code 全程幂等,按 unionid/openid 认人)。

但长官的担心有真实根据,而且比想的更严重:9-20 记录的线上 bug —— 拿不到 unionid → 409 硬失败。一旦微信侧授权断了,重授权这条唯一通道也断了,那才是真锁死。
→ 所以给家长一条不依赖微信的第二通道是对的,而且正好能兜住 unionid 那个 bug。两件事应该一起看。

事实 3 · 存量扫码家长现在账号密码都等于没有

app/services/brand_code_service.py:98 只取了 created[0]["id"],generate_seats 生成的明文密码在这一行之后就永久丢失;紧接着 :110 又把 must_change_password = False 写死,把"首登强制改密"这个唯一补救引导也关掉了。

→ 这批人物理上有 username、有 password_hash,但没有任何人知道密码,也永远不会被引导去设一个。要给他们补,必须同时动这两行 + 对存量做一次数据回填。

事实 4 · 手机号现在不是可信数据,直接当登录凭证有坑

首登改密时收手机号(app/api/seat_auth.py:737)只校验格式 ^1\d{10}$,没有短信验证码。也就是说库里的手机号,是用户随便填的。
更麻烦的是唯一性口径:migrations/.../phase74_001:92-103 是三条 partial unique index(h5端 / 门店席位 / 家长席位 各自唯一,跨端可重复)→ 用手机号登录时,同一个号可能同时命中一个门店席位和一个家长席位(店员自己也是家长就会撞上)。

事实 5 · 手机号当登录名 = 安全等级实质下降(自检补 · 原方案完全漏了)

今天攻击一个席位要同时知道两样:10 位内部账号(不公开)+ 6 位数字密码。
手机号一旦能当登录名,前一样就变成公开信息,安全性只剩 6 位数字密码这一把锁。

缓解措施是有的,但要看清强度:auth_service.py:64-66 渐进冷却 —— 窗口内失败 15 次才触发,冷却 60s 起、封顶 300s(seat_login 与 admin 登录共用)。
→ 换算:约 4300 次/天,6 位密码空间 10^6,定向暴破期望约 115 天。不是一捅就破,但也谈不上稳。
→ 这不是否决项,是需要长官知情后拍板的取舍:要便利就得接受这个下降,或者把密码口径从"6 位数字"放宽(但那会动所有席位,范围大得多)。


一、现状硬事实(方案的地基 · 全部实证)

项 现状 证据
门店账号 4位序号+YYYYMM = 10 位纯数字(如 0134202609) seat_service.py:162
家长账号 6位序号+YYMM = 10 位纯数字(如 0000012609) seat_service.py:239
唯一解析点 全仓只有一处把账号当结构切片:int(u[:4]) + len(u)==10,且只服务门店取号 seat_service.py:158
后端校验 username 只有 max_length=64,无纯数字约束 auth.py:29 seat_auth.py:92
前端输入框 无 maxLength / inputMode / 正则,已设 autoCapitalize="none" seat-login-sheet.tsx:291-305
DB String(64) unique index nullable,无格式 CHECK models/user.py:33
登录查用户 username == X AND role == 'org_seat',零个端点支持手机号 seat_auth.py:229-234
密码口径 强制正好 6 位数字(长官定,非 D8 强密码) seat_auth.py:728
手机号唯一性 三端各自唯一、跨端可重复 phase74_001:92-103
短信验证码 代码齐全(限频/冷却都有),但模板 ALIYUN_SMS_TEMPLATE_CODE_VERIFY 未注册、env 未配 sms_service.py:98,108 + .env.example 无此项(对照组:PLAN_COMPLETE 在 :14 有)
登录 UI 家长端与门店端共用同一份 SeatLoginSheet;平板端是显式复制的第二份 seat-login-sheet.tsx / tablet/seat-login.tsx:4 红线注释
家长端密码门 硬关:enable_password_login=false → 永不开门(红线) chat/page.tsx:791
前端主动拦手机号 输入像手机号 → 提交前拦下,提示"账号不是手机号" seat-login-hints.ts:12-23(9-05 生产事故产物)
前端路由保护 没有集中式 hook,每个页面各写一份 gate store-refractive/page.tsx:94-105 等

二、方案 A · 账号加字母前缀

做法

新号格式 P0000012609(家长)/ S0134202609(门店),老号一律不动,两种格式长期并存。

为什么两端用不同字母(而不是都用 T)

字母本身就成为池标识 → 家长池和门店池结构上不可能重叠,批次三那套查重兜底从此只是保险丝而非主力。同时出问题时一眼能分辨是谁的号。

大小写:用大写 + 登录时统一 normalize

代价(诚实说)

  1. 家长要手输账号时多一次键盘切换 —— 但方案 B(手机号登录)正好抵消这个代价,两件事是配套的。
  2. 用户可见文案 seat-login-hints.ts:14「请输入提供给您的10位数字账号」会失真,必须改 + 同步改测试断言。
  3. 门店号如果也加前缀,seat_service.py:153/158 的 LIKE "____{yyyymm}" + u[:4].isdigit() + len(u)==10 三道假设全部要改,这是整个方案里唯一的高风险点(改错会导致门店取号恒从 1 开始、撞老号)。
    → 若想把风险压到最低:只给家长号加前缀,门店号一个字不动。因为 len(u)==10 and u[:4].isdigit() 天然把 11 位带字母的家长号排除在外,门店逻辑一行都不用改。代价是门店号仍是纯数字,跟手机号的视觉区分度没改善。

三、方案 B · 手机号也能登录

分两步走(必须分,因为第二步卡在外部依赖)

B1 · 手机号当第二个账号名(立即可做,零外部依赖)
seat_auth.py:229-234 查用户从 username == X 改成 username == X OR phone == X,密码那把锁不变。
→ 家长接不住账号时,用自己手机号 + 密码照样进。

B2 · 手机号 + 短信验证码登录(硬阻断,等模板)
真正做到"连密码都不用记"。但 ALIYUN_SMS_TEMPLATE_CODE_VERIFY 模板没在阿里云后台注册过、env 没配(实证见上表)。
→ 需要长官去阿里云后台申请一个验证码短信模板,批下来之前这一步物理上做不了。代码侧(限频 60s / 日 10 条 / IP 小时 30 条)已经现成。

必须同时解决的三个衍生问题

  1. 命中多条账号怎么办(事实 4):仓库里已有现成范式 —— seat_forgot_password 的 candidates 机制(seat_auth.py:811-836),命中多个账号时返回候选让前端弹选。直接复用,不用新发明。
  2. 前端拦截必须改造,不能简单删:isPhoneLikeUsername 是 9-05 生产事故(店员把手机号当账号输)的产物。删掉后那个场景会退化成 401 + 产告警。
    → 正确做法:把前端预判改成后端准确判断 —— 后端查不到就返回"该手机号未绑定任何账号,请用账号登录"。比现在更准。
  3. 手机号真实性:建议在"首登设密码收手机号"那一步补上验证码校验,否则脏手机号会继续积累(A 填了 B 的号,B 以后就绑不了)。但这一条同样依赖 B2 的短信模板。
    → 折中:B1 先上(不碰手机号采集),模板批下来后一并补校验 + 上 B2。

四、方案 C · 品牌码家长补发账号密码

长官的思路完全可行,而且能大面积复用现成件

现有的「首登强制改密弹窗」(seat-change-password-sheet.tsx)已经就是"阻塞式 + 同时收密码和手机号 + 无关闭按钮"。要做的只是:

步骤 改哪 说明
① 别再关引导 brand_code_service.py:110 must_change_password = False → 新扫码家长改回 True。⚠️ :88 注释写明这是有意设计("本席位永远走微信免密登录,不存在首登改密"),本案是推翻既有设计,不是修疏漏
①b 返回体要加字段 brand_code_service.py:175,241 + brand_code_public.py:31 🔴 自检发现:activate 返回体只有 access_token/refresh_token/nickname/avatar/seat_type/org_features/visible_features,既没有 username 也没有 must_change_password → 前端既不知道要弹窗、也拿不到账号可显示。两个字段都得补
①c 前端要存 username web/app/h5/chat/lib/auth.ts:606 🔴 自检发现:setSeatProfile 只存 seatType + visibleFeatures,根本没存 username → 要新增存储键
② 弹框多显一行 seat-change-password-sheet.tsx 加一行「您的账号:P0000012609(请记好)」(依赖 ①b+①c 先到位)
③ 打开家长端密码门 chat/page.tsx:791 红线 这条硬编码不打开,密码发了也没有入口可用
④ 存量回填 一次性 SQL 已扫码家长 must_change_password 回填 True,下次进来就被要求设密码

触发时机(长官说"点任何功能就让他填")

⚠️ 这里有工作量陷阱:前端没有集中式路由保护 hook,每个页面各写一份 gate(实证见上表)。
- 做成真正的"点任何功能都拦" → 要新建一个共享 gate,并逐页接进去(≈ 6-8 个页面),工作量翻倍。
- 只在进聊天页后首次发消息时拦 → 单点改造,覆盖绝大多数场景,工作量小一个量级。

🔴 自检新发现的风险:家长在两个机构都有子席位 → 手机号撞唯一索引

uq_users_phone_parent 是家长席位内全局唯一。一位妈妈在门店 A 扫过品牌码、在门店 B 也有子席位 → 两条 parent 记录 → 设密码时填同一个手机号,第二条必然 409。
而 seat_change_password 没有先查重,直接靠 IntegrityError 兜成 409(seat_auth.py:749-759),家长只会看到一句"手机号已被使用",完全不知道该怎么办。
→ 换门店 / 多孩家庭都会撞上,不是边缘场景。必须在方案里定:是放宽索引、还是把手机号改成选填、还是给一个能自解释的提示。

密码口径

沿用现有6 位数字(seat_auth.py:728),不改。这样跟门店席位完全一致,前后端零额外改动。


五、方案 D · 登录弹框两端怎么区分

关键约束:登录之前系统分不出来人

家长端和门店端共用同一份 SeatLoginSheet,账号没输进来之前根本不知道来的是谁 —— 靠登录框自己区分是做不到的,只能靠入口(从 /h5/chat 弹的是家长,从 /h5/store-refractive 弹的是门店)。

两个选项

⚠️ 无论选哪个:平板端那份是复制出来的第二份代码(tablet/seat-login.tsx:4 有红线注释"不改共享 SeatLoginSheet"),改了主件必须手动同步平板端,漏了就是平板端行为不一致。


六、影响面总表

模块 受影响 依据
门店取号逻辑 🔴 仅当门店号也加前缀才动,是全案最高风险点 seat_service.py:153/158
家长取号 🟠 拼装格式改一行 seat_service.py:239
登录接口 🟠 查用户语句 + 大小写 normalize seat_auth.py:229-234
前端登录框(手机+平板 2 份) 🟠 文案 + 拦截改造,两份都要改 seat-login-sheet.tsx / tablet/seat-login.tsx
品牌码建席位 🟠 2 行 + 存量回填 brand_code_service.py:98,110
家长端密码门红线 🟠 需长官拍板打开 chat/page.tsx:791
后端 pydantic 校验 🟢 无需改(max_length=64 够用) auth.py:29
数据库 schema 🟢 无需改(64 位够,无格式 CHECK) models/user.py:33
微信反查回填账号 🟢 不解析格式,零依赖 seat_service.py:721-767
xlsx 导出/开通表回写 🟢 走 inlineStr 文本,不丢前导 0 web/lib/xlsx.ts:40
isPhoneLikeUsername 误判 🟢 正则 ^1\d{10}$,带字母天然不命中 seat-login-hints.ts:21
结算算钱 / 报表统计 🟡 9-21 上个会话查过"与序号无关",本案实施前需再复核一次 见备注

七、外部依赖与前置条件(卡人的)

  1. 🔴 阿里云短信验证码模板未注册 —— 长官需在阿里云后台申请,否则 B2(验证码登录)和"手机号真实性校验"物理上做不了。
  2. 🔴 必须先把 Phase 83 四个 MR(#730-#733)发上生产,再动本案。准确理由(自检修正):本案改的取号代码依赖 phase83_001 迁移(seat_seq_counter 表),该迁移目前只在 test 库、生产没有 —— 抢跑上生产直接炸,不是"代码打架"这么含糊。
  3. 🟠 存量数据回填(扫码家长 must_change_password)属于生产写操作,需长官单独批准。

八、待长官拍板(D1-D10)

# 决策点 选项 我的建议
D1 前缀字母 ①家长 P / 门店 S ②两端都 T ③只家长加前缀,门店不动 ③ 风险最低;若要视觉上彻底分开则选 ①
D2 大小写 ①大写+登录时 normalize ②小写 ③区分大小写 ①
D3 手机号登录开放范围 ①家长+门店都开 ②只家长开 ①
D4 登录方式 ①手机号+密码(现在就能做) ②等短信模板做验证码登录 先 ①,②列入下一批
D5 手机号命中多账号 ①候选让用户选(有现成范式) ②提示改用账号 ①
D6 登录框是否分端 ①不区分,统一"账号或手机号" ②按入口传 scene ①
D7 品牌码家长设密码触发时机 ①点任意功能都拦(要新建共享 gate) ②只在首次发消息拦 ② 先做,不够再扩
D8 存量扫码家长是否回填 must_change_password=True 是/否 是(否则这批人永远没密码)
D9 家长端 enable_password_login 红线开关 ①全局打开 ②按机构开 ③不开 ② 按机构开,可灰度
D10 首登收手机号是否改成必填 必填/选填 选填→等验证码能力到位再改必填

九、建议实施批次


十、我没验的 / 说不准的

  1. 结算与报表对账号格式的依赖,是引用 9-21 上个会话的调查结论,本次没有重新 grep,实施前要再核一遍。
  2. admin 后台席位列表按 username 模糊搜索的行为,本次未查,加前缀后搜索体验是否变化待确认。
  3. 阿里云 SDK(aliyunsdkcore)在 test/生产环境是否真的装上了,本机沙箱验不了,要在真实环境确认。

十一、自检修订记录(2026-09-23 · 长官要求"认真自检"后)

逐条复查原方案,共改 5 处,其中 2 处是实质错误、2 处是漏掉的风险、1 处是理由不准。

# 性质 原方案怎么写的 自检查到什么 改成
1 🔴 实质漏项 全篇没提安全性 手机号当登录名 = 把"不公开的 10 位账号"这层换成公开信息,只剩 6 位数字密码;冷却阈值实为 15 次 / 封顶 300s(auth_service.py:64-66),定向暴破期望约 115 天 新增「事实 5」,列为需长官知情拍板的取舍
2 🔴 实质低估 方案 C 写"改 2 行 + 弹框加一行" activate 返回体没有 username、没有 must_change_password(brand_code_service.py:175);前端 setSeatProfile 只存 seatType/visibleFeatures(auth.ts:606) 补 ①b ①c 两步,改动量从 2 行升到"后端返回体 + 前端存储 + 弹框"三层
3 🔴 危险表述 写"后端登录入口做 upper normalize" 表述含糊,顺手加到 /api/auth/login 会让 admin/机构/渠道账号全员登不进 改成硬隔离:只 /api/seat/login;并如实写明 admin 账号形态因 RLS 假 0 查不到,按未知处理不赌
4 🟠 漏风险 方案 C 没提手机号冲突 uq_users_phone_parent 家长席位内全局唯一 → 跨机构两个子席位的同一家长必撞 409,且后端无先查重 新增专节,列为必须拍板项
5 🟡 理由不准 发版顺序写"代码会打架" 真实原因是依赖 phase83_001 迁移,生产库没有该表 改成迁移依赖

自检中被推翻的自己的判断(记账):
- 我原以为"手机号登录只是多个入口、安全性不变",错。账号本身承担了第二因子的作用,换成公开手机号就是降级。
- 我原以为"品牌码家长复用现有改密弹窗 = 改 2 行",错。漏查了返回体和前端存储两层。

自检中确认成立的(没有被推翻,可以放心用):
- 改密接口 SeatChangePasswordRequest 只要 new_password + contact_phone,不要老密码(seat_auth.py:113-119)→ 品牌码家长不知道旧密码也能设新密码,方案 C 的复用路径成立。
- 家长取号查重用 username.in_(candidates) 精确匹配、无长度假设(seat_service.py:239-245)→ 加前缀只改拼装一行。
- 门店取号 LIKE "____{yyyymm}" 是 10 字符模式 + len(u)==10 + seat_type 过滤三重排除 → 只给家长加前缀时,门店逻辑确实一行都不用改。
- 平板端账号输入框同样无 maxLength/inputMode 约束(tablet/seat-login.tsx:264)→ 加字母前缀前端零阻力这条对两端都成立。


十二、决策台账(长官逐条拍板 · 滚动更新)

# 决策点 长官拍板 时间
D1 前缀范围 ✅ 只在家长端账号加前缀,门店号一个字不动(原方案选项③) 2026-09-23

D1 落地后的确定影响(已实证,不再是推测)


十三、批次五 · 最终范围(2026-09-23 长官逐条拍板 · 归口 Phase 83)

归口依据:T 前缀要改的 seat_service.py:239 取号拼装,正是批次三新写的函数里的行。
硬依赖:批次五的迁移接在 phase83_001 之后;生产现停在 phase48a_002 → #730-#733 必须先上生产。

已拍板清单

# 决策 定案
1 家长账号前缀 大写 T + 6位序号 + YYMM(11位);门店号不动(10位纯数字)
2 登录大小写 登录时统一转大写,只动 /api/seat/login,绝不碰 /api/auth/login(后台账号可能含小写字母,一 upper 全员登不进)
3 新老分界 席位加开关:老席位关(只能账号+密码)/ 新席位开(初始化后用手机号+密码)
4 新席位登录框 只显示手机号,不给"用账号登录"入口(长官明确:不让)
5 老席位 行为一个字不变,零感知
6 初始化收手机号 不允许跳过,前端+后端都要锁(现状后端是可选的)
7 初始化弹窗 新增一行只读「您的账号 Txxxxxxxxxx 请记好」
8 一号多账号 先验密码 → 再弹候选;候选项必须标身份(门店员工/家长),因为两个账号可能在同一家店
9 平板端 只查门店席位,不需要候选框
10 微信自动带入 新席位带手机号 / 老席位带账号(同一个开关驱动)
11 品牌码家长 点任意功能弹同一个初始化弹窗;微信失效时可用手机号+密码兜底
12 短信验证码 先不用(模板未注册)
13 品牌码后台页 加手机号列 + 加「重置密码」按钮;不加「换绑」

决策 13 的依据(9-23 物理查实)

实施必须守住的 5 条红线(自检挖出)

  1. 老席位开关默认关,不动他们任何行为
  2. 前端放行手机号 + 后端支持手机号登录必须同一批上线(分批会让手机号明文进审计表和告警日志)
  3. lookup_seat_username_by_wx 只加字段不改语义(被忘记密码共用,改语义会打坏候选列表)
  4. 手机端与平板端是两份复制代码,必须同步改,漏一份就两端行为不一致
  5. 手机号查无此人不推告警、不计冷却,防 9-05 告警刷屏复发

文档债(待清)

impl 顶部进度表还停在批次一:83.4/83.5 标 🔄、83.6-83.12 标 ⬜,但批次二三实际已合 test 验过。
→ 写批次五 impl 时逐条验收后再划勾,不直接标 ✅。