交付出件箱
← 返回出件箱
批次四 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 · 主会话复核本记录后追加)
- 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 +-,证明命令有效
- D18 里的
fatal: path 'app/core/jwt_local.py' does not exist 是同一个路径错误,以 D18b 为准(app/auth/jwt_local.py:24)
- D2 的 0 命中只限
migrations/ app/ web/app/ tests/,不是全仓;docs/ 里有命中(方案文档本身:docs/superpowers/plans/tech/phase83-batch4-bulk-open.md)
- D22「改密不校验旧密码」单看 D22 证据偏弱(只查了
old_password/current_password 两个名字)。更强的证据是 D21:seat_auth.py 里 verify_password( 只出现在 :218/:332/:354,都不在 seat_change_password(:1002 起)函数体里
- D1b-D20 这一段是子代理原样执行主会话给定脚本产生的。主会话抽查亲跑:
brand_code_service.py :97/:104/:108/:113、activity.py:107、test_seat_login_e2e.py 用例数 23,与记录完全一致
- D27 只定位了函数起始行,函数体在 D29