01 网络层与鉴权
一句话职责:为全部业务请求提供统一的 OkHttp 拦截器链(前缀补全 / Bearer 鉴权 / 401 单飞刷新 / 友好错误文案 / 无按钮权限拦截),封装 Retrofit 实例与带鉴权字节下载,并把登录态以「内存 AuthState + DataStore」双份持有供拦截器同步读取。
- 入口:全局,无独立页面(横切模块)
- 权限:依赖
android.permission.INTERNET、ACCESS_NETWORK_STATE;明文 http 需network_security_config - 对接后端接口数:3 个鉴权类端点(
AuthApi)+ 全部业务端点经统一前缀下发
源码范围
| 源文件 | 一句话职责 |
|---|---|
data/remote/ApiClient.kt |
OkHttp 拦截器链、Retrofit 实例、401 单飞刷新、带鉴权 downloadBytes、内存 AuthState |
data/remote/Paging.kt |
分页常量 PAGE_SIZE = 20 |
data/remote/Constants.kt |
BASE_URL、API_PREFIX 部署形态常量 |
util/NetError.kt |
FriendlyNetErrorInterceptor 与 Throwable.toUserFriendly 友好文案 |
util/PermissionDeniedNotifier.kt |
全局「无按钮权限」事件流(MutableSharedFlow) |
data/remote/model/Result.kt |
统一响应 Result{code,message,data},code=0/200 成功 |
data/remote/model/PageResult.kt |
分页响应 PageResult{list,total,page,pageSize} |
di/AppModule.kt |
以 BASE_URL + SessionStore 装配 ApiClient 单例 |
1. 模块概述
本模块是 App 所有 HTTP 流量的唯一出口,业务 Repository 不直接构造 OkHttpClient,统一通过 ApiClient.create<T>() 拿 Retrofit 接口、或 ApiClient.downloadBytes() 取原始字节。职责边界:
- 请求侧:补全部署前缀、附加
Authorization: Bearer、写User-Agent。 - 响应侧:401 自动刷新并重放、网络异常转友好文案、无按钮权限触发全局弹窗。
- 登录态:
AuthState(内存,拦截器同步读)+SessionStore(DataStore,持久化)双份,刷新后两者一起更新。 - 不负责:业务状态码(
code!=0)的 UI 处理、分页数据的组装------这些由各 Repository/ViewModel 完成。
与其它模块依赖:AuthRepository 在登录/登出/刷新时读写 AuthState 与 SessionStore;FileRepository 用 downloadBytes 取预览/头像字节;MessageWsClient 复用 ApiClient.refreshAccessToken() 的 401 兜底。
2. 页面与状态
无独立 Compose 页面,对外暴露的是「横切状态面」:
| 状态面 | 载体 | 说明 |
|---|---|---|
| 内存登录态 | object AuthState(accessToken/refreshToken/mutex,@Volatile) |
拦截器同步读取,避免每次请求走异步 Flow |
| 友好错误文案 | Throwable.toUserFriendly(fallback) |
任意 IOException 经 FriendlyNetErrorInterceptor 替换为客户友好文案 |
| 无按钮权限事件 | PermissionDeniedNotifier.events(SharedFlow,extraBufferCapacity=1) |
MainScaffold 常驻收集并弹专用对话框 |
| 下载结果 | suspend downloadBytes(): ByteArray |
失败抛 IOException,由 FileRepository 转 Result |
四类边界态(空/加载/错误/无权限)在本模块只负责「错误文案」与「无权限事件」两种信号的产生,具体 UI 由业务屏呈现。
3. 数据与接口
3.1 调用链
业务 Screen
→ *ViewModel(StateFlow UiState)
→ *Repository
→ ApiClient.create<T>() → Retrofit 接口(OkHttp 拦截器链)
→ ApiClient.downloadBytes(relativePath)(仅借阅预览 / 头像)
3.2 鉴权类端点(AuthApi.kt,真实注解)
| 方法 | 路径 | 用途 | 关键参数 |
|---|---|---|---|
POST |
/auth/login |
登录获取令牌 | @Body LoginRequest{username,password} → Result<LoginResponse> |
POST |
/auth/refresh |
刷新访问令牌(401 单飞调用) | @Body RefreshRequest{refreshToken} → Result<LoginResponse.RefreshResult> |
POST |
/auth/logout |
退出登录 | 无参 → Result<Unit> |
接口注解一律带前导
/(相对主机根)。/auth/refresh经refreshRetrofit(独立 client,无 auth 拦截器)调用,避免递归。
3.3 带鉴权字节下载(非 @Http 注解,走 downloadBytes)
| 消费点 | 相对路径形态 | 说明 |
|---|---|---|
| 电子档案预览 | /file/preview-pdf/{id}(PDF 原样返回,Office 由后端转 PDF) |
借用主 client,自动带 Bearer + 401 刷新 |
| 头像 | /file/{id}(头像 URL 形如 /file/{id}) |
同上 |
3.4 响应契约
Result<T>:code成功判定为code == 0 || code == 200;message失败文案;data载荷可空。PageResult<T>:list/total/page/pageSize,服务端分页。Paging.kt:PAGE_SIZE = 20,为列表接口默认每页条数与端上增量分页(visibleCount)统一口径;/workflow/task/todo固定返回 50(后端独立约定),不在此列。
4. 关键设计决策与实现要点
4.1 拦截器链顺序与职责
主 client(okHttpClient)按 addInterceptor 顺序(应用拦截器,单次、保序):
| 顺序 | 拦截器 | 职责 | 幂等性 |
|---|---|---|---|
| 1 | FriendlyNetErrorInterceptor |
包裹后续全部调用,把 IOException 的 message 替换为 toUserFriendly 文案 |
仅改异常 message,不改请求 |
| 2 | ApiPrefixInterceptor |
给 path 补 API_PREFIX(/auth/login→/api/auth/login) |
首段已等于前缀则放行,不重复拼接;前缀为空整段不启用 |
| 3 | userAgentInterceptor |
写 User-Agent: LanTaiArchive/<VERSION_NAME> (Android ...) |
覆盖写,幂等 |
| 4 | HttpLoggingInterceptor(BASIC) |
基础日志 | 只读 |
| 5 | authInterceptor |
附加 Bearer;遇 401 触发单飞刷新并重放 |
见 4.2 |
| 6 | PermissionDeniedInterceptor |
解析 body code!=0/200 且 message 形如「无...权限」→ 通知弹窗 |
用 peekBody 不消费原 body |
刷新专用 client(refreshRetrofit)链:FriendlyNetError → ApiPrefix → userAgent → Logging,无 auth、无权限拦截 ,避免刷新请求自身再触发刷新形成递归。FriendlyNetError 放最前,使其能包裹权限解析与日志拦截器抛出的网络异常。
4.2 401 单飞刷新与重放
authInterceptor检测到response.code == 401 && AuthState.accessToken != null时,进入runBlocking { tryRefresh() }。tryRefresh()用AuthState.mutex.withLock串行化:同一时刻只发一次/auth/refresh,并发 401 请求在锁上排队,避免多请求同时刷新互相踩踏。- 刷新成功后:更新
AuthState.accessToken/refreshToken,并sessionStore.updateTokens(...)持久化;关闭原 401 响应(response.close()),用新令牌重建请求chain.proceed(newRequest)重放一次。 - 刷新失败(refreshToken 失效 / 网络异常):返回原 401 响应,交由业务层引导重新登录。
- 公开
suspend refreshAccessToken()供MessageWsClient在 WebSocket 握手 401 时复用同一逻辑。
4.3 超时集中与「不设 callTimeout」
文件级 withAppTimeouts() 统一:connectTimeout=15s、writeTimeout=60s、readTimeout=60s。主 client 与刷新 client 共用此函数。
- 连接 15s:局域网/移动网正常 1s 内建连,15s 覆盖弱网首包又能尽早暴露不可达。
- 读 60s:OkHttp 默认读超时仅 10s 。电子档案预览
GET /file/preview-pdf/{id}中 Office 文件由后端现场调 LibreOffice 转 PDF,大文件常超 10s,旧配置会被掐断表现为「预览失败且原因不可见」。60s 覆盖转换耗时。 - 不设置
callTimeout:整体墙钟超时会把「慢但能成」的转换误杀,由读超时兜底即可。 - 新增任何
OkHttpClient必须挂withAppTimeouts(),否则回落系统默认 read 10s,上述预览场景会被掐断。
4.4 downloadBytes 抛异常而非返回 null
downloadBytes 失败一律 throw IOException,不吞成 null:
- 非 2xx(含 401/404/500)→
IOException("下载失败(HTTP ${code})"),保留状态码信息。 - 响应体为空 →
IOException("下载失败(响应内容为空)")。 - 网络失败经
FriendlyNetErrorInterceptor已转友好文案(注意:非 2xx 的IOException在proceed返回之后、downloadBytes 内部抛,不经过拦截器链,故保持带码文案;纯网络异常在proceed内被拦截器捕获转友好)。
两个消费点:FileRepository 将异常统一转 Result 并映射用户文案------调用方据此区分「超时 / 断网 / 文件不存在」。
4.5 前缀必须由拦截器补的根因
Constants.kt 的 BASE_URL 只写到 host+端口且必须以 / 结尾(如 http://192.168.200.178/)。Retrofit 对以 / 开头的相对 URL 按主机根 解析并丢弃 base URL 的 path 部分 ,因此把 /api 写进 BASE_URL 无效。前缀只能交由 ApiPrefixInterceptor 在 OkHttp 层改写 path,一处生效、覆盖 Retrofit 请求与 downloadBytes。
4.6 User-Agent 与 BuildConfig
APP_USER_AGENT = "LanTaiArchive/" + BuildConfig.VERSION_NAME + " (Android <RELEASE>; <MODEL>)",让后端登录日志(sys_login_log.user_agent)识别为 App 客户端。BuildConfig.VERSION_NAME 来自 AGP 生成的 BuildConfig------AGP 8 默认不生成,故 build.gradle.kts 的 buildFeatures { buildConfig = true } 必须开启,否则编译失败。
4.7 无按钮权限拦截链路
后端权限不足走 HTTP 200 + body code!=0/200 且 message 形如「无...权限」。PermissionDeniedInterceptor 仅处理 application/json,用 peekBody 读取不消费原 body;命中后用 PermissionDeniedNotifier.notify(msg) 发事件。MainScaffold 常驻 collect 该 SharedFlow(extraBufferCapacity=1,collect 前瞬时发出也不丢)弹专用对话框「去 Web 角色管理分配按钮权限」。与业务屏完全解耦,无需改动各 Repository/ViewModel。
5. 部署
5.1 打包与安装
bash
# 调试包(本机已连设备或模拟器)
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
# 发布包
./gradlew :app:assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apk
5.2 必须改的配置项
| 配置项 | 位置 | 取值与说明 |
|---|---|---|
BASE_URL |
Constants.kt |
只写到 host+端口且以 / 结尾;模拟器 http://10.0.2.2:8080/、真机 http://<局域网IP>:8080/、nginx http://<nginx地址>/ |
API_PREFIX |
Constants.kt |
经 nginx 且外部前缀 /api → "/api";后端自身 context-path → 填同值;直连无前缀 → ""(拦截器整体不启用) |
| 明文 http 白名单 | res/xml/network_security_config.xml |
targetSdk=34 默认禁明文,需把后端域名加入 domain-config cleartextTrafficPermitted="true";上 HTTPS 可移除 |
5.3 权限
INTERNET、ACCESS_NETWORK_STATE(联网);POST_NOTIFICATIONS/FOREGROUND_SERVICE/FOREGROUND_SERVICE_DATA_SYNC(消息推送,见 16 篇);CAMERA(扫码,见 12 篇);USE_BIOMETRIC(指纹登录,见 05 篇)。本模块本身只需 INTERNET。
5.4 依赖的后端能力与数据
- 后端无 context-path、接口挂根路径;nginx 侧
proxy_pass http://127.0.0.1:8080/;(末尾/)负责剥掉/api。 /auth/login、/auth/refresh返回Result<LoginResponse>,refreshToken用于刷新。- 用 curl 直连验证后端(绕过 App 前缀):
bash
# 直连后端(API_PREFIX 置空时 App 同此形态)
curl -s -X POST http://192.168.200.178/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<pwd>"}'
# 经 nginx(带 /api 前缀)
curl -s -X POST http://<nginx>/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<pwd>"}'
6. 验证清单
6.1 验证 401 刷新
- 前置数据:一个会过期的 accessToken(或临时把后端 token 有效期调短)。
- 操作:登录后等待令牌过期,触发任意需鉴权请求。
- 预期:
authInterceptor捕获 401 →tryRefresh()调/auth/refresh→ 用新令牌重放成功。 - 观察点:logcat
okhttp.HttpLoggingInterceptorBASIC 可见一次原始 401 + 一次/auth/refresh+ 一次重放请求;AuthState.accessToken已更新。 - 失败排查:
| 现象 | 可能原因 |
|---|---|
| 一直 401 不刷新 | AuthState.accessToken 为空(未登录 / 启动未载入) |
| 刷新后立刻又 401 | refreshToken 失效,需重新登录 |
| 栈溢出/递归 | 误把 auth 拦截器加进 refreshRetrofit |
6.2 验证超时
- 操作:访问一个故意慢的 Office 转 PDF 接口(>10s)。
- 预期:read 60s 内返回;若把
READ_TIMEOUT_SEC改回 10s 则被掐断。 - 观察点:logcat 关键字
timed out/ 友好文案「服务器响应超时,请稍后重试」;后端日志该接口仍在转。 - 失败排查:超时发生在非主 client(未挂
withAppTimeouts)→ 漏配超时。
6.3 验证错误文案
- 操作:断网或填错
BASE_URL主机。 - 预期:UI 显示「服务器开小差了,请稍后重试」,而非暴露 IP/端口的原始
IOExceptionmessage。 - 观察点:logcat 原始异常仍含技术细节(
toUserFriendly保留cause),但上游看到的 message 已是友好文案。 - 失败排查:仍透传 IP →
FriendlyNetErrorInterceptor未置于链首或被移除。
6.4 验证无权限弹窗
- 前置数据:后端存在一个「无 X 权限」的
code!=0响应(message以「无」开头、「权限」结尾)。 - 操作:以无该按钮权限的账号触发对应请求。
- 预期:
PermissionDeniedNotifier发事件 →MainScaffold弹「去 Web 角色管理分配按钮权限」。 - 观察点:logcat 该响应 HTTP 200 + body
code非 0;事件流被消费一次(extraBufferCapacity=1保证不丢)。 - 失败排查:
| 现象 | 可能原因 |
|---|---|
| 不弹窗 | body 非 JSON / message 不匹配「无...权限」句式 |
| 弹窗但业务数据丢失 | 拦截器误消费了原 body(应使用 peekBody) |
7. 已知边界与未实现
ApiPrefixInterceptor仅改写 path,不处理已带 query 的 base 之外的复杂情形;相对 URL 必须带前导/。- 401 刷新为「串行互斥」而非「结果复用」:并发 401 在锁上排队,各自仍发一次
/auth/refresh(后端允许多次刷新时可用;若后端单次刷新即吊销旧 refreshToken,需上层收敛)。 downloadBytes不做断点续传、不做进度回调,仅一次性返回全量字节。- 无按钮权限仅按
message句式启发式识别,后端若改文案规则需同步此处。 callTimeout刻意不设置,依赖读超时兜底;若需整体墙钟上限由调用方另行控制。