交付出件箱

← 返回出件箱

批次四 83.28 开工前 verify 原始输出

# 83.28 开工前 verify · 1980fbeb docs(phase83.batch4): 83.26/83.27 标 ✅(8f34d79a/82e8304c · 主会话亲跑证据见提交说明) · 2026-09-28 22:23:46
## V1 seat_service 取号/查重/取机构
### app/services/seat_service.py:1-60
1: """Seat Management Service · Phase 46.5 NEW · 席位 CRUD(镜像 user_mgmt_service)
2: 
3: 按席位售卖防共享授权 · seat = users 表 role='org_seat' 行(复用 h5_user 模型)。
4: 跟 tech §5.2 + impl 46.5 验收标准 1:1 对齐。
5: 
6: 4 个公开函数:
7: - generate_seats(org_id, count, db) → [{username, password(明文 · 仅此次)}]
8: - list_seats(org_id, page, size, db, q=None) → {items, total} · q=体验门店名称 ILIKE 检索
9: - reset_seat_password(org_id, seat_id, db) → {username, password(新明文)} · 清 current_device_id(踢)
10: - set_seat_status(org_id, seat_id, is_active, db) → None · 停用清 current_device_id(踢)
11: 
12: 设计要点(tech §5.2 + §6 安全测绘):
13: - 用户名 `org{org_id}-{rand6}` 随机后缀(secrets · 防枚举 · O2 已定)+ 查重
14: - 初始密码 generate_seat_initial_password() 6 位数字(Phase 68 · 原 12 位混合乱码难传难输)
15:   · DB 只存 argon2 hash · 明文仅生成/重置一次性返回
16: - org 隔离:按 seat_id 操作的函数均校验 seat.linked_org_id == org_id(防跨机构越权)
17: - reset / 停用 清 current_device_id = 被动踢下线(席位换手机/防共享)
18: 
19: 依赖:
20: - app/models/user.py: User(role='org_seat' · username/password_hash/linked_org_id/is_active/current_device_id)
21: - app/models/organization.py: Organization(org 存在性校验)
22: - app/services/password_utils.py: generate_seat_initial_password() + hash_password
23:   (generate_temp_password 12 位版仍在,归后台管理员账号用 · 两者不可合并)
24: """
25: from __future__ import annotations
26: 
27: import logging
28: import re
29: from datetime import datetime, timedelta, timezone
30: from typing import Any, Optional
31: 
32: from fastapi import HTTPException
33: from sqlalchemy import func, or_, select
34: from sqlalchemy.dialects.postgresql import insert as pg_insert
35: from sqlalchemy.exc import IntegrityError
36: from sqlalchemy.ext.asyncio import AsyncSession
37: 
38: from app.models.org_parent_seat_counter import OrgParentSeatCounter
39: from app.models.organization import Organization
40: from app.models.user import User
41: from app.services.auth_service import clear_login_cooldown
42: from app.services.password_utils import generate_seat_initial_password, hash_password
43: 
44: logger = logging.getLogger(__name__)
45: 
46: 
47: # ============================================================================
48: # Internal helpers
49: # ============================================================================
50: def _seat_status(u: User) -> str:
51:     """三态派生(二期批3 spec 项3 · 口径与 web/app/h5/settings/components/SeatStoreCard.tsx
52:     ParentSubseatSection history 步的既有前端算法逐字对齐):只认 seat_expires_at,
53:     不看 seat_bound_at(那是微信身份绑定,跟席位到期是两回事 · 已有历史教训见三态文档)。
54: 
55:     NULL → "pending"(待绑定/未激活);有值+未过期 → "active"(使用中);
56:     有值+已过期 → "expired"(已到期)。
57:     """
58:     if u.seat_expires_at is None:
59:         return "pending"
60:     from datetime import datetime, timezone as _tz
### app/services/seat_service.py:213-258
213: async def _alloc_parent_usernames(
214:     db: AsyncSession, org_id: int, prefix: str, count: int
215: ) -> list[str]:
216:     """批量分配家长席位新格式账号(Phase 83.14)。
217: 
218:     格式:前缀(1-6 个大写字母)+ 6 位机构内序号(如 BF100001)。
219: 
220:     撞号只剩一类(计数表按机构原子递增,同批/跨并发请求天然不重复;不再有
221:     月度格式与旧格式撞车的问题 —— 前缀已跟旧的纯数字门店/老家长号格式区分开):
222:     仍保留批量 `SELECT ... WHERE username IN (...)` 查重兜底(username 全表唯一,
223:     手工建的后台账号理论上可能撞)。
224: 
225:     不足时**重新走计数表再取一批**(不自行把 new_max 往后数,否则会与并发请求
226:     的号段重叠)· 最多重试 5 轮,超限抛明确错误而非 `while True`。
227:     """
228:     collected: list[str] = []
229:     remaining = count
230:     max_rounds = 5
231:     for _round in range(max_rounds):
232:         if remaining <= 0:
233:             break
234:         seq_range = await _alloc_org_seq_batch(db, org_id, remaining)
235:         # 计数表的行锁到这里立即释放(commit),不横跨后面的 User 插入循环。
236:         await db.commit()
237:         candidates = [f"{prefix}{seq:06d}" for seq in seq_range]
238:         existing = set(
239:             (
240:                 await db.execute(
241:                     select(User.username).where(User.username.in_(candidates))
242:                 )
243:             ).scalars().all()
244:         )
245:         collected.extend(c for c in candidates if c not in existing)
246:         remaining = count - len(collected)
247: 
248:     if remaining > 0:
249:         raise HTTPException(
250:             status_code=500,
251:             detail="生成家长席位账号失败(撞号重试次数超限)· 请重试",
252:         )
253:     return collected[:count]
254: 
255: 
256: # ============================================================================
257: # 46.5 generate_seats(POST /api/admin/orgs/{id}/seats/generate)
258: # ============================================================================
25:from __future__ import annotations
27:import logging
28:import re
29:from datetime import datetime, timedelta, timezone
30:from typing import Any, Optional
32:from fastapi import HTTPException
33:from sqlalchemy import func, or_, select
34:from sqlalchemy.dialects.postgresql import insert as pg_insert
35:from sqlalchemy.exc import IntegrityError
36:from sqlalchemy.ext.asyncio import AsyncSession
38:from app.models.org_parent_seat_counter import OrgParentSeatCounter
39:from app.models.organization import Organization
40:from app.models.user import User
41:from app.services.auth_service import clear_login_cooldown
42:from app.services.password_utils import generate_seat_initial_password, hash_password
98:async def _get_org_or_404(org_id: int, db: AsyncSession) -> Organization:
129:async def _gen_unique_username(org_id: int, db: AsyncSession) -> str:
## V2 generate_seat_initial_password 定义处
app/services/password_utils.py:108:def generate_seat_initial_password() -> str:
## V3 activity_log_service 头部+emit_lifecycle 全文
### app/services/activity_log_service.py:1-20
1: """Phase 52.2 · 席位行为埋点服务(纯 service 层 · 端点接线在 52.3)
2: 
3: 三个函数:
4: - resolve_actor  : 把鉴权上下文 (external_user_id, org_id, channel) 映射到
5:                    (actor_type, actor_id, org_id);无法归因返回 None
6: - emit_activity  : 写 seat_activity_log 一行(fire-and-forget best-effort · D12)
7: - emit_lifecycle : 写 account_lifecycle_event 一行(生命周期/结果标签源 · 53 用)
8: 
9: 铁律:
10: - best-effort(D12):埋点失败绝不影响主业务链路 → 全捕 Exception + log_incident + 不 re-raise
11: - fresh session(仿 h5_chat.py:_write_chat_messages_fresh):内部自开 async_session_maker
12:   session · 不接收外部 db 参数 · 与请求链路解耦(避免 AsyncSession 跨 ASGI scope 死局)
13: - detail 只放枚举 + 引用(D13)· 禁内容拷贝(见下方白名单注释)
14: """
15: import asyncio
16: from typing import Any, Optional
17: 
18: from app.database import async_session_maker
19: from app.models.activity import AccountLifecycleEvent, ReportImage, SeatActivityLog
20: from app.services.incident_logger import log_incident
### app/services/activity_log_service.py:194-240
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",
226:                 "subject_type": subject_type,
227:                 "event_type": event_type,
228:                 "error": str(e)[:300],
229:             },
230:         )
## V4 emit_lifecycle / schedule_emit 全部调用方
app/services/activity_log_service.py:7:- emit_lifecycle : 写 account_lifecycle_event 一行(生命周期/结果标签源 · 53 用)
app/services/activity_log_service.py:225:                "op": "emit_lifecycle",
app/services/seat_service.py:361:    from app.services.activity_log_service import emit_lifecycle, schedule_emit
app/services/seat_service.py:363:        schedule_emit(emit_lifecycle(org_id, "seat", str(sid), "seat_added", {}))
app/services/seat_service.py:603:    from app.services.activity_log_service import emit_lifecycle, schedule_emit
app/services/seat_service.py:604:    schedule_emit(emit_lifecycle(
app/services/seat_service.py:662:        from app.services.activity_log_service import emit_lifecycle, schedule_emit
app/services/seat_service.py:663:        schedule_emit(emit_lifecycle(
app/services/seat_service.py:682:    from app.services.activity_log_service import emit_lifecycle, schedule_emit
app/services/seat_service.py:683:    schedule_emit(emit_lifecycle(
app/services/org_service.py:155:    from app.services.activity_log_service import emit_lifecycle, schedule_emit
app/services/org_service.py:157:    schedule_emit(emit_lifecycle(_emit_org_id, "org", str(_emit_org_id), "org_activated", {}))
app/services/org_service.py:476:    from app.services.activity_log_service import emit_lifecycle, schedule_emit
app/services/org_service.py:477:    schedule_emit(emit_lifecycle(org_id, "org", str(org_id), "org_deactivated", {}))
app/services/org_service.py:503:    from app.services.activity_log_service import emit_lifecycle, schedule_emit
app/services/org_service.py:504:    schedule_emit(emit_lifecycle(
app/api/chat_history.py:175:    from app.services.activity_log_service import resolve_actor, emit_activity, schedule_emit
app/api/chat_history.py:178:        schedule_emit(emit_activity(
app/api/find_similar.py:70:    from app.services.activity_log_service import resolve_actor, emit_activity, schedule_emit
app/api/find_similar.py:75:        schedule_emit(emit_activity(
app/api/internal_settlement.py:565:            from app.services.activity_log_service import emit_lifecycle, schedule_emit
app/api/internal_settlement.py:566:            schedule_emit(
app/api/internal_settlement.py:567:                emit_lifecycle(record.org_id, "org", str(record.org_id), "org_activated", {})
app/api/internal_settlement.py:843:        from app.services.activity_log_service import emit_lifecycle, schedule_emit
app/api/internal_settlement.py:845:            schedule_emit(
app/api/internal_settlement.py:846:                emit_lifecycle(
app/api/internal_settlement.py:1360:    is_active 同步、且自带 emit_lifecycle(org_activated/org_deactivated · 两个
app/api/four_l_report.py:359:        schedule_emit,
app/api/four_l_report.py:364:        schedule_emit(emit_activity(
app/api/four_l_report.py:372:                schedule_emit(record_report_image(
app/api/h5_consult.py:95:    from app.services.activity_log_service import resolve_actor, emit_activity, schedule_emit
app/api/h5_consult.py:99:        schedule_emit(emit_activity(
app/api/h5_consult.py:142:        emit_lifecycle,
app/api/h5_consult.py:144:        schedule_emit,
app/api/h5_consult.py:149:        schedule_emit(emit_lifecycle(
app/api/h5_consult.py:152:        schedule_emit(emit_activity(
## V5 database.py 会话/引擎
5:from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
7:from sqlalchemy.pool import NullPool
22:engine = create_async_engine(
25:    poolclass=NullPool,
40:sync_engine = create_engine(
47:SessionLocal = sessionmaker(bind=sync_engine, autocommit=False, autoflush=False)
50:@event.listens_for(engine.sync_engine, "connect")
58:async_session_maker = async_sessionmaker(
71:    async with async_session_maker() as session:
## V6 startup_event 末尾
### app/main.py:370-398
370:             from app.services.faiss_service import get_faiss_service
371: 
372:             t0 = _time.perf_counter()
373:             faiss_index_dir = os.getenv("FAISS_INDEX_DIR", "data/faiss_indices/doctor_001")
374:             get_faiss_service(faiss_index_dir)
375:             dt_a = (_time.perf_counter() - t0) * 1000
376: 
377:             t1 = _time.perf_counter()
378:             b1_index_dir = os.getenv("B1_FAISS_INDEX_DIR", "data/faiss_indices/b1_doctor_283")
379:             get_faiss_service(b1_index_dir)
380:             dt_b = (_time.perf_counter() - t1) * 1000
381: 
382:             logging.info(
383:                 f"✅ FAISS preload 完成 · KB A={dt_a:.0f}ms · B1={dt_b:.0f}ms · "
384:                 f"家长首次请求 0 等待"
385:             )
386:         except Exception as e:
387:             # silent fallback · preload 失败不阻塞启动 · 业务请求 lazy load 仍能工作
388:             logging.warning(f"⚠️ FAISS preload 失败 · 业务请求 lazy load 兜底: {e}")
389:     else:
390:         logging.info("⚠️ FAISS_PRELOAD_ENABLED=false · 跳过 preload(家长首次请求会等 ~10s)")
391: 
392:     # Phase 72 批 2 · 核心侧月结调度已删:月结/半月结职责全在 settlement-api 自己的调度器
393:     # (生产该 flag 本就显式 false;test 机未设吃默认 true 属隐患,随本批一并根除)
394: 
395: 
396: @app.on_event("shutdown")
397: async def shutdown_event():
398:     """应用关闭事件 · 清理 keep-alive task"""
## V7 users 非空且无默认值的列(批量插入必须给)
36:    role = Column(String(20), nullable=False)
74:    is_active = Column(Boolean, nullable=False, default=True, server_default="true")
76:        Boolean, nullable=False, default=True, server_default="true"
81:        Boolean, nullable=False, default=False, server_default="false",
94:        nullable=False,
199:        DateTime(timezone=True), nullable=False, server_default=func.now()
203:        nullable=False,
262:        nullable=False,
265:    password_hash = Column(String(255), nullable=False)
267:        DateTime(timezone=True), nullable=False, server_default=func.now()
## V8 测试里对 seat_added / schedule_emit 的断言方式
tests/integration/test_phase52_emit_points.py:15:  #7 lifecycle(seat_added) · generate_seats 直调             → account_lifecycle_event
tests/integration/test_phase52_emit_points.py:449:# #7 lifecycle(seat_added) · generate_seats 直调
tests/integration/test_phase52_emit_points.py:451:async def test_07_lifecycle_seat_added(seat_ctx):
tests/integration/test_phase52_emit_points.py:452:    """generate_seats(count=3) commit 后 · 逐 seat emit seat_added lifecycle"""
tests/integration/test_phase52_emit_points.py:461:    rows = await _poll_lifecycle(oid, "seat_added", "seat")
tests/integration/test_phase52_emit_points.py:462:    assert len(rows) == 3, f"应 emit 3 条 seat_added lifecycle · 实际 {len(rows)}"
## V9 OrgBrainConfig.enable_parent_subseat
app/models/database.py:120:    enable_parent_subseat = Column(
## V10 org_parent_seat_counter 读法先例
app/services/seat_service.py:38:from app.models.org_parent_seat_counter import OrgParentSeatCounter
app/services/seat_service.py:195:        pg_insert(OrgParentSeatCounter)
app/services/seat_service.py:199:            set_={"last_seq": OrgParentSeatCounter.last_seq + count},
app/services/seat_service.py:201:        .returning(OrgParentSeatCounter.last_seq)
app/api/admin_org_subseat_prefix.py:29:from app.models.org_parent_seat_counter import OrgParentSeatCounter
app/api/admin_org_subseat_prefix.py:56:        select(OrgParentSeatCounter.last_seq).where(OrgParentSeatCounter.org_id == org_id)
## V11 本地库迁移状态
phase83_003
seat_open_batches
## V12 本 worktree 分支
feature/phase83-batch4-impl
feature/phase83-batch4-bulk-open