"""消息通知中心 请求/响应契约。 ⚠️ 命名约定:本组接口按 PRD 前端契约使用 **camelCase**(sentAt / isRead / pageSize …), 与库内其他 snake_case 接口不同——PRD 与前端原型(notifications.html)按 camelCase 对接, 需求方接口清单亦明确写作 sentAt / isRead,故整组遵循之。响应序列化走 pydantic alias。 字段说明都写在 Field(description=...) 里,起服务后打开 /docs 即是给前端的在线文档。 """ from __future__ import annotations from datetime import datetime from typing import Any from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel class _CamelModel(BaseModel): """出参统一 camelCase(alias);populate_by_name 允许服务端代码仍用 snake_case 构造。""" model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class InfoRow(_CamelModel): """卡片信息行(PRD §3「信息行」列),前端按 label: value 逐行渲染。""" label: str = Field(description="行标签,如「过期说明」「到账账户」「失败原因」") value: str = Field(description="行内容(已按 PRD 文案拼好变量,前端直接展示)") class NotificationItem(_CamelModel): """一条通知卡片。 卡片头部三要素:categoryLabel(分类标签)+ 未读红点(isRead=false 时展示)+ 时间(sentAt)。 时间显示规则(前端处理):今天→「今天」;昨天→「昨天」;当年→「M月D日」;跨年→「YYYY年M月D日」。 """ id: int = Field(description="通知 id(未读消除、push 联动都用它)") category: str = Field( description="分类 key:withdraw_assistant=提现助手 / system=系统通知 / " "feedback=我的反馈 / report=我的爆料 / invite=好友邀请" ) category_label: str = Field(description="分类中文标签(卡片头部直接展示)") type: str = Field( description="类型 key(13 种,决定点击行为,见 PRD §2):reward_expiring 即将失效 / " "reward_expired 已失效 / withdraw_success 提现成功 / withdraw_failed 提现失败 / " "perm_accessibility 无障碍异常 / perm_battery 省电策略异常 / " "perm_autostart 自启动异常 / perm_overlay 悬浮窗异常 / " "feedback_reply 官方回复 / feedback_reward 反馈奖励 / " "report_approved 爆料审核通过 / invite_order_reward 好友下单奖励 / " "invite_remind 好友催单提醒" ) card_style: str = Field( description="卡片版式:dual_amount 双金额卡 / withdraw 提现卡 / plain_text 纯文本卡 / " "coin_reward 金币奖励卡 / friend_cash 好友现金卡" ) title: str = Field(description="卡片标题(双金额/提现/金币奖励/好友现金卡标题居中)") coins: int | None = Field( default=None, description="金币数(整数,不带小数)。dual_amount / coin_reward 卡有值,其余 null", ) cash_cents: int | None = Field( default=None, description="现金金额,单位【分】。dual_amount / withdraw / friend_cash 卡有值,其余 null", ) cash_yuan: str | None = Field( default=None, description="现金金额展示串(元,保留两位小数,如 \"12.80\"),与 cashCents 同源,可直接展示", ) info_rows: list[InfoRow] = Field( description="信息行列表(label: value),内容已按 PRD §3 拼好,前端逐行渲染即可" ) action_text: str | None = Field( default=None, description="操作行文案(如「立即激活您的收益」「去开启」);null=无操作行(提现成功卡)。" "注意:点击目标是整张卡片,不区分卡片主体和操作行", ) extra: dict[str, Any] = Field( description="点击跳转所需业务参数,按 type 取用:perm_* → {permission: accessibility|battery|" "autostart|overlay}(点击时实时检测该权限);feedback_* → {feedbackId};" "report_approved → {reportId};withdraw_failed → {withdrawId};" "invite_order_reward → {inviteeNickname};invite_remind → " "{inviteeNickname, scrollTo:\"remind\"};reward_expiring → {batchId}" ) sent_at: datetime = Field(description="下发时间(ISO8601 带 +08:00 时区),前端按显示规则格式化") is_read: bool = Field(description="是否已读;false 时分类标签右侧展示 6px 红点(#E53935)") class NotificationListOut(_CamelModel): """GET /api/v1/notifications 出参。列表已按时间倒序排好(最新在前,**不分组**; PRD §1 的"按分类分组"为笔误,已确认取消),前端无需再排。""" items: list[NotificationItem] = Field(description="当前页通知卡片") page: int = Field(description="当前页码(1 起)") page_size: int = Field(description="每页条数") total: int = Field(description="全部通知总条数(含已读)") has_more: bool = Field(description="是否还有下一页") unread_count: int = Field(description="当前未读总数(与 /notifications/unread-count 同口径,省一次请求)") class UnreadCountOut(_CamelModel): """GET /api/v1/notifications/unread-count 出参(首页铃铛角标)。""" count: int = Field(description="未读总条数(精确值)") badge_text: str | None = Field( description="角标展示文案:超过 99 返回 \"99+\";等于 0 返回 null(整个角标隐藏,不展示空红点)" ) class MarkReadRequest(_CamelModel): """POST /api/v1/notifications/read 入参,两种模式二选一: - `{"ids": [90001, 90002]}` 单条/多条置读——点击某张卡片、点击 push 落地后同步置读; - `{"all": true}` 全量清零——进入通知中心(或退出时)自动清零(PRD §4)。 同时传时 all=true 优先;不存在/已读的 id 自动忽略(幂等,可放心重试)。 """ ids: list[int] | None = Field(default=None, description="要置为已读的通知 id 列表") all: bool = Field(default=False, description="true=清空该用户全部未读") class MarkReadOut(_CamelModel): """POST /api/v1/notifications/read 出参。""" ok: bool = Field(description="固定 true(参数非法时走 400,不会到这里)") marked_count: int = Field(description="本次实际由未读变为已读的条数(重复请求会是 0)") unread_count: int = Field(description="处理后的剩余未读总数,可直接刷新铃铛角标")