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

33
core/ble/build.gradle.kts Normal file
View File

@@ -0,0 +1,33 @@
// core:ble 蓝牙层:设备扫描(名称前缀过滤)+ GATT 连接封装骨架
// 权限申请由 app 壳负责Android 12L 分版见 app 模块),本模块假定权限已授予
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.stec.cmd.core.ble"
compileSdk = 34
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":core:common"))
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,191 @@
package com.stec.cmd.core.ble
import android.annotation.SuppressLint
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresPermission
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
/** 连接状态机(骨架期最小集,自动重连策略 S3 扩展)。 */
sealed interface GattState {
data object Connecting : GattState
data object Connected : GattState
data object ServicesDiscovered : GattState
data class Disconnected(val status: Int) : GattState
}
/**
* 单设备 GATT 连接会话骨架。
*
* 用法S2/S3 业务侧):
* ```
* val session = connector.connect(scanned)
* session.state.filter { it is GattState.ServicesDiscovered }.first()
* session.notifyBytes(BleUuids.profileOf(scanned.type)).collect { bytes ->
* val readings = parser.feed(bytes)
* }
* ```
*/
@SuppressLint("MissingPermission")
class BleSession internal constructor(
context: Context,
address: String,
) {
private val bluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val gatt: BluetoothGatt? =
bluetoothManager.adapter?.getRemoteDevice(address)?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
it.connectGatt(context, false, callback, BluetoothGatt.TRANSPORT_LE)
} else {
it.connectGatt(context, false, callback)
}
}
private val _state = MutableStateFlow<GattState>(GattState.Connecting)
/** 连接状态流Disconnected 后会话即终结。 */
val state: StateFlow<GattState> = _state
/** 等待服务发现完成(连接后调用一次)。 */
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
suspend fun awaitServicesDiscovered(timeoutMillis: Long = DEFAULT_TIMEOUT) {
withTimeout(timeoutMillis) {
state.filter { it != GattState.Connecting && it != GattState.Connected }.first()
}
}
/**
* 订阅 Notify 特征(写 CCCD 开启通知),返回原始字节流。
* 每帧 `0D 0A` 定界由 ble-protocol 解析器负责,本流按 Notify 回调原样发射。
*/
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun notifyBytes(profile: BleUuids.Profile): Flow<ByteArray> {
val gatt = checkNotNull(gatt) { "GATT 未初始化" }
val characteristic = checkNotNull(
gatt.getService(profile.service)?.getCharacteristic(profile.notifyCharacteristic),
) { "Notify 特征不存在: ${profile.notifyCharacteristic}" }
return callbackFlow {
val listener = object : BluetoothGattCallback() {
@Deprecated("API 33 起走带 characteristic 参数的新回调;骨架期两版共存")
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
if (characteristic.uuid == profile.notifyCharacteristic) {
trySend(characteristic.value ?: ByteArray(0))
}
}
}
check(gatt.setCharacteristicNotification(characteristic, true)) {
"开启 Notify 失败: ${profile.notifyCharacteristic}"
}
gatt.writeDescriptor(
characteristic.getDescriptor(DESCRIPTOR_CCCD)?.apply {
value = ENABLE_NOTIFICATION_VALUE
},
)
// 单回调实例仅做字节监听(连接生命周期由主 callback 管理)
awaitClose {
runCatching {
gatt.setCharacteristicNotification(characteristic, false)
}
}
}
}
/** 写特征VM208 激励命令下发)。返回是否写入成功。 */
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
suspend fun write(profile: BleUuids.Profile, data: ByteArray, timeoutMillis: Long = DEFAULT_TIMEOUT): Boolean {
val gatt = checkNotNull(gatt) { "GATT 未初始化" }
val characteristic = gatt.getService(profile.service)
?.getCharacteristic(profile.writeCharacteristic)
?: return false
val done = CompletableDeferred<Boolean>()
writeListener.completeWith = done
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = data
if (!gatt.writeCharacteristic(characteristic)) {
done.complete(false)
}
return withTimeout(timeoutMillis) { done.await() }
}
/** 主动断开并释放 GATT 资源。 */
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun disconnect() {
gatt?.disconnect()
}
private val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
_state.value = GattState.Connected
gatt.discoverServices()
}
BluetoothProfile.STATE_DISCONNECTED -> {
_state.value = GattState.Disconnected(status)
gatt.close()
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
_state.value = GattState.ServicesDiscovered
} else {
_state.value = GattState.Disconnected(status)
}
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
writeListener.completeWith.complete(status == BluetoothGatt.GATT_SUCCESS)
}
}
/** write 挂起桥:把主 callback 的 onCharacteristicWrite 结果转给调用协程。 */
private val writeListener = object {
lateinit var completeWith: CompletableDeferred<Boolean>
}
private companion object {
const val DEFAULT_TIMEOUT = 10_000L
/** Client Characteristic Config 描述符Notify 开关)。 */
val DESCRIPTOR_CCCD: java.util.UUID = java.util.UUID.fromString(
"00002902-0000-1000-8000-00805f9b34fb",
)
val ENABLE_NOTIFICATION_VALUE = byteArrayOf(0x01, 0x00)
}
}
/**
* GATT 连接入口:按扫描结果建立会话。
* S0 骨架不引 Hilt由 app/S3 按需构造或包装。
*/
class BleConnector(private val context: Context) {
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun connect(scanned: ScannedDevice): BleSession = BleSession(context, scanned.address)
}

View File

@@ -0,0 +1,86 @@
package com.stec.cmd.core.ble
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresPermission
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/** 一次扫描命中:已按名称前缀过滤为目标设备。 */
data class ScannedDevice(
val name: String,
val address: String,
val rssi: Int,
val type: DeviceType,
)
/** 扫描失败(硬件不可用/扫描过频等code 为 ScanCallback.SCAN_FAILED_* 常量。 */
class BleScanFailed(val code: Int) : Exception("BLE 扫描失败: code=$code")
/**
* BLE 扫描器:按设备名前缀过滤 IE-1000 / VM208。
*
* 前缀过滤在内存中做(广播名偶有截断,[ScanFilter.setDeviceNamePrefix]
* 在部分机型不可靠),全量回调后按 [DeviceType.fromDeviceName] 筛选。
* 权限由 app 壳申请12L+ 为 BLUETOOTH_SCAN/CONNECT旧版为 SCAN + 定位)。
*/
class BleScanner(context: Context) {
private val bluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
/**
* 开始扫描,命中流自动去重(同地址只保留最近一次)。
* 收集端取消或调用 [close] 时停止扫描。
*/
@SuppressLint("MissingPermission")
@RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
fun scan(
types: Set<DeviceType> = DeviceType.entries.toSet(),
): Flow<ScannedDevice> {
val scanner = bluetoothManager.adapter?.bluetoothLeScanner
?: throw BleScanFailed(UNAVAILABLE_ADAPTER)
return callbackFlow {
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
val callback = object : ScanCallback() {
private val seen = HashMap<String, ScannedDevice>()
override fun onScanResult(callbackType: Int, result: ScanResult) {
val name = result.scanRecord?.deviceName ?: result.device?.name
val type = DeviceType.fromDeviceName(name) ?: return
if (type !in types) return
val device = ScannedDevice(
name = name,
address = result.device.address,
rssi = result.rssi,
type = type,
)
// 同地址覆盖发射UI 层按 address 收敛列表
seen[device.address] = device
trySend(device)
}
override fun onScanFailed(errorCode: Int) {
close(BleScanFailed(errorCode))
}
}
scanner.startScan(null, settings, callback)
awaitClose {
runCatching { scanner.stopScan(callback) }
}
}
}
private companion object {
const val UNAVAILABLE_ADAPTER = -100
}
}

View File

@@ -0,0 +1,42 @@
package com.stec.cmd.core.ble
import java.util.UUID
/**
* 两类设备的 GATT UUID 汇总(协议原文 docs/BLE-IE1000.txt
*
* ⚠ VM208 服务 UUID 原文档仅 31 位十六进制(`0000fee2000010a0800000602f9b3698`
* 不足标准 128 位32 位)。此处按末位补 0 占位:
* **S2 真机联调时须用 nRF Connect 实测校准**,确认后再更新本文件。
*/
object BleUuids {
/** 单设备的 GATT 档案:服务 + 写特征 + Notify 特征。 */
data class Profile(
val service: UUID,
val writeCharacteristic: UUID,
val notifyCharacteristic: UUID,
)
/** IE-1000 振弦式读数仪。 */
val IE1000: Profile = Profile(
service = uuid("0000fee6-0000-2000-6000-006012345678"),
writeCharacteristic = uuid("FE000000-0000-0000-0000-000000000580"),
notifyCharacteristic = uuid("FE000000-0000-0000-0000-000000000581"),
)
/** VM208 综合采集仪(服务 UUID 见类注释⚠)。 */
val VM208: Profile = Profile(
service = uuid("0000fee2-0000-10a0-8000-0602f9b36980"),
writeCharacteristic = uuid("a44bc439-abfd-45a2-4254-2d4d31129700"),
notifyCharacteristic = uuid("a44bc439-abfd-45a2-4254-2d4d31129701"),
)
/** 按设备类型取档案。 */
fun profileOf(type: DeviceType): Profile = when (type) {
DeviceType.IE1000 -> IE1000
DeviceType.VM208 -> VM208
}
private fun uuid(value: String): UUID = UUID.fromString(value)
}

View File

@@ -0,0 +1,22 @@
package com.stec.cmd.core.ble
/**
* 平台支持的采集设备类型(协议原文见 docs/BLE-IE1000.txt
*
* 蓝牙名以**前缀**匹配:
* - IE-1000 振弦式读数仪,广播名 `IE-1000`
* - VM208 综合采集仪,广播名 `VM208 xxxxxx`(带序列号后缀)。
*/
enum class DeviceType(val namePrefix: String) {
IE1000("IE-1000"),
VM208("VM208");
companion object {
/** 按广播名前缀识别设备类型;无法识别返回 null外场杂散设备直接忽略。 */
fun fromDeviceName(name: String?): DeviceType? {
if (name.isNullOrEmpty()) return null
return entries.firstOrNull { name.startsWith(it.namePrefix) }
}
}
}

View File

@@ -0,0 +1,30 @@
// core:common 通用基础层AppResult / UiState / 日志门面
// 代码保持纯 Kotlin不引 Android API便于 JVM 单测与被所有 core 模块复用
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.stec.cmd.core.common"
compileSdk = 34
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
api(libs.kotlinx.coroutines.core)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,36 @@
package com.stec.cmd.core.common
/**
* 轻量日志门面core 层不依赖 android.util.Log
* app 壳启动时注入 LogCat 实现AppLog.sink = ...),测试环境可不注入或注入内存实现。
*/
object AppLog {
enum class Level { DEBUG, INFO, WARN, ERROR }
fun interface Sink {
fun log(level: Level, tag: String, message: String, throwable: Throwable?)
}
@Volatile
private var sink: Sink? = null
/** app 启动时调用一次;传 null 恢复为静默(适用于单元测试)。 */
fun install(sink: Sink?) {
this.sink = sink
}
fun d(tag: String, message: String) = dispatch(Level.DEBUG, tag, message, null)
fun i(tag: String, message: String) = dispatch(Level.INFO, tag, message, null)
fun w(tag: String, message: String, throwable: Throwable? = null) =
dispatch(Level.WARN, tag, message, throwable)
fun e(tag: String, message: String, throwable: Throwable? = null) =
dispatch(Level.ERROR, tag, message, throwable)
private fun dispatch(level: Level, tag: String, message: String, throwable: Throwable?) {
sink?.log(level, tag, message, throwable)
}
}

View File

@@ -0,0 +1,48 @@
package com.stec.cmd.core.common
/**
* 全工程统一的结果封装UI 层与数据层之间只通过 [AppResult] 传递成败,
* 具体业务异常(网络/数据库/协议)由各 core 模块定义并在此统一消费。
*/
sealed interface AppResult<out T> {
/** 成功,携带业务数据。 */
data class Success<T>(val data: T) : AppResult<T>
/** 失败,携带原始异常与可选的人类可读说明。 */
data class Error(
val error: Throwable,
val message: String? = error.message,
) : AppResult<Nothing>
/** 加载中(首屏/刷新语义由 UI 层区分)。 */
data object Loading : AppResult<Nothing>
companion object {
/** 同步块结果捕获:块内抛出的任意异常转为 [Error]。 */
inline fun <T> of(block: () -> T): AppResult<T> = try {
Success(block())
} catch (t: Throwable) {
Error(t)
}
}
}
/** 成功值映射,失败/加载态原样透传。 */
inline fun <T, R> AppResult<T>.map(transform: (T) -> R): AppResult<R> = when (this) {
is AppResult.Success -> AppResult.Success(transform(data))
is AppResult.Error -> this
is AppResult.Loading -> this
}
/** 成功时的副作用钩子,返回值不变。 */
inline fun <T> AppResult<T>.onSuccess(block: (T) -> Unit): AppResult<T> {
if (this is AppResult.Success) block(data)
return this
}
/** 失败时的副作用钩子,返回值不变。 */
inline fun <T> AppResult<T>.onError(block: (AppResult.Error) -> Unit): AppResult<T> {
if (this is AppResult.Error) block(this)
return this
}

View File

@@ -0,0 +1,28 @@
package com.stec.cmd.core.common
/**
* 页面级视图状态MVVM 中 ViewModel 对 UI 暴露的最小状态机。
* 与 [AppResult] 解耦——AppResult 描述一次调用UiState 描述一个页面。
*/
sealed interface UiState<out T> {
/** 初始空闲态(尚未发起加载)。 */
data object Idle : UiState<Nothing>
/** 加载中。 */
data object Loading : UiState<Nothing>
/** 内容就绪。 */
data class Success<T>(val data: T) : UiState<T>
/**
* 失败态。
*
* @property message 用户可读的错误说明
* @property retryable 是否允许重试429/403 等限流场景由业务层定)
*/
data class Error(
val message: String,
val retryable: Boolean = true,
) : UiState<Nothing>
}

View File

@@ -0,0 +1,38 @@
// core:database 本地存储Room AppDatabase + 上传队列(离线优先参考实现)
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "com.stec.cmd.core.database"
compileSdk = 34
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":core:common"))
api(libs.androidx.room.runtime)
api(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,27 @@
package com.stec.cmd.core.database
import androidx.room.Database
import androidx.room.RoomDatabase
/**
* 应用数据库骨架。
*
* S0 仅注册上传队列一张表作为离线优先参考实现;
* S1+ 新业务实体(任务缓存、测点、统计数据等)在 entities 中追加并升 version。
* 骨架期 exportSchema=false无迁移需求开启迁移管理时再导出 schema。
*/
@Database(
entities = [
UploadQueueEntity::class,
],
version = 1,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun uploadQueueDao(): UploadQueueDao
companion object {
const val DATABASE_NAME = "stec-cmd.db"
}
}

View File

@@ -0,0 +1,32 @@
package com.stec.cmd.core.database
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/** 数据库 Hilt 装配:全工程共享一个 AppDatabase。 */
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(
@ApplicationContext context: Context,
): AppDatabase = Room.databaseBuilder(
context,
AppDatabase::class.java,
AppDatabase.DATABASE_NAME,
).build()
@Provides
@Singleton
fun provideUploadQueueDao(
database: AppDatabase,
): UploadQueueDao = database.uploadQueueDao()
}

View File

@@ -0,0 +1,54 @@
package com.stec.cmd.core.database
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
/**
* 上传队列 DAO演示离线优先的入队/取待传/回写状态闭环。
* S1+ 各数据类型 DAO 照此挂载到 [AppDatabase]。
*/
@Dao
interface UploadQueueDao {
/** 入队一条待上传数据。 */
@Insert
suspend fun enqueue(entity: UploadQueueEntity): Long
/** 按入队顺序观察待上传(含失败待重试)队列。 */
@Query(
"SELECT * FROM upload_queue " +
"WHERE status IN (:statuses) ORDER BY created_at ASC",
)
fun observeByStatuses(vararg statuses: Int): Flow<List<UploadQueueEntity>>
/** 一次性取待上传列表(上传调度器使用)。 */
@Query(
"SELECT * FROM upload_queue " +
"WHERE status IN (:statuses) ORDER BY created_at ASC LIMIT :limit",
)
suspend fun takePending(statuses: List<Int>, limit: Int = DEFAULT_BATCH): List<UploadQueueEntity>
/** 标记上传中。 */
@Query("UPDATE upload_queue SET status = :status WHERE id = :id")
suspend fun updateStatus(id: Long, status: Int)
/** 标记成功并记录时间。 */
@Query("UPDATE upload_queue SET status = :status, uploaded_at = :uploadedAt WHERE id = :id")
suspend fun markSuccess(id: Long, status: Int, uploadedAt: Long)
/** 失败重试:状态回待传并累计重试次数。 */
@Query(
"UPDATE upload_queue SET status = :status, retry_count = retry_count + 1 WHERE id = :id",
)
suspend fun markRetry(id: Long, status: Int)
/** 清理已成功且早于截止时间的记录(队列瘦身)。 */
@Query("DELETE FROM upload_queue WHERE status = :status AND uploaded_at IS NOT NULL AND uploaded_at < :before")
suspend fun purgeSuccessBefore(status: Int, before: Long): Int
companion object {
private const val DEFAULT_BATCH = 50
}
}

View File

@@ -0,0 +1,67 @@
package com.stec.cmd.core.database
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* 采集数据上传队列(离线优先参考实现,对应功能明细 M4-07
*
* 设计口径:所有采集数据先入本队列再尝试上传,网络失败留在队列内自动补传;
* S1+ 各数据类型(水平位移/测斜/轴力/水位…)照此模式挂载自己的实体,
* payloadJson 统一存接口文档约定的上传 JSON 结构。
*/
@Entity(tableName = "upload_queue")
data class UploadQueueEntity(
/** 自增主键。 */
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
val id: Long = 0L,
/** 设备类型IE1000 / VM208见 core:ble DeviceType。 */
@ColumnInfo(name = "device_type")
val deviceType: String,
/** 设备蓝牙标识(蓝牙名或 MAC。 */
@ColumnInfo(name = "device_id")
val deviceId: String,
/** 业务数据类型(水平位移/测斜/轴力/水位等S1+ 扩展枚举)。 */
@ColumnInfo(name = "data_type")
val dataType: String,
/** 按接口文档组装好的上传 JSON。 */
@ColumnInfo(name = "payload_json")
val payloadJson: String,
/** 队列状态,见 [UploadStatus]。 */
@ColumnInfo(name = "status")
val status: Int = UploadStatus.PENDING,
/** 已重试次数429/403 限流退避依据)。 */
@ColumnInfo(name = "retry_count")
val retryCount: Int = 0,
/** 入队时间epoch 毫秒)。 */
@ColumnInfo(name = "created_at")
val createdAt: Long,
/** 上传成功时间epoch 毫秒),未上传为 null。 */
@ColumnInfo(name = "uploaded_at")
val uploadedAt: Long? = null,
)
/** 队列状态常量S0 用 Int 避免 Room TypeConverter 样板S1 可按需替换为枚举)。 */
object UploadStatus {
/** 待上传。 */
const val PENDING = 0
/** 上传中。 */
const val UPLOADING = 1
/** 上传成功(保留一段时间后由清理任务删除)。 */
const val SUCCESS = 2
/** 失败待重试。 */
const val FAILED = 3
}

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
}