"""员工自助修改自己的 GitHub ID —— 提交、确认、落地。 ## 为什么这条链路存在 ``identity_persons.github_id`` 只在入职的「其他声明」表单里采集一次, 之后**没有任何更新入口**。本域三处都靠它认人:自动加入规则跳过没有 handle 的人(``auto_join``)、对账靠它把「认不出是谁」的成员接回人 (``reconcile``)、离职回收靠它找到要摘掉的账号。417 个在职员工里 154 个 没有 handle,而 ``github_org_missing_handle`` 那条推送让员工「去申请一次, 顺便就登记上了」—— 但 :class:`GithubOrgJoinRequest` 刻意不回写人员主数据, 所以那句话在这条链路上线之前是不成立的。 ## 审批人是数据,不是角色 审批人 = 申请人自己的 HR Ops(``hris_hrops_person_id``),缺失时回退 HRBP。 与邮件组 owner 同构:一个随人而变的责任,不是一个角色。两者都没有的人 (生产上 4 个)提交的单子 ``approver_person_id`` 为 NULL,由 HR / global_admin 的兜底队列接住 —— 单子不会烂在那里没人管。 不走飞书审批实例,是因为飞书审批需要一个**固定**的审批人分派,而这里的 审批人逐人不同;而且新建一条审批定义要动飞书后台,那不是我们能控制的。 ## 落地顺序在 coordinator 里,不在这里 通过之后 SF→Helix→飞书 的写入顺序、以及失败时那道防回滚闸,都在 ``core.coordinators.github_handle_change_coordinator``——跨域编排按本仓 规矩不能写在域里。这里只负责:谁能提、谁能批、什么时候算重复。 """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.core.coordinators.github_handle_change_coordinator import ( apply_github_id_change, broadcast_github_id_change, ) from app.core.exceptions import AppException, NotFoundError from app.core.rbac import can_decide_github_id_change from app.identity.person_models import IdentityPerson from . import constants as const from .driver_access import resolve_orgless_driver from .models import GithubIdChangeRequest from .validators import bounded_text, normalize_github_login logger = logging.getLogger(__name__) #: 「还在职」的口径。与本域别处一致:待离职的人仍持有访问权,所以他们 #: 的 handle 依然要认得出来;但他们不能再提新单子(见 ``submit``)。 _EMPLOYED_STATUSES = ("active", "leaver_pending") def _now() -> datetime: return datetime.now(UTC) async def resolve_approver_id( db: AsyncSession, person: IdentityPerson, ) -> str | None: """谁该确认这个人的 GitHub ID 变更。 HR Ops 优先,回退 HRBP(``hris_hrbp_person_id`` 优先于本地覆盖的 ``hrbp_person_id`` —— 与 ``new_hire_recipient_resolver`` 同一优先级)。 两者都没有、或指向的人已经不在职时返回 ``None``,由兜底队列接住。 返回 ``None`` **不是**错误:生产上 417 人里 4 个既没有 HR Ops 也没有 HRBP,把这判成提交失败,等于让这 4 个人永远改不了自己的 handle。 """ for candidate in ( person.hris_hrops_person_id, person.hris_hrbp_person_id, person.hrbp_person_id, ): if candidate is None: continue row = await db.get(IdentityPerson, candidate) if ( row is not None and row.deleted_at is None and row.employment_status in _EMPLOYED_STATUSES ): return str(row.id) return None async def verify_handle_on_github( db: AsyncSession, login: str, ) -> bool | None: """这个 handle 在 GitHub 上存不存在。**咨询性质,永不阻断。** 三态,而且三态不能塌成两态:``True`` 存在、``False`` GitHub 明确说 404、``None`` 我们没问到(没配凭证、限流、网络抖)。把 ``None`` 当成 ``False`` 会让审批人看到一个「查无此人」的红字,然后驳回一个其实拼写 正确的 handle —— 一次 GitHub 抖动变成一次冤枉的驳回。 """ driver = await resolve_orgless_driver(db) if driver is None: return None try: return await driver.get_user(login) is not None except Exception as exc: # noqa: BLE001 — 咨询性质:问不到就是问不到 logger.info( "github_id change: handle verification unavailable for %r: %s", login, exc, ) return None class GithubIdChangeService: def __init__(self, db: AsyncSession) -> None: self.db = db self.audit = AuditService(db) # ── 读 ──────────────────────────────────────────────────────── async def get_state(self, person: IdentityPerson) -> dict[str, Any]: """个人设置页要的一切:当前值、在途单、能不能提。""" pending = await self._pending_for(str(person.id)) return { "github_id": person.github_id or None, # 非 NULL = 当前值是员工自助改的,HRIS 还没读回同样的值。 # 前端据此显示「已生效,HRIS 同步中」,而不是让人以为没成。 "hris_sync_pending": person.github_id_locked_at is not None, "pending_request": ( await self.serialize(pending) if pending else None ), } async def list_mine( self, person: IdentityPerson, *, limit: int = 50, ) -> list[dict[str, Any]]: rows = ( await self.db.execute( select(GithubIdChangeRequest) .where(GithubIdChangeRequest.person_id == str(person.id)) .order_by(GithubIdChangeRequest.created_at.desc()) .limit(limit) ) ).scalars().all() return [await self.serialize(r) for r in rows] async def list_pending_for_approver( self, *, person: IdentityPerson, role_names: list[str], ) -> list[dict[str, Any]]: """等着我确认的单子。 与 ``action_inbox`` 的同名 provider 必须给出同一个集合 —— 两边 算法不同就会出现「待办里有、点进去列表是空的」。所以两边共用这个 方法的 WHERE 条件语义: * 兜底角色(HR / global_admin)→ 全公司的在途单,含无人可派的; * 普通人 → 只看被指派给自己的,且不含自己提的(四眼)。 """ q = select(GithubIdChangeRequest).where( GithubIdChangeRequest.status == const.HANDLE_CHANGE_STATUS_PENDING, ) if not can_decide_github_id_change(role_names): q = q.where( GithubIdChangeRequest.approver_person_id == str(person.id), GithubIdChangeRequest.person_id != str(person.id), ) rows = ( await self.db.execute( q.order_by(GithubIdChangeRequest.created_at.asc()).limit(200) ) ).scalars().all() return [await self.serialize(r) for r in rows] async def serialize(self, row: GithubIdChangeRequest) -> dict[str, Any]: names = await self._person_labels( [row.person_id, row.approver_person_id, row.decided_by_person_id], ) return { "id": row.id, "person_id": row.person_id, "person_name": names.get(row.person_id), "old_github_id": row.old_github_id, "new_github_id": row.new_github_id, "reason": row.reason, "status": row.status, "github_exists": row.github_exists, "approver_person_id": row.approver_person_id, "approver_name": names.get(row.approver_person_id or ""), "decided_by_person_id": row.decided_by_person_id, "decided_by_name": names.get(row.decided_by_person_id or ""), "decided_at": row.decided_at.isoformat() if row.decided_at else None, "decision_note": row.decision_note, "hris_write_status": row.hris_write_status, "hris_write_error": row.hris_write_error, "created_at": row.created_at.isoformat(), "updated_at": row.updated_at.isoformat(), } async def _person_labels( self, ids: list[str | None], ) -> dict[str, str]: wanted = {i for i in ids if i} if not wanted: return {} rows = ( await self.db.execute( select( IdentityPerson.id, IdentityPerson.display_name, IdentityPerson.work_email, ).where(IdentityPerson.id.in_(wanted)) ) ).all() return {str(pid): (dn or we or str(pid)) for pid, dn, we in rows} async def _pending_for( self, person_id: str, ) -> GithubIdChangeRequest | None: return ( await self.db.execute( select(GithubIdChangeRequest).where( GithubIdChangeRequest.person_id == person_id, GithubIdChangeRequest.status == const.HANDLE_CHANGE_STATUS_PENDING, ) ) ).scalars().first() # ── 提交 ────────────────────────────────────────────────────── async def submit( self, *, requester: IdentityPerson, github_id: str, reason: str | None, trace_id: str, ) -> GithubIdChangeRequest: """提交一张修改单。返回 pending 行;通知在提交成功后发。 服务层自带离职闸:路由上的那道只挡 HTTP,MCP 工具是直接调服务的。 """ from app.core.grant_eligibility import ( # noqa: PLC0415 is_blocked_from_new_grant, ) if is_blocked_from_new_grant(requester): raise AppException( code="GITHUB_ID_REQUESTER_INELIGIBLE", message=( "Departing or departed employees cannot change their " "GitHub ID." ), status_code=403, ) login = normalize_github_login(github_id, field="github_id") clean_reason = bounded_text( reason, const.HANDLE_CHANGE_REASON_MAX_LEN, "reason", ) current = (requester.github_id or "").strip() if current.lower() == login.lower(): raise AppException( code="GITHUB_ID_UNCHANGED", message=( f"Your GitHub ID is already '{current}'. Nothing to " f"change." ), status_code=409, ) await self._reject_if_taken(login, exclude_person_id=str(requester.id)) if await self._pending_for(str(requester.id)) is not None: raise AppException( code="GITHUB_ID_REQUEST_PENDING", message=( "You already have a GitHub ID change waiting for " "confirmation. Withdraw it first to submit a new one." ), status_code=409, ) row = GithubIdChangeRequest( id=str(uuid.uuid4()), person_id=str(requester.id), old_github_id=current or None, new_github_id=login, reason=clean_reason, status=const.HANDLE_CHANGE_STATUS_PENDING, approver_person_id=await resolve_approver_id(self.db, requester), # 咨询性质,提交时问一次就存下来:审批面是一个列表,逐行现问 # 会把一次翻页变成 N 次 GitHub 调用(并且直接撞上限流)。 # # 这次外部调用发生在一个已经做过 SELECT 的事务里。之所以能接受: # 它只读、不持锁,驱动自带超时与重试上限,而且**问不到就是 None** # (`verify_handle_on_github` 从不抛)—— 最坏情况是这一格显示 # 「未校验」,不会让提交失败,也不会让事务停在一个写锁上。 github_exists=await verify_handle_on_github(self.db, login), ) self.db.add(row) try: await self.db.flush() except IntegrityError as exc: # 输给了 ``open_request_key`` 的竞争 —— 另一个标签页刚建了在途 # 行。给一个干净的 409,而不是 500。 raise AppException( code="GITHUB_ID_REQUEST_PENDING", message=( "You already have a GitHub ID change waiting for " "confirmation. Withdraw it first to submit a new one." ), status_code=409, ) from exc await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=requester.id, action="github_id.change_requested", status="success", resource_type="identity_person", resource_id=str(requester.id), detail={ "request_id": row.id, "old_github_id": row.old_github_id, "new_github_id": row.new_github_id, "approver_person_id": row.approver_person_id, "reason": row.reason, "github_exists": row.github_exists, }, ) await self._notify_approver(row, requester=requester, trace_id=trace_id) return row async def _reject_if_taken( self, login: str, *, exclude_person_id: str, ) -> None: """另一个在职员工已经占了这个 handle → 直接拒。 不是洁癖:``service.resolve_person_by_login`` 和 ``reconcile`` 都按 ``lower(trim(github_id))`` 反查人,重复 handle 会让它们一次拿到两 个人,而它们的返回形状只容得下一个 —— 于是台账会把成员资格接到 错误的人身上,离职时摘错账号。存量重复(迁移里没建唯一索引的原因) 由对账去暴露;新的重复在这里就挡住。 """ target = login.strip().lower() holder = ( await self.db.execute( select(IdentityPerson.id, IdentityPerson.display_name) .where( func.lower(func.trim(IdentityPerson.github_id)) == target, IdentityPerson.id != exclude_person_id, IdentityPerson.deleted_at.is_(None), IdentityPerson.employment_status.in_(_EMPLOYED_STATUSES), ) .limit(1) ) ).first() if holder is not None: raise AppException( code="GITHUB_ID_ALREADY_TAKEN", message=( f"GitHub ID '{login}' is already on file for another " f"employee. If it is really yours, contact IT." ), status_code=409, ) # ── 撤回 ────────────────────────────────────────────────────── async def cancel( self, *, request_id: str, requester: IdentityPerson, trace_id: str, ) -> GithubIdChangeRequest: row = await self.db.get(GithubIdChangeRequest, request_id) if row is None: raise NotFoundError("GithubIdChangeRequest", request_id) if row.person_id != str(requester.id): raise AppException( code="NOT_AUTHORIZED", message="You can only withdraw your own request.", status_code=403, ) if row.status != const.HANDLE_CHANGE_STATUS_PENDING: raise AppException( code="GITHUB_ID_REQUEST_NOT_PENDING", message="This request has already been decided.", status_code=409, ) row.status = const.HANDLE_CHANGE_STATUS_CANCELLED row.decided_at = _now() await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=requester.id, action="github_id.change_cancelled", status="success", resource_type="identity_person", resource_id=str(requester.id), detail={"request_id": row.id}, ) return row # ── 确认 / 驳回 ─────────────────────────────────────────────── async def decide( self, *, request_id: str, actor: IdentityPerson, role_names: list[str], decision: str, note: str | None, trace_id: str, ) -> tuple[GithubIdChangeRequest, IdentityPerson]: """确认或驳回。通过时同步写 SF + 人员行,飞书由调用方在 commit 之后广播(见 ``broadcast_github_id_change`` 的注释)。 返回 ``(单子, 申请人)`` —— 调用方要用申请人发结果通知。 """ if decision not in ("approve", "reject"): raise AppException( code="GITHUB_ID_BAD_DECISION", message="decision must be 'approve' or 'reject'", status_code=400, ) # 行锁:这个 POST 会写 SF(一次不可回滚的外部副作用)。没有锁的话 # 两次并发点击都读到 pending、都过闸、都往 SF 写一遍,并且两条结果 # 通知都发给申请人。 row = await self.db.get( GithubIdChangeRequest, request_id, with_for_update=True, ) if row is None: raise NotFoundError("GithubIdChangeRequest", request_id) if row.status != const.HANDLE_CHANGE_STATUS_PENDING: raise AppException( code="GITHUB_ID_REQUEST_NOT_PENDING", message="This request has already been decided.", status_code=409, ) is_named_approver = row.approver_person_id == str(actor.id) if not (is_named_approver or can_decide_github_id_change(role_names)): raise AppException( code="NOT_AUTHORIZED", message=( "Only this employee's HR Ops (or HR / IT Admin) can " "confirm a GitHub ID change." ), status_code=403, ) # 四眼:自己的单子自己不能批。驳回自己的等同撤回,无害,放行。 if decision == "approve" and row.person_id == str(actor.id): raise AppException( code="GITHUB_ID_SELF_APPROVAL_FORBIDDEN", message="You cannot confirm your own GitHub ID change.", status_code=403, ) requester = await self.db.get(IdentityPerson, row.person_id) if requester is None: raise NotFoundError("IdentityPerson", row.person_id) clean_note = bounded_text( note, const.HANDLE_CHANGE_NOTE_MAX_LEN, "note", ) if decision == "approve": # 提交到审批之间可能过了几天:handle 可能已经被别人登记走了, # 申请人也可能已经在走离职。两者都要在写 SF **之前**重新查。 await self._reject_if_taken( row.new_github_id, exclude_person_id=row.person_id, ) if ( requester.deleted_at is not None or requester.employment_status not in _EMPLOYED_STATUSES ): raise AppException( code="GITHUB_ID_REQUESTER_LEFT", message=( "This employee is no longer active; the change was " "not applied." ), status_code=409, ) hris = await apply_github_id_change( self.db, person=requester, new_github_id=row.new_github_id, trace_id=trace_id, ) row.hris_write_status = { "ok": const.HANDLE_CHANGE_HRIS_OK, "failed": const.HANDLE_CHANGE_HRIS_FAILED, "skipped": const.HANDLE_CHANGE_HRIS_SKIPPED, }.get(str(hris.get("status")), const.HANDLE_CHANGE_HRIS_FAILED) if row.hris_write_status != const.HANDLE_CHANGE_HRIS_OK: row.hris_write_error = str(hris.get("reason") or "")[:512] or None row.status = const.HANDLE_CHANGE_STATUS_APPROVED else: row.status = const.HANDLE_CHANGE_STATUS_REJECTED row.decided_by_person_id = str(actor.id) row.decided_at = _now() row.decision_note = clean_note await self.db.flush() await self.audit.log( trace_id=trace_id, actor_type="user", actor_user_id=actor.id, action=f"github_id.change_{row.status}", status="success", resource_type="identity_person", resource_id=row.person_id, detail={ "request_id": row.id, "old_github_id": row.old_github_id, "new_github_id": row.new_github_id, "decision": decision, "note": row.decision_note, "hris_write_status": row.hris_write_status, "hris_write_error": row.hris_write_error, # 谁**该**批 vs 谁**真的**批了。代批是允许的,但审计要能 # 一眼看出这一单是不是绕过了指定审批人。 "named_approver_person_id": row.approver_person_id, "decided_by_named_approver": is_named_approver, }, ) await self._notify_requester(row, requester=requester, actor=actor, trace_id=trace_id) return row, requester # ── 通知 ────────────────────────────────────────────────────── # # 渠道只要 ``feishu``:飞书 DM + 站内信(outbox 行本身就是站内信那一 # 条)。刻意不发邮件 —— 改一个 handle 不值得占一封工作邮件,而这两个 # 渠道已经覆盖了「在手机上能看到」和「回来能翻到」。 async def _notify_approver( self, row: GithubIdChangeRequest, *, requester: IdentityPerson, trace_id: str, ) -> None: """推给指定审批人。没有指定审批人时**不发** —— 兜底队列是被动的。 与邮件组「没有 owner 的组」同一处置:不给每个 HR / global_admin 逐单推一次(那是打扰),他们的待办队列会把这些单子一并显示出来。 """ if not row.approver_person_id: logger.info( "github_id change %s has no named approver — it will be " "picked up from the HR backstop queue", row.id, ) return approver = await self.db.get( IdentityPerson, row.approver_person_id, ) if approver is None: return from app.notifications.service import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) who = requester.display_name or requester.work_email or "" old = row.old_github_id or "—" # 语言中立的记号,而不是一句中文 —— context 的值在发送时就定死了, # 塞一句中文进去,英文/日文模板会原样把它印出来。三态各一个记号, # 含义由各语言模板里的字段名说清楚。 verify_mark = { True: "✓", False: "✗ (HTTP 404)", None: "— (n/a)", }[row.github_exists] body = ( f"{who} 申请把自己的 GitHub ID 从 {old} 改为 " f"{row.new_github_id},等待你确认。\n\n" f" · GitHub 账号是否存在:{verify_mark}\n" f" · 理由:{row.reason or '—'}\n\n" "确认后 Helix 会同步更新 HRIS 与飞书通讯录。" "在 Helix「我的待办」中确认或驳回。" ) try: await NotificationService(self.db).dispatch( NotificationEnvelope( recipient=approver, title=f"{who} 申请修改 GitHub ID", body_text=body, template_key="github_id_change_requested", channels=["feishu"], context={ "person_name": ( approver.display_name or approver.work_email or "" ), "requester_name": who, "requester_email": requester.work_email or "", "old_github_id": old, "new_github_id": row.new_github_id, "verify_mark": verify_mark, "reason": row.reason or "—", }, url="/my/todos?tab=githubId", related_resource_type="github_id_change_request", ), trace_id=trace_id, actor_user_id=requester.id, ) except Exception as exc: # noqa: BLE001 — 通知失败不该让提交失败 logger.warning( "github_id change: approver notification failed for %s: %s", row.id, exc, ) async def _notify_requester( self, row: GithubIdChangeRequest, *, requester: IdentityPerson, actor: IdentityPerson, trace_id: str, ) -> None: """把结果告诉申请人。这是他自己发起的动作的回执,不是新增打扰。""" from app.notifications.service import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) approved = row.status == const.HANDLE_CHANGE_STATUS_APPROVED decider = actor.display_name or actor.work_email or "" if approved: title = f"你的 GitHub ID 已更新为 {row.new_github_id}" body = ( f"{decider} 已确认你的修改,Helix 与飞书通讯录会在几分钟内" f"显示 {row.new_github_id}。" ) # HRIS 没写成时**明说**,而不是让人以为全都同步好了 —— 这条 # 差异会一直存在到有人手工把 SF 改过来。 if row.hris_write_status == const.HANDLE_CHANGE_HRIS_FAILED: body += ( "\n\n注意:回写 HRIS(SuccessFactors)这一步没有成功," "IT 会跟进处理;在此期间 Helix 侧的值不会被覆盖。" ) else: title = "你的 GitHub ID 修改申请被驳回" body = f"{decider} 驳回了你把 GitHub ID 改为 {row.new_github_id} 的申请。" if row.decision_note: body += f"\n\n备注:{row.decision_note}" try: await NotificationService(self.db).dispatch( NotificationEnvelope( recipient=requester, title=title, body_text=body, template_key=( "github_id_change_approved" if approved else "github_id_change_rejected" ), channels=["feishu"], context={ "person_name": ( requester.display_name or requester.work_email or "" ), "new_github_id": row.new_github_id, "old_github_id": row.old_github_id or "—", "decider_name": decider, "note": row.decision_note or "—", "hris_note": ( "回写 HRIS 未成功,IT 会跟进。" if row.hris_write_status == const.HANDLE_CHANGE_HRIS_FAILED else "" ), }, url="/me/settings?tab=profile", related_resource_type="github_id_change_request", ), trace_id=trace_id, actor_user_id=actor.id, ) except Exception as exc: # noqa: BLE001 — 回执失败不该回滚已生效的变更 logger.warning( "github_id change: requester notification failed for %s: %s", row.id, exc, ) @staticmethod def broadcast_after_commit(person_id: str) -> None: """commit 之后再调 —— 见 coordinator 里的注释。""" broadcast_github_id_change(person_id)