/** * 自然写互动课堂电视端应用软件 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( 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? = null, retryCount: Int = 0 ): ApiResult { // 检查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 { 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> { val result = request("GET", "/report/class/$classId/students") if (result.isSuccess && result.data != null) { val list = mutableListOf() 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 { 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) { val body = JSONObject().apply { put("device_id", deviceId) info.forEach { (k, v) -> put(k, v) } } request("POST", "/device/info", body) } }