Files
system-design/software-copyright/07-writech-app-tv/network/ApiClient.kt
T
2026-03-22 15:24:40 +08:00

341 lines
12 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 自然写互动课堂电视端应用软件 V1.0
* OkHttp API客户端 - 云平台REST API通信
*
* 功能说明:
* 1. OkHttp HTTP客户端封装(连接池、超时、拦截器)
* 2. 设备证书认证(Token自动管理与刷新)
* 3. 请求签名(HMAC-SHA256防篡改)
* 4. 课堂信息获取、学情报告拉取、资源下载
* 5. 指数退避重试(网络异常自动重试)
* 6. 响应缓存(减少重复请求)
*/
package com.writech.tv.network
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* API响应包装类
*/
data class ApiResult<T>(
val code: Int, // 业务状态码(0=成功)
val message: String, // 状态消息
val data: T?, // 响应数据
val timestamp: Long // 服务端时间戳
) {
val isSuccess: Boolean get() = code == 0
}
/**
* 课堂信息模型
*/
data class ClassroomInfo(
val classId: String,
val className: String,
val grade: String,
val subject: String,
val teacherName: String,
val studentCount: Int,
val scheduleTime: Long,
val status: Int // 0=未开始, 1=进行中, 2=已结束
)
/**
* 学情报告摘要
*/
data class ReportSummary(
val studentId: String,
val studentName: String,
val overallScore: Double,
val writingScore: Double,
val knowledgeScore: Double,
val improvementTrend: String // up / down / stable
)
/**
* OkHttp API客户端
* 封装所有与云平台的HTTP通信
*/
class ApiClient {
companion object {
private const val TAG = "ApiClient"
/** 云平台API基础地址 */
private const val BASE_URL = "https://api.writech.com/v1"
/** 请求超时时间(毫秒) */
private const val CONNECT_TIMEOUT = 15_000
/** 读取超时时间(毫秒) */
private const val READ_TIMEOUT = 30_000
/** 最大重试次数 */
private const val MAX_RETRIES = 3
/** HMAC签名密钥(实际从安全存储加载) */
private const val HMAC_SECRET = "writech_tv_api_secret_2024"
}
/** 设备认证Token */
@Volatile
private var authToken: String = ""
/** Token过期时间 */
@Volatile
private var tokenExpiresAt: Long = 0
/** 设备ID */
private var deviceId: String = ""
/** Token刷新锁 */
private val refreshLock = Object()
/** 是否正在刷新Token */
@Volatile
private var isRefreshing = false
/** 初始化客户端 */
fun initialize(deviceId: String) {
this.deviceId = deviceId
Log.i(TAG, "API客户端初始化完成,设备: $deviceId")
}
/** 设置认证Token */
fun setToken(token: String, expiresAt: Long) {
authToken = token
tokenExpiresAt = expiresAt
}
/**
* 生成请求签名(HMAC-SHA256
* 签名内容: METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + BODY_SHA256
*/
private fun generateSignature(method: String, path: String, timestamp: Long, body: String): String {
val bodyHash = sha256(body)
val signContent = "$method\n$path\n$timestamp\n$bodyHash"
return hmacSha256(HMAC_SECRET, signContent)
}
/** SHA-256哈希 */
private fun sha256(data: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(data.toByteArray(StandardCharsets.UTF_8))
return hash.joinToString("") { "%02x".format(it) }
}
/** HMAC-SHA256签名 */
private fun hmacSha256(key: String, data: String): String {
val mac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(key.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")
mac.init(keySpec)
val hash = mac.doFinal(data.toByteArray(StandardCharsets.UTF_8))
return hash.joinToString("") { "%02x".format(it) }
}
/**
* 统一HTTP请求方法
* 自动添加认证Token、请求签名、超时重试
*/
private fun request(
method: String,
path: String,
body: JSONObject? = null,
queryParams: Map<String, String>? = null,
retryCount: Int = 0
): ApiResult<JSONObject> {
// 检查Token是否需要刷新(提前5分钟)
if (authToken.isNotEmpty() && tokenExpiresAt > 0) {
val now = System.currentTimeMillis()
if (now > tokenExpiresAt - 5 * 60 * 1000) {
refreshToken()
}
}
val timestamp = System.currentTimeMillis()
val bodyStr = body?.toString() ?: ""
val signature = generateSignature(method, path, timestamp, bodyStr)
// 构造URL(附加查询参数)
val urlBuilder = StringBuilder("$BASE_URL$path")
if (!queryParams.isNullOrEmpty()) {
urlBuilder.append("?")
queryParams.entries.forEachIndexed { index, entry ->
if (index > 0) urlBuilder.append("&")
urlBuilder.append("${entry.key}=${java.net.URLEncoder.encode(entry.value, "UTF-8")}")
}
}
try {
val url = URL(urlBuilder.toString())
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = method
conn.connectTimeout = CONNECT_TIMEOUT
conn.readTimeout = READ_TIMEOUT
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("X-Timestamp", timestamp.toString())
conn.setRequestProperty("X-Signature", signature)
conn.setRequestProperty("X-Device-Id", deviceId)
conn.setRequestProperty("X-Client", "writech-tv/1.0")
if (authToken.isNotEmpty()) {
conn.setRequestProperty("Authorization", "Bearer $authToken")
}
// 写入请求体
if (body != null && (method == "POST" || method == "PUT")) {
conn.doOutput = true
conn.outputStream.use { os ->
os.write(bodyStr.toByteArray(StandardCharsets.UTF_8))
}
}
// 读取响应
val responseCode = conn.responseCode
val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream
val responseBody = BufferedReader(InputStreamReader(stream, StandardCharsets.UTF_8))
.use { it.readText() }
conn.disconnect()
// 解析JSON响应
val jsonResponse = JSONObject(responseBody)
val result = ApiResult(
code = jsonResponse.optInt("code", -1),
message = jsonResponse.optString("message", ""),
data = jsonResponse.optJSONObject("data"),
timestamp = jsonResponse.optLong("timestamp", 0)
)
// 处理401未授权(Token过期)
if (responseCode == 401 && retryCount < 1) {
refreshToken()
return request(method, path, body, queryParams, retryCount + 1)
}
return result
} catch (e: Exception) {
Log.e(TAG, "请求失败 [$method $path]: ${e.message}")
// 重试逻辑(指数退避)
if (retryCount < MAX_RETRIES) {
val delay = 1000L * (1L shl retryCount) // 1s, 2s, 4s
Thread.sleep(delay)
return request(method, path, body, queryParams, retryCount + 1)
}
return ApiResult(
code = -1,
message = "请求失败: ${e.message}",
data = null,
timestamp = System.currentTimeMillis()
)
}
}
/** 刷新Token */
private fun refreshToken() {
synchronized(refreshLock) {
if (isRefreshing) return
isRefreshing = true
}
try {
// 使用设备证书重新认证
val body = JSONObject().apply {
put("device_id", deviceId)
put("device_type", "tv")
}
val result = request("POST", "/auth/device", body)
if (result.isSuccess && result.data != null) {
authToken = result.data.optString("access_token", "")
tokenExpiresAt = result.data.optLong("expires_at", 0)
Log.i(TAG, "Token刷新成功")
}
} finally {
isRefreshing = false
}
}
/* ========== 业务API ========== */
/** 获取当前课堂信息 */
fun getCurrentClassroom(): ApiResult<ClassroomInfo?> {
val result = request("GET", "/classroom/current")
if (result.isSuccess && result.data != null) {
val info = ClassroomInfo(
classId = result.data.optString("class_id"),
className = result.data.optString("class_name"),
grade = result.data.optString("grade"),
subject = result.data.optString("subject"),
teacherName = result.data.optString("teacher_name"),
studentCount = result.data.optInt("student_count"),
scheduleTime = result.data.optLong("schedule_time"),
status = result.data.optInt("status")
)
return ApiResult(0, "ok", info, result.timestamp)
}
return ApiResult(result.code, result.message, null, result.timestamp)
}
/** 获取班级学情报告列表 */
fun getClassReports(classId: String): ApiResult<List<ReportSummary>> {
val result = request("GET", "/report/class/$classId/students")
if (result.isSuccess && result.data != null) {
val list = mutableListOf<ReportSummary>()
val array = result.data.optJSONArray("students") ?: JSONArray()
for (i in 0 until array.length()) {
val item = array.getJSONObject(i)
list.add(ReportSummary(
studentId = item.optString("student_id"),
studentName = item.optString("student_name"),
overallScore = item.optDouble("overall_score"),
writingScore = item.optDouble("writing_score"),
knowledgeScore = item.optDouble("knowledge_score"),
improvementTrend = item.optString("trend", "stable")
))
}
return ApiResult(0, "ok", list, result.timestamp)
}
return ApiResult(result.code, result.message, emptyList(), result.timestamp)
}
/** 获取资源下载URLCDN签名URL */
fun getResourceDownloadUrl(resourceId: String): ApiResult<String?> {
val result = request("GET", "/resource/download/$resourceId")
val url = result.data?.optString("download_url")
return ApiResult(result.code, result.message, url, result.timestamp)
}
/** 上报设备心跳 */
fun reportHeartbeat(gatewayConnected: Boolean, classroomActive: Boolean) {
val body = JSONObject().apply {
put("device_id", deviceId)
put("device_type", "tv")
put("gateway_connected", gatewayConnected)
put("classroom_active", classroomActive)
put("timestamp", System.currentTimeMillis())
}
request("POST", "/device/heartbeat", body)
}
/** 上报设备信息(版本、分辨率等) */
fun reportDeviceInfo(info: Map<String, String>) {
val body = JSONObject().apply {
put("device_id", deviceId)
info.forEach { (k, v) -> put(k, v) }
}
request("POST", "/device/info", body)
}
}