交付出件箱

← 返回出件箱

批次四 impl 开工前 verify 原始输出(30 维 · 命令原样输出 · 未经转述)

# 基线 81962b75  2026-09-28 21:54:34
## D1 漂移 3a75d611..origin/test(应用文件+迁移目录)
10
-- migrations 新增:
## D2 迁移挂点
origin/test:migrations/versions/phase83_001_seat_seq_counter.py:25:down_revision = "phase48a_002"
origin/test:migrations/versions/phase83_002_subseat_prefix_and_phone_login.py:34:down_revision = "phase83_001"
origin/test:migrations/versions/phase85_001_wecom_agent.py:22:down_revision = "phase83_002"
origin/test:migrations/versions/phase85_002_wecom_handover_settings.py:17:down_revision = "phase85_001"
origin/test:migrations/versions/phase85_003_wecom_group_handover_mode.py:22:down_revision = "phase85_002"
origin/test:migrations/versions/phase85_004_wecom_learned_qa.py:22:down_revision = "phase85_003"
(上行空=0命中) 对照 phase83_002 命中数:
2
## D3 provision 端点
### app/api/admin_parent_seats.py:38-70
38: from app.models.organization import Organization
39: from app.services import parent_seat_service as ps
40: from app.services import seat_service
41: 
42: router = APIRouter(prefix="/api/admin/parent-seats")
43: 
44: # 效果图 §9 模块设置弹窗的五项(唯一事实源 · 与前端 PARENT_FEATURES 对齐)
45: ALLOWED_FEATURES = ["chat", "report", "myopia_world", "family", "about"]
46: 
47: 
48: class StatusIn(BaseModel):
49:     is_active: bool
50: 
51: 
52: class ExtendIn(BaseModel):
53:     """Phase 83.3 · 子席位延期(长官 9-21 拍板:延的是席位到期 seat_expires_at,
54:     不是品牌码计费口径 brand_code_activations.expires_at)"""
55: 
56:     extend_days: int = Field(..., ge=1, le=3650)
57: 
58: 
59: class ProvisionIn(BaseModel):
60:     """开通子席位(效果图 §9 未画此按钮 —— 原计划开通只在 B 批门店端;
61:     8-25 长官定:平台侧也要能开,否则门店出问题后台救不了场,且 B 批未上线前无从开通)"""
62: 
63:     org_id: int
64:     count: int = Field(1, ge=1, le=50)
65:     valid_days: Optional[int] = Field(365, ge=1, le=3650)
66: 
67: 
68: class FeatureConfigIn(BaseModel):
69:     """家长端模块设置 · 全局一份(效果图 §9:改完对所有子席位生效)"""
70: 
### app/api/admin_parent_seats.py:108-155
108:         "end": end,
109:     }
110: 
111: 
112: # ============================================================================
113: # 统计卡 / 机构聚合(页面主体)
114: # ============================================================================
115: @router.post("/provision")
116: async def provision(
117:     body: ProvisionIn,
118:     admin: dict = Depends(require_role("admin")),
119:     db: AsyncSession = Depends(get_db),
120: ) -> dict[str, Any]:
121:     """平台侧开通家长子席位 · 返明文凭证(仅此次)
122: 
123:     - 业务层复用 seat_service.generate_seats(seat_type='parent' 末位参),不另造轮子
124:     - seat_source 由模型 server_default 落 'platform'(与 B 批门店开通的 'store' 区分)
125:     - 🔴 同时打开该机构的 enable_parent_subseat:平台既然给这家开了子席位,
126:       机构就该被允许用;不开的话 B 批上线后门店端设置页看不到入口,状态自相矛盾
127:     """
128:     org = (
129:         await db.execute(select(Organization).where(Organization.id == body.org_id))
130:     ).scalar_one_or_none()
131:     if org is None:
132:         raise HTTPException(status_code=404, detail="机构不存在")
133: 
134:     seats = await seat_service.generate_seats(
135:         org_id=body.org_id,
136:         count=body.count,
137:         db=db,
138:         valid_days=body.valid_days,
139:         seat_type="parent",
140:     )
141: 
142:     cfg = (
143:         await db.execute(select(OrgBrainConfig).where(OrgBrainConfig.org_id == body.org_id))
144:     ).scalar_one_or_none()
145:     if cfg is None:
146:         cfg = OrgBrainConfig(org_id=body.org_id)
147:         db.add(cfg)
148:     cfg.enable_parent_subseat = True
149:     await db.commit()
150: 
151:     return {"org_id": body.org_id, "org_name": org.name, "seats": seats}
152: 
153: 
154: @router.get("/stats")
155: async def stats(
## D4 generate_seats 签名+全部调用方
origin/test:app/api/admin_parent_seats.py:123:    - 业务层复用 seat_service.generate_seats(seat_type='parent' 末位参),不另造轮子
origin/test:app/api/admin_parent_seats.py:134:    seats = await seat_service.generate_seats(
origin/test:app/api/admin_seats.py:116:async def generate_seats(
origin/test:app/api/admin_seats.py:126:    seats = await seat_service.generate_seats(
origin/test:app/api/internal_settlement.py:584:        created = await generate_seats(
origin/test:app/api/internal_settlement.py:705:        created = await generate_seats(org_id, payload.seat_count, session, payload.valid_days)
origin/test:app/api/internal_settlement.py:1650:    created = await generate_seats(
origin/test:app/api/store_subseat.py:97:    seats = await seat_service.generate_seats(
origin/test:app/services/brand_code_service.py:97:    created = await seat_service.generate_seats(
origin/test:app/services/seat_service.py:7:- generate_seats(org_id, count, db) → [{username, password(明文 · 仅此次)}]
origin/test:app/services/seat_service.py:257:# 46.5 generate_seats(POST /api/admin/orgs/{id}/seats/generate)
origin/test:app/services/seat_service.py:259:async def generate_seats(
## D5 seat_service 建号段
### app/services/seat_service.py:176-215
176: # 起不再写入,_alloc_seq_batch/_parent_period 随本次改造一并删除,成孤儿函数)
177: # ============================================================================
178: _PARENT_SEAT_KIND = "parent"
179: 
180: 
181: async def _alloc_org_seq_batch(db: AsyncSession, org_id: int, count: int) -> range:
182:     """原子批量取号(按机构 · tech §3.2)· 一条 INSERT..ON CONFLICT..RETURNING 拿一批。
183: 
184:     首次该 org_id 无行 → 直接插入 last_seq=100000+count(seed 值即"起点+本批量",
185:     区间从 100001 起);已存在 → last_seq = last_seq + count(累加)。
186:     RETURNING 拿到的是累加后的终值 new_max,本批号段 = [new_max-count+1, new_max]。
187: 
188:     超上限靠模型 CHECK(last_seq BETWEEN 100000 AND 999999):UPSERT 越界时
189:     db.execute 直接抛 IntegrityError(不会返回 new_max,不写"执行后比较 new_max"
190:     的死代码)。⚠️ 调用点 generate_seats 里本函数在外层 try 之外,外层
191:     except IntegrityError 兜不到这里,故必须在本函数内部自行 try/except →
192:     rollback → 转 409,否则就是裸 500。
193:     """
194:     stmt = (
195:         pg_insert(OrgParentSeatCounter)
196:         .values(org_id=org_id, last_seq=100000 + count)
197:         .on_conflict_do_update(
198:             index_elements=["org_id"],
199:             set_={"last_seq": OrgParentSeatCounter.last_seq + count},
200:         )
201:         .returning(OrgParentSeatCounter.last_seq)
202:     )
203:     try:
204:         new_max = (await db.execute(stmt)).scalar_one()
205:     except IntegrityError:
206:         await db.rollback()
207:         raise HTTPException(
208:             status_code=409, detail="该机构家长账号编号已用完,请联系平台"
209:         )
210:     return range(new_max - count + 1, new_max + 1)
211: 
212: 
213: async def _alloc_parent_usernames(
214:     db: AsyncSession, org_id: int, prefix: str, count: int
215: ) -> list[str]:
### app/services/seat_service.py:262-370
262:     db: AsyncSession,
263:     valid_days: Optional[int] = None,
264:     experience_store_name: Optional[str] = None,
265:     seat_type: str = "store",
266:     seat_source: Optional[str] = None,
267:     created_by_seat_id: Optional[int] = None,
268:     # ⚠️ Phase 74:seat_type / seat_source / created_by_seat_id 必须保持签名最末 ——
269:     # internal_settlement.py:566/659 按位置参数调用(org_id, count, session, valid_days),
270:     # 插中间会全错位;结算不传 → 恒 store / None。
271:     # seat_source:None = 交给模型 server_default('platform');批 B 门店端显式传 'store'
272:     # created_by_seat_id:None = 平台开通或调用方不知道开通人(老数据/admin_parent_seats.py
273:     # 不传)· 门店端 store_subseat.py 传调用者自己的 seat id(8-28 长官拍板「谁开通展示谁的码」)
274: ) -> list[dict]:
275:     """批量生成 count 个席位 · 返明文凭证列表(仅此次 · DB 只存 hash)
276: 
277:     流程(镜像 user_mgmt_service.create_user):
278:     1. 校验 org 存在(404)
279:     2. count 范围校验(1-100)
280:     3. 循环 count 次:
281:        a. 生成唯一用户名 org{org_id}-{rand6}(随机后缀防枚举 + 查重)
282:        b. generate_seat_initial_password() 6 位数字 + argon2 hash
283:        c. INSERT users(role='org_seat', linked_org_id=org_id, is_active=True)
284:     4. 一次 commit · 返 [{username, password(明文)}]
285: 
286:     valid_days: 席位有效期天数 · 存 seat_valid_days · 留空(None)=永久不到期。
287:       D8:生成时不算 seat_expires_at(留 NULL 未激活)· 首次登录激活时才算 = 激活时间 + valid_days。
288:     """
289:     if count < 1 or count > 100:
290:         raise HTTPException(status_code=400, detail="count 必须 1-100")
291: 
292:     org = await _get_org_or_404(org_id, db)
293:     normalized_store_name = experience_store_name.strip() if experience_store_name else None
294: 
295:     # D8(org-expiry-removal):生成时**不算** seat_expires_at·只存 valid_days。
296:     # 首次登录激活时才算 seat_expires_at = 激活时间 + valid_days(闲置期不扣)。
297:     # seat_valid_days=None → 永久席位(seat_expires_at 永远 NULL)。
298: 
299:     # Phase 83.14 · 家长席位走"前缀 + 机构内六位序号"新格式(tech §3.2):没设
300:     # 前缀不许开号,循环外一次性批量分配(避免退化成"逐条取号"、避免计数表行锁
301:     # 横跨整批耗时)。门店等其它 seat_type 完全走原 _gen_unique_username 逐条
302:     # 生成,本段零改动(不受前缀影响)。
303:     parent_usernames: list[str] = []
304:     if seat_type == _PARENT_SEAT_KIND:
305:         if not org.subseat_prefix:
306:             raise HTTPException(
307:                 status_code=409,
308:                 detail="该机构还没设置子席位账号前缀,请先到『机构管理 → 更多 → 子席位前缀』里设置",
309:             )
310:         parent_usernames = await _alloc_parent_usernames(
311:             db, org_id, org.subseat_prefix, count
312:         )
313: 
314:     results: list[dict] = []
315:     created_ids: list[int] = []  # Phase 52.3a · 收集 seat.id(预抓 int)供 commit 后 emit
316:     try:
317:         for i in range(count):
318:             username = (
319:                 parent_usernames[i]
320:                 if seat_type == _PARENT_SEAT_KIND
321:                 else await _gen_unique_username(org_id, db)
322:             )
323:             temp_pw = generate_seat_initial_password()
324:             seat = User(
325:                 username=username,
326:                 password_hash=hash_password(temp_pw),
327:                 role="org_seat",
328:                 linked_org_id=org_id,
329:                 experience_store_name=normalized_store_name,
330:                 is_active=True,
331:                 must_change_password=True,  # 功能2(7-07·v2):新席位首登强制改密(改 v1「无自助改密流」)
332:                 seat_valid_days=valid_days,  # D8:存天数(None=永久)· seat_expires_at 留 NULL(未激活)
333:                 seat_type=seat_type,  # Phase 74 · store=门店(默认) / parent=家长子席位
334:                 phone_login_enabled=True,  # Phase 83.14(决策3):所有新建席位(门店+家长,含品牌码)开手机号登录
335:                 # Phase 74 批 B · 开通来源:门店端传 'store' · 不传则走模型 server_default('platform')
336:                 **({"seat_source": seat_source} if seat_source else {}),
337:                 # Phase 74(8-28)· 开通人 seat id(自引用)· None 时列本身允许 NULL,无 server_default
338:                 # 可依赖,故直接赋值(不走上面 seat_source 那种"不传则省略键"的写法)
339:                 created_by_seat_id=created_by_seat_id,
340:             )
341:             db.add(seat)
342:             await db.flush()  # 拿 seat.id · 同时让下一轮查重看到本轮 username
343:             created_ids.append(int(seat.id))  # Phase 52.3a · flush 后预抓本地 int(防 emit 时 lazy load)
344:             # 二期批3 §2 项9(路B):前端需要 seat_user_id 才能对刚开通的席位串调
345:             # 备注/绑定端点 · 4 个调用方均已 verify 对多余键容错(见本行 commit 说明)
346:             results.append({"id": int(seat.id), "username": username, "password": temp_pw})
347: 
348:         await db.commit()
349:     except IntegrityError:
350:         # Phase 83.11 · 撞号兜底最后一道防线(改前 generate_seats 完全没捕获,
351:         # 并发下是未处理异常直接冒出裸 500)。批量分配 + 批量查重已过滤掉绝大多数
352:         # 碰撞,这里兜的是"查重之后、真正插入/提交之前"的极小概率 TOCTOU 竞态。
353:         # 回滚整批(本请求内尚未提交的席位全部作废,计数表已 commit 的号段允许被
354:         # 浪费不回收,客户端重试整批即可,不做部分成功的复杂补偿)。
355:         await db.rollback()
356:         raise HTTPException(
357:             status_code=409, detail="生成席位账号时发生并发冲突,请重试"
358:         )
359: 
360:     # Phase 52.3a · #7 lifecycle(seat_added) 埋点(fire-and-forget · commit 成功后逐 seat emit)
361:     from app.services.activity_log_service import emit_lifecycle, schedule_emit
362:     for sid in created_ids:
363:         schedule_emit(emit_lifecycle(org_id, "seat", str(sid), "seat_added", {}))
364: 
365:     return results
366: 
367: 
368: # ============================================================================
369: # 46.5 list_seats(GET /api/admin/orgs/{id}/seats · 分页)
370: # ============================================================================
## D6 生命周期写入
origin/test:app/models/activity.py:105:class AccountLifecycleEvent(Base):
origin/test:app/models/activity.py:107:    __tablename__ = "account_lifecycle_event"
origin/test:app/services/activity_log_service.py:29:def schedule_emit(coro) -> None:
origin/test:app/services/activity_log_service.py:194:async def emit_lifecycle(
## D1b 漂移对照组(全仓 diff --stat 末 3 行,证明命令有效)
 tests/unit/test_wecom_learned_qa_judge.py          | 280 +++++++++++++++++++++
 tests/unit/test_wecom_learned_qa_service.py        | 125 ++++++++-
 9 files changed, 791 insertions(+), 132 deletions(-)
## D6b 生命周期函数体
### app/services/activity_log_service.py:20-45
20: from app.services.incident_logger import log_incident
21: 
22: 
23: # ───────────────────────── fire-and-forget 调度助手(防 asyncio task GC footgun)─────────────────────────
24: # 裸 asyncio.create_task(coro) 不持引用 → task 在 await DB 期间可能被 GC 提前回收 → 静默间歇丢事件。
25: # 对可靠审计数据是硬伤(52 席位行为流水/生命周期结果标签源)。持 task 强引用直到 done 后自动 discard。
26: _bg_tasks: set = set()
27: 
28: 
29: def schedule_emit(coro) -> None:
30:     """fire-and-forget 调度 · 持 task 强引用防 GC 提前回收(asyncio 官方 footgun) · done 后自动 discard"""
31:     t = asyncio.create_task(coro)
32:     _bg_tasks.add(t)
33:     t.add_done_callback(_bg_tasks.discard)
34: 
35: 
36: # ───────────────────────── detail 白名单(D13 · 只放枚举 + 引用 · 禁内容拷贝)─────────────────────────
37: # feature_open  : {"feature": ...}
38: # data_generated: {"kind": ..., "ref": ...}
39: # question_asked: {"session_id": ..., "message_ref": ...}
40: # admin_view    : {"viewed_type": ..., "viewed_id": ...}
41: # lifecycle     : {"extended_days","old_expiry","new_expiry","seat_valid_days_after","operator","source"} / {"amount"} 等
42: # 调用方(52.3 端点接线)负责只传白名单 key;本 service 不做内容拷贝,
43: # detail 直接落库(detail or {})。
44: 
45: 
### app/services/activity_log_service.py:194-225
194: async def emit_lifecycle(
195:     org_id: int,
196:     subject_type: str,
197:     subject_id: str,
198:     event_type: str,
199:     detail: Optional[dict[str, Any]],
200: ) -> None:
201:     """写 account_lifecycle_event 一行(fire-and-forget best-effort · 生命周期结果标签源)。
202: 
203:     同 emit_activity 的 fresh-session + 全捕模式;失败时 log_incident(severity='error')
204:     (生命周期是 53 结果标签源 · 失败必留 severity=error 案 · §4③)。
205:     """
206:     try:
207:         async with async_session_maker() as session:
208:             session.add(
209:                 AccountLifecycleEvent(
210:                     org_id=org_id,
211:                     subject_type=subject_type,
212:                     subject_id=subject_id,
213:                     event_type=event_type,
214:                     detail=detail or {},
215:                 )
216:             )
217:             await session.commit()
218:     except Exception as e:  # noqa: BLE001 · best-effort · 全捕不 re-raise
219:         await log_incident(
220:             module="activity",
221:             error_type=type(e).__name__,
222:             severity="error",
223:             org_id=org_id,
224:             detail={
225:                 "op": "emit_lifecycle",
### app/models/activity.py:105-130
105: class AccountLifecycleEvent(Base):
106:     """生命周期/结果标签(subject_id 无 FK · 多态留痕 · 长留)"""
107:     __tablename__ = "account_lifecycle_event"
108: 
109:     id = Column(BigInteger, primary_key=True, autoincrement=True)
110:     org_id = Column(Integer, nullable=False, comment="多态留痕 · 无 FK(phase52_003 去 FK · 审计行独立于机构生命周期长留)")
111:     subject_type = Column(String(16), nullable=False)
112:     subject_id = Column(String(64), nullable=False, comment="多态留痕 · 无 FK")
113:     event_type = Column(String(24), nullable=False)
114:     detail = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
115:     created_at = Column(TIMESTAMP(timezone=True), nullable=False, server_default=text("now()"))
116: 
117:     __table_args__ = (
118:         CheckConstraint(f"subject_type IN {_in(_SUBJECT_TYPES)}", name="ck_lifecycle_subject_type"),
119:         CheckConstraint(f"event_type IN {_in(_LIFECYCLE_EVENTS)}", name="ck_lifecycle_event_type"),
120:         Index("idx_lifecycle_org_time", "org_id", "created_at"),
121:         Index("idx_lifecycle_subject_time", "subject_type", "subject_id", "created_at"),
122:     )
123: 
124:     def __repr__(self):
125:         return f"<AccountLifecycleEvent(id={self.id}, subject={self.subject_type}:{self.subject_id}, event={self.event_type})>"
126: 
127: 
128: class OrgActivityMonthly(Base):
129:     """月度聚合(复合 PK (org_id, month) · 长留)"""
130:     __tablename__ = "org_activity_monthly"
## D7 品牌码建号/激活
### app/services/brand_code_service.py:1-30
1: """Phase 48A 批2 · 品牌方合作 - 扫码进门核心逻辑(NEW)
2: 
3: 按技术方案 /home/dev2/reports/phase48-brand-code-tech-20260916.md §五①实现:
4:   家长扫品牌码 → 微信授权 → 按 wechat_unionid 认人(有号合并 / 没号建号)
5:   → 写一条开通记录(去重靠 DB 约束) → 签发家长子席位 JWT
6: 
7: 🔴 本次实施重新核实并落地的关键决策(不是 tech 文档的字面搬抄,是对其风险1的裁决):
8:   - 孩子档案(Patient.owner_seat_id)必须挂在 role='org_seat' AND seat_type='parent'
9:     的家长子席位上 —— 已在 app/api/parent_patients.py:69 `_require_parent` 用
10:     `require_org_seat` + `seat_type != 'parent'` 双重实测确认(h5_user JWT 走
11:     require_h5_user,物理上调不到 /api/parent/patients 任何端点)。故本模块签发的
12:     必须是 **org_seat** JWT(轨道B),不是 h5_user JWT(轨道A)——两者是完全不同的
13:     users 表角色,h5_user 拿不到家长档案能力。
14:   - "认人"查询**特意不用** app/services/seat_service.py:600 `lookup_seat_username_by_wx`
15:     (它不按 org_id 过滤 · 一个家庭在店A、店B各扫各的码时会被错误合并到同一个座位)。
16:     本模块按 `linked_org_id == brand_org_id` 精确匹配"这个品牌码专属的家长子席位",
17:     与 §六「跨品牌码各自计数」的设计意图(同一家长扫A又扫B,activations 各插一行)
18:     保持一致 —— 若照抄 lookup_seat_username_by_wx,跨机构会话会被错误共用同一账号。
19: """
20: from __future__ import annotations
21: 
22: from datetime import datetime, timedelta, timezone
23: from typing import Optional
24: 
25: from fastapi import HTTPException, status
26: from sqlalchemy import or_, select
27: from sqlalchemy.dialects.postgresql import insert as pg_insert
28: from sqlalchemy.ext.asyncio import AsyncSession
29: 
30: from app.auth import jwt_local
### app/services/brand_code_service.py:80-115
80: async def _provision_family_seat(
81:     brand_org_id: int, unionid: Optional[str], openid: Optional[str],
82:     nickname: Optional[str], avatar: Optional[str], db: AsyncSession,
83: ) -> User:
84:     """新建 1 个家长子席位(复用既有 generate_seats · 4.4 节)并立即补写微信绑定
85: 
86:     - seat_source="brand_code":10 字符,String(10) 恰好放满,无 CHECK 约束限制取值
87:       (已 grep 确认 app/models/user.py 无 seat_source CHECK)。
88:     - must_change_password 不再显式置 False(Phase 83.18 · 2026-09-24 决策11 推翻本函数
89:       原「永远免密」设计):新扫码家长和其它新席位一样,保持 generate_seats 默认给的
90:       True,首次进站需先设密码(手机号 + 密码登录)。老用户重扫走 `_find_existing_family_seat`
91:       命中分支(见 `activate_brand_code` 下方),该分支只刷新展示信息,不触碰这个字段,
92:       原有免密体验不受影响。
93:     - seat_valid_days 留 None(永久):tech 文档未就"品牌码新建家长席位是否该有单独
94:       有效期"给出结论,规则20 的 365 天有效期定义在 brand_code_activations.expires_at
95:       (计费口径),不是席位本身的到期时间,两者是两套独立时间轴,不混用。
96:     """
97:     created = await seat_service.generate_seats(
98:         org_id=brand_org_id,
99:         count=1,
100:         db=db,
101:         seat_type="parent",
102:         seat_source="brand_code",
103:     )
104:     seat_id = created[0]["id"]
105:     seat = await db.get(User, seat_id)
106:     if seat is None:  # pragma: no cover · generate_seats 刚 commit 的行理论上必存在
107:         raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="家长席位创建失败")
108:     seat.seat_bound_unionid = unionid
109:     seat.seat_bound_openid = openid
110:     seat.seat_bound_at = datetime.now(timezone.utc)
111:     seat.wechat_nickname = nickname
112:     seat.wechat_avatar = avatar
113:     await db.commit()
114:     await db.refresh(seat)
115:     return seat
### app/services/brand_code_service.py:173-215
173: async def activate_brand_code(brand_token: str, code: str, db: AsyncSession) -> dict:
174:     """五①主流程:验签 → 双门 → OAuth → 认人 → 开通记录 → 签发 JWT
175: 
176:     返回字典(供 app/api/brand_code_public.py 组装响应模型):
177:       access_token / refresh_token / nickname / avatar / seat_type / org_features / visible_features
178:     """
179:     brand = await _brand_code_or_error(brand_token, db)
180:     await _require_brand_code_feature_enabled(brand.org_id, db)
181: 
182:     try:
183:         wx = await exchange_code(code)
184:     except HTTPException:
185:         raise
186:     except Exception as e:
187:         await log_incident(
188:             module="brand_code_activate",
189:             error_type="brand_code_activate_exchange_fail",
190:             detail={"error": str(e)},
191:         )
192:         raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="微信身份获取失败,请重试")
193: 
194:     openid = wx.get("openid")
195:     unionid = wx.get("unionid")
196:     if not openid:
197:         await log_incident(
198:             module="brand_code_activate",
199:             error_type="brand_code_activate_no_openid",
200:             detail={"raw": str(wx)[:200]},
201:         )
202:         raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="微信身份异常(缺 openid),请重试")
203: 
204:     nickname = wx.get("nickname")
205:     avatar = wx.get("headimgurl")
206: 
207:     seat = await _find_existing_family_seat(brand.org_id, unionid, openid, db)
208:     if seat is None:
209:         seat = await _provision_family_seat(brand.org_id, unionid, openid, nickname, avatar, db)
210:     else:
211:         # 命中已有席位 → 刷新展示信息(同 seat_wx_identity"本人"分支的做法)
212:         seat.wechat_nickname = nickname
213:         seat.wechat_avatar = avatar
214:         await db.commit()
215:         await db.refresh(seat)
## D8 登录+改密
### app/api/seat_auth.py:1-45
1: """席位密码登录 API 路由 · Phase 46.4 NEW
2: 
3: 按席位售卖防共享授权 · 复用 users 表(role='org_seat') + auth_service 零件 + jwt_local
4: device claim(46.2)+ require_org_seat 中间件(46.3)。
5: 
6: 3 个 POST endpoint(tech §3.2-§3.4):
7: - POST /api/seat/login    · {username, password, device_id, org_code} → access+refresh JWT(带 device_id)
8:                             · 复用 D9 锁定 / LoginAttempt / verify_password / 审计零件
9:                             · 写 users.current_device_id = device_id(仅记录最近登录设备 · 多设备共存 · 不再踢旧设备)
10: - POST /api/seat/refresh  · {refresh_token} → 新 access(带 device_id)
11:                             · 多设备共存 · 槽位 NULL=管理侧撤销全踢(refresh.device_id 非 None 且
12:                               seat.current_device_id 为 None → 401)
13: - POST /api/seat/logout   · 加 Redis 黑名单(复用 auth_service.logout)
14: 
15: 复用要点:
16: - 密码校验 password_utils.verify_password
17: - 渐进冷却(bug2 · 原 D9 硬锁)/ LoginAttempt 写入 / 审计 → 复用 auth_service.enforce_login_cooldown + register_login_failure
18: - 黑名单 → 复用 auth_service.logout(Redis SADD token_blacklist)
19: - JWT 签发 → jwt_local.issue_access_token/issue_refresh_token(device_id 参数 · 46.2)
20: - 审计 event_type 复用 'login_success'(chk_event_type CHECK 不含 'seat_login' · 用 detail
21:   discriminator 区分 · 镜像 auth_service.logout 的 detail={'action':'logout'} 模式)
22: """
23: from __future__ import annotations
24: 
25: import ipaddress
26: import re
27: from datetime import datetime, timedelta, timezone
28: from typing import Literal
29: 
30: from fastapi import APIRouter, Depends, HTTPException, Request, status
31: from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
32: from pydantic import BaseModel, Field
33: from sqlalchemy import cast, func, or_, select, text
34: from sqlalchemy.dialects.postgresql import INET
35: from sqlalchemy.exc import IntegrityError
36: from sqlalchemy.ext.asyncio import AsyncSession
37: 
38: from app.auth import jwt_local
39: from app.auth.middleware import require_h5_user
40: from app.auth.jwt_local import (
41:     JWT_ALGORITHM,
42:     JWT_SECRET_KEY,
43:     decode_wx_identity_token,
44:     issue_wx_identity_token,
45: )
### app/api/seat_auth.py:210-222
210:                 )
211:             )
212:         ).scalar_one_or_none()
213: 
214:     await auth_service.enforce_login_cooldown(
215:         seat, ip, user_agent, db, channel="seat_password"
216:     )
217: 
218:     if seat is None or not verify_password(body.password, seat.password_hash or ""):
219:         # 修复1:查到 seat 用 seat.username(规范值)记账;查不到仍用 stripped 输入值。
220:         await auth_service.register_login_failure(
221:             seat,
222:             seat.username if seat is not None else stripped,
### app/api/seat_auth.py:545-595
545:     if not body.wx_identity_token and (seat.seat_type or "store") == "parent":
546:         await auth_service._write_audit(
547:             user_id=seat.id,
548:             event_type="login_failure",
549:             ip=ip,
550:             ua=user_agent,
551:             detail={"reason": "wx_identity_required_parent", "channel": "seat_password"},
552:             db=db,
553:         )
554:         raise HTTPException(
555:             status_code=status.HTTP_403_FORBIDDEN,
556:             detail="家长账号请先完成微信授权后登录",
557:         )
558: 
559:     # ── Phase 50 · 微信身份绑定判定(绑定键 unionid 优先·openid 兜底 / D4 重置 / D7 历史不清) ──
560:     # 公众号未绑开放平台时 OAuth 不返 unionid → 用 openid 兜底(单公众号入口 openid 对同一人稳定)。
561:     # 本人判定 unionid/openid 任一命中即本人(兼容将来绑开放平台后 unionid 才出现的升级场景)。
562:     # 插在 is_active 通过后、写 device 前 · 不传 wx_identity_token = 退回 Phase 46 行为(回滚兼容)
563:     if body.wx_identity_token:
564:         ident = decode_wx_identity_token(body.wx_identity_token)  # {unionid,openid,nickname,avatar}
565:         cur_unionid = ident.get("unionid")
566:         cur_openid = ident.get("openid")
567:         bound_any = seat.seat_bound_unionid or seat.seat_bound_openid
568:         is_self = (
569:             (cur_unionid and seat.seat_bound_unionid and cur_unionid == seat.seat_bound_unionid)
570:             or (cur_openid and seat.seat_bound_openid and cur_openid == seat.seat_bound_openid)
571:         )
572:         if not bound_any:  # 首登 → 绑定(unionid 可能 None·公众号未绑开放平台)
573:             seat.seat_bound_unionid = cur_unionid
574:             seat.seat_bound_openid = cur_openid
575:             seat.seat_bound_at = datetime.now(timezone.utc)
576:             seat.wechat_nickname = ident.get("nickname")  # 复用现有列(展示)
577:             seat.wechat_avatar = ident.get("avatar")
578:         elif not is_self:  # 非本人 → 403
579:             await auth_service._write_audit(
580:                 user_id=seat.id,
581:                 event_type="login_failure",
582:                 ip=ip,
583:                 ua=user_agent,
584:                 detail={"reason": "wx_identity_mismatch", "channel": "seat_password"},
585:                 db=db,
586:             )
587:             raise HTTPException(
588:                 status_code=status.HTTP_403_FORBIDDEN,
589:                 detail="登录失败,请席位本人登录,感谢",
590:             )
591:         else:  # 本人 → 刷新昵称头像 + 自动升级 unionid(开放平台后补)
592:             if cur_unionid and not seat.seat_bound_unionid:
593:                 seat.seat_bound_unionid = cur_unionid
594:             seat.wechat_nickname = ident.get("nickname")
595:             seat.wechat_avatar = ident.get("avatar")
### app/api/seat_auth.py:1046-1054
1046:     # 否则新号既没密码可用又没手机号可反查,彻底登不进。设置页改密(must_change_password
1047:     # 已为 False)不受影响,照旧可不传。放在查出 seat 之后单独判,§2b 现有格式校验原位不动。
1048:     if seat.must_change_password and contact_phone is None:
1049:         raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="请填写手机号")
1050: 
1051:     seat.password_hash = hash_password(body.new_password)
1052:     seat.must_change_password = False
1053:     seat.password_changed_at = datetime.now(timezone.utc)
1054:     if contact_phone is not None:
### app/api/seat_auth.py:1156-1164
1156:             status_code=status.HTTP_403_FORBIDDEN,
1157:             detail="无法自助重置,请联系管理员",
1158:         )
1159: 
1160:     seat.password_hash = hash_password(body.new_password)
1161:     seat.password_changed_at = datetime.now(timezone.utc)
1162:     seat.must_change_password = False
1163:     seat.current_device_id = None  # 踢旧设备(换设备忘密码是主场景 · 旧会话作废)
1164:     seat.device_epoch = (seat.device_epoch or 0) + 1  # 代际号 +1 · 防新登录填回槽位后误恢复旧 token
## D9 启动钩子+告警
### app/main.py:320-345
320:             logging.info("[GPU-keepalive] 任务取消 · 退出")
321:             raise
322:         except Exception as e:
323:             logging.warning(f"[GPU-keepalive] 循环异常 · 继续: {e}")
324: 
325: 
326: @app.on_event("startup")
327: async def startup_event():
328:     """应用启动事件"""
329:     logging.info("Doctor AI Brain 启动完成")
330: 
331:     # ③ fail-fast(6-04 立 · 根治根因 2) · DB 连通性自检 ·
332:     # 防空密码/认证失败带病启动狂 500(P0 6-02 教训)· env flag 可回滚
333:     import os
334:     if os.getenv("DB_STARTUP_CHECK_ENABLED", "true").lower() == "true":
335:         from app.database import verify_db_connectivity
336:         await verify_db_connectivity()
337:         logging.info("✅ DB 启动自检通过 · 连通正常")
338: 
339:     # 预热 DashScope 连接(建立 TCP+TLS 连接池)
340:     import httpx
341:     import os
342:     dashscope_api_key = os.getenv("DASHSCOPE_API_KEY")
343:     if dashscope_api_key:
344:         try:
345:             async with httpx.AsyncClient(timeout=5.0) as client:
### app/services/incident_logger.py:1-45
1: """Phase 27a.4: 错误上报基础设施。
2: 
3: 2026-08-22 升级:warn 及以上告警经 alert_notifier 推送企业微信
4: (群机器人 webhook 优先 · 回落应用消息)· 未配置通道则不推送(默认关闭)。
5: 仍待 Phase 30+:incident_log 表落库 + alert_rules 规则匹配
6: (当前告警仅存在于容器 stdout · json-file 10MB×3 滚动会冲掉)。
7: """
8: import json
9: import logging
10: from typing import Any
11: 
12: logger = logging.getLogger(__name__)
13: 
14: 
15: async def log_incident(
16:     module: str,
17:     error_type: str,
18:     severity: str = 'error',
19:     task_id: str | None = None,
20:     org_id: int | None = None,
21:     user_ref: str | None = None,
22:     detail: dict[str, Any] | None = None,
23: ) -> None:
24:     """统一错误上报接口。
25:     所有涉及外部依赖 / 异步处理 / 队列 / 超时的失败路径必须调用此函数,
26:     禁止散写 logger.error(详见 requirement-change-workflow.md 约束)。
27:     """
28:     payload = {
29:         "module": module,
30:         "error_type": error_type,
31:         "severity": severity,
32:         "task_id": task_id,
33:         "org_id": org_id,
34:         "user_ref": user_ref,
35:         "detail": detail or {},
36:     }
37:     logger.error(f"[INCIDENT] {json.dumps(payload, ensure_ascii=False, default=str)}")
38: 
39:     # 2026-08-22 · 告警真通知到人(原占位实现只写 stdout · 容器日志滚动几天即冲掉 ·
40:     # 聊天屈光哨兵在生产真响过却无人知晓)。未配置告警通道时 push_alert 直接返回 False ·
41:     # 存量部署零行为变更;推送异常内部吞掉 · 绝不影响业务链路。
42:     try:
43:         from app.services.alert_notifier import push_alert
44:         await push_alert(module, error_type, severity, detail)
45:     except Exception as e:  # 双保险:import 失败也不许拖累业务
## D10 模型
### app/models/org_parent_seat_counter.py:1-50
1: """Phase 83.13 · 按机构家长号原子计数表(NEW)
2: 
3: 取代 `seat_seq_counter`(Phase 83.10 · 按月计数):批次五家长取号改用「前缀 + 机构内
4: 六位序号」(tech §3.2),号段不再按 YYMM 切分,而是按机构累加,一个机构从 100001 起
5: 只增不减地往上走,直到 999999 用尽(CHECK 锁死区间,超限由 UPSERT 触发
6: IntegrityError,调用方 `app/services/seat_service.py` 捕获后转 409)。
7: 
8: `org_id` 既是主键也是外键(一机构一行,`ON DELETE CASCADE`:机构被删时计数行跟着
9: 消失,不需要业务代码显式清理)。`last_seq` 默认 100000(= 还没开过号),首次取号走
10: `INSERT ... ON CONFLICT (org_id) DO UPDATE SET last_seq = last_seq + :n RETURNING
11: last_seq` 原子 UPSERT(照抄 `seat_seq_counter` 的写法,只是主键从 `(period,
12: seat_kind)` 换成 `org_id`)。
13: 
14: `seat_seq_counter` 表本身不删(见 tech §2.3,技术债记账,不在本批范围)。
15: """
16: from sqlalchemy import Column, ForeignKey, Integer, TIMESTAMP, CheckConstraint, text
17: 
18: from app.models.database import Base
19: 
20: 
21: class OrgParentSeatCounter(Base):
22:     """按机构原子计数家长号序号 · PK = FK = org_id"""
23: 
24:     __tablename__ = "org_parent_seat_counter"
25: 
26:     org_id = Column(
27:         Integer,
28:         ForeignKey("organizations.id", ondelete="CASCADE"),
29:         primary_key=True,
30:     )
31:     last_seq = Column(
32:         Integer,
33:         nullable=False,
34:         server_default=text("100000"),
35:         comment="该机构已分配到的最大家长号序号(六位数字,100001 起用)",
36:     )
37:     updated_at = Column(TIMESTAMP(timezone=True), server_default=text("now()"))
38: 
39:     __table_args__ = (
40:         CheckConstraint(
41:             "last_seq BETWEEN 100000 AND 999999",
42:             name="ck_org_parent_seat_counter_range",
43:         ),
44:     )
45: 
46:     def __repr__(self):
47:         return (
48:             f"<OrgParentSeatCounter(org_id={self.org_id}, "
49:             f"last_seq={self.last_seq})>"
50:         )
16:origin/test:app/models/__init__.py:46:from app.models.activity import (
28:origin/test:app/models/__init__.py:97:from app.models.brand_code import BrandCode, BrandCodeActivation
origin/test:app/models/user.py:34:    password_hash = Column(String(255), nullable=True)
origin/test:app/models/user.py:74:    must_change_password = Column(
origin/test:app/models/user.py:84:    last_login_at = Column(DateTime(timezone=True), nullable=True)
origin/test:app/models/user.py:100:    seat_bound_unionid = Column(
origin/test:app/models/user.py:105:    seat_bound_openid = Column(
origin/test:app/models/user.py:110:    seat_bound_at = Column(
origin/test:app/models/user.py:115:    # 席位级独立到期(NULL=永久不到期 · 首次登录激活时算 = 激活时间 + seat_valid_days)
origin/test:app/models/user.py:116:    seat_expires_at = Column(
origin/test:app/models/user.py:121:    # D8(org-expiry-removal):有效期天数 · 生成时存 · 首次登录激活时 seat_expires_at=激活时间+天数
origin/test:app/models/user.py:122:    seat_valid_days = Column(
origin/test:app/models/user.py:129:    seat_type = Column(
origin/test:app/models/user.py:138:    # 后台「全部来源」筛选就能区分两条开通路径。仅 seat_type='parent' 有业务意义。
origin/test:app/models/user.py:139:    seat_source = Column(
origin/test:app/models/user.py:156:    created_by_seat_id = Column(
origin/test:app/models/user.py:165:    seat_remark_name = Column(
origin/test:app/models/user.py:194:            "(role = 'admin' AND username IS NOT NULL AND password_hash IS NOT NULL) OR "
origin/test:app/models/user.py:195:            "(role = 'org_admin' AND username IS NOT NULL AND password_hash IS NOT NULL AND linked_org_id IS NOT NULL) OR "
origin/test:app/models/user.py:197:            "(role = 'org_seat' AND username IS NOT NULL AND password_hash IS NOT NULL AND linked_org_id IS NOT NULL) OR "
origin/test:app/models/user.py:198:            "(role = 'channel' AND username IS NOT NULL AND password_hash IS NOT NULL AND channel_id IS NOT NULL) OR "
origin/test:app/models/user.py:199:            "(role = 'platform_staff' AND username IS NOT NULL AND password_hash IS NOT NULL)",
origin/test:app/models/user.py:239:    password_hash = Column(String(255), nullable=False)
## D11 前端 page.tsx 关键位置
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:68:// 长官 8-28 真机反馈(截图实数):子席位明细弹框可视区实测只完整露出 9 行,主表同款 10 条/页
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:148:  const [cred, setCred] = useState<{ seats: { username: string; password: string }[]; kind: string } | null>(null);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:150:  const [credCopied, setCredCopied] = useState<string | null>(null);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:159:      setCredCopied(mark);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:160:      setTimeout(() => setCredCopied(null), 2000);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:204:  const load = useCallback(async () => {
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:257:  const loadDetail = useCallback(async (orgId: number) => {
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:335:        setCred({ seats: [{ username: b.username, password: b.password }], kind: kind === "reset" ? "重置密码" : "换绑" });
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:460:  const doProvision = async () => {
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:485:      setCred({
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:813:          <thead className="bg-slate-50 text-slate-500 border-y border-slate-100">
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:815:              <th className="px-6 py-3.5 font-medium">机构</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:816:              <th className="px-6 py-3.5 font-medium">已开通</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:817:              <th className="px-6 py-3.5 font-medium">使用中</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:818:              <th className="px-6 py-3.5 font-medium">已到期</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:819:              <th className="px-6 py-3.5 font-medium">待绑定</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:820:              <th className="px-6 py-3.5 font-medium">最近开通</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:821:              <th className="px-6 py-3.5 font-medium text-right">操作</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:882:                <h3 className="font-bold text-slate-900">{detail.org_name} · 子席位明细</h3>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:892:                {exporting ? "导出中…" : "导出明细"}
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:974:              <thead className="bg-slate-50 text-slate-500 border-b border-slate-100 sticky top-0">
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:976:                  <th className="px-4 py-3 font-medium">账号</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:977:                  <th className="px-4 py-3 font-medium">微信</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:978:                  <th className="px-4 py-3 font-medium">家长手机</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:979:                  <th className="px-4 py-3 font-medium">来源</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:980:                  <th className="px-4 py-3 font-medium">状态</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:981:                  <th className="px-4 py-3 font-medium">开通 / 到期</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:982:                  <th className="px-4 py-3 font-medium">绑定时间</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:983:                  <th className="px-4 py-3 font-medium text-right">行内管控</th>
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:1212:                    max={50}
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:1236:                onClick={doProvision}
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:1306:          <div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" onClick={() => setCred(null)} />
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:1343:              <button onClick={() => setCred(null)} className="flex-1 py-2 rounded-xl bg-slate-900 text-white text-[13px] font-medium">我已记录</button>
### web/app/(dashboard)/admin/parent-seats/page.tsx:455-500
455:       // 拉全量失败就保留兜底列表,不影响开通(有意的降级 · 保留)
456:       console.warn("拉取全量机构异常 · 保留兜底列表,不影响开通", e);
457:     }
458:   };
459: 
460:   const doProvision = async () => {
461:     if (!provOrg) return;
462:     setProvBusy(true);
463:     try {
464:       const res = await apiFetch("/api/admin/parent-seats/provision", {
465:         method: "POST",
466:         headers: { "Content-Type": "application/json" },
467:         body: JSON.stringify({
468:           org_id: Number(provOrg),
469:           count: provCount,
470:           valid_days: provDays.trim() === "" ? null : Number(provDays),
471:         }),
472:       });
473:       if (!res.ok) {
474:         console.error(`开通子席位失败 status=${res.status}`);
475:         toast({
476:           title: "开通失败",
477:           description: res.status >= 500 ? "服务端出错,请稍后重试或联系技术" : "请求被拒绝,请检查填写内容后重试",
478:           variant: "destructive",
479:         });
480:         return;
481:       }
482:       const b = await res.json();
483:       setShowProv(false);
484:       // 复用凭证弹窗(多个账号逐行展示 · 批 M 条 5:每席位一行「账号:X 密码:Y」)
485:       setCred({
486:         seats: b.seats.map((x: { username: string; password: string }) => ({
487:           username: x.username,
488:           password: x.password,
489:         })),
490:         kind: `为「${b.org_name}」开通 ${b.seats.length} 个子席位`,
491:       });
492:       await load();
493:     } catch (e) {
494:       console.error("开通子席位异常", e);
495:       toast({ title: "开通失败", description: "网络异常,请检查网络后重试", variant: "destructive" });
496:     } finally {
497:       setProvBusy(false);
498:     }
499:   };
500: 
### web/app/(dashboard)/admin/parent-seats/page.tsx:860-900
860:               </button>
861:               <button
862:                 onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
863:                 disabled={page >= pageCount}
864:                 aria-label="下一页"
865:                 className="w-7 h-7 grid place-items-center rounded-lg border border-slate-200 disabled:opacity-40 hover:bg-slate-50"
866:               >
867:                 <ChevronRight className="w-4 h-4" />
868:               </button>
869:             </span>
870:           </div>
871:         )}
872:       </div>
873: 
874:       {/* 明细大框(非抽屉) */}
875:       {detail && (
876:         <div className="fixed inset-0 z-50">
877:           <div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" onClick={closeDetail} />
878:           {/* 批 K 三令(长官 8-25):弹框固定高度 · 翻页时框体不跳,表格区内滚、页脚钉底 */}
879:           <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[1100px] max-w-[92vw] h-[720px] max-h-[88vh] flex flex-col bg-white rounded-2xl shadow-2xl border border-slate-100">
880:             <div className="flex items-center justify-between px-6 py-5 border-b border-slate-100">
881:               <div>
882:                 <h3 className="font-bold text-slate-900">{detail.org_name} · 子席位明细</h3>
883:                 <p className="text-[12.5px] text-slate-500 mt-0.5">
884:                   共 {detail.total} 个 · 使用中 {detail.in_use} · 已到期 {detail.expired}
885:                 </p>
886:               </div>
887:               <button
888:                 onClick={() => exportSeats(detail.org_id)}
889:                 disabled={exporting}
890:                 className="text-[12.5px] font-medium px-3 py-1.5 rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 disabled:opacity-50"
891:               >
892:                 {exporting ? "导出中…" : "导出明细"}
893:               </button>
894:             </div>
895:             {/* 批 K · 明细内筛选行(长官:「查看明细里加一个筛选框,可以筛状态/账号/到期时间等」)
896:                 Phase 83.9b/d · 加时间范围 + 筛选基准 + 状态多选(打开时继承主页当前值,弹框内
897:                 再调只影响弹框、不回写主页,见 dParams/loadDetail) */}
898:             <div className="flex items-center gap-2 px-6 py-3 border-b border-slate-100 bg-slate-50/50 flex-wrap">
899:               <div className="relative flex-1 min-w-[200px]">
900:                 <Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
### web/app/(dashboard)/admin/parent-seats/page.tsx:1140-1235
1140:       {/* 开通子席位弹窗 */}
1141:       {showProv && (() => {
1142:         // Phase 83.21 · 选中机构是否已设前缀(方案图第 13 块:没前缀不让开 · 有前缀灰字提示)
1143:         const provSelectedOrg = allOrgs.find((o) => String(o.id) === provOrg);
1144:         const provNoPrefix = !!provSelectedOrg && !provSelectedOrg.subseat_prefix;
1145:         return (
1146:         <div className="fixed inset-0 z-50">
1147:           <div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" onClick={() => setShowProv(false)} />
1148:           <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[420px] max-w-[92vw] bg-white rounded-2xl shadow-2xl border border-slate-100">
1149:             <div className="px-6 py-5 border-b border-slate-100">
1150:               <h3 className="font-bold text-slate-900">开通子席位</h3>
1151:               <p className="text-[12.5px] text-slate-500 mt-0.5">
1152:                 子席位不计入正式席位数、不进结算 · 开通后该机构的家长子席位功能同时启用
1153:               </p>
1154:             </div>
1155:             <div className="px-6 py-4 space-y-3 text-[13px]">
1156:               <div>
1157:                 <label htmlFor="prov-org" className="text-slate-500 text-[12.5px]">所属机构</label>
1158:                 {/* 批 K · 模糊检索选机构:输入过滤 + 点选回填 */}
1159:                 <div className="relative mt-1">
1160:                   <input
1161:                     id="prov-org"
1162:                     aria-label="所属机构"
1163:                     value={provPickOpen ? provSearch : (allOrgs.find((o) => String(o.id) === provOrg)?.name ?? "")}
1164:                     onFocus={() => { setProvPickOpen(true); setProvSearch(""); }}
1165:                     onChange={(e) => { setProvSearch(e.target.value); setProvPickOpen(true); }}
1166:                     placeholder="输入机构名检索"
1167:                     className="w-full border border-slate-200 rounded-xl px-3 py-2.5 bg-white outline-none focus:border-slate-400"
1168:                   />
1169:                   {provPickOpen && (
1170:                     <div className="absolute z-10 inset-x-0 mt-1 max-h-[200px] overflow-y-auto bg-white border border-slate-200 rounded-xl shadow-xl">
1171:                       {allOrgs
1172:                         .filter((o) => !provSearch.trim() || o.name.includes(provSearch.trim()))
1173:                         .slice(0, 50)
1174:                         .map((o) => (
1175:                           <button
1176:                             key={o.id}
1177:                             type="button"
1178:                             onClick={() => { setProvOrg(String(o.id)); setProvPickOpen(false); }}
1179:                             aria-label={`prov-org-${o.name}`}
1180:                             className={`w-full text-left px-3 py-2 text-[13px] hover:bg-slate-50 ${String(o.id) === provOrg ? "text-emerald-700 font-medium" : "text-slate-700"}`}
1181:                           >
1182:                             {o.name}
1183:                           </button>
1184:                         ))}
1185:                       {allOrgs.filter((o) => !provSearch.trim() || o.name.includes(provSearch.trim())).length === 0 && (
1186:                         <p className="px-3 py-3 text-[12.5px] text-slate-400 text-center">没有匹配的机构</p>
1187:                       )}
1188:                     </div>
1189:                   )}
1190:                 </div>
1191:               </div>
1192:               {/* Phase 83.21 · 前缀提示(方案图第 13 块) */}
1193:               {provSelectedOrg && (
1194:                 provNoPrefix ? (
1195:                   <p className="text-[12px] text-amber-600">
1196:                     该机构还没设置子席位账号前缀,请先到『机构管理 → 更多 → 子席位前缀』里设置
1197:                   </p>
1198:                 ) : (
1199:                   <p className="text-[12px] text-slate-400">
1200:                     账号前缀:{provSelectedOrg.subseat_prefix},新开的号接着往下排
1201:                   </p>
1202:                 )
1203:               )}
1204:               <div className="grid grid-cols-2 gap-3">
1205:                 <div>
1206:                   <label htmlFor="prov-count" className="text-slate-500 text-[12.5px]">数量(1-50)</label>
1207:                   <input
1208:                     id="prov-count"
1209:                     aria-label="数量"
1210:                     type="number"
1211:                     min={1}
1212:                     max={50}
1213:                     value={provCount}
1214:                     onChange={(e) => setProvCount(Number(e.target.value))}
1215:                     className="w-full mt-1 border border-slate-200 rounded-xl px-3 py-2.5"
1216:                   />
1217:                 </div>
1218:                 <div>
1219:                   <label htmlFor="prov-days" className="text-slate-500 text-[12.5px]">有效期(天)</label>
1220:                   <input
1221:                     id="prov-days"
1222:                     aria-label="有效期"
1223:                     type="number"
1224:                     min={1}
1225:                     max={3650}
1226:                     value={provDays}
1227:                     onChange={(e) => setProvDays(e.target.value)}
1228:                     className="w-full mt-1 border border-slate-200 rounded-xl px-3 py-2.5"
1229:                   />
1230:                 </div>
1231:               </div>
1232:             </div>
1233:             <div className="px-6 py-4 flex justify-end gap-2 border-t border-slate-100">
1234:               <button onClick={() => setShowProv(false)} className="text-[13px] px-5 py-2 rounded-xl border border-slate-200 text-slate-600">取消</button>
1235:               <button
## D12 回归测试文件存在性+用例数
tests/integration/test_phase74_admin_parent_seats.py -> 26
tests/integration/test_seat_login_e2e.py -> 23
tests/integration/test_phase83_batch5_seat_login.py -> 15
tests/integration/test_seat_auth_jwt_middleware.py -> 0
tests/integration/test_admin_brand_code.py -> 16
tests/integration/test_brand_code_claim.py -> 9
tests/integration/test_phase83_batch5_brand_code.py -> 7
tests/integration/test_phase52_emit_points.py -> 10
tests/unit/test_phase83_seat_seq_counter.py -> 11
tests/unit/test_phase68_seat_initial_password.py -> 0
tests/integration/test_seat_expiry_e2e.py -> 12
tests/contract/test_app_import_smoke.py -> 4
-- 按名字找(防路径猜错):
tests/contract/test_app_import_smoke.py
tests/integration/test_admin_brand_code.py
tests/integration/test_brand_code_claim.py
tests/integration/test_phase52_emit_points.py
tests/integration/test_phase68_seat_initial_password.py
tests/integration/test_phase74_admin_parent_seats.py
tests/integration/test_phase83_batch5_brand_code.py
tests/integration/test_phase83_batch5_seat_login.py
tests/integration/test_seat_expiry_e2e.py
tests/integration/test_seat_login_e2e.py
tests/unit/test_phase83_seat_seq_counter.py
tests/unit/test_seat_auth_jwt_middleware.py
-- 前端 parent-seats 测试:
web/__tests__/admin-menu-parent-seats.test.tsx
web/__tests__/admin-parent-seats-badge-extend.test.tsx
web/__tests__/admin-parent-seats-detail-filter-multi.test.tsx
web/__tests__/admin-parent-seats-source-column.test.tsx
web/__tests__/admin-parent-seats-subseat-prefix.test.tsx
web/__tests__/parent-seats-xlsx.test.ts
## D13 新文件确实不存在(对照 seat_service.py)
app/services/temp_password.py -> 0
app/services/seat_bulk_open_service.py -> 0
app/models/seat_open_batch.py -> 0
app/services/seat_service.py -> 1
## D14 前端下载写法先例
origin/test:web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:339:      const blob = await resp.blob();
origin/test:web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:340:      const url = URL.createObjectURL(blob);
origin/test:web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:451:      const blob = await resp.blob();
origin/test:web/app/(dashboard)/admin/orgs/[id]/brand-code/page.tsx:452:      const url = URL.createObjectURL(blob);
origin/test:web/app/(dashboard)/admin/orgs/[id]/seats/page.tsx:1376:    const url = URL.createObjectURL(blob);
origin/test:web/app/(dashboard)/admin/orgs/page.tsx:937:      const blob = await resp.blob();
origin/test:web/app/(dashboard)/admin/orgs/page.tsx:938:      const url = URL.createObjectURL(blob);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:358:    const url = URL.createObjectURL(blob);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:18:import { apiFetch } from "@/lib/auth";
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:209:        apiFetch(`/api/admin/parent-seats/stats?${p}`),
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:210:        apiFetch(`/api/admin/parent-seats/by-org?${p}`),
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:255:  // 三处共用同一份参数构造逻辑,不再各自裸 fetch(修复此前 openDetail 和行内刷新
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:259:      const res = await apiFetch(`/api/admin/parent-seats/orgs/${orgId}/detail?${dParams().toString()}`);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:318:      const res = await apiFetch(url, {
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:377:      const res = await apiFetch(`/api/admin/parent-seats/export?${p.toString()}`);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:438:      const res = await apiFetch("/api/v1/admin/orgs?page=1&page_size=200");
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:464:      const res = await apiFetch("/api/admin/parent-seats/provision", {
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:503:      const res = await apiFetch("/api/admin/parent-seats/feature-config");
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:527:      const res = await apiFetch("/api/admin/parent-seats/feature-config", {
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:556:        const res = await apiFetch("/api/admin/parent-seats/by-org");
## D15 依赖
origin/test:requirements.txt:31:# Authentication · Phase 29A 批 1 自研登录(casdoor 已删 · 改 python-jose + argon2)
origin/test:requirements.txt:33:python-jose[cryptography]==3.3.0
origin/test:requirements.txt:66:# 2026-07-17 · 补漏(同上)· app/api/excel_import.py:3 顶层 import openpyxl
origin/test:requirements.txt:68:openpyxl==3.1.5
## D16 子席位列表查询/导出
### app/services/parent_seat_service.py:25-40
25: from app.models.organization import Organization
26: from app.models.user import User
27: 
28: # 家长子席位判定(全模块唯一事实源)
29: _PARENT = (User.role == "org_seat", User.seat_type == "parent")
30: 
31: # Phase 83.6(长官 9-21):导出单开的硬上限,防单机构失控数据量拖垮请求。
32: # 超限时 org_seat_rows_for_export 必须显式标注 truncated=True,不许静默截断。
33: EXPORT_MAX_ROWS = 20000
34: 
35: 
36: def _now() -> datetime:
37:     return datetime.now(timezone.utc)
38: 
39: 
40: # ── 三态判定(效果图 §9 统计卡 + 状态列的唯一口径)────────────────────────
### app/services/parent_seat_service.py:95-145
95:     if org_ids:
96:         stmt = stmt.where(User.linked_org_id.in_(org_ids))
97:     elif org_id is not None:
98:         stmt = stmt.where(User.linked_org_id == org_id)
99:     if source:
100:         stmt = stmt.where(User.seat_source == source)
101:     else:
102:         # Phase 83.1(长官 9-18 拍板):子席位管理五处(列表/统计卡/机构聚合/明细/导出)
103:         # 默认只含 platform + store,brand_code(品牌码扫码开通)一律不出现 —— 扫码席位
104:         # 在品牌码页(admin_brand_code.py)单独管理。API 层 source 只放开
105:         # Literal["platform","store"](admin_parent_seats.py),永远传不进 "brand_code",
106:         # 所以这里必须显式排除,不能靠"传 source≠brand_code"做否定过滤。
107:         stmt = stmt.where(
108:             or_(User.seat_source.is_(None), User.seat_source != "brand_code")
109:         )
110:     if state:
111:         # Phase 83.8(长官 9-21):state 改多选,逗号串在 API 层已解析成 list;
112:         # 单值 ["pending"] 与旧 == "pending" 等价。四态条件互不互斥(如某席位可
113:         # 同时满足 pending 与 disabled),多选按 OR 合并,不是互斥单选。
114:         _cond_map = {
115:             "expired": _expired_cond,
116:             "in_use": _in_use_cond,
117:             "pending": _pending_cond,
118:             "disabled": lambda: User.is_active.is_(False),
119:         }
120:         conds = [_cond_map[s]() for s in state if s in _cond_map]
121:         if conds:
122:             stmt = stmt.where(or_(*conds))
123:     time_col = {
124:         "opened": User.created_at,
125:         "bound": User.seat_bound_at,
126:         "expired": User.seat_expires_at,
127:     }.get(time_field, User.created_at)
128:     if start is not None:
129:         stmt = stmt.where(time_col >= start)
130:     if end is not None:
131:         stmt = stmt.where(time_col <= end)
132:     if q and q.strip():
133:         escaped = q.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
134:         like = f"%{escaped}%"
135:         stmt = stmt.where(
136:             or_(
137:                 User.username.ilike(like),
138:                 User.phone.ilike(like),
139:                 # 9-25 长官令:总表搜索加「微信昵称」维度,跟明细弹窗口径一致
140:                 User.wechat_nickname.ilike(like),
141:             )
142:         )
143:     return stmt
144: 
145: 
## D17 重置密码/停用端点
origin/test:app/api/admin_parent_seats.py:115:@router.post("/provision")
origin/test:app/api/admin_parent_seats.py:154:@router.get("/stats")
origin/test:app/api/admin_parent_seats.py:171:@router.get("/by-org")
origin/test:app/api/admin_parent_seats.py:192:@router.get("/feature-config")
origin/test:app/api/admin_parent_seats.py:207:@router.put("/feature-config")
origin/test:app/api/admin_parent_seats.py:230:@router.get("/orgs/{org_id}/detail")
origin/test:app/api/admin_parent_seats.py:254:@router.get("/export")
origin/test:app/api/admin_parent_seats.py:291:@router.patch("/{seat_id}/status")
origin/test:app/api/admin_parent_seats.py:302:@router.post("/{seat_id}/reset-password")
origin/test:app/api/admin_parent_seats.py:312:@router.post("/{seat_id}/reassign")
origin/test:app/api/admin_parent_seats.py:322:@router.patch("/{seat_id}/extend")
### app/api/admin_parent_seats.py:288-340
288: # ============================================================================
289: # 行内管控(复用 seat_service 七件套 · 前置 parent 守门)
290: # ============================================================================
291: @router.patch("/{seat_id}/status")
292: async def set_status(
293:     seat_id: int,
294:     body: StatusIn,
295:     admin: dict = Depends(require_role("admin")),
296:     db: AsyncSession = Depends(get_db),
297: ) -> dict[str, Any]:
298:     seat = await ps.get_parent_seat_or_404(seat_id, db)
299:     return await seat_service.set_seat_status(int(seat.linked_org_id), seat_id, body.is_active, db)
300: 
301: 
302: @router.post("/{seat_id}/reset-password")
303: async def reset_password(
304:     seat_id: int,
305:     admin: dict = Depends(require_role("admin")),
306:     db: AsyncSession = Depends(get_db),
307: ) -> dict[str, Any]:
308:     seat = await ps.get_parent_seat_or_404(seat_id, db)
309:     return await seat_service.reset_seat_password(int(seat.linked_org_id), seat_id, db)
310: 
311: 
312: @router.post("/{seat_id}/reassign")
313: async def reassign(
314:     seat_id: int,
315:     admin: dict = Depends(require_role("admin")),
316:     db: AsyncSession = Depends(get_db),
317: ) -> dict[str, Any]:
318:     seat = await ps.get_parent_seat_or_404(seat_id, db)
319:     return await seat_service.reassign_seat_user(int(seat.linked_org_id), seat_id, db)
320: 
321: 
322: @router.patch("/{seat_id}/extend")
323: async def extend(
324:     seat_id: int,
325:     body: ExtendIn,
326:     admin: dict = Depends(require_role("admin")),
327:     db: AsyncSession = Depends(get_db),
328: ) -> dict[str, Any]:
329:     """Phase 83.3 · 子席位延期透传路由
330: 
331:     只透传,业务逻辑全在 seat_service.extend_seat(511 行,已存在,禁止重写)。
332:     不让前端直接打通用端点 admin_seats.py 的
333:     `PATCH /admin/orgs/{org_id}/seats/{seat_id}/extend` —— 那个端点没有
334:     "必须是家长席位"校验,门店席位 id 也能打进去;这里复用本文件既有的
335:     get_parent_seat_or_404 三层守门,拿门店席位 id 打进来一律 404。
336:     """
337:     seat = await ps.get_parent_seat_or_404(seat_id, db)
338:     return await seat_service.extend_seat(
339:         org_id=int(seat.linked_org_id),
340:         seat_id=seat_id,
## D18 密钥
### app/core/jwt_local.py:18-30
fatal: path 'app/core/jwt_local.py' does not exist in 'origin/test'
origin/test:app/main.py:13:# Phase 29A 批 1.3 · JWT_SECRET_KEY 启动 verify(≥ 64 字节 abort)
origin/test:app/main.py:16:if len(os.getenv("JWT_SECRET_KEY", "")) < 64:
origin/test:app/main.py:18:        "FATAL: JWT_SECRET_KEY env 未配或 < 64 字节 · 启动 abort · "
## D19 测试夹具
tests/__init__.py
tests/api
tests/conftest.py
tests/contract
tests/e2e
tests/fixtures
tests/integration
tests/migrations
tests/models
tests/phase21_uat.js
tests/screenshots
tests/scripts
tests/services
tests/smoke
tests/tasks
tests/test_alert_not_alertable_types.py
tests/test_alert_plainify_27b.py
tests/test_build_kb_a_exclude_video.py
tests/test_login_fail_grace.py
tests/test_miniapp_profile.py
tests/test_persona_manager.py
tests/test_phase45_4_ingest_video.py
tests/test_phase45_5_video_rag.py
tests/test_phase45_6_video_media.py
tests/test_phase45_header_strip.py
tests/test_phase45_parent_doc.py
tests/test_phase67_no_inline_figures.py
tests/test_phase79_api.py
tests/test_phase79_distill.py
tests/test_phase79_film_analysis.py
tests/test_phase79_menu.py
tests/test_phase79_models.py
tests/test_phase79_orchestrate.py
tests/test_phase79_task.py
tests/test_tts_sft_stream_incident.py
tests/uat
tests/unit
origin/test:tests/conftest.py:18:@pytest.fixture(scope="session", autouse=True)
origin/test:tests/conftest.py:19:def _phase83_seat_seq_counter_table():
origin/test:tests/conftest.py:33:@pytest.fixture(scope="session")
origin/test:tests/conftest.py:34:def client():
origin/test:tests/conftest.py:39:@pytest.fixture(scope="function")
origin/test:tests/conftest.py:40:async def async_client():
origin/test:tests/conftest.py:45:@pytest.fixture(scope="function")
origin/test:tests/conftest.py:46:async def db_session():
origin/test:tests/conftest.py:53:@pytest.fixture(scope="function")
origin/test:tests/conftest.py:54:async def redis_client():
origin/test:tests/conftest.py:62:@pytest.fixture(scope="session")
origin/test:tests/conftest.py:63:def auth_headers():
origin/test:tests/conftest.py:68:@pytest.fixture(scope="function")
origin/test:tests/conftest.py:69:async def seed_doctor(db_session):
origin/test:tests/conftest.py:123:@pytest.fixture(scope="function")
origin/test:tests/conftest.py:124:async def seed_org(seed_doctor):
origin/test:tests/integration/conftest.py:18:@pytest.fixture(scope="function")
origin/test:tests/integration/conftest.py:19:async def nested_session():
## D20 部分唯一索引写法先例
origin/test:migrations/versions/phase48a_001_brand_code.py:113:        postgresql_where=sa.text("wechat_unionid IS NULL"),
### migrations/versions/phase83_002_subseat_prefix_and_phone_login.py:1-60
1: """phase83_002 · 子席位前缀 + 按机构家长号计数表 + 手机号登录开关
2: 
3: 依据:docs/superpowers/plans/tech/phase83-batch5-login-overhaul.md 第二节 §2.1-2.2
4: 
5: 本迁移做什么(tech §2.2 三处结构变更):
6: 1. `organizations` 加列 `subseat_prefix VARCHAR(6) NULL`(家长号前缀,机构管理员手动
7:    设置,未设置的机构全部 NULL,不回填)+ 唯一索引(PG 允许多个 NULL 并存)+ CHECK
8:    限定 1-6 个大写字母。
9: 2. 新表 `org_parent_seat_counter`:按机构(而非按月,替代旧的 `seat_seq_counter`)
10:    原子计数取号,`last_seq` 起点/上限用 CHECK 锁在 100000~999999(六位数字)。
11: 3. `users` 加列 `phone_login_enabled BOOLEAN NOT NULL DEFAULT false`:新席位开、老席
12:    位一律 false 零感知(决策 3/5、红线 1)。
13: 
14: 【手写原因】`app/models/user.py:46` 的 `phone` 列声明写着 `unique=True`,但生产 / test
15: 库里 `phone` 实际由 phase74_001 建的是**三个带条件的唯一索引**
16: (`uq_users_phone_h5` / `uq_users_phone_store` / `uq_users_phone_parent`,9-24 对生产
17: `pg_indexes` 实查确认),模型声明与库内真实约束不一致。若用 `alembic revision
18: --autogenerate`,工具会照 ORM 声明反推出一个覆盖全表的唯一约束/索引,与库内已有的
19: 三个条件唯一索引冲突(重复约束 / 语义错误),所以本迁移及后续所有涉及 `users` 表的
20: 迁移都必须手写,不得 autogenerate。
21: 
22: 【挂载点】本仓当前 3 个 alembic head:`34ec94466f0c` / `phase79_001` / `phase83_001`
23: (`alembic heads` 实测,2026-09-24)。批次五只接 `phase83_001` 这一支,不做
24: merge heads(历史多头债务,不在本批范围,见 phase83_001 迁移注释同款先例)。
25: 
26: Revision ID: phase83_002
27: Revises: phase83_001
28: Create Date: 2026-09-24
29: """
30: from alembic import op
31: import sqlalchemy as sa
32: 
33: revision = "phase83_002"
34: down_revision = "phase83_001"
35: branch_labels = None
36: depends_on = None
37: 
38: 
39: def upgrade() -> None:
40:     # --- a) organizations.subseat_prefix -----------------------------------
41:     op.add_column(
42:         "organizations",
43:         sa.Column(
44:             "subseat_prefix",
45:             sa.String(6),
46:             nullable=True,
47:             comment="子席位(家长号)账号前缀 · 1-6 个大写字母 · 跨机构唯一 · "
48:             "开过号后不可改 · Phase 83.13",
49:         ),
50:     )
51:     # --- b) 唯一索引(普通唯一索引,PG 允许多个 NULL 并存) -------------------
52:     op.create_index(
53:         "uq_organizations_subseat_prefix",
54:         "organizations",
55:         ["subseat_prefix"],
56:         unique=True,
57:     )
58:     # --- c) 格式 CHECK ---------------------------------------------------
59:     op.create_check_constraint(
60:         "ck_organizations_subseat_prefix_format",
## D18b 密钥真实路径
origin/test:app/auth/jwt_local.py:11:JWT_SECRET_KEY env 启动时由 main.py 校验 ≥ 64 字节 · 本模块只读 env · 不二次校验。
origin/test:app/auth/jwt_local.py:24:JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")  # 启动时由 main.py verify ≥ 64 字节
## D21 🔴 verify_password 全部调用点(临时密码号会不会在别处被校验)
origin/test:app/api/internal_settlement.py:283:        if not verify_password(payload.old_password, account.password_hash):
origin/test:app/api/seat_auth.py:218:    if seat is None or not verify_password(body.password, seat.password_hash or ""):
origin/test:app/api/seat_auth.py:332:        if not verify_password(body.password, seat.password_hash or ""):
origin/test:app/api/seat_auth.py:354:        if verify_password(body.password, c.password_hash or ""):
origin/test:app/services/auth_service.py:213:    if user is None or not verify_password(password, user.password_hash or ""):
origin/test:app/services/auth_service.py:418:    if not verify_password(old_password, user.password_hash or ""):
origin/test:app/services/auth_service.py:443:        if verify_password(new_password, h.password_hash):
origin/test:app/services/password_utils.py:47:def verify_password(plain: str, hashed: str) -> bool:
## D22 改密端点是否校验旧密码
origin/test:app/api/seat_auth.py:1002:async def seat_change_password(
## D23 测试夹具建表方式
### tests/conftest.py:1-52
1: """
2: pytest configuration for adding project root to sys.path
3: """
4: import sys
5: from pathlib import Path
6: import pytest
7: from fastapi.testclient import TestClient
8: from httpx import AsyncClient, ASGITransport
9: import redis.asyncio as redis
10: import os
11: 
12: project_root = Path(__file__).parent.parent
13: sys.path.insert(0, str(project_root))
14: 
15: from app.main import app
16: 
17: 
18: @pytest.fixture(scope="session", autouse=True)
19: def _phase83_seat_seq_counter_table():
20:     """Phase 83.10 · seat_seq_counter 尚未跑 alembic upgrade(migration 由主会话
21:     执行,测试 agent 禁止跑)。generate_seats(seat_type='parent') 现在依赖这张表,
22:     没有它会在任何调用点炸 UndefinedTableError,牵连一切既有的 parent 路径测试
23:     (即便测试文件本身与本 Phase 无关)。这里全局幂等建表(checkfirst 默认开·
24:     已存在则什么都不做·从不 DROP),migration 真跑之后这个 fixture 自然变成
25:     no-op,不需要再改。"""
26:     from app.database import sync_engine
27:     from app.models.database import Base
28:     from app.models.seat_seq_counter import SeatSeqCounter
29: 
30:     Base.metadata.create_all(bind=sync_engine, tables=[SeatSeqCounter.__table__])
31: 
32: 
33: @pytest.fixture(scope="session")
34: def client():
35:     """提供 FastAPI TestClient(铁律 1 要求)"""
36:     with TestClient(app) as c:
37:         yield c
38: 
39: @pytest.fixture(scope="function")
40: async def async_client():
41:     """提供异步 HTTP 客户端"""
42:     async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
43:         yield ac
44: 
45: @pytest.fixture(scope="function")
46: async def db_session():
47:     """提供测试数据库会话(铁律 1 要求)"""
48:     from app.database import async_session_maker
49:     async with async_session_maker() as session:
50:         yield session
51:         await session.rollback()
52: 
### tests/integration/conftest.py:1-40
1: """Integration test 专用 fixture(仅影响 tests/integration/ 目录)
2: 
3: 为 27b.6.5 / 27b.7.5 整合测试解决一个核心问题:
4: service 内部 `await db.commit()` 会真提交到 PG,conftest 默认 db_session.rollback()
5: 对已 commit 数据无效,必污染测试 DB。
6: 
7: 方案:SQLAlchemy 2.0 `join_transaction_mode="create_savepoint"`
8: - outer transaction 在 fixture 启动 → 测试结束 rollback
9: - service 内 await db.commit() → 实际 RELEASE SAVEPOINT,outer trans 仍 active
10: - 测试退出 → outer trans rollback → 撤销所有 service-committed 数据
11: """
12: from __future__ import annotations
13: 
14: import pytest
15: from sqlalchemy.ext.asyncio import AsyncSession
16: 
17: 
18: @pytest.fixture(scope="function")
19: async def nested_session():
20:     """SAVEPOINT-based session · service.commit() 视为 savepoint release"""
21:     from app.database import engine
22: 
23:     async with engine.connect() as conn:
24:         outer_trans = await conn.begin()
25:         session = AsyncSession(
26:             bind=conn,
27:             expire_on_commit=False,
28:             join_transaction_mode="create_savepoint",
29:         )
30:         try:
31:             yield session
32:         finally:
33:             await session.close()
34:             await outer_trans.rollback()
## D12b 路径更正后用例数
tests/unit/test_seat_auth_jwt_middleware.py -> 13
tests/integration/test_phase68_seat_initial_password.py -> 5
## D24 startup_event 结束位置
327: async def startup_event():
396: @app.on_event("shutdown")
397: async def shutdown_event():
## D25 models/__init__ 注册写法
### app/models/__init__.py:90-115
90: from app.models.refractive_record import (
91:     RefractiveRecord,
92:     RefractiveRecordEye,
93:     RefractiveRecordVisualFunction,
94: )
95: # Phase 48A · 品牌方合作 - 品牌码(NEW · 未 import 则不进 Base.metadata,
96: # autogenerate 会误判为多余表生成 DROP TABLE,同 SettlementIdempotency/ArchiveLog 的坑)
97: from app.models.brand_code import BrandCode, BrandCodeActivation
98: # Phase 85 · 企微客户群代理(未 import 则不进 Base.metadata · 同上坑)
99: from app.models.wecom_agent import (
100:     WecomServiceGroup,
101:     WecomOrgSettings,
102:     WecomEvent,
103:     WecomAgentHeartbeat,
104: )
105: 
106: __all__ = [
107:     "Base",
108:     "Doctor",
109:     "KnowledgeBaseRouting",
110:     "Organization",
111:     "OrgBrainConfig",
112:     "APICredential",
113:     "SessionToken",
114:     "UsageLog",
115:     "User",
## D26 前端开通弹窗 state + 明细导出函数
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:166:  // 修「保存静默失败」bug · 照 provBusy 惯例:保存中禁用按钮防连点 + 失败要出声
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:174:  const [provCount, setProvCount] = useState(1);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:175:  const [provDays, setProvDays] = useState<string>("365");
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:176:  const [provBusy, setProvBusy] = useState(false);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:352:  const [exporting, setExporting] = useState(false);
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:368:  const exportSeats = async (orgId?: number) => {
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:469:          count: provCount,
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:470:          valid_days: provDays.trim() === "" ? null : Number(provDays),
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:1213:                    value={provCount}
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:1226:                    value={provDays}
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:1237:                disabled={!provOrg || provBusy || provNoPrefix}
origin/test:web/app/(dashboard)/admin/parent-seats/page.tsx:1240:                {provBusy ? "开通中…" : "确认开通"}
### web/app/(dashboard)/admin/parent-seats/page.tsx:340-362
340:         await loadDetail(detail.org_id);
341:       }
342:       await load();
343:     } catch (e) {
344:       console.error(`${actionLabel}异常`, e);
345:       toast({ title: `${actionLabel}失败`, description: "网络异常,请检查网络后重试", variant: "destructive" });
346:     }
347:   };
348: 
349:   /** 批 K(长官 8-25 改口径)· 导出当前筛选范围:
350:       单机构 → 1 个 xlsx;多机构 → 每机构 1 个 xlsx 打成 zip(文件名带机构名)。
351:       Excel 内部:标题行 + 品牌绿表头 + 斑马纹细边框(lib/xlsx 批 K 升级)。 */
352:   const [exporting, setExporting] = useState(false);
353: 
354:   const doDownload = (bytes: Uint8Array, name: string, mime: string) => {
355:     // ⚠️ 必须 appendChild 进 DOM 再 click,且 click 后才 revoke ——
356:     // 脱离 DOM 的 <a> 在 Chromium 下不触发下载(照门店凭证下载 page.tsx:1377 的写法)
357:     const blob = new Blob([new Uint8Array(bytes)], { type: mime });
358:     const url = URL.createObjectURL(blob);
359:     const a = document.createElement("a");
360:     a.href = url;
361:     a.download = name;
362:     document.body.appendChild(a);
## D27 重置密码写什么
origin/test:app/services/seat_service.py:455:async def reset_seat_password(
## D28 登录是否写 last_login_at(已激活判据前提)
origin/test:app/api/seat_auth.py:404:    6. seat.last_login_at = now()
origin/test:app/api/seat_auth.py:613:    # ── Step 5+6: 写 current_device_id(仅记录最近登录设备 · 多设备共存,不踢旧) + last_login_at ──
origin/test:app/api/seat_auth.py:615:    seat.last_login_at = datetime.now(timezone.utc)
origin/test:app/services/auth_service.py:245:    user.last_login_at = datetime.now(timezone.utc)
## D29 重置密码函数体
### app/services/seat_service.py:455-492
455: async def reset_seat_password(
456:     org_id: int, seat_id: int, db: AsyncSession
457: ) -> dict:
458:     """重置席位密码 · 新临时密码 + 清 current_device_id(踢下线) · 返新明文
459: 
460:     org 隔离:seat.linked_org_id == org_id 否则 404(_get_seat_in_org_or_404)。
461:     清 current_device_id = 被动踢(防旧设备继续用 · 镜像 seat_auth 被动踢机制)。
462:     """
463:     seat = await _get_seat_in_org_or_404(org_id, seat_id, db)
464: 
465:     temp_pw = generate_seat_initial_password()
466:     seat.password_hash = hash_password(temp_pw)
467:     seat.current_device_id = None  # 踢下线 · 旧设备 refresh 时 device_id 失配 401
468:     seat.device_epoch = (seat.device_epoch or 0) + 1  # 代际号 +1 · 防新登录填回槽位后误恢复旧 token
469:     # 2026-07-13:重置后强制改密(镜像渠道重置 channels.py:687 / 后台用户重置
470:     # user_mgmt_service.py:278)· 否则激活过的席位 must_change=False · seat_login
471:     # 下发 False · 前端不弹改密窗 · 临时密码变长期密码。
472:     seat.must_change_password = True
473:     await db.commit()
474: 
475:     # bug2 L3(2026-08-08):重置=明确解锁意图 · 清冷却+失败账本 ·
476:     # 否则新临时密码仍被残余冷却拦(7-12 渠道同款陷阱)
477:     await clear_login_cooldown(seat, db)
478: 
479:     return {"username": seat.username, "password": temp_pw}
480: 
481: 
482: # ============================================================================
483: # Phase 50 · reassign_seat_user(POST /api/admin/orgs/{id}/seats/{seat_id}/reassign)
484: # ============================================================================
485: async def reassign_seat_user(
486:     org_id: int, seat_id: int, db: AsyncSession
487: ) -> dict:
488:     """一键换绑使用人(D4) · 清 seat_bound_* + 重置密码 + 踢设备 + 返新明文
489: 
490:     一步完成「换人」(覆盖换人 + 本人换微信号/丢号两场景 · 都安全):
491:     - 清微信绑定 → 下次登录 seat_bound_unionid IS NULL → 首绑新人
492:     - 重置密码 + 踢设备 → 旧凭证立即失效(防旧人抢绑)
## D30 页面现有 exportSeats 调用后端导出+下载链路
### web/app/(dashboard)/admin/parent-seats/page.tsx:368-400
368:   const exportSeats = async (orgId?: number) => {
369:     if (exporting) return;
370:     setExporting(true);
371:     try {
372:       const p = params();
373:       if (orgId != null) {
374:         p.delete("org_ids");
375:         p.set("org_id", String(orgId));
376:       }
377:       const res = await apiFetch(`/api/admin/parent-seats/export?${p.toString()}`);
378:       if (!res.ok) {
379:         console.error(`导出失败 status=${res.status}`);
380:         toast({
381:           title: "导出失败",
382:           description: res.status >= 500 ? "服务端出错,请稍后重试或联系技术" : "请求被拒绝,请重试",
383:           variant: "destructive",
384:         });
385:         return;
386:       }
387:       const groups: { org_id: number; org_name: string; items: (SeatRow & { org_name?: string })[] }[] =
388:         (await res.json()).orgs || [];
389:       if (!groups.length) {
390:         // 筛选范围内确实没有数据 · 不是失败,提示语要跟上面「导出失败」区分开
391:         toast({ title: "没有可导出的数据", description: "当前筛选范围内暂无子席位记录" });
392:         return;
393:       }
394:       const today = new Date().toISOString().slice(0, 10);
395:       const mk = (g: (typeof groups)[number]) =>
396:         buildXlsx({
397:           sheetName: "子席位",
398:           title: `${g.org_name} · 家长子席位 · ${today}`,
399:           headers: ["账号", "机构", "家长手机", "微信昵称", "状态", "开通", "到期", "来源"],
400:           rows: g.items.map((r) => [

自检勘误(9-28 · 主会话复核本记录后追加)

  1. D1 命令里 app/core/jwt_local.py 路径写错(该路径不存在,git diff 对不存在的路径静默返回空,所以 D1 的「空」对它无意义)。真实路径是 app/auth/jwt_local.py(见 D18b),已补查:git diff --stat 3a75d611 81962b75 -- app/auth/jwt_local.py 输出为空;同命令对照 git diff --stat 3a75d611 81962b75 | head -1 输出 app/api/wecom_agent.py | 19 +-,证明命令有效
  2. D18 里的 fatal: path 'app/core/jwt_local.py' does not exist 是同一个路径错误,以 D18b 为准(app/auth/jwt_local.py:24)
  3. D2 的 0 命中只限 migrations/ app/ web/app/ tests/,不是全仓;docs/ 里有命中(方案文档本身:docs/superpowers/plans/tech/phase83-batch4-bulk-open.md)
  4. D22「改密不校验旧密码」单看 D22 证据偏弱(只查了 old_password/current_password 两个名字)。更强的证据是 D21:seat_auth.py 里 verify_password( 只出现在 :218/:332/:354,都不在 seat_change_password(:1002 起)函数体里
  5. D1b-D20 这一段是子代理原样执行主会话给定脚本产生的。主会话抽查亲跑:brand_code_service.py :97/:104/:108/:113、activity.py:107、test_seat_login_e2e.py 用例数 23,与记录完全一致
  6. D27 只定位了函数起始行,函数体在 D29