# 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, 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": "

...

", "body_text": "Iran attacked Bahrain...", "title_zh": "伊朗袭击巴林...", "body_zh_html": "

...

", "body_zh_text": "伊朗袭击巴林和约旦...", "body_zh_formatted": "
...
", "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? = 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? = null, ) @Serializable data class SubscriptionDto( val id: Long, @SerialName("source_id") val sourceId: Long, @SerialName("category_filter") val categoryFilter: List? = 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) : 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, // 已拆分逗号分隔 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>?, 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 不碰。