feat(chat): 添加聊天界面和会话管理功能

- 实现了本地IM缓存功能,支持会话、消息历史和草稿的存储
- 开发了聊天界面UI组件,包含消息列表、输入框和搜索功能
- 创建了聊天相关的ViewModel,处理消息收发和状态管理
- 构建了会话列表界面,支持置顶、免打扰和删除操作
- 集成了群组功能,实现群聊管理和群设置界面
- 添加了实时消息推送和会话状态同步机制
这个提交包含在:
XuqmGroup 2026-04-27 23:41:58 +08:00
父节点 201f3d566f
当前提交 bc329ec566
共有 43 个文件被更改,包括 1605 次插入35 次删除

查看文件

@ -138,6 +138,8 @@ cd update-service && mvn spring-boot:run &
}
```
> 说明SDK 和 demo 侧传入的 `appId` 实际按 `appKey` 解析。当前默认值是 `ak_demo_chat`,如果数据库里没有这条记录,tenant-service 会在启动时自动创建。
#### 功能服务(需 Token
| 方法 | 路径 | 说明 |
@ -220,6 +222,8 @@ POST /api/im/auth/login
&avatar=https://... (可选)
```
该接口需要由 demo-service 带上 `X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature` 头完成 AppSecret 验签。
响应:`{ "data": { "token": "eyJ..." } }`
### HTTP 消息接口

查看文件

@ -0,0 +1,52 @@
package com.xuqm.common.security;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
public final class AppRequestSignatureUtil {
private static final String HMAC_ALG = "HmacSHA256";
private AppRequestSignatureUtil() {
}
public static String payload(String appId,
String userId,
String nickname,
String avatar,
long timestamp,
String nonce) {
return normalize(appId) + '\n'
+ normalize(userId) + '\n'
+ normalize(nickname) + '\n'
+ normalize(avatar) + '\n'
+ timestamp + '\n'
+ normalize(nonce);
}
public static String sign(String secret, String payload) {
try {
Mac mac = Mac.getInstance(HMAC_ALG);
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALG));
byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (Exception e) {
throw new IllegalStateException("Failed to sign app request", e);
}
}
public static boolean matches(String secret, String payload, String expectedSignature) {
String actual = sign(secret, payload);
return MessageDigest.isEqual(
actual.getBytes(StandardCharsets.UTF_8),
normalize(expectedSignature).getBytes(StandardCharsets.UTF_8)
);
}
private static String normalize(String value) {
return value == null ? "" : value;
}
}

查看文件

@ -0,0 +1,63 @@
package com.xuqm.demo.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.xuqm.common.exception.BusinessException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class DemoAppSecretClient {
private final RestTemplate restTemplate;
private final Map<String, String> cache = new ConcurrentHashMap<>();
@Value("${demo.tenant-service-url:http://xuqm-tenant-service:8081}")
private String tenantServiceUrl;
@Value("${demo.internal-token:xuqm-internal-token}")
private String internalToken;
public DemoAppSecretClient(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public String getAppSecret(String appId) {
return cache.computeIfAbsent(appId, this::fetchAppSecret);
}
private String fetchAppSecret(String appId) {
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
.path("/api/internal/sdk/apps/{appId}/secret")
.buildAndExpand(appId)
.toUriString();
HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
try {
ResponseEntity<JsonNode> response = restTemplate.exchange(
url,
HttpMethod.GET,
new HttpEntity<>(headers),
JsonNode.class
);
JsonNode body = response.getBody();
if (response.getStatusCode().is2xxSuccessful()
&& body != null
&& body.path("code").asInt() == 200) {
return body.path("data").path("appSecret").asText(null);
}
} catch (RestClientException e) {
throw new BusinessException(502, "Failed to resolve app secret: " + e.getMessage());
}
throw new BusinessException(502, "Failed to resolve app secret for appId: " + appId);
}
}

查看文件

@ -2,12 +2,17 @@ package com.xuqm.demo.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.AppRequestSignatureUtil;
import com.xuqm.common.security.JwtUtil;
import com.xuqm.demo.entity.DemoUserEntity;
import com.xuqm.demo.repository.DemoUserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -28,6 +33,7 @@ public class DemoAuthService {
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
private final RestTemplate restTemplate;
private final DemoAppSecretClient appSecretClient;
@Value("${demo.im-service-url:http://xuqm-im-service:8082}")
private String imServiceUrl;
@ -35,11 +41,13 @@ public class DemoAuthService {
public DemoAuthService(DemoUserRepository userRepository,
JwtUtil jwtUtil,
PasswordEncoder passwordEncoder,
RestTemplate restTemplate) {
RestTemplate restTemplate,
DemoAppSecretClient appSecretClient) {
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
this.passwordEncoder = passwordEncoder;
this.restTemplate = restTemplate;
this.appSecretClient = appSecretClient;
}
public record AuthResult(String demoToken, String imToken, UserProfile profile) {}
@ -97,23 +105,40 @@ public class DemoAuthService {
/**
* Calls im-service to ensure the IM account exists and obtain an IM token.
* GET {imServiceUrl}/api/im/auth/login?appId={appId}&userId={userId}&nickname={nickname}
* POST {imServiceUrl}/api/im/auth/login?appId={appId}&userId={userId}&nickname={nickname}
* Response: {"code":200,"data":{"token":"..."}}
*/
private String callImServiceLogin(String appId, String userId, String nickname) {
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString();
String effectiveNickname = nickname != null ? nickname : userId;
String appSecret = appSecretClient.getAppSecret(appId);
String payload = AppRequestSignatureUtil.payload(appId, userId, effectiveNickname, null, timestamp, nonce);
String signature = AppRequestSignatureUtil.sign(appSecret, payload);
String url = UriComponentsBuilder.fromHttpUrl(imServiceUrl)
.path("/api/im/auth/login")
.queryParam("appId", appId)
.queryParam("userId", userId)
.queryParam("nickname", nickname != null ? nickname : userId)
.queryParam("nickname", effectiveNickname)
.toUriString();
try {
JsonNode response = restTemplate.getForObject(url, JsonNode.class);
if (response != null && response.path("code").asInt() == 200) {
return response.path("data").path("token").asText();
HttpHeaders headers = new HttpHeaders();
headers.set("X-App-Timestamp", String.valueOf(timestamp));
headers.set("X-App-Nonce", nonce);
headers.set("X-App-Signature", signature);
ResponseEntity<JsonNode> response = restTemplate.exchange(
url,
HttpMethod.POST,
new HttpEntity<>(headers),
JsonNode.class
);
JsonNode body = response.getBody();
if (body != null && body.path("code").asInt() == 200) {
return body.path("data").path("token").asText();
}
log.warn("im-service login returned unexpected response for appId={} userId={}: {}", appId, userId, response);
log.warn("im-service login returned unexpected response for appId={} userId={}: {}", appId, userId, body);
return null;
} catch (RestClientException e) {
log.error("Failed to call im-service login for appId={} userId={}: {}", appId, userId, e.getMessage());

查看文件

@ -35,6 +35,8 @@ jwt:
expiration: 86400000
demo:
tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081}
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
im-service-url: ${IM_SERVICE_URL:http://xuqm-im-service:8082}
logging:

查看文件

@ -87,7 +87,7 @@
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| POST | `/api/im/auth/login` | 否 | 获取 IM Token |
| POST | `/api/im/auth/login` | 否 | 获取 IM Token;需要 `X-App-Timestamp` / `X-App-Nonce` / `X-App-Signature` |
| POST | `/api/im/messages/send` | 是 | 发送消息 |
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
| GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 |
@ -114,6 +114,8 @@
| POST | `/api/v1/rn/{id}/publish` | 是 | 发布 Bundle |
| GET | `/api/v1/rn/files/{appId}/{platform}/{moduleId}` | 否 | 下载 Bundle |
说明:这里的 `appId``appKey` 解析。当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用;如果没有,会自动补一条默认 demo 应用和基础服务配置。`POST /api/im/auth/login` 还要求 demo-service 通过 AppSecret 生成签名头再转发给 IM 服务。
## curl 示例
### 运营平台登录
@ -141,3 +143,15 @@ curl 'https://dev.xuqinmin.com/api/v1/rn/update/check?appId=ak_demo_chat&platfor
```bash
curl -X POST 'https://dev.xuqinmin.com/api/im/auth/login?appId=ak_demo_chat&userId=demo_alice'
```
### IM 会话与关系链
```bash
curl 'https://dev.xuqinmin.com/api/im/conversations?appId=ak_demo_chat'
curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/pinned?appId=ak_demo_chat&chatType=SINGLE&pinned=true'
curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/draft?appId=ak_demo_chat&chatType=SINGLE&draft=hello'
curl -X DELETE 'https://dev.xuqinmin.com/api/im/conversations/user_002?appId=ak_demo_chat&chatType=SINGLE'
curl 'https://dev.xuqinmin.com/api/im/groups?appId=ak_demo_chat'
curl 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat'
curl 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&direction=incoming'
```

查看文件

@ -1,9 +1,11 @@
package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.common.security.AppRequestSignatureUtil;
import com.xuqm.im.service.ImAccountService;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -26,7 +28,14 @@ public class AuthController {
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam(required = false) String nickname,
@RequestParam(required = false) String avatar) {
@RequestParam(required = false) String avatar,
@RequestHeader(value = "X-App-Timestamp", required = false) String timestamp,
@RequestHeader(value = "X-App-Nonce", required = false) String nonce,
@RequestHeader(value = "X-App-Signature", required = false) String signature) {
if (timestamp == null || nonce == null || signature == null) {
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing app signature"));
}
accountService.validateSignature(appId, userId, nickname, avatar, timestamp, nonce, signature);
String token = accountService.loginOrRegister(appId, userId, nickname, avatar);
return ResponseEntity.ok(ApiResponse.success(Map.of("token", token)));
}

查看文件

@ -0,0 +1,50 @@
package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.entity.ImBlacklistEntity;
import com.xuqm.im.service.BlacklistService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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 java.util.List;
@RestController
@RequestMapping("/api/im/blacklist")
public class BlacklistController {
private final BlacklistService blacklistService;
public BlacklistController(BlacklistService blacklistService) {
this.blacklistService = blacklistService;
}
@GetMapping
public ResponseEntity<ApiResponse<List<ImBlacklistEntity>>> list(
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(blacklistService.list(appId, userId)));
}
@PostMapping
public ResponseEntity<ApiResponse<ImBlacklistEntity>> add(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestParam String blockedUserId) {
return ResponseEntity.ok(ApiResponse.success(blacklistService.add(appId, userId, blockedUserId)));
}
@DeleteMapping
public ResponseEntity<ApiResponse<Void>> remove(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestParam String blockedUserId) {
blacklistService.remove(appId, userId, blockedUserId);
return ResponseEntity.ok(ApiResponse.ok());
}
}

查看文件

@ -1,11 +1,15 @@
package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.repository.ImMessageRepository;
import com.xuqm.im.model.ConversationView;
import com.xuqm.im.service.ConversationStateService;
import com.xuqm.im.service.MessageService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@ -17,17 +21,73 @@ import java.util.List;
public class ConversationController {
private final MessageService messageService;
private final ConversationStateService conversationStateService;
public ConversationController(MessageService messageService) {
public ConversationController(MessageService messageService,
ConversationStateService conversationStateService) {
this.messageService = messageService;
this.conversationStateService = conversationStateService;
}
@GetMapping("/conversations")
public ResponseEntity<ApiResponse<List<ImMessageRepository.ConversationSummary>>> conversations(
public ResponseEntity<ApiResponse<List<ConversationView>>> conversations(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(messageService.conversations(appId, userId, size)));
return ResponseEntity.ok(ApiResponse.success(messageService.conversationViews(appId, userId, size)));
}
@PutMapping("/conversations/{targetId}/pinned")
public ResponseEntity<ApiResponse<Void>> setPinned(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@PathVariable String targetId,
@RequestParam String chatType,
@RequestParam boolean pinned) {
conversationStateService.setPinned(appId, userId, targetId, chatType, pinned);
return ResponseEntity.ok(ApiResponse.ok());
}
@PutMapping("/conversations/{targetId}/muted")
public ResponseEntity<ApiResponse<Void>> setMuted(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@PathVariable String targetId,
@RequestParam String chatType,
@RequestParam boolean muted) {
conversationStateService.setMuted(appId, userId, targetId, chatType, muted);
return ResponseEntity.ok(ApiResponse.ok());
}
@PutMapping("/conversations/{targetId}/read")
public ResponseEntity<ApiResponse<Void>> markRead(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@PathVariable String targetId,
@RequestParam String chatType) {
conversationStateService.markRead(appId, userId, targetId, chatType);
return ResponseEntity.ok(ApiResponse.ok());
}
@PutMapping("/conversations/{targetId}/draft")
public ResponseEntity<ApiResponse<Void>> setDraft(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@PathVariable String targetId,
@RequestParam String chatType,
@RequestParam(required = false) String draft) {
conversationStateService.setDraft(appId, userId, targetId, chatType, draft);
return ResponseEntity.ok(ApiResponse.ok());
}
@DeleteMapping("/conversations/{targetId}")
public ResponseEntity<ApiResponse<Void>> deleteConversation(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@PathVariable String targetId,
@RequestParam String chatType) {
conversationStateService.hideConversation(appId, userId, targetId, chatType);
return ResponseEntity.ok(ApiResponse.ok());
}
}

查看文件

@ -0,0 +1,62 @@
package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.entity.ImFriendRequestEntity;
import com.xuqm.im.service.FriendRequestService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
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.bind.annotation.PathVariable;
import java.util.List;
@RestController
@RequestMapping("/api/im/friend-requests")
public class FriendRequestController {
private final FriendRequestService friendRequestService;
public FriendRequestController(FriendRequestService friendRequestService) {
this.friendRequestService = friendRequestService;
}
@GetMapping
public ResponseEntity<ApiResponse<List<ImFriendRequestEntity>>> list(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestParam(defaultValue = "incoming") String direction) {
List<ImFriendRequestEntity> list = "outgoing".equalsIgnoreCase(direction)
? friendRequestService.outgoing(appId, userId)
: friendRequestService.incoming(appId, userId);
return ResponseEntity.ok(ApiResponse.success(list));
}
@PostMapping
public ResponseEntity<ApiResponse<ImFriendRequestEntity>> send(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestParam String toUserId,
@RequestParam(required = false) String remark) {
return ResponseEntity.ok(ApiResponse.success(friendRequestService.send(appId, userId, toUserId, remark)));
}
@PostMapping("/{requestId}/accept")
public ResponseEntity<ApiResponse<ImFriendRequestEntity>> accept(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@PathVariable String requestId) {
return ResponseEntity.ok(ApiResponse.success(friendRequestService.accept(appId, requestId, userId)));
}
@PostMapping("/{requestId}/reject")
public ResponseEntity<ApiResponse<ImFriendRequestEntity>> reject(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@PathVariable String requestId) {
return ResponseEntity.ok(ApiResponse.success(friendRequestService.reject(appId, requestId, userId)));
}
}

查看文件

@ -0,0 +1,45 @@
package com.xuqm.im.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
return ResponseEntity.status(resolveStatus(e.getCode()))
.body(ApiResponse.error(e.getCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
return ResponseEntity.badRequest().body(ApiResponse.badRequest(e.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("参数错误")));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(500, e.getMessage() == null ? "服务异常" : e.getMessage()));
}
private HttpStatus resolveStatus(int code) {
return switch (code) {
case 400 -> HttpStatus.BAD_REQUEST;
case 401 -> HttpStatus.UNAUTHORIZED;
case 403 -> HttpStatus.FORBIDDEN;
case 404 -> HttpStatus.NOT_FOUND;
default -> HttpStatus.INTERNAL_SERVER_ERROR;
};
}
}

查看文件

@ -19,6 +19,13 @@ public class GroupController {
this.groupService = groupService;
}
@GetMapping("/{groupId}")
public ResponseEntity<ApiResponse<ImGroupEntity>> get(
@PathVariable String groupId,
@AuthenticationPrincipal String userId) {
return ResponseEntity.ok(ApiResponse.success(groupService.get(groupId, userId)));
}
@PostMapping
public ResponseEntity<ApiResponse<ImGroupEntity>> create(
@RequestBody CreateGroupRequest req,
@ -35,12 +42,21 @@ public class GroupController {
return ResponseEntity.ok(ApiResponse.success(groupService.listUserGroups(appId, userId)));
}
@PutMapping("/{groupId}")
public ResponseEntity<ApiResponse<ImGroupEntity>> update(
@PathVariable String groupId,
@RequestBody UpdateGroupRequest req,
@AuthenticationPrincipal String userId) {
return ResponseEntity.ok(ApiResponse.success(
groupService.update(groupId, userId, req.name(), req.announcement())));
}
@PostMapping("/{groupId}/members")
public ResponseEntity<ApiResponse<ImGroupEntity>> addMember(
@PathVariable String groupId,
@RequestBody MemberRequest req,
@AuthenticationPrincipal String userId) {
return ResponseEntity.ok(ApiResponse.success(groupService.addMember(groupId, req.userId())));
return ResponseEntity.ok(ApiResponse.success(groupService.addMember(groupId, req.userId(), userId)));
}
@DeleteMapping("/{groupId}/members/{targetUserId}")
@ -51,6 +67,35 @@ public class GroupController {
return ResponseEntity.ok(ApiResponse.success(groupService.removeMember(groupId, targetUserId, userId)));
}
@PostMapping("/{groupId}/roles")
public ResponseEntity<ApiResponse<ImGroupEntity>> setRole(
@PathVariable String groupId,
@RequestBody SetRoleRequest req,
@AuthenticationPrincipal String userId) {
return ResponseEntity.ok(ApiResponse.success(
groupService.setRole(groupId, userId, req.userId(), req.role())));
}
@PostMapping("/{groupId}/mute")
public ResponseEntity<ApiResponse<ImGroupEntity>> muteMember(
@PathVariable String groupId,
@RequestBody MuteMemberRequest req,
@AuthenticationPrincipal String userId) {
return ResponseEntity.ok(ApiResponse.success(
groupService.muteMember(groupId, userId, req.userId(), req.minutes())));
}
@DeleteMapping("/{groupId}")
public ResponseEntity<ApiResponse<Void>> dismiss(
@PathVariable String groupId,
@AuthenticationPrincipal String userId) {
groupService.dismiss(groupId, userId);
return ResponseEntity.ok(ApiResponse.ok());
}
public record CreateGroupRequest(String name, List<String> memberIds) {}
public record UpdateGroupRequest(String name, String announcement) {}
public record MemberRequest(String userId) {}
public record SetRoleRequest(String userId, String role) {}
public record MuteMemberRequest(String userId, long minutes) {}
}

查看文件

@ -59,6 +59,6 @@ public class MessageController {
@RequestParam String appId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(messageService.groupHistory(appId, groupId, page, size)));
return ResponseEntity.ok(ApiResponse.success(messageService.groupHistory(appId, groupId, userId, page, size)));
}
}

查看文件

@ -0,0 +1,19 @@
package com.xuqm.im.entity;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
@MappedSuperclass
public abstract class BaseIdEntity {
@Id
private String id;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}

查看文件

@ -1,5 +1,7 @@
package com.xuqm.im.entity;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
@ -41,6 +43,7 @@ public class ImAccountEntity {
private Status status;
@Column(nullable = false)
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime createdAt;
public String getId() { return id; }
@ -64,6 +67,7 @@ public class ImAccountEntity {
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,42 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
@Entity
@Table(
name = "im_blacklist",
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "blockedUserId"}),
indexes = @Index(name = "idx_blacklist_app_user", columnList = "appId,userId")
)
public class ImBlacklistEntity extends BaseIdEntity {
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 128)
private String userId;
@Column(nullable = false, length = 128)
private String blockedUserId;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getBlockedUserId() { return blockedUserId; }
public void setBlockedUserId(String blockedUserId) { this.blockedUserId = blockedUserId; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,85 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
@Entity
@Table(
name = "im_conversation_state",
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "targetId", "chatType"}),
indexes = {
@Index(name = "idx_conv_state_app_user", columnList = "appId,userId"),
@Index(name = "idx_conv_state_app_target", columnList = "appId,targetId")
}
)
public class ImConversationStateEntity extends BaseIdEntity {
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 128)
private String userId;
@Column(nullable = false, length = 128)
private String targetId;
@Column(nullable = false, length = 16)
private String chatType;
@Column(nullable = false)
private boolean pinned;
@Column(nullable = false)
private boolean muted;
@Column(nullable = false)
private boolean hidden;
@Column(columnDefinition = "TEXT")
private String draft;
private LocalDateTime lastReadAt;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getTargetId() { return targetId; }
public void setTargetId(String targetId) { this.targetId = targetId; }
public String getChatType() { return chatType; }
public void setChatType(String chatType) { this.chatType = chatType; }
public boolean isPinned() { return pinned; }
public void setPinned(boolean pinned) { this.pinned = pinned; }
public boolean isMuted() { return muted; }
public void setMuted(boolean muted) { this.muted = muted; }
public boolean isHidden() { return hidden; }
public void setHidden(boolean hidden) { this.hidden = hidden; }
public String getDraft() { return draft; }
public void setDraft(String draft) { this.draft = draft; }
public LocalDateTime getLastReadAt() { return lastReadAt; }
public void setLastReadAt(LocalDateTime lastReadAt) { this.lastReadAt = lastReadAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,61 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
@Entity
@Table(
name = "im_friend_request",
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "fromUserId", "toUserId"}),
indexes = @Index(name = "idx_friend_request_app_to", columnList = "appId,toUserId")
)
public class ImFriendRequestEntity extends BaseIdEntity {
public enum Status { PENDING, ACCEPTED, REJECTED }
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 128)
private String fromUserId;
@Column(nullable = false, length = 128)
private String toUserId;
@Column(length = 256)
private String remark;
@Column(nullable = false, length = 16)
private String status;
@Column(nullable = false)
private LocalDateTime createdAt;
private LocalDateTime reviewedAt;
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getFromUserId() { return fromUserId; }
public void setFromUserId(String fromUserId) { this.fromUserId = fromUserId; }
public String getToUserId() { return toUserId; }
public void setToUserId(String toUserId) { this.toUserId = toUserId; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getReviewedAt() { return reviewedAt; }
public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; }
}

查看文件

@ -1,5 +1,7 @@
package com.xuqm.im.entity;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@ -28,7 +30,11 @@ public class ImGroupEntity {
@Column(nullable = false, columnDefinition = "TEXT")
private String adminIds;
@Column(columnDefinition = "TEXT")
private String announcement;
@Column(nullable = false)
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime createdAt;
public String getId() { return id; }
@ -49,6 +55,10 @@ public class ImGroupEntity {
public String getAdminIds() { return adminIds; }
public void setAdminIds(String adminIds) { this.adminIds = adminIds; }
public String getAnnouncement() { return announcement; }
public void setAnnouncement(String announcement) { this.announcement = announcement; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,47 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
@Entity
@Table(
name = "im_group_mute",
uniqueConstraints = @UniqueConstraint(columnNames = {"groupId", "userId"}),
indexes = @Index(name = "idx_group_mute_group_user", columnList = "groupId,userId")
)
public class ImGroupMuteEntity extends BaseIdEntity {
@Column(nullable = false, length = 64)
private String groupId;
@Column(nullable = false, length = 128)
private String userId;
private LocalDateTime mutedUntil;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
public String getGroupId() { return groupId; }
public void setGroupId(String groupId) { this.groupId = groupId; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public LocalDateTime getMutedUntil() { return mutedUntil; }
public void setMutedUntil(LocalDateTime mutedUntil) { this.mutedUntil = mutedUntil; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -1,5 +1,7 @@
package com.xuqm.im.entity;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
@ -54,6 +56,7 @@ public class ImMessageEntity {
private String mentionedUserIds;
@Column(nullable = false)
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime createdAt;
public String getId() { return id; }
@ -83,6 +86,7 @@ public class ImMessageEntity {
public String getMentionedUserIds() { return mentionedUserIds; }
public void setMentionedUserIds(String mentionedUserIds) { this.mentionedUserIds = mentionedUserIds; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,21 @@
package com.xuqm.im.json;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
public class EpochMillisLocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
@Override
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
gen.writeNumber(value.toInstant(ZoneOffset.UTC).toEpochMilli());
}
}

查看文件

@ -0,0 +1,12 @@
package com.xuqm.im.model;
public record ConversationView(
String targetId,
String chatType,
String lastMsgContent,
String lastMsgType,
Long lastMsgTime,
int unreadCount,
boolean isMuted,
boolean isPinned
) {}

查看文件

@ -0,0 +1,18 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.ImBlacklistEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ImBlacklistRepository extends JpaRepository<ImBlacklistEntity, String> {
List<ImBlacklistEntity> findByAppIdAndUserId(String appId, String userId);
Optional<ImBlacklistEntity> findByAppIdAndUserIdAndBlockedUserId(
String appId, String userId, String blockedUserId);
boolean existsByAppIdAndUserIdAndBlockedUserId(String appId, String userId, String blockedUserId);
void deleteByAppIdAndUserIdAndBlockedUserId(String appId, String userId, String blockedUserId);
}

查看文件

@ -0,0 +1,19 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.ImConversationStateEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ImConversationStateRepository extends JpaRepository<ImConversationStateEntity, String> {
Optional<ImConversationStateEntity> findByAppIdAndUserIdAndTargetIdAndChatType(
String appId, String userId, String targetId, String chatType);
List<ImConversationStateEntity> findByAppIdAndUserId(String appId, String userId);
List<ImConversationStateEntity> findByAppIdAndUserIdAndHiddenFalse(String appId, String userId);
void deleteByAppIdAndUserIdAndTargetIdAndChatType(
String appId, String userId, String targetId, String chatType);
}

查看文件

@ -0,0 +1,15 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.ImFriendRequestEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ImFriendRequestRepository extends JpaRepository<ImFriendRequestEntity, String> {
Optional<ImFriendRequestEntity> findByAppIdAndFromUserIdAndToUserId(
String appId, String fromUserId, String toUserId);
List<ImFriendRequestEntity> findByAppIdAndToUserId(String appId, String toUserId);
List<ImFriendRequestEntity> findByAppIdAndFromUserId(String appId, String fromUserId);
}

查看文件

@ -0,0 +1,14 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.ImGroupMuteEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.Optional;
public interface ImGroupMuteRepository extends JpaRepository<ImGroupMuteEntity, String> {
Optional<ImGroupMuteEntity> findByGroupIdAndUserIdAndMutedUntilAfter(
String groupId, String userId, LocalDateTime time);
void deleteByGroupId(String groupId);
}

查看文件

@ -76,6 +76,35 @@ public interface ImMessageRepository extends JpaRepository<ImMessageEntity, Stri
@Param("groupId") String groupId,
Pageable pageable);
@Query("""
select count(m) from ImMessageEntity m
where m.appId = :appId
and m.chatType = com.xuqm.im.entity.ImMessageEntity$ChatType.SINGLE
and ((m.fromUserId = :userId and m.toId = :peerId)
or (m.fromUserId = :peerId and m.toId = :userId))
and m.fromUserId <> :userId
and (:since is null or m.createdAt > :since)
""")
long countUnreadSingleConversation(
@Param("appId") String appId,
@Param("userId") String userId,
@Param("peerId") String peerId,
@Param("since") LocalDateTime since);
@Query("""
select count(m) from ImMessageEntity m
where m.appId = :appId
and m.chatType = com.xuqm.im.entity.ImMessageEntity$ChatType.GROUP
and m.toId = :groupId
and m.fromUserId <> :userId
and (:since is null or m.createdAt > :since)
""")
long countUnreadGroupConversation(
@Param("appId") String appId,
@Param("userId") String userId,
@Param("groupId") String groupId,
@Param("since") LocalDateTime since);
long countByAppId(String appId);
@Query("select count(m) from ImMessageEntity m where m.appId = :appId and m.createdAt >= :since")

查看文件

@ -0,0 +1,51 @@
package com.xuqm.im.service;
import com.xuqm.im.entity.ImBlacklistEntity;
import com.xuqm.im.repository.ImBlacklistRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Service
public class BlacklistService {
private final ImBlacklistRepository repository;
public BlacklistService(ImBlacklistRepository repository) {
this.repository = repository;
}
@Transactional
public ImBlacklistEntity add(String appId, String userId, String blockedUserId) {
return repository.findByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId)
.orElseGet(() -> {
ImBlacklistEntity entity = new ImBlacklistEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId);
entity.setUserId(userId);
entity.setBlockedUserId(blockedUserId);
entity.setCreatedAt(LocalDateTime.now());
return repository.save(entity);
});
}
@Transactional
public void remove(String appId, String userId, String blockedUserId) {
repository.deleteByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId);
}
public List<ImBlacklistEntity> list(String appId, String userId) {
return repository.findByAppIdAndUserId(appId, userId);
}
public boolean isBlocked(String appId, String userId, String blockedUserId) {
return repository.existsByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId);
}
public boolean isEitherBlocked(String appId, String userId, String targetUserId) {
return isBlocked(appId, userId, targetUserId) || isBlocked(appId, targetUserId, userId);
}
}

查看文件

@ -0,0 +1,115 @@
package com.xuqm.im.service;
import com.xuqm.im.entity.ImConversationStateEntity;
import com.xuqm.im.repository.ImConversationStateRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Service
public class ConversationStateService {
private final ImConversationStateRepository repository;
public ConversationStateService(ImConversationStateRepository repository) {
this.repository = repository;
}
@Transactional
public ImConversationStateEntity setPinned(String appId, String userId, String targetId, String chatType, boolean pinned) {
ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType);
state.setPinned(pinned);
touch(state);
return repository.save(state);
}
@Transactional
public ImConversationStateEntity setMuted(String appId, String userId, String targetId, String chatType, boolean muted) {
ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType);
state.setMuted(muted);
touch(state);
return repository.save(state);
}
@Transactional
public ImConversationStateEntity markRead(String appId, String userId, String targetId, String chatType) {
ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType);
state.setLastReadAt(LocalDateTime.now());
state.setHidden(false);
touch(state);
return repository.save(state);
}
@Transactional
public ImConversationStateEntity setDraft(String appId, String userId, String targetId, String chatType, String draft) {
ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType);
state.setDraft(draft);
touch(state);
return repository.save(state);
}
@Transactional
public ImConversationStateEntity hideConversation(String appId, String userId, String targetId, String chatType) {
ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType);
state.setHidden(true);
state.setDraft(null);
touch(state);
return repository.save(state);
}
@Transactional
public void deleteConversationState(String appId, String userId, String targetId, String chatType) {
repository.deleteByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType);
}
@Transactional
public void clearHiddenForUsers(String appId, String targetId, String chatType, Collection<String> userIds) {
for (String userId : userIds) {
ImConversationStateEntity state = repository
.findByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType)
.orElse(null);
if (state != null && state.isHidden()) {
state.setHidden(false);
touch(state);
repository.save(state);
}
}
}
public ImConversationStateEntity getOrCreate(String appId, String userId, String targetId, String chatType) {
return repository.findByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType)
.orElseGet(() -> {
ImConversationStateEntity entity = new ImConversationStateEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId);
entity.setUserId(userId);
entity.setTargetId(targetId);
entity.setChatType(chatType);
entity.setPinned(false);
entity.setMuted(false);
entity.setHidden(false);
entity.setDraft(null);
entity.setLastReadAt(null);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
return entity;
});
}
public ImConversationStateEntity find(String appId, String userId, String targetId, String chatType) {
return repository.findByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType)
.orElse(null);
}
public List<ImConversationStateEntity> listVisible(String appId, String userId) {
return repository.findByAppIdAndUserIdAndHiddenFalse(appId, userId);
}
private void touch(ImConversationStateEntity entity) {
entity.setUpdatedAt(LocalDateTime.now());
}
}

查看文件

@ -0,0 +1,89 @@
package com.xuqm.im.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.im.entity.ImFriendRequestEntity;
import com.xuqm.im.repository.ImFriendRequestRepository;
import com.xuqm.im.repository.ImFriendRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Service
public class FriendRequestService {
private final ImFriendRequestRepository requestRepository;
private final ImFriendRepository friendRepository;
public FriendRequestService(ImFriendRequestRepository requestRepository,
ImFriendRepository friendRepository) {
this.requestRepository = requestRepository;
this.friendRepository = friendRepository;
}
@Transactional
public ImFriendRequestEntity send(String appId, String fromUserId, String toUserId, String remark) {
return requestRepository.findByAppIdAndFromUserIdAndToUserId(appId, fromUserId, toUserId)
.orElseGet(() -> {
ImFriendRequestEntity entity = new ImFriendRequestEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId);
entity.setFromUserId(fromUserId);
entity.setToUserId(toUserId);
entity.setRemark(remark);
entity.setStatus(ImFriendRequestEntity.Status.PENDING.name());
entity.setCreatedAt(LocalDateTime.now());
return requestRepository.save(entity);
});
}
@Transactional
public ImFriendRequestEntity accept(String appId, String requestId, String operatorId) {
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
request.setStatus(ImFriendRequestEntity.Status.ACCEPTED.name());
request.setReviewedAt(LocalDateTime.now());
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()));
return request;
}
@Transactional
public ImFriendRequestEntity reject(String appId, String requestId, String operatorId) {
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
request.setReviewedAt(LocalDateTime.now());
return requestRepository.save(request);
}
public List<ImFriendRequestEntity> incoming(String appId, String userId) {
return requestRepository.findByAppIdAndToUserId(appId, userId);
}
public List<ImFriendRequestEntity> outgoing(String appId, String userId) {
return requestRepository.findByAppIdAndFromUserId(appId, userId);
}
private ImFriendRequestEntity getRequest(String appId, String requestId, String operatorId) {
ImFriendRequestEntity request = requestRepository.findById(requestId)
.orElseThrow(() -> new BusinessException(404, "好友申请不存在"));
if (!request.getAppId().equals(appId) || !request.getToUserId().equals(operatorId)) {
throw new BusinessException(403, "无权操作");
}
return request;
}
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();
entity.setAppId(appId);
entity.setUserId(userId);
entity.setFriendId(friendId);
return friendRepository.save(entity);
}
}

查看文件

@ -1,6 +1,7 @@
package com.xuqm.im.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.AppRequestSignatureUtil;
import com.xuqm.common.security.JwtUtil;
import com.xuqm.im.entity.ImAccountEntity;
import com.xuqm.im.repository.ImAccountRepository;
@ -15,10 +16,36 @@ public class ImAccountService {
private final ImAccountRepository accountRepository;
private final JwtUtil jwtUtil;
private final ImAppSecretClient appSecretClient;
public ImAccountService(ImAccountRepository accountRepository, JwtUtil jwtUtil) {
public ImAccountService(ImAccountRepository accountRepository, JwtUtil jwtUtil, ImAppSecretClient appSecretClient) {
this.accountRepository = accountRepository;
this.jwtUtil = jwtUtil;
this.appSecretClient = appSecretClient;
}
public void validateSignature(String appId,
String userId,
String nickname,
String avatar,
String timestamp,
String nonce,
String signature) {
long ts;
try {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
throw new BusinessException(401, "Invalid app signature");
}
long now = System.currentTimeMillis();
if (Math.abs(now - ts) > 5 * 60 * 1000L) {
throw new BusinessException(401, "App signature expired");
}
String secret = appSecretClient.getAppSecret(appId);
String payload = AppRequestSignatureUtil.payload(appId, userId, nickname, avatar, ts, nonce);
if (!AppRequestSignatureUtil.matches(secret, payload, signature)) {
throw new BusinessException(401, "Invalid app signature");
}
}
public String loginOrRegister(String appId, String userId, String nickname, String avatar) {

查看文件

@ -0,0 +1,59 @@
package com.xuqm.im.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.xuqm.common.exception.BusinessException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class ImAppSecretClient {
private final RestTemplate restTemplate = new RestTemplate();
private final Map<String, String> cache = new ConcurrentHashMap<>();
@Value("${im.tenant-service-url:http://xuqm-tenant-service:8081}")
private String tenantServiceUrl;
@Value("${im.internal-token:xuqm-internal-token}")
private String internalToken;
public String getAppSecret(String appId) {
return cache.computeIfAbsent(appId, this::fetchAppSecret);
}
private String fetchAppSecret(String appId) {
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
.path("/api/internal/sdk/apps/{appId}/secret")
.buildAndExpand(appId)
.toUriString();
HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
try {
ResponseEntity<JsonNode> response = restTemplate.exchange(
url,
HttpMethod.GET,
new HttpEntity<>(headers),
JsonNode.class
);
JsonNode body = response.getBody();
if (response.getStatusCode().is2xxSuccessful()
&& body != null
&& body.path("code").asInt() == 200) {
return body.path("data").path("appSecret").asText(null);
}
} catch (RestClientException e) {
throw new BusinessException(502, "Failed to resolve app secret: " + e.getMessage());
}
throw new BusinessException(502, "Failed to resolve app secret for appId: " + appId);
}
}

查看文件

@ -4,25 +4,34 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.entity.ImGroupMuteEntity;
import com.xuqm.im.repository.ImGroupRepository;
import com.xuqm.im.repository.ImGroupMuteRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class ImGroupService {
private final ImGroupRepository groupRepository;
private final ImGroupMuteRepository muteRepository;
private final ObjectMapper objectMapper;
public ImGroupService(ImGroupRepository groupRepository, ObjectMapper objectMapper) {
public ImGroupService(ImGroupRepository groupRepository,
ImGroupMuteRepository muteRepository,
ObjectMapper objectMapper) {
this.groupRepository = groupRepository;
this.muteRepository = muteRepository;
this.objectMapper = objectMapper;
}
@Transactional
public ImGroupEntity create(String appId, String name, String creatorId, List<String> memberIds) {
List<String> members = new ArrayList<>(memberIds);
if (!members.contains(creatorId)) members.add(creatorId);
@ -34,13 +43,28 @@ public class ImGroupService {
group.setCreatorId(creatorId);
group.setMemberIds(toJson(members));
group.setAdminIds(toJson(List.of(creatorId)));
group.setAnnouncement(null);
group.setCreatedAt(LocalDateTime.now());
return groupRepository.save(group);
}
public ImGroupEntity addMember(String groupId, String userId) {
ImGroupEntity group = groupRepository.findById(groupId)
public ImGroupEntity get(String groupId) {
return groupRepository.findById(groupId)
.orElseThrow(() -> new BusinessException(404, "群组不存在"));
}
public ImGroupEntity get(String groupId, String requesterId) {
ImGroupEntity group = get(groupId);
if (!memberIds(group).contains(requesterId) && !group.getCreatorId().equals(requesterId)) {
throw new BusinessException(403, "不在群内");
}
return group;
}
@Transactional
public ImGroupEntity addMember(String groupId, String userId, String operatorId) {
ImGroupEntity group = get(groupId);
ensureCanManage(group, operatorId);
List<String> members = fromJson(group.getMemberIds());
if (!members.contains(userId)) {
members.add(userId);
@ -50,9 +74,9 @@ public class ImGroupService {
return group;
}
@Transactional
public ImGroupEntity removeMember(String groupId, String userId, String operatorId) {
ImGroupEntity group = groupRepository.findById(groupId)
.orElseThrow(() -> new BusinessException(404, "群组不存在"));
ImGroupEntity group = get(groupId);
List<String> admins = fromJson(group.getAdminIds());
if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) {
throw new BusinessException(403, "无权操作");
@ -63,6 +87,78 @@ public class ImGroupService {
return groupRepository.save(group);
}
@Transactional
public ImGroupEntity update(String groupId, String operatorId, String name, String announcement) {
ImGroupEntity group = get(groupId);
ensureCanManage(group, operatorId);
if (name != null && !name.isBlank()) {
group.setName(name);
}
if (announcement != null) {
group.setAnnouncement(announcement);
}
return groupRepository.save(group);
}
@Transactional
public ImGroupEntity setRole(String groupId, String operatorId, String userId, String role) {
ImGroupEntity group = get(groupId);
ensureCanManage(group, operatorId);
List<String> admins = new ArrayList<>(fromJson(group.getAdminIds()));
if ("ADMIN".equalsIgnoreCase(role)) {
if (!admins.contains(userId)) admins.add(userId);
} else {
admins.remove(userId);
if (userId.equals(group.getCreatorId())) {
throw new BusinessException(403, "群主不能降级");
}
}
group.setAdminIds(toJson(admins));
return groupRepository.save(group);
}
@Transactional
public ImGroupEntity muteMember(String groupId, String operatorId, String userId, long minutes) {
ImGroupEntity group = get(groupId);
ensureCanManage(group, operatorId);
ImGroupMuteEntity mute = muteRepository
.findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now())
.orElseGet(() -> {
ImGroupMuteEntity entity = new ImGroupMuteEntity();
entity.setId(UUID.randomUUID().toString());
entity.setGroupId(groupId);
entity.setUserId(userId);
entity.setCreatedAt(LocalDateTime.now());
return entity;
});
mute.setMutedUntil(LocalDateTime.now().plusMinutes(Math.max(minutes, 0)));
mute.setUpdatedAt(LocalDateTime.now());
muteRepository.save(mute);
return group;
}
@Transactional
public void dismiss(String groupId, String operatorId) {
ImGroupEntity group = get(groupId);
if (!group.getCreatorId().equals(operatorId)) {
throw new BusinessException(403, "只有群主可以解散群");
}
muteRepository.deleteByGroupId(groupId);
groupRepository.delete(group);
}
public boolean isMemberMuted(String groupId, String userId) {
return muteRepository.findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now()).isPresent();
}
public List<String> memberIds(ImGroupEntity group) {
return fromJson(group.getMemberIds());
}
public List<String> adminIds(ImGroupEntity group) {
return fromJson(group.getAdminIds());
}
public List<ImGroupEntity> listByApp(String appId) {
return groupRepository.findByAppId(appId);
}
@ -78,4 +174,11 @@ public class ImGroupService {
private List<String> fromJson(String json) {
try { return objectMapper.readValue(json, new TypeReference<>() {}); } catch (Exception e) { return new ArrayList<>(); }
}
private void ensureCanManage(ImGroupEntity group, String operatorId) {
List<String> admins = fromJson(group.getAdminIds());
if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) {
throw new BusinessException(403, "无权操作");
}
}
}

查看文件

@ -3,8 +3,10 @@ package com.xuqm.im.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.im.cluster.ImClusterPublisher;
import com.xuqm.im.entity.ImGroupEntity;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.model.ConversationView;
import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.repository.WebhookConfigRepository;
import org.springframework.beans.factory.annotation.Value;
@ -18,8 +20,10 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import com.xuqm.im.repository.ImMessageRepository;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@Service
@ -29,6 +33,9 @@ public class MessageService {
private final WebhookConfigRepository webhookRepository;
private final KeywordFilterService keywordFilterService;
private final ImClusterPublisher clusterPublisher;
private final ImGroupService groupService;
private final BlacklistService blacklistService;
private final ConversationStateService conversationStateService;
private final ObjectMapper objectMapper;
@Value("${im.webhook-timeout-ms:3000}")
@ -38,11 +45,17 @@ public class MessageService {
WebhookConfigRepository webhookRepository,
KeywordFilterService keywordFilterService,
ImClusterPublisher clusterPublisher,
ImGroupService groupService,
BlacklistService blacklistService,
ConversationStateService conversationStateService,
ObjectMapper objectMapper) {
this.messageRepository = messageRepository;
this.webhookRepository = webhookRepository;
this.keywordFilterService = keywordFilterService;
this.clusterPublisher = clusterPublisher;
this.groupService = groupService;
this.blacklistService = blacklistService;
this.conversationStateService = conversationStateService;
this.objectMapper = objectMapper;
}
@ -54,6 +67,18 @@ public class MessageService {
throw new BusinessException("消息包含违禁内容");
}
}
ImGroupEntity group = null;
if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
group = groupService.get(req.toId());
if (!groupService.memberIds(group).contains(fromUserId)) {
throw new BusinessException(403, "不在群内");
}
if (groupService.isMemberMuted(req.toId(), fromUserId)) {
throw new BusinessException(403, "当前用户已被禁言");
}
} else if (blacklistService.isEitherBlocked(appId, fromUserId, req.toId())) {
throw new BusinessException(403, "已被拉黑,无法发送消息");
}
ImMessageEntity message = new ImMessageEntity();
message.setId(UUID.randomUUID().toString());
@ -74,6 +99,9 @@ public class MessageService {
clusterPublisher.publish(destination, message);
if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) {
clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", message);
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId()));
} else if (req.chatType() == ImMessageEntity.ChatType.GROUP) {
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), groupService.memberIds(group));
}
dispatchWebhooks(appId, message);
@ -109,7 +137,11 @@ public class MessageService {
appId, userId, toId, PageRequest.of(page, size));
}
public Page<ImMessageEntity> groupHistory(String appId, String groupId, int page, int size) {
public Page<ImMessageEntity> groupHistory(String appId, String groupId, String userId, int page, int size) {
ImGroupEntity group = groupService.get(groupId);
if (!groupService.memberIds(group).contains(userId)) {
throw new BusinessException(403, "不在群内");
}
return messageRepository.findGroupHistory(appId, groupId, PageRequest.of(page, size));
}
@ -117,6 +149,50 @@ public class MessageService {
return messageRepository.findConversations(appId, userId, size);
}
public List<ConversationView> conversationViews(String appId, String userId, int size) {
int fetchSize = Math.max(size * 3, size);
return messageRepository.findConversations(appId, userId, fetchSize).stream()
.map(summary -> toConversationView(appId, userId, summary))
.filter(Objects::nonNull)
.limit(size)
.toList();
}
private ConversationView toConversationView(
String appId,
String userId,
ImMessageRepository.ConversationSummary summary
) {
String targetId = summary.getTargetId();
String chatType = summary.getChatType();
var state = conversationStateService.find(appId, userId, targetId, chatType);
if (state != null && state.isHidden()) {
return null;
}
Page<ImMessageEntity> page = chatType.equals("GROUP")
? messageRepository.findGroupHistory(appId, targetId, PageRequest.of(0, 1))
: messageRepository.findSingleConversation(appId, userId, targetId, PageRequest.of(0, 1));
ImMessageEntity lastMessage = page.getContent().stream().findFirst().orElse(null);
LocalDateTime lastReadAt = state == null ? null : state.getLastReadAt();
long unreadCount = chatType.equals("GROUP")
? messageRepository.countUnreadGroupConversation(appId, userId, targetId, lastReadAt)
: messageRepository.countUnreadSingleConversation(appId, userId, targetId, lastReadAt);
return new ConversationView(
targetId,
chatType,
lastMessage != null ? lastMessage.getContent() : null,
lastMessage != null ? lastMessage.getMsgType().name() : null,
toEpochMillis(lastMessage != null ? lastMessage.getCreatedAt() : summary.getLastTime()),
(int) unreadCount,
state != null && state.isMuted(),
state != null && state.isPinned()
);
}
private long toEpochMillis(LocalDateTime time) {
return time == null ? 0L : time.toInstant(ZoneOffset.UTC).toEpochMilli();
}
@Async
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);

查看文件

@ -44,6 +44,8 @@ jwt:
expiration: 86400000
im:
tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081}
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
multi-login: true
message-history-days: 30
webhook-timeout-ms: 3000

查看文件

@ -0,0 +1,21 @@
package com.xuqm.tenant.config;
import com.xuqm.tenant.service.SdkAppProvisioningService;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class SdkAppInitializer implements ApplicationRunner {
private final SdkAppProvisioningService provisioningService;
public SdkAppInitializer(SdkAppProvisioningService provisioningService) {
this.provisioningService = provisioningService;
}
@Override
public void run(ApplicationArguments args) {
provisioningService.ensureBootstrapApp();
}
}

查看文件

@ -34,6 +34,7 @@ public class SecurityConfig {
.requestMatchers(
"/api/auth/**",
"/api/sdk/**",
"/api/internal/sdk/**",
"/actuator/health",
"/actuator/info"
).permitAll()

查看文件

@ -0,0 +1,43 @@
package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.service.SdkAppProvisioningService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/internal/sdk")
public class InternalSdkController {
private final SdkAppProvisioningService provisioningService;
@Value("${sdk.internal-token:xuqm-internal-token}")
private String internalToken;
public InternalSdkController(SdkAppProvisioningService provisioningService) {
this.provisioningService = provisioningService;
}
@GetMapping("/apps/{appId}/secret")
public ResponseEntity<ApiResponse<Map<String, String>>> getAppSecret(
@PathVariable String appId,
@RequestHeader(value = "X-Internal-Token", required = false) String token) {
if (token == null || !internalToken.equals(token)) {
return ResponseEntity.status(403)
.body(ApiResponse.error(403, "Forbidden"));
}
AppEntity app = provisioningService.resolveApp(appId);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"appId", app.getAppKey(),
"appSecret", app.getAppSecret()
)));
}
}

查看文件

@ -1,9 +1,10 @@
package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.repository.AppRepository;
import com.xuqm.tenant.repository.FeatureServiceRepository;
import com.xuqm.tenant.service.SdkAppProvisioningService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@ -18,8 +19,8 @@ import java.util.Map;
@RequestMapping("/api/sdk")
public class SdkConfigController {
private final AppRepository appRepository;
private final FeatureServiceRepository featureServiceRepository;
private final SdkAppProvisioningService sdkAppProvisioningService;
@Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}")
private String imWsUrl;
@ -30,28 +31,24 @@ public class SdkConfigController {
@Value("${sdk.im-api-url:https://im.dev.xuqinmin.com}")
private String imApiUrl;
public SdkConfigController(AppRepository appRepository,
FeatureServiceRepository featureServiceRepository) {
this.appRepository = appRepository;
public SdkConfigController(FeatureServiceRepository featureServiceRepository,
SdkAppProvisioningService sdkAppProvisioningService) {
this.featureServiceRepository = featureServiceRepository;
this.sdkAppProvisioningService = sdkAppProvisioningService;
}
/**
* GET /api/sdk/config?appId=XXX public, no auth required.
*
* Returns SDK configuration URLs and enabled feature flags for the given appId.
* Returns 404 if the appId does not exist in the system.
* Returns SDK configuration URLs and enabled feature flags for the given appId/appKey.
* The demo app (`ak_demo_chat`) is auto-provisioned if it does not exist.
*/
@GetMapping("/config")
public ResponseEntity<ApiResponse<SdkConfigResponse>> getConfig(
@RequestParam String appId) {
if (!appRepository.existsById(appId)) {
return ResponseEntity.status(404)
.body(ApiResponse.error(404, "App not found: " + appId));
}
List<FeatureServiceEntity> features = featureServiceRepository.findByAppId(appId);
AppEntity app = sdkAppProvisioningService.resolveApp(appId);
List<FeatureServiceEntity> features = featureServiceRepository.findByAppId(app.getAppKey());
boolean imEnabled = features.stream()
.anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.IM && f.isEnabled());

查看文件

@ -15,6 +15,7 @@ import java.util.Optional;
public interface TenantRepository extends JpaRepository<TenantEntity, String> {
Optional<TenantEntity> findByUsername(String username);
Optional<TenantEntity> findByEmail(String email);
Optional<TenantEntity> findFirstByOrderByCreatedAtAsc();
boolean existsByUsername(String username);
boolean existsByEmail(String email);
List<TenantEntity> findByParentId(String parentId);

查看文件

@ -0,0 +1,149 @@
package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.repository.AppRepository;
import com.xuqm.tenant.repository.FeatureServiceRepository;
import com.xuqm.tenant.repository.TenantRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
@Service
public class SdkAppProvisioningService {
private static final SecureRandom RANDOM = new SecureRandom();
private final AppRepository appRepository;
private final TenantRepository tenantRepository;
private final FeatureServiceRepository featureServiceRepository;
private final PasswordEncoder passwordEncoder;
@Value("${sdk.bootstrap-app-key:ak_demo_chat}")
private String bootstrapAppKey;
@Value("${sdk.bootstrap-app-name:Demo Chat}")
private String bootstrapAppName;
@Value("${sdk.bootstrap-app-package:com.xuqm.demo}")
private String bootstrapAppPackage;
@Value("${sdk.bootstrap-app-description:XuqmGroup demo app}")
private String bootstrapAppDescription;
public SdkAppProvisioningService(AppRepository appRepository,
TenantRepository tenantRepository,
FeatureServiceRepository featureServiceRepository,
PasswordEncoder passwordEncoder) {
this.appRepository = appRepository;
this.tenantRepository = tenantRepository;
this.featureServiceRepository = featureServiceRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public AppEntity ensureBootstrapApp() {
return ensureApp(bootstrapAppKey, true);
}
@Transactional
public AppEntity resolveApp(String appId) {
return appRepository.findByAppKey(appId)
.or(() -> appRepository.findById(appId))
.orElseGet(() -> {
if (!bootstrapAppKey.equals(appId)) {
throw new BusinessException(404, "App not found: " + appId);
}
return ensureApp(appId, true);
});
}
@Transactional
public AppEntity ensureApp(String appKey, boolean bootstrapDefaults) {
AppEntity existing = appRepository.findByAppKey(appKey).orElse(null);
if (existing != null) {
if (bootstrapDefaults) {
ensureFeatureDefaults(existing);
}
return existing;
}
TenantEntity owner = resolveOwnerTenant();
AppEntity app = new AppEntity();
app.setId(UUID.randomUUID().toString());
app.setTenantId(owner.getId());
app.setPackageName(bootstrapAppPackage);
app.setName(bootstrapAppName);
app.setDescription(bootstrapAppDescription);
app.setIconUrl(null);
app.setAppKey(appKey);
app.setAppSecret(generateSecret());
app.setCreatedAt(LocalDateTime.now());
app = appRepository.save(app);
if (bootstrapDefaults) {
ensureFeatureDefaults(app);
}
return app;
}
private TenantEntity resolveOwnerTenant() {
return tenantRepository.findFirstByOrderByCreatedAtAsc()
.orElseGet(this::createBootstrapTenant);
}
private TenantEntity createBootstrapTenant() {
TenantEntity tenant = new TenantEntity();
tenant.setId(UUID.randomUUID().toString());
tenant.setUsername("system");
tenant.setPassword(passwordEncoder.encode(generateSecret()));
tenant.setEmail("system@xuqinmin.com");
tenant.setNickname("System");
tenant.setPhone(null);
tenant.setType(TenantEntity.Type.MAIN);
tenant.setStatus(TenantEntity.Status.ACTIVE);
tenant.setParentId(null);
tenant.setCreatedAt(LocalDateTime.now());
return tenantRepository.save(tenant);
}
private void ensureFeatureDefaults(AppEntity app) {
for (FeatureServiceEntity.Platform platform : List.of(
FeatureServiceEntity.Platform.ANDROID,
FeatureServiceEntity.Platform.IOS,
FeatureServiceEntity.Platform.HARMONY)) {
for (FeatureServiceEntity.ServiceType serviceType : List.of(
FeatureServiceEntity.ServiceType.IM,
FeatureServiceEntity.ServiceType.PUSH,
FeatureServiceEntity.ServiceType.UPDATE)) {
featureServiceRepository.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, serviceType)
.orElseGet(() -> {
FeatureServiceEntity feature = new FeatureServiceEntity();
feature.setId(UUID.randomUUID().toString());
feature.setAppId(app.getAppKey());
feature.setPlatform(platform);
feature.setServiceType(serviceType);
feature.setEnabled(true);
feature.setConfig(null);
feature.setCreatedAt(LocalDateTime.now());
return featureServiceRepository.save(feature);
});
}
}
}
private String generateSecret() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}

查看文件

@ -80,6 +80,11 @@ management:
include: health,info
sdk:
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
bootstrap-app-key: ${SDK_BOOTSTRAP_APP_KEY:ak_demo_chat}
bootstrap-app-name: ${SDK_BOOTSTRAP_APP_NAME:Demo Chat}
bootstrap-app-package: ${SDK_BOOTSTRAP_APP_PACKAGE:com.xuqm.demo}
bootstrap-app-description: ${SDK_BOOTSTRAP_APP_DESCRIPTION:XuqmGroup demo app}
im-ws-url: ${SDK_IM_WS_URL:wss://im.dev.xuqinmin.com/ws/im}
file-service-url: ${SDK_FILE_SERVICE_URL:https://file.dev.xuqinmin.com}
im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com}