feat(chat): 添加聊天界面和会话管理功能
- 实现了本地IM缓存功能,支持会话、消息历史和草稿的存储 - 开发了聊天界面UI组件,包含消息列表、输入框和搜索功能 - 创建了聊天相关的ViewModel,处理消息收发和状态管理 - 构建了会话列表界面,支持置顶、免打扰和删除操作 - 集成了群组功能,实现群聊管理和群设置界面 - 添加了实时消息推送和会话状态同步机制
这个提交包含在:
父节点
201f3d566f
当前提交
bc329ec566
@ -138,6 +138,8 @@ cd update-service && mvn spring-boot:run &
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 说明:SDK 和 demo 侧传入的 `appId` 实际按 `appKey` 解析。当前默认值是 `ak_demo_chat`,如果数据库里没有这条记录,tenant-service 会在启动时自动创建。
|
||||||
|
|
||||||
#### 功能服务(需 Token)
|
#### 功能服务(需 Token)
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
@ -220,6 +222,8 @@ POST /api/im/auth/login
|
|||||||
&avatar=https://... (可选)
|
&avatar=https://... (可选)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
该接口需要由 demo-service 带上 `X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature` 头完成 AppSecret 验签。
|
||||||
|
|
||||||
响应:`{ "data": { "token": "eyJ..." } }`
|
响应:`{ "data": { "token": "eyJ..." } }`
|
||||||
|
|
||||||
### HTTP 消息接口
|
### 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.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
|
import com.xuqm.common.security.AppRequestSignatureUtil;
|
||||||
import com.xuqm.common.security.JwtUtil;
|
import com.xuqm.common.security.JwtUtil;
|
||||||
import com.xuqm.demo.entity.DemoUserEntity;
|
import com.xuqm.demo.entity.DemoUserEntity;
|
||||||
import com.xuqm.demo.repository.DemoUserRepository;
|
import com.xuqm.demo.repository.DemoUserRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@ -28,6 +33,7 @@ public class DemoAuthService {
|
|||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
|
private final DemoAppSecretClient appSecretClient;
|
||||||
|
|
||||||
@Value("${demo.im-service-url:http://xuqm-im-service:8082}")
|
@Value("${demo.im-service-url:http://xuqm-im-service:8082}")
|
||||||
private String imServiceUrl;
|
private String imServiceUrl;
|
||||||
@ -35,11 +41,13 @@ public class DemoAuthService {
|
|||||||
public DemoAuthService(DemoUserRepository userRepository,
|
public DemoAuthService(DemoUserRepository userRepository,
|
||||||
JwtUtil jwtUtil,
|
JwtUtil jwtUtil,
|
||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder,
|
||||||
RestTemplate restTemplate) {
|
RestTemplate restTemplate,
|
||||||
|
DemoAppSecretClient appSecretClient) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.jwtUtil = jwtUtil;
|
this.jwtUtil = jwtUtil;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.restTemplate = restTemplate;
|
this.restTemplate = restTemplate;
|
||||||
|
this.appSecretClient = appSecretClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record AuthResult(String demoToken, String imToken, UserProfile profile) {}
|
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.
|
* 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":"..."}}
|
* Response: {"code":200,"data":{"token":"..."}}
|
||||||
*/
|
*/
|
||||||
private String callImServiceLogin(String appId, String userId, String nickname) {
|
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)
|
String url = UriComponentsBuilder.fromHttpUrl(imServiceUrl)
|
||||||
.path("/api/im/auth/login")
|
.path("/api/im/auth/login")
|
||||||
.queryParam("appId", appId)
|
.queryParam("appId", appId)
|
||||||
.queryParam("userId", userId)
|
.queryParam("userId", userId)
|
||||||
.queryParam("nickname", nickname != null ? nickname : userId)
|
.queryParam("nickname", effectiveNickname)
|
||||||
.toUriString();
|
.toUriString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
JsonNode response = restTemplate.getForObject(url, JsonNode.class);
|
HttpHeaders headers = new HttpHeaders();
|
||||||
if (response != null && response.path("code").asInt() == 200) {
|
headers.set("X-App-Timestamp", String.valueOf(timestamp));
|
||||||
return response.path("data").path("token").asText();
|
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;
|
return null;
|
||||||
} catch (RestClientException e) {
|
} catch (RestClientException e) {
|
||||||
log.error("Failed to call im-service login for appId={} userId={}: {}", appId, userId, e.getMessage());
|
log.error("Failed to call im-service login for appId={} userId={}: {}", appId, userId, e.getMessage());
|
||||||
|
|||||||
@ -35,6 +35,8 @@ jwt:
|
|||||||
expiration: 86400000
|
expiration: 86400000
|
||||||
|
|
||||||
demo:
|
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}
|
im-service-url: ${IM_SERVICE_URL:http://xuqm-im-service:8082}
|
||||||
|
|
||||||
logging:
|
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/send` | 是 | 发送消息 |
|
||||||
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
|
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
|
||||||
| GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 |
|
| GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 |
|
||||||
@ -114,6 +114,8 @@
|
|||||||
| POST | `/api/v1/rn/{id}/publish` | 是 | 发布 Bundle |
|
| POST | `/api/v1/rn/{id}/publish` | 是 | 发布 Bundle |
|
||||||
| GET | `/api/v1/rn/files/{appId}/{platform}/{moduleId}` | 否 | 下载 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 示例
|
## curl 示例
|
||||||
|
|
||||||
### 运营平台登录
|
### 运营平台登录
|
||||||
@ -141,3 +143,15 @@ curl 'https://dev.xuqinmin.com/api/v1/rn/update/check?appId=ak_demo_chat&platfor
|
|||||||
```bash
|
```bash
|
||||||
curl -X POST 'https://dev.xuqinmin.com/api/im/auth/login?appId=ak_demo_chat&userId=demo_alice'
|
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;
|
package com.xuqm.im.controller;
|
||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.common.security.AppRequestSignatureUtil;
|
||||||
import com.xuqm.im.service.ImAccountService;
|
import com.xuqm.im.service.ImAccountService;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.PostMapping;
|
||||||
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;
|
||||||
@ -26,7 +28,14 @@ public class AuthController {
|
|||||||
@RequestParam @NotBlank String appId,
|
@RequestParam @NotBlank String appId,
|
||||||
@RequestParam @NotBlank String userId,
|
@RequestParam @NotBlank String userId,
|
||||||
@RequestParam(required = false) String nickname,
|
@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);
|
String token = accountService.loginOrRegister(appId, userId, nickname, avatar);
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of("token", token)));
|
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;
|
package com.xuqm.im.controller;
|
||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
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 com.xuqm.im.service.MessageService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
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.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.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;
|
||||||
@ -17,17 +21,73 @@ import java.util.List;
|
|||||||
public class ConversationController {
|
public class ConversationController {
|
||||||
|
|
||||||
private final MessageService messageService;
|
private final MessageService messageService;
|
||||||
|
private final ConversationStateService conversationStateService;
|
||||||
|
|
||||||
public ConversationController(MessageService messageService) {
|
public ConversationController(MessageService messageService,
|
||||||
|
ConversationStateService conversationStateService) {
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
|
this.conversationStateService = conversationStateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/conversations")
|
@GetMapping("/conversations")
|
||||||
public ResponseEntity<ApiResponse<List<ImMessageRepository.ConversationSummary>>> conversations(
|
public ResponseEntity<ApiResponse<List<ConversationView>>> conversations(
|
||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@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;
|
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
|
@PostMapping
|
||||||
public ResponseEntity<ApiResponse<ImGroupEntity>> create(
|
public ResponseEntity<ApiResponse<ImGroupEntity>> create(
|
||||||
@RequestBody CreateGroupRequest req,
|
@RequestBody CreateGroupRequest req,
|
||||||
@ -35,12 +42,21 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(groupService.listUserGroups(appId, userId)));
|
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")
|
@PostMapping("/{groupId}/members")
|
||||||
public ResponseEntity<ApiResponse<ImGroupEntity>> addMember(
|
public ResponseEntity<ApiResponse<ImGroupEntity>> addMember(
|
||||||
@PathVariable String groupId,
|
@PathVariable String groupId,
|
||||||
@RequestBody MemberRequest req,
|
@RequestBody MemberRequest req,
|
||||||
@AuthenticationPrincipal String userId) {
|
@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}")
|
@DeleteMapping("/{groupId}/members/{targetUserId}")
|
||||||
@ -51,6 +67,35 @@ public class GroupController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(groupService.removeMember(groupId, targetUserId, userId)));
|
return ResponseEntity.ok(ApiResponse.success(groupService.removeMember(groupId, targetUserId, userId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CreateGroupRequest(String name, List<String> memberIds) {}
|
@PostMapping("/{groupId}/roles")
|
||||||
public record MemberRequest(String userId) {}
|
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 String appId,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size) {
|
@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;
|
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.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.EnumType;
|
import jakarta.persistence.EnumType;
|
||||||
@ -41,6 +43,7 @@ public class ImAccountEntity {
|
|||||||
private Status status;
|
private Status status;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
public String getId() { return id; }
|
public String getId() { return id; }
|
||||||
@ -64,6 +67,7 @@ public class ImAccountEntity {
|
|||||||
public Status getStatus() { return status; }
|
public Status getStatus() { return status; }
|
||||||
public void setStatus(Status status) { this.status = status; }
|
public void setStatus(Status status) { this.status = status; }
|
||||||
|
|
||||||
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = 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;
|
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.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
@ -28,7 +30,11 @@ public class ImGroupEntity {
|
|||||||
@Column(nullable = false, columnDefinition = "TEXT")
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
private String adminIds;
|
private String adminIds;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String announcement;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
public String getId() { return id; }
|
public String getId() { return id; }
|
||||||
@ -49,6 +55,10 @@ public class ImGroupEntity {
|
|||||||
public String getAdminIds() { return adminIds; }
|
public String getAdminIds() { return adminIds; }
|
||||||
public void setAdminIds(String adminIds) { this.adminIds = 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 LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = 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;
|
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.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.EnumType;
|
import jakarta.persistence.EnumType;
|
||||||
@ -54,6 +56,7 @@ public class ImMessageEntity {
|
|||||||
private String mentionedUserIds;
|
private String mentionedUserIds;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
public String getId() { return id; }
|
public String getId() { return id; }
|
||||||
@ -83,6 +86,7 @@ public class ImMessageEntity {
|
|||||||
public String getMentionedUserIds() { return mentionedUserIds; }
|
public String getMentionedUserIds() { return mentionedUserIds; }
|
||||||
public void setMentionedUserIds(String mentionedUserIds) { this.mentionedUserIds = mentionedUserIds; }
|
public void setMentionedUserIds(String mentionedUserIds) { this.mentionedUserIds = mentionedUserIds; }
|
||||||
|
|
||||||
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = 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,
|
@Param("groupId") String groupId,
|
||||||
Pageable pageable);
|
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);
|
long countByAppId(String appId);
|
||||||
|
|
||||||
@Query("select count(m) from ImMessageEntity m where m.appId = :appId and m.createdAt >= :since")
|
@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;
|
package com.xuqm.im.service;
|
||||||
|
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
|
import com.xuqm.common.security.AppRequestSignatureUtil;
|
||||||
import com.xuqm.common.security.JwtUtil;
|
import com.xuqm.common.security.JwtUtil;
|
||||||
import com.xuqm.im.entity.ImAccountEntity;
|
import com.xuqm.im.entity.ImAccountEntity;
|
||||||
import com.xuqm.im.repository.ImAccountRepository;
|
import com.xuqm.im.repository.ImAccountRepository;
|
||||||
@ -15,10 +16,36 @@ public class ImAccountService {
|
|||||||
|
|
||||||
private final ImAccountRepository accountRepository;
|
private final ImAccountRepository accountRepository;
|
||||||
private final JwtUtil jwtUtil;
|
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.accountRepository = accountRepository;
|
||||||
this.jwtUtil = jwtUtil;
|
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) {
|
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.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
import com.xuqm.im.entity.ImGroupEntity;
|
import com.xuqm.im.entity.ImGroupEntity;
|
||||||
|
import com.xuqm.im.entity.ImGroupMuteEntity;
|
||||||
import com.xuqm.im.repository.ImGroupRepository;
|
import com.xuqm.im.repository.ImGroupRepository;
|
||||||
|
import com.xuqm.im.repository.ImGroupMuteRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ImGroupService {
|
public class ImGroupService {
|
||||||
|
|
||||||
private final ImGroupRepository groupRepository;
|
private final ImGroupRepository groupRepository;
|
||||||
|
private final ImGroupMuteRepository muteRepository;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public ImGroupService(ImGroupRepository groupRepository, ObjectMapper objectMapper) {
|
public ImGroupService(ImGroupRepository groupRepository,
|
||||||
|
ImGroupMuteRepository muteRepository,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
|
this.muteRepository = muteRepository;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public ImGroupEntity create(String appId, String name, String creatorId, List<String> memberIds) {
|
public ImGroupEntity create(String appId, String name, String creatorId, List<String> memberIds) {
|
||||||
List<String> members = new ArrayList<>(memberIds);
|
List<String> members = new ArrayList<>(memberIds);
|
||||||
if (!members.contains(creatorId)) members.add(creatorId);
|
if (!members.contains(creatorId)) members.add(creatorId);
|
||||||
@ -34,13 +43,28 @@ public class ImGroupService {
|
|||||||
group.setCreatorId(creatorId);
|
group.setCreatorId(creatorId);
|
||||||
group.setMemberIds(toJson(members));
|
group.setMemberIds(toJson(members));
|
||||||
group.setAdminIds(toJson(List.of(creatorId)));
|
group.setAdminIds(toJson(List.of(creatorId)));
|
||||||
|
group.setAnnouncement(null);
|
||||||
group.setCreatedAt(LocalDateTime.now());
|
group.setCreatedAt(LocalDateTime.now());
|
||||||
return groupRepository.save(group);
|
return groupRepository.save(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ImGroupEntity addMember(String groupId, String userId) {
|
public ImGroupEntity get(String groupId) {
|
||||||
ImGroupEntity group = groupRepository.findById(groupId)
|
return groupRepository.findById(groupId)
|
||||||
.orElseThrow(() -> new BusinessException(404, "群组不存在"));
|
.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());
|
List<String> members = fromJson(group.getMemberIds());
|
||||||
if (!members.contains(userId)) {
|
if (!members.contains(userId)) {
|
||||||
members.add(userId);
|
members.add(userId);
|
||||||
@ -50,9 +74,9 @@ public class ImGroupService {
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public ImGroupEntity removeMember(String groupId, String userId, String operatorId) {
|
public ImGroupEntity removeMember(String groupId, String userId, String operatorId) {
|
||||||
ImGroupEntity group = groupRepository.findById(groupId)
|
ImGroupEntity group = get(groupId);
|
||||||
.orElseThrow(() -> new BusinessException(404, "群组不存在"));
|
|
||||||
List<String> admins = fromJson(group.getAdminIds());
|
List<String> admins = fromJson(group.getAdminIds());
|
||||||
if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) {
|
if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) {
|
||||||
throw new BusinessException(403, "无权操作");
|
throw new BusinessException(403, "无权操作");
|
||||||
@ -63,6 +87,78 @@ public class ImGroupService {
|
|||||||
return groupRepository.save(group);
|
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) {
|
public List<ImGroupEntity> listByApp(String appId) {
|
||||||
return groupRepository.findByAppId(appId);
|
return groupRepository.findByAppId(appId);
|
||||||
}
|
}
|
||||||
@ -78,4 +174,11 @@ public class ImGroupService {
|
|||||||
private List<String> fromJson(String json) {
|
private List<String> fromJson(String json) {
|
||||||
try { return objectMapper.readValue(json, new TypeReference<>() {}); } catch (Exception e) { return new ArrayList<>(); }
|
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.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
import com.xuqm.im.cluster.ImClusterPublisher;
|
import com.xuqm.im.cluster.ImClusterPublisher;
|
||||||
|
import com.xuqm.im.entity.ImGroupEntity;
|
||||||
import com.xuqm.im.entity.ImMessageEntity;
|
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.SendMessageRequest;
|
import com.xuqm.im.model.SendMessageRequest;
|
||||||
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;
|
||||||
@ -18,8 +20,10 @@ 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.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import com.xuqm.im.repository.ImMessageRepository;
|
import com.xuqm.im.repository.ImMessageRepository;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@ -29,6 +33,9 @@ public class MessageService {
|
|||||||
private final WebhookConfigRepository webhookRepository;
|
private final WebhookConfigRepository webhookRepository;
|
||||||
private final KeywordFilterService keywordFilterService;
|
private final KeywordFilterService keywordFilterService;
|
||||||
private final ImClusterPublisher clusterPublisher;
|
private final ImClusterPublisher clusterPublisher;
|
||||||
|
private final ImGroupService groupService;
|
||||||
|
private final BlacklistService blacklistService;
|
||||||
|
private final ConversationStateService conversationStateService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Value("${im.webhook-timeout-ms:3000}")
|
@Value("${im.webhook-timeout-ms:3000}")
|
||||||
@ -38,11 +45,17 @@ public class MessageService {
|
|||||||
WebhookConfigRepository webhookRepository,
|
WebhookConfigRepository webhookRepository,
|
||||||
KeywordFilterService keywordFilterService,
|
KeywordFilterService keywordFilterService,
|
||||||
ImClusterPublisher clusterPublisher,
|
ImClusterPublisher clusterPublisher,
|
||||||
|
ImGroupService groupService,
|
||||||
|
BlacklistService blacklistService,
|
||||||
|
ConversationStateService conversationStateService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.webhookRepository = webhookRepository;
|
this.webhookRepository = webhookRepository;
|
||||||
this.keywordFilterService = keywordFilterService;
|
this.keywordFilterService = keywordFilterService;
|
||||||
this.clusterPublisher = clusterPublisher;
|
this.clusterPublisher = clusterPublisher;
|
||||||
|
this.groupService = groupService;
|
||||||
|
this.blacklistService = blacklistService;
|
||||||
|
this.conversationStateService = conversationStateService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,6 +67,18 @@ public class MessageService {
|
|||||||
throw new BusinessException("消息包含违禁内容");
|
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();
|
ImMessageEntity message = new ImMessageEntity();
|
||||||
message.setId(UUID.randomUUID().toString());
|
message.setId(UUID.randomUUID().toString());
|
||||||
@ -74,6 +99,9 @@ public class MessageService {
|
|||||||
clusterPublisher.publish(destination, message);
|
clusterPublisher.publish(destination, message);
|
||||||
if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) {
|
if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) {
|
||||||
clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", message);
|
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);
|
dispatchWebhooks(appId, message);
|
||||||
@ -109,7 +137,11 @@ public class MessageService {
|
|||||||
appId, userId, toId, PageRequest.of(page, size));
|
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));
|
return messageRepository.findGroupHistory(appId, groupId, PageRequest.of(page, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,6 +149,50 @@ public class MessageService {
|
|||||||
return messageRepository.findConversations(appId, userId, size);
|
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
|
@Async
|
||||||
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
|
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
|
||||||
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
||||||
|
|||||||
@ -44,6 +44,8 @@ jwt:
|
|||||||
expiration: 86400000
|
expiration: 86400000
|
||||||
|
|
||||||
im:
|
im:
|
||||||
|
tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081}
|
||||||
|
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
|
||||||
multi-login: true
|
multi-login: true
|
||||||
message-history-days: 30
|
message-history-days: 30
|
||||||
webhook-timeout-ms: 3000
|
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(
|
.requestMatchers(
|
||||||
"/api/auth/**",
|
"/api/auth/**",
|
||||||
"/api/sdk/**",
|
"/api/sdk/**",
|
||||||
|
"/api/internal/sdk/**",
|
||||||
"/actuator/health",
|
"/actuator/health",
|
||||||
"/actuator/info"
|
"/actuator/info"
|
||||||
).permitAll()
|
).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;
|
package com.xuqm.tenant.controller;
|
||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.tenant.entity.AppEntity;
|
||||||
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
||||||
import com.xuqm.tenant.repository.AppRepository;
|
|
||||||
import com.xuqm.tenant.repository.FeatureServiceRepository;
|
import com.xuqm.tenant.repository.FeatureServiceRepository;
|
||||||
|
import com.xuqm.tenant.service.SdkAppProvisioningService;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@ -18,8 +19,8 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/sdk")
|
@RequestMapping("/api/sdk")
|
||||||
public class SdkConfigController {
|
public class SdkConfigController {
|
||||||
|
|
||||||
private final AppRepository appRepository;
|
|
||||||
private final FeatureServiceRepository featureServiceRepository;
|
private final FeatureServiceRepository featureServiceRepository;
|
||||||
|
private final SdkAppProvisioningService sdkAppProvisioningService;
|
||||||
|
|
||||||
@Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}")
|
@Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}")
|
||||||
private String imWsUrl;
|
private String imWsUrl;
|
||||||
@ -30,28 +31,24 @@ public class SdkConfigController {
|
|||||||
@Value("${sdk.im-api-url:https://im.dev.xuqinmin.com}")
|
@Value("${sdk.im-api-url:https://im.dev.xuqinmin.com}")
|
||||||
private String imApiUrl;
|
private String imApiUrl;
|
||||||
|
|
||||||
public SdkConfigController(AppRepository appRepository,
|
public SdkConfigController(FeatureServiceRepository featureServiceRepository,
|
||||||
FeatureServiceRepository featureServiceRepository) {
|
SdkAppProvisioningService sdkAppProvisioningService) {
|
||||||
this.appRepository = appRepository;
|
|
||||||
this.featureServiceRepository = featureServiceRepository;
|
this.featureServiceRepository = featureServiceRepository;
|
||||||
|
this.sdkAppProvisioningService = sdkAppProvisioningService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/sdk/config?appId=XXX — public, no auth required.
|
* GET /api/sdk/config?appId=XXX — public, no auth required.
|
||||||
*
|
*
|
||||||
* Returns SDK configuration URLs and enabled feature flags for the given appId.
|
* Returns SDK configuration URLs and enabled feature flags for the given appId/appKey.
|
||||||
* Returns 404 if the appId does not exist in the system.
|
* The demo app (`ak_demo_chat`) is auto-provisioned if it does not exist.
|
||||||
*/
|
*/
|
||||||
@GetMapping("/config")
|
@GetMapping("/config")
|
||||||
public ResponseEntity<ApiResponse<SdkConfigResponse>> getConfig(
|
public ResponseEntity<ApiResponse<SdkConfigResponse>> getConfig(
|
||||||
@RequestParam String appId) {
|
@RequestParam String appId) {
|
||||||
|
|
||||||
if (!appRepository.existsById(appId)) {
|
AppEntity app = sdkAppProvisioningService.resolveApp(appId);
|
||||||
return ResponseEntity.status(404)
|
List<FeatureServiceEntity> features = featureServiceRepository.findByAppId(app.getAppKey());
|
||||||
.body(ApiResponse.error(404, "App not found: " + appId));
|
|
||||||
}
|
|
||||||
|
|
||||||
List<FeatureServiceEntity> features = featureServiceRepository.findByAppId(appId);
|
|
||||||
|
|
||||||
boolean imEnabled = features.stream()
|
boolean imEnabled = features.stream()
|
||||||
.anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.IM && f.isEnabled());
|
.anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.IM && f.isEnabled());
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import java.util.Optional;
|
|||||||
public interface TenantRepository extends JpaRepository<TenantEntity, String> {
|
public interface TenantRepository extends JpaRepository<TenantEntity, String> {
|
||||||
Optional<TenantEntity> findByUsername(String username);
|
Optional<TenantEntity> findByUsername(String username);
|
||||||
Optional<TenantEntity> findByEmail(String email);
|
Optional<TenantEntity> findByEmail(String email);
|
||||||
|
Optional<TenantEntity> findFirstByOrderByCreatedAtAsc();
|
||||||
boolean existsByUsername(String username);
|
boolean existsByUsername(String username);
|
||||||
boolean existsByEmail(String email);
|
boolean existsByEmail(String email);
|
||||||
List<TenantEntity> findByParentId(String parentId);
|
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
|
include: health,info
|
||||||
|
|
||||||
sdk:
|
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}
|
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}
|
file-service-url: ${SDK_FILE_SERVICE_URL:https://file.dev.xuqinmin.com}
|
||||||
im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com}
|
im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户