This commit is contained in:
阿猫
2026-09-03 12:38:16 +08:00
parent 09669fe4a2
commit e96e92d7ab
68 changed files with 2810 additions and 0 deletions

View 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)
}

View File

@@ -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"
}
}

View File

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

View File

@@ -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"
}
}

View File

@@ -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?
}

View File

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