交付出件箱

← 返回出件箱

Phase 74 方案自检 第二轮 · 12 个新维度物理 grep

2026-08-24 · dev2 · 对 origin/test(FETCH_HEAD)· 每个 0 命中都带对照组
第一轮台账:c-end-plan-selfaudit-v2-20260824.md(20 维 · F1~F11)

本轮换维度打:第一轮查的是"能不能做",本轮专查第一轮没碰过的越权面
和长官新决定引出的新问题(手机号保持唯一 / 重置密码按钮放门店端)。

结论:3 个新问题(含 1 个把上轮乐观判断打回来的)+ 2 个好消息 + 1 条设计缺口。


一、🔴 新抓到的问题

G1 · 家长端「我的账号」用不了现有的用户资料接口 —— 席位 token 会被 403 挡掉

证据(仓库里已有人真打验过并写进注释)

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 批要多一个小接口,不是纯前端活。


G2 · 🔴 门店端自助开通 / 重置密码,要新建一整套席位向接口 —— 打回我第一轮的乐观判断

证据

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 批工作量上调,且是本期权限风险最高的一批。


G3 · 「解析成功才扣」这条配额口径,在异步秒读链路上落点没定 —— 不能想当然

证据

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 里想当然写一句"成功才扣"就派单。


二、🟢 两个好消息(都是实证,不是推测)

G4 · 秒读配额守门可以外挂,「禁改五文件」真能做到一行不改

第一轮我担心的矛盾是: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) 时判断成"只认机构、配额落不了地" —— 判错了,
是读完实现才翻转的。记一笔:看见函数名就下结论 = 又一次差点凭印象说话。

G5 · 会话历史天然就是隔离的,不用额外做

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 后端一律忽略(已有防越权设计)。
⇒ 家长天然看不到门店的会话,方案里「会话历史原样复用」这条成立,零改动。


三、🟡 一条要改的设计前提

G6 · 家长档案必须挂机构(字段非空),所以隔离只能靠"加列 + 过滤",没有别的路

app/models/refractive_record.py:38
    org_id = Column(Integer, ForeignKey("organizations.id", ondelete="CASCADE"),
                    nullable=False,                       ← 非空
                    comment="机构隔离键(服务端由席位推导)")

org_id NOT NULL,不能让家长档案"不挂机构"来躲开门店搜索。
⇒ 第一轮 F 说的「加归属标记 + 搜索过滤」是唯一可行路径,这条确认没有更省的走法。

G7 · 配额确实必须新建模型(印证第一轮判断)

app/models/h5_chat.py:78   class H5QuotaUsage
    org_id / message_count / image_count / date(YYYY-MM-DD)

只有机构 + 按天 + 消息数/图片数,没有账号维度、没有秒读次数、没有 365 天周期。
⇒ 「6 次 / 账号 / 开通起 365 天」复用不了,新建模型的判断成立。


四、本轮 12 维总表

# 维度 结果 对照组
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 处 ✅

五、路线图要再改的地方(在第一轮基础上追加)

  1. B 批权重再上调,且列为本期权限风险最高的一批(G2)——
    新建席位向接口时,鉴权必须写死三层:本机构 + 仅 parent + 不得触碰正式席位
  2. D 批多一个小接口:家长「我的账号」走席位侧,不碰 /api/h5/user/profile(G1)
  3. G 批 tech 必须先定扣减落点(G3)—— 异步链路,"成功才扣"不能想当然
  4. G 批守门写在 main.py 注册处,four_l_report.py 保持零改(G4)
  5. 会话历史一条删掉,天然隔离,不占工作量(G5)

五之二、🔴 长官 2026-08-24 当场拍板(实施唯一口径 · 覆盖此前所有相反写法)

# 事项 长官决定(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 加一个查询改造 + 两处弹框。


七、自主注册功能(未启用)影响 verify · 12 维(2026-08-24 长官令)

被检问题:Phase 74 的改动(手机号各端唯一 / 微信不限制+弹框 / seat_type 新列)
对「用户自主注册」功能(enable_user_registration · 已做未启用)有没有影响。

结论:1 处真影响(必须设计对) + 1 处存量隐患(非我们引入) + 其余互不干扰

🔴 R1 · 手机号"各端唯一"必须把自主注册用户算作第三端,否则注册绑手机号会炸 500

🟡 R2 · 存量隐患(顺带发现 · 非 Phase 74 引入):两开关后台无物理互斥

✅ 其余 10 维全部互不干扰(带证据)

# 维度 结果
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)

八、第三轮自检 · 10 维(2026-08-24 · 专打 D1/D5 新决定牵出的改动面)

🎁 V2+V10 · 意外好消息:家长「自绑手机号」天然现成 —— 但文案要换

🟡 V9 · 全仓 16 个文件有 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 必做项。

🔴 V8 · 手机号改"各端唯一"的 migration 有存量炸点 —— 记为发版前置动作

建新的按端唯一索引时,存量数据若已有重复 phone,migration 直接失败。
云机查不了生产库(工具指到空库,已实测)⇒ 🔴 发 test/生产前必须先跑存量重复检查 SQL,有重复先治数据再上约束。

🟡 V1 · 账号名生成器共用序号空间(留长官一个小决策)

_gen_unique_username:全局按月连续序号 0001202607(长官 7-07 拍板),无端区分。
家长也吃这个序号空间 ⇒ 门店月度序号会跳号(0003 被家长占了),且账号名看不出是家长。
默认共用(零改动);若长官想区分可加前缀(生成器加分支,不动门店路径)。tech 留决策点。

🟡 V4 · generate_seats 加参数的红线一句

结算侧是位置参数调用(internal_settlement.py:566/659)⇒ 新加 seat_type 参数
必须放签名最末 + 默认 'store',插中间全错位。tech 写死。

✅ 其余 5 维通过

# 维度 结果
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 输出。