2026-08-24 · dev2 · 对
origin/test(FETCH_HEAD)· 每个 0 命中都带对照组
第一轮台账:c-end-plan-selfaudit-v2-20260824.md(20 维 · F1~F11)本轮换维度打:第一轮查的是"能不能做",本轮专查第一轮没碰过的越权面
和长官新决定引出的新问题(手机号保持唯一 / 重置密码按钮放门店端)。结论:3 个新问题(含 1 个把上轮乐观判断打回来的)+ 2 个好消息 + 1 条设计缺口。
证据(仓库里已有人真打验过并写进注释)
app/api/h5_store_card.py:13-18
已 e2e 真打证实:席位 token 打 `/api/h5/user/profile` 返 403「仅 H5 用户可访问」
同一 token 打 `/api/chat/sessions` 返 200 → 排除 token 无效/端点损坏 → 设置页对席位不可用。
否决方案 A(放宽 _resolve_current_user):那是 Phase 29B 家长资料域守门,
放宽 = 席位混入家长链路
问题
我们的家长子席位在技术上就是 role='org_seat' 的席位。
⇒ 家长端设置页要做的「我的账号 / 修改密码」,不能直接复用 h5_user 那套资料接口,会被这道守门 403。
而且注释里写得很清楚:放宽这道守门是被否决过的方案(会让门店席位混进家长资料域)。
修法:家长端「我的账号」走席位侧接口(读 users 自己那行 + seat_auth 的改密端点),
不碰 /api/h5/user/profile。⇒ D 批要多一个小接口,不是纯前端活。
证据
app/api/admin_seats.py:6 4 endpoint(全 Depends(require_role("admin")) · org 隔离)
app/api/admin_seats.py:120 admin: dict = Depends(require_role("admin")),
app/api/admin_seats.py:146 admin: dict = Depends(require_role("admin")),
门店侧现有接口(h5_store_card.py,9 个端点)全是名片相关,没有任何管席位的口子。
问题
第一轮 F5 我说「C 批被高估了,seat_service 七件套现成」—— 只说对了一半:
- ✅ 业务层(seat_service.py)确实现成,七个函数都能复用
- 🔴 接口层全部是 require_role("admin"),门店席位调不了
⇒ B 批要在门店侧新建一套席位向 HTTP 接口(开通 / 列表 / 重置密码 / 停用),
每个都要自己写鉴权(require_org_seat + 只能管本机构 + 只能管家长子席位、不能碰正式席位)。
这条尤其要紧:如果新接口的权限没写死"只能管 parent",
门店席位就能通过它重置本机构其他正式席位的密码 —— 那是横向越权,比家长越权严重得多。
修正后的判断:B 批工作量上调,且是本期权限风险最高的一批。
证据
app/api/four_l_report.py:48 router = APIRouter(prefix="/api/h5", tags=["4l-report"])
:269 @router.post("/4l-report")
:425 @router.get("/4l-report/{task_id}/queue") ← 有 task_id + 排队查询
:446 @router.get("/4l-reports")
:524 @router.get("/4l-report/{report_id}/summary")
task_id + /queue 说明秒读是异步任务:POST 只是把任务扔进队列,
真正解析成功与否,在 POST 的响应里根本看不到。
⇒ 方案写的「解析成功才扣,失败回滚」没法在入口处实现。
必须在 tech 阶段专门设计扣减落点(任务完成回调处扣 / 预扣+失败补偿),
不能在 impl 里想当然写一句"成功才扣"就派单。
第一轮我担心的矛盾是:G 批要在秒读入口扣配额,但 four_l_report.py 在禁改清单里。
本轮查清楚了,矛盾不存在:
app/main.py:119 app.include_router(four_l_report.router, tags=["Phase 27a 4L 方案"])
⇒ 守门挂在 注册路由这一行(dependencies=[Depends(...)]),
four_l_report.py 一个字都不用改。守门内部先判账号类型,
门店席位直接放行(fail-open),对门店端零影响。
并且身份拿得到 —— 这是本轮最大的翻转:
app/api/h5_chat.py:58 async def verify_api_key(...) ← 名字骗人
:75 # Phase 46.7.5 glue: 席位密码登录 Bearer JWT(role='org_seat')进 H5 聊天链路
:116 return org, f"seat_{seat.id}" ← 账号身份就在这
verify_api_key 这个名字看着像只认机构 API Key,实际上认席位 Bearer,
并且返回 seat_<席位id>。⇒ 按账号记配额技术上完全可行,不用改鉴权。
⚠️ 我第一次看到 Depends(verify_api_key) 时判断成"只认机构、配额落不了地" —— 判错了,
是读完实现才翻转的。记一笔:看见函数名就下结论 = 又一次差点凭印象说话。
app/auth/middleware.py:274 "external_user_id": f"seat_{seat.id}",
app/api/chat_history.py:33 ChatSession.user_id == user["external_user_id"],
app/api/chat_history.py:97 user_id/org_id/doctor_id 仍接收但**后端忽略**(防 BFLA 越权)
每个席位的会话按 seat_<自己的id> 归属,查询做 owner verify,
且前端传的 id 后端一律忽略(已有防越权设计)。
⇒ 家长天然看不到门店的会话,方案里「会话历史原样复用」这条成立,零改动。
app/models/refractive_record.py:38
org_id = Column(Integer, ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False, ← 非空
comment="机构隔离键(服务端由席位推导)")
org_id NOT NULL,不能让家长档案"不挂机构"来躲开门店搜索。
⇒ 第一轮 F 说的「加归属标记 + 搜索过滤」是唯一可行路径,这条确认没有更省的走法。
app/models/h5_chat.py:78 class H5QuotaUsage
org_id / message_count / image_count / date(YYYY-MM-DD)
只有机构 + 按天 + 消息数/图片数,没有账号维度、没有秒读次数、没有 365 天周期。
⇒ 「6 次 / 账号 / 开通起 365 天」复用不了,新建模型的判断成立。
| # | 维度 | 结果 | 对照组 |
|---|---|---|---|
| N1 | 会话历史归属 | 按 user_id owner verify ⇒ 天然隔离 ✅ |
该文件 8 个端点 ✅ |
| N2 | 席位标识构造 | external_user_id = seat_<id>(middleware:274) |
middleware org_id = 11 ✅ |
| N3 | 秒读鉴权 | 名为 api_key 实认席位 Bearer,返 seat_<id> ⇒ 配额可行 🔄翻转 |
— |
| N4 | 席位访问家长资料域 | 403 被挡(仓库注释已 e2e 验证)🔴 | 同 token 打 chat/sessions 返 200 ✅ |
| N5 | 后台席位接口鉴权 | 全 require_role("admin") ⇒ 门店端要新建 🔴 |
— |
| N6 | 门店侧现有接口面 | h5_store_card 9 端点全名片,无管席位口子 |
端点数 9 ✅ |
| N7 | 档案机构隔离键 | org_id NOT NULL ⇒ 只能加列过滤 |
— |
| N8 | 微信绑定复用 | seat_bound_unionid(user.py:86)现成 ✅ |
— |
| N9 | migration 链 | 71 个版本 · 1 个根(down_revision=None)· 链完整 | — |
| N10 | 现有配额模型 | 机构+按天+消息/图片 ⇒ 复用不了 ✅印证 | — |
| N11 | 秒读路由注册 | main.py:119 可外挂守门 ⇒ 禁改文件零改 ✅ |
include_router 共 54 处 ✅ |
| N12 | 前端凭据方式 | auth.ts:2 X-API-Key fallback 已删,走 Bearer |
web/app/h5 中 Authorization = 9 处 ✅ |
/api/h5/user/profile(G1)main.py 注册处,four_l_report.py 保持零改(G4)| # | 事项 | 长官决定(8-24 终版) | 对实施的影响 |
|---|---|---|---|
| D1 | 手机号唯一性 | 🔄 改为「各端唯一」:门店端一个 + 家长端一个(同一手机号最多两个账号,每端各一)。当天早些时候定的"只绑一次"被此终版覆盖 | A 批动 users.phone 全局唯一约束 → 按端部分唯一;3 处按手机号找人的查询加端过滤(h5_user_auth.py:233 / miniapp_phone.py:91 / seat_service.py:408);中风险 · 发 test 后门店登录全量回归 |
| D2 | 门店端放不放"重置密码" | 不放,门店需要重置就联系平台,平台在后台点 | B 批只剩「开通」一件事;G2 标的"本期最高权限风险"消除 |
| D3 | 初始密码 7 天要不要放宽 | 不放宽,维持 7 天 | 零改动 |
| D4 | 登不上的提示文案 | 不改,继续用「初始密码已过期,请联系管理员重新开通」,通用一套、不给家长单开 | 零改动;不做按账号类型分支 |
| D5 | 微信绑定 | 不限制(维持现状,不加任何拦截);自助重置遇"绑多个"改为弹框让本人选重置哪个账号;换设备凭微信回填账号名同样支持列表选(同一查询改法,两处共用) | lookup_seat_username_by_wx 从"多条返 None"改为返列表;忘记密码弹层 + 登录回填两处前端加选择 UI |
| D6 | 实施顺序 | 先做 C 端手机端,平板端最后 | A → D → E → F → G → B → C → H |
| D7 | 重置密码的叫法 + 确认弹窗 | 认可(叫「重置密码」· 配确认弹窗) | 仅后台侧,门店端不涉及 |
D1+D5 组合的净效果:店员可用同一手机号 + 同一微信再开家长账号;
两个账号都能微信自助重置(弹框选);原"绑多个即拒绝自助重置"的死角消除。
代价:A 批加数据库约束改造(中风险),D5 加一个查询改造 + 两处弹框。
被检问题:Phase 74 的改动(手机号各端唯一 / 微信不限制+弹框 / seat_type 新列)
对「用户自主注册」功能(enable_user_registration· 已做未启用)有没有影响。
role='h5_user'(h5_user_service.py:65/110),也写 users.phoneh5_user_service.py:64 建号带 phone;h5_user_auth.py:240 绑手机号写 phone)h5_user_auth.py:233)没有 role 过滤 + scalar_one_or_none():python
select(User).where(User.phone == req.phone, User.id != current_user.id)seat_type 分——h5_user 行的 seat_type 会落 server_default,'store' 就跟门店席位共享唯一空间照样撞 ⇒ 约束必须带 role 维度(tech 定稿)internal_settlement.py:321"二选一")和签约模板写死;admin/orgs/page.tsx:228/229),能同时打开ensureRegistered(chat/page.tsx:1485)只看机构开关、不看是不是席位,| # | 维度 | 结果 |
|---|---|---|
| 1 | 开关消费面 | 10 文件 46 行,全为"机构开关下发+首页拦截",无席位专属逻辑 |
| 2 | 注册账号 role | h5_user,与席位 org_seat 不同 role,互不进对方查询 |
| 3 | 微信列隔离 | 注册用 wechat_unionid(独立列)· 席位绑定用 seat_bound_unionid ⇒ 微信弹框方案完全不碰注册 |
| 4 | lookup_seat_username_by_wx |
硬过滤 role='org_seat'(seat_service.py:565)⇒ h5_user 永不进弹框候选 |
| 5 | must_register_phone |
只在 /api/h5/oauth/exchange 返回体(page.tsx:963),席位/家长不走该端点 |
| 6 | RegisterSheet 触发 | 仅 ensureRegistered + 用户卡点击两处,且都被机构开关(默认关)守住 |
| 7 | seat 登录下发 | seat_auth.py:387/588 只是把开关值透传给前端,不参与鉴权 |
| 8 | 品牌区 gate | store-branding-gate.ts:21 注册开关只影响散客首页品牌区,席位路径无关 |
| 9 | 签约模板 | internal_settlement.py:321 写死 user_registration=False ⇒ 签约门店(家长的宿主机构)不会开注册 |
| 10 | seat_type 对 h5_user 行 | 新列 nullable/default 不进任何注册逻辑(全仓注册链路 0 处读 seat_type——本 Phase 新词,天然 0) |
User.phone(seat_auth.py:616/648)⇒ 家长首登自动完成绑手机号,不用新做seat-change-password-sheet.tssx:219 + 后端 docstring)role='org_seat' 判断 —— 家长上线后语义全变,A 批必须逐个审api: admin_activity / api_v1 / h5_chat / internal_settlement / refractive_records / seat_auth
auth: jwt_local / middleware / zhaoyang_permissions
models: activity / settlement / user
services: activity_log_service / org_insight_service / seat_service
tasks: activity_rollup_task
原语义「org_seat=门店席位」→ 新语义「门店+家长」。逐文件定该含家长还是排除:
含(登录/聊天/改密/refresh)· 排(席位计数/洞察/结算/活动汇总/list_seats)。此清单进 A 批 impl 必做项。
建新的按端唯一索引时,存量数据若已有重复 phone,migration 直接失败。
云机查不了生产库(工具指到空库,已实测)⇒ 🔴 发 test/生产前必须先跑存量重复检查 SQL,有重复先治数据再上约束。
_gen_unique_username:全局按月连续序号 0001202607(长官 7-07 拍板),无端区分。
家长也吃这个序号空间 ⇒ 门店月度序号会跳号(0003 被家长占了),且账号名看不出是家长。
默认共用(零改动);若长官想区分可加前缀(生成器加分支,不动门店路径)。tech 留决策点。
generate_seats 加参数的红线一句结算侧是位置参数调用(internal_settlement.py:566/659)⇒ 新加 seat_type 参数
必须放签名最末 + 默认 'store',插中间全错位。tech 写死。
| # | 维度 | 结果 |
|---|---|---|
| V3 | JWT payload | 只有 role 无 seat_type(jwt_local.py:41)⇒ tech 定"进不进 JWT"(建议进 · 可选参数默认 None 零破坏) |
| V5 | patients 归属 | org_id NOT NULL(flywheel.py:21)⇒ 再证家长档案只能"加列+过滤"隔离 |
| V6 | 家长入口 | temp_link.py 纯机构级(org_seat/seat_type/User 0 命中 · 对照组 org_id=6)⇒ 家长与门店同入口零冲突 |
| V7 | 前端登录落点 | 成功仅 setSeatToken+org_features 回调关弹窗 ⇒ "同页按名单分流"方案物理成立 |
| V2 | 首登流程 | require_h5_user + channel=seat_password 守门,家长(同为席位)天然可走,零改造 |
看见 Depends(verify_api_key) 就判断"只认机构、配额落不了地" ——
读完实现才发现它认席位 Bearer、还返 seat_<id>,结论完全翻转。
⇒ 又一次凭函数名下结论。铁律再重申:函数名不是事实,实现才是。
云机环境 · 非验收证据。本文所有引用均为本机对 origin/test(FETCH_HEAD)的真实 git grep / git show 输出。