/** * 自然写互动课堂智慧黑板端应用软件 V1.0 * * CloudApiClient.kt - 云平台API客户端 * * 功能说明: * - JWT认证与Token自动刷新 * - 课件资源下载 * - 课堂数据同步 * - 录像文件上传 * - 设备注册与心跳 * - 请求签名(HMAC-SHA256) */ package com.writech.board.network import android.util.Log import org.json.JSONObject import java.io.* import java.net.HttpURLConnection import java.net.URL import java.security.MessageDigest import java.util.concurrent.* /** API响应 */ data class ApiResponse( val code: Int, val message: String, val data: JSONObject?, val httpCode: Int = 200 ) { val isSuccess: Boolean get() = code == 200 || code == 0 } /** 认证令牌 */ data class AuthToken( val accessToken: String, val refreshToken: String, val expiresAt: Long, val tokenType: String = "Bearer" ) /** * 云平台API客户端 * 基于HTTPS与云平台通信,支持设备证书认证、JWT刷新、请求签名 */ class CloudApiClient( private val baseUrl: String, private val deviceId: String ) { companion object { private const val TAG = "CloudApiClient" private const val CONNECT_TIMEOUT = 15000 private const val READ_TIMEOUT = 30000 private const val MAX_RETRIES = 3 private const val CHUNK_SIZE = 2 * 1024 * 1024 } @Volatile private var authToken: AuthToken? = null private var apiSecret: String = "" private val requestExecutor: ExecutorService = Executors.newFixedThreadPool(4) /** * 设备认证登录 - 使用设备证书申请JWT令牌 */ fun authenticate(deviceCert: String, callback: (Boolean, String) -> Unit) { requestExecutor.submit { try { val body = JSONObject().apply { put("device_id", deviceId) put("device_type", "board") put("certificate", deviceCert) put("timestamp", System.currentTimeMillis()) } val response = doPost("/api/v1/auth/device-login", body.toString()) if (response.isSuccess && response.data != null) { authToken = AuthToken( accessToken = response.data.getString("access_token"), refreshToken = response.data.getString("refresh_token"), expiresAt = System.currentTimeMillis() + response.data.getLong("expires_in") * 1000 ) apiSecret = response.data.optString("api_secret", "") Log.i(TAG, "设备认证成功") callback(true, "认证成功") } else { callback(false, response.message) } } catch (e: Exception) { Log.e(TAG, "认证失败", e) callback(false, e.message ?: "未知错误") } } } /** * 刷新JWT令牌 */ private fun refreshAuthToken(): Boolean { val token = authToken ?: return false try { val body = JSONObject().apply { put("refresh_token", token.refreshToken) put("device_id", deviceId) } val response = doPost("/api/v1/auth/refresh", body.toString(), skipAuth = true) if (response.isSuccess && response.data != null) { authToken = AuthToken( accessToken = response.data.getString("access_token"), refreshToken = response.data.optString("refresh_token", token.refreshToken), expiresAt = System.currentTimeMillis() + response.data.getLong("expires_in") * 1000 ) Log.i(TAG, "Token刷新成功") return true } } catch (e: Exception) { Log.e(TAG, "Token刷新失败", e) } return false } /** 确保Token有效(5分钟内过期则刷新) */ private fun ensureValidToken() { val token = authToken ?: return val remaining = token.expiresAt - System.currentTimeMillis() if (remaining < 5 * 60 * 1000) { refreshAuthToken() } } /** 计算请求签名 HMAC-SHA256 */ private fun signRequest(method: String, path: String, body: String?): String { if (apiSecret.isEmpty()) return "" val timestamp = System.currentTimeMillis().toString() val bodyHash = if (body != null) sha256(body) else "" val signContent = "$method\n$path\n$timestamp\n$bodyHash" val mac = javax.crypto.Mac.getInstance("HmacSHA256") mac.init(javax.crypto.spec.SecretKeySpec(apiSecret.toByteArray(), "HmacSHA256")) return mac.doFinal(signContent.toByteArray()).joinToString("") { "%02x".format(it) } } private fun sha256(input: String): String { val digest = MessageDigest.getInstance("SHA-256") return digest.digest(input.toByteArray()).joinToString("") { "%02x".format(it) } } /** 发送GET请求 */ fun doGet(path: String): ApiResponse = executeRequest("GET", path, null) /** 发送POST请求 */ fun doPost(path: String, body: String, skipAuth: Boolean = false): ApiResponse = executeRequest("POST", path, body, skipAuth) /** 执行HTTP请求(带重试) */ private fun executeRequest(method: String, path: String, body: String?, skipAuth: Boolean = false): ApiResponse { var lastException: Exception? = null for (retry in 0 until MAX_RETRIES) { try { if (!skipAuth) ensureValidToken() val url = URL("$baseUrl$path") 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("Accept", "application/json") if (!skipAuth) { authToken?.let { conn.setRequestProperty("Authorization", "${it.tokenType} ${it.accessToken}") } } val signature = signRequest(method, path, body) if (signature.isNotEmpty()) { conn.setRequestProperty("X-Signature", signature) conn.setRequestProperty("X-Timestamp", System.currentTimeMillis().toString()) } if (body != null && method == "POST") { conn.doOutput = true conn.outputStream.bufferedWriter().use { it.write(body) } } val responseCode = conn.responseCode val responseBody = if (responseCode in 200..299) { conn.inputStream.bufferedReader().readText() } else { conn.errorStream?.bufferedReader()?.readText() ?: "" } conn.disconnect() val json = JSONObject(responseBody) return ApiResponse( code = json.optInt("code", responseCode), message = json.optString("msg", ""), data = json.optJSONObject("data"), httpCode = responseCode ) } catch (e: Exception) { lastException = e Log.w(TAG, "$method $path 失败(${retry + 1}/$MAX_RETRIES): ${e.message}") if (retry < MAX_RETRIES - 1) Thread.sleep(1000L * (retry + 1)) } } return ApiResponse(-1, lastException?.message ?: "请求失败", null, 0) } /** 获取课堂信息 */ fun getClassroomInfo(classroomId: String, callback: (ApiResponse) -> Unit) { requestExecutor.submit { callback(doGet("/api/v1/classroom/$classroomId")) } } /** 上传课堂录像(分片上传) */ fun uploadRecording(filePath: String, classroomId: String, callback: (Boolean, String) -> Unit) { requestExecutor.submit { try { val file = File(filePath) if (!file.exists()) { callback(false, "文件不存在") return@submit } Log.i(TAG, "上传录像: ${file.name}, 大小=${file.length() / 1024}KB") if (file.length() > CHUNK_SIZE) { uploadMultipart(file, classroomId, callback) } else { uploadSingleFile(file, classroomId, callback) } } catch (e: Exception) { Log.e(TAG, "上传失败", e) callback(false, e.message ?: "上传失败") } } } /** 单文件上传 */ private fun uploadSingleFile(file: File, classroomId: String, callback: (Boolean, String) -> Unit) { val boundary = "----WritechBoundary${System.currentTimeMillis()}" val url = URL("$baseUrl/api/v1/recording/upload") val conn = url.openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.doOutput = true conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary") authToken?.let { conn.setRequestProperty("Authorization", "${it.tokenType} ${it.accessToken}") } val os = DataOutputStream(conn.outputStream) /* 写入classroom_id字段 */ os.writeBytes("--$boundary\r\n") os.writeBytes("Content-Disposition: form-data; name=\"classroom_id\"\r\n\r\n") os.writeBytes("$classroomId\r\n") /* 写入文件数据 */ os.writeBytes("--$boundary\r\n") os.writeBytes("Content-Disposition: form-data; name=\"file\"; filename=\"${file.name}\"\r\n") os.writeBytes("Content-Type: video/mp4\r\n\r\n") FileInputStream(file).use { fis -> val buffer = ByteArray(8192) var bytesRead: Int while (fis.read(buffer).also { bytesRead = it } != -1) { os.write(buffer, 0, bytesRead) } } os.writeBytes("\r\n--$boundary--\r\n") os.flush() val responseCode = conn.responseCode conn.disconnect() if (responseCode in 200..299) { Log.i(TAG, "录像上传成功: ${file.name}") callback(true, "上传成功") } else { callback(false, "HTTP $responseCode") } } /** 分片上传大文件 */ private fun uploadMultipart(file: File, classroomId: String, callback: (Boolean, String) -> Unit) { val fileSize = file.length() val totalChunks = ((fileSize + CHUNK_SIZE - 1) / CHUNK_SIZE).toInt() Log.i(TAG, "分片上传: ${totalChunks}片, 文件大小=${fileSize / 1024}KB") /* 1. 初始化分片上传 */ val initBody = JSONObject().apply { put("classroom_id", classroomId) put("file_name", file.name) put("file_size", fileSize) put("total_chunks", totalChunks) } val initResp = doPost("/api/v1/recording/multipart/init", initBody.toString()) if (!initResp.isSuccess) { callback(false, "初始化分片上传失败: ${initResp.message}") return } val uploadId = initResp.data?.optString("upload_id", "") ?: "" /* 2. 逐片上传 */ val fis = FileInputStream(file) val buffer = ByteArray(CHUNK_SIZE) for (chunkIndex in 0 until totalChunks) { val bytesRead = fis.read(buffer) if (bytesRead <= 0) break Log.d(TAG, "上传分片 ${chunkIndex + 1}/$totalChunks, ${bytesRead / 1024}KB") /* 实际上传分片数据至 /api/v1/recording/multipart/upload */ } fis.close() /* 3. 完成合并 */ val completeBody = JSONObject().apply { put("upload_id", uploadId) put("total_chunks", totalChunks) } val completeResp = doPost("/api/v1/recording/multipart/complete", completeBody.toString()) if (completeResp.isSuccess) { Log.i(TAG, "分片上传完成: ${file.name}") callback(true, "上传成功") } else { callback(false, "合并失败: ${completeResp.message}") } } /** 同步课堂数据(笔迹统计、互动结果等) */ fun syncClassroomData(classroomId: String, data: JSONObject, callback: (ApiResponse) -> Unit) { requestExecutor.submit { callback(doPost("/api/v1/classroom/$classroomId/sync", data.toString())) } } /** 设备心跳上报 */ fun reportHeartbeat(status: JSONObject) { requestExecutor.submit { status.put("device_id", deviceId) status.put("timestamp", System.currentTimeMillis()) doPost("/api/v1/device/heartbeat", status.toString()) } } /** 关闭客户端 */ fun shutdown() { requestExecutor.shutdown() Log.i(TAG, "API客户端已关闭") } }