feat: 实现站内通知系统
- 后端: 新增 notifications/user_notifications 表迁移及 ORM 模型
- 后端: 实现 schema/repository/service/router 全套通知 API
- GET /api/v1/notifications (列表+游标分页)
- GET /api/v1/notifications/unread-count
- PATCH /api/v1/notifications/{id}/read (幂等)
- PATCH /api/v1/notifications/mark-all-read (幂等)
- 后端: payload 使用 Pydantic discriminated union (none/open_route/open_url)
- 后端: 19 个单元测试全部通过
- Flutter: 通知 feature 完整实现 (models/apis/repositories/bloc/UI)
- Flutter: Home 页通知按钮接入真实页面,显示未读 badge
- Flutter: 14 个测试全部通过
- 协议文档: notification-inbox-protocol.md 及错误码注册
This commit is contained in:
@@ -77,6 +77,12 @@ This document is the source of truth for backend RFC7807 `code` values consumed
|
||||
| `AVATAR_SIGNED_URL_FAILED` | 502 | Backend failed to generate avatar signed upload URL | Show retry toast and keep previous avatar |
|
||||
| `AVATAR_UPLOAD_FAILED` | 502 | Backend failed to upload avatar bytes to storage | Show retry toast and keep previous avatar |
|
||||
|
||||
## Notification
|
||||
|
||||
| code | status | meaning | frontend handling |
|
||||
|---|---:|---|---|
|
||||
| `NOTIFICATION_NOT_FOUND` | 404 | Notification not found or not owned by current user | Show not-found message and refresh list |
|
||||
|
||||
## Global
|
||||
|
||||
| code | status | meaning | frontend handling |
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Notification Inbox Protocol (Frontend <-> Backend)
|
||||
|
||||
This document defines the notification inbox contract for authenticated users.
|
||||
|
||||
Protocol verification status:
|
||||
|
||||
- Backend route source: `backend/src/v1/notifications/router.py`
|
||||
- Backend service source: `backend/src/v1/notifications/service.py`
|
||||
- Backend repository source: `backend/src/v1/notifications/repository.py`
|
||||
- Backend schema source: `backend/src/v1/notifications/schemas.py`
|
||||
|
||||
## Compatibility strategy
|
||||
|
||||
- Additive evolution only.
|
||||
- Existing response fields are stable and must remain backward-compatible.
|
||||
- New `action` values may be added to `payload`; unknown `action` values must be ignored by the client.
|
||||
|
||||
## Routes
|
||||
|
||||
### GET /api/v1/notifications
|
||||
|
||||
List notifications for the current user.
|
||||
|
||||
**Authorization**: Requires authenticated session. User identity from JWT `sub`.
|
||||
|
||||
**Query parameters**:
|
||||
|
||||
- `limit` (optional, integer, default 20, max 50): number of items per page
|
||||
- `cursor` (optional, string): pagination cursor (ISO 8601 timestamp of last item's `created_at`)
|
||||
|
||||
**Response (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"notificationId": "uuid",
|
||||
"type": "system",
|
||||
"title": "Welcome",
|
||||
"body": "Welcome to the app!",
|
||||
"payload": {
|
||||
"action": "none"
|
||||
},
|
||||
"isRead": false,
|
||||
"readAt": null,
|
||||
"createdAt": "2026-04-10T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"nextCursor": "2026-04-09T12:00:00Z",
|
||||
"hasMore": true
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
- `items`: array of notification items, ordered by `createdAt` descending
|
||||
- `nextCursor`: timestamp cursor for next page, `null` if no more items
|
||||
- `hasMore`: boolean indicating if more items exist
|
||||
- `type`: string, currently only `system`
|
||||
- `payload`: discriminated union (see Payload section below)
|
||||
- `isRead`: boolean
|
||||
- `readAt`: ISO 8601 timestamp or `null`
|
||||
- Results are filtered: `notifications.status = 'published'` and `notifications.deleted_at IS NULL`
|
||||
|
||||
### GET /api/v1/notifications/unread-count
|
||||
|
||||
Get the number of unread notifications for the current user.
|
||||
|
||||
**Authorization**: Requires authenticated session. User identity from JWT `sub`.
|
||||
|
||||
**Response (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 5
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
- `count`: integer `>= 0`
|
||||
- Counts only notifications where `notifications.status = 'published'` and `notifications.deleted_at IS NULL`
|
||||
|
||||
### PATCH /api/v1/notifications/{id}/read
|
||||
|
||||
Mark a single notification as read. Idempotent.
|
||||
|
||||
**Authorization**: Requires authenticated session. `id` must belong to the current user's `user_notifications`.
|
||||
|
||||
**Path parameters**:
|
||||
|
||||
- `id`: UUID of the `user_notifications` record
|
||||
|
||||
**Response (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"notificationId": "uuid",
|
||||
"type": "system",
|
||||
"title": "Welcome",
|
||||
"body": "Welcome to the app!",
|
||||
"payload": {
|
||||
"action": "none"
|
||||
},
|
||||
"isRead": true,
|
||||
"readAt": "2026-04-10T01:00:00Z",
|
||||
"createdAt": "2026-04-10T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error responses**:
|
||||
|
||||
- 404 `NOTIFICATION_NOT_FOUND`: notification not found or not owned by current user
|
||||
- Already-read notifications return 200 with current state (idempotent)
|
||||
|
||||
### PATCH /api/v1/notifications/mark-all-read
|
||||
|
||||
Mark all unread notifications for the current user as read. Idempotent.
|
||||
|
||||
**Authorization**: Requires authenticated session. User identity from JWT `sub`.
|
||||
|
||||
**Response (200)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"updatedCount": 3
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
- `updatedCount`: integer `>= 0`, number of notifications that were actually changed from unread to read
|
||||
- If all notifications are already read, returns `{ "updatedCount": 0 }`
|
||||
- Only affects notifications where `notifications.status = 'published'` and `notifications.deleted_at IS NULL`
|
||||
|
||||
## Payload
|
||||
|
||||
`payload` is a discriminated union based on the `action` field.
|
||||
|
||||
### action = "none"
|
||||
|
||||
No navigation action on tap.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "none"
|
||||
}
|
||||
```
|
||||
|
||||
### action = "open_route"
|
||||
|
||||
Navigate to an in-app route.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "open_route",
|
||||
"route": "/divination/history",
|
||||
"entityId": "optional-uuid",
|
||||
"tab": "optional-tab-name"
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
- `route`: required, string, max 200 characters, app-internal route path
|
||||
- `entityId`: optional, string, max 64 characters, business object ID
|
||||
- `tab`: optional, string, max 32 characters, sub-page navigation parameter
|
||||
- `url`: must be absent
|
||||
|
||||
### action = "open_url"
|
||||
|
||||
Open an external URL.
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "open_url",
|
||||
"url": "https://example.com/page"
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
- `url`: required, string, max 500 characters, external URL
|
||||
- `route`, `entityId`, `tab`: must be absent
|
||||
|
||||
## Error contract linkage
|
||||
|
||||
- RFC7807 + extension `code`, optional `params`.
|
||||
- Shared registry: `docs/protocols/common/http-error-codes.md`.
|
||||
- New error codes for this feature are registered in the same registry.
|
||||
Reference in New Issue
Block a user