2026-09-23 · 基线 origin/test
e6770bb7· 全部结论已 grep 实证(file:line 附在每条后面)
起因:长官 9-23 提出三个方向 —— ①账号加字母前缀 ②手机号也能登录 ③品牌码家长补发账号密码
三件事能做,但有 4 个反直觉的事实必须先纠正,否则方案会建在错的前提上。纠正完之后,三件事其实能共用同一套改造,不是三个独立工程。
批次三(MR #733)已经落了通用批量查重 + max_rounds=5 重试 + IntegrityError → 409,撞号有兜底。
加字母前缀的真实价值不是"修 bug",而是把两个号池在结构上变成不可能重叠 —— 兜底逻辑从"每次开号都要跑查重"变成"永远不会撞"。方向是对的,但要点名它是加固,不是救火。
web/app/h5/chat/page.tsx:861 分支:品牌码家长 isSeatWxMode=true,token 过期不弹密码框,而是渲染微信重授权页,点"确认"重走 OAuth 就能回来(activate_brand_code 全程幂等,按 unionid/openid 认人)。
但长官的担心有真实根据,而且比想的更严重:9-20 记录的线上 bug —— 拿不到 unionid → 409 硬失败。一旦微信侧授权断了,重授权这条唯一通道也断了,那才是真锁死。
→ 所以给家长一条不依赖微信的第二通道是对的,而且正好能兜住 unionid 那个 bug。两件事应该一起看。
app/services/brand_code_service.py:98 只取了 created[0]["id"],generate_seats 生成的明文密码在这一行之后就永久丢失;紧接着 :110 又把 must_change_password = False 写死,把"首登强制改密"这个唯一补救引导也关掉了。
→ 这批人物理上有 username、有 password_hash,但没有任何人知道密码,也永远不会被引导去设一个。要给他们补,必须同时动这两行 + 对存量做一次数据回填。
首登改密时收手机号(app/api/seat_auth.py:737)只校验格式 ^1\d{10}$,没有短信验证码。也就是说库里的手机号,是用户随便填的。
更麻烦的是唯一性口径:migrations/.../phase74_001:92-103 是三条 partial unique index(h5端 / 门店席位 / 家长席位 各自唯一,跨端可重复)→ 用手机号登录时,同一个号可能同时命中一个门店席位和一个家长席位(店员自己也是家长就会撞上)。
今天攻击一个席位要同时知道两样: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 等 |
新号格式 P0000012609(家长)/ S0134202609(门店),老号一律不动,两种格式长期并存。
字母本身就成为池标识 → 家长池和门店池结构上不可能重叠,批次三那套查重兜底从此只是保险丝而非主力。同时出问题时一眼能分辨是谁的号。
= 是大小写敏感的,p0000012609 ≠ P0000012609。用户打小写就登不进 —— 这是必踩的坑。/api/seat/login 这一个入口先 username.strip().upper() 再精确匹配。我们自己发的号全大写、老的纯数字 upper 之后不变 → 唯一索引照用,零性能代价,用户大小写随便打都能进。/api/auth/login(admin / 机构管理员 / 渠道端走那条)。那些账号很可能含小写字母,一 upper 就全员登不进 —— 这是会炸全平台的操作。sudo db-query test 查 users 返 0 行,但这是 RLS 假 0(已知坑),查不到 ≠ 不含字母。所以按"未知"处理,设计上硬隔离,不赌。func.upper(User.username) == ... 查询 —— 那会让索引失效走全表扫。T/P/S 与数字 0/1 无混淆风险;小写 l/o 会和 1/0 混,不选。seat-login-hints.ts:14「请输入提供给您的10位数字账号」会失真,必须改 + 同步改测试断言。seat_service.py:153/158 的 LIKE "____{yyyymm}" + u[:4].isdigit() + len(u)==10 三道假设全部要改,这是整个方案里唯一的高风险点(改错会导致门店取号恒从 1 开始、撞老号)。len(u)==10 and u[:4].isdigit() 天然把 11 位带字母的家长号排除在外,门店逻辑一行都不用改。代价是门店号仍是纯数字,跟手机号的视觉区分度没改善。B1 · 手机号当第二个账号名(立即可做,零外部依赖)
seat_auth.py:229-234 查用户从 username == X 改成 username == X OR phone == X,密码那把锁不变。
→ 家长接不住账号时,用自己手机号 + 密码照样进。
B2 · 手机号 + 短信验证码登录(硬阻断,等模板)
真正做到"连密码都不用记"。但 ALIYUN_SMS_TEMPLATE_CODE_VERIFY 模板没在阿里云后台注册过、env 没配(实证见上表)。
→ 需要长官去阿里云后台申请一个验证码短信模板,批下来之前这一步物理上做不了。代码侧(限频 60s / 日 10 条 / IP 小时 30 条)已经现成。
seat_forgot_password 的 candidates 机制(seat_auth.py:811-836),命中多个账号时返回候选让前端弹选。直接复用,不用新发明。isPhoneLikeUsername 是 9-05 生产事故(店员把手机号当账号输)的产物。删掉后那个场景会退化成 401 + 产告警。现有的「首登强制改密弹窗」(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),不改。这样跟门店席位完全一致,前后端零额外改动。
家长端和门店端共用同一份 SeatLoginSheet,账号没输进来之前根本不知道来的是谁 —— 靠登录框自己区分是做不到的,只能靠入口(从 /h5/chat 弹的是家长,从 /h5/store-refractive 弹的是门店)。
seat_type 唯一索引照样查得准。SeatLoginSheet 加一个 scene: "parent" | "store" prop,由调用方传,家长场景多显示手机号提示。改动可控但多一个分支要维护。⚠️ 无论选哪个:平板端那份是复制出来的第二份代码(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 上个会话查过"与序号无关",本案实施前需再复核一次 | 见备注 |
phase83_001 迁移(seat_seq_counter 表),该迁移目前只在 test 库、生产没有 —— 抢跑上生产直接炸,不是"代码打架"这么含糊。must_change_password)属于生产写操作,需长官单独批准。| # | 决策点 | 选项 | 我的建议 |
|---|---|---|---|
| D1 | 前缀字母 | ①家长 P / 门店 S ②两端都 T ③只家长加前缀,门店不动 | ③ 风险最低;若要视觉上彻底分开则选 ① |
| D2 | 大小写 | ①大写+登录时 normalize ②小写 ③区分大小写 | ① |
| D3 | 手机号登录开放范围 | ①家长+门店都开 ②只家长开 | ① |
| D4 | 登录方式 | ①手机号+密码(现在就能做) ②等短信模板做验证码登录 | 先 ①,②列入下一批 |
| D5 | 手机号命中多账号 | ①候选让用户选(有现成范式) ②提示改用账号 | ① |
| D6 | 登录框是否分端 | ①不区分,统一"账号或手机号" ②按入口传 scene | ① |
| D7 | 品牌码家长设密码触发时机 | ①点任意功能都拦(要新建共享 gate) ②只在首次发消息拦 | ② 先做,不够再扩 |
| D8 | 存量扫码家长是否回填 must_change_password=True |
是/否 | 是(否则这批人永远没密码) |
| D9 | 家长端 enable_password_login 红线开关 |
①全局打开 ②按机构开 ③不开 | ② 按机构开,可灰度 |
| D10 | 首登收手机号是否改成必填 | 必填/选填 | 选填→等验证码能力到位再改必填 |
aliyunsdkcore)在 test/生产环境是否真的装上了,本机沙箱验不了,要在真实环境确认。逐条复查原方案,共改 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 |
[前缀][6位序号][YYMM] 11 位;门店号维持 [4位序号][YYYYMM] 10 位纯数字LIKE "____{yyyymm}"(10 字符模式)+ len(u)==10 + seat_type 过滤三重排除,11 位带字母的家长号进不去(seat_service.py:153,158)f"{seq:06d}{period}" → f"T{seq:06d}{period}"(seat_service.py:239);查重是 in_() 精确匹配,无长度假设,不用动归口依据: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 | 品牌码后台页 | 加手机号列 + 加「重置密码」按钮;不加「换绑」 |
parent-seats/page.tsx:971),导出 xlsx 也带;只有品牌码页缺brand_code_activations.family_seat_user_id → users.id 外键可达;要改 _bulk_enrich_family_seats(admin_brand_code.py:183)+ ActivationRecord 模型 + 列表/导出两端点get_parent_seat_or_404 只认 role/seat_type,不看 seat_source → 品牌码席位直接能用,后端零改动(现有「停用/延期」已是同款复用实证)lookup_seat_username_by_wx 只加字段不改语义(被忘记密码共用,改语义会打坏候选列表)impl 顶部进度表还停在批次一:83.4/83.5 标 🔄、83.6-83.12 标 ⬜,但批次二三实际已合 test 验过。
→ 写批次五 impl 时逐条验收后再划勾,不直接标 ✅。