Files
system-design/software-copyright/09-writech-app-board/network/CloudApiClient.kt
T
2026-03-22 15:24:40 +08:00

350 lines
13 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
*
* 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客户端已关闭")
}
}