Files
diary-news/docs/android/02-api-contract.md

585 lines
13 KiB
Markdown
Raw Normal View History

# 02 · API 契约
> 后端:`http://207.57.129.228:3000/api/v1`
>
> 所有接口**走 Bearer JWT**(除 `auth/*` 外)。401 自动由 OkHttp `TokenAuthenticator` 处理。
>
> 本文档是**对后端 OpenAPI 的客户端镜像**,任何 schema 改动两边必须同步更新。
---
## 1. 鉴权(2 个)
### 1.1 `POST /auth/login`
**用途**:用户名密码登录,拿 token。
**Request**:
```json
{
"username": "owner",
"password": "test1234"
}
```
**Response 200**:
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 3600
}
```
**DTO**:
```kotlin
@Serializable
data class LoginRequest(
val username: String,
val password: String,
)
@Serializable
data class TokenPairDto(
val access_token: String,
val refresh_token: String,
@SerialName("token_type") val tokenType: String = "bearer",
@SerialName("expires_in") val expiresIn: Int,
)
```
### 1.2 `POST /auth/refresh`
**用途**:access 过期时换新的。
**Request**:
```json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
```
**Response 200**:同 `login`
**DTO**:
```kotlin
@Serializable
data class RefreshRequest(
@SerialName("refresh_token") val refreshToken: String,
)
```
---
## 2. 我(2 个)
### 2.1 `GET /me`
**Response 200**:
```json
{
"id": 1,
"username": "owner",
"email": null,
"role": "owner",
"created_at": "2026-01-15T08:00:00Z",
"last_login_at": "2026-06-10T03:42:11Z"
}
```
**DTO**:
```kotlin
@Serializable
data class MeDto(
val id: Long,
val username: String,
val email: String? = null,
val role: String, // "owner" | "member"
@SerialName("created_at") val createdAt: String,
@SerialName("last_login_at") val lastLoginAt: String? = null,
)
```
### 2.2 `GET /me/usage`
**用途**:TMT 翻译配额使用情况。
**Response 200**:
```json
{
"month_used": 128000,
"month_quota": 5000000,
"month_remaining": 4872000
}
```
**DTO**:
```kotlin
@Serializable
data class UsageDto(
@SerialName("month_used") val monthUsed: Long,
@SerialName("month_quota") val monthQuota: Long,
@SerialName("month_remaining") val monthRemaining: Long,
)
```
---
## 3. 文章(2 个)
### 3.1 `GET /articles` — 列表(分页)
**Query 参数**:
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `page` | int | 否 | 1 | 页码(从 1 开始)|
| `page_size` | int | 否 | 50 | 每页条数(1-200)|
| `source` | string | 否 | - | 逗号分隔 source slug,如 `dw,nhk` |
| `q` | string | 否 | - | 标题 / 正文模糊搜索 |
| `lang` | enum | 否 | "both" | `src` / `zh` / `both` |
| `since` | datetime | 否 | 24h 前 | 起始时间(UTC)|
| `until` | datetime | 否 | - | 结束时间(UTC)|
| `category` | string | 否 | - | LLM 分类 |
| `starred_only` | bool | 否 | false | 只看收藏 |
**Response 200**:
```json
{
"items": [
{
"id": 510006,
"source": {
"id": 4,
"name": "BBC 中文",
"slug": "bbc-zh",
"region": "UK"
},
"title": "Iran attacks Bahrain and Jordan...",
"title_zh": "伊朗袭击巴林和约旦以报复美国对霍尔木兹的袭击",
"body_zh_text": "...",
"summary_zh": "...",
"lang_src": "en",
"translation_status": "ok",
"category": "国际,军事",
"published_at": "2026-06-10T03:30:00Z",
"fetched_at": "2026-06-10T03:35:21Z",
"image_url": "https://...",
"image_ai_url": "https://...",
"commentary": "中东局势进一步升级...",
"commentary_status": "ok",
"is_starred": false
}
],
"page": 1,
"page_size": 50,
"total": 228,
"total_pages": 5
}
```
**DTO**:
```kotlin
@Serializable
data class ArticleListResponseDto(
val items: List<ArticleListItemDto>,
val page: Int,
@SerialName("page_size") val pageSize: Int,
val total: Int,
@SerialName("total_pages") val totalPages: Int,
)
@Serializable
data class ArticleListItemDto(
val id: Long,
val source: SourceBriefDto,
val title: String,
@SerialName("title_zh") val titleZh: String? = null,
@SerialName("body_zh_text") val bodyZhText: String? = null,
@SerialName("summary_zh") val summaryZh: String? = null,
@SerialName("lang_src") val langSrc: String? = null,
@SerialName("translation_status") val translationStatus: String, // "pending" | "ok" | "failed"
val category: String? = null,
@SerialName("published_at") val publishedAt: String? = null,
@SerialName("fetched_at") val fetchedAt: String,
@SerialName("image_url") val imageUrl: String? = null,
@SerialName("image_ai_url") val imageAiUrl: String? = null,
val commentary: String? = null,
@SerialName("commentary_status") val commentaryStatus: String? = null,
@SerialName("is_starred") val isStarred: Boolean = false,
)
@Serializable
data class SourceBriefDto(
val id: Long,
val name: String,
val slug: String,
val region: String? = null,
)
```
### 3.2 `GET /articles/{id}` — 详情
**Response 200**(字段是 ListItem 的超集,再加正文):
```json
{
"id": 510006,
"source": { ... },
"url": "https://www.bbc.com/...",
"title": "Iran attacks...",
"body_html": "<p>...</p>",
"body_text": "Iran attacked Bahrain...",
"title_zh": "伊朗袭击巴林...",
"body_zh_html": "<p>...</p>",
"body_zh_text": "伊朗袭击巴林和约旦...",
"body_zh_formatted": "<div class='diary-para'>...</div>",
"summary_zh": "...",
"lang_src": "en",
"author": "Jane Doe",
"image_url": "...",
"image_ai_url": "...",
"translation_status": "ok",
"translation_engine": "tencent",
"translated_at": "2026-06-10T03:36:02Z",
"category": "国际,军事",
"format_status": "ok",
"classify_status": "ok",
"image_ai_status": "ok",
"commentary_status": "ok",
"commentary": "中东局势进一步升级...",
"entities": {
"PERSON": ["Trump", "Khamenei"],
"ORG": ["UN", "EU"]
},
"sentiment": -0.42,
"duplicate_of": null,
"published_at": "2026-06-10T03:30:00Z",
"fetched_at": "2026-06-10T03:35:21Z",
"is_starred": true
}
```
**DTO**:
```kotlin
@Serializable
data class ArticleDetailDto(
// 继承列表项所有字段
val id: Long,
val source: SourceBriefDto,
val title: String,
@SerialName("title_zh") val titleZh: String? = null,
@SerialName("body_zh_text") val bodyZhText: String? = null,
@SerialName("summary_zh") val summaryZh: String? = null,
@SerialName("lang_src") val langSrc: String? = null,
@SerialName("translation_status") val translationStatus: String,
val category: String? = null,
@SerialName("published_at") val publishedAt: String? = null,
@SerialName("fetched_at") val fetchedAt: String,
@SerialName("image_url") val imageUrl: String? = null,
@SerialName("image_ai_url") val imageAiUrl: String? = null,
val commentary: String? = null,
@SerialName("commentary_status") val commentaryStatus: String? = null,
@SerialName("is_starred") val isStarred: Boolean = false,
// 详情独有
val url: String,
@SerialName("body_html") val bodyHtml: String? = null,
@SerialName("body_text") val bodyText: String,
@SerialName("body_zh_html") val bodyZhHtml: String? = null,
@SerialName("body_zh_formatted") val bodyZhFormatted: String? = null,
val author: String? = null,
@SerialName("translation_engine") val translationEngine: String? = null,
@SerialName("translated_at") val translatedAt: String? = null,
@SerialName("format_status") val formatStatus: String? = null,
@SerialName("classify_status") val classifyStatus: String? = null,
@SerialName("image_ai_status") val imageAiStatus: String? = null,
val entities: JsonObject? = null,
val sentiment: Double? = null,
@SerialName("duplicate_of") val duplicateOf: Long? = null,
)
```
---
## 4. 源(1 个)
### 4.1 `GET /sources`
**Response 200**:
```json
[
{
"id": 1,
"name": "DW 中文",
"slug": "dw",
"kind": "rss",
"url": "https://rss.dw.com/...",
"enabled": true,
"region": "DE",
"language_src": "de",
"priority": 10,
"fetch_interval_min": 30,
"translate_to": "zh",
"last_fetched_at": "2026-06-10T03:35:21Z",
"last_status": "ok",
"consecutive_failures": 0,
"blocklist_tags": ["体育", "娱乐"]
}
]
```
**DTO**:
```kotlin
@Serializable
data class SourceDto(
val id: Long,
val name: String,
val slug: String,
val kind: String,
val url: String,
val enabled: Boolean,
val region: String? = null,
@SerialName("language_src") val languageSrc: String? = null,
val priority: Int,
@SerialName("fetch_interval_min") val fetchIntervalMin: Int,
@SerialName("translate_to") val translateTo: String,
@SerialName("last_fetched_at") val lastFetchedAt: String? = null,
@SerialName("last_status") val lastStatus: String? = null,
@SerialName("consecutive_failures") val consecutiveFailures: Int = 0,
@SerialName("blocklist_tags") val blocklistTags: List<String>? = null,
)
```
---
## 5. 收藏(3 个)
### 5.1 `GET /bookmarks` — 我的收藏
**Response 200**:
```json
[
{
"id": 88,
"article_id": 510006,
"note": "中东局势长期观察",
"created_at": "2026-06-10T05:12:30Z"
}
]
```
**DTO**:
```kotlin
@Serializable
data class BookmarkDto(
val id: Long,
@SerialName("article_id") val articleId: Long,
val note: String? = null,
@SerialName("created_at") val createdAt: String,
)
```
### 5.2 `POST /bookmarks` — 收藏
**Request**:
```json
{
"article_id": 510006,
"note": "可选备注"
}
```
**Response 201**:同 5.1。
**DTO**:
```kotlin
@Serializable
data class BookmarkCreateRequest(
@SerialName("article_id") val articleId: Long,
val note: String? = null,
)
```
### 5.3 `DELETE /bookmarks/{article_id}` — 取消收藏
**Response 204**(无 body)。
---
## 6. 订阅(3 个)
### 6.1 `POST /subscriptions` — 订阅源
**Request**:
```json
{
"source_id": 1,
"category_filter": ["国际", "科技"]
}
```
**Response 201**:
```json
{
"id": 12,
"source_id": 1,
"category_filter": ["国际", "科技"],
"created_at": "2026-06-10T05:00:00Z"
}
```
**DTO**:
```kotlin
@Serializable
data class SubscriptionCreateRequest(
@SerialName("source_id") val sourceId: Long,
@SerialName("category_filter") val categoryFilter: List<String>? = null,
)
@Serializable
data class SubscriptionDto(
val id: Long,
@SerialName("source_id") val sourceId: Long,
@SerialName("category_filter") val categoryFilter: List<String>? = null,
@SerialName("created_at") val createdAt: String,
)
```
### 6.2 `GET /subscriptions` — 我的订阅列表
**Response 200**:`SubscriptionDto[]`
### 6.3 `DELETE /subscriptions/{id}` — 取消订阅
**Response 204**。
---
## 7. 错误响应
### 7.1 4xx / 5xx 统一格式(RFC 7807)
```json
{
"type": "about:blank",
"title": "Article not found",
"status": 404,
"instance": "http://207.57.129.228:3000/api/v1/articles/999999"
}
```
### 7.2 422 Validation Error
```json
{
"type": "about:blank",
"title": "Validation Error",
"status": 422,
"errors": [
{
"loc": ["body", "username"],
"msg": "field required",
"type": "value_error.missing"
}
],
"instance": "..."
}
```
### 7.3 客户端处理策略
```kotlin
sealed class ApiError(message: String) : Exception(message) {
object Unauthorized : ApiError("请重新登录")
object NotFound : ApiError("资源不存在")
data class Validation(val errors: List<String>) : ApiError("请求参数错误: $errors")
data class Server(val status: Int, val title: String) : ApiError("服务器错误: $title")
data class Network(cause: Throwable) : ApiError("网络错误: ${cause.message}")
}
// 在 Repository 里捕获 HttpException:
val response = try {
api.listArticles(...)
} catch (e: HttpException) {
when (e.code()) {
401 -> throw ApiError.Unauthorized
404 -> throw ApiError.NotFound
422 -> {
val body = e.response()?.errorBody()?.string()
throw ApiError.Validation(parseValidationErrors(body))
}
else -> throw ApiError.Server(e.code(), e.message())
}
} catch (e: IOException) {
throw ApiError.Network(e)
}
```
---
## 8. DTO ↔ Domain 映射表
DTO 是网络层的事,domain model 是 UI 层的事。**永远不要让 DTO 漏到 UI**。
| DTO | Domain | 转换点 |
|---|---|---|
| `ArticleListItemDto` | `domain.model.Article` | `ArticlePagingSource.map()` |
| `ArticleDetailDto` | `domain.model.ArticleDetail` | `ArticleRepository.getArticle()` |
| `SourceDto` | `domain.model.Source` | `SourceRepository.list()` |
| `BookmarkDto` | `domain.model.Bookmark` | `BookmarkRepository.list()` |
| `MeDto` | `domain.model.User` | `AuthRepository.me()` |
**Domain model 示例**(UI 用):
```kotlin
data class Article(
val id: Long,
val sourceName: String,
val sourceSlug: String,
val title: String,
val titleZh: String?,
val bodyZhText: String?,
val summaryZh: String?,
val langSrc: String?,
val translationStatus: String,
val categories: List<String>, // 已拆分逗号分隔
val publishedAt: Instant?,
val imageUrl: String?,
val imageAiUrl: String?,
val commentary: String?,
val commentaryStatus: String?,
val isStarred: Boolean,
)
data class ArticleDetail(
val article: Article,
val url: String,
val bodyText: String,
val bodyHtml: String?,
val bodyZhHtml: String?,
val bodyZhFormatted: String?, // 优先用这个做正文渲染
val author: String?,
val translationEngine: String?,
val formatStatus: String?,
val classifyStatus: String?,
val imageAiStatus: String?,
val entities: Map<String, List<String>>?,
val sentiment: Double?,
)
```
---
## 9. 不在 MVP 的接口(列出来,以后再用)
下面这些是 `admin` 系列,客户端不需要:
- `GET /admin/sources` `POST /admin/sources` `PATCH /admin/sources/{id}` `DELETE /admin/sources/{id}`
- `POST /admin/refresh/{source_id}`
- `POST /admin/translation/rerun/{article_id}`
- `POST /admin/translation/quota/reset`
- `GET /admin/health`
- `GET /admin/llm/settings` `PUT /admin/llm/settings` `POST /admin/llm/settings/reset` `POST /admin/llm/settings/test`
- `POST /admin/llm/enrich/{article_id}`
这些都是 **owner 自己后台用**,你会在 web 后台管,app 不碰。