交付出件箱

← 返回出件箱

Phase 74 方案自检 v2 · 20 维物理 grep 严打

2026-08-24 · dev2 · 对最新 origin/test(FETCH_HEAD)逐维 git grep · 每个 0 命中都带对照组
被检对象:c-end-dev-roadmap-v2-20260824.md(路线图 v2)+ 记忆 c-end-verified-tech-facts

结论:抓到 3 个 P0 真问题 + 5 个 P1 计划偏差 + 3 个 P2 口径错误。其中 F2 必须长官拍板。


一、🔴 P0 真问题(会造成事故 · 方案必须改)

F1 · 子席位会混进后台席位列表和结算视图 —— v2 完全漏掉 ⚠️ 本次最严重

证据

app/services/seat_service.py:222  async def list_seats(org_id, page, size, db, q, field)
    query = select(User).where(
        User.role == "org_seat",
        User.linked_org_id == org_id,
    )                      ← 只有这两个条件 · 没有任何 seat_type 概念

消费方两个,都不是我方能单独控制的:

app/api/admin_seats.py:155          return await seat_service.list_seats(...)     ← 后台「机构管理→席位」
app/api/internal_settlement.py:45   from app.services.seat_service import (
                                        generate_seats, list_seats,
                                        reset_seat_password, set_seat_status,)   ← 结算系统 X-Service-Token 调

后果

  1. 家长子席位一建出来,门店后台席位列表立刻混进一堆不认识的账号,分页 total 变大
    —— 门店会问"我买了 10 个席位怎么显示 15 个"
  2. 结算系统看得到这些席位 ⇒ 直接违反长官拍板口径「子席位不计入正式席位数、不进结算口径」

修法(进 A 批)


F2 · users.phone 是全局唯一,与「正式席位手机号可再开子席位」口径物理冲突 🔴 需长官拍板

证据(model + migration 双证)

app/models/user.py:47
    phone = Column(String(20), unique=True, nullable=True, index=True)

migrations/versions/phase29_001_create_auth_tables.py:43
    sa.Column("phone", sa.String(20), unique=True, nullable=True),

⚠️ 同文件 :98 的 ix_users_phone 是 postgresql_where=phone IS NOT NULL 的普通 partial index,
不是唯一索引 —— 但列级 unique=True 生成的 UNIQUE 约束是独立存在的,唯一性照样成立。

而首登必写 phone,绕不过去:

app/api/seat_auth.py:88  class SeatChangePasswordRequest
    contact_phone: str = Field(..., min_length=1, max_length=20)   ← 必填 · 后端 ^1\d{10}$ 校验

后果

门店正式席位已经绑了 138xxxx,同一个人(店员本人也是家长)拿同一手机号开子席位 →
家长首登改密写 User.phone 时 UNIQUE 约束冲突,直接报错登不进去。

这与 8-21 拍板的「正式席位手机号可再开子席位(互不影响)」物理矛盾。

三条路,请长官选

方案 做法 代价
甲 家长子席位不写 users.phone,手机号另存新字段 改动最小、零风险;但子席位无法用手机号检索(后台搜索少一个面)
乙 唯一约束改成 (phone, role) 或 partial unique(排除 parent) 要动 users 表约束 —— 🔴 门店端登录链路共用这张表,风险最高
丙 改口径:同一手机号全系统只能一个账号,正式席位手机号不能再开子席位 零代码;但推翻 8-21 已拍板的口径

🔴 我倾向 甲(不碰门店端在用的约束,符合"只做加法不做改法"),但这是口径变更,我不自作主张,等您定。


F3 · _ACTOR_TYPES 是 DB CheckConstraint,加一档必须写 migration;且下游有真实计数消费方

证据

app/models/activity.py:33
    _ACTOR_TYPES = ("org_seat", "h5_user", "temp_link", "miniapp", "admin")
app/models/activity.py:68   CheckConstraint(... name="ck_seat_activity_actor_type")   ← seat_activity_log
app/models/activity.py:93   CheckConstraint(... name="ck_report_image_actor_type")    ← report_image

下游真实消费方(会被虚增的地方)

app/tasks/activity_rollup_task.py:11
    active_seats : count(DISTINCT actor_id WHERE actor_type='org_seat' AND event ∈ usage)
app/services/org_insight_service.py:287
    o["src_counts"]["org_seat"] == 0

后果

  1. 不是改个 Python tuple 就完事 —— 要 alter 两张表的 check 约束,漏了家长活动直接写库报错
  2. 若图省事把家长活动打成 actor_type='org_seat' ⇒ 虚增门店活跃席位数,机构洞察和日汇总全歪

修法:migration 加 parent_seat 一档(两张表都要)+ 反向清查上面两个消费方逐个排除。


二、🟡 P1 计划偏差(不是 bug,但排序/工作量判断要修)

F4 · 🔴 我 v2 的排序理由站不住 —— 席位签发入口本来就在 PC 后台,不在门店端

证据

app/services/seat_service.py:171/197   INSERT users(role='org_seat', linked_org_id=org_id, ...)
唯一调用方:
  app/api/admin_seats.py:127        seats = await seat_service.generate_seats(...)
  app/api/internal_settlement.py:45 (结算侧也调)

我在 v2 里写「子席位账号的唯一签发入口就是门店端开通弹层,B 不做完 D/E 连账号都造不出来」
—— 这句是错的。后台现成就能签发席位,只要加个 seat_type='parent' 入参。

修正:B(门店端)和 C(PC 后台)都不是 D/E 的硬前置。
⇒ 可以直接按长官"先出 C 端手机端"的意思走 A → D → E,B/C 往后排。

F5 · C 批(PC 后台)被我高估了 —— seat_service 七件套全是现成的

generate_seats / list_seats / reset_seat_password / reassign_seat_user /
set_seat_status / update_seat_profile / extend_seat        (全在 seat_service.py)

正好覆盖方案要的「停用 / 启用 / 换绑 / 重置密码」。⇒ C 批主要是加 seat_type 过滤 + 做页面,不是从零建。

F6 · 续期 / 到期机制现成 —— 方案把续期放二期是保守了

app/api/seat_auth.py:281-283  seat_expires_at < now → 拒登录(NULL=永久)   ← 惰性到期,与方案设想一致
app/api/seat_auth.py:346-347  首登激活才起算 expires_at = 激活时间 + seat_valid_days
seat_service.extend_seat(...)                                            ← 续期现成

⇒ 「一期固定 1 年」= 建席位时传 valid_days=365,零新代码;续期也不用等二期。

F7 · 🔴 初始密码 7 天不激活即失效 —— 方案完全没提,上线会真伤家长

app/services/seat_service.py   initial_password_expires_at = now + SEAT_INITIAL_PASSWORD_VALID_DAYS
                               # Phase 68 · 初始密码 7 天内未激活即失效(D1/D2)

业务流程是「门店开通 → 把账号密码交给家长 → 家长回家登录」。
家长 7 天内没登录,密码就废了 ⇒ 必须二选一:
①给门店端一个"重发/重置初始密码"入口(B 批加) ②给子席位单独放宽这个天数。
不写进 tech,上线就是投诉。

F8 · 🟢 平板功能名单有冻结测试当护栏(这是好事,别改它)

web/app/h5/tablet/pane/panes/__tests__/menu-sidebar.spec.tsx:90
    it('TABLET_FEATURES 冻结 8 个 active id 枚举(Phase 63 追加第 8 项)', () => {
      expect(TABLET_FEATURES.map((f) => f.id)).toEqual(ALL_IDS);
    });

⇒ H 批(平板家长端)一旦动 TABLET_FEATURES,这个测试立刻红 —— 现成的门店端零回归护栏。
🔴 铁律:别去改这个测试来"让它过",要另起一份家长名单(与 D-D 拍板一致)。


三、🟢 P2 口径 / 路径错误(记忆和方案里写错的)

F9 · 两处文件路径记错了(第一次 grep 对照组也是 0,按铁律换查法才找到)

写错的 物理真实路径
web/components/admin/AdminConsoleShell.tsx web/app/(dashboard)/admin/AdminConsoleShell.tsx
app/api/h5_refractive_records.py / /api/h5/refractive-records app/api/refractive_records.py

⚠️ 这正是「对照组失效必须换查法」那条铁律救回来的两次 —— 若照旧路径写进 tech,实施会话必然踩空。

F10 · refraction_method 是自由 String(10),不是 DB 枚举 ⇒ 扩枚举比估的轻

app/models/flywheel.py:124
    refraction_method = Column(String(10), comment='小瞳/散瞳/电脑')     ← 无 CheckConstraint

⇒ 扩成「无 / 小瞳 / 快散 / 中散 / 慢散」不需要 migration 改约束,只改注释 + 应用层校验。
(⚠️ String(10) 够放,最长「慢散」2 字;但要防前端传长串截断。)

F11 · 平板首页确实有进设置的口子 —— 与记忆冲突,越权面比预估大一个入口

web/app/h5/tablet/page.tsx:478        router.push("/h5/settings?from=%2Fh5%2Ftablet");
web/app/h5/tablet/pane/page.tsx:163   router.push(`/h5/settings?from=...`)

记忆 c-end-verified-tech-facts 写「平板首页没有进设置的口子(profile 热区坐标已弃用,是残留)」
—— 与代码不符。:478 是一条真实的 router.push。
⇒ 开工时必须确认这条是不是活路径;若是活的,家长端平板越权面多一个入口,D/H 批都要堵。


四、✅ 复核通过 · 无需改动(13 项)

# 维度 结果 对照组
1 Phase 74 号 0 命中 · 空闲 Phase 73 = 4 文件 ✅
2 席位 = users 表 role='org_seat' + linked_org_id 属实,无独立 org_seats 表 ⇒ seat_type 加 users 正确 __tablename__ 查法有效 ✅
3 SeatLoginResponse 仅 4 字段 属实(access/refresh/org_features/must_change_password) 同文件 org_features = 8 ✅
4 OrgBrainConfig database.py:61 · enable_refractive_record :113 属实 · opt-in 可照抄 —
5 /api/h5/store-card 可多带一列 h5_store_card.py:145 select · :193 进返回体 —
6 SeatStoreCard.tsx:316 gate 写法 属实 {data.enable_tablet_home_branding && ( —
7 设置页两分支 settings/page.tsx:176/178 属实 ⇒ 第三分支方案成立 —
8 报告秒读文件齐 + extract 签名 vllm_client.py:704 async def extract(self, image_path, image_type) -> dict 4 文件全在 ✅
9 vision_exams 表 app/models/flywheel.py:54 —
10 秒读现在无配额 four_l_report.py quota 0 命中 h5_quota_usage = 3 文件 ✅
11 后台平台管理组 看板/机构管理/机构洞察/席位活动 ⇒ 加一行即可 —
12 门店设置页两端同一路由 平板 :478/:163、手机 menu-sidebar.tsx:228 全指 /h5/settings ⇒ 一份实现覆盖两端成立 —
13 档案搜索 org 级泄漏 refractive_records.py:40 search_patients(db, seat["org_id"], ...) 属实 require_org_seat = 3 文件 ✅

五、路线图要改的地方(汇总)

  1. A 批加两件:list_seats 加 seat_type 过滤(F1)· _ACTOR_TYPES migration 两张表(F3)
  2. 顺序改:B/C 不再是 D/E 硬前置 ⇒ A → D → E → F → G → B → C → H
    (若长官要门店端早点能自助开通,B 可随时插到 A 之后)
  3. B 批加一件:初始密码 7 天失效的"重发/重置"入口(F7)
  4. C 批降权重:seat_service 七件套现成,主要是过滤 + 页面(F5)
  5. 续期挪进一期:extend_seat 现成,零成本(F6)
  6. tech 里改掉两处错路径(F9)· 扩枚举不需 migration(F10)· 平板设置入口要重新确认(F11)
  7. F2 等长官拍板后才能定 tech

六、等长官拍板

待决 选项
🔴 F2 手机号唯一冲突 甲(家长不写 users.phone · 我倾向)/ 乙(改唯一约束 · 风险最高)/ 丙(改口径)
F7 初始密码 7 天 给门店"重发"入口 / 给子席位放宽天数
顺序 A→D→E→F→G→B→C→H(先出 C 端)还是 B 插到 A 之后

云机环境 · 非验收证据。本文所有代码引用均为本机对 origin/test(FETCH_HEAD)的真实 git grep 输出。