341 lines
12 KiB
Kotlin
341 lines
12 KiB
Kotlin
/**
|
||
* 自然写互动课堂电视端应用软件 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)
|
||
}
|
||
|
||
/** 获取资源下载URL(CDN签名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)
|
||
}
|
||
}
|