docs(android): 完整方案 + logo 资源 + 启动屏
新增 docs/android/ 目录: - README.md 总入口(快速上手 + 决策摘要 + 数据流) - 01-architecture.md 模块划分 + 数据流 + 选型理由 - 02-api-contract.md 每个接口的请求/响应 + DTO 字段映射 - 03-build-run.md Gradle/SDK/网络安全白名单/真机调试 - 04-milestones.md 7 天里程碑 + DoD + E2E 测试场景 新增 assets/: - logo/: 主图标 master + adaptive icon + 5 DPI launcher (方/圆) - splash/: 启动屏 logo + 完整背景预览 + 5 DPI 资源 - android_resources/: 集成所需的 XML(adaptive icon/主题/颜色/字符串/drawable/layout) - INTEGRATION.md 集成指南 - logo.svg + _make_logo.py 设计源 设计风格:参考用户提供的木质方块字母积木图,米色木纹底 + 深棕色字母 D,代表 'Diary',温暖私人日记感。 服务器体检:所有容器/API/DB/翻译主链路正常,TMT 本月已用 0.37%。 MaaS 备用通道上次已验证可用。
This commit is contained in:
585
docs/android/02-api-contract.md
Normal file
585
docs/android/02-api-contract.md
Normal file
@@ -0,0 +1,585 @@
|
||||
# 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 不碰。
|
||||
Reference in New Issue
Block a user