software copyright

This commit is contained in:
jiahong
2026-03-22 15:24:40 +08:00
parent e303bb868a
commit 60f336e345
155 changed files with 127262 additions and 0 deletions
@@ -0,0 +1,502 @@
/*
* 自然写互动课堂应用开发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;
}
}