01-网络层与鉴权

01 网络层与鉴权

一句话职责:为全部业务请求提供统一的 OkHttp 拦截器链(前缀补全 / Bearer 鉴权 / 401 单飞刷新 / 友好错误文案 / 无按钮权限拦截),封装 Retrofit 实例与带鉴权字节下载,并把登录态以「内存 AuthState + DataStore」双份持有供拦截器同步读取。

  • 入口:全局,无独立页面(横切模块)
  • 权限:依赖 android.permission.INTERNETACCESS_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_URLAPI_PREFIX 部署形态常量
util/NetError.kt FriendlyNetErrorInterceptorThrowable.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 在登录/登出/刷新时读写 AuthStateSessionStoreFileRepositorydownloadBytes 取预览/头像字节;MessageWsClient 复用 ApiClient.refreshAccessToken() 的 401 兜底。


2. 页面与状态

无独立 Compose 页面,对外暴露的是「横切状态面」:

状态面 载体 说明
内存登录态 object AuthStateaccessToken/refreshToken/mutex@Volatile 拦截器同步读取,避免每次请求走异步 Flow
友好错误文案 Throwable.toUserFriendly(fallback) 任意 IOExceptionFriendlyNetErrorInterceptor 替换为客户友好文案
无按钮权限事件 PermissionDeniedNotifier.eventsSharedFlowextraBufferCapacity=1 MainScaffold 常驻收集并弹专用对话框
下载结果 suspend downloadBytes(): ByteArray 失败抛 IOException,由 FileRepositoryResult

四类边界态(空/加载/错误/无权限)在本模块只负责「错误文案」与「无权限事件」两种信号的产生,具体 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/refreshrefreshRetrofit(独立 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 == 200message 失败文案;data 载荷可空。
  • PageResult<T>list / total / page / pageSize,服务端分页。
  • Paging.ktPAGE_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/200message 形如「无...权限」→ 通知弹窗 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=15swriteTimeout=60sreadTimeout=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 的 IOExceptionproceed 返回之后、downloadBytes 内部抛,不经过拦截器链,故保持带码文案;纯网络异常在 proceed 内被拦截器捕获转友好)。

两个消费点:FileRepository 将异常统一转 Result 并映射用户文案------调用方据此区分「超时 / 断网 / 文件不存在」。

4.5 前缀必须由拦截器补的根因

Constants.ktBASE_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.ktsbuildFeatures { buildConfig = true } 必须开启,否则编译失败。

4.7 无按钮权限拦截链路

后端权限不足走 HTTP 200 + body code!=0/200message 形如「无...权限」。PermissionDeniedInterceptor 仅处理 application/json,用 peekBody 读取不消费原 body;命中后用 PermissionDeniedNotifier.notify(msg) 发事件。MainScaffold 常驻 collectSharedFlowextraBufferCapacity=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 权限

INTERNETACCESS_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.HttpLoggingInterceptor BASIC 可见一次原始 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/端口的原始 IOException message。
  • 观察点: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 刻意不设置,依赖读超时兜底;若需整体墙钟上限由调用方另行控制。
相关推荐
不要喷香水6 小时前
00-总览与架构
kotlin·app·档案管理
mmsx7 小时前
Android 地图卡成 PPT 之后:双渲染管线与空间网格渐进加载怎么救
android·app
河北清兮网络科技20 小时前
开发软件怎么找靠谱的公司?普通人最全筛选避坑指南
小程序·app·短剧·短剧app·广告联盟
SXkehuirongsheng1 天前
APP定制开发怎么判断服务商的技术实力
百度·微信·app·网站建设·文心一言·微信公众平台
mmsx1 天前
MapLibre 实战 13|让比例尺显示 100m 而不是 347.2m:屏幕距离换算与两个易错点
android·前端·app
HouWan2 天前
Flutter iOS UISceneDelegate 迁移指南:理清新的 Scene 生命周期
flutter·ios·app
SXkehuirongsheng2 天前
APP 定制开发哪家交付质量好?
app
熊猫钓鱼>_>3 天前
ArkTS 性能优化实战:从冷启动到长列表,一套可复现的实测方法论
app·harmonyos·arkts·鸿蒙·组件·性能·arkui
方白羽9 天前
OkHttp 5.3 隐形变更引发的线上偶发崩溃复盘
android·app·客户端