"""GitHub ID 自助修改的 HTTP 面。 一个路由文件承载两侧,因为它们是同一条链路的两端,而**两端都不是按角色 判的**:提交按「是不是本人」,确认按「是不是这个人的 HR Ops」——后者是 数据驱动的责任,与邮件组 owner 同构。放进 ``router.py``(按 ``can_manage_github_org`` 收口的管理面)会让 HR Ops 拿不到自己的队列; 放进 ``self_router.py`` 又会让「审批别人的单子」出现在一个语义是「我自己 的东西」的前缀下。 ``_leaver_runtime_gate`` 挂在整个路由上:待离职的人既不该提新单子,也不 该再做人事确认。服务层另有同一道闸 —— MCP 工具直接调服务,不经过这里。 """ from __future__ import annotations from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exceptions import NotFoundError from app.core.response import success_response from app.identity.person_models import IdentityPerson from . import constants as const from .handle_change_service import GithubIdChangeService router = APIRouter( prefix="/api/v1/me/github-id", tags=["github-id-change"], dependencies=[Depends(_leaver_runtime_gate)], ) class SubmitBody(BaseModel): # 200 而不是 39:允许粘一整条个人主页链接,归一化在校验器里做。在这里 # 卡 39 会让一个能被正确解析的输入在到达归一化之前就被 422 掉。 github_id: str = Field(min_length=1, max_length=200) reason: str | None = Field( default=None, max_length=const.HANDLE_CHANGE_REASON_MAX_LEN, ) class DecideBody(BaseModel): decision: str = Field(pattern="^(approve|reject)$") note: str | None = Field( default=None, max_length=const.HANDLE_CHANGE_NOTE_MAX_LEN, ) async def _person(db: AsyncSession, user: AuthUser) -> IdentityPerson: row = await db.get(IdentityPerson, user.id) if row is None: raise NotFoundError("IdentityPerson", str(user.id)) return row def _roles(user: AuthUser) -> list[str]: """角色取自 ``get_current_user`` 返回的对象,而不是 ``_person()`` 那一行。 两者**目前是同一个 ORM 实例** —— ``get_current_user`` 依赖的也是 ``get_db``,所以 ``db.get(IdentityPerson, user.id)`` 命中身份映射, ``X-View-Role`` 的收窄(``_role_names_override``,写在实例的 ``__dict__`` 上)照样带着。所以这不是在修一个现存的泄漏。 它是在**不依赖那个巧合**:收窄的载体按设计就是这个依赖的返回值,而 「重新查出来的行恰好是同一个对象」是 session 作用域的实现细节。哪天 鉴权换成自己的 session(PAT 路径、或者把用户加载挪进中间件),那个 别名关系就没了,而按角色收口的地方会静默地开始看到未收窄的角色集。 """ return list(user.role_names or []) def _trace(request: Request) -> str: return str(getattr(request.state, "trace_id", "") or "") # ── 本人 ────────────────────────────────────────────────────────── @router.get("") async def get_my_github_id( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """当前值 + 在途单 —— 个人设置页一次调用拿全。 和 ``list_requestable_orgs`` 同一个道理:界面上「当前是什么」和「有没有 在审」必须一起显示,分两次调用的那点时间差正好够渲染出一个不该有的 「修改」按钮。 """ person = await _person(db, user) data = await GithubIdChangeService(db).get_state(person) return success_response(data, request=request) @router.get("/requests") async def list_my_requests( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await _person(db, user) data = await GithubIdChangeService(db).list_mine(person) return success_response(data, request=request) @router.post("/requests", status_code=201) async def submit_request( body: SubmitBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await _person(db, user) svc = GithubIdChangeService(db) row = await svc.submit( requester=person, github_id=body.github_id, reason=body.reason, trace_id=_trace(request), ) data = await svc.serialize(row) await db.commit() return success_response(data, request=request) @router.post("/requests/{request_id}/cancel") async def cancel_request( request_id: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await _person(db, user) svc = GithubIdChangeService(db) row = await svc.cancel( request_id=request_id, requester=person, trace_id=_trace(request), ) data = await svc.serialize(row) await db.commit() return success_response(data, request=request) # ── 审批人 ──────────────────────────────────────────────────────── @router.get("/approvals") async def list_pending_approvals( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """等我确认的单子。没有角色闸 —— 是不是审批人由数据决定。""" person = await _person(db, user) data = await GithubIdChangeService(db).list_pending_for_approver( person=person, role_names=_roles(user), ) return success_response(data, request=request) @router.post("/approvals/{request_id}/decide") async def decide_request( request_id: str, body: DecideBody, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await _person(db, user) svc = GithubIdChangeService(db) row, requester = await svc.decide( request_id=request_id, actor=person, role_names=_roles(user), decision=body.decision, note=body.note, trace_id=_trace(request), ) data = await svc.serialize(row) await db.commit() # commit 之后才广播:``enqueue_person_sync`` 开的是自己的 session, # 在 commit 之前调它,它读到的还是旧 handle,于是把旧值推去飞书。 if row.status == const.HANDLE_CHANGE_STATUS_APPROVED: GithubIdChangeService.broadcast_after_commit(str(requester.id)) return success_response(data, request=request)