# 消息通知中心(notifications 族)

> 所属:notifications 组(前缀 `/api/v1/notifications`,源 `app/api/v1/notifications.py`) | 鉴权:**全部 Bearer**(消息按用户隔离) | [← 返回 API 索引](./README.md)
>
> 对应 PRD《消息通知中心》(通知类型清单 / 点击跳转 / 字段元素 / 未读红点 / Push 文案)。
> Push 侧(厂商直推 + 测试)见 [push-vendor-test.md](./push-vendor-test.md)。
>
> **数据落库**:消息存 `notification` 表(`app/repositories/notification.py`,按用户隔离,`sentAt` 倒序)。业务事件统一走 `app/services/notification_events.py` 下发(站内消息 + 厂商 push 一条链路,业务事务 commit 后触发、失败只 log 不影响业务)。**已接入 6 类真实触发**:
>
> | type | 触发点 |
> |---|---|
> | `withdraw_success` | 提现单转账到账(免确认直达 / 查单归一化 / 对账兜底,`repositories/wallet.py`) |
> | `withdraw_failed` | 提现退款收口 `_refund_withdraw`(微信侧失败、审核拒绝、解绑退回) |
> | `feedback_reply` | admin 反馈审核「拒绝」(原因/留言用户可见,`admin/routers/feedback.py`) |
> | `feedback_reward` | admin 反馈审核「采纳」发金币(必带官方留言) |
> | `report_approved` | admin 上报更低价「通过」发金币(`admin/routers/price_report.py`) |
> | `invite_order_reward` | 被邀请好友首次成功比价 → 邀请人发 2 元(`repositories/invite.try_reward_on_compare`) |
>
> 其余类型(奖励过期 ×2、权限异常 ×4、好友催单)业务侧尚未接入。要造联调数据,用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true` 逐条插入。
>
> ⚠️ **字段命名**:本组接口(含 push 测试组)对外为 **camelCase**(`sentAt` / `isRead` / `pageSize`…),与库内其他 snake_case 接口不同——按 PRD 前端契约对接,勿混用。

## 通知类型速查(13 种)

列表**服务端已排好序:全列表按时间倒序**(最新在前,**不做分类分组**——PRD §1 的"按分类分组"为笔误,2026-07-14 需求方确认取消),前端按返回顺序渲染即可。category 仅用于卡片头部的分类标签展示。

| category | 分类标签 | type | 类型 | cardStyle 版式 | actionText 操作行 | extra 里带什么 |
|---|---|---|---|---|---|---|
| withdraw_assistant | 提现助手 | `reward_expiring` | 金币现金奖励即将失效 | dual_amount 双金额卡 | 立即激活您的收益 | `batchId` |
| withdraw_assistant | 提现助手 | `reward_expired` | 金币现金奖励已失效 | dual_amount 双金额卡 | 立即赚取新收益 | — |
| withdraw_assistant | 提现助手 | `withdraw_success` | 提现成功 | withdraw 提现卡 | **null(无操作行,点击仅消红点)** | — |
| withdraw_assistant | 提现助手 | `withdraw_failed` | 提现失败,款项已退回 | withdraw 提现卡 | 重新提现 | `withdrawId` |
| system | 系统通知 | `perm_accessibility` | 比价功能异常(无障碍) | plain_text 纯文本卡 | 去开启 | `permission:"accessibility"` |
| system | 系统通知 | `perm_battery` | 比价续航保护异常(省电策略) | plain_text 纯文本卡 | 去开启 | `permission:"battery"` |
| system | 系统通知 | `perm_autostart` | 比价启动保护异常(自启动) | plain_text 纯文本卡 | 去开启 | `permission:"autostart"` |
| system | 系统通知 | `perm_overlay` | 比价按钮异常(悬浮窗) | plain_text 纯文本卡 | 去开启 | `permission:"overlay"` |
| feedback | 我的反馈 | `feedback_reply` | 官方回复 | plain_text 纯文本卡 | 查看详情 | `feedbackId` |
| feedback | 我的反馈 | `feedback_reward` | 反馈奖励(必带官方留言行) | coin_reward 金币奖励卡 | 查看反馈详情 | `feedbackId` |
| report | 我的爆料 | `report_approved` | 爆料审核通过 | coin_reward 金币奖励卡 | 查看爆料详情 | `reportId` |
| invite | 好友邀请 | `invite_order_reward` | 好友下单奖励到账 | friend_cash 好友现金卡 | 邀请更多好友赚现金 | `inviteeNickname` |
| invite | 好友邀请 | `invite_remind` | 好友催单提醒 | plain_text 纯文本卡 | 去提醒 TA | `inviteeNickname`, `scrollTo:"remind"` |

点击跳转逻辑按 PRD §2 由客户端按 `type` 分发;点击目标 = 整张卡片(不区分主体和操作行),任何点击都先调 `POST /read` 消该条红点。

## GET /api/v1/notifications — 消息列表(分页)

**入参(query)**

| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `page` | int | ❌ | 页码,1 起,默认 1 |
| `pageSize` | int | ❌ | 每页条数,默认 20,最大 100 |

**出参**

```jsonc
{
  "items": [
    {
      "id": 90001,
      "category": "withdraw_assistant",   // 分类 key(5 种,见上表)
      "categoryLabel": "提现助手",         // 卡片头部左上角分类标签
      "type": "reward_expiring",          // 类型 key(13 种,决定点击行为)
      "cardStyle": "dual_amount",         // 版式:dual_amount/withdraw/plain_text/coin_reward/friend_cash
      "title": "金币现金奖励即将失效",      // 卡片标题
      "coins": 86,                        // 金币数,整数;无金币的版式为 null
      "cashCents": 1280,                  // 现金金额(分);无现金的版式为 null
      "cashYuan": "12.80",                // 现金展示串(元,两位小数),与 cashCents 同源
      "infoRows": [                       // 信息行,已按 PRD 拼好文案,逐行 label: value 渲染
        { "label": "过期说明", "value": "您有86金币和12.80元现金即将失效，完成一次一键领券或一键比价即可激活收益" },
        { "label": "过期时间", "value": "3天后失效" }
      ],
      "actionText": "立即激活您的收益",    // 操作行;null = 无操作行(提现成功卡)
      "extra": { "batchId": "batch_20260714" },  // 跳转/联动参数,按 type 取用(见上表)
      "sentAt": "2026-07-14T14:59:58+08:00",     // ISO8601 带时区
      "isRead": false                     // false → 分类标签右侧显示 6px 红点(#E53935)
    }
  ],
  "page": 1,
  "pageSize": 20,
  "total": 16,
  "hasMore": false,
  "unreadCount": 12    // 与 /unread-count 同口径,进页面可顺手刷角标
}
```

**时间显示规则(前端处理 `sentAt`)**:今天 →「今天」;昨天 →「昨天」;当年 →「M月D日」(不补零);跨年 →「YYYY年M月D日」。`sentAt` 恒带 +08:00(服务端已归一,与库底层用 SQLite/PostgreSQL 无关)。

**数值约束(PRD §3)**:金币整数不带小数;现金/提现金额两位小数(直接用 `cashYuan`)。

## GET /api/v1/notifications/unread-count — 未读总数(首页铃铛角标)

无入参。**出参**:

```jsonc
{ "count": 12, "badgeText": "12" }   // count>99 时 badgeText="99+";count=0 时 badgeText=null → 整个角标隐藏
```

刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时(原型监听 `pageshow`)。

## POST /api/v1/notifications/read — 标记已读

**入参(JSON),两种模式二选一(同时传时 `all` 优先)**

| 模式 | body | 使用场景 |
|---|---|---|
| 单条/多条 | `{ "ids": [90001, 90003] }` | ① 点击某张消息卡片(点击后无论跳转/弹窗/无动作都算已读);② 用户点击 push 直达落地页后,客户端拿 push extras 里的 `notificationId` 同步置读 |
| 全量清零 | `{ "all": true }` | 进入通知中心自动清零(只浏览列表就消红点,无需逐条点击;退出通知中心时也可再调一次兜底) |

**出参**

```jsonc
{ "ok": true, "markedCount": 2, "unreadCount": 10 }   // unreadCount = 处理后剩余未读,可直接刷新角标
```

幂等:不存在/已读的 id 忽略,重复调用 `markedCount=0` 不报错。

**错误**:`400` ids 与 all 都没传(或 ids 为空数组);`401` 未鉴权。

## 联调小抄

```bash
# 1. 登录拿 token(SMS mock:任意手机号 + 任意 6 位验证码)
curl -X POST :8770/api/v1/auth/sms/send  -d '{"phone":"13800001234"}'
curl -X POST :8770/api/v1/auth/sms/login -d '{"phone":"13800001234","code":"123456"}'
# 2. 列表 / 角标 / 置读
curl ":8770/api/v1/notifications?page=1&pageSize=20" -H "Authorization: Bearer $TOKEN"
curl ":8770/api/v1/notifications/unread-count"        -H "Authorization: Bearer $TOKEN"
curl -X POST ":8770/api/v1/notifications/read" -d '{"all":true}' -H "Authorization: Bearer $TOKEN"
```

列表初始为空,登录后先用 [POST /api/v1/push/test](./push-vendor-test.md) 的 `createNotification:true` 插几条(可指定 `type` 覆盖不同版式),再验列表 / 角标 / 置读全流程;它同时把 `notificationId` 放进 push extras,可闭环验证「push → 站内已读联动」。
