Files
system-design/software-copyright/11-writech-sdk/android/CloudClient.java
T
2026-03-22 15:24:40 +08:00

503 lines
18 KiB
Java
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.
/*
* 自然写互动课堂应用开发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;
}
}