交付出件箱

← 返回出件箱

Phase 48A/B/C「品牌方合作」技术方案

日期:2026-09-16(本次修订:同日追加)| 状态:阶段 3 技术方案(需求已拍板,见 brand-partner-spec-v1-20260915.md)


0. 基线声明(本次修订新增 · 必读)

本文档所有 file:line 引用、文件存在性/不存在性声明、行号、函数签名、行数统计,均以 origin/test 为唯一基线复核,复核时刻 origin/test 的 commit sha:

$ git fetch origin test -q && git rev-parse origin/test
96638b642085a47cb10648da020673653566aa6e

复核方式:全程只读 git ls-tree / git show origin/test:<path> / git archive origin/test,未 checkout、未切分支、未改动仓库任何代码、未跑 alembic、未起服务。

🔴 本文档上一版的教训(必须写明,否则后续实施者会重犯):上一版文首自称"当前 worktree(tip b282c143,即 main 当前状态)"是复核基线,并声称已借此纠正了"证据报告"的两处错误。但 b282c143 所在的工作区/worktree 不是 origin/test,只是本机某个已合并到 main 的分支状态——查错了基线。用错基线导致上一版即便自称"独立复核",行号仍与 origin/test 的真实内容不一致(例如 refractive_record_service.py 的 6 处 Patient.org_id == 行号,main 上是 142/370/470/511/545/599,origin/test 上实际是 142/370/486/527/561/615——两边行号在 370 之后就分道,因为 origin/test 比 main 多出「门店手机端视光档案适配」线带来的代码增量)。本次修订把所有此类引用统一改回 origin/test 真实行号,订正清单见「验证附录 · 订正对照表」。

后续实施前提醒:origin/test 是活分支,实施时点的 sha 大概率已经比 96638b64 更新,开工前必须重新跑本节的 git rev-parse origin/test 和下文各处 git show origin/test:<path> 命令,不能直接套用本文档写作时刻锁定的行号。


一、一句话目标 + 三个子 Phase 边界

目标:品牌方印刷固定二维码 → 家长扫码微信授权免注册进家长端(48A)→ 家长与门店互扫把孩子档案绑到具体门店(48B)→ 家长端 4L 硬件展示区自助绑定点亮、倒逼门店采购(48C)。

Phase 范围 本文档覆盖
48A 品牌码生成/管理(PC 后台)+ 扫码微信授权 + 免注册进家长端 + 开通计数 第三、四、五节主体
48B 家长 ↔ 门店互扫绑定(复用 bind_patient_seat/unbind_patient_seat,只是把"门店手点"换成"扫码触发同一后端调用") 第五节③
48C 4L 展示区四个灰框 + 家长自助绑定设备 第三节新增表简述,UI 与型号库不在本文档范围(型号清单先手工维护,UI 已有效果图)

依赖顺序(spec §二):48A + 48C 并行动,48B 紧跟 48A(48B 依赖 48A 已产出的家长身份)。


二、技术现状(origin/test 复核,可直接复用的部件)

2.1 微信 OAuth code 换身份 —— 已有两条独立可用的兑换端点(本次修订新发现,比上一版更完整)

app/services/wechat_oauth_service.py:38 async def exchange_code(code: str, with_userinfo: bool = True) -> dict —— 公众号 snsapi_userinfo 两步换取,返回 openid/unionid/nickname/headimgurl。

$ git show origin/test:app/services/wechat_oauth_service.py | sed -n '38,39p'
async def exchange_code(code: str, with_userinfo: bool = True) -> dict:

调用方(origin/test 实测 5 个文件含 exchange_code,对照组 grep -rln "doctor_id or 1" 命中 7 个文件,证明两条 grep 查法都有效,非误查为 0):

① 家长端 h5_user 轨道(轨道A)—— 已是完整可用的独立端点,与"席位登录窗"完全无关:

app/api/h5_user_auth.py:

POST /api/h5/oauth/exchange  → exchange_code(code) → 若无 unionid 直接 409(不允许无 unionid 建 h5_user)
                              → app/services/h5_user_service.py:8 upsert_by_unionid(...)
                              → issue_access_token / issue_refresh_token(app/auth/jwt_local.py)
                              → 返回 {access_token, refresh_token, must_register_phone}

前端消费方 web/app/h5/chat/page.tsx(urlOauthCode && urlState !== "seat" 分支,约 970-1000 行区间):拿到 access_token 后直接 setH5UserJwt(data.access_token) + setToken(data.access_token),不弹任何登录窗、不经过席位密码环节——这是家长端"扫码免登"已经在跑的现成技术路径。

② 门店/家长子席位轨道(轨道B)—— seat_wx_identity,只做"身份令牌 + 账号提示",不是免登录:

app/api/seat_auth.py:459 async def seat_wx_identity:

POST /api/seat/wx-identity → exchange_code(code) → 签 10 分钟 wx_identity_token(不写库)
                            → app/services/seat_service.py:600 lookup_seat_username_by_wx(unionid, openid, db)
                            → 返回 {wx_identity_token, nickname, avatar, seat_username}

seat_username 只是"命中已绑定的家长子席位账号名",用途是前端登录框自动填账号(app/api/seat_auth.py:126-131 WxIdentityResponse.seat_username 注释:"换设备时前端自动填入,免手输 10 位数字")——无论 seat_username 是否命中,前端都会 setSeatGateOpen(true) 弹出账密登录窗(web/app/h5/chat/page.tsx 约 918-958 行区间,命中确认见下方证据)。

🔴 纠正上一版 4.1 的设计前提错误:上一版 4.1 描述"在 exchange_code 之后、issue_wx_identity_token 之前,插入一段"方案2"判断——existing_seat_username = await seat_service.lookup_seat_username_by_wx(...)",把这行调用当作要新增的代码。但 origin/test 上 seat_wx_identity 已经在调用 lookup_seat_username_by_wx(就是产出 seat_username 字段的那行),这不是新代码,是现状。48A/方案2 真正要新增的是:在已有 seat_username 变量的基础上,追加"若未命中家长子席位,则查 h5_user 表;命中则直接签 h5_user JWT 跳过密码窗"这一段新分支,而不是重新引入一次 lookup_seat_username_by_wx 调用。第四节 4.1 已按此订正重写。

2.2 前端发起 OAuth + 回跳承接的既有模式

web/app/h5/chat/lib/wx-oauth.ts:36 export async function startSeatWxOAuth(redirectUri: string)(origin/test 上文件路径与函数名核实存在,行号本次未逐字核对——未核实,实施前需重查)。品牌码落地页应照"前端发起+前端承接"结构写,不新起后端 callback。

2.3 家长两种身份机制(本次复核确认,两条轨道均已在 origin/test 上线运行,不是"只有雏形")

轨道 表现形式 归属 用途
A:h5_user 全局身份 users 表 role='h5_user',靠 wechat_unionid 唯一索引,不挂 linked_org_id 全平台唯一 /api/h5/oauth/exchange 完整可用(见 2.1①),家长端登录态 JWT
B:org_seat 家长子席位 users 表 role='org_seat' + seat_type='parent' + linked_org_id=<某机构> 挂在具体一个机构下 patients.owner_seat_id 的唯一合法指向对象(见 2.5),/api/seat/wx-identity 只做账号提示,仍需账密登录

轨道 B 的开通端点:

$ git ls-tree -r --name-only origin/test -- app/api/store_subseat.py app/api/admin_parent_seats.py
app/api/store_subseat.py
app/api/admin_parent_seats.py
$ git show origin/test:app/api/store_subseat.py | grep -n "@router.post(\"/provision\")"
65:@router.post("/provision")
$ git show origin/test:app/api/admin_parent_seats.py | grep -n "@router.post(\"/provision\")"
94:@router.post("/provision")

两者最终都调用 app/services/seat_service.py:164 async def generate_seats(org_id, count, db, valid_days=None, experience_store_name=None, seat_type="store", seat_source=None, created_by_seat_id=None)(订正:上一版写 :156,origin/test 实际是 :164)。

48A 的真实缺口结论不变:扫码进来的陌生家长既不是门店席位(seat_type != 'parent' 才能过 require_store_seat,家长子席位天然被拦)也不是平台管理员,两个既有 provision 端点物理上都拦得住"陌生家长"这类调用方——48A 必须新开一条公开端点。

2.4 微信身份 ↔ 家长子席位的绑定与反查机制

app/models/user.py:94 seat_bound_unionid(+ :99 seat_bound_openid)——未在本次核实范围内逐字核对,沿用上一版行号,实施前需重查。

反查函数 app/services/seat_service.py:600 async def lookup_seat_username_by_wx(unionid, openid, db, return_all=False)(本次已核实存在于该行):凭已绑定的微信身份反查 role='org_seat' 且 is_active=True 的席位账号。这是 spec 规则6"已有子席位的合并到原账号"能落地的现成工具,且已经被 seat_wx_identity 调用(2.1②),不是"待接入"的孤儿函数。

2.5 patients 归属字段与 owner_seat_id 的约束(本次复核重新核实,比上一版更精确 —— 约束层级从"结构性硬事实"订正为"API 层 + 写路径校验共同保证,非 DB 级约束")

app/models/flywheel.py:16-56(Patient 模型,本次逐行核实):

org_id = Column(Integer, ForeignKey('organizations.id', ondelete='CASCADE'), nullable=True)
doctor_id = Column(Integer, ForeignKey('doctors.id', ondelete='CASCADE'), nullable=False)
...
owner_type = Column(
    String(10), nullable=True, server_default=text("'seat'"),
    comment='档案归属 seat=门店建 / parent=家长建 · Phase 74',
)
owner_seat_id = Column(
    Integer, nullable=True,
    comment='owner_type=parent 时的家长席位 users.id · 家长只见自己的 · Phase 74',
)

🔴 本次核实新发现,上一版未提及的关键约束层级:owner_seat_id 没有 FK 约束(Column 定义里没有 ForeignKey),"必须指向 role='org_seat' AND seat_type='parent' 的行"不是数据库物理约束,是两处应用层校验叠加起来的效果:

  1. 写路径①(门店指认绑定):app/services/refractive_record_service.py:475 bind_patient_seat 显式查 target(User.id == seat_user_id, User.linked_org_id == org_id, User.role == "org_seat", User.seat_type == "parent"),不满足则 404——这条路径有校验。
  2. 写路径②(家长自建档案):app/services/refractive_record_service.py:338 create_patient(..., owner_seat_id: Optional[int] = None, ...) 本身对传入的 owner_seat_id 零校验,只做 owner_type="parent" if owner_seat_id is not None else "seat" 的赋值——真正的约束在调用方 app/api/parent_patients.py:69 if seat.get("seat_type") != "parent": raise ...(家长端建档 API 在调用 create_patient 之前,先靠 require_org_seat 系鉴权 + 这一行显式判断挡掉非家长子席位身份),传入的 owner_seat_id=seat["user_id"](parent_patients.py:347)天然就是"当前已通过鉴权的家长子席位自己的 id"。

结论调整:约束依然成立、依然是 48A/48B 设计必须遵守的前提(h5_user 轨道 A 不能直接持有档案),但结论的性质从"结构性硬事实"改为"应用层约束(两处写路径各自校验),实施新代码时若绕开 create_patient/bind_patient_seat 直接 Patient(owner_seat_id=...) 建对象,DB 不会拦,必须自己在新代码里重复这层校验"——这对 4.4 节的 brand_code_service.py 实现有直接指导意义:新服务在调用现成的 create_patient/bind_patient_seat 时天然安全,但如果图省事直接 ORM 赋值就会绕过校验,需要写代码时特别小心。

另可见真实门店可见性判据(app/services/refractive_record_service.py:79 _store_visible_clause,比上一版引用的片段更完整,含"门店自己的档案"分支):

is_store_owned = or_(Patient.owner_type.is_(None), Patient.owner_type == "seat")
org_parent_seat_ids = select(User.id).where(
    User.linked_org_id == org_id, User.role == "org_seat", User.seat_type == "parent",
)
is_org_parent_owned = and_(
    Patient.owner_type == "parent",
    Patient.owner_seat_id.in_(org_parent_seat_ids),
)
return or_(is_store_owned, is_org_parent_owned)

(该函数注释注明"长官 9-02 深夜二次拍板":家长子席位自建档案不再要求门店先手动 /bind,只要该子席位挂在本机构就直接可见——这条既有逻辑与本 Phase 的 service_org_id 新字段是两套并行的可见性判据,实施时要注意别互相打架,第四节 4.2 已就"门店视角调用 vs 品牌方视角调用"分别说明。)

2.6 机构级免登录链接(可复用的基础设施骨架,非直接套用)

app/services/org_service.py:314 async def generate_access_link(org_id, db, host)(订正:上一版写 :312,实际 :314)→ app/api/admin_orgs_access_link.py:76 POST /{org_id}/access-link → 家长打开 {host}/h5/chat?t={token} → app/api/temp_link.py:82 temp_exchange 验签换业务 SessionToken。

⚠️ 不能直接套用的原因:generate_access_link 内 jti = f"orglink-{org_id}"(org_service.py:363,注释 :355 说明命名规则 "admin_link_orglink-N")是按机构 id 派生的确定性值,temp_link.py 里 external_user_id(该字段定义在 :64,赋值分别在 :144/:155/:190 —— 订正:上一版写 ":108-109",origin/test 上没有 :108-109 这个行号区间对应此逻辑)所有扫同一机构码的人换到同一个身份——是"机构共享身份",不是"每人一个身份"。品牌码需要按人区分,必须叠加微信 OAuth(2.1)而不能照抄这条链路的 payload 设计。

二维码图片渲染可直接复用:app/api/qr.py:26-27 @router.get("/qr") / async def generate_qr_png(text: str, size: int = 320) -> Response。

2.7 OrgBrainConfig 功能开关命名规范(新增开关要跟随的既有模式)

app/models/database.py:120 enable_parent_subseat(Boolean nullable=False server_default false)。同款还有 :113 enable_refractive_record、:83 enable_4l_report(本次逐行核实,行号与上一版一致,无需订正)。规则22"任何机构都可开启品牌码,默认不开"应照 enable_parent_subseat 的写法新增 enable_brand_code 字段。

2.8 审计表现状

app/models/audit.py:23 class AuthAuditLog(event_type 枚举 5 类,6 月留存)和 :64 class LoginAttempt(15 分钟窗口失败计数,7 天清)——本次逐行核实,行号与上一版一致。两张表语义(认证安全事件)和生命周期都不匹配"365天有效期+累计计数+对账导出",第三节的"开通记录表"必须新建。

2.9 家长端 token 存储位置

web/app/h5/chat/lib/auth.ts:8 H5_JWT_STORAGE_KEY = "h5_user_jwt"(sessionStorage)。品牌码落地页最终把家长放进家长端时,应直接复用 2.1① 已经在跑的 /api/h5/oauth/exchange + 前端既有 setH5UserJwt/setToken 调用点(web/app/h5/chat/page.tsx 约 998/1000 行区间,订正:上一版写 :997/:999,本次未逐行重新数、区间大致吻合但未做逐字核对,未核实精确行号,实施前需重查),不要另起存储 key。


三、要新建的东西

(本节为设计产物,不涉及现网代码事实核验,内容与上一版一致,未改动。新建文件的"不存在性"已用 origin/test 重新核实,见验证附录 A。)

3.1 数据表

表1:brand_codes(机构码表)

字段 类型 可空 说明
id Integer PK - -
org_id Integer FK organizations.id ondelete CASCADE False 一机构一码,唯一索引 ux_brand_codes_org_id
code_token String(255) False 签名后的固定 token(进二维码 URL),唯一索引
is_enabled Boolean False server_default true,配合 OrgBrainConfig.enable_brand_code 双门(fail-closed,与 enable_parent_subseat 模式一致,见 app/api/store_subseat.py:36-38 _require_org_subseat_enabled 的写法)
created_at DateTime(tz) False server_default now()
created_by_admin_id Integer FK users.id ondelete SET NULL True 哪个平台管理员开启的

表2:brand_code_activations(开通记录表,规则20计费口径的唯一数据源)

字段 类型 可空 说明
id BigInteger PK - -
brand_org_id Integer FK organizations.id ondelete CASCADE False 哪个机构的码(品牌方)
wechat_unionid String(64) True 微信授权成功后拿到;公众号未绑开放平台时可能为 NULL
wechat_openid String(64) False 最低身份保证(同 seat_wx_identity 的兜底逻辑:openid 缺失才是真异常)
wechat_nickname / wechat_avatar String(128) / Text True 展示用,管理页名单列
family_seat_user_id Integer FK users.id ondelete SET NULL True 关联到的家长子席位账号(若走轨道A方案则可能为 NULL,见第九节风险1)
activated_at DateTime(tz) False server_default now(),= 微信授权成功时刻
expires_at DateTime(tz) False activated_at + 365天,写入时直接算好
child_patient_count 不落这张表 - 运行时查 patients.org_id=brand_org_id 聚合即可

去重约束:UNIQUE(brand_org_id, wechat_unionid);UNIQUE(brand_org_id, wechat_openid) WHERE wechat_unionid IS NULL 部分唯一索引兜底。

3.2 新增字段

patients.service_org_id(Integer FK organizations.id ondelete SET NULL,nullable=True,无默认值)——patients.org_id 现状已是 nullable=True(本次已重新核实,见 2.5),新增列风格与之一致,无需 backfill。

3.3 新增端点

方法 路径 入参 出参 权限
POST /api/public/brand-code/activate {brand_token: str, code: str} {h5_access_token, h5_refresh_token, nickname, avatar, must_bind_family_seat: bool} 公开,无需登录态
POST /api/admin/orgs/{org_id}/brand-code 无 body {code_token, qr_url} require_role("admin")
PATCH /api/admin/orgs/{org_id}/brand-code/status {is_enabled: bool} {is_enabled} require_role("admin")
GET /api/admin/orgs/{org_id}/brand-code/summary 无 {code_token, qr_url, enabled_at, stats: {...}} require_role("admin") 或该机构自己的 org_admin
GET /api/admin/orgs/{org_id}/brand-code/activations 分页/搜索/日期筛选 {records: [...], total} 同上
GET /api/admin/orgs/{org_id}/brand-code/export 日期筛选 xlsx 文件流 同上

3.4 新增前端页面


四、要改的东西(逐项 file:line,本节按 origin/test 全面订正)

4.1 家长身份合并逻辑:app/api/seat_auth.py:459 seat_wx_identity(本次重写,纠正上一版的设计前提错误)

现状(origin/test 实测):

$ git show origin/test:app/api/seat_auth.py | sed -n '459,510p'
async def seat_wx_identity(...):
    ...
    token = issue_wx_identity_token(unionid, openid, wx.get("nickname"), wx.get("headimgurl"))
    seat_username = await lookup_seat_username_by_wx(unionid, openid, db)   # ← 已存在,非新增
    return WxIdentityResponse(
        wx_identity_token=token, nickname=wx.get("nickname"), avatar=wx.get("headimgurl"),
        seat_username=seat_username,
    )

前端(web/app/h5/chat/page.tsx,urlOauthCode && urlState === "seat" 分支)拿到响应后无条件 setSeatGateOpen(true)(不管 seat_username 是否命中,都要求输入密码,seat_username 只是免手输账号)。

🔴 纠正:上一版把 lookup_seat_username_by_wx 调用写成"要在 exchange_code 之后、issue_wx_identity_token 之前新增"的代码,这段调用已经存在(就是产出 seat_username 那行),只是产出用途是"填账号框"不是"判断能否免密登录"。

正确的改动位置:在已有的 seat_username = await lookup_seat_username_by_wx(unionid, openid, db) 这行之后,新增判断——

seat_username = await lookup_seat_username_by_wx(unionid, openid, db)  # 已有代码,不动
h5_user_access_token = h5_user_refresh_token = None
if not seat_username:
    # 新增:未命中家长子席位 → 查是否已有 h5_user 记录(轨道A · 曾扫过某个品牌码激活过)
    h5_user = (await db.execute(
        select(User).where(User.wechat_unionid == unionid, User.role == "h5_user")
    )).scalar_one_or_none() if unionid else None
    if h5_user:
        h5_user_access_token = jwt_local.issue_access_token(h5_user)
        h5_user_refresh_token = jwt_local.issue_refresh_token(h5_user)
return WxIdentityResponse(
    ..., h5_user_access_token=h5_user_access_token, h5_user_refresh_token=h5_user_refresh_token,
)

WxIdentityResponse(app/api/seat_auth.py:126-131)需要新增两个可选字段 h5_user_access_token: str | None = None / h5_user_refresh_token: str | None = None。

前端 web/app/h5/chat/page.tsx 在 wx-identity 响应处理处(urlOauthCode && urlState === "seat" 分支内,约 918-958 行区间)新增分支:若响应带 h5_user_access_token,走跟 urlOauthCode && urlState !== "seat" 分支(约 970-1000 行区间,2.1① 已确认现成可用)同款 setH5UserJwt + setToken 处理,跳过 setSeatGateOpen(true),直接跳家长端首页。web/app/h5/tablet/page.tsx(exchange_identity 步骤,约 296 行起)同步改(未逐行核对确切行号,实施前需重查)。

⚠️ 本次审核提示(供长官/tech 阶段判断):既然 2.1① 已确认存在一条完全独立、无需改任何现有代码即可用的 /api/h5/oauth/exchange(h5_user 轨道,直接给 JWT、零密码窗),48A"扫码进门"主链路(第五节①)是否可以直接复用这条现成端点、而不必新开 /api/public/brand-code/activate,是本文档尚未回答的问题——现成端点缺的只是"品牌码验签 + 开通记录落库 + 家长子席位自动开通"这三段业务逻辑,理论上可以包一层而不是平行造一个新端点。本文档暂维持"新开公开端点"的设计(因为 /api/h5/oauth/exchange 目前语义是"公众号泛用登录",混入品牌码专属逻辑会让它承担过多职责),但这是可讨论的设计选型,不是唯一解,列入第九节风险。

4.2 可见性过滤:app/services/refractive_record_service.py(6 处,本次按 origin/test 订正全部行号)

$ git show origin/test:app/services/refractive_record_service.py | grep -n "Patient.org_id =="
142:    stmt = select(Patient).where(or_(Patient.org_id == org_id, Patient.is_sample.is_(True)))
370:                Patient.org_id == org_id,
486:            select(Patient).where(Patient.id == patient_id, Patient.org_id == org_id)
527:            select(Patient).where(Patient.id == patient_id, Patient.org_id == org_id)
561:        Patient.id == patient_id, or_(Patient.org_id == org_id, Patient.is_sample.is_(True))
615:                    or_(Patient.org_id == org_id, Patient.is_sample.is_(True)),

🔴 订正:上一版给的行号是 142/370/470/511/545/599,origin/test 上实际是 142/370/486/527/561/615(142/370 两处凑巧一致,后 4 处全部偏移,偏移量 16 行——推断上一版实际查的是本机某个比 origin/test 少 16 行代码增量的分支/worktree,而非 origin/test 本身;至于是否恰好是"证据报告"或旧 main,未继续深挖,直接以 origin/test 为准)。

所属函数(本次用 origin/test 的函数定义行核实):142=_patients_filtered_stmt(def 于 :130)、370=create_patient(def 于 :338)、486=bind_patient_seat(def 于 :475)、527=unbind_patient_seat(def 于 :517)、561=get_org_patient(def 于 :540)、615=_patient_is_sample(def 于 :600)。

改法:org_id(谁带进来的)保持不动,新增 service_org_id(谁在服务他)判断——可见性变成:

or_(
    and_(Patient.org_id == org_id, Patient.service_org_id.is_(None)),  # 未被认领,品牌方可见
    Patient.service_org_id == org_id,                                   # 已被认领,服务机构可见
    Patient.is_sample.is_(True),
)

6 处逐一核对(结论与上一版一致,仅行号订正):门店端视角调用(142 门店分支、486/527/561/615)传入的 org_id 就是"服务机构",理应用 service_org_id == org_id 为主,org_id == org_id AND service_org_id IS NULL 为辅;370(create_patient)是写路径,创建时不涉及 service_org_id,不用改。每一处改动必须单独 review 是不是门店视角调用还是品牌方视角调用,不能一次性批量替换。

另需注意 2.5 揭示的既有 _store_visible_clause(:79,基于 owner_type/owner_seat_id 的可见性判据)是与本节 org_id/service_org_id 判据平行的另一套过滤逻辑,两者叠加使用时(例如 search_patients 内部)要确认 AND/OR 组合关系不会互相抵消,实施时需要在 _patients_filtered_stmt(:130,内部同时用到两套判据)里通读一遍完整函数体再改,本文档未展开该函数全文,列为实施前必读项。

4.3 patients.doctor_id 取值(spec §六-3 悬而未决项)

app/models/flywheel.py:27 doctor_id = Column(Integer, ForeignKey('doctors.id', ondelete='CASCADE'), nullable=False)(本次逐行核实,origin/test 上确认仍是 NOT NULL,行号与上一版一致,无需订正)。品牌码扫码建档链路必须显式传 doctor_id,不能不传。

🔴 订正:上一版称"现有 13 处 org.doctor_id or 1 兜底是技术债",本次在 origin/test 的 app/ 目录下重新统计:

$ grep -rn "doctor_id or 1" app/ | wc -l
7

(对照组:同一 app/ 目录下 grep -rln "exchange_code" --include=*.py 命中 5 个文件,非空,证明查法有效)实际是 7 处,不是 13 处。app/services/org_service.py:30/:89 的注释确认这是已知技术债("去掉对运行时 org.doctor_id or 1 兜底的依赖")。本方案不建议照抄这个兜底写法,但目前没有"品牌方该配哪个 doctor_id"的业务规则,需长官确认。

4.4 家长子席位自动开通(新逻辑,落在哪个文件)

不直接改 seat_service.generate_seats(订正:函数定义行号上一版写 :156,origin/test 实际 :164;签名末位参数顺序被 internal_settlement.py——本次核实该文件路径实际是 app/api/internal_settlement.py,不是app/services/internal_settlement.py——:566/659 位置传参依赖,seat_service.py:173-174 注释已明确写"插中间会全错位",不能改签名)。新建 app/services/brand_code_service.py,内部调用 seat_service.generate_seats(org_id=brand_org_id, count=1, db=db, seat_type="parent", seat_source="brand_code") 生成 1 个家长子席位,生成后立即补写 seat.seat_bound_unionid = unionid,跳过"首次登录改密"步骤(seat.must_change_password 需要在创建后显式置 False)。

⚠️ 对照 2.5 的订正结论:本函数走的是 generate_seats(平台/门店开通同一底层函数),产出的席位天然满足 role='org_seat' AND seat_type='parent'(generate_seats 内部固定写入这两个字段),不涉及绕开 create_patient/bind_patient_seat 校验的风险——2.5 提到的"图省事直接 ORM 赋值会绕过校验"指的是给 Patient.owner_seat_id 赋值时的风险,不是给 User 表开席位时的风险,两者不要混淆。


五、关键链路时序

①扫码进门(48A 主链路)

  1. 家长微信扫品牌码图片 → 打开 {host}/h5/brand-scan?bt={brand_token} → 前端跳转微信授权(redirect_uri 指回本页)
  2. 微信带 ?code= 跳回 → 前端 POST /api/public/brand-code/activate(3.3 新端点)
  3. 端点内部:验签 brand_token → 查 brand_codes.is_enabled + OrgBrainConfig.enable_brand_code(双门)→ 调 exchange_code(code) 拿 unionid/openid → 调 lookup_seat_username_by_wx(2.4)查是否已有家长子席位
  4. 命中 → 复用该席位签 JWT;不命中 → 调 brand_code_service(4.4 新函数)建新家长子席位 + 绑定微信身份
  5. INSERT/UPSERT brand_code_activations(去重约束防重复计数,3.1)
  6. 返回家长端 JWT,落 web/app/h5/chat/lib/auth.ts 的 H5_JWT_STORAGE_KEY(2.9),跳转家长端首页

(本节维持"新开公开端点"设计,但 4.1 已标注:4.1 揭示的既有 /api/h5/oauth/exchange 是否可直接复用是待讨论项,若长官/tech 阶段选择复用现成端点,本时序图的步骤2-3需要相应改为"调用 /api/h5/oauth/exchange 并在其内部插入品牌码专属分支",不是本文档能单方面定的选型。)

②公众号复访(方案2,老链接兼容)

  1. 家长(此前已扫码激活过)点公众号菜单里的老链接 ?login=seat → seat_wx_identity(4.1 改动点)
  2. exchange_code 拿到 unionid → lookup_seat_username_by_wx 未命中(陌生家长确实没有门店发的席位)→ 查 users 表 role='h5_user' 且该 unionid
  3. 本 Phase 需要新增:如 4.1 所述,这一查找逻辑现在明确了要接在既有 lookup_seat_username_by_wx 调用之后,不是重新引入该调用——4.1 的代码块已按此重写。轨道矛盾风险仍然存在(若 48A 扫码进门给家长建的是轨道B而不是轨道A,这里仍查不到),见第九节风险1,未解决。

③机构扫码绑定(48B)

  1. 门店端"扫一扫"扫家长端展示的档案二维码(或反向)→ 拿到 patient_id + 家长子席位标识
  2. 调用既有 app/api/refractive_records.py:155 bind_patient_seat 端点(POST /api/h5/refractive-records/patients/{patient_id}/bind,require_store_seat;订正:上一版写 :156,origin/test 实测装饰器行是 :155)→ 落 app/services/refractive_record_service.py:475 bind_patient_seat(订正:上一版写 :459,实际 :475,见 4.2)
  3. 本 Phase 需要新增:bind_patient_seat 目前只写 patient.owner_seat_id,规则7要求同时写 patient.service_org_id = org_id

④导出对账

  1. PC 后台品牌码整页点"导出对账表" → GET /api/admin/orgs/{org_id}/brand-code/export
  2. 端点内查 brand_code_activations WHERE brand_org_id=org_id,按日期筛选,聚合"开通个数"(COUNT(*),不联表算金额)
  3. 生成 xlsx(日期格式化到年月日)返回文件流

六、数据完整性与计费口径

(内容与上一版一致,均为设计产物,不涉及现网代码事实核验,未改动。)


七、迁移策略

⚠️ 本次复核确认的现状(origin/test,sha 96638b64,仅代表 2026-09-16 写作时刻,实施时必须重查):

$ git archive origin/test -- migrations/versions | tar -x -C /tmp/test_migrations
$ ls /tmp/test_migrations/migrations/versions | wc -l
97
$ python3 -c "<AST 解析脚本,见下方完整代码>"
parsed_ok: 97 / 97
HEADS: ['34ec94466f0c', 'batch7a_read_receipt', 'phase79_001']

本次独立复核结果与上一版一致(97 个文件,3 个 head),本节无需订正。AST 解析脚本(禁用正则/grep 猜测,理由:正则会被 docstring 里的示例文本误伤):

import ast, os
d = 'migrations/versions'   # 或 git archive 展开后的路径
files = [f for f in os.listdir(d) if f.endswith('.py')]
revs, downs = {}, {}
for fn in files:
    tree = ast.parse(open(os.path.join(d, fn), encoding='utf-8').read())
    rev = down = None
    for node in ast.walk(tree):
        if isinstance(node, (ast.Assign, ast.AnnAssign)):
            targets = node.targets if isinstance(node, ast.Assign) else [node.target]
            for t in targets:
                if isinstance(t, ast.Name) and t.id == 'revision' and isinstance(node.value, ast.Constant):
                    rev = node.value.value
                if isinstance(t, ast.Name) and t.id == 'down_revision':
                    v = node.value
                    down = v.value if isinstance(v, ast.Constant) else (
                        tuple(e.value for e in v.elts if isinstance(e, ast.Constant))
                        if isinstance(v, (ast.Tuple, ast.List)) else None)
    if rev: revs[rev] = fn; downs[rev] = down
referenced = set()
for d2 in downs.values():
    if d2 is None: continue
    referenced.update(d2) if isinstance(d2, tuple) else referenced.add(d2)
print('HEADS:', [r for r in revs if r not in referenced])

Phase 48A 新迁移(新增 brand_codes/brand_code_activations 两张表 + patients.service_org_id 列)应 down_revision 接到实施那一刻 origin/test 的某一个真实 head,且大概率需要先跑一次"合并多头"的 merge migration(down_revision = (head1, head2, head3) 元组写法,参照 34ec94466f0c 对应文件的既有写法——本次未逐字重新核对该文件内容,沿用上一版描述,未核实)才能继续往下接单头。


八、与并行开发线的避让

origin/test tip 即 96638b64(本次复核 sha),比上一版记录的 ca6530c0 更新(说明 origin/test 在两次复核之间确实又往前推进了,佐证第0节"活分支需实施前重查"的提醒)。"门店手机端视光档案适配"线(app/services/refractive_record_service.py 从 2915 行/628行等历史尺寸增长到本次核实的 3534 行)已经在 origin/test 历史中,本次核实的行号(4.2 六处)已经是把这条线合并进来之后的最新结果,不需要再单独避让——上一版"避让规则"针对的是"该线尚未合并"的场景,本次核实时该线已合并完毕,此节风险已随基线更新自动解除,仅保留一条提醒:

开工前必须重新 git log --oneline -5 origin/test -- app/services/refractive_record_service.py,确认落地时的 test 状态是否又有新的同文件改动叠加,行号可能再变。


九、风险与未决

  1. 公众号复访方案2 的轨道矛盾(第五节②已展开,本次复核未解决,与上一版结论一致):48A 扫码建的家长身份如果落在轨道B、而方案2按轨道A查找——两条轨道对不上。长官/tech阶段需二选一:
  2. 方案甲:48A 扫码进门时同时创建/合并一条 h5_user 记录,家长子席位只在真正要建档案时才按需现建
  3. 方案乙:方案2的复访检查逻辑改成只查轨道B(lookup_seat_username_by_wx 已经这么查了)
    本文档倾向方案甲,但这是需要长官拍板的技术选型
  4. 48A 是否该复用既有 /api/h5/oauth/exchange 而非新开公开端点(本次复核新增的风险项,见 4.1 末尾说明):现成端点已经做到"OAuth code → h5_user JWT,零密码窗",48A 缺的只是品牌码验签 + 落库 + 自动开通家长子席位三段业务逻辑;究竟包一层现成端点还是平行新开,需长官/tech阶段判断,本文档暂维持"新开"但未定论
  5. patients.doctor_id 取值(第四节4.3):品牌机构扫码建档时该传哪个 doctor_id,需长官定
  6. 公众号菜单链接是否需要改:本次复核未涉及公众号菜单配置本身,未核实
  7. 微信授权域名配置:WECHAT_OFFICIAL_APPID/WECHAT_OFFICIAL_APPSECRET 对应公众号的"网页授权域名"白名单是否已包含品牌码落地页域名,本次未核实,需实施前在微信公众平台后台确认
  8. 品牌方能否编辑档案的写权限边界:spec 规则18"能看=能改"已拍板,但 PATCH 端点层面是否已统一走"能看即放行",本文档第四节只列了读路径(4.2),写路径未逐一复核,实施时需要补一轮同样的 grep
  9. 本次复核未覆盖的既有引用,标注"未核实",实施前必须重查:app/models/user.py:94/99(seat_bound_unionid/seat_bound_openid)、web/app/h5/chat/lib/wx-oauth.ts:36(startSeatWxOAuth)、web/app/h5/chat/page.tsx 998/1000 行区间(setH5UserJwt/setToken 精确行号)、web/app/h5/tablet/page.tsx 296 行区间精确行号、34ec94466f0c 对应迁移文件内容、OrgBrainConfig.enable_brand_code 新增开关的字段顺序位置

十、实施拆批建议

(与上一版一致,未改动,批次划分是设计产物不涉及代码事实核验;批4的验收描述因 4.1 设计重写需同步更新。)

批次 做什么 怎么验收 依赖
批1 数据层:brand_codes/brand_code_activations 两张表 + patients.service_org_id 列的 alembic 迁移(含第七节的现查 head + 合并多头) alembic upgrade head 无报错;\d brand_codes \d brand_code_activations 字段/索引齐;\d patients 新列存在 无
批2 后端:brand_code_service.py(4.4)+ /api/public/brand-code/activate(3.3)+ /api/admin/orgs/{id}/brand-code* 四个管理端点 真跑:用测试用的微信 sandbox code 或 mock exchange_code 走一遍激活流程,brand_code_activations 表落一行;PATCH 停用/启用真查 is_enabled 翻转 批1
批3 可见性改写:第四节4.2 六处(142/370/486/527/561/615)refractive_record_service.py 改动 + bind_patient_seat 补写 service_org_id 三组测试数据(未认领/已认领/曾认领已解绑)分别用品牌方 org 和服务机构 org 的身份查询,验证可见范围符合规则7/10表格 批1
批4 方案2 兼容:4.1 seat_wx_identity 改动(在已有 lookup_seat_username_by_wx 结果之上追加 h5_user 分支)+ 前端 chat/tablet 两处分支 用一个"轨道A"测试账号(先手工插入一条 h5_user 记录)走老链接 ?login=seat,验证不弹密码窗、直接进家长端;对照组:一个完全陌生的 unionid 走同一入口仍应弹密码窗 批2(且需先解决第九节风险1、风险2的选型)
批5 前端:brand-scan 落地页 + PC 品牌码管理整页 + 机构管理列表按钮(新位置约 :1182 附近,见 3.4) Chrome/Playwright 真渲染扫码落地页 + 管理整页两个页面,截图核对与效果图一致 批2、批3
批6 导出对账 xlsx 真下载文件,用 openpyxl 打开核对表头/日期格式/不含金额列 批2

验证附录

A. 文件不存在性核实(第三节"新建文件",本次全部按 origin/test 重跑)

$ for f in app/models/brand_code.py app/api/brand_code_public.py app/api/admin_brand_code.py \
  app/services/brand_code_service.py "web/app/h5/brand-scan/page.tsx" \
  "web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx" \
  web/app/h5/chat/lib/brand-scan.ts migrations/versions/phase48a_brand_code_activation.py; do
  echo "$f -> $(git ls-tree -r --name-only origin/test -- "$f" | wc -l)"; done
app/models/brand_code.py -> 0
app/api/brand_code_public.py -> 0
app/api/admin_brand_code.py -> 0
app/services/brand_code_service.py -> 0
web/app/h5/brand-scan/page.tsx -> 0
web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx -> 0
web/app/h5/chat/lib/brand-scan.ts -> 0
migrations/versions/phase48a_brand_code_activation.py -> 0

对照组(已知存在文件,证明查法有效):

$ git ls-tree -r --name-only origin/test -- app/api/qr.py app/api/refractive_records.py | wc -l
2

B. 关键函数/端点存在性 + 行号核实(本次全部用 git show origin/test:<file> 重跑)

$ git rev-parse origin/test
96638b642085a47cb10648da020673653566aa6e

$ git ls-tree -r --name-only origin/test -- app/api/store_subseat.py app/api/admin_parent_seats.py | wc -l
2
$ git show origin/test:app/api/store_subseat.py | grep -n "@router.post(\"/provision\")"
65:@router.post("/provision")
$ git show origin/test:app/api/admin_parent_seats.py | grep -n "@router.post(\"/provision\")"
94:@router.post("/provision")

$ git show origin/test:app/auth/middleware.py | grep -n "async def require_h5_user\|async def require_org_seat\|async def require_store_seat"
157:async def require_h5_user(
322:async def require_org_seat(
398:async def require_store_seat(seat: dict = Depends(require_org_seat)) -> dict:

$ git show origin/test:app/services/seat_service.py | grep -n "^async def generate_seats\|^async def lookup_seat_username_by_wx"
164:async def generate_seats(
600:async def lookup_seat_username_by_wx(

$ wc -l app/services/refractive_record_service.py  # (git show origin/test: 版本)
3534

$ git show origin/test:app/services/refractive_record_service.py | grep -n "org_id =="
71:                OrgBrainConfig.org_id == org_id
100:        User.linked_org_id == org_id,
142:    stmt = select(Patient).where(or_(Patient.org_id == org_id, Patient.is_sample.is_(True)))
370:                Patient.org_id == org_id,
486:            select(Patient).where(Patient.id == patient_id, Patient.org_id == org_id)
499:                User.linked_org_id == org_id,
527:            select(Patient).where(Patient.id == patient_id, Patient.org_id == org_id)
561:        Patient.id == patient_id, or_(Patient.org_id == org_id, Patient.is_sample.is_(True))
615:                    or_(Patient.org_id == org_id, Patient.is_sample.is_(True)),

$ git show origin/test:app/models/flywheel.py | sed -n '16,56p'   # org_id nullable=True / doctor_id nullable=False / owner_type / owner_seat_id 无FK,已贴在 2.5

$ git show origin/test:app/services/refractive_record_service.py | sed -n '475,536p'  # bind_patient_seat/unbind_patient_seat 全文,已贴在 2.5

$ git show origin/test:app/api/parent_patients.py | grep -n "create_patient\|seat_type\|owner_seat_id" | head -10
69:    if seat.get("seat_type") != "parent":
337:    result = await svc.create_patient(
347:        owner_seat_id=seat["user_id"],

$ git show origin/test:app/api/seat_auth.py | grep -n "async def seat_wx_identity\|class WxIdentityResponse\|seat_username: str"
126:class WxIdentityResponse(BaseModel):
131:    seat_username: str | None = None
459:async def seat_wx_identity(

$ git show origin/test:app/api/h5_user_auth.py | sed -n '1,20p'   # /api/h5/oauth/exchange 定义与用途,已贴在 2.1

$ git ls-tree -r --name-only origin/test -- app/api/internal_settlement.py | wc -l
1
$ git ls-tree -r --name-only origin/test -- app/services/internal_settlement.py | wc -l
0
# 订正:上一版/seat_service.py 注释里写"internal_settlement.py:566/659"未带路径,
# 实际路径是 app/api/internal_settlement.py,不是 app/services/ 下

$ git show origin/test:app/services/org_service.py | grep -n "async def generate_access_link\|doctor_id or 1\|orglink-"
30:    `org.doctor_id or 1` 的隐式兜底 · 让绑定在创建时就显式确定)。
89:    # (去掉对运行时 `org.doctor_id or 1` 兜底的依赖 · 创建即显式确定绑定)
314:async def generate_access_link(
363:        "jti": f"orglink-{org_id}",

$ git archive origin/test -- app | tar -x -C /tmp/test_migrations
$ grep -rn "doctor_id or 1" /tmp/test_migrations/app | wc -l
7
$ grep -rln "exchange_code" /tmp/test_migrations/app --include=*.py | wc -l   # 对照组,非空证明查法有效
5

$ git show origin/test:app/api/admin_orgs_access_link.py | grep -n "@router.post(\"/{org_id}/access-link\""
76:@router.post("/{org_id}/access-link", response_model=AccessLinkResponse)
$ git show origin/test:app/api/temp_link.py | grep -n "@router.post(\"/temp-exchange\"\|external_user_id"
64:    external_user_id: str
82:@router.post("/temp-exchange", response_model=TempExchangeResponse)
144:        external_user_id=user_id,
155:            "external_user_id": user_id,
190:        external_user_id=user_id,
$ git show origin/test:app/api/qr.py | grep -n "@router.get(\"/qr\")\|async def generate_qr_png"
26:@router.get("/qr")
27:async def generate_qr_png(text: str, size: int = 320) -> Response:

$ git show origin/test:app/models/database.py | grep -n "enable_parent_subseat = Column\|enable_refractive_record = Column\|enable_4l_report = Column"
83:    enable_4l_report = Column(...)
113:    enable_refractive_record = Column(
120:    enable_parent_subseat = Column(

$ git show origin/test:app/models/audit.py | grep -n "^class AuthAuditLog\|^class LoginAttempt"
23:class AuthAuditLog(Base):
64:class LoginAttempt(Base):

$ git show origin/test:web/app/h5/chat/lib/auth.ts | grep -n "H5_JWT_STORAGE_KEY"
8:const H5_JWT_STORAGE_KEY = "h5_user_jwt";

$ git show origin/test:app/api/refractive_records.py | grep -n "@router.post(\"/patients/{patient_id}/bind\")\|@router.delete(\"/patients/{patient_id}/bind\")"
155:@router.post("/patients/{patient_id}/bind")
174:@router.delete("/patients/{patient_id}/bind")

$ git show origin/test:"web/app/(dashboard)/admin/orgs/page.tsx" | grep -n "访问链接"
742:  // 查看已有机构访问链接...
867:  // 查看访问链接...
1182:                        访问链接

对照组(已知存在的调用关系,证明 grep 查法有效):grep -rln "exchange_code" /tmp/test_migrations/app --include=*.py 命中 5 个文件(wechat_oauth_service.py 定义处 + seat_auth.py/h5_user_auth.py 等调用处),非零,与 doctor_id or 1 的 7 处一起互证查法未失效。

C. Alembic head 现查(AST 解析,origin/test sha 96638b64)

$ git archive origin/test -- migrations/versions | tar -x -C /tmp/test_migrations
$ ls /tmp/test_migrations/migrations/versions | wc -l
97
$ python3 <AST 解析脚本见第七节>
parsed_ok: 97 / 97
HEADS: ['34ec94466f0c', 'batch7a_read_receipt', 'phase79_001']

D. 订正对照表(本次修订新增 · 逐条列出原写法 → 订正后 → 证据命令)

# 位置 原写法(上一版,基线错误:main/worktree 而非 origin/test) 订正后(origin/test,sha 96638b64) 证据命令
1 文首基线声明 "当前 worktree(tip b282c143,即 main 当前状态)" 一律以 origin/test 为基线 git rev-parse origin/test
2 0节/4.2/附录C refractive_record_service.py 6处行号 142/370/470/511/545/599 142/370/486/527/561/615 git show origin/test:app/services/refractive_record_service.py \| grep -n "org_id =="
3 2.3/4.4 generate_seats 定义行 :156 :164 git show origin/test:app/services/seat_service.py \| grep -n "^async def generate_seats"
4 4.4 internal_settlement.py 路径 未写全路径,易误猜为 app/services/ 实际是 app/api/internal_settlement.py git ls-tree -r --name-only origin/test -- app/api/internal_settlement.py app/services/internal_settlement.py
5 2.6 generate_access_link 定义行 :312 :314 git show origin/test:app/services/org_service.py \| grep -n "async def generate_access_link"
6 2.6 external_user_id / jti 行号 "temp_link.py:108-109" 定义 :64,赋值 :144/:155/:190;jti 计算在 org_service.py:363,命名注释在 :355 git show origin/test:app/api/temp_link.py \| grep -n "external_user_id"
7 2.3 require_store_seat 定义行(附录B 曾写 :438) :438 :398 git show origin/test:app/auth/middleware.py \| grep -n "async def require_store_seat"
8 4.3 org.doctor_id or 1 兜底处数 "13 处" "7 处" grep -rn "doctor_id or 1" app/ \| wc -l(origin/test 归档后统计)
9 3.4 机构管理列表"访问链接"按钮位置 ":1167 附近" ":1182" git show origin/test:"web/app/(dashboard)/admin/orgs/page.tsx" \| grep -n "访问链接"
10 5节③ bind_patient_seat 端点装饰器行 "refractive_records.py:156" ":155" git show origin/test:app/api/refractive_records.py \| grep -n "@router.post(\"/patients/{patient_id}/bind\")"
11 5节③ bind_patient_seat 函数定义行 "refractive_record_service.py:459" ":475"(:459 实际是 seat_wx_identity 的行号,两处引用互相串了) git show origin/test:app/services/refractive_record_service.py \| grep -n "^async def bind_patient_seat"
12 4.1 设计前提 "lookup_seat_username_by_wx 调用是要新增的代码" 该调用已存在于 seat_wx_identity,产出 seat_username 字段仅用于登录框自动填账号,前端仍无条件弹密码窗;真正要新增的是"未命中时查 h5_user 并签发免密 JWT"这一段 git show origin/test:app/api/seat_auth.py \| sed -n '459,510p' + git show origin/test:web/app/h5/chat/page.tsx \| sed -n '895,1005p'
13 2.5 owner_seat_id 约束性质 "结构性硬事实,不是推断" owner_seat_id 无 FK 约束,"必须指向 org_seat+parent" 是 bind_patient_seat 校验 + parent_patients.py:69 调用方判断两处应用层校验叠加的效果,create_patient 本身对该参数零校验 git show origin/test:app/models/flywheel.py \| sed -n '48,56p' + git show origin/test:app/services/refractive_record_service.py \| sed -n '338,392p' + git show origin/test:app/api/parent_patients.py \| sed -n '65,72p'
14 二(未直接写错但本次补充) 未提及存在完全独立可用的 h5_user OAuth 端点 app/api/h5_user_auth.py POST /api/h5/oauth/exchange 已完整可用,直达 JWT 无密码窗,是 48A 潜在可复用资产(见第九节风险2) git show origin/test:app/api/h5_user_auth.py \| sed -n '1,60p'

自检摘要


本文档存放路径:/home/dev2/reports/phase48-brand-code-tech-20260916.md(正本)+ /home/dev2/reports/shared/phase48-brand-code-tech-20260916.md(团队共享出件箱副本)