feat(im): 添加即时通讯SDK核心功能

- 实现IM API接口定义,包括消息、群组、好友、黑名单等功能
- 定义IM消息相关数据模型,包含聊天类型、消息类型、用户资料等
- 实现ImSDK单例类,提供登录、消息发送、群组管理、好友管理等核心功能
- 添加WebSocket连接管理,支持自动重连机制
- 实现历史消息查询、群组操作、用户资料管理等API调用
- 添加会话状态管理,支持置顶、静音、草稿等功能
- 集成文件上传结果,支持多媒体消息发送
- 实现连接状态监听和事件回调机制
这个提交包含在:
XuqmGroup 2026-04-28 21:05:06 +08:00
父节点 1e395171a3
当前提交 73060518f0
共有 15 个文件被更改,包括 882 次插入76 次删除

查看文件

@ -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) {}
}