"""回执:一张卡片在事情结束之后,变成它自己的台账。 ## 这是在解决什么 用户的原话:「helix 的推送没有任何回执的能力,这个审批过了,还是去审批」。 一条通知说的是「有人在等你做 X」。在这个模块之前,**没有任何东西回来说 X 做完了** —— 一张已经批准的续期审批卡在飞书里原样挂着一颗活的「去审批」按钮,点下去落在 一个早已翻页的页面上。而批准这件事可能发生在飞书审批应用里、Helix 网页里、MCP 里,所以「谁去改那张卡」不能挂在某一个入口上。 ## 一次结案,三个面一起变 铃铛、移动端行动收件箱、飞书卡片读的是**同一张 `notification_outbox`**。所以 :func:`settle` 做两件事,顺序是刻意的: 1. **先在行上盖戳**(`settled_*`)—— 这一步决定铃铛红点、收件箱列表、H5 行动 收件箱立刻不再把这条算作待办。它是本地写,不会失败。 2. **再去改飞书那张卡** —— 外部调用,可能失败(消息太老、应用换了、网络抖)。 失败只记日志:回执的**记录**已经落地,少改一张卡不该把领域事务拖下水。 反过来的顺序会得到最坏的结果:卡片改成了绿色「已批准」,而铃铛里那条还写着 「去审批」—— 两个面对着同一件事说不同的话,而员工没有任何办法知道哪个是真的。 ## 为什么它永远不抛异常 `settle` 的调用点是**领域流程的终态迁移**:批准、驳回、超期关单。那些事务已经 把权威状态写完了。一次回执失败绝不能回滚一次真实的批准 —— 所以整个函数包在 SAVEPOINT 里(和 `dispatch` 同样的理由),并且返回计数而不是抛错。 ## 幂等 只有 `settled_at IS NULL` 的行会被结案。重复调用不会二次改写卡片,也不会把处理人 换成后来那个人。这一点由 PR3 的对账 cron 直接依赖:它会对同一件事反复调用 `settle`,而只有第一次做实事。 """ from __future__ import annotations import logging from datetime import UTC, datetime from typing import Any from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.locale import DEFAULT_LANG, Lang, normalize_lang, pick_variant, resolve_lang from app.core.txn import best_effort from app.identity.person_models import IdentityPerson from app.integrations.compat import get_feishu_config from app.notifications import card_design from app.notifications.models import NotificationOutbox logger = logging.getLogger(__name__) #: 回执那句话的主语部分。「由 <谁> 于 <什么时候> <做了什么>」。 _BY_LINE: dict[str, dict[Lang, str]] = { card_design.STATUS_APPROVED: { "zh": "已批准", "en": "Approved", "ja": "承認されました", }, card_design.STATUS_REJECTED: { "zh": "已驳回", "en": "Declined", "ja": "却下されました", }, card_design.STATUS_EXPIRED: { "zh": "已到期关闭", "en": "Closed as expired", "ja": "期限切れで終了", }, card_design.STATUS_WITHDRAWN: { "zh": "已撤回", "en": "Withdrawn", "ja": "取り下げられました", }, card_design.STATUS_DONE: { "zh": "已完成", "en": "Done", "ja": "完了しました", }, } #: 回执上那两行事实的标签。 _LABEL_ACTOR: dict[Lang, str] = { "zh": "处理人", "en": "Handled by", "ja": "対応者", } _LABEL_WHEN: dict[Lang, str] = { "zh": "处理时间", "en": "Handled at", "ja": "対応日時", } #: 结案后留下的那颗次要按钮。原来的「去审批」不能留 —— 它邀请一次会被拒绝的点击。 _BUTTON_DETAIL: dict[Lang, str] = { "zh": "查看详情", "en": "View details", "ja": "詳細を見る", } #: 没有具体处理人时的主语(到期关单、系统自动完成)。 _ACTOR_SYSTEM: dict[Lang, str] = { "zh": "系统自动", "en": "Automatically", "ja": "システム", } def _pick(table: dict[Lang, str], lang: str | None) -> str: key: Lang = normalize_lang(lang) or DEFAULT_LANG # type: ignore[assignment] return table.get(key) or table[DEFAULT_LANG] def localized_note(note: dict[str, Any] | None, lang: str | None) -> str: """把 ``settled_note`` 的三语字典解析成读者那一种。""" if not isinstance(note, dict): return "" resolved = pick_variant( {k: str(v) for k, v in note.items() if isinstance(v, str)}, normalize_lang(lang) or DEFAULT_LANG, ) return resolved or str(note.get("zh") or "") def receipt_headline( *, outcome: str, actor_name: str | None, when: datetime, lang: str | None, ) -> str: """回执卡正文的第一句:谁、什么时候、做了什么。""" verb = _pick(_BY_LINE.get(outcome, _BY_LINE[card_design.STATUS_DONE]), lang) who = actor_name or _pick(_ACTOR_SYSTEM, lang) stamp = when.strftime("%m-%d %H:%M") if (normalize_lang(lang) or DEFAULT_LANG) == "zh": return f"由 {who} 于 {stamp} {verb}。" if (normalize_lang(lang) or DEFAULT_LANG) == "ja": return f"{stamp} に {who} により{verb}。" return f"{verb} by {who} at {stamp}." def build_receipt_card( row: NotificationOutbox, *, lang: str | None, actor_name: str | None, absolute_url: str | None, icon_img_key: str | None = None, ) -> dict[str, Any]: """把一条已结案的 outbox 行渲染成回执卡。 正文**不是**原来那段正文。原文说的是「请你去处理」,而这张卡现在说的是「已经 处理完了」—— 把两句话叠在一起,读者要自己判断哪句是现在的状态。所以回执卡是 重新写的:一句结果、两行事实(谁/何时)、领域补充的一句话,以及一颗指向详情的 **次要**按钮。原来的主按钮必须消失:它邀请的是一次会被拒绝的点击。 标题保留原标题 —— 聊天记录里靠它认出「这是那件事」;状态由头色、徽章和图标 表达,那正是 `card_design` 把状态做成一等概念换来的。 """ from app.notifications.service import ( # noqa: PLC0415 — cycle CardAction, CardFact, _build_interactive_card, _card_header_color, compose_default_card_footer, ) outcome = row.settled_outcome or card_design.STATUS_DONE when = row.settled_at or datetime.now(UTC) facts = [ CardFact( label=_pick(_LABEL_ACTOR, lang), value=actor_name or _pick(_ACTOR_SYSTEM, lang), icon="person", ), CardFact( label=_pick(_LABEL_WHEN, lang), value=when.strftime("%Y-%m-%d %H:%M"), icon="time", ), ] body = receipt_headline( outcome=outcome, actor_name=actor_name, when=when, lang=lang, ) note = localized_note(row.settled_note, lang) if note: body = f"{body}\n\n{note}" actions = None action_urls = None if absolute_url: actions = [CardAction( label=_pick(_BUTTON_DETAIL, lang), url=absolute_url, style="default", )] action_urls = [absolute_url] return _build_interactive_card( title=row.title, body_text=body, url=None, button_label="", is_en=(normalize_lang(lang) or DEFAULT_LANG) != "zh", lang=lang, status=outcome, header_color=_card_header_color(row.template_key, outcome), facts=facts, extra_actions=actions, extra_action_urls=action_urls, footer=compose_default_card_footer(lang, seed=row.template_key or row.title), icon_img_key=icon_img_key, ) async def settle( db: AsyncSession, *, resource_type: str, resource_id: UUID | str, outcome: str, actor_person_id: UUID | None = None, note: dict[str, str] | None = None, ) -> int: """把讲过这件事的每一条通知结案,并把飞书卡片改成回执。 返回结案的行数(0 = 本来就没有未结案的行,常见且正常)。 **永不抛异常**:调用点是领域流程的终态迁移,那里的权威状态已经写完了,一次 回执失败不该回滚一次真实的批准。 """ if outcome not in card_design.SETTLED_STATUSES: # 不是终态就不该结案 —— 一个拼错的值会把卡片改成中性态并且吃掉按钮, # 比不改更糟。大声记下来,什么都不做。 logger.error( "settle() refused a non-terminal outcome %r for %s (nothing changed)", outcome, resource_type, ) return 0 async with best_effort(db, label="settle notification cards") as step: rows = (await db.execute( select(NotificationOutbox) .where(NotificationOutbox.related_resource_type == resource_type) .where(NotificationOutbox.related_resource_id == str(resource_id)) .where(NotificationOutbox.settled_at.is_(None)) )).scalars().all() if not rows: return 0 now = datetime.now(UTC) for row in rows: row.settled_at = now row.settled_outcome = outcome row.settled_by_person_id = actor_person_id row.settled_note = dict(note) if note else None await db.flush() settled_rows = list(rows) if not step.ok: # SAVEPOINT 回滚了 —— 行已经过期,连读都不能读(MissingGreenlet)。 logger.warning( "settle() could not stamp rows for %s/%s", resource_type, resource_id, ) return 0 # 卡片是**之后**才改的,失败不影响上面那次盖戳(它随调用方的事务提交 —— # HTTP 请求由 ``get_db`` 收尾,cron 由 handler 显式 commit)。所以最坏的 # 情况是飞书那一面慢一点,由对账 cron 补上。 # # 反过来的顺序才是危险的。也接受一个次要的不对称:改卡片发生在提交**之前**, # 万一调用方之后回滚,卡片已经绿了而行仍未结案 —— 此时对账会重新解析领域 # 状态并再改一次(内容一致),所以它自愈;而调用方回滚的前提是那次决策本身 # 也回滚了,那正是应该被重新判定的情况。 await _repaint_cards(db, settled_rows, actor_person_id=actor_person_id) return len(settled_rows) async def _repaint_cards( db: AsyncSession, rows: list[NotificationOutbox], *, actor_person_id: UUID | None, ) -> None: """把每一条有 ``feishu_message_id`` 的行的卡片原地改成回执。 整段都是 best-effort:集成没配、消息太老、网络抖 —— 任何一种都只意味着那张 卡还是老样子,而记录已经是对的了。 """ targets = [r for r in rows if r.feishu_message_id] if not targets: return try: cfg = await get_feishu_config(session=db) except Exception as exc: # noqa: BLE001 — 集成不可用不该影响结案 logger.info("settle: feishu integration unavailable, cards left as-is: %s", exc) return app_id = str(cfg.get("app_id") or "") app_secret = str(cfg.get("app_secret") or "") if not app_id or not app_secret: return from app.approval.feishu_client import FeishuApprovalClient # noqa: PLC0415 from app.notifications import card_icon # noqa: PLC0415 from app.notifications.service import NotificationService # noqa: PLC0415 client = FeishuApprovalClient(app_id=app_id, app_secret=app_secret) actor_name = await _actor_name(db, actor_person_id) icon_img_key = await card_icon.resolve_icon_img_key(db, client, app_id=app_id) service = NotificationService(db) # 收件人 → 语言。一件事的卡片会发给不共享语言的多个人,回执必须逐人渲染 # ——「已批准」这三个字要用读者自己的语言说。 for row in targets: try: lang = await _recipient_lang(db, row) absolute_url = await service._resolve_absolute_url(row.url) # noqa: SLF001 card = build_receipt_card( row, lang=lang, actor_name=actor_name, absolute_url=absolute_url, icon_img_key=icon_img_key, ) await client.update_card_message( message_id=str(row.feishu_message_id), card=card, ) except Exception as exc: # noqa: BLE001 — 一张卡改不了不影响别的 logger.info( "settle: card %s left unchanged: %s", row.feishu_message_id, exc, ) async def _actor_name(db: AsyncSession, person_id: UUID | None) -> str | None: if person_id is None: return None person = await db.get(IdentityPerson, person_id) if person is None: return None return person.display_name or person.work_email async def _recipient_lang(db: AsyncSession, row: NotificationOutbox) -> str | None: if row.recipient_person_id is None: return None person = await db.get(IdentityPerson, row.recipient_person_id) return resolve_lang(person) if person is not None else None __all__ = [ "build_receipt_card", "localized_note", "receipt_headline", "settle", ]