feat(im): 添加即时通讯服务端SDK

- 实现用户登录认证功能,支持签名验证
- 添加消息发送、撤回及历史记录查询功能
- 实现会话管理功能,包括置顶、静音、已读标记等
- 添加好友系统功能,支持好友申请、同意、删除操作
- 实现群组管理功能,包括创建、解散、成员管理等
- 添加黑名单管理功能
- 实现推送服务集成,支持消息推送注册和发送
- 添加应用版本更新功能,支持Android应用和RN Bundle更新
- 实现关键词过滤和全局禁言管理功能
- 添加管理员消息查询和管理功能
- 集成Webhook配置管理功能
- 实现多服务器URL配置支持,包括基础服务、推送服务和更新服务
- 添加multipart表单数据上传支持,用于文件传输
- 实现统一的API请求处理和响应解析机制
这个提交包含在:
XuqmGroup 2026-04-28 17:43:46 +08:00
父节点 bbf1fd0769
当前提交 c217e96482

查看文件

@ -14,7 +14,10 @@ import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
@ -31,6 +34,8 @@ public final class XuqmImServerSdk {
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final String baseUrl;
private final String pushBaseUrl;
private final String updateBaseUrl;
private final String appId;
private final String appSecret;
private final Supplier<String> bearerTokenSupplier;
@ -41,6 +46,8 @@ public final class XuqmImServerSdk {
.registerModule(new JavaTimeModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
this.baseUrl = trimTrailingSlash(Objects.requireNonNull(builder.baseUrl, "baseUrl"));
this.pushBaseUrl = trimTrailingSlash(builder.pushBaseUrl == null ? builder.baseUrl : builder.pushBaseUrl);
this.updateBaseUrl = trimTrailingSlash(builder.updateBaseUrl == null ? builder.baseUrl : builder.updateBaseUrl);
this.appId = Objects.requireNonNull(builder.appId, "appId");
this.appSecret = Objects.requireNonNull(builder.appSecret, "appSecret");
this.bearerTokenSupplier = builder.bearerTokenSupplier;
@ -405,6 +412,216 @@ public final class XuqmImServerSdk {
return response.data();
}
public void registerPushToken(String userId, String vendor, String token) {
request(
"POST",
buildUri(pushBaseUrl, "/api/push/register", Map.of(
"appId", appId,
"userId", userId,
"vendor", vendor,
"token", token
)),
null,
publicHeaders(),
new TypeReference<ApiResponse<Void>>() {}
);
}
public void sendPush(String userId, String title, String body, String payload) {
Map<String, String> query = new LinkedHashMap<>();
query.put("appId", appId);
query.put("userId", userId);
query.put("title", title);
query.put("body", body);
if (payload != null) {
query.put("payload", payload);
}
request(
"POST",
buildUri(pushBaseUrl, "/api/push/send", query),
null,
publicHeaders(),
new TypeReference<ApiResponse<Void>>() {}
);
}
public Map<String, Object> checkAppUpdate(String platform, int currentVersionCode) {
ApiResponse<Map<String, Object>> response = request(
"GET",
buildUri(updateBaseUrl, "/api/v1/updates/app/check", Map.of(
"appId", appId,
"platform", platform,
"currentVersionCode", String.valueOf(currentVersionCode)
)),
null,
publicHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public AppVersionView uploadAppVersion(
String platform,
String versionName,
int versionCode,
String changeLog,
boolean forceUpdate,
Path apkFile) {
Map<String, String> form = new LinkedHashMap<>();
form.put("appId", appId);
form.put("platform", platform);
form.put("versionName", versionName);
form.put("versionCode", String.valueOf(versionCode));
if (changeLog != null) {
form.put("changeLog", changeLog);
}
form.put("forceUpdate", String.valueOf(forceUpdate));
ApiResponse<AppVersionView> response = multipartRequest(
"POST",
buildUri(updateBaseUrl, "/api/v1/updates/app/upload", Map.of()),
form,
"apkFile",
apkFile,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public AppVersionView publishAppVersion(String id) {
ApiResponse<AppVersionView> response = request(
"POST",
buildUri(updateBaseUrl, "/api/v1/updates/app/" + encode(id) + "/publish", Map.of()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public AppVersionView unpublishAppVersion(String id) {
ApiResponse<AppVersionView> response = request(
"POST",
buildUri(updateBaseUrl, "/api/v1/updates/app/" + encode(id) + "/unpublish", Map.of()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public AppVersionView grayAppVersion(String id, boolean enabled, int percent) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("enabled", enabled);
body.put("percent", percent);
ApiResponse<AppVersionView> response = request(
"POST",
buildUri(updateBaseUrl, "/api/v1/updates/app/" + encode(id) + "/gray", Map.of()),
body,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public List<AppVersionView> listAppVersions(String platform) {
ApiResponse<List<AppVersionView>> response = request(
"GET",
buildUri(updateBaseUrl, "/api/v1/updates/app/list", Map.of("appId", appId, "platform", platform)),
null,
publicHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public RnBundleView checkRnUpdate(String moduleId, String platform, String currentVersion) {
ApiResponse<RnBundleView> response = request(
"GET",
buildUri(updateBaseUrl, "/api/v1/rn/update/check", Map.of(
"appId", appId,
"moduleId", moduleId,
"platform", platform,
"currentVersion", currentVersion
)),
null,
publicHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public RnBundleView uploadRnBundle(
String moduleId,
String platform,
String version,
String minCommonVersion,
String note,
Path bundle) {
Map<String, String> form = new LinkedHashMap<>();
form.put("appId", appId);
form.put("moduleId", moduleId);
form.put("platform", platform);
form.put("version", version);
if (minCommonVersion != null) {
form.put("minCommonVersion", minCommonVersion);
}
if (note != null) {
form.put("note", note);
}
ApiResponse<RnBundleView> response = multipartRequest(
"POST",
buildUri(updateBaseUrl, "/api/v1/rn/upload", Map.of()),
form,
"bundle",
bundle,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public RnBundleView publishRnBundle(String id) {
ApiResponse<RnBundleView> response = request(
"POST",
buildUri(updateBaseUrl, "/api/v1/rn/" + encode(id) + "/publish", Map.of()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public RnBundleView unpublishRnBundle(String id) {
ApiResponse<RnBundleView> response = request(
"POST",
buildUri(updateBaseUrl, "/api/v1/rn/" + encode(id) + "/unpublish", Map.of()),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public List<RnBundleView> listRnBundles(String moduleId, String platform) {
Map<String, String> query = new LinkedHashMap<>();
query.put("appId", appId);
if (moduleId != null) {
query.put("moduleId", moduleId);
}
if (platform != null) {
query.put("platform", platform);
}
ApiResponse<List<RnBundleView>> response = request(
"GET",
buildUri(updateBaseUrl, "/api/v1/rn/list", query),
null,
publicHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public List<String> listFriends() {
ApiResponse<List<String>> response = request(
"GET",
@ -821,6 +1038,10 @@ public final class XuqmImServerSdk {
return Map.of("Authorization", "Bearer " + token);
}
private Map<String, String> publicHeaders() {
return Map.of();
}
private <T> ApiResponse<T> request(
String method,
URI uri,
@ -856,6 +1077,66 @@ public final class XuqmImServerSdk {
}
}
private <T> ApiResponse<T> multipartRequest(
String method,
URI uri,
Map<String, String> formFields,
String fileFieldName,
Path file,
Map<String, String> headers,
TypeReference<ApiResponse<T>> responseType
) {
String boundary = "----XuqmBoundary" + UUID.randomUUID().toString().replace("-", "");
try {
HttpRequest.BodyPublisher bodyPublisher = ofMultipartData(boundary, formFields, fileFieldName, file);
Map<String, String> mergedHeaders = new LinkedHashMap<>(headers);
mergedHeaders.put("Content-Type", "multipart/form-data; boundary=" + boundary);
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
.headers(flatten(mergedHeaders))
.method(method, bodyPublisher);
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new ImSdkException("HTTP " + response.statusCode() + ": " + response.body());
}
ApiResponse<T> apiResponse = objectMapper.readValue(response.body(), responseType);
if (apiResponse.code() != 200) {
throw new ImSdkException(apiResponse.message());
}
return apiResponse;
} catch (IOException e) {
throw new ImSdkException("Request failed: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ImSdkException("Request interrupted", e);
}
}
private HttpRequest.BodyPublisher ofMultipartData(
String boundary,
Map<String, String> formFields,
String fileFieldName,
Path file
) throws IOException {
var byteArrays = new ArrayList<byte[]>();
Charset charset = StandardCharsets.UTF_8;
for (Map.Entry<String, String> entry : formFields.entrySet()) {
byteArrays.add(("--" + boundary + "\r\n").getBytes(charset));
byteArrays.add(("Content-Disposition: form-data; name=\"" + entry.getKey() + "\"\r\n\r\n").getBytes(charset));
byteArrays.add(entry.getValue().getBytes(charset));
byteArrays.add("\r\n".getBytes(charset));
}
byteArrays.add(("--" + boundary + "\r\n").getBytes(charset));
byteArrays.add(("Content-Disposition: form-data; name=\"" + fileFieldName + "\"; filename=\"" + file.getFileName() + "\"\r\n").getBytes(charset));
byteArrays.add(("Content-Type: application/octet-stream\r\n\r\n").getBytes(charset));
byteArrays.add(Files.readAllBytes(file));
byteArrays.add("\r\n".getBytes(charset));
byteArrays.add(("--" + boundary + "--\r\n").getBytes(charset));
return HttpRequest.BodyPublishers.ofByteArrays(byteArrays);
}
private URI buildUri(String path, Map<String, String> query) {
StringBuilder builder = new StringBuilder(baseUrl).append(path);
if (query != null && !query.isEmpty()) {
@ -876,6 +1157,26 @@ public final class XuqmImServerSdk {
return URI.create(builder.toString());
}
private URI buildUri(String base, String path, Map<String, String> query) {
StringBuilder builder = new StringBuilder(trimTrailingSlash(base)).append(path);
if (query != null && !query.isEmpty()) {
builder.append('?');
boolean first = true;
for (Map.Entry<String, String> entry : query.entrySet()) {
String value = entry.getValue();
if (value == null) {
continue;
}
if (!first) {
builder.append('&');
}
builder.append(encode(entry.getKey())).append('=').append(encode(value));
first = false;
}
}
return URI.create(builder.toString());
}
private Map<String, String> loginQuery(String userId, String nickname, String avatar, long timestamp, String nonce) {
Map<String, String> query = new LinkedHashMap<>();
query.put("appId", appId);
@ -951,6 +1252,8 @@ public final class XuqmImServerSdk {
public static final class Builder {
private String baseUrl;
private String pushBaseUrl;
private String updateBaseUrl;
private String appId;
private String appSecret;
private Supplier<String> bearerTokenSupplier;
@ -962,6 +1265,16 @@ public final class XuqmImServerSdk {
return this;
}
public Builder pushBaseUrl(String pushBaseUrl) {
this.pushBaseUrl = pushBaseUrl;
return this;
}
public Builder updateBaseUrl(String updateBaseUrl) {
this.updateBaseUrl = updateBaseUrl;
return this;
}
public Builder appId(String appId) {
this.appId = appId;
return this;
@ -1098,6 +1411,39 @@ public final class XuqmImServerSdk {
Long updatedAt
) {}
public record AppVersionView(
String id,
String appId,
String platform,
String versionName,
int versionCode,
String downloadUrl,
String changeLog,
boolean forceUpdate,
String publishStatus,
String appStoreUrl,
String marketUrl,
boolean grayEnabled,
int grayPercent,
Long createdAt
) {}
public record RnBundleView(
String id,
String appId,
String moduleId,
String platform,
String version,
String bundleUrl,
String md5,
String minCommonVersion,
String note,
String publishStatus,
boolean grayEnabled,
int grayPercent,
Long createdAt
) {}
public record HistoryQuery(
String msgType,
String keyword,
@ -1159,6 +1505,24 @@ public final class XuqmImServerSdk {
Boolean enabled
) {}
public record AppVersionUploadRequest(
String appId,
String platform,
String versionName,
int versionCode,
String changeLog,
boolean forceUpdate
) {}
public record RnBundleUploadRequest(
String appId,
String moduleId,
String platform,
String version,
String minCommonVersion,
String note
) {}
public record CreateGroupRequest(
String name,
List<String> memberIds,