2026-09-24 深夜 · 对象:
docs/superpowers/plans/tech/phase83-batch5-login-overhaul.md(分支feature/subseat-login-v2)
自检后修订已提交a5d27dac(本地,未 push)。全部证据是脚本 / 命令的原始输出,附在文末,没有转述。
9-24 深夜复核更正(长官追问后逐条重核):初稿有 2 处说错 1 处说得不准,都已改——①「品牌码家长在家长子席位页显示待绑定」错:该页列表本就排除品牌码家长,已撤回;②「漏拦 4 个入口」错:实为 2 个(个人档案、近视的世界),抗近领航 / 谣言粉碎机家长看不到;③「新号回来第一次登录报错」不准:准确说是「下次需要重新登录时」(退出或登录过期),平时免登不会碰到。其余结论逐条重核成立。
环境标签:云机代码只读 grep + 生产 / test 库只读 SELECT(只出数量,无账号 / 手机号原文)。不是业务验收,业务验收要去 test 环境。
| # | 维度 | 做法 | 结果 |
|---|---|---|---|
| D0 | 基线 | git log / merge-base | 分支 HEAD b6577372,基线 e6770bb7,origin/test 498b8252 |
| D1 | 基线差异 × 方案文件 | base→test 变更文件 ∩ 方案 184 处引用的文件 | 11 个变更文件,与方案重叠 0(对照组:变更集非空) |
| D2 | 行号取证 | 脚本逐条读取 file:line 区间,用正则匹配预期内容 | 180/184 PASS;4 处 ±1(seat_service :347→:347-348、brand-code 页 :373→:374、:510 实为默认参数、:736-741 是 docstring 列举)——已改 |
| D3 | NEW 文件不存在 | git ls-files |
5 个新文件全 0(对照组 admin_brand_code.py = 1) |
| D4 | NEW 名字不撞名 | 全仓 grep | 10 个新名字全 0;multiple_accounts 3 处是复用找回密码现有结构(本就要复用);phone_not_found 1 处是无关测试函数名(对照组 must_change_password = 63) |
| D5 | 引用符号存在 | grep def / export | 26 个全部存在;另发现 generate_seats 同名端点 admin_seats.py:116 → 引出 D7 第 4 个门店开号入口 |
| D6 | 迁移链 | alembic heads 官方命令 |
3 个 head(34ec94466f0c / phase79_001 / phase83_001),phase83_001 是叶子,phase83_002 不存在 ✅(自写解析器误报第 4 个 head,已用官方命令纠正) |
| D7 | 入口穷举 | grep 所有调用方 | generate_seats 调用方比方案多 1 个(admin_seats.py:126,建门店号,不受前缀影响)——已补;登录 / 改密 / 找回 / 微信回跳 / 刷新调用方与方案一致 |
| D8 | must_change_password 全仓读者 | grep app + web | 席位侧只在登录响应用;发现结算清理按它判孤儿(见三-1) |
| D9 | 品牌码家长绕过入口 | grep router.push / location.href | 漏拦 2 个入口(见三-3;初稿误写 4 个,已更正) |
| D10 | 门店号 seat_type 为 NULL | 代码惯例 + 实查 | 代码惯例 or_(seat_type IS NULL, ='store');生产 / test 实查 NULL 均 0 个——已写明照惯例 |
| D11 | 测试清单 | 文件存在 + 用例数 | 9 个后端 + 6 个前端用例数全部一致(phase68 用 it.each,宽口径 7 ✅) |
| D12 | 口径一致性 | 方案文档 vs tech 关键词 | 一致;决策 7「不显示账号」:弹窗现在就不显示账号(全文件只有 :163 一处提到“账号”)——已注明 |
| D13 | 红线覆盖 | grep | 红线 2/3/4/5 有;红线 1 补上引用 |
| D14 | 数据事实(生产 / test) | seatops 只读通道 count | 见四,方案里的数字全部复核一致 |
| D15 | 线索追查 | 读代码上下文 | 发现「记住密码」冲突(见三-2) |
_purge_undelivered_seats(internal_settlement.py:381,条件 :396-400,调用 :536 / :692)会直接删除同时满足「must_change_password=True、last_login_at 为空、id 在水位以上」的席位。last_login_at:brand_code_service.py 里 grep 为 0 处,签发 token 那段代码见附录 L4。must_change_password=True 之后,正在用的品牌码家长就完全符合这个删除条件。:577/698/1643 都不传 seat_type),所以这个条件正好只覆盖结算自己建的号。seat-login-sheet.tsx:198、平板 :172)。下次打开时,本机记住的内容优先于微信带入(手机端 :90-108)。seat-change-password-sheet.tsx:95-99,代码注释写的就是「用户名不变故不动」)。孩子的个人档案(:3037)、近视的世界(:3053)两个入口家长看得到,原方案没拦,已补。复核更正:初稿写成「漏拦 4 个」,把抗近领航 :3143、谣言粉碎机 :3161 也算进去了——复核显示条件后发现它们在 isParentSeat ? … : … 的非家长分支(:3082 之后),家长看不到,不用拦。
不能改 lookup_seat_username_by_wx:它的 return_all=True 被找回密码候选列表(seat_auth.py:815)共用,改了会打坏候选列表(红线 3)。
后台「生成席位」admin_seats.py:126 原来没写进方案。它建的是门店号,不受前缀影响;新号照常 phone_login_enabled=True。
长官指出后复查:该页列表本来就排除了品牌码家长(parent_seat_service.py:103-108 显式 seat_source != "brand_code"),品牌码家长只在品牌码页管理。是我只看了状态判定、没看列表过滤,查漏了。
| 项 | 生产 | test | 方案里的说法 |
|---|---|---|---|
| 席位总数 | 173(家长 20 / 门店 153) | 303(家长 65 / 门店 238) | 173 / 303 ✅ |
| 账号带小写字母 | 门店 12 | 门店 18 | 12 / 18 ✅ |
| 账号长得像手机号 | 0 | 0 | 新补:手机号分支不会误伤任何现有账号 |
| 账号只差大小写的撞车 | 0 组 | 0 组 | 新补:转大写兜底不会串号 |
| seat_type 为 NULL | 0 | 0 | 新补 |
| 有手机号的席位 | 家长 6 / 门店 111 | 家长 7 / 门店 26 | — |
| 同一手机号挂 ≥2 个席位 | 2 组 | 2 组 | 都是老号,批次五上线后仍按账号登,不受影响 |
| 机构带 prefix 的列 / users 带 phone_login 的列 | 无 / 无 | 无 / 无 | 都是新增 ✅ |
| alembic 版本 | phase48a_002 | 3 个 head | #730-#733 要先上生产 ✅ |
| 手机号唯一索引 | h5 / parent / store 三个 | 同左 | 迁移必须手写 ✅ |
| 品牌码 / 开通记录 | 0 / 0 | 1 / 2 | 上线前生产不开品牌码 ✅ |
======== D0 基线 ========
b6577372 docs(phase83): 批次五 tech 开工前复审修订(前后端两路对抗审查 · 主会话逐条复核)
feature/subseat-login-v2
0
merge-base: e6770bb7 Merge branch 'refs/heads/fix/org-service-rumor-param' into 'refs/heads/test'. Code review title: docs(org-service): 标注 enable_rumor_buster 跨文件联动约束. Code review Link: https://codeup.aliyun.com/69f0e93a7b6e0a0129631e95/doctor-ai-brain/change/734
origin/test: 498b8252 Merge branch 'refs/heads/claude/objective-rosalind-51220c' into 'refs/heads/test'. Code review title: fix(chat): 流式生成器外层兜底归还请求级 DB 连接(异常/aclose/断开路径). Code review Link: https://codeup.aliyun.com/69f0e93a7b6e0a0129631e95/doctor-ai-brain/change/740
======== D1 基线差异 × tech 涉及文件(期望 0 重叠) ========
base→test 变更文件数: 11
与 tech 锚点文件重叠: 0 个
对照组(变更集里确有文件): ['app/api/h5_chat.py', 'app/services/brain_gateway.py', 'app/services/org_service.py']
======== D2 行号取证(tech 每条 file:line · PASS=该区间命中预期内容) ========
PASS migrations/versions/phase83_001_seat_seq_counter.py:24-24 | revision = "phase83_001"
PASS migrations/versions/phase74_001_parent_subseat_foundation.py:92-103 | "CREATE UNIQUE INDEX uq_users_phone_h5 ON users(phone) "
PASS app/models/user.py:33-33 | username = Column(String(64), unique=True, nullable=True, index=True)
PASS app/models/user.py:46-46 | phone = Column(String(20), unique=True, nullable=True, index=True)
PASS app/models/organization.py:11-59 | class Organization(Base):
PASS app/services/seat_service.py:129-170 | async def _gen_unique_username(org_id: int, db: AsyncSession) -> str:
PASS app/services/seat_service.py:186-210 | async def _alloc_seq_batch(
PASS app/services/seat_service.py:213-255 | async def _alloc_parent_usernames(db: AsyncSession, count: int) -> list[str]:
PASS app/services/seat_service.py:237-238 | await db.commit()
PASS app/services/seat_service.py:239-239 | candidates = [f"{seq:06d}{period}" for seq in seq_range]
PASS app/services/seat_service.py:261-261 | async def generate_seats(
PASS app/services/seat_service.py:305-317 | if seat_type == _PARENT_SEAT_KIND:
PASS app/services/seat_service.py:307-307 | parent_usernames = await _alloc_parent_usernames(db, count)
PASS app/services/seat_service.py:311-311 | try:
PASS app/services/seat_service.py:319-334 | must_change_password=True, # 功能2(7-07·v2):新席位首登强制改密(改 v1「无自助改密流」)
PASS app/services/seat_service.py:343-343 | except IntegrityError:
FAIL app/services/seat_service.py:347-347 rx=不回收|浪费 | 实际首行: # 回滚整批(本请求内尚未提交的席位全部作废,计数表已 commit 的号段允许被 | ±60 行命中 [348]
PASS app/services/seat_service.py:721-721 | async def lookup_seat_username_by_wx(
FAIL app/services/seat_service.py:736-741 rx=return None | 实际首行: 返回 None 的四种情形: | ±60 行命中 []
PASS app/services/seat_service.py:762-766 | if return_all:
PASS app/services/brand_code_service.py:94-100 | created = await seat_service.generate_seats(
PASS app/services/brand_code_service.py:110-110 | seat.must_change_password = False
PASS app/services/brand_code_service.py:88-90 | - must_change_password 显式置 False:本席位永远走微信免密登录,不存在"首登改密"
PASS app/services/brand_code_service.py:205-205 | seat = await _find_existing_family_seat(brand.org_id, unionid, openid, db)
PASS app/services/brand_code_service.py:240-248 | return {
PASS app/api/brand_code_public.py:30-36 | class ActivateResponse(BaseModel):
PASS app/api/admin_brand_code.py:41-41 | router = APIRouter(prefix="/api/admin/orgs", tags=["管理后台-机构-品牌码"])
PASS app/api/admin_brand_code.py:128-142 | class ActivationRecord(BaseModel):
PASS app/api/admin_brand_code.py:183-239 | async def _bulk_enrich_family_seats(seat_ids: list[int], db: AsyncSession) -> di
PASS app/api/admin_brand_code.py:306-346 | async def open_brand_code(
PASS app/api/admin_brand_code.py:349-361 | async def set_brand_code_status(
PASS app/api/admin_brand_code.py:423-436 | @router.get("/{org_id}/brand-code/activations", response_model=ActivationListRes
PASS app/api/admin_brand_code.py:670-681 | @router.get("/{org_id}/brand-code/export")
PASS app/api/admin_parent_seats.py:132-132 | raise HTTPException(status_code=404, detail="机构不存在")
PASS app/api/admin_parent_seats.py:134-134 | seats = await seat_service.generate_seats(
PASS app/api/admin_parent_seats.py:302-309 | @router.post("/{seat_id}/reset-password")
PASS app/services/parent_seat_service.py:280-289 | async def get_parent_seat_or_404(seat_id: int, db: AsyncSession) -> User:
PASS app/api/store_subseat.py:55-62 | status_code=status.HTTP_403_FORBIDDEN,
PASS app/api/store_subseat.py:79-79 | seats = await seat_service.generate_seats(
PASS app/api/api_v1.py:225-261 | class OrgItem(BaseModel):
PASS app/api/api_v1.py:288-289 | @router.get("/admin/orgs", response_model=List[OrgItem])
PASS app/api/h5_store_card.py:229-229 | @router.get("/store-card")
PASS app/main.py:49-49 | from app.api import annotations, community, wecom, auth, knowledge_b1, api_key_m
PASS app/main.py:233-233 | app.include_router(admin_brand_code.router, tags=["Phase 48A 批1 · admin 品牌码管理"])
PASS app/main.py:116-116 | _LOGIN_PATHS = {"/api/seat/login": "seat_login", "/api/auth/login": "auth_login"
PASS app/main.py:139-139 | error_type = f"{_LOGIN_PATHS[path]}_{exc.status_code}"
PASS app/main.py:163-164 | from app.services.alert_notifier import schedule_login_fail_alert
PASS app/services/alert_notifier.py:44-44 | _NOT_ALERTABLE: frozenset[tuple[str, str]] = frozenset({
PASS app/services/alert_notifier.py:56-56 | return (module, error_type) not in _NOT_ALERTABLE
PASS app/services/alert_notifier.py:84-98 | async def note_login_success(username: str | None) -> None:
PASS app/services/alert_notifier.py:111-111 | async def schedule_login_fail_alert(
PASS app/services/alert_notifier.py:338-338 | if not is_alertable(module, error_type):
PASS app/services/alert_notifier.py:342-342 | if is_deferred(module) and not _force:
PASS app/services/auth_service.py:139-150 | select(func.count()).select_from(LoginAttempt).where(
PASS app/services/auth_service.py:150-150 | if attempts >= COOLDOWN_THRESHOLD and user is not None:
PASS app/api/seat_auth.py:152-152 | async def seat_login(
PASS app/api/seat_auth.py:183-224 | ipaddress.ip_address(ip)
PASS app/api/seat_auth.py:228-235 | User.username == username,
PASS app/api/seat_auth.py:228-235 | ).scalar_one_or_none()
PASS app/api/seat_auth.py:231-231 | User.username == username,
PASS app/api/seat_auth.py:241-255 | if seat is None or not verify_password(body.password, seat.password_hash or ""):
PASS app/api/seat_auth.py:260-265 | # ── Step 3: is_active check ──
PASS app/api/seat_auth.py:267-284 | # ── Step 4: org_code 一致性(seat.linked_org_id 对应 org.org_code == 请求 org_code) ──
PASS app/api/seat_auth.py:299-304 | # ── Step 4.5: 机构人工停用 check(D1 org-expiry-removal:去掉机构级时间到期·只看 is_active 开关) ──
PASS app/api/seat_auth.py:306-312 | # ── Step 4.6: 席位级到期 check(seat_expires_at < now → 拒登录) ──
PASS app/api/seat_auth.py:314-330 | # 未带 wx_identity_token 一律 403 —— 前端 seat-login-hints 撞此 detail 特征串
PASS app/api/seat_auth.py:318-318 | if not body.wx_identity_token and (seat.seat_type or "store") == "parent":
PASS app/api/seat_auth.py:350-363 | detail="登录失败,请席位本人登录,感谢",
PASS app/api/seat_auth.py:369-384 | # 安全硬化:已绑微信的席位必须带 wx_identity_token · 防绕过 openid 绑定。
PASS app/api/seat_auth.py:386-396 | # ── Step 5+6: 写 current_device_id(仅记录最近登录设备 · 多设备共存,不踢旧) + last_login_at ──
PASS app/api/seat_auth.py:398-418 | # ── Step 7: 签 token(带 device_id + device_epoch) + 写 login_attempt(success) + 审计
PASS app/api/seat_auth.py:400-400 | await note_login_success(username)
PASS app/api/seat_auth.py:420-457 | org_features = {
PASS app/api/seat_auth.py:454-454 | "must_change_password": bool(seat.must_change_password),
PASS app/api/seat_auth.py:463-537 | @router.post("/seat/wx-identity", response_model=WxIdentityResponse)
PASS app/api/seat_auth.py:490-490 | unionid = wx.get("unionid") # 公众号未绑开放平台时为 None · 正常
FAIL app/api/seat_auth.py:510-510 rx=return_all | 实际首行: seat_username = await lookup_seat_username_by_wx(unionid, openid, db) | ±60 行命中 []
PASS app/api/seat_auth.py:706-706 | async def seat_change_password(
PASS app/api/seat_auth.py:731-738 | if body.contact_phone is None:
PASS app/api/seat_auth.py:741-745 | seat = (
PASS app/api/seat_auth.py:750-750 | seat.must_change_password = False
PASS app/api/seat_auth.py:756-759 | raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该手机号已被其他账号使用")
PASS app/api/seat_auth.py:788-789 | @router.post("/seat/forgot-password")
PASS app/api/seat_auth.py:851-851 | seat.must_change_password = False
PASS app/api/seat_auth.py:870-870 | return {"username": seat.username}
PASS app/api/internal_settlement.py:577-577 | created = await generate_seats(
PASS app/api/internal_settlement.py:698-698 | created = await generate_seats(org_id, payload.seat_count, session, payload.vali
PASS app/api/internal_settlement.py:1643-1643 | created = await generate_seats(
PASS web/app/h5/chat/components/seat-login-sheet.tsx:97-97 | const wxSeat = getWxIdentitySeatUsername();
PASS web/app/h5/chat/components/seat-login-sheet.tsx:118-121 | if (isPhoneLikeUsername(username)) {
PASS web/app/h5/chat/components/seat-login-sheet.tsx:129-136 | body: JSON.stringify({
PASS web/app/h5/chat/components/seat-login-sheet.tsx:192-192 | setSeatToken(data.access_token);
PASS web/app/h5/chat/components/seat-login-sheet.tsx:201-201 | setWxIdentitySeatUsername(username.trim());
PASS web/app/h5/chat/components/seat-login-sheet.tsx:203-208 | if (data.must_change_password) {
PASS web/app/h5/chat/components/seat-login-sheet.tsx:222-224 | return <SeatChangePasswordSheet onSuccess={() => onSuccess(pendingOrgFeatures)}
PASS web/app/h5/chat/components/seat-login-sheet.tsx:300-300 | placeholder="席位账号"
PASS web/app/h5/chat/components/seat-login-sheet.tsx:328-332 | placeholder={
PASS web/app/h5/chat/components/seat-login-sheet.tsx:357-366 | {(error || cooldownLeft > 0) && (
PASS web/app/h5/chat/components/seat-login-sheet.tsx:401-410 | 忘记密码?点此自助重置
PASS web/app/h5/chat/components/seat-login-sheet.tsx:402-402 | {getWxIdentitySeatUsername() ? (
PASS web/app/h5/chat/components/seat-login-sheet.tsx:415-430 | setUsername(u);
PASS web/app/h5/tablet/components/seat-login.tsx:82-82 | const wxSeat = getWxIdentitySeatUsername();
PASS web/app/h5/tablet/components/seat-login.tsx:104-107 | if (isPhoneLikeUsername(username)) {
PASS web/app/h5/tablet/components/seat-login.tsx:115-121 | body: JSON.stringify({
PASS web/app/h5/tablet/components/seat-login.tsx:168-168 | setSeatToken(data.access_token);
PASS web/app/h5/tablet/components/seat-login.tsx:174-174 | setWxIdentitySeatUsername(username.trim());
PASS web/app/h5/tablet/components/seat-login.tsx:179-179 | if (data.must_change_password) {
PASS web/app/h5/tablet/components/seat-login.tsx:197-199 | return <SeatChangePasswordSheet onSuccess={() => onSuccess(pendingOrgFeatures)}
PASS web/app/h5/tablet/components/seat-login.tsx:272-272 | placeholder="席位账号"
PASS web/app/h5/tablet/components/seat-login.tsx:300-304 | placeholder={
PASS web/app/h5/tablet/components/seat-login.tsx:329-338 | {(error || cooldownLeft > 0) && (
PASS web/app/h5/tablet/components/seat-login.tsx:372-381 | 忘记密码?点此自助重置
PASS web/app/h5/tablet/components/seat-login.tsx:386-401 | setUsername(u);
PASS web/app/h5/chat/components/seat-forgot-password-sheet.tsx:23-23 | onSuccess: (username: string, newPassword: string) => void;
PASS web/app/h5/chat/components/seat-forgot-password-sheet.tsx:82-86 | if (resp.status === 409 && j?.detail?.code === "multiple_accounts") {
PASS web/app/h5/chat/components/seat-forgot-password-sheet.tsx:100-100 | const data = (await resp.json()) as { username?: string };
PASS web/app/h5/chat/components/seat-forgot-password-sheet.tsx:106-106 | onSuccess(data.username, pw1);
PASS web/app/h5/chat/components/seat-forgot-password-sheet.tsx:141-165 | {candidates.length > 0 && (
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:17-17 | import { authHeaders, getSeatType,
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:22-32 | interface SeatChangePasswordSheetProps {
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:36-36 | const isParent = getSeatType() === "parent";
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:37-37 | const phoneLabel = isParent ? "家长手机号" : "门店联系电话";
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:91-91 | setError(detail || "设置失败,请重试");
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:153-153 | <div className="mt-1 text-[12.5px] text-emerald-50/90">首次登录 · 请设置您的密码</div>
PASS web/app/h5/chat/components/seat-change-password-sheet.tsx:277-284 | <span className="font-semibold text-gray-700">立即失效</span>
PASS web/app/h5/chat/components/__tests__/seat-change-password-sheet.spec.tsx:93-95 | it("渲染门店联系电话输入框", () => {
PASS web/app/h5/chat/lib/wx-oauth.ts:71-91 | export function setWxIdentityToken(
PASS web/app/h5/chat/lib/auth.ts:9-19 | // Phase 46.8 · 席位密码登录:access_token + device_id 持久化 localStorage
PASS web/app/h5/chat/lib/auth.ts:555-555 | export function setSeatToken(token: string): void {
PASS web/app/h5/chat/lib/auth.ts:585-585 | export function clearSeatToken(): void {
PASS web/app/h5/chat/lib/auth.ts:682-682 | setSeatToken(data.access_token);
PASS web/app/h5/chat/lib/seat-login-hints.ts:13-14 | "席位账号不是手机号,请输入提供给您的10位数字账号";
PASS web/app/h5/chat/page.tsx:924-991 | setWxIdentityToken(
PASS web/app/h5/chat/page.tsx:975-980 | setWxIdentityToken(
PASS web/app/h5/chat/page.tsx:1179-1179 | setIsParentSeat(getSeatType() === "parent");
PASS web/app/h5/chat/page.tsx:1634-1634 | const handleStart4L = useCallback(() => {
PASS web/app/h5/chat/page.tsx:1970-1970 | const handleOpenHistory = useCallback(async () => {
PASS web/app/h5/chat/page.tsx:2012-2012 | const sendText = useCallback(async (rawText?: string) => {
PASS web/app/h5/chat/page.tsx:2670-2690 | handleStart4L();
PASS web/app/h5/chat/page.tsx:2720-2725 | setInputText(text);
PASS web/app/h5/chat/page.tsx:3019-3021 | onClick={handleOpenHistory}
PASS web/app/h5/chat/page.tsx:3087-3089 | onClick={() => router.push("/h5/store-refractive")}
PASS web/app/h5/chat/page.tsx:3108-3110 | onClick={handleStart4L}
PASS web/app/h5/chat/page.tsx:3122-3124 | onClick={handleOpenHistory}
PASS web/app/h5/chat/page.tsx:3261-3263 | if (e.key === "Enter") void sendText();
PASS web/app/h5/chat/page.tsx:3269-3270 | onClick={() => void sendText()}
PASS web/app/h5/chat/page.tsx:3378-3378 | <SeatLoginSheet
PASS web/app/h5/tablet/page.tsx:338-343 | setWxIdentityToken(
PASS web/app/h5/tablet/page.tsx:713-713 | <TabletSeatLogin
PASS web/app/h5/brand-scan/page.tsx:99-99 | router.replace("/h5/chat?login=seat");
PASS web/app/h5/store-refractive/page.tsx:509-509 | <SeatLoginSheet
PASS web/app/h5/store-refractive/[patientId]/page.tsx:730-730 | <SeatLoginSheet
PASS web/app/h5/store-refractive/[patientId]/record/new/page.tsx:2485-2485 | <SeatLoginSheet
PASS web/app/h5/store-refractive/[patientId]/record/[recordId]/page.tsx:1154-1154 | <SeatLoginSheet
PASS web/app/h5/store-refractive/_components/create-patient-sheet.tsx:133-133 | // 401 续期失败 / 403 → 父级(page.tsx)原地弹 <SeatLoginSheet>,本组件不直接处理跳转
PASS web/app/h5/family/[id]/exam/new/page.tsx:1347-1347 | <SeatLoginSheet orgCode={null} onSuccess={() => setAuthGateOpen(false)} />
PASS web/app/h5/family/[id]/exam/new/page.tsx:663-666 | if (!getSeatToken() || getSeatType() !== "parent") {
PASS web/app/h5/family/[id]/page.tsx:2516-2516 | router.push(`/h5/family/${pid}/exam/new`);
PASS web/app/h5/family/[id]/page.tsx:2863-2863 | router.push(`/h5/family/${pid}/exam/new?rid=${rid}`);
PASS web/app/h5/settings/components/ParentAccountCard.tsx:272-272 | collectPhone={false}
PASS web/app/h5/settings/components/SeatStoreCard.tsx:39-58 | interface StoreCardData {
PASS web/app/h5/settings/components/SeatStoreCard.tsx:95-95 | const r = await fetch(`${API_BASE}/api/h5/store-card`, { headers: authHeaders()
PASS web/app/h5/settings/components/SeatStoreCard.tsx:922-1003 | ? "开通席位"
PASS web/app/h5/settings/components/SeatStoreCard.tsx:1354-1358 | (text-[#9a4a00] bg-[#fff8ed] border-[#fde7c7])· 方案红线:详情页不放管控按钮 */}
PASS web/app/h5/settings/components/SeatStoreCard.tsx:1364-1385 | {busy ? "开通中…" : `确认开通 ${count} 个`}
PASS web/app/(dashboard)/admin/orgs/page.tsx:46-77 | interface OrgItem {
PASS web/app/(dashboard)/admin/orgs/page.tsx:121-121 | <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0" />
PASS web/app/(dashboard)/admin/orgs/page.tsx:1074-1091 | if (!brandCodeConfirm) return;
PASS web/app/(dashboard)/admin/orgs/page.tsx:1083-1083 | window.alert("开启品牌码失败,请稍后重试");
PASS web/app/(dashboard)/admin/orgs/page.tsx:1291-1365 | {/* 9-10 修回退:重新生成凭证/停用启用/删除机构收进「更多」下拉(fixed 定位·见 menuOpen) */}
PASS web/app/(dashboard)/admin/orgs/page.tsx:1368-1410 | {brandCodeConfirm.confirming ? "开启中…" : "确认开启"}
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:170-170 | const [provOrg, setProvOrg] = useState("");
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:177-177 | const [allOrgs, setAllOrgs] = useState<{ id: number; name: string }[]>([]);
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:334-334 | setCred({ seats: [{ username: b.username, password: b.password }], kind: kind ==
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:445-445 | setAllOrgs(rows.map((o: { id: number; name: string }) => ({ id: o.id, name: o.na
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:971-971 | <th className="px-4 py-3 font-medium">家长手机</th>
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:1035-1035 | <button onClick={() => setConfirmAction({ seat: s, kind: "reset" })} className="
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:1110-1110 | : { title: <>重置 <code className="font-mono text-blue-700">{seat.username}</code>
PASS web/app/(dashboard)/admin/parent-seats/page.tsx:1127-1216 | {/* 开通子席位弹窗 */}
PASS web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:358-378 | const handleToggleEnabled = async () => {
FAIL web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:373-373 rx=操作失败,请稍后重试 | 实际首行: if (resp.ok) await loadSummary(); | ±60 行命中 [374]
PASS web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:461-464 | disabled={!summary || toggling}
PASS web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:590-600 | <th className="px-6 py-4 font-medium">用户</th>
PASS web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:634-652 | 停用
合计 184 条:PASS 180 / FAIL 4
======== D3 NEW 声明 · 新文件必须不存在(git ls-files 期望 0) ========
migrations/versions/phase83_002_subseat_prefix_and_phone_login.py → 0
app/models/org_parent_seat_counter.py → 0
app/api/admin_org_subseat_prefix.py → 0
tests/integration/test_phase83_batch5_login.py → 0
web/app/h5/chat/components/__tests__/seat-forgot-password-sheet.spec.tsx → 0
对照组 已存在文件 app/api/admin_brand_code.py → 1
======== D4 NEW 名字 · 全仓代码 0 命中(撞名检查) ========
subseat_prefix 0
phone_login_enabled 0
org_parent_seat_counter 0
OrgParentSeatCounter 0
login_fail_kind 0
phone_not_found 1
bound_seats 0
login_phone 0
chosen_username 0
requireSeatPasswordSetup 0
subseat-prefix 0
multiple_accounts 3
对照组 must_change_password → 63
======== D5 符号存在(tech 引用的函数/类) ========
def generate_seats app/services/seat_service.py:261:async def generate_seats(
app/api/admin_seats.py:116:async def generate_seats(
def _alloc_parent_usernames app/services/seat_service.py:213:async def _alloc_parent_usernames(db: AsyncSession, count: int) -> list[str]:
def _alloc_seq_batch app/services/seat_service.py:186:async def _alloc_seq_batch(
def lookup_seat_username_by_wx app/services/seat_service.py:721:async def lookup_seat_username_by_wx(
def register_login_failure app/services/auth_service.py:112:async def register_login_failure(
def note_login_success app/services/alert_notifier.py:84:async def note_login_success(username: str | None) -> None:
def push_alert app/services/alert_notifier.py:326:async def push_alert(
def is_alertable app/services/alert_notifier.py:54:def is_alertable(module: str, error_type: str) -> bool:
_NOT_ALERTABLE app/services/alert_notifier.py:44:_NOT_ALERTABLE: frozenset[tuple[str, str]] = frozenset({
app/services/alert_notifier.py:56: return (module, error_type) not in _NOT_ALERTABLE
def get_parent_seat_or_404 app/services/parent_seat_service.py:280:async def get_parent_seat_or_404(seat_id: int, db: AsyncSession) -> Us
def open_brand_code app/api/admin_brand_code.py:307:async def open_brand_code(
def set_brand_code_status app/api/admin_brand_code.py:350:async def set_brand_code_status(
def activate_brand_code app/services/brand_code_service.py:171:async def activate_brand_code(brand_token: str, code: str, db: AsyncSes
def list_orgs app/api/api_v1.py:289:async def list_orgs(
app/api/internal_settlement.py:1145:async def list_orgs_for_mobile(
class OrgItem app/api/api_v1.py:225:class OrgItem(BaseModel):
_PARENT_SEAT_KIND = app/services/seat_service.py:176:_PARENT_SEAT_KIND = "parent"
export function setWxIdentityToken web/app/h5/chat/lib/wx-oauth.ts:71:export function setWxIdentityToken(
export function clearSeatToken web/app/h5/chat/lib/auth.ts:585:export function clearSeatToken(): void {
export function setSeatToken web/app/h5/chat/lib/auth.ts:555:export function setSeatToken(token: string): void {
export function isPhoneLikeUsername web/app/h5/chat/lib/seat-login-hints.ts:21:export function isPhoneLikeUsername(username: string): boolean {
export function getSeatType web/app/h5/chat/lib/auth.ts:620:export function getSeatType(): string {
export function SeatChangePasswordSheet web/app/h5/chat/components/seat-change-password-sheet.tsx:34:export function SeatChangePasswordSheet({ onSucce
export function SeatLoginSheet web/app/h5/chat/components/seat-login-sheet.tsx:52:export function SeatLoginSheet({ orgCode, kicked = false, o
function handleStart4L\|const handleStart4L web/app/h5/chat/page.tsx:1634: const handleStart4L = useCallback(() => {
const handleOpenHistory\|function handleOpenHistory web/app/h5/tablet/pane/panes/report-pane.tsx:408: const handleOpenHistory = useCallback(async () => {
web/app/h5/chat/page.tsx:1970: const handleOpenHistory = useCallback(async () => {
const sendText\|function sendText web/app/h5/tablet/pane/panes/chat-pane.tsx:585: const sendText = useCallback(async (rawText?: string) => {
web/app/h5/chat/page.tsx:2012: const sendText = useCallback(async (rawText?: string) => {
register_login_failure 签名:
112:async def register_login_failure(
113- user: Optional[User],
114- username: str,
115- ip: str,
116- user_agent: str,
main.py 130-145(error_type 拼接处能否拿到 request):
try:
path = request.url.path
if exc.status_code >= 500:
await log_incident(
module="http_5xx",
error_type=f"http_{exc.status_code}",
severity="error",
detail={"method": request.method, "path": path, "detail": str(exc.detail)[:160]},
)
elif path in _LOGIN_PATHS and exc.status_code in _LOGIN_FAIL_STATUSES:
raw_username = getattr(request.state, "login_username", None)
error_type = f"{_LOGIN_PATHS[path]}_{exc.status_code}"
detail = {
"username": _mask_username(raw_username),
"ip": request.client.host if request.client else None,
"reason": str(exc.detail)[:120],
}
# 9-07 长官令:带上机构 + 昵称,否则掩码用户名指向几十个人、告警无法落到具体的人。
# 端点只在「密码已验过」之后才挂这两个值,所以猜密码类失败自然不带。
======== D6 迁移链(head 必须 3 个且 phase83_001 是叶子) ========
迁移文件数: 103 | heads: ['34ec94466f0c', 'phase50_seat_wx_binding', 'phase79_001', 'phase83_001']
phase83_001 被谁接: 无(叶子)
phase83_002 已存在? False
======== D7 入口穷举(tech 列的入口是否全) ========
-- generate_seats( 调用方
app/services/seat_service.py:7:- generate_seats(org_id, count, db) → [{username, password(明文 · 仅此次)}]
app/services/seat_service.py:259:# 46.5 generate_seats(POST /api/admin/orgs/{id}/seats/generate)
app/services/brand_code_service.py:94: created = await seat_service.generate_seats(
app/api/admin_seats.py:126: seats = await seat_service.generate_seats(
app/api/admin_parent_seats.py:123: - 业务层复用 seat_service.generate_seats(seat_type='parent' 末位参),不另造轮子
app/api/admin_parent_seats.py:134: seats = await seat_service.generate_seats(
app/api/store_subseat.py:79: seats = await seat_service.generate_seats(
app/api/internal_settlement.py:577: created = await generate_seats(
app/api/internal_settlement.py:698: created = await generate_seats(org_id, payload.seat_count, session, payload.valid_days)
app/api/internal_settlement.py:1643: created = await generate_seats(
-- seat_type="parent" 建号处
app/services/brand_code_service.py:98: seat_type="parent",
app/api/admin_parent_seats.py:139: seat_type="parent",
app/api/store_subseat.py:84: seat_type="parent",
-- /api/seat/login 前端调用
web/app/h5/tablet/components/seat-login.tsx
web/app/h5/chat/components/seat-login-sheet.tsx
web/app/h5/chat/lib/auth.ts
-- <SeatLoginSheet 挂载
web/app/h5/store-refractive/[patientId]/page.tsx:730: <SeatLoginSheet
web/app/h5/store-refractive/[patientId]/record/[recordId]/page.tsx:1154: <SeatLoginSheet
web/app/h5/store-refractive/[patientId]/record/new/page.tsx:2485: <SeatLoginSheet
web/app/h5/store-refractive/_components/create-patient-sheet.tsx:133: // 401 续期失败 / 403 → 父级(page.tsx)原地弹 <SeatLoginSheet>,本组件不直接处理跳转
web/app/h5/store-refractive/page.tsx:509: <SeatLoginSheet
web/app/h5/family/[id]/exam/new/page.tsx:1347: <SeatLoginSheet orgCode={null} onSuccess={() => setAuthGateOpen(false)} />
web/app/h5/chat/page.tsx:3378: <SeatLoginSheet
-- setWxIdentityToken( 调用
web/app/h5/tablet/page.tsx:338: setWxIdentityToken(
web/app/h5/chat/page.tsx:975: setWxIdentityToken(
-- <SeatChangePasswordSheet 挂载
web/app/h5/settings/components/ParentAccountCard.tsx:271: <SeatChangePasswordSheet
web/app/h5/tablet/components/seat-login.tsx:198: return <SeatChangePasswordSheet onSuccess={() => onSuccess(pendingOrgFeatures)} />;
web/app/h5/chat/components/seat-login-sheet.tsx:223: return <SeatChangePasswordSheet onSuccess={() => onSuccess(pendingOrgFeatures)} />;
-- /api/seat/change-password 调用
web/app/h5/chat/components/seat-change-password-sheet.tsx:10:// 提交:POST /api/seat/change-password(authHeaders 带 seat Bearer){new_password}
web/app/h5/chat/components/seat-change-password-sheet.tsx:75: const resp = await fetch(`${API_BASE}/api/seat/change-password`, {
-- /api/seat/forgot-password 调用
web/app/h5/chat/components/seat-forgot-password-sheet.tsx:7:// 提交:POST /api/seat/forgot-password {wx_identity_token, new_password}(公开端点 · 无登录态)
web/app/h5/chat/components/seat-forgot-password-sheet.tsx:67: const resp = await fetch(`${API_BASE}/api/seat/forgot-password`, {
-- SeatForgotPasswordSheet 挂载
web/app/h5/tablet/components/seat-login.tsx:387: <SeatForgotPasswordSheet
web/app/h5/chat/components/seat-login-sheet.tsx:416: <SeatForgotPasswordSheet
-- clearSeatToken 调用文件数
10
-- setSeatToken( 调用
web/app/h5/tablet/components/seat-login.tsx:6:// 复用 lib(import·非复制):getSeatDeviceId/setSeatToken(auth) + getWxIdentityToken/getWxIdentityDisplay(wx-oauth)
web/app/h5/tablet/components/seat-login.tsx:168: setSeatToken(data.access_token);
web/app/h5/brand-scan/page.tsx:87: setSeatToken(data.access_token);
web/app/h5/chat/components/seat-login-sheet.tsx:14:// · 成功 → setSeatToken(access_token)(localStorage) → onSuccess(org_features) 关弹窗
web/app/h5/chat/components/seat-login-sheet.tsx:192: setSeatToken(data.access_token);
web/app/h5/chat/lib/auth.ts:682: setSeatToken(data.access_token);
-- brand-code/status 前端调用
web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:11: * - PATCH /api/admin/orgs/{org_id}/brand-code/status body {is_enabled} → 停用/启用
web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:368: const resp = await apiFetch(`/api/admin/orgs/${orgId}/brand-code/status`, {
-- 品牌码激活前端调用
web/app/h5/brand-scan/page.tsx:10:// → POST /api/public/brand-code/activate {brand_token, code} → 后端认人/建号/开通记录
web/app/h5/brand-scan/page.tsx:69: const resp = await fetch(`${apiBase}/api/public/brand-code/activate`, {
======== D8 must_change_password 全仓读者(品牌码家长改为 True 后会不会在别处被拦) ========
app/services/h5_user_service.py:74: must_change_password=False,
app/services/h5_user_service.py:115: must_change_password=False,
app/services/user_mgmt_service.py:52: "must_change_password": u.must_change_password,
app/services/user_mgmt_service.py:126: 4. INSERT user · must_change_password=True · password_changed_at=NULL(强制首次改密)
app/services/user_mgmt_service.py:150: must_change_password=True,
app/services/user_mgmt_service.py:265: """admin 重置 · 同 create_user · 但 user 已存在 · 生成新临时密码 · must_change_password=Tr
app/services/user_mgmt_service.py:278: user.must_change_password = True
app/services/seat_service.py:94: "must_change_password": u.must_change_password,
app/services/seat_service.py:326: must_change_password=True, # 功能2(7-07·v2):新席位首登强制改密(改 v1「无自助改密�
app/services/seat_service.py:466: seat.must_change_password = True
app/services/seat_service.py:505: seat.must_change_password = True
app/services/brand_code_service.py:88: - must_change_password 显式置 False:本席位永远走微信免密登录,不存在"首登改密"
app/services/brand_code_service.py:110: seat.must_change_password = False
app/services/auth_service.py:15:- login(username, password, ip, user_agent, db) → {access_token, refresh_token, must_change_password}
app/services/auth_service.py:201: 返回: {access_token, refresh_token, must_change_password}
app/services/auth_service.py:228: must_change = bool(user.must_change_password)
app/services/auth_service.py:264: "must_change_password": must_change,
app/services/auth_service.py:401: 5. 写 password_history + 更新 user · 写 audit + 清 must_change_password
app/services/auth_service.py:453: user.must_change_password = False
app/models/user.py:74: must_change_password = Column(
app/api/auth.py:36: must_change_password: bool
app/api/admin_seats.py:72: must_change_password: bool = False
app/api/me_router.py:38: must_change_password: bool
app/api/me_router.py:86: must_change_password=bool(user.must_change_password),
app/api/seat_auth.py:106: must_change_password: bool = False
app/api/seat_auth.py:454: "must_change_password": bool(seat.must_change_password),
app/api/seat_auth.py:702:# 长官定:只改密码(不改用户名)· 密码「6 位数字即可」· 改完清 must_change_password
app/api/seat_auth.py:717: 3. 更新 password_hash(argon2) + must_change_password=False + password_changed_at=now + phone
app/api/seat_auth.py:750: seat.must_change_password = False
app/api/seat_auth.py:851: seat.must_change_password = False
app/api/internal_settlement.py:109: must_change_password=True,首次登录仍强制改密。
app/api/internal_settlement.py:215: must_change_password=True,
app/api/internal_settlement.py:250: "must_change_password": True,
app/api/internal_settlement.py:287: account.must_change_password = False
app/api/internal_settlement.py:293: "must_change_password": False,
app/api/internal_settlement.py:299: account.must_change_password = True
app/api/internal_settlement.py:306: "must_change_password": True,
app/api/internal_settlement.py:387: must_change_password=True AND last_login_at IS NULL 的孤儿,绝不误删在用席位;
app/api/internal_settlement.py:399: User.must_change_password == True, # noqa: E712
app/api/admin_users.py:43: must_change_password: bool
app/api/admin_users.py:191: """D10 重置临时密码 · must_change_password=True · 返新明文一次性"""
-- 前端:
web/app/channel/minicup/_components/channel-h5-shell.tsx:33: must_change_password: boolean;
web/app/channel/minicup/_components/channel-h5-shell.tsx:353: if (data.role !== "channel" || !data.channel_id || data.must_change_password) {
web/app/channel/minicup/_components/channel-h5-shell.tsx:415: if (data.must_change_password) {
web/app/(dashboard)/admin/users/[id]/page.tsx:37: must_change_password: boolean;
web/app/(dashboard)/admin/users/page.tsx:30: must_change_password: boolean;
web/app/(dashboard)/admin/users/page.tsx:184: {u.must_change_password && (
web/app/(dashboard)/admin/users/page.tsx:205: <StatusBadge locked={locked} mustChange={u.must_change_password} isActive={u.is_activ
web/app/(dashboard)/admin/orgs/[id]/seats/page.tsx:92: must_change_password?: boolean;
web/app/(dashboard)/settings/account/page.tsx:19: must_change_password: boolean;
web/app/h5/tablet/components/seat-login.tsx:161: must_change_password?: boolean;
web/app/h5/tablet/components/seat-login.tsx:179: if (data.must_change_password) {
web/app/h5/chat/components/seat-login-sheet.tsx:181: must_change_password?: boolean;
web/app/h5/chat/components/seat-login-sheet.tsx:203: if (data.must_change_password) {
web/app/h5/chat/components/seat-change-password-sheet.tsx:3:// 触发:席位登录成功后 · 后端返 must_change_password=true → 由登录组件
web/app/login/page.tsx:13: * · must_change_password=true → router.push('/change-password')
web/app/login/page.tsx:128: must_change_password: boolean;
web/app/login/page.tsx:133: if (data.must_change_password) {
-- app/auth/ 下(期望 0): 0 | 对照组 app/auth/ 有 .py: 5
======== D9 品牌码家长绕过入口(菜单 / 其他页能不能不经 chat/page 拦截点就用功能) ========
284: router.push("/h5/settings");
-- chat/page.tsx 里 router.push 全部:
2678: router.push("/h5/find-similar");
3037: onClick={() => router.push("/h5/family")}
3089: onClick={() => router.push("/h5/store-refractive")}
3143: onClick={() => router.push("/h5/eye-nav")}
3161: onClick={() => router.push("/h5/rumor-buster")}
3338: router.push("/h5/settings");
======== D10 seat_type 为 NULL 的门店号(scene 过滤必须把 NULL 当门店) ========
123: seat_type = Column(
132: # 后台「全部来源」筛选就能区分两条开通路径。仅 seat_type='parent' 有业务意义。
app/services/seat_service.py:154: or_(User.seat_type.is_(None), User.seat_type == "store"),
app/services/seat_service.py:176:_PARENT_SEAT_KIND = "parent"
app/services/seat_service.py:236: seq_range = await _alloc_seq_batch(db, _PARENT_SEAT_KIND, remaining, period)
app/services/seat_service.py:306: if seat_type == _PARENT_SEAT_KIND:
app/services/seat_service.py:315: if seat_type == _PARENT_SEAT_KIND
app/services/seat_service.py:408: or_(User.seat_type.is_(None), User.seat_type == "store")
app/services/seat_service.py:570: or_(User.seat_type.is_(None), User.seat_type == "store")
-- tech 是否写了 NULL 当门店: 3
-- getSeatType 默认值:
/** 席位类型:'parent'=家长子席位 · 'store'/null=门店(默认 · 老会话无此键也归 store) */
export function getSeatType(): string {
if (typeof window === "undefined") return "store";
try {
return window.localStorage.getItem(SEAT_TYPE_STORAGE_KEY) || "store";
} catch {
return "store";
}
}
/** 家长可见模块名单(store 席位恒 [] · 门店端不消费此名单) */
export function getSeatVisibleFeatures(): string[] {
======== D11 测试清单(文件存在 + 用例数) ========
test_seat_login_e2e.py tech= 23 实测=23 ✅ tests/integration/test_seat_login_e2e.py
test_seat_forgot_password_e2e.py tech= 5 实测=5 ✅ tests/integration/test_seat_forgot_password_e2e.py
test_admin_brand_code.py tech= 15 实测=15 ✅ tests/integration/test_admin_brand_code.py
test_phase74_admin_parent_seats.py tech= 25 实测=25 ✅ tests/integration/test_phase74_admin_parent_seats.py
test_seat_expiry_e2e.py tech= 12 实测=12 ✅ tests/integration/test_seat_expiry_e2e.py
test_phase68_ip_throttle.py tech= 8 实测=8 ✅ tests/integration/test_phase68_ip_throttle.py
test_login_fail_grace.py tech= 7 实测=7 ✅ tests/test_login_fail_grace.py
test_alert_not_alertable_types.py tech= 5 实测=5 ✅ tests/test_alert_not_alertable_types.py
test_phase83_seat_seq_counter.py tech= 8 实测=8 ✅ tests/unit/test_phase83_seat_seq_counter.py
seat-login-sheet-wx.spec.tsx tech= 9 实测=9 ✅ web/app/h5/chat/components/__tests__/seat-login-sheet-wx.spec.tsx
phase68-seat-login.spec.tsx tech= 7 实测=0 ❌ web/app/h5/tablet/components/__tests__/phase68-seat-login.spec.tsx
seat-change-password-sheet.spec.tsx tech= 12 实测=12 ✅ web/app/h5/chat/components/__tests__/seat-change-password-sheet.spec.tsx
wx-oauth.spec.ts tech= 10 实测=10 ✅ web/app/h5/chat/lib/__tests__/wx-oauth.spec.ts
seat-login-sheet-phone-hint.spec.tsx tech= 2 实测=2 ✅ web/app/h5/chat/components/__tests__/seat-login-sheet-phone-hint.spec.tsx
tablet seat-login.spec.tsx tech=8 实测= 8 web/app/h5/tablet/components/__tests__/seat-login.spec.tsx
seat-login-hints.spec.ts:198 → expect(PHONE_LIKE_USERNAME_MESSAGE).toBe("席位账号不是手机号,请输入提供给您的10位数字账号"); web/app/h5/chat/lib/__tests__/seat-login-hints.spec.ts
admin-orgs-*.test.tsx 3 个文件
admin-parent-seats-*.test.tsx 2 个文件
admin-org-brand-code-seat-actions.test.tsx 1 个文件
seat-store-card-branding.test.tsx 1 个文件
verify.sh chat-e2e 存在: 9 | 触发清单含 chat/page.tsx: 1
======== D12 口径一致性(方案文档 §十三 vs tech) ========
100001 方案文档 2 | tech 3
1-6|1~6 方案文档 1 | tech 1
门店版 方案文档 4 | tech 3
统一叫「手机号」 方案文档 2 | tech 1
不显示账号|不再显示账号 方案文档 1 | tech 0
短信 方案文档 9 | tech 1
换绑 方案文档 3 | tech 1
请席位本人登录 方案文档 1 | tech 0
只认手机号 方案文档 2 | tech 2
只认账号 方案文档 1 | tech 1
tech 引用的决策号: [1, 2, 3, 6, 10, 11, 12, 13, 14, 15, 16]
方案文档表格行号: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
方案文档有、tech 没引用: [4, 5, 7, 8, 9]
======== D13 红线覆盖(tech 引用了哪些红线) ========
tech 提到的红线号: [2, 3, 4, 5]
======== D14 数据事实 · test 库(db-query test · 只读) ========
relname | n_live_tup
------------------------+------------
brand_code_activations | 2
brand_codes | 1
organizations | 88
seat_seq_counter | 1
users | 388
(5 rows)
(注:结果最多 1000 行 · 超出请加更严格 WHERE / LIMIT)
org_seat_rows
---------------
0
(1 row)
(注:结果最多 1000 行 · 超出请加更严格 WHERE / LIMIT)
column_name
-------------
(0 rows)
(注:结果最多 1000 行 · 超出请加更严格 WHERE / LIMIT)
indexname
-----------------------
uq_users_phone_h5
uq_users_phone_store
uq_users_phone_parent
(3 rows)
(注:结果最多 1000 行 · 超出请加更严格 WHERE / LIMIT)
-- 品牌码相关表名(models):
app/models/brand_code.py:31: __tablename__ = "brand_codes"
app/models/brand_code.py:78: __tablename__ = "brand_code_activations"
[stderr] grep: app/models/__pycache__/share_ai.cpython-310.pyc: binary file matches
======== D15 追查线索 ========
-- alembic heads(官方命令 · 只读脚本目录):
34ec94466f0c (head)
phase79_001 (head)
phase83_001 (head)
-- phase50_seat_wx_binding 的 revision 声明与谁接它:
migrations/versions/phase50_seat_expiry.py:4:Revises: phase50_seat_wx_binding
migrations/versions/phase50_seat_expiry.py:11:down_revision = phase50_seat_wx_binding(dev/test 链 phase50 都是 head · 单头延续)。
migrations/versions/phase50_seat_expiry.py:20:down_revision = "phase50_seat_wx_binding"
migrations/versions/phase50_seat_wx_binding.py:3:Revision ID: phase50_seat_wx_binding
migrations/versions/phase50_seat_wx_binding.py:28:revision = "phase50_seat_wx_binding"
-- auth.ts 调 /api/seat/login 的上下文:
21:const SEAT_REMEMBER_USERNAME_KEY = "seat_remember_username";
22:const SEAT_REMEMBER_PASSWORD_KEY = "seat_remember_password";
262: * (席位登录无 `?t=` temp-exchange · 开关改由 /api/seat/login 响应下发 · 镜像 temp_link)
674: const resp = await fetch(`${API_BASE}/api/seat/refresh`, {
724: window.localStorage.setItem(SEAT_REMEMBER_USERNAME_KEY, username);
725: window.localStorage.setItem(SEAT_REMEMBER_PASSWORD_KEY, password);
736: username: window.localStorage.getItem(SEAT_REMEMBER_USERNAME_KEY) ?? "",
737: password: window.localStorage.getItem(SEAT_REMEMBER_PASSWORD_KEY) ?? "",
748: window.localStorage.removeItem(SEAT_REMEMBER_USERNAME_KEY);
749: window.localStorage.removeItem(SEAT_REMEMBER_PASSWORD_KEY);
try {
return {
nickname: window.localStorage.getItem(SEAT_DISPLAY_NICKNAME_KEY),
avatar: window.localStorage.getItem(SEAT_DISPLAY_AVATAR_KEY),
};
} catch {
return { nickname: null, avatar: null };
}
}
/** 用 refresh_token 静默续 access(access 30min 过期时调) · 实现"7天免登"。
*
* 成功(refresh 有效 + 机构/席位未到期)→ 写新 access → 返 true(调用方带新 token 重试原请求)
* 失败(无 refresh / refresh 过期401 / 机构到期403 / 单设备踢401)→ 返 false(调用方走重登录 or 过期门)
*/
export async function refreshSeatToken(): Promise<boolean> {
const refresh = getSeatRefreshToken();
if (!refresh) return false;
try {
const resp = await fetch(`${API_BASE}/api/seat/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
});
if (!resp.ok) return false; // 401=refresh失效/被踢 · 403=机构/席位到期 → 续期失败
const data = await resp.json();
if (!data?.access_token) return false;
setSeatToken(data.access_token);
return true;
} catch {
return false; // 网络抖动 → 当续期失败 · 调用方兜底
}
}
/** 获取/生成本设备 device_id(localStorage UUID · 首访生成 · 后续复用)
*
* 单设备被动踢:device_id 是"这台设备"的稳定标识 · 同一浏览器多次登录复用同一 id ·
* 换浏览器/清缓存 = 新设备(新 id) → 后端 current_device_id 覆盖 → 旧设备被踢。
*/
export function getSeatDeviceId(): string {
if (typeof window === "undefined") return "";
try {
let id = window.localStorage.getItem(SEAT_DEVICE_ID_KEY);
if (!id) {
id =
typeof crypto !== "undefined" && crypto.randomUUID
-- auth.ts 导出的登录相关函数:
565:export function getSeatRefreshToken(): string | null {
575:export function setSeatRefreshToken(token: string): void {
670:export async function refreshSeatToken(): Promise<boolean> {
721:export function setRememberedSeatCredentials(username: string, password: string): void {
732:export function getRememberedSeatCredentials(): { username: string; password: string } {
745:export function clearRememberedSeatCredentials(): void {
-- phone_not_found 现有命中:
tests/test_miniapp_profile.py:90:async def test_unbind_phone_not_found():
-- admin_seats.py 生成接口(第 4 个门店开号入口?):
# ============================================================================
@router.post(
"/admin/orgs/{org_id}/seats/generate", response_model=SeatGenerateResponse
)
async def generate_seats(
org_id: int,
body: SeatGenerateRequest,
admin: dict = Depends(require_role("admin")),
db: AsyncSession = Depends(get_db),
):
"""批量生成 N 个席位 · 返明文凭证列表(仅此次 · 前端 Blob 下 CSV)
valid_days 留空=永久不到期 · 传则各席位 seat_expires_at = now + days。
"""
seats = await seat_service.generate_seats(
org_id=org_id,
count=body.count,
db=db,
valid_days=body.valid_days,
experience_store_name=body.experience_store_name,
)
-- 记住密码(remember)在登录框:
web/app/h5/chat/components/seat-login-sheet.tsx:32: getRememberedSeatCredentials,
web/app/h5/chat/components/seat-login-sheet.tsx:33: setRememberedSeatCredentials,
web/app/h5/chat/components/seat-login-sheet.tsx:34: clearRememberedSeatCredentials,
web/app/h5/chat/components/seat-login-sheet.tsx:91: const { username: ru, password: rp } = getRememberedSeatCredentials();
web/app/h5/chat/components/seat-login-sheet.tsx:159: if (hint.clearRemembered) {
web/app/h5/chat/components/seat-login-sheet.tsx:160: clearRememberedSeatCredentials();
web/app/h5/chat/components/seat-login-sheet.tsx:198: setRememberedSeatCredentials(username.trim(), password);
web/app/h5/tablet/components/seat-login.tsx:18: getRememberedSeatCredentials,
web/app/h5/tablet/components/seat-login.tsx:19: setRememberedSeatCredentials,
web/app/h5/tablet/components/seat-login.tsx:20: clearRememberedSeatCredentials,
web/app/h5/tablet/components/seat-login.tsx:76: const { username: ru, password: rp } = getRememberedSeatCredentials();
web/app/h5/tablet/components/seat-login.tsx:140: if (hint.clearRemembered) {
web/app/h5/tablet/components/seat-login.tsx:141: clearRememberedSeatCredentials();
web/app/h5/tablet/components/seat-login.tsx:172: setRememberedSeatCredentials(username.trim(), password);
== L1 清理函数调用方 / 同类判孤儿条件 ==
381:async def _purge_undelivered_seats(
399: User.must_change_password == True, # noqa: E712
400: User.last_login_at.is_(None),
536: await _purge_undelivered_seats(session, record.org_id, record.seat_id_watermark)
692: await _purge_undelivered_seats(session, org_id, record.seat_id_watermark)
== L2 internal_settlement.py:150-180 ==
# access_token / refresh_token —— 那是能代表用户身份继续调微信接口的凭据,
# 绝不透传给结算侧或前端。
# ────────────────────────────────────────────────────�
class WechatOAuthExchangePayload(BaseModel):
code: str = Field(min_length=1)
# 批 E-3:门店服务页进页面**静默**取 openid(snsapi_base),拿不到也不需要昵称头像。
# 默认 True 保持批 B 签约授权(snsapi_userinfo)行为不变。
with_userinfo: bool = True
@router.post("/wechat/oauth-exchange")
async def internal_wechat_oauth_exchange(payload: WechatOAuthExchangePayload) -> dict[str, Any]:
from app.services.wechat_oauth_service import exchange_code
code = payload.code.strip()
if not code:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="code 不能为空")
info = await exchange_code(code, with_userinfo=payload.with_userinfo)
return {
"openid": info.get("openid"),
"unionid": info.get("unionid"),
"nickname": info.get("nickname"),
"headimgurl": info.get("headimgurl"),
}
# ────────────────────────────────────────────────────�
# 2. POST /orgs/{org_id}/activate-provision —— 2026-07-30 已下线,整段删除。
# 出处:phase64-contract/REQUIREMENTS-v2.2.md:97-105「上线顺序」三步 +
# CORE-REPLY-FINAL.md:128-130 第 11 问「你们切换并通知后核心才下旧路径」承诺。
== L3 全仓按 last_login_at 为空判断的地方 ==
app/services/parent_seat_service.py:65: User.last_login_at.is_(None),
app/api/internal_settlement.py:387: must_change_password=True AND last_login_at IS NULL 的孤儿,绝不误删在用席位;
app/api/internal_settlement.py:400: User.last_login_at.is_(None),
== L4 品牌码激活签发 token 的两条路(老用户 / 新用户) ==
module="brand_code_activate",
error_type="brand_code_activate_no_openid",
detail={"raw": str(wx)[:200]},
)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="微信身份异常(缺 openid),请重试")
nickname = wx.get("nickname")
avatar = wx.get("headimgurl")
seat = await _find_existing_family_seat(brand.org_id, unionid, openid, db)
if seat is None:
seat = await _provision_family_seat(brand.org_id, unionid, openid, nickname, avatar, db)
else:
# 命中已有席位 → 刷新展示信息(同 seat_wx_identity"本人"分支的做法)
seat.wechat_nickname = nickname
seat.wechat_avatar = avatar
await db.commit()
await db.refresh(seat)
await _record_activation(brand.org_id, unionid, openid, nickname, avatar, seat.id, db)
access, refresh = _issue_seat_tokens(seat)
# org_features 下发(镜像 app/api/seat_auth.py:420-438 seat_login 同款字段,
# 供前端 applySeatOrgFeatures 消费 · 未重构 seat_auth.py 避免触碰红线函数)
cfg = await db.scalar(select(OrgBrainConfig).where(OrgBrainConfig.org_id == seat.linked_org_id))
org_features = {
"enable_4l_report": cfg.enable_4l_report if cfg else False,
"enable_find_similar": cfg.enable_find_similar if cfg else True,
"enable_anti_myopia_plan": cfg.enable_anti_myopia_plan if cfg else True,
"enable_eye_nav": cfg.enable_eye_nav if cfg else True,
"enable_rumor_buster": cfg.enable_rumor_buster if cfg else True,
"enable_myopia_world": cfg.enable_myopia_world if cfg else True,
"show_expiry_countdown": cfg.show_expiry_countdown if cfg else False,
"enable_user_registration": cfg.enable_user_registration if cfg else False,
"enable_refractive_record": cfg.enable_refractive_record if cfg else False,
"seat_expires_at": seat.seat_expires_at.isoformat() if seat.seat_expires_at else None,
}
# late import 防循环 import(seat_auth 顶部未 import 本模块 · 单向依赖)
from app.api.seat_auth import _parent_visible_features
visible_features = await _parent_visible_features(db)
return {
"access_token": access,
"refresh_token": refresh,
"nickname": nickname,
"avatar": avatar,
"seat_type": "parent",
"org_features": org_features,
"visible_features": visible_features,
}
== L5 登录框「记住密码」预填与写入 (手机端) ==
setCooldownLeft((s) => s - 1);
if (cooldownLeft === 1) setError(""); // 归零瞬间清掉冷却报错
}, 1000);
return () => clearTimeout(t);
}, [cooldownLeft]);
useEffect(() => {
const { username: ru, password: rp } = getRememberedSeatCredentials();
if (ru) {
// 本机记住的优先:能存下来说明这台设备上本人成功登录过,比微信反查更贴合"上次怎么用的"
setUsername(ru);
} else {
// 换设备时 localStorage 是空的 —— 此时凭微信身份把账号带出来,免手输 10 位数字
const wxSeat = getWxIdentitySeatUsername();
if (wxSeat) {
setUsername(wxSeat);
setAutoFilledFromWx(true);
}
}
if (rp) {
setPassword(rp);
prefilledPwRef.current = rp;
setHasPrefilledPw(true);
}
}, []);
const canSubmit =
username.trim().length > 0 && password.length > 0 && !loading && cooldownLeft === 0;
---
// Phase 68.5 · 判定交给两端共用的纯函数(seat-login-hints.ts):
// · 401 且提交的就是挂载时预填的那串 → 本地存的已作废 → 清 localStorage + 清空输入框
// · 其余情形维持改造前行为(detail 优先 · 403/422 各自兜底)
const hint = resolveLoginFailureHint({
status: resp.status,
detail,
submittedPassword: password,
prefilledPassword: prefilledPwRef.current,
});
if (hint.clearRemembered) {
clearRememberedSeatCredentials();
prefilledPwRef.current = "";
setHasPrefilledPw(false);
}
if (hint.clearPasswordInput) setPassword("");
// bug2 · 429 冷却:起倒计时(归零自动恢复) · 文案由下方渲染层实时刷新秒数
---
setLoading(false);
return;
}
// 成功:存 access_token(localStorage)→ authHeaders() 后续自动带 Bearer
setSeatToken(data.access_token);
// Phase 74 · 席位类型与名单跟 token 同时落(家长/门店同一登录窗 · 登录后分流)
setSeatProfile(data.seat_type, data.visible_features);
// ①7天免登:refresh_token 一起存 · access 30min 过期时静默续(refreshSeatToken)· 不再强制重登
setSeatRefreshToken(data.refresh_token);
// 记住凭证(用户名+密码)· 下次弹登录框自动填 · 免二次输入
setRememberedSeatCredentials(username.trim(), password);
// bug2 浮窗修 A:登录成功=绑定已就位 · 写回反查账号(首绑当场的温浮窗
// sessionStorage 还是"未绑"旧判定 · 不写回则忘记密码入口一直藏着)
setWxIdentitySeatUsername(username.trim());
// 功能2 · 首登需改密 → 弹改密窗(改完由改密窗回调进入)· 否则走原成功流
if (data.must_change_password) {
setPendingOrgFeatures(data.org_features);
setPendingChangePw(true);
setLoading(false);
return;
}
setSuccess(true);
// 成功瞬态后回调 page 关弹窗 + 进入对话(对齐 demo success 节奏) · 带 org_features 下发机构开关
setTimeout(() => {
onSuccess(data.org_features);
== L6 平板 记住密码 ==
if (cooldownLeft === 1) setError(""); // 归零瞬间清掉冷却报错
}, 1000);
return () => clearTimeout(t);
}, [cooldownLeft]);
useEffect(() => {
const { username: ru, password: rp } = getRememberedSeatCredentials();
if (ru) {
// 本机记住的优先:能存下来说明这台设备上本人成功登录过,比微信反查更贴合"上次怎么用的"
setUsername(ru);
} else {
// 换设备时 localStorage 是空的 —— 此时凭微信身份把账号带出来,免手输 10 位数字
const wxSeat = getWxIdentitySeatUsername();
if (wxSeat) {
setUsername(wxSeat);
setAutoFilledFromWx(true);
}
}
if (rp) {
setPassword(rp);
prefilledPwRef.current = rp;
== L7 hint.clearRemembered 来源 ==
43: clearRemembered: boolean;
69: clearRemembered: false,
88: clearRemembered: false,
100: clearRemembered: false,
116: clearRemembered: true,
123: clearRemembered: false,
== L8 改密弹窗成功后是否更新记住的密码 ==
11:// · 成功 → 同步更新功能1 记住的密码(localStorage seat_remember_password · 免下次自动填旧密码)
95: // 成功:同步更新功能1 记住的密码(键对齐 chat/lib/auth.ts SEAT_REMEMBER_PASSWORD_KEY)
99: window.localStorage.setItem("seat_remember_password", pw1);
对照组 login-sheet remember 命中数: 7
== L9 改密弹窗现在显示账号吗 ==
163: 为保护账号安全,请设置您专属的 6 位数字登录密码
== L10 chat/page.tsx 未拦截的功能入口及显示条件 ==
--- around 3037
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
style={{ background: "linear-gradient(135deg,#eaf7f0,#daefe2)" }}
>
<History className="w-[11px] h-[11px] text-[#2fa37c]" />
</div>
<span className="text-[12px] font-semibold text-[#1f3a30] leading-none">历史解读</span>
<ArrowUpRight className="w-3 h-3 text-[#7a9285] -mr-0.5" />
</button>
)}
{parentVisible.includes("family") && (
<button
type="button"
onClick={() => router.push("/h5/family")}
className="flex items-center gap-1.5 pl-2 pr-2.5 py-1.5 bg-white border border-[#e3eee7] rounded-full hover:bg-[#f3faf6] active:
>
--- around 3143
<History className="w-[11px] h-[11px] text-[#2fa37c]" />
</div>
<span className="text-[12px] font-semibold text-[#1f3a30] leading-none">历史解读</span>
<ArrowUpRight className="w-3 h-3 text-[#7a9285] -mr-0.5" />
</button>
</>
)}
{/* Phase 47.5(长官 6-12)· 抗近领航向导入口 · 找同款案例旁 · 点进 /h5/eye-nav 独立向导 */}
{/* Phase 47(长官 6-15)· 机构开关 gate 抗近领航入口 · 关则不渲染 */}
{featureFlags.enable_eye_nav && (
<button
type="button"
onClick={() => router.push("/h5/eye-nav")}
className="flex items-center gap-1.5 pl-2 pr-2.5 py-1.5 bg-white border border-[#e3eee7] rounded-full hover:bg-[#f3faf6] active:bg-[#e
>
--- around 3161
>
<Compass className="w-[11px] h-[11px] text-[#2fa37c]" />
</div>
<span className="text-[12px] font-semibold text-[#1f3a30] leading-none">抗近领航</span>
<span className="text-[10px] text-white bg-[#2fa37c] px-1.5 rounded-full font-bold leading-[16px]">NEW</span>
<ArrowUpRight className="w-3 h-3 text-[#7a9285] -mr-0.5" />
</button>
)}
{/* Phase 75(长官 8-24)· 机构开关 gate 谣言粉碎机入口 · 与「近视的世界」同排 · 关则不渲染 */}
{featureFlags.enable_rumor_buster && (
<button
type="button"
onClick={() => router.push("/h5/rumor-buster")}
className="flex items-center gap-1.5 pl-2 pr-2.5 py-1.5 bg-white border border-[#e3eee7] rounded-full hover:bg-[#f3faf6] active:bg-[#e
>
--- around 3338
onClose={() => setMenuOpen(false)}
sessions={chatSessions}
onNewSession={handleNewSession}
onDeleteSession={handleDeleteSession}
onSelectSession={handleSelectSession}
showLogout
userPhoneVerified={userPhoneVerified}
userNickname={userNickname}
userAvatar={userAvatar}
onClickUserCard={() => {
setMenuOpen(false);
if (userPhoneVerified) {
router.push("/h5/settings");
}
// 机构开启用户注册时 · 未注册点用户卡弹 RegisterSheet(关时维持 0 反应)
== L11 菜单侧栏 settings 入口上下文 ==
</ul>
)}
</div>
{/* Phase 61.7 · 席位专属「设置」入口(61.7-A:组件内自路由 · page.tsx 0 改动 · 0 触发 chat-e2e 工件)
为什么不复用顶部用户卡:它走 onClickUserCard → page.tsx 判 userPhoneVerified,
席位态该值为 false → 落到注册分支/0 反应 → 席位进不去设置页(名片维护无入口)。
家长态不显示本入口 · 维持用户卡原路径 0 改动。 */}
{isSeat && (
<div className="px-4 pb-2 pt-1">
<button
type="button"
onClick={() => {
onClose();
router.push("/h5/settings");
}}
className="w-full flex items-center px-3 py-3 bg-white rounded-xl border border-[#e3eee7] active:bg-[#f3faf6] transition"
aria-label="settings"
>
<Settings className="w-[15px] h-[15px] text-[#2fa37c] shrink-0" />
<span className="flex-1 ml-2.5 text-[13px] font-semibold text-[#1f2a24] text-left">
== L12 phase68-seat-login.spec.tsx 用例数(宽口径) ==
7
42: it.each(RENDERERS)(
56: it.each(RENDERERS)(
70: it.each(RENDERERS)(
== L13 seat_service.py:345-349 ==
# 并发下是未处理异常直接冒出裸 500)。批量分配 + 批量查重已过滤掉绝大多数
# 碰撞,这里兜的是"查重之后、真正插入/提交之前"的极小概率 TOCTOU 竞态。
# 回滚整批(本请求内尚未提交的席位全部作废,计数表已 commit 的号段允许被
# 浪费不回收,客户端重试整批即可,不做部分成功的复杂补偿)。
await db.rollback()
== L14 lookup_seat_username_by_wx 返回 None 段 ==
(seat_auth.seat_login 的 Phase 50 绑定段)。换设备时 localStorage 是空的,
账号框本要手输 10 位数字 —— 这里凭微信把它带出来。
安全性:openid/unionid 由服务端用 OAuth code 换得(seat_wx_identity 的 docstring
明确"禁止前端裸传"),前端伪造不了,故只会返回请求者本人绑的账号。
返回 None 的四种情形:
· unionid 与 openid **都为空** —— 必须先行短路。库里大量未激活席位的
seat_bound_* 均为 NULL,不短路就可能把陌生席位当成"本人"带出去。
· 没有任何席位绑定该身份
· 命中多条(D5:业务上不应发生,但宁可让用户手输也不能猜错账号)
· 命中的席位已停用
"""
if not unionid and not openid:
return [] if return_all else None
conds = []
if unionid:
conds.append(User.seat_bound_unionid == unionid)
if openid:
conds.append(User.seat_bound_openid == openid)
rows = (
await db.execute(
select(User.username, User.seat_type).where(
User.role == "org_seat",
User.is_active.is_(True),
or_(*conds),
)
)
).all()
[org_seat 总数] [(173,)]
[seat_type 分布] [('parent', 20), ('store', 153)]
[seat_type×seat_source 分布] [('parent', 'platform', 20), ('store', 'platform', 153)]
[账号长得像手机号(^1\d{10}$) 按类型] []
[账号含小写字母 按类型] [('store', 12)]
[有手机号 按类型] [('parent', 6), ('store', 111)]
[同一手机号挂 >=2 个席位(组数)] [(2,)]
[全表账号只差大小写的撞车组数] [(0,)]
[机构总数] [(47,)]
[organizations 带 prefix 的列] []
[users 带 phone_login 的列] []
[alembic 版本] [('phase48a_002',)]
[users 上 phone 相关索引] [('uq_users_phone_h5',), ('uq_users_phone_parent',), ('uq_users_phone_store',)]
[品牌码表 brand_codes 行数] 0
[品牌码表 brand_code_activations 行数] 0
exit=0
[org_seat 总数] [(303,)]
[seat_type 分布] [('parent', 65), ('store', 238)]
[seat_type×seat_source 分布] [('parent', 'brand_code', 2), ('parent', 'platform', 21), ('parent', 'store', 42), ('store', 'platform', 238)]
[账号长得像手机号(^1\d{10}$) 按类型] []
[账号含小写字母 按类型] [('store', 18)]
[有手机号 按类型] [('parent', 7), ('store', 26)]
[同一手机号挂 >=2 个席位(组数)] [(2,)]
[全表账号只差大小写的撞车组数] [(0,)]
[机构总数] [(88,)]
[organizations 带 prefix 的列] []
[users 带 phone_login 的列] []
[alembic 版本] [('34ec94466f0c',), ('phase79_001',), ('phase83_001',)]
[users 上 phone 相关索引] [('uq_users_phone_h5',), ('uq_users_phone_parent',), ('uq_users_phone_store',)]
[品牌码表 brand_codes 行数] 1
[品牌码表 brand_code_activations 行数] 2
exit=0