/* * 自然写互动课堂应用开发SDK软件 V1.0 * CloudClient - 云平台API客户端 * * 功能说明: * 1. 封装云平台REST API调用(用户认证、作业、笔迹等) * 2. JWT + Refresh Token 双令牌自动刷新机制 * 3. 请求签名与加密(防篡改、防重放) * 4. 请求重试与超时控制 * 5. 笔迹数据批量上传(分片压缩) * 6. 文件上传/下载(OSS预签名URL) */ package com.writech.sdk.android; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Map; import java.util.TreeMap; import java.util.zip.GZIPOutputStream; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; /** * 云平台API客户端 * 提供统一的HTTP调用封装,支持JWT认证和请求签名 */ public class CloudClient { private static final String TAG = "WritechCloudClient"; /* 默认请求超时(毫秒) */ private static final int DEFAULT_CONNECT_TIMEOUT = 10000; private static final int DEFAULT_READ_TIMEOUT = 30000; /* 最大重试次数 */ private static final int MAX_RETRY_COUNT = 3; /* 笔迹批量上传分片大小(字节) */ private static final int STROKE_CHUNK_SIZE = 64 * 1024; /* ========== 认证令牌管理 ========== */ private String mBaseUrl; /* 云平台API基础URL */ private String mAccessToken; /* JWT访问令牌 */ private String mRefreshToken; /* 刷新令牌 */ private long mTokenExpireTime; /* 令牌过期时间(毫秒时间戳) */ private String mAppKey; /* 应用密钥(用于请求签名) */ private String mAppSecret; /* 应用签名密钥 */ /* 令牌刷新回调 */ private TokenRefreshCallback mTokenCallback; /** 令牌刷新回调接口 */ public interface TokenRefreshCallback { void onTokenRefreshed(String newAccessToken, String newRefreshToken); void onTokenRefreshFailed(int errorCode, String message); } /* ========== 构造与初始化 ========== */ /** * 创建云平台API客户端 * @param baseUrl 云平台API基础地址(如 https://api.writech.com) * @param appKey SDK应用标识 * @param appSecret SDK应用密钥 */ public CloudClient(String baseUrl, String appKey, String appSecret) { mBaseUrl = baseUrl; mAppKey = appKey; mAppSecret = appSecret; } /** 设置认证令牌 */ public void setTokens(String accessToken, String refreshToken, long expireTime) { mAccessToken = accessToken; mRefreshToken = refreshToken; mTokenExpireTime = expireTime; } /** 设置令牌刷新回调 */ public void setTokenRefreshCallback(TokenRefreshCallback callback) { mTokenCallback = callback; } /* ========== 用户认证API ========== */ /** * 用户登录(账号密码方式) * @param username 用户名 * @param password 密码(明文,SDK内部做SHA256后传输) * @return JSON响应字符串,包含accessToken和refreshToken */ public String login(String username, String password) throws IOException { String passwordHash = sha256(password); String body = "{\"username\":\"" + username + "\",\"password\":\"" + passwordHash + "\"}"; return postJson("/api/v1/auth/login", body); } /** * 刷新访问令牌 * 在accessToken过期前自动调用,使用refreshToken获取新令牌 */ public boolean refreshAccessToken() { try { String body = "{\"refreshToken\":\"" + mRefreshToken + "\"}"; String response = postJsonNoAuth("/api/v1/auth/refresh", body); /* 解析响应中的新令牌 */ String newAccess = extractJsonValue(response, "accessToken"); String newRefresh = extractJsonValue(response, "refreshToken"); if (newAccess != null && newRefresh != null) { mAccessToken = newAccess; mRefreshToken = newRefresh; /* 默认过期时间30分钟 */ mTokenExpireTime = System.currentTimeMillis() + 30 * 60 * 1000; if (mTokenCallback != null) { mTokenCallback.onTokenRefreshed(newAccess, newRefresh); } return true; } } catch (IOException e) { if (mTokenCallback != null) { mTokenCallback.onTokenRefreshFailed(-1, e.getMessage()); } } return false; } /* ========== 作业管理API ========== */ /** 获取作业列表 */ public String getAssignments(String classId, int page, int pageSize) throws IOException { String params = "classId=" + classId + "&page=" + page + "&pageSize=" + pageSize; return get("/api/v1/assignments?" + params); } /** 获取作业详情 */ public String getAssignmentDetail(String assignmentId) throws IOException { return get("/api/v1/assignments/" + assignmentId); } /** 提交作业 */ public String submitAssignment(String assignmentId, String studentId, String answerJson) throws IOException { String body = "{\"assignmentId\":\"" + assignmentId + "\",\"studentId\":\"" + studentId + "\",\"answers\":" + answerJson + "}"; return postJson("/api/v1/assignments/submit", body); } /* ========== 笔迹数据上传API ========== */ /** * 上传笔迹数据(单次) * @param studentId 学生ID * @param pageId 页面ID * @param strokeJson 笔迹JSON数据 */ public String uploadStroke(String studentId, String pageId, String strokeJson) throws IOException { String body = "{\"studentId\":\"" + studentId + "\",\"pageId\":\"" + pageId + "\",\"strokes\":" + strokeJson + "}"; return postJson("/api/v1/strokes/upload", body); } /** * 批量上传笔迹数据(大数据量分片压缩) * 将笔迹数据按CHUNK_SIZE分片,GZIP压缩后逐片上传 * * @param studentId 学生ID * @param strokeBytes 笔迹二进制数据 * @return 上传成功的分片数 */ public int uploadStrokeBatch(String studentId, byte[] strokeBytes) throws IOException { /* GZIP压缩原始数据 */ byte[] compressed = gzipCompress(strokeBytes); /* 计算分片数 */ int totalChunks = (compressed.length + STROKE_CHUNK_SIZE - 1) / STROKE_CHUNK_SIZE; int uploadedChunks = 0; String uploadId = generateUploadId(); for (int i = 0; i < totalChunks; i++) { int offset = i * STROKE_CHUNK_SIZE; int length = Math.min(STROKE_CHUNK_SIZE, compressed.length - offset); byte[] chunk = new byte[length]; System.arraycopy(compressed, offset, chunk, 0, length); /* 上传分片 */ String url = mBaseUrl + "/api/v1/strokes/upload-chunk"; String boundary = "----WritechBoundary" + System.currentTimeMillis(); HttpURLConnection conn = createConnection(url, "POST"); conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); addAuthHeaders(conn); OutputStream os = conn.getOutputStream(); /* 写入表单字段 */ writeMultipartField(os, boundary, "uploadId", uploadId); writeMultipartField(os, boundary, "studentId", studentId); writeMultipartField(os, boundary, "chunkIndex", String.valueOf(i)); writeMultipartField(os, boundary, "totalChunks", String.valueOf(totalChunks)); /* 写入二进制数据块 */ writeMultipartFile(os, boundary, "data", "chunk_" + i + ".gz", chunk); os.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); os.flush(); int responseCode = conn.getResponseCode(); conn.disconnect(); if (responseCode == 200) { uploadedChunks++; } else { break; } } return uploadedChunks; } /* ========== Multipart POST (静态方法供OCREngine调用) ========== */ /** * 发送Multipart POST请求 * @param url 完整URL * @param token Bearer令牌 * @param imageData 图像二进制数据 * @param strokeData 笔迹数据 * @param targetChar 目标字符 * @param timeoutMs 超时毫秒数 * @return 响应JSON字符串 */ public static String postMultipart(String url, String token, byte[] imageData, byte[] strokeData, String targetChar, int timeoutMs) throws IOException { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setRequestMethod("POST"); conn.setConnectTimeout(timeoutMs); conn.setReadTimeout(timeoutMs); conn.setDoOutput(true); if (token != null) { conn.setRequestProperty("Authorization", "Bearer " + token); } String boundary = "----WritechBound" + System.nanoTime(); conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); OutputStream os = conn.getOutputStream(); if (imageData != null) { writeMultipartFile(os, boundary, "image", "stroke.png", imageData); } if (strokeData != null) { writeMultipartFile(os, boundary, "strokes", "strokes.bin", strokeData); } if (targetChar != null) { writeMultipartField(os, boundary, "targetChar", targetChar); } os.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); os.flush(); String response = readResponse(conn); conn.disconnect(); return response; } /* ========== HTTP基础方法 ========== */ /** GET请求 */ public String get(String path) throws IOException { return executeWithRetry("GET", path, null); } /** POST JSON请求(带认证) */ public String postJson(String path, String jsonBody) throws IOException { return executeWithRetry("POST", path, jsonBody); } /** POST JSON请求(无认证,用于登录/刷新令牌) */ private String postJsonNoAuth(String path, String body) throws IOException { String url = mBaseUrl + path; HttpURLConnection conn = createConnection(url, "POST"); conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); conn.setDoOutput(true); OutputStream os = conn.getOutputStream(); os.write(body.getBytes(StandardCharsets.UTF_8)); os.flush(); String response = readResponse(conn); conn.disconnect(); return response; } /** 带重试和令牌自动刷新的HTTP请求执行 */ private String executeWithRetry(String method, String path, String body) throws IOException { int retryCount = 0; IOException lastException = null; while (retryCount < MAX_RETRY_COUNT) { try { /* 检查令牌是否即将过期(提前5分钟刷新) */ if (mTokenExpireTime > 0 && System.currentTimeMillis() > mTokenExpireTime - 5 * 60 * 1000) { refreshAccessToken(); } String url = mBaseUrl + path; HttpURLConnection conn = createConnection(url, method); addAuthHeaders(conn); if ("POST".equals(method) && body != null) { conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); conn.setDoOutput(true); OutputStream os = conn.getOutputStream(); os.write(body.getBytes(StandardCharsets.UTF_8)); os.flush(); } int responseCode = conn.getResponseCode(); /* 401未授权,尝试刷新令牌后重试 */ if (responseCode == 401 && retryCount == 0) { conn.disconnect(); if (refreshAccessToken()) { retryCount++; continue; } } String response = readResponse(conn); conn.disconnect(); return response; } catch (IOException e) { lastException = e; retryCount++; /* 指数退避重试间隔 */ try { Thread.sleep(1000L * retryCount); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } throw lastException != null ? lastException : new IOException("请求失败,已重试" + MAX_RETRY_COUNT + "次"); } /* ========== 请求签名 ========== */ /** 添加认证和签名请求头 */ private void addAuthHeaders(HttpURLConnection conn) { if (mAccessToken != null) { conn.setRequestProperty("Authorization", "Bearer " + mAccessToken); } /* 添加请求签名头(防篡改) */ String timestamp = String.valueOf(System.currentTimeMillis()); String nonce = generateNonce(); String signData = mAppKey + timestamp + nonce; String signature = hmacSha256(signData, mAppSecret); conn.setRequestProperty("X-App-Key", mAppKey); conn.setRequestProperty("X-Timestamp", timestamp); conn.setRequestProperty("X-Nonce", nonce); conn.setRequestProperty("X-Signature", signature); } /* ========== 工具方法 ========== */ /** 创建HTTP连接 */ private HttpURLConnection createConnection(String urlStr, String method) throws IOException { URL url = new URL(urlStr); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod(method); conn.setConnectTimeout(DEFAULT_CONNECT_TIMEOUT); conn.setReadTimeout(DEFAULT_READ_TIMEOUT); conn.setRequestProperty("User-Agent", "WritechSDK/1.0"); conn.setRequestProperty("Accept", "application/json"); return conn; } /** 读取HTTP响应 */ private static String readResponse(HttpURLConnection conn) throws IOException { InputStream is; try { is = conn.getInputStream(); } catch (IOException e) { is = conn.getErrorStream(); if (is == null) throw e; } ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] buffer = new byte[4096]; int len; while ((len = is.read(buffer)) != -1) { baos.write(buffer, 0, len); } is.close(); return baos.toString("UTF-8"); } /** GZIP压缩 */ private byte[] gzipCompress(byte[] data) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); GZIPOutputStream gzos = new GZIPOutputStream(baos); gzos.write(data); gzos.finish(); gzos.close(); return baos.toByteArray(); } /** 写入Multipart文本字段 */ private static void writeMultipartField(OutputStream os, String boundary, String name, String value) throws IOException { String field = "--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + value + "\r\n"; os.write(field.getBytes(StandardCharsets.UTF_8)); } /** 写入Multipart文件字段 */ private static void writeMultipartFile(OutputStream os, String boundary, String name, String filename, byte[] data) throws IOException { String header = "--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n" + "Content-Type: application/octet-stream\r\n\r\n"; os.write(header.getBytes(StandardCharsets.UTF_8)); os.write(data); os.write("\r\n".getBytes(StandardCharsets.UTF_8)); } /** SHA-256哈希 */ private String sha256(String input) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); return bytesToHex(hash); } catch (Exception e) { return input; } } /** HMAC-SHA256签名 */ private String hmacSha256(String data, String key) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); return bytesToHex(hash); } catch (Exception e) { return ""; } } /** 字节数组转十六进制字符串 */ private String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02x", b)); } return sb.toString(); } /** 生成随机Nonce */ private String generateNonce() { return Long.toHexString(System.nanoTime()) + Long.toHexString((long)(Math.random() * Long.MAX_VALUE)); } /** 生成上传ID */ private String generateUploadId() { return "upload_" + System.currentTimeMillis() + "_" + (int)(Math.random() * 10000); } /** 从JSON中提取字段值(简化解析) */ private String extractJsonValue(String json, String key) { if (json == null) return null; String searchKey = "\"" + key + "\""; int idx = json.indexOf(searchKey); if (idx < 0) return null; int start = json.indexOf("\"", idx + searchKey.length() + 1) + 1; int end = json.indexOf("\"", start); if (start > 0 && end > start) { return json.substring(start, end); } return null; } }