"""每周治理摘要 —— 把治理待办推给该动手的人。 ## 这个模块为什么必须存在 上线第一天的实测:``leavers_still_in_orgs`` 是 **1**(一个已离职的人还在 tikv 里),``pingcap-inc`` 的坐席只剩 **3** 个。这两件事都已经写进了台账、 写进了审计行、也画进了 KPI 卡 —— **但没有任何东西会去告诉该处理它的人。** 「有 dispatch 代码」不等于「有人收到过」;而「界面上有个数」离「有人看到」 还差一步:得有人主动打开那个页面。一个说得出问题但不会开口的系统,是把 工作推回给人。 ## 不发的时候必须真的不发 没有待办就**什么都不发**。一封每周固定到达、里面写着「一切正常」的邮件, 第三周就会被规则归档,而第四周真的出事时它也一起被归档了。这条不是节省 资源,是保住这个渠道的可信度。 ## 扇出走平台原语,不自己写循环 发送用 :func:`app.notifications.fanout.enqueue_then_drain`。收件人只有个位数, 听起来用不上它 —— 但代价的形状与规模无关:每条 outbox INSERT 都会拿 ``identity_persons`` 上的 FK 锁,而「循环里 dispatch」就是在 N 次外部往返 期间一直握着那些共享行的锁。本仓 2026-06 的登录被拖垮就是这个形状。 ## 「认不出是谁」为什么不算 actionable 它不计入「本周要处理的事」,但**不是**因为它会自己收敛 —— 实测 217 个认不出 的 handle 里,能靠字面对上的是 0 个。它不计入是因为节奏不同:那是一个几百条 的核对任务,靠界面上的 AI 建议分批清。把一个三位数放进「本周待办」,前面 那几条(其中一条是已经发生的权限暴露)就被淹掉了。它作为**背景数字**出现在 卡片里,而不是作为催办。 """ from __future__ import annotations import logging from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthRole, AuthUserRole from app.core.rbac import GITHUB_ADMIN_ROLE from app.identity.person_models import IdentityPerson from app.notifications.fanout import enqueue_then_drain from app.notifications.service import ( CardAction, CardHighlight, CardStat, CardText, LocalizedText, NotificationEnvelope, ) from . import governance logger = logging.getLogger(__name__) #: 谁收。``github_admin`` 是这套东西的正主;``global_admin`` 兜底,因为 #: ``github_admin`` 目前只有两个人,两个人同时休假时这份摘要不该消失。 #: #: 刻意**不**含 ``auditor`` / ``management`` —— 他们能看这个页面,但这是 #: 一封催办,而催一个没有权限点那个按钮的人,只会训练他忽略这个渠道。 _DIGEST_ROLES: tuple[str, ...] = (GITHUB_ADMIN_ROLE, "global_admin", "it_admin") #: 界面上「待处理」那一页。摘要里的每个按钮都要落到能直接动手的地方 —— #: 一封说「有 3 件事要处理」却只给首页链接的信,等于让人再找一遍。 #: #: ⚠️ 这是前端**真实**路由(``App.tsx`` 里 ````), #: 不是后端 API 前缀。写错的后果是一封每周都在发、点开就 404 的信 —— #: 而前后端的每一道检查都会照常通过,因为没有任何工具会去核对这个字面量。 _ACTION_PATH = "/admin/github?tab=actions" async def digest_recipients(db: AsyncSession) -> list[IdentityPerson]: """收件人:在职的 github_admin + global_admin,去重。 一个人可能通过历史行拿到同一个角色两次,也可能同时是两个角色 —— 去重按 person id,不按 (person, role)。 """ rows = list( ( await db.execute( select(IdentityPerson) .join(AuthUserRole, AuthUserRole.user_id == IdentityPerson.id) .join(AuthRole, AuthRole.id == AuthUserRole.role_id) .where( AuthRole.name.in_(_DIGEST_ROLES), IdentityPerson.employment_status == "active", IdentityPerson.deleted_at.is_(None), ) ) ).scalars().all() ) seen: set[Any] = set() out: list[IdentityPerson] = [] for p in rows: if p.id in seen: continue seen.add(p.id) out.append(p) out.sort(key=lambda p: (p.display_name or p.work_email or "")) return out def _leaver_lines( items: list[dict[str, Any]], limit: int = 8, ) -> list[CardText]: lines: list[CardText] = [] for it in items[:limit]: who = it["person_label"] or it["github_login"] lines.append( f"[b]{who}[/b] · {it['github_login']} · {it['org_slug']}" + (f" · 离职 {it['termination_date'][:10]}" if it.get("termination_date") else "") ) if len(items) > limit: lines.append(f"…… 另有 {len(items) - limit} 条") return lines def _seat_lines(items: list[dict[str, Any]]) -> list[CardText]: out: list[CardText] = [] for it in items: # 余量 ≤ 0 时说「已用满」而不是印一个负数 —— 负余量在界面上像是 # 数据错了,而它其实是「超了,新邀请会直接失败」。 state = ( "已用满,新邀请会直接失败" if it["remaining"] <= 0 else f"还剩 {it['remaining']} 个" ) out.append( f"[b]{it['org_slug']}[/b] · {state} " f"({it['filled']}/{it['total']}, {it['plan']} 计划)" ) return out def _manual_lines( items: list[dict[str, Any]], limit: int = 8, ) -> list[CardText]: out: list[CardText] = [] for it in items[:limit]: waited = it.get("waiting_days") out.append( f"[b]{it['github_login']}[/b] · {it['org_slug']}" + (f" · 已等 {waited} 天" if waited is not None else "") ) if len(items) > limit: out.append(f"…… 另有 {len(items) - limit} 条") return out def build_digest(payload: dict[str, Any]) -> dict[str, Any] | None: """把治理待办渲染成一张卡片的素材。没有待办时返回 ``None``。 与界面共用 :func:`governance.collect_action_items` 的输出 —— 两份 「什么算待办」的实现迟早会分叉,而那种分叉的表现是界面上有、周报里 没有,或者反过来。 """ counts = payload["counts"] if payload["actionable_total"] <= 0: return None leavers = payload["leaver_still_member"] seats = payload["seat_low"] manual = payload["awaiting_manual"] unlinked = payload["unlinked"] stats = [ CardStat( label=LocalizedText( zh="离职未回收", en="Leavers still in orgs", ja="退職者が組織に残存", ), value=f"{counts[governance.KIND_LEAVER]} 条", hint=LocalizedText( zh="已经发生的访问权暴露", en="Access exposure already in effect", ja="すでに発生しているアクセス露出", ), ), CardStat( label=LocalizedText( zh="坐席告急", en="Seats running low", ja="シート残少", ), value=f"{counts[governance.KIND_SEAT_LOW]} 个组织", hint=LocalizedText( zh="用满后新邀请会直接失败", en="New invites fail outright once full", ja="満席になると新規招待は即失敗", ), ), CardStat( label=LocalizedText( zh="等人工邀请", en="Awaiting manual invite", ja="手動招待待ち", ), value=f"{counts[governance.KIND_AWAITING_MANUAL]} 条", ), CardStat( label=LocalizedText( zh="还没认出是谁", en="Not yet identified", ja="未特定", ), value=f"{counts[governance.KIND_UNLINKED]} 条", # 背景数字,不是催办 —— 见模块文档。 hint=LocalizedText( zh=f"已确认外部 {unlinked.get('external_confirmed', 0)} 条", en=f"{unlinked.get('external_confirmed', 0)} confirmed external", ja=f"社外確認済み {unlinked.get('external_confirmed', 0)} 件", ), ), ] highlights = [] if leavers: highlights.append( CardHighlight( label=LocalizedText( zh="🔴 离职了还在组织里", en="🔴 Leavers still members", ja="🔴 退職後も在籍", ), items=_leaver_lines(leavers), ) ) if seats: highlights.append( CardHighlight( label=LocalizedText( zh="🟠 坐席不足,需要加坐席", en="🟠 Seats low — buy more", ja="🟠 シート不足 —— 追加購入が必要", ), items=_seat_lines(seats), ) ) if manual: highlights.append( CardHighlight( label=LocalizedText( zh="🟠 批了但还没发出邀请", en="🟠 Approved but not invited yet", ja="🟠 承認済みだが招待未送信", ), items=_manual_lines(manual), ) ) actions = [ CardAction( label=LocalizedText( zh="去处理", en="Take action", ja="対応する", ), url=_ACTION_PATH, style="primary", ), ] # 坐席那条的「可直接操作的 URL」—— 加坐席是在 GitHub 的账单页做的, # Helix 帮不上,能做的是把人直接送到那一页而不是让他自己找。 for it in seats[:2]: actions.append( CardAction( label=LocalizedText( zh=f"{it['org_slug']} 加坐席", en=f"Add seats · {it['org_slug']}", ja=f"{it['org_slug']} シート追加", ), url=it["billing_url"], style="danger" if it["exhausted"] else "default", ) ) title = f"GitHub 组织治理:{payload['actionable_total']} 件待处理" body = "\n".join( [ f"离职未回收 {counts[governance.KIND_LEAVER]} 条," f"坐席告急 {counts[governance.KIND_SEAT_LOW]} 个组织," f"等人工邀请 {counts[governance.KIND_AWAITING_MANUAL]} 条。", ] ) return { "title": title, "body_text": body, "card_stats": stats, "card_highlights": highlights, "card_actions": actions, } async def send_governance_digest( db: AsyncSession, *, trace_id: str, seat_threshold: int | None = None, ) -> dict[str, Any]: """跑一次治理摘要。返回本次做了什么 —— 包括「什么都没发」的原因。 返回值刻意把 ``sent=0`` 的两种原因分开(``no_action_items`` 与 ``no_recipients``):前者是这套东西正常工作的样子,后者是一个配置 事故 —— 没人持有 github_admin 角色时这份摘要会静默消失,而那正是 「有 dispatch 代码 ≠ 有人收到过」的典型形态。 """ payload = await governance.collect_action_items( db, seat_threshold=seat_threshold, ) card = build_digest(payload) if card is None: # 没有待办就什么都不发 —— 见模块文档。 logger.info("github_org: governance digest skipped, nothing to act on") return { "sent": 0, "skipped": "no_action_items", "counts": payload["counts"], } recipients = await digest_recipients(db) if not recipients: # 不是「一切正常」,是没人会收到。大声记一条,并把它当成结果的一 # 部分返回,这样 job 详情页上看得见。 logger.warning( "github_org: %s governance action items but NOBODY holds " "github_admin/global_admin — digest went nowhere", payload["actionable_total"], ) return { "sent": 0, "skipped": "no_recipients", "counts": payload["counts"], "actionable_total": payload["actionable_total"], } # 🔴 扇出走平台唯一的那个原语,不自己写循环。 # # ``dispatch`` 在事务里做外部投递,而每条 outbox INSERT 都会拿 # ``identity_persons`` 上的 FK 锁 —— 一个「循环里 dispatch」的扇出就是 # 在 N 次外部往返期间一直握着那些共享行的锁,而那正是本仓 2026-06 那次 # 登录被拖垮的形状(见 PLATFORM「Multi-recipient notification fan-out # has ONE primitive」)。收件人只有个位数听起来无所谓,但代价的形状与 # 规模无关:一次飞书超时就能把窗口从秒级拉到十几秒。 # # ``enqueue_then_drain`` 顺带给了两件这里确实需要的东西:phase 1 先把 # 每个人的 outbox 行落库并逐行提交(部署重启打断 drain 时,剩下的行 # 仍在 PENDING,由 ``notification.outbox_retry`` 兜住),以及一个人 # 发失败不会回滚已经发出去的那些。 envelopes = [ NotificationEnvelope( recipient=person, title=card["title"], body_text=card["body_text"], url=_ACTION_PATH, card_subtitle=LocalizedText( zh="每周一次。没有待处理的事项时这封摘要不会发出。", en=( "Weekly. Nothing is sent when there is nothing to act on." ), ja="週次。対応事項がない週は送信されません。", ), card_stats=card["card_stats"], card_highlights=card["card_highlights"], card_actions=card["card_actions"], related_resource_type="github_org", ) for person in recipients ] stats = await enqueue_then_drain( db, envelopes, trace_id=trace_id, label="github_org.governance_digest", ) return { "sent": stats["delivered"], "enqueued": stats["enqueued"], "failed": stats["failed"], "recipients": [str(p.id) for p in recipients], "counts": payload["counts"], "actionable_total": payload["actionable_total"], }