"""员工自己能改的那几项偏好 —— 目前只有一项:他读哪种语言。 ## 为什么单独一个模块 这一列(`identity_persons.preferred_language`)现在有**三个**写入面:个人设置页 (`PATCH /auth/me/profile`)、飞书帮助卡上的语言按钮、以及给 AI 客户端的 MCP 工具。三处各写一遍「校验 → 赋值 → 落审计」,迟早会有一处漏掉审计、或者接受一个 另外两处不接受的值 —— 而这一列决定的是**这个人收到的每一封通知、每一张卡**用 哪种语言(ADR 2026-08-09),写错了很久都不会有人报。 所以把「怎么写」收在这里,面各自负责「谁能写」(会话类型、RBAC、PAT 拒绝)。 """ from __future__ import annotations from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.core.locale import Lang, normalize_lang from app.identity.person_models import IdentityPerson #: 入库的拼法。列里存的是 BCP-47 风格的值(个人设置页与通知模板都按它读), #: 而代码里流通的是 :data:`app.core.locale.Lang` 的三个短码 —— 两者的映射只在 #: 这里写一次。 STORED_BY_LANG: dict[Lang, str] = { "zh": "zh_CN", "en": "en_US", "ja": "ja_JP", } #: 审计动作名。和个人设置页那条**故意不同**:同一个人的语言被谁改了、从哪个面 #: 改的,是排查「我的通知怎么突然变成英文了」时第一个要问的问题。 ACTION_LANGUAGE_SET = "identity.person.language_set" async def set_preferred_language( db: AsyncSession, *, person: IdentityPerson, lang: Lang, source: str, trace_id: str = "", ) -> bool: """把这个人的语言偏好设成 ``lang``。返回**是否真的变了**。 不 commit —— 调用方决定事务边界(卡片那一侧还要在同一个事务里发卡)。 值没变时不写审计:一条「从 en 改成 en」的记录只会淹没真正的变更。 """ stored = STORED_BY_LANG[lang] previous = person.preferred_language if previous == stored: return False person.preferred_language = stored await AuditService(db).log( trace_id=trace_id, actor_type="user", actor_user_id=person.id, action=ACTION_LANGUAGE_SET, resource_type="identity_person", resource_id=str(person.id), status="success", detail={"from": previous, "to": stored, "source": source}, ) return True def current_lang(person: Any) -> Lang | None: """这个人**显式设过**的语言,没设过返回 ``None``。 和 :func:`app.core.locale.resolve_lang` 的区别是它**不猜**:界面上要标出 「当前选的是哪一个」,而按国家推断出来的那个并不是他选的 —— 把推断值画成 选中态,会让「我明明没选过」变成一个没人能回答的问题。 """ return normalize_lang(getattr(person, "preferred_language", None)) __all__ = [ "ACTION_LANGUAGE_SET", "STORED_BY_LANG", "current_lang", "set_preferred_language", ]