software copyright
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user