"""卡头那个 Helix 标志,以及它为什么不能写死在代码里。 飞书卡片 2.0 的 header 图标可以是自定义图片,取的是 ``img_v3_*`` 形式的 ``image_key``。那个 key 由 ``POST /im/v1/images`` 上传得到,而**它是按飞书应用 隔离的**:dev 应用传出来的 key,prod 应用解析不了(实测 —— 跨应用引用会被拒)。 Helix 的 dev 与 prod 是两个不同的自建应用,所以把某次上传的 key 写进源码,等于 让生产上每一张卡的图标静默变空。 于是图标是**运行时按应用解析**的:图片作为仓库资产随代码走,key 按 ``(app_id, 图片内容哈希)`` 缓存在 ``platform_settings``。哈希进 key 意味着换一张 logo 只要替换 PNG —— 下一次发卡自动重新上传,不需要记得去清缓存。 失败一律降级成「这张卡没有自定义图标」,永远不让图标搞挂一条通知:卡片渲染路径 上每一个抛出的异常,最终都是「员工没收到那件事」。 """ from __future__ import annotations import hashlib import logging import time from pathlib import Path from typing import TYPE_CHECKING, Any from sqlalchemy.ext.asyncio import AsyncSession if TYPE_CHECKING: # pragma: no cover — typing only from app.approval.feishu_client import FeishuApprovalClient logger = logging.getLogger(__name__) #: 随代码发布的图标资产。256×256 PNG,由 ``web/public/helix-favicon.svg`` #: 光栅化而来 —— 卡头图标和浏览器标签页图标是同一个标志,不该有两套画法。 ICON_PATH = Path(__file__).with_name("assets") / "helix-card-icon.png" #: 缓存所在的平台设置键。值形如 #: ``{"app_id": "cli_…", "sha256": "…", "img_key": "img_v3_…"}``。 SETTING_KEY = "notifications.card_icon" #: 上传失败后的冷却期(秒)。没有它,一个持续失败的上传会被每一张待发卡片 #: 重试一次 —— 一次全公司 fan-out 就是几百次无用的外部调用。 _FAILURE_COOLDOWN_SECONDS = 600 #: 进程内记忆:``(app_id, sha256) → img_key``。平台设置已经够快,但发卡是 #: 逐收件人的循环,一次 fan-out 会问上几百次。 _MEMO: dict[tuple[str, str], str] = {} #: ``app_id → 冷却到什么时候``。 _FAILED_UNTIL: dict[str, float] = {} def _icon_bytes() -> bytes | None: """读图标资产。文件缺失只记一条日志 —— 卡片照发,只是没有图标。""" try: return ICON_PATH.read_bytes() except OSError as exc: logger.warning("card icon asset unreadable at %s: %s", ICON_PATH, exc) return None def _digest(content: bytes) -> str: return hashlib.sha256(content).hexdigest() def _cached_key(stored: Any, *, app_id: str, sha: str) -> str | None: """从平台设置里取出仍然有效的 key。 三个字段全部要对得上:``app_id`` 变了(换应用/换环境)或 ``sha256`` 变了 (换了 logo)都必须重新上传,拿旧 key 去渲染只会得到一个空图标。 """ if not isinstance(stored, dict): return None if stored.get("app_id") != app_id or stored.get("sha256") != sha: return None key = stored.get("img_key") return key if isinstance(key, str) and key else None async def _persist(app_id: str, sha: str, img_key: str) -> None: """把 key 写进平台设置 —— 用**自己的 session**。 调用方的 session 正处在某个领域事务里(建请求、批审批),而这是一次派生 缓存写入。挂在调用方的事务上会有两个后果:调用方回滚会连带把缓存丢掉,而 这里的任何写失败会污染调用方的事务(failed flush 会毒化 session)。所以 开一个独立 session,并且吞掉它自己的异常。 不写审计:这不是人设置的配置,是一次内容寻址的上传缓存。审计流是给 「谁改了什么」看的,把系统自己的缓存刷新混进去只会让它更难读。 """ from app.core.database import async_session_factory # noqa: PLC0415 — cycle from app.integrations.models import PlatformSetting # noqa: PLC0415 — cycle try: async with async_session_factory() as own: row = await own.get(PlatformSetting, SETTING_KEY) value = {"app_id": app_id, "sha256": sha, "img_key": img_key} if row is None: own.add(PlatformSetting( key=SETTING_KEY, value=value, description=( "飞书卡头 Helix 图标的 image_key 缓存(按应用 + 图片哈希)。" "由 notifications.card_icon 自动维护,不要手工编辑。" ), )) else: row.value = value await own.commit() except Exception as exc: # noqa: BLE001 — 缓存写失败不该影响发卡 logger.warning("card icon key not persisted (will re-upload): %s", exc) async def resolve_icon_img_key( db: AsyncSession, client: FeishuApprovalClient, *, app_id: str, ) -> str | None: """这个应用下 Helix 图标的 ``image_key``,拿不到就返回 ``None``。 顺序:进程内记忆 → 平台设置 → 上传一次并缓存。``None`` 的含义是「这张卡 用语义图标或不用图标」,渲染器据此降级,不是错误。 """ from app.integrations.service import get_platform_setting # noqa: PLC0415 if not app_id: return None content = _icon_bytes() if content is None: return None sha = _digest(content) memo = _MEMO.get((app_id, sha)) if memo: return memo try: stored = await get_platform_setting(SETTING_KEY, None, session=db) except Exception as exc: # noqa: BLE001 — 读设置失败就走上传那一路 logger.info("card icon setting unreadable: %s", exc) stored = None cached = _cached_key(stored, app_id=app_id, sha=sha) if cached: _MEMO[(app_id, sha)] = cached return cached if time.monotonic() < _FAILED_UNTIL.get(app_id, 0.0): return None try: img_key = await client.upload_message_image(content=content) except Exception as exc: # noqa: BLE001 — 外部调用,失败就不要图标 _FAILED_UNTIL[app_id] = time.monotonic() + _FAILURE_COOLDOWN_SECONDS logger.warning("card icon upload failed, cards go iconless: %s", exc) return None _MEMO[(app_id, sha)] = img_key await _persist(app_id, sha, img_key) return img_key def reset_cache_for_tests() -> None: """清进程内记忆。 测试之间必须清:这两个 dict 是模块级的,一个用假 client 跑出来的 key 会被 下一个测试当成真的用(而且 ``_FAILED_UNTIL`` 的冷却会让后面的测试看起来 「上传从来没发生过」)。 """ _MEMO.clear() _FAILED_UNTIL.clear() __all__ = [ "ICON_PATH", "SETTING_KEY", "reset_cache_for_tests", "resolve_icon_img_key", ]