*
This commit is contained in:
48
core/network/build.gradle.kts
Normal file
48
core/network/build.gradle.kts
Normal file
@@ -0,0 +1,48 @@
|
||||
// core:network 网络框架:鉴权三要素拦截器、统一响应解析、Retrofit/OkHttp 装配
|
||||
// NetworkConfig 的实现由 app 壳提供(配置不进代码库,见 M7-04)
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.stec.cmd.core.network"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
// NetworkModule 依据 BuildConfig.DEBUG 决定是否装配 BODY 级日志拦截器
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
|
||||
// 网络能力对外暴露(业务模块直接声明 Retrofit Service)
|
||||
api(libs.retrofit)
|
||||
api(libs.okhttp)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
implementation(libs.retrofit.converter.kotlinx)
|
||||
implementation(libs.okhttp.logging)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 平台统一响应包裹(接口文档 1.1 返回状态值约定)。
|
||||
*
|
||||
* - code 为**字符串**:"200" 成功、"500" 失败、"429" 频繁请求超限、"403" 连续报错超限;
|
||||
* - msg:执行成功/失败提示信息;
|
||||
* - data:业务数据,失败时为 null。
|
||||
*/
|
||||
@Serializable
|
||||
data class ApiEnvelope<T>(
|
||||
val code: String,
|
||||
@SerialName("msg") val message: String? = null,
|
||||
val data: T? = null,
|
||||
) {
|
||||
/** 是否业务成功(code = "200")。 */
|
||||
val isSuccess: Boolean get() = code == CODE_SUCCESS
|
||||
|
||||
/** 取业务数据;失败或无数据时抛出对应 [ApiError]。 */
|
||||
fun bodyOrThrow(): T {
|
||||
if (!isSuccess) throw ApiError.fromCode(code, message)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return data as? T ?: throw ApiError.EmptyBodyError(message)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 接口请求成功。 */
|
||||
const val CODE_SUCCESS = "200"
|
||||
|
||||
/** 接口请求失败。 */
|
||||
const val CODE_SERVER_ERROR = "500"
|
||||
|
||||
/** 同一条数据一分钟内频繁请求,超过允许最大次数。 */
|
||||
const val CODE_TOO_MANY_REQUESTS = "429"
|
||||
|
||||
/** 同一条数据连续报错,超过允许出错最大次数。 */
|
||||
const val CODE_FORBIDDEN = "403"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
/**
|
||||
* 平台接口错误体系:UI 层只需捕获 [ApiError] 并按子类给出提示。
|
||||
*
|
||||
* 语义对齐接口文档 1.1:
|
||||
* - 500 → [ServerError] 业务失败;
|
||||
* - 429 → [TooManyRequests] 一分钟内同一条数据频繁请求超限;
|
||||
* - 403 → [Locked] 同一条数据连续报错超限(平台锁定,非 HTTP 403 权限语义);
|
||||
* - HTTP 层失败(超时/断网)→ [NetworkError]。
|
||||
*/
|
||||
sealed class ApiError(
|
||||
message: String?,
|
||||
cause: Throwable? = null,
|
||||
) : Exception(message, cause) {
|
||||
|
||||
/** 服务器业务失败(响应 code = 500)。 */
|
||||
class ServerError(
|
||||
val code: String,
|
||||
message: String?,
|
||||
) : ApiError(message)
|
||||
|
||||
/** 请求过频(响应 code = 429)。 */
|
||||
class TooManyRequests(
|
||||
message: String?,
|
||||
) : ApiError(message ?: DEFAULT_TOO_MANY_MESSAGE)
|
||||
|
||||
/** 连续报错被平台锁定(响应 code = 403)。 */
|
||||
class Locked(
|
||||
message: String?,
|
||||
) : ApiError(message ?: DEFAULT_LOCKED_MESSAGE)
|
||||
|
||||
/** 成功码但 data 缺失(服务端契约异常)。 */
|
||||
class EmptyBodyError(
|
||||
message: String?,
|
||||
) : ApiError(message ?: "响应成功但缺少数据")
|
||||
|
||||
/** 网络/超时等传输层失败。 */
|
||||
class NetworkError(
|
||||
cause: Throwable,
|
||||
) : ApiError(cause.message, cause)
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_TOO_MANY_MESSAGE = "操作过于频繁,请稍后再试"
|
||||
private const val DEFAULT_LOCKED_MESSAGE = "连续失败次数过多,请稍后再试"
|
||||
|
||||
/** 按响应体 code 映射业务错误。 */
|
||||
fun fromCode(code: String, message: String?): ApiError = when (code) {
|
||||
ApiEnvelope.CODE_TOO_MANY_REQUESTS -> TooManyRequests(message)
|
||||
ApiEnvelope.CODE_FORBIDDEN -> Locked(message)
|
||||
else -> ServerError(code, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 鉴权三要素拦截器(接口文档 1.2):为每个请求注入请求头。
|
||||
*
|
||||
* - `SecretKey`:供应商密钥,必带;
|
||||
* - `SystemCode`:供应商编码,必带;
|
||||
* - `Token`:登录后颁发;[TokenProvider.currentToken] 为 null 时跳过,
|
||||
* 登录/验证码等未登录接口天然不携带。
|
||||
*/
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val config: NetworkConfig,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request().newBuilder()
|
||||
.header(HEADER_SECRET_KEY, config.secretKey)
|
||||
.header(HEADER_SYSTEM_CODE, config.systemCode)
|
||||
.apply {
|
||||
config.tokenProvider.currentToken()?.let { token ->
|
||||
header(HEADER_TOKEN, token)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HEADER_SECRET_KEY = "SecretKey"
|
||||
const val HEADER_SYSTEM_CODE = "SystemCode"
|
||||
const val HEADER_TOKEN = "Token"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
/**
|
||||
* 网络配置抽象:baseUrl 与鉴权三要素的提供者。
|
||||
*
|
||||
* 接口文档 1.2 约定:
|
||||
* - SecretKey:供应商调用密钥,会定期更换,必须做成配置项(M7-04);
|
||||
* - SystemCode:供应商编码,与 SecretKey 一一对应;
|
||||
* - Token:登录后颁发,除登录接口外必须携带。
|
||||
*
|
||||
* 实现由 app 壳注入(Hilt @Binds),值来自加密配置存储,禁止硬编码进代码库。
|
||||
*/
|
||||
interface NetworkConfig {
|
||||
|
||||
/** 平台接口基地址(例:https://jcd.stec.p-q.co/)。 */
|
||||
val baseUrl: String
|
||||
|
||||
/** 供应商调用密钥。 */
|
||||
val secretKey: String
|
||||
|
||||
/** 供应商编码。 */
|
||||
val systemCode: String
|
||||
|
||||
/** Token 快照提供者。 */
|
||||
val tokenProvider: TokenProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 快照提供者。
|
||||
*
|
||||
* 拦截器在 OkHttp 后台线程同步读取,实现方应返回内存缓存值
|
||||
* (app 侧登录/登出/刷新时更新缓存 + 持久化),避免每次请求读盘。
|
||||
*/
|
||||
interface TokenProvider {
|
||||
|
||||
/** 当前 Token;未登录返回 null(此时请求不携带 Token 头)。 */
|
||||
fun currentToken(): String?
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* 网络层 Hilt 装配。
|
||||
*
|
||||
* [NetworkConfig] 的实现由 app 壳通过 @Binds 提供(配置不进代码库);
|
||||
* Retrofit Service 接口在 S1+ 各业务模块中声明后直接注入使用。
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJson(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
config: NetworkConfig,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.addInterceptor(AuthInterceptor(config))
|
||||
|
||||
// 外业调试期保留 BODY 级日志;发布构建由 proguard 移除该拦截器装配
|
||||
if (BuildConfig.DEBUG) {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
builder.addInterceptor(logging)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(
|
||||
client: OkHttpClient,
|
||||
json: Json,
|
||||
config: NetworkConfig,
|
||||
): Retrofit = Retrofit.Builder()
|
||||
.baseUrl(config.baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
private const val CONNECT_TIMEOUT_SECONDS = 15L
|
||||
private const val READ_TIMEOUT_SECONDS = 30L
|
||||
private const val WRITE_TIMEOUT_SECONDS = 30L
|
||||
}
|
||||
Reference in New Issue
Block a user