Files
diary-news/docs/android/02-api-contract.md
Mavis 02f0260dfc 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 备用通道上次已验证可用。
2026-06-10 14:11:43 +08:00

13 KiB

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:

{
 "username": "owner",
 "password": "test1234"
}

Response 200:

{
 "access_token": "eyJhbGciOiJIUzI1NiIs...",
 "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
 "token_type": "bearer",
 "expires_in": 3600
}

DTO:

@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:

{
 "refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}

Response 200:同 login

DTO:

@Serializable
data class RefreshRequest(
 @SerialName("refresh_token") val refreshToken: String,
)

2. 我(2 个)

2.1 GET /me

Response 200:

{
 "id": 1,
 "username": "owner",
 "email": null,
 "role": "owner",
 "created_at": "2026-01-15T08:00:00Z",
 "last_login_at": "2026-06-10T03:42:11Z"
}

DTO:

@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:

{
 "month_used": 128000,
 "month_quota": 5000000,
 "month_remaining": 4872000
}

DTO:

@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:

{
 "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:

@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 的超集,再加正文):

{
 "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:

@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:

[
 {
 "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:

@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:

[
 {
 "id": 88,
 "article_id": 510006,
 "note": "中东局势长期观察",
 "created_at": "2026-06-10T05:12:30Z"
 }
]

DTO:

@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:

{
 "article_id": 510006,
 "note": "可选备注"
}

Response 201:同 5.1。

DTO:

@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:

{
 "source_id": 1,
 "category_filter": ["国际", "科技"]
}

Response 201:

{
 "id": 12,
 "source_id": 1,
 "category_filter": ["国际", "科技"],
 "created_at": "2026-06-10T05:00:00Z"
}

DTO:

@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)

{
 "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

{
 "type": "about:blank",
 "title": "Validation Error",
 "status": 422,
 "errors": [
 {
 "loc": ["body", "username"],
 "msg": "field required",
 "type": "value_error.missing"
 }
 ],
 "instance": "..."
}

7.3 客户端处理策略

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 用):

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 不碰。