software copyright
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 自然写互动课堂智慧黑板端应用软件 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客户端已关闭")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user