新增 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 备用通道上次已验证可用。
13 KiB
13 KiB
02 · API 契约
后端:
http://207.57.129.228:3000/api/v1所有接口走 Bearer JWT(除
auth/*外)。401 自动由 OkHttpTokenAuthenticator处理。本文档是对后端 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/sourcesPOST /admin/sourcesPATCH /admin/sources/{id}DELETE /admin/sources/{id}POST /admin/refresh/{source_id}POST /admin/translation/rerun/{article_id}POST /admin/translation/quota/resetGET /admin/healthGET /admin/llm/settingsPUT /admin/llm/settingsPOST /admin/llm/settings/resetPOST /admin/llm/settings/testPOST /admin/llm/enrich/{article_id}
这些都是 owner 自己后台用,你会在 web 后台管,app 不碰。