日期:2026-09-16(本次修订:同日追加)| 状态:阶段 3 技术方案(需求已拍板,见 brand-partner-spec-v1-20260915.md)
本文档所有 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> 命令,不能直接套用本文档写作时刻锁定的行号。
目标:品牌方印刷固定二维码 → 家长扫码微信授权免注册进家长端(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 复核,可直接复用的部件)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 已按此订正重写。
web/app/h5/chat/lib/wx-oauth.ts:36 export async function startSeatWxOAuth(redirectUri: string)(origin/test 上文件路径与函数名核实存在,行号本次未逐字核对——未核实,实施前需重查)。品牌码落地页应照"前端发起+前端承接"结构写,不新起后端 callback。
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/api/store_subseat.py:65-66 POST /api/h5/store-subseat/provision:鉴权 require_store_seat(app/auth/middleware.py:398 async def require_store_seat(seat: dict = Depends(require_org_seat))——内部显式判断 if seat.get("seat_type") == "parent": raise 403,家长子席位一律 403,不是"没资格调用"而是"调用即被拒",比上一版"两者都不是"的推断更硬)。app/api/admin_parent_seats.py:94-95 POST /api/admin/parent-seats/provision:鉴权 require_role("admin")。两者最终都调用 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 必须新开一条公开端点。
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②),不是"待接入"的孤儿函数。
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' 的行"不是数据库物理约束,是两处应用层校验叠加起来的效果:
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——这条路径有校验。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 品牌方视角调用"分别说明。)
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。
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 字段。
app/models/audit.py:23 class AuthAuditLog(event_type 枚举 5 类,6 月留存)和 :64 class LoginAttempt(15 分钟窗口失败计数,7 天清)——本次逐行核实,行号与上一版一致。两张表语义(认证安全事件)和生命周期都不匹配"365天有效期+累计计数+对账导出",第三节的"开通记录表"必须新建。
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。)
表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 部分唯一索引兜底。
patients.service_org_id(Integer FK organizations.id ondelete SET NULL,nullable=True,无默认值)——patients.org_id 现状已是 nullable=True(本次已重新核实,见 2.5),新增列风格与之一致,无需 backfill。
| 方法 | 路径 | 入参 | 出参 | 权限 |
|---|---|---|---|---|
| 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 文件流 | 同上 |
web/app/h5/brand-scan/page.tsx(家长扫码落地页)web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx(PC 独立整页)web/app/(dashboard)/admin/orgs/page.tsx 现有"访问链接"按钮同一操作列区域(订正:上一版写":1167 附近",origin/test 上"访问链接"按钮文案实际位于 :1182)加一个状态相关按钮。origin/test 全面订正)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 目前语义是"公众号泛用登录",混入品牌码专属逻辑会让它承担过多职责),但这是可讨论的设计选型,不是唯一解,列入第九节风险。
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,内部同时用到两套判据)里通读一遍完整函数体再改,本文档未展开该函数全文,列为实施前必读项。
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"的业务规则,需长官确认。
不直接改 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 表开席位时的风险,两者不要混淆。
{host}/h5/brand-scan?bt={brand_token} → 前端跳转微信授权(redirect_uri 指回本页)?code= 跳回 → 前端 POST /api/public/brand-code/activate(3.3 新端点)brand_token → 查 brand_codes.is_enabled + OrgBrainConfig.enable_brand_code(双门)→ 调 exchange_code(code) 拿 unionid/openid → 调 lookup_seat_username_by_wx(2.4)查是否已有家长子席位brand_code_service(4.4 新函数)建新家长子席位 + 绑定微信身份brand_code_activations(去重约束防重复计数,3.1)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 并在其内部插入品牌码专属分支",不是本文档能单方面定的选型。)
?login=seat → seat_wx_identity(4.1 改动点)exchange_code 拿到 unionid → lookup_seat_username_by_wx 未命中(陌生家长确实没有门店发的席位)→ 查 users 表 role='h5_user' 且该 unionidlookup_seat_username_by_wx 调用之后,不是重新引入该调用——4.1 的代码块已按此重写。轨道矛盾风险仍然存在(若 48A 扫码进门给家长建的是轨道B而不是轨道A,这里仍查不到),见第九节风险1,未解决。patient_id + 家长子席位标识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)bind_patient_seat 目前只写 patient.owner_seat_id,规则7要求同时写 patient.service_org_id = org_idGET /api/admin/orgs/{org_id}/brand-code/exportbrand_code_activations WHERE brand_org_id=org_id,按日期筛选,聚合"开通个数"(COUNT(*),不联表算金额)(内容与上一版一致,均为设计产物,不涉及现网代码事实核验,未改动。)
UNIQUE(brand_org_id, wechat_unionid) + wechat_unionid IS NULL 时用 UNIQUE(brand_org_id, wechat_openid) 部分索引兜底。activated_at = 微信授权成功后端写库那一刻。expires_at = activated_at + 365天,写入时直接计算好存列。expires_at < now() 视为过期;过期后家长仍可登录,只是"当前有效"统计口径按 expires_at >= now() 过滤。brand_code_activations 里各插一行。⚠️ 本次复核确认的现状(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 状态是否又有新的同文件改动叠加,行号可能再变。
h5_user 记录,家长子席位只在真正要建档案时才按需现建lookup_seat_username_by_wx 已经这么查了)/api/h5/oauth/exchange 而非新开公开端点(本次复核新增的风险项,见 4.1 末尾说明):现成端点已经做到"OAuth code → h5_user JWT,零密码窗",48A 缺的只是品牌码验签 + 落库 + 自动开通家长子席位三段业务逻辑;究竟包一层现成端点还是平行新开,需长官/tech阶段判断,本文档暂维持"新开"但未定论patients.doctor_id 取值(第四节4.3):品牌机构扫码建档时该传哪个 doctor_id,需长官定WECHAT_OFFICIAL_APPID/WECHAT_OFFICIAL_APPSECRET 对应公众号的"网页授权域名"白名单是否已包含品牌码落地页域名,本次未核实,需实施前在微信公众平台后台确认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 |
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
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 处一起互证查法未失效。
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']
| # | 位置 | 原写法(上一版,基线错误: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' |
git rev-parse origin/test → 96638b642085a47cb10648da020673653566aa6e(本次唯一基线)git ls-tree -r --name-only origin/test -- <路径> | wc -l 均为 0;对照组 qr.py+refractive_records.py = 2(非零,证明查法有效)git show origin/test:<file> | grep -n 命中 ≥1(见附录B,无 0 命中项)refractive_record_service.py 的 142/370/486/527/561/615)用 sed -n 打出上下文核对,均与声明的函数归属一致(见 4.2 与本文写作过程中的现场核对)origin/test 归档,97/97 全部解析成功,heads 与上一版一致(本节唯一未变的大项)web/app/h5/chat/lib/wx-oauth.ts:36 精确内容、app/models/user.py:94/99、web/app/h5/chat/page.tsx 998/1000 精确行号、web/app/h5/tablet/page.tsx 296 区间精确行号、34ec94466f0c 迁移文件内容——均列入第九节风险7,实施前必须重查seat_wx_identity 改法)因发现代码现状与设计前提不符,改动方式必须重写(订正已完成);4.1 末尾新增一条未解决的选型风险(是否该复用现成 /api/h5/oauth/exchange),需长官/tech阶段裁决,本文档未擅自选边本文档存放路径:/home/dev2/reports/phase48-brand-code-tech-20260916.md(正本)+ /home/dev2/reports/shared/phase48-brand-code-tech-20260916.md(团队共享出件箱副本)