feat(im): 添加即时通讯SDK核心功能
- 实现IM API接口定义,包括消息、群组、好友、黑名单等功能 - 定义IM消息相关数据模型,包含聊天类型、消息类型、用户资料等 - 实现ImSDK单例类,提供登录、消息发送、群组管理、好友管理等核心功能 - 添加WebSocket连接管理,支持自动重连机制 - 实现历史消息查询、群组操作、用户资料管理等API调用 - 添加会话状态管理,支持置顶、静音、草稿等功能 - 集成文件上传结果,支持多媒体消息发送 - 实现连接状态监听和事件回调机制
这个提交包含在:
父节点
1e395171a3
当前提交
73060518f0
@ -320,6 +320,25 @@ public final class XuqmImServerSdk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ImMessage parseMessageCallbackPayload(String body) {
|
||||||
|
WebhookCallbackEnvelope envelope = parseCallbackEnvelope(body);
|
||||||
|
if (!envelope.isMessageEvent()
|
||||||
|
|| !(envelope.isEvent("message.sent")
|
||||||
|
|| envelope.isEvent("message.edited")
|
||||||
|
|| envelope.isEvent("message.revoked"))) {
|
||||||
|
throw new ImSdkException("Callback event is not a message event");
|
||||||
|
}
|
||||||
|
return readPayload(envelope, ImMessage.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageReadCallbackPayload parseMessageReadCallbackPayload(String body) {
|
||||||
|
WebhookCallbackEnvelope envelope = parseCallbackEnvelope(body);
|
||||||
|
if (!envelope.isReadReceiptEvent()) {
|
||||||
|
throw new ImSdkException("Callback event is not a message.read event");
|
||||||
|
}
|
||||||
|
return readPayload(envelope, MessageReadCallbackPayload.class);
|
||||||
|
}
|
||||||
|
|
||||||
public PageResult<ImMessage> searchMessages(
|
public PageResult<ImMessage> searchMessages(
|
||||||
String keyword,
|
String keyword,
|
||||||
String chatType,
|
String chatType,
|
||||||
@ -540,6 +559,25 @@ public final class XuqmImServerSdk {
|
|||||||
return response.data();
|
return response.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public UnifiedReleaseResult uploadUnifiedRelease(UnifiedReleaseManifest manifest, Map<String, Path> files) {
|
||||||
|
Map<String, String> form = new LinkedHashMap<>();
|
||||||
|
form.put("appId", appId);
|
||||||
|
try {
|
||||||
|
form.put("manifest", objectMapper.writeValueAsString(manifest));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new ImSdkException("Failed to serialize unified release manifest", e);
|
||||||
|
}
|
||||||
|
ApiResponse<UnifiedReleaseResult> response = multipartRequest(
|
||||||
|
"POST",
|
||||||
|
buildUri(updateBaseUrl, "/api/v1/updates/unified/upload", Map.of()),
|
||||||
|
form,
|
||||||
|
files,
|
||||||
|
authorizedHeaders(),
|
||||||
|
new TypeReference<>() {}
|
||||||
|
);
|
||||||
|
return response.data();
|
||||||
|
}
|
||||||
|
|
||||||
public AppVersionView publishAppVersion(String id) {
|
public AppVersionView publishAppVersion(String id) {
|
||||||
ApiResponse<AppVersionView> response = request(
|
ApiResponse<AppVersionView> response = request(
|
||||||
"POST",
|
"POST",
|
||||||
@ -698,6 +736,17 @@ public final class XuqmImServerSdk {
|
|||||||
return response.data();
|
return response.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<FriendLinkView> addFriends(List<String> friendIds) {
|
||||||
|
ApiResponse<List<FriendLinkView>> response = request(
|
||||||
|
"POST",
|
||||||
|
buildUri("/api/im/friends/batch", appQuery()),
|
||||||
|
new FriendBatchRequest(friendIds),
|
||||||
|
authorizedHeaders(),
|
||||||
|
new TypeReference<>() {}
|
||||||
|
);
|
||||||
|
return response.data();
|
||||||
|
}
|
||||||
|
|
||||||
public void removeFriend(String friendId) {
|
public void removeFriend(String friendId) {
|
||||||
Map<String, String> query = appQuery();
|
Map<String, String> query = appQuery();
|
||||||
query.put("friendId", friendId);
|
query.put("friendId", friendId);
|
||||||
@ -710,6 +759,16 @@ public final class XuqmImServerSdk {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void removeFriends(List<String> friendIds) {
|
||||||
|
request(
|
||||||
|
"POST",
|
||||||
|
buildUri("/api/im/friends/batch/remove", appQuery()),
|
||||||
|
new FriendBatchRequest(friendIds),
|
||||||
|
authorizedHeaders(),
|
||||||
|
new TypeReference<ApiResponse<Void>>() {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public List<FriendRequestView> listFriendRequests(String direction) {
|
public List<FriendRequestView> listFriendRequests(String direction) {
|
||||||
Map<String, String> query = appQuery();
|
Map<String, String> query = appQuery();
|
||||||
if (direction != null) {
|
if (direction != null) {
|
||||||
@ -763,6 +822,28 @@ public final class XuqmImServerSdk {
|
|||||||
return response.data();
|
return response.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<FriendRequestView> acceptFriendRequests(List<String> requestIds) {
|
||||||
|
ApiResponse<List<FriendRequestView>> response = request(
|
||||||
|
"POST",
|
||||||
|
buildUri("/api/im/friend-requests/batch/accept", appQuery()),
|
||||||
|
new RequestBatch(requestIds),
|
||||||
|
authorizedHeaders(),
|
||||||
|
new TypeReference<>() {}
|
||||||
|
);
|
||||||
|
return response.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FriendRequestView> rejectFriendRequests(List<String> requestIds) {
|
||||||
|
ApiResponse<List<FriendRequestView>> response = request(
|
||||||
|
"POST",
|
||||||
|
buildUri("/api/im/friend-requests/batch/reject", appQuery()),
|
||||||
|
new RequestBatch(requestIds),
|
||||||
|
authorizedHeaders(),
|
||||||
|
new TypeReference<>() {}
|
||||||
|
);
|
||||||
|
return response.data();
|
||||||
|
}
|
||||||
|
|
||||||
public List<BlacklistView> listBlacklist() {
|
public List<BlacklistView> listBlacklist() {
|
||||||
ApiResponse<List<BlacklistView>> response = request(
|
ApiResponse<List<BlacklistView>> response = request(
|
||||||
"GET",
|
"GET",
|
||||||
@ -869,6 +950,17 @@ public final class XuqmImServerSdk {
|
|||||||
return response.data();
|
return response.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public GroupView addGroupMembers(String groupId, List<String> userIds) {
|
||||||
|
ApiResponse<GroupView> response = request(
|
||||||
|
"POST",
|
||||||
|
buildUri("/api/im/groups/" + encode(groupId) + "/members/batch", appQuery()),
|
||||||
|
new MemberBatchRequest(userIds),
|
||||||
|
authorizedHeaders(),
|
||||||
|
new TypeReference<>() {}
|
||||||
|
);
|
||||||
|
return response.data();
|
||||||
|
}
|
||||||
|
|
||||||
public GroupView removeGroupMember(String groupId, String targetUserId) {
|
public GroupView removeGroupMember(String groupId, String targetUserId) {
|
||||||
ApiResponse<GroupView> response = request(
|
ApiResponse<GroupView> response = request(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
@ -880,6 +972,17 @@ public final class XuqmImServerSdk {
|
|||||||
return response.data();
|
return response.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public GroupView removeGroupMembers(String groupId, List<String> userIds) {
|
||||||
|
ApiResponse<GroupView> response = request(
|
||||||
|
"POST",
|
||||||
|
buildUri("/api/im/groups/" + encode(groupId) + "/members/batch/remove", appQuery()),
|
||||||
|
new MemberBatchRequest(userIds),
|
||||||
|
authorizedHeaders(),
|
||||||
|
new TypeReference<>() {}
|
||||||
|
);
|
||||||
|
return response.data();
|
||||||
|
}
|
||||||
|
|
||||||
public GroupView setGroupRole(String groupId, String userId, String role) {
|
public GroupView setGroupRole(String groupId, String userId, String role) {
|
||||||
ApiResponse<GroupView> response = request(
|
ApiResponse<GroupView> response = request(
|
||||||
"POST",
|
"POST",
|
||||||
@ -960,6 +1063,28 @@ public final class XuqmImServerSdk {
|
|||||||
return response.data();
|
return response.data();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<GroupJoinRequestView> acceptGroupJoinRequests(String groupId, List<String> requestIds) {
|
||||||
|
ApiResponse<List<GroupJoinRequestView>> response = request(
|
||||||
|
"POST",
|
||||||
|
buildUri("/api/im/groups/" + encode(groupId) + "/join-requests/batch/accept", appQuery()),
|
||||||
|
new RequestBatch(requestIds),
|
||||||
|
authorizedHeaders(),
|
||||||
|
new TypeReference<>() {}
|
||||||
|
);
|
||||||
|
return response.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<GroupJoinRequestView> rejectGroupJoinRequests(String groupId, List<String> requestIds) {
|
||||||
|
ApiResponse<List<GroupJoinRequestView>> response = request(
|
||||||
|
"POST",
|
||||||
|
buildUri("/api/im/groups/" + encode(groupId) + "/join-requests/batch/reject", appQuery()),
|
||||||
|
new RequestBatch(requestIds),
|
||||||
|
authorizedHeaders(),
|
||||||
|
new TypeReference<>() {}
|
||||||
|
);
|
||||||
|
return response.data();
|
||||||
|
}
|
||||||
|
|
||||||
public void setConversationPinned(String targetId, String chatType, boolean pinned) {
|
public void setConversationPinned(String targetId, String chatType, boolean pinned) {
|
||||||
Map<String, String> query = appQuery();
|
Map<String, String> query = appQuery();
|
||||||
query.put("chatType", chatType);
|
query.put("chatType", chatType);
|
||||||
@ -1107,6 +1232,14 @@ public final class XuqmImServerSdk {
|
|||||||
return value == null ? "" : value;
|
return value == null ? "" : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private <T> T readPayload(WebhookCallbackEnvelope envelope, Class<T> payloadType) {
|
||||||
|
try {
|
||||||
|
return objectMapper.treeToValue(envelope.payload(), payloadType);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new ImSdkException("Failed to parse callback payload", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String text(JsonNode node, String field) {
|
private static String text(JsonNode node, String field) {
|
||||||
JsonNode value = node == null ? null : node.get(field);
|
JsonNode value = node == null ? null : node.get(field);
|
||||||
return value == null || value.isNull() ? null : value.asText();
|
return value == null || value.isNull() ? null : value.asText();
|
||||||
@ -1201,6 +1334,39 @@ public final class XuqmImServerSdk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private <T> ApiResponse<T> multipartRequest(
|
||||||
|
String method,
|
||||||
|
URI uri,
|
||||||
|
Map<String, String> formFields,
|
||||||
|
Map<String, Path> files,
|
||||||
|
Map<String, String> headers,
|
||||||
|
TypeReference<ApiResponse<T>> responseType
|
||||||
|
) {
|
||||||
|
String boundary = "----XuqmBoundary" + UUID.randomUUID().toString().replace("-", "");
|
||||||
|
try {
|
||||||
|
HttpRequest.BodyPublisher bodyPublisher = ofMultipartData(boundary, formFields, files);
|
||||||
|
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(
|
private HttpRequest.BodyPublisher ofMultipartData(
|
||||||
String boundary,
|
String boundary,
|
||||||
Map<String, String> formFields,
|
Map<String, String> formFields,
|
||||||
@ -1227,6 +1393,34 @@ public final class XuqmImServerSdk {
|
|||||||
return HttpRequest.BodyPublishers.ofByteArrays(byteArrays);
|
return HttpRequest.BodyPublishers.ofByteArrays(byteArrays);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private HttpRequest.BodyPublisher ofMultipartData(
|
||||||
|
String boundary,
|
||||||
|
Map<String, String> formFields,
|
||||||
|
Map<String, Path> files
|
||||||
|
) 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Map.Entry<String, Path> entry : files.entrySet()) {
|
||||||
|
Path file = entry.getValue();
|
||||||
|
byteArrays.add(("--" + boundary + "\r\n").getBytes(charset));
|
||||||
|
byteArrays.add(("Content-Disposition: form-data; name=\"" + entry.getKey() + "\"; 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) {
|
private URI buildUri(String path, Map<String, String> query) {
|
||||||
StringBuilder builder = new StringBuilder(baseUrl).append(path);
|
StringBuilder builder = new StringBuilder(baseUrl).append(path);
|
||||||
if (query != null && !query.isEmpty()) {
|
if (query != null && !query.isEmpty()) {
|
||||||
@ -1417,6 +1611,8 @@ public final class XuqmImServerSdk {
|
|||||||
Instant createdAt
|
Instant createdAt
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public record FriendBatchRequest(List<String> friendIds) {}
|
||||||
|
|
||||||
public record FriendRequestView(
|
public record FriendRequestView(
|
||||||
String id,
|
String id,
|
||||||
String appId,
|
String appId,
|
||||||
@ -1509,7 +1705,23 @@ public final class XuqmImServerSdk {
|
|||||||
JsonNode payload,
|
JsonNode payload,
|
||||||
String signature,
|
String signature,
|
||||||
String appId
|
String appId
|
||||||
) {}
|
) {
|
||||||
|
public boolean isType(String type) {
|
||||||
|
return type != null && callbackType != null && callbackType.equalsIgnoreCase(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEvent(String event) {
|
||||||
|
return event != null && callbackEvent != null && callbackEvent.equalsIgnoreCase(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMessageEvent() {
|
||||||
|
return isType("message");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isReadReceiptEvent() {
|
||||||
|
return isEvent("message.read");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record AppVersionView(
|
public record AppVersionView(
|
||||||
String id,
|
String id,
|
||||||
@ -1624,6 +1836,46 @@ public final class XuqmImServerSdk {
|
|||||||
String note
|
String note
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public record MessageReadCallbackPayload(
|
||||||
|
String appId,
|
||||||
|
String readerId,
|
||||||
|
String peerId,
|
||||||
|
String groupId,
|
||||||
|
String chatType,
|
||||||
|
long readAt,
|
||||||
|
List<String> messageIds
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record UnifiedReleaseManifest(
|
||||||
|
List<AppUploadItem> appVersions,
|
||||||
|
List<RnBundleUploadItem> rnBundles
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record AppUploadItem(
|
||||||
|
String fileKey,
|
||||||
|
String platform,
|
||||||
|
String versionName,
|
||||||
|
int versionCode,
|
||||||
|
String changeLog,
|
||||||
|
boolean forceUpdate,
|
||||||
|
String appStoreUrl,
|
||||||
|
String marketUrl
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record RnBundleUploadItem(
|
||||||
|
String fileKey,
|
||||||
|
String moduleId,
|
||||||
|
String platform,
|
||||||
|
String version,
|
||||||
|
String minCommonVersion,
|
||||||
|
String note
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record UnifiedReleaseResult(
|
||||||
|
List<AppVersionView> appVersions,
|
||||||
|
List<RnBundleView> rnBundles
|
||||||
|
) {}
|
||||||
|
|
||||||
public record CreateGroupRequest(
|
public record CreateGroupRequest(
|
||||||
String name,
|
String name,
|
||||||
List<String> memberIds,
|
List<String> memberIds,
|
||||||
@ -1637,10 +1889,14 @@ public final class XuqmImServerSdk {
|
|||||||
|
|
||||||
public record MemberRequest(String userId) {}
|
public record MemberRequest(String userId) {}
|
||||||
|
|
||||||
|
public record MemberBatchRequest(List<String> userIds) {}
|
||||||
|
|
||||||
public record SetRoleRequest(String userId, String role) {}
|
public record SetRoleRequest(String userId, String role) {}
|
||||||
|
|
||||||
public record MuteMemberRequest(String userId, long minutes) {}
|
public record MuteMemberRequest(String userId, long minutes) {}
|
||||||
|
|
||||||
|
public record RequestBatch(List<String> requestIds) {}
|
||||||
|
|
||||||
public static final class ImSdkException extends RuntimeException {
|
public static final class ImSdkException extends RuntimeException {
|
||||||
public ImSdkException(String message) {
|
public ImSdkException(String message) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|||||||
@ -9,10 +9,13 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -41,7 +44,50 @@ public class FriendController {
|
|||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@RequestParam String friendId) {
|
@RequestParam String friendId) {
|
||||||
// Insert userId -> friendId if not already present
|
return ResponseEntity.ok(ApiResponse.success(addFriendLink(appId, userId, friendId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/batch")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImFriendEntity>>> addFriends(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody FriendBatchRequest req) {
|
||||||
|
List<ImFriendEntity> links = new ArrayList<>();
|
||||||
|
for (String friendId : unique(req.friendIds())) {
|
||||||
|
if (friendId == null || friendId.isBlank() || userId.equals(friendId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
links.add(addFriendLink(appId, userId, friendId));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(links));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{friendId}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> removeFriend(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String friendId,
|
||||||
|
@RequestParam String appId) {
|
||||||
|
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, userId, friendId);
|
||||||
|
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, friendId, userId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/batch/remove")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> removeFriends(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody FriendBatchRequest req) {
|
||||||
|
for (String friendId : unique(req.friendIds())) {
|
||||||
|
if (friendId == null || friendId.isBlank() || userId.equals(friendId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, userId, friendId);
|
||||||
|
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, friendId, userId);
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImFriendEntity addFriendLink(String appId, String userId, String friendId) {
|
||||||
ImFriendEntity forward = friendRepository
|
ImFriendEntity forward = friendRepository
|
||||||
.findByAppIdAndUserIdAndFriendId(appId, userId, friendId)
|
.findByAppIdAndUserIdAndFriendId(appId, userId, friendId)
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
@ -52,7 +98,6 @@ public class FriendController {
|
|||||||
return friendRepository.save(e);
|
return friendRepository.save(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert friendId -> userId bi-directionally if not already present
|
|
||||||
friendRepository.findByAppIdAndUserIdAndFriendId(appId, friendId, userId)
|
friendRepository.findByAppIdAndUserIdAndFriendId(appId, friendId, userId)
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
ImFriendEntity e = new ImFriendEntity();
|
ImFriendEntity e = new ImFriendEntity();
|
||||||
@ -61,18 +106,12 @@ public class FriendController {
|
|||||||
e.setFriendId(userId);
|
e.setFriendId(userId);
|
||||||
return friendRepository.save(e);
|
return friendRepository.save(e);
|
||||||
});
|
});
|
||||||
|
return forward;
|
||||||
return ResponseEntity.ok(ApiResponse.success(forward));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{friendId}")
|
private List<String> unique(List<String> friendIds) {
|
||||||
public ResponseEntity<ApiResponse<Void>> removeFriend(
|
return friendIds == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(friendIds));
|
||||||
@AuthenticationPrincipal String userId,
|
|
||||||
@PathVariable String friendId,
|
|
||||||
@RequestParam String appId) {
|
|
||||||
// Remove both directions
|
|
||||||
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, userId, friendId);
|
|
||||||
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, friendId, userId);
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(null));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record FriendBatchRequest(List<String> friendIds) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,4 +59,22 @@ public class FriendRequestController {
|
|||||||
@PathVariable String requestId) {
|
@PathVariable String requestId) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(friendRequestService.reject(appId, requestId, userId)));
|
return ResponseEntity.ok(ApiResponse.success(friendRequestService.reject(appId, requestId, userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/batch/accept")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImFriendRequestEntity>>> acceptBatch(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody BatchRequest req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(friendRequestService.acceptBatch(appId, req.requestIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/batch/reject")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImFriendRequestEntity>>> rejectBatch(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody BatchRequest req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(friendRequestService.rejectBatch(appId, req.requestIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BatchRequest(List<String> requestIds) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,6 +75,14 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(groupService.addMember(groupId, req.userId(), userId)));
|
return ResponseEntity.ok(ApiResponse.success(groupService.addMember(groupId, req.userId(), userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{groupId}/members/batch")
|
||||||
|
public ResponseEntity<ApiResponse<ImGroupEntity>> addMembers(
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@RequestBody MemberBatchRequest req,
|
||||||
|
@AuthenticationPrincipal String userId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(groupService.addMembers(groupId, req.userIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{groupId}/members/{targetUserId}")
|
@DeleteMapping("/{groupId}/members/{targetUserId}")
|
||||||
public ResponseEntity<ApiResponse<ImGroupEntity>> removeMember(
|
public ResponseEntity<ApiResponse<ImGroupEntity>> removeMember(
|
||||||
@PathVariable String groupId,
|
@PathVariable String groupId,
|
||||||
@ -83,6 +91,14 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(groupService.removeMember(groupId, targetUserId, userId)));
|
return ResponseEntity.ok(ApiResponse.success(groupService.removeMember(groupId, targetUserId, userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{groupId}/members/batch/remove")
|
||||||
|
public ResponseEntity<ApiResponse<ImGroupEntity>> removeMembers(
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@RequestBody MemberBatchRequest req,
|
||||||
|
@AuthenticationPrincipal String userId) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(groupService.removeMembers(groupId, req.userIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{groupId}/roles")
|
@PostMapping("/{groupId}/roles")
|
||||||
public ResponseEntity<ApiResponse<ImGroupEntity>> setRole(
|
public ResponseEntity<ApiResponse<ImGroupEntity>> setRole(
|
||||||
@PathVariable String groupId,
|
@PathVariable String groupId,
|
||||||
@ -144,9 +160,31 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(groupService.rejectJoinRequest(appId, requestId, userId)));
|
return ResponseEntity.ok(ApiResponse.success(groupService.rejectJoinRequest(appId, requestId, userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{groupId}/join-requests/batch/accept")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImGroupJoinRequestEntity>>> acceptJoinRequests(
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody RequestBatch req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
groupService.acceptJoinRequests(appId, groupId, req.requestIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{groupId}/join-requests/batch/reject")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImGroupJoinRequestEntity>>> rejectJoinRequests(
|
||||||
|
@PathVariable String groupId,
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestBody RequestBatch req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
|
groupService.rejectJoinRequests(appId, groupId, req.requestIds(), userId)));
|
||||||
|
}
|
||||||
|
|
||||||
public record CreateGroupRequest(String name, List<String> memberIds, String groupType) {}
|
public record CreateGroupRequest(String name, List<String> memberIds, String groupType) {}
|
||||||
public record UpdateGroupRequest(String name, String announcement) {}
|
public record UpdateGroupRequest(String name, String announcement) {}
|
||||||
public record MemberRequest(String userId) {}
|
public record MemberRequest(String userId) {}
|
||||||
|
public record MemberBatchRequest(List<String> userIds) {}
|
||||||
public record SetRoleRequest(String userId, String role) {}
|
public record SetRoleRequest(String userId, String role) {}
|
||||||
public record MuteMemberRequest(String userId, long minutes) {}
|
public record MuteMemberRequest(String userId, long minutes) {}
|
||||||
|
public record RequestBatch(List<String> requestIds) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.xuqm.im.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record MessageReadCallbackPayload(
|
||||||
|
String appId,
|
||||||
|
String readerId,
|
||||||
|
String peerId,
|
||||||
|
String groupId,
|
||||||
|
String chatType,
|
||||||
|
long readAt,
|
||||||
|
List<String> messageIds
|
||||||
|
) {}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.xuqm.im.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
|
public record WebhookCallbackEnvelope(
|
||||||
|
String callbackId,
|
||||||
|
String callbackType,
|
||||||
|
String callbackEvent,
|
||||||
|
long requestTime,
|
||||||
|
JsonNode payload,
|
||||||
|
String signature,
|
||||||
|
String appId
|
||||||
|
) {}
|
||||||
@ -105,6 +105,24 @@ public class FriendRequestService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ImFriendRequestEntity> acceptBatch(String appId, List<String> requestIds, String operatorId) {
|
||||||
|
List<ImFriendRequestEntity> result = new java.util.ArrayList<>();
|
||||||
|
for (String requestId : unique(requestIds)) {
|
||||||
|
result.add(acceptInternal(appId, requestId, operatorId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ImFriendRequestEntity> rejectBatch(String appId, List<String> requestIds, String operatorId) {
|
||||||
|
List<ImFriendRequestEntity> result = new java.util.ArrayList<>();
|
||||||
|
for (String requestId : unique(requestIds)) {
|
||||||
|
result.add(rejectInternal(appId, requestId, operatorId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public List<ImFriendRequestEntity> incoming(String appId, String userId) {
|
public List<ImFriendRequestEntity> incoming(String appId, String userId) {
|
||||||
return requestRepository.findByAppIdAndToUserId(appId, userId).stream()
|
return requestRepository.findByAppIdAndToUserId(appId, userId).stream()
|
||||||
.filter(request -> ImFriendRequestEntity.Status.PENDING.name().equals(request.getStatus()))
|
.filter(request -> ImFriendRequestEntity.Status.PENDING.name().equals(request.getStatus()))
|
||||||
@ -124,6 +142,44 @@ public class FriendRequestService {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ImFriendRequestEntity acceptInternal(String appId, String requestId, String operatorId) {
|
||||||
|
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
|
||||||
|
request.setStatus(ImFriendRequestEntity.Status.ACCEPTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImFriendRequestEntity saved = requestRepository.save(request);
|
||||||
|
friendRepository
|
||||||
|
.findByAppIdAndUserIdAndFriendId(appId, request.getFromUserId(), request.getToUserId())
|
||||||
|
.orElseGet(() -> friendEntity(appId, request.getFromUserId(), request.getToUserId()));
|
||||||
|
friendRepository
|
||||||
|
.findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId())
|
||||||
|
.orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId()));
|
||||||
|
publishNotification(
|
||||||
|
request,
|
||||||
|
request.getToUserId(),
|
||||||
|
request.getFromUserId(),
|
||||||
|
"FRIEND_REQUEST_STATUS",
|
||||||
|
"好友申请已通过",
|
||||||
|
buildDescription("好友申请已通过", request.getRemark())
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImFriendRequestEntity rejectInternal(String appId, String requestId, String operatorId) {
|
||||||
|
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
|
||||||
|
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImFriendRequestEntity saved = requestRepository.save(request);
|
||||||
|
publishNotification(
|
||||||
|
saved,
|
||||||
|
saved.getToUserId(),
|
||||||
|
saved.getFromUserId(),
|
||||||
|
"FRIEND_REQUEST_STATUS",
|
||||||
|
"好友申请已拒绝",
|
||||||
|
buildDescription("好友申请已拒绝", saved.getRemark())
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
private com.xuqm.im.entity.ImFriendEntity friendEntity(String appId, String userId, String friendId) {
|
private com.xuqm.im.entity.ImFriendEntity friendEntity(String appId, String userId, String friendId) {
|
||||||
com.xuqm.im.entity.ImFriendEntity entity = new com.xuqm.im.entity.ImFriendEntity();
|
com.xuqm.im.entity.ImFriendEntity entity = new com.xuqm.im.entity.ImFriendEntity();
|
||||||
entity.setAppId(appId);
|
entity.setAppId(appId);
|
||||||
@ -132,6 +188,10 @@ public class FriendRequestService {
|
|||||||
return friendRepository.save(entity);
|
return friendRepository.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<String> unique(List<String> requestIds) {
|
||||||
|
return requestIds == null ? List.of() : new java.util.ArrayList<>(new java.util.LinkedHashSet<>(requestIds));
|
||||||
|
}
|
||||||
|
|
||||||
private void publishNotification(
|
private void publishNotification(
|
||||||
ImFriendRequestEntity request,
|
ImFriendRequestEntity request,
|
||||||
String fromUserId,
|
String fromUserId,
|
||||||
|
|||||||
@ -92,6 +92,28 @@ public class ImGroupService {
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupEntity addMembers(String groupId, List<String> userIds, String operatorId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureCanManage(group, operatorId);
|
||||||
|
List<String> members = new ArrayList<>(fromJson(group.getMemberIds()));
|
||||||
|
boolean changed = false;
|
||||||
|
for (String userId : userIds == null ? List.<String>of() : userIds) {
|
||||||
|
if (userId == null || userId.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!members.contains(userId)) {
|
||||||
|
members.add(userId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
group.setMemberIds(toJson(members));
|
||||||
|
return groupRepository.save(group);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ImGroupEntity removeMember(String groupId, String userId, String operatorId) {
|
public ImGroupEntity removeMember(String groupId, String userId, String operatorId) {
|
||||||
ImGroupEntity group = get(groupId);
|
ImGroupEntity group = get(groupId);
|
||||||
@ -105,6 +127,28 @@ public class ImGroupService {
|
|||||||
return groupRepository.save(group);
|
return groupRepository.save(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ImGroupEntity removeMembers(String groupId, List<String> userIds, String operatorId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
List<String> admins = fromJson(group.getAdminIds());
|
||||||
|
if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) {
|
||||||
|
throw new BusinessException(403, "无权操作");
|
||||||
|
}
|
||||||
|
List<String> members = new ArrayList<>(fromJson(group.getMemberIds()));
|
||||||
|
boolean changed = false;
|
||||||
|
for (String userId : userIds == null ? List.<String>of() : userIds) {
|
||||||
|
if (userId == null || userId.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
changed |= members.remove(userId);
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
group.setMemberIds(toJson(members));
|
||||||
|
return groupRepository.save(group);
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ImGroupEntity update(String groupId, String operatorId, String name, String announcement) {
|
public ImGroupEntity update(String groupId, String operatorId, String name, String announcement) {
|
||||||
ImGroupEntity group = get(groupId);
|
ImGroupEntity group = get(groupId);
|
||||||
@ -288,6 +332,28 @@ public class ImGroupService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ImGroupJoinRequestEntity> acceptJoinRequests(String appId, String groupId, List<String> requestIds, String operatorId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureCanManage(group, operatorId);
|
||||||
|
List<ImGroupJoinRequestEntity> result = new ArrayList<>();
|
||||||
|
for (String requestId : unique(requestIds)) {
|
||||||
|
result.add(acceptJoinRequestInternal(appId, group, requestId, operatorId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<ImGroupJoinRequestEntity> rejectJoinRequests(String appId, String groupId, List<String> requestIds, String operatorId) {
|
||||||
|
ImGroupEntity group = get(groupId);
|
||||||
|
ensureCanManage(group, operatorId);
|
||||||
|
List<ImGroupJoinRequestEntity> result = new ArrayList<>();
|
||||||
|
for (String requestId : unique(requestIds)) {
|
||||||
|
result.add(rejectJoinRequestInternal(appId, group, requestId, operatorId));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private String toJson(List<String> list) {
|
private String toJson(List<String> list) {
|
||||||
try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; }
|
try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; }
|
||||||
}
|
}
|
||||||
@ -382,7 +448,54 @@ public class ImGroupService {
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ImGroupJoinRequestEntity acceptJoinRequestInternal(String appId, ImGroupEntity group, String requestId, String operatorId) {
|
||||||
|
ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId);
|
||||||
|
if (!group.getId().equals(request.getGroupId())) {
|
||||||
|
throw new BusinessException(400, "加群申请不属于当前群");
|
||||||
|
}
|
||||||
|
ensureCanManage(group, operatorId);
|
||||||
|
request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
|
addMemberInternal(group, request.getRequesterId());
|
||||||
|
publishJoinRequestNotification(
|
||||||
|
group,
|
||||||
|
operatorId,
|
||||||
|
List.of(request.getRequesterId()),
|
||||||
|
"GROUP_JOIN_REQUEST_STATUS",
|
||||||
|
"入群申请已通过",
|
||||||
|
buildDescription("入群申请已通过", null),
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImGroupJoinRequestEntity rejectJoinRequestInternal(String appId, ImGroupEntity group, String requestId, String operatorId) {
|
||||||
|
ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId);
|
||||||
|
if (!group.getId().equals(request.getGroupId())) {
|
||||||
|
throw new BusinessException(400, "加群申请不属于当前群");
|
||||||
|
}
|
||||||
|
ensureCanManage(group, operatorId);
|
||||||
|
request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name());
|
||||||
|
request.setReviewedAt(LocalDateTime.now());
|
||||||
|
ImGroupJoinRequestEntity saved = joinRequestRepository.save(request);
|
||||||
|
publishJoinRequestNotification(
|
||||||
|
group,
|
||||||
|
operatorId,
|
||||||
|
List.of(request.getRequesterId()),
|
||||||
|
"GROUP_JOIN_REQUEST_STATUS",
|
||||||
|
"入群申请已拒绝",
|
||||||
|
buildDescription("入群申请已拒绝", null),
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
private String normalizeGroupType(String groupType) {
|
private String normalizeGroupType(String groupType) {
|
||||||
return (groupType == null || groupType.isBlank()) ? "WORK" : groupType.trim().toUpperCase();
|
return (groupType == null || groupType.isBlank()) ? "WORK" : groupType.trim().toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<String> unique(List<String> values) {
|
||||||
|
return values == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(values));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,13 +8,14 @@ import com.xuqm.im.entity.ImMessageEntity;
|
|||||||
import com.xuqm.im.entity.WebhookConfigEntity;
|
import com.xuqm.im.entity.WebhookConfigEntity;
|
||||||
import com.xuqm.im.model.ConversationView;
|
import com.xuqm.im.model.ConversationView;
|
||||||
import com.xuqm.im.model.EditMessageRequest;
|
import com.xuqm.im.model.EditMessageRequest;
|
||||||
|
import com.xuqm.im.model.MessageReadCallbackPayload;
|
||||||
import com.xuqm.im.model.SendMessageRequest;
|
import com.xuqm.im.model.SendMessageRequest;
|
||||||
|
import com.xuqm.im.model.WebhookCallbackEnvelope;
|
||||||
import com.xuqm.im.repository.ImFriendRepository;
|
import com.xuqm.im.repository.ImFriendRepository;
|
||||||
import com.xuqm.im.repository.WebhookConfigRepository;
|
import com.xuqm.im.repository.WebhookConfigRepository;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@ -23,13 +24,19 @@ import java.net.URI;
|
|||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import com.xuqm.im.repository.ImMessageRepository;
|
import com.xuqm.im.repository.ImMessageRepository;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MessageService {
|
public class MessageService {
|
||||||
@ -47,6 +54,7 @@ public class MessageService {
|
|||||||
private final ImPushBridgeClient pushBridgeClient;
|
private final ImPushBridgeClient pushBridgeClient;
|
||||||
private final ImFeatureConfigClient featureConfigClient;
|
private final ImFeatureConfigClient featureConfigClient;
|
||||||
private final ImFriendRepository friendRepository;
|
private final ImFriendRepository friendRepository;
|
||||||
|
private final ImAppSecretClient appSecretClient;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Value("${im.webhook-timeout-ms:3000}")
|
@Value("${im.webhook-timeout-ms:3000}")
|
||||||
@ -63,6 +71,7 @@ public class MessageService {
|
|||||||
ImPushBridgeClient pushBridgeClient,
|
ImPushBridgeClient pushBridgeClient,
|
||||||
ImFeatureConfigClient featureConfigClient,
|
ImFeatureConfigClient featureConfigClient,
|
||||||
ImFriendRepository friendRepository,
|
ImFriendRepository friendRepository,
|
||||||
|
ImAppSecretClient appSecretClient,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.webhookRepository = webhookRepository;
|
this.webhookRepository = webhookRepository;
|
||||||
@ -75,6 +84,7 @@ public class MessageService {
|
|||||||
this.pushBridgeClient = pushBridgeClient;
|
this.pushBridgeClient = pushBridgeClient;
|
||||||
this.featureConfigClient = featureConfigClient;
|
this.featureConfigClient = featureConfigClient;
|
||||||
this.friendRepository = friendRepository;
|
this.friendRepository = friendRepository;
|
||||||
|
this.appSecretClient = appSecretClient;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -155,7 +165,7 @@ public class MessageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchWebhooks(appId, saved);
|
dispatchWebhooks(appId, "message.sent", saved);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,6 +198,7 @@ public class MessageService {
|
|||||||
log.debug("revoke group messageId={} groupId={}", saved.getId(), saved.getToId());
|
log.debug("revoke group messageId={} groupId={}", saved.getId(), saved.getToId());
|
||||||
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
|
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
|
||||||
}
|
}
|
||||||
|
dispatchWebhooks(appId, "message.revoked", saved);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,7 +253,7 @@ public class MessageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchWebhooks(appId, saved);
|
dispatchWebhooks(appId, "message.edited", saved);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,6 +277,7 @@ public class MessageService {
|
|||||||
log.debug("admin revoke group messageId={} groupId={}", saved.getId(), saved.getToId());
|
log.debug("admin revoke group messageId={} groupId={}", saved.getId(), saved.getToId());
|
||||||
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
|
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
|
||||||
}
|
}
|
||||||
|
dispatchWebhooks(appId, "message.revoked", saved);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -314,14 +326,26 @@ public class MessageService {
|
|||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
List<String> messageIds = new java.util.ArrayList<>();
|
||||||
for (ImMessageEntity message : messages) {
|
for (ImMessageEntity message : messages) {
|
||||||
if (message.getStatus() == ImMessageEntity.MsgStatus.READ) {
|
if (message.getStatus() == ImMessageEntity.MsgStatus.READ) {
|
||||||
|
messageIds.add(message.getId());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
message.setStatus(ImMessageEntity.MsgStatus.READ);
|
message.setStatus(ImMessageEntity.MsgStatus.READ);
|
||||||
ImMessageEntity saved = messageRepository.save(message);
|
ImMessageEntity saved = messageRepository.save(message);
|
||||||
clusterPublisher.publish("/user/" + peerId + "/queue/messages", saved);
|
clusterPublisher.publish("/user/" + peerId + "/queue/messages", saved);
|
||||||
|
messageIds.add(saved.getId());
|
||||||
}
|
}
|
||||||
|
dispatchWebhooks(appId, "message.read", new MessageReadCallbackPayload(
|
||||||
|
appId,
|
||||||
|
readerId,
|
||||||
|
peerId,
|
||||||
|
null,
|
||||||
|
chatType,
|
||||||
|
toEpochMillis(readAt),
|
||||||
|
messageIds
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void syncGroupReadReceipt(String appId, String readerId, String groupId, LocalDateTime readAt) {
|
public void syncGroupReadReceipt(String appId, String readerId, String groupId, LocalDateTime readAt) {
|
||||||
@ -335,10 +359,21 @@ public class MessageService {
|
|||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
List<String> messageIds = new java.util.ArrayList<>();
|
||||||
for (ImMessageEntity message : messages) {
|
for (ImMessageEntity message : messages) {
|
||||||
message.setGroupReadCount(groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()));
|
message.setGroupReadCount(groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()));
|
||||||
clusterPublisher.publish("/topic/group/" + groupId, message);
|
clusterPublisher.publish("/topic/group/" + groupId, message);
|
||||||
|
messageIds.add(message.getId());
|
||||||
}
|
}
|
||||||
|
dispatchWebhooks(appId, "message.read", new MessageReadCallbackPayload(
|
||||||
|
appId,
|
||||||
|
readerId,
|
||||||
|
null,
|
||||||
|
groupId,
|
||||||
|
ImMessageEntity.ChatType.GROUP.name(),
|
||||||
|
toEpochMillis(readAt),
|
||||||
|
messageIds
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Page<ImMessageEntity> adminHistory(
|
public Page<ImMessageEntity> adminHistory(
|
||||||
@ -487,26 +522,74 @@ public class MessageService {
|
|||||||
return java.util.Optional.empty();
|
return java.util.Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Async
|
protected void dispatchWebhooks(String appId, String callbackEvent, ImMessageEntity message) {
|
||||||
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
|
dispatchWebhooks(appId, callbackEvent, (Object) message);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void dispatchWebhooks(String appId, String callbackEvent, Object payload) {
|
||||||
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
||||||
if (webhooks.isEmpty()) return;
|
if (webhooks.isEmpty()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String body = objectMapper.writeValueAsString(message);
|
String appSecret = appSecretClient.getAppSecret(appId);
|
||||||
|
long requestTime = System.currentTimeMillis();
|
||||||
|
String nonce = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
String callbackId = UUID.randomUUID().toString();
|
||||||
|
WebhookCallbackEnvelope envelope = new WebhookCallbackEnvelope(
|
||||||
|
callbackId,
|
||||||
|
"message",
|
||||||
|
callbackEvent,
|
||||||
|
requestTime,
|
||||||
|
objectMapper.valueToTree(payload),
|
||||||
|
null,
|
||||||
|
appId
|
||||||
|
);
|
||||||
|
String body = objectMapper.writeValueAsString(envelope);
|
||||||
|
String signature = signWebhook(appId, appSecret, requestTime, nonce, body);
|
||||||
HttpClient client = HttpClient.newHttpClient();
|
HttpClient client = HttpClient.newHttpClient();
|
||||||
for (WebhookConfigEntity webhook : webhooks) {
|
for (WebhookConfigEntity webhook : webhooks) {
|
||||||
try {
|
try {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(webhook.getUrl()))
|
.uri(URI.create(webhook.getUrl()))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-App-Id", appId)
|
||||||
|
.header("X-App-Timestamp", String.valueOf(requestTime))
|
||||||
|
.header("X-App-Nonce", nonce)
|
||||||
|
.header("X-App-Signature", signature)
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
.build();
|
.build();
|
||||||
client.send(request, HttpResponse.BodyHandlers.ofString());
|
client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
} catch (Exception ignored) {
|
} catch (Exception e) {
|
||||||
|
log.warn("dispatch webhook failed appId={} url={} event={} reason={}",
|
||||||
|
appId, webhook.getUrl(), callbackEvent, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} catch (Exception e) {
|
||||||
|
log.warn("prepare webhook failed appId={} event={} reason={}", appId, callbackEvent, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) {
|
||||||
|
String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body);
|
||||||
|
return hmacSha256Hex(appSecret, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256Hex(String value) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("Failed to hash webhook body", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hmacSha256Hex(String secret, String payload) {
|
||||||
|
try {
|
||||||
|
Mac mac = Mac.getInstance("HmacSHA256");
|
||||||
|
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||||
|
return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to sign webhook body", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,35 +3,27 @@ package com.xuqm.update.controller;
|
|||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.update.entity.AppVersionEntity;
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
import com.xuqm.update.repository.AppVersionRepository;
|
import com.xuqm.update.repository.AppVersionRepository;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import com.xuqm.update.service.UpdateAssetService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/updates")
|
@RequestMapping("/api/v1/updates")
|
||||||
public class AppVersionController {
|
public class AppVersionController {
|
||||||
|
|
||||||
private final AppVersionRepository versionRepository;
|
private final AppVersionRepository versionRepository;
|
||||||
|
private final UpdateAssetService updateAssetService;
|
||||||
|
|
||||||
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
public AppVersionController(AppVersionRepository versionRepository, UpdateAssetService updateAssetService) {
|
||||||
private String uploadDir;
|
|
||||||
|
|
||||||
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
|
||||||
private String baseUrl;
|
|
||||||
|
|
||||||
public AppVersionController(AppVersionRepository versionRepository) {
|
|
||||||
this.versionRepository = versionRepository;
|
this.versionRepository = versionRepository;
|
||||||
|
this.updateAssetService = updateAssetService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/app/check")
|
@GetMapping("/app/check")
|
||||||
@ -69,17 +61,7 @@ public class AppVersionController {
|
|||||||
@RequestParam int versionCode,
|
@RequestParam int versionCode,
|
||||||
@RequestParam(required = false) String changeLog,
|
@RequestParam(required = false) String changeLog,
|
||||||
@RequestParam(defaultValue = "false") boolean forceUpdate,
|
@RequestParam(defaultValue = "false") boolean forceUpdate,
|
||||||
@RequestParam(required = false) MultipartFile apkFile) throws IOException {
|
@RequestParam(required = false) MultipartFile apkFile) throws Exception {
|
||||||
|
|
||||||
String downloadUrl = null;
|
|
||||||
if (apkFile != null && !apkFile.isEmpty()) {
|
|
||||||
String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename();
|
|
||||||
Path dir = Paths.get(uploadDir, "apk");
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
Path dest = dir.resolve(filename);
|
|
||||||
apkFile.transferTo(dest.toFile());
|
|
||||||
downloadUrl = baseUrl + "/files/apk/" + filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
AppVersionEntity entity = new AppVersionEntity();
|
AppVersionEntity entity = new AppVersionEntity();
|
||||||
entity.setId(UUID.randomUUID().toString());
|
entity.setId(UUID.randomUUID().toString());
|
||||||
@ -87,7 +69,7 @@ public class AppVersionController {
|
|||||||
entity.setPlatform(platform);
|
entity.setPlatform(platform);
|
||||||
entity.setVersionName(versionName);
|
entity.setVersionName(versionName);
|
||||||
entity.setVersionCode(versionCode);
|
entity.setVersionCode(versionCode);
|
||||||
entity.setDownloadUrl(downloadUrl);
|
entity.setDownloadUrl(updateAssetService.storeAppPackage(apkFile));
|
||||||
entity.setChangeLog(changeLog);
|
entity.setChangeLog(changeLog);
|
||||||
entity.setForceUpdate(forceUpdate);
|
entity.setForceUpdate(forceUpdate);
|
||||||
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
||||||
|
|||||||
@ -8,33 +8,26 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.security.DigestInputStream;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import com.xuqm.update.service.UpdateAssetService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/rn")
|
@RequestMapping("/api/v1/rn")
|
||||||
public class RnBundleController {
|
public class RnBundleController {
|
||||||
|
|
||||||
private final RnBundleRepository bundleRepository;
|
private final RnBundleRepository bundleRepository;
|
||||||
|
private final UpdateAssetService updateAssetService;
|
||||||
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
|
||||||
private String uploadDir;
|
|
||||||
|
|
||||||
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
||||||
public RnBundleController(RnBundleRepository bundleRepository) {
|
public RnBundleController(RnBundleRepository bundleRepository, UpdateAssetService updateAssetService) {
|
||||||
this.bundleRepository = bundleRepository;
|
this.bundleRepository = bundleRepository;
|
||||||
|
this.updateAssetService = updateAssetService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/update/check")
|
@GetMapping("/update/check")
|
||||||
@ -74,14 +67,8 @@ public class RnBundleController {
|
|||||||
@RequestParam(required = false) String minCommonVersion,
|
@RequestParam(required = false) String minCommonVersion,
|
||||||
@RequestParam(required = false) String note,
|
@RequestParam(required = false) String note,
|
||||||
@RequestParam MultipartFile bundle) throws Exception {
|
@RequestParam MultipartFile bundle) throws Exception {
|
||||||
|
UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle(
|
||||||
String filename = moduleId + "." + platform.name().toLowerCase() + ".bundle";
|
appId, platform.name(), moduleId, bundle);
|
||||||
Path dir = Paths.get(uploadDir, "rn", appId, platform.name().toLowerCase(), moduleId);
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
Path dest = dir.resolve(filename);
|
|
||||||
|
|
||||||
String md5 = computeMd5(bundle);
|
|
||||||
bundle.transferTo(dest.toFile());
|
|
||||||
|
|
||||||
RnBundleEntity entity = new RnBundleEntity();
|
RnBundleEntity entity = new RnBundleEntity();
|
||||||
entity.setId(UUID.randomUUID().toString());
|
entity.setId(UUID.randomUUID().toString());
|
||||||
@ -89,8 +76,8 @@ public class RnBundleController {
|
|||||||
entity.setModuleId(moduleId);
|
entity.setModuleId(moduleId);
|
||||||
entity.setPlatform(platform);
|
entity.setPlatform(platform);
|
||||||
entity.setVersion(version);
|
entity.setVersion(version);
|
||||||
entity.setBundleUrl(dest.toAbsolutePath().toString());
|
entity.setBundleUrl(stored.bundlePath());
|
||||||
entity.setMd5(md5);
|
entity.setMd5(stored.md5());
|
||||||
entity.setMinCommonVersion(minCommonVersion);
|
entity.setMinCommonVersion(minCommonVersion);
|
||||||
entity.setNote(note);
|
entity.setNote(note);
|
||||||
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
||||||
@ -142,15 +129,6 @@ public class RnBundleController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
|
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String computeMd5(MultipartFile file) throws Exception {
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("MD5");
|
|
||||||
try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) {
|
|
||||||
byte[] buf = new byte[8192];
|
|
||||||
while (dis.read(buf) != -1) {}
|
|
||||||
}
|
|
||||||
return HexFormat.of().formatHex(digest.digest());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolvePublicBaseUrl() {
|
private String resolvePublicBaseUrl() {
|
||||||
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||||
String suffix = "/api/v1/updates";
|
String suffix = "/api/v1/updates";
|
||||||
|
|||||||
@ -0,0 +1,108 @@
|
|||||||
|
package com.xuqm.update.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
|
import com.xuqm.update.entity.RnBundleEntity;
|
||||||
|
import com.xuqm.update.model.UnifiedReleaseManifest;
|
||||||
|
import com.xuqm.update.model.UnifiedReleaseResult;
|
||||||
|
import com.xuqm.update.repository.AppVersionRepository;
|
||||||
|
import com.xuqm.update.repository.RnBundleRepository;
|
||||||
|
import com.xuqm.update.service.UpdateAssetService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/updates")
|
||||||
|
public class UnifiedReleaseController {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AppVersionRepository appVersionRepository;
|
||||||
|
private final RnBundleRepository rnBundleRepository;
|
||||||
|
private final UpdateAssetService updateAssetService;
|
||||||
|
|
||||||
|
public UnifiedReleaseController(
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
AppVersionRepository appVersionRepository,
|
||||||
|
RnBundleRepository rnBundleRepository,
|
||||||
|
UpdateAssetService updateAssetService) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.appVersionRepository = appVersionRepository;
|
||||||
|
this.rnBundleRepository = rnBundleRepository;
|
||||||
|
this.updateAssetService = updateAssetService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/unified/upload")
|
||||||
|
public ResponseEntity<ApiResponse<UnifiedReleaseResult>> upload(
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestParam String manifest,
|
||||||
|
HttpServletRequest request) throws Exception {
|
||||||
|
|
||||||
|
if (!(request instanceof MultipartHttpServletRequest multipartRequest)) {
|
||||||
|
throw new IllegalArgumentException("multipart request required");
|
||||||
|
}
|
||||||
|
|
||||||
|
UnifiedReleaseManifest unifiedReleaseManifest =
|
||||||
|
objectMapper.readValue(manifest, UnifiedReleaseManifest.class);
|
||||||
|
|
||||||
|
List<AppVersionEntity> appVersions = new ArrayList<>();
|
||||||
|
for (UnifiedReleaseManifest.AppUploadItem item : safeList(unifiedReleaseManifest.appVersions())) {
|
||||||
|
MultipartFile file = multipartRequest.getFile(item.fileKey());
|
||||||
|
AppVersionEntity entity = new AppVersionEntity();
|
||||||
|
entity.setId(UUID.randomUUID().toString());
|
||||||
|
entity.setAppId(appId);
|
||||||
|
entity.setPlatform(item.platform());
|
||||||
|
entity.setVersionName(item.versionName());
|
||||||
|
entity.setVersionCode(item.versionCode());
|
||||||
|
entity.setChangeLog(item.changeLog());
|
||||||
|
entity.setForceUpdate(item.forceUpdate());
|
||||||
|
entity.setAppStoreUrl(item.appStoreUrl());
|
||||||
|
entity.setMarketUrl(item.marketUrl());
|
||||||
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
entity.setDownloadUrl(updateAssetService.storeAppPackage(file));
|
||||||
|
appVersions.add(appVersionRepository.save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RnBundleEntity> rnBundles = new ArrayList<>();
|
||||||
|
for (UnifiedReleaseManifest.RnBundleUploadItem item : safeList(unifiedReleaseManifest.rnBundles())) {
|
||||||
|
MultipartFile file = multipartRequest.getFile(item.fileKey());
|
||||||
|
UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle(
|
||||||
|
appId,
|
||||||
|
item.platform().name(),
|
||||||
|
item.moduleId(),
|
||||||
|
file);
|
||||||
|
|
||||||
|
RnBundleEntity entity = new RnBundleEntity();
|
||||||
|
entity.setId(UUID.randomUUID().toString());
|
||||||
|
entity.setAppId(appId);
|
||||||
|
entity.setModuleId(item.moduleId());
|
||||||
|
entity.setPlatform(item.platform());
|
||||||
|
entity.setVersion(item.version());
|
||||||
|
entity.setBundleUrl(stored.bundlePath());
|
||||||
|
entity.setMd5(stored.md5());
|
||||||
|
entity.setMinCommonVersion(item.minCommonVersion());
|
||||||
|
entity.setNote(item.note());
|
||||||
|
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
rnBundles.add(rnBundleRepository.save(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(new UnifiedReleaseResult(appVersions, rnBundles)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> List<T> safeList(List<T> input) {
|
||||||
|
return input == null ? List.of() : input;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.xuqm.update.model;
|
||||||
|
|
||||||
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
|
import com.xuqm.update.entity.RnBundleEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record UnifiedReleaseManifest(
|
||||||
|
List<AppUploadItem> appVersions,
|
||||||
|
List<RnBundleUploadItem> rnBundles) {
|
||||||
|
|
||||||
|
public record AppUploadItem(
|
||||||
|
String fileKey,
|
||||||
|
AppVersionEntity.Platform platform,
|
||||||
|
String versionName,
|
||||||
|
int versionCode,
|
||||||
|
String changeLog,
|
||||||
|
boolean forceUpdate,
|
||||||
|
String appStoreUrl,
|
||||||
|
String marketUrl) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RnBundleUploadItem(
|
||||||
|
String fileKey,
|
||||||
|
String moduleId,
|
||||||
|
RnBundleEntity.Platform platform,
|
||||||
|
String version,
|
||||||
|
String minCommonVersion,
|
||||||
|
String note) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.xuqm.update.model;
|
||||||
|
|
||||||
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
|
import com.xuqm.update.entity.RnBundleEntity;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record UnifiedReleaseResult(
|
||||||
|
List<AppVersionEntity> appVersions,
|
||||||
|
List<RnBundleEntity> rnBundles) {
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package com.xuqm.update.service;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.security.DigestInputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UpdateAssetService {
|
||||||
|
|
||||||
|
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
||||||
|
private String uploadDir;
|
||||||
|
|
||||||
|
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
public String storeAppPackage(MultipartFile apkFile) throws IOException {
|
||||||
|
if (apkFile == null || apkFile.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename();
|
||||||
|
Path dir = Paths.get(uploadDir, "apk");
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
Path dest = dir.resolve(filename);
|
||||||
|
apkFile.transferTo(dest.toFile());
|
||||||
|
return baseUrl + "/files/apk/" + filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredRnBundle storeRnBundle(String appId, String platform, String moduleId, MultipartFile bundle) throws Exception {
|
||||||
|
if (bundle == null || bundle.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("bundle file is required");
|
||||||
|
}
|
||||||
|
String filename = moduleId + "." + platform.toLowerCase() + ".bundle";
|
||||||
|
Path dir = Paths.get(uploadDir, "rn", appId, platform.toLowerCase(), moduleId);
|
||||||
|
Files.createDirectories(dir);
|
||||||
|
Path dest = dir.resolve(filename);
|
||||||
|
|
||||||
|
String md5 = computeMd5(bundle);
|
||||||
|
bundle.transferTo(dest.toFile());
|
||||||
|
return new StoredRnBundle(dest.toAbsolutePath().toString(), md5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String computeMd5(MultipartFile file) throws Exception {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("MD5");
|
||||||
|
try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) {
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
while (dis.read(buf) != -1) {
|
||||||
|
// read fully
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HexFormat.of().formatHex(digest.digest());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StoredRnBundle(String bundlePath, String md5) {}
|
||||||
|
}
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户