"""飞书审批回调 → 执行。两个来源共用一个入口。 * **Helix 发起**:``approval_requests.source_payload`` 里有 ``github_org_join_request_id``,handle 与组织都是提交时校验过的。 * **飞书直接提交**(``accept_external_origin``):没有本地申请单,一切 从表单里现场解析 —— 谁提交的、什么 handle、哪个组织。 两条路在解析出 ``(org, handle, person)`` 之后完全汇合。这是有意的: 「审批通过之后做什么」只该有一份实现,否则过渡期里两条路会悄悄长歪。 **只有 APPROVED 才动作。** 其余状态(拒绝 / 撤销 / 超时)只翻状态, 不碰 GitHub、不碰台账。 """ from __future__ import annotations import logging from datetime import UTC, datetime from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException from app.identity.person_models import IdentityPerson from . import constants as const from . import execution from .models import GithubOrg, GithubOrgJoinRequest from .request_service import ( FEISHU_FLOW_KEY, extract_form_values, resolve_org_from_form, ) from .validators import normalize_github_login logger = logging.getLogger(__name__) def _now() -> datetime: return datetime.now(UTC) async def _resolve_applicant_from_feishu( db: AsyncSession, instance_detail: dict[str, Any], ) -> IdentityPerson | None: """飞书提交人 → Helix 人员行。认不出返回 None,**不抛**。 认不出人**不阻止执行**:org owner 已经批了,访问权该给。台账留 ``person_id=NULL``,让它出现在「认不出是谁」那个数里由人去认 —— 比拒绝执行(把一张已批准的单子卡死)诚实得多。 与 SOC2 那条路的差别正在这里:那边认不出人是可以放行的**例外**, 这边是常态之一(外来单子可能来自还没进 HRIS 的合同工)。 邮箱匹配同时认 ``work_email`` 与 ``email_aliases``,并且两边都 ``lower()`` —— 不依赖列的 collation。一次 collation 变更不该悄悄 改变「这个人是谁」的答案。 """ import json as _json # noqa: PLC0415 from sqlalchemy import func, or_, select # noqa: PLC0415 candidate_id: str | None = None candidate_type = "open_id" if instance_detail.get("open_id"): candidate_id = str(instance_detail["open_id"]) elif instance_detail.get("user_id"): candidate_id = str(instance_detail["user_id"]) candidate_type = "user_id" if not candidate_id: return None try: from app.approval.feishu_client import ( # noqa: PLC0415 FeishuApprovalClient, ) from app.integrations.compat import get_feishu_config # noqa: PLC0415 cfg = await get_feishu_config(session=db) client = FeishuApprovalClient( app_id=str(cfg.get("app_id") or ""), app_secret=str(cfg.get("app_secret") or ""), ) user_rec = await client.get_user_with_custom_attrs( candidate_id, user_id_type=candidate_type, ) except Exception as e: # noqa: BLE001 logger.warning("github_org: could not resolve Feishu submitter: %s", e) return None email = str((user_rec or {}).get("email") or "").strip().lower() if not email: return None return ( await db.execute( select(IdentityPerson).where( IdentityPerson.deleted_at.is_(None), or_( func.lower(IdentityPerson.work_email) == email, func.json_contains( IdentityPerson.email_aliases, _json.dumps(email), ), ), ) ) ).scalars().first() async def handle_feishu_callback( db: AsyncSession, *, approval_request: Any, instance_detail: dict[str, Any], approval_status: str, ) -> None: """飞书审批回调。只有 APPROVED 才动作,只有终态才翻状态。 ⚠️ 这个函数**不只在终态被调用**:分发器对每一次实例事件都调它, 提交那一刻的 ``PENDING`` 也在内。非终态一律原样返回。 这个函数**不 commit** —— 提交由 ``ApprovalService`` 的回调路径统一 负责,和平台里其它域一致。抛异常会让整个请求回滚、飞书重投,而 重投是安全的:执行层与台账写入都是幂等的。 """ source_payload = dict(getattr(approval_request, "source_payload", None) or {}) local_request_id = source_payload.get("github_org_join_request_id") local_row: GithubOrgJoinRequest | None = None if local_request_id: local_row = await db.get(GithubOrgJoinRequest, str(local_request_id)) # ── 非通过:只翻**终态**,而且只翻认得的那两个 ───────────── # # 🔴 分发器对**每一次**飞书实例事件都调这里,包括提交那一刻投来的 # ``status=PENDING``(以及之后每个审批节点的那几次)。所以这里读到 # 的 ``approval_status`` 常态就有 ``"pending"``,它**不是终态**。 # # 原来的写法是「不是 approved 就是 rejected 或 cancelled」—— # 于是提交后 2 秒,一张正在审批中的单子就被自己翻成了「已取消」: # 员工在「我的 GitHub 组织」里看到已取消、连撤销按钮都没了,而飞书 # 上那张单子还好端端地 Under Review(生产上 2026-09-01 的 1/1 张单 # 子就是这样,decided_at 比 created_at 只晚 2 秒)。 # # 因此:``pending`` 直接返回,不认得的状态也直接返回 —— 只有明确的 # rejected / cancelled 才落终态。默认值宁可是「什么都不做」,也不能 # 是「取消掉一张还在审批的单子」。 if approval_status != "approved": terminal = { "rejected": const.JOIN_REQUEST_STATUS_REJECTED, "cancelled": const.JOIN_REQUEST_STATUS_CANCELLED, }.get(approval_status) if ( terminal is not None and local_row is not None and local_row.status == const.JOIN_REQUEST_STATUS_PENDING ): local_row.status = terminal local_row.decided_at = _now() return # ── 解析 (org, handle, person) ─────────────────────────── org: GithubOrg | None = None login: str | None = None person: IdentityPerson | None = None if local_row is not None: org = await db.get(GithubOrg, local_row.org_id) login = local_row.github_login person = await db.get(IdentityPerson, local_row.person_id) else: # 外来单子:一切从表单里现场解析。 from app.integrations.service import ( # noqa: PLC0415 get_approval_flow, ) try: flow = await get_approval_flow(FEISHU_FLOW_KEY, session=db) schema = flow.payload_schema or {} except Exception: # noqa: BLE001 schema = {} values = extract_form_values(instance_detail, schema) raw_login = values.get("github_login") try: login = normalize_github_login(raw_login) except Exception: # noqa: BLE001 login = None org = await resolve_org_from_form(db, values.get("org")) person = await _resolve_applicant_from_feishu(db, instance_detail) if org is None or not login: # 一张已经批准、却无处落地的单子。**必须响亮** —— 静默丢弃正是 # 这套东西要消灭的那种失败。抛出去让飞书重投并留下失败审计; # 重投若仍解析不出,运维会在审批台账上看到它卡着。 raise AppException( code="GITHUB_CALLBACK_UNRESOLVABLE", message=( "Approved GitHub org request could not be resolved to an " f"organization + handle (org={org.slug if org else None!r}, " f"login={login!r}). Check the Feishu form mapping." ), status_code=422, ) if not org.is_active: raise AppException( code="GITHUB_ORG_INACTIVE", message=( f"Approved request targets organization '{org.slug}', which is " f"retired in Helix. Re-activate it or handle this manually." ), status_code=409, ) # 离职闸:审批可能跑了几天,批下来的时候人已经走了。 from app.core.grant_eligibility import ( # noqa: PLC0415 is_blocked_from_new_grant, ) if person is not None and is_blocked_from_new_grant(person): if local_row is not None: local_row.status = const.JOIN_REQUEST_STATUS_FAILED local_row.decided_at = _now() local_row.last_error = ( "applicant is departing or has left — access not granted" ) raise AppException( code="GITHUB_APPLICANT_LEFT", message=( f"{person.work_email} has left or is leaving; the approved " f"GitHub organization access was NOT granted." ), status_code=409, ) instance_code = str(instance_detail.get("instance_code") or "") trace_id = f"feishu_callback:{instance_code}" if instance_code else "feishu_callback" outcome = await execution.execute_approved( db, org=org, github_login=login, person=person, source=const.SOURCE_APPROVAL, trace_id=trace_id, join_request_id=(local_row.id if local_row else None), ) if local_row is not None: local_row.executed_at = _now() local_row.decided_at = local_row.decided_at or _now() if outcome.outcome == execution.OUTCOME_FAILED: local_row.status = const.JOIN_REQUEST_STATUS_FAILED local_row.last_error = (outcome.error or "")[:512] or None else: local_row.status = const.JOIN_REQUEST_STATUS_APPROVED local_row.last_error = None if outcome.outcome == execution.OUTCOME_FAILED: # 抛出去:飞书重投一次是安全的(执行幂等),而一个卡住的单子 # 必须在审批台账上看得见,不能悄悄躺成 approved。 raise AppException( code="GITHUB_INVITE_FAILED", message=( f"Approved request for {login}@{org.slug} could not be " f"executed ({outcome.failure_kind}): {outcome.error}" ), status_code=502, ) # 通知申请人去接受邀请 —— 邀请发出去了没人告诉他,就是那 7 天过期的 # 主要成因。 if outcome.outcome == execution.OUTCOME_INVITED and person is not None: await _notify_invited(db, person=person, org=org, login=login) async def _notify_invited( db: AsyncSession, *, person: IdentityPerson, org: GithubOrg, login: str, ) -> None: """告诉申请人「邀请发出去了,去 GitHub 点接受」。 尽力而为:通知失败绝不能让一次成功的授予回滚。 """ try: from app.notifications.service import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) # ``dispatch`` 而不是 ``push_inbox_and_feishu``:这是一条**要人 # 动手**的通知(不去点接受,7 天后邀请就没了),所以它值得重试 # 队列与死信,而不是「尽力推一次、失败就算了」那条社交通道。 await NotificationService(db).dispatch( NotificationEnvelope( recipient=person, title=f"GitHub 组织邀请已发出:{org.slug}", body_text=( f"你申请加入 {org.slug} 已通过,邀请已发到 GitHub 账号 " f"{login}。\n\n" f"请打开 https://github.com/{org.slug} 接受邀请。" f"GitHub 的组织邀请 " f"{const.GITHUB_INVITATION_TTL_DAYS} 天后过期;过期了也不用" f"重新申请,Helix 会自动重发。" ), template_key="github_org_invited", url=f"https://github.com/{org.slug}", context={ "org_slug": org.slug, "github_login": login, "ttl_days": const.GITHUB_INVITATION_TTL_DAYS, }, card_subtitle="审批已通过,还差你点一下「接受」", related_resource_type="github_org", ) ) except Exception as e: # noqa: BLE001 logger.warning( "github_org: invite notification failed for %s: %s", person.work_email, e, )