diff --git a/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java b/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java index 99e8d49..3683ccf 100644 --- a/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java +++ b/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java @@ -56,6 +56,12 @@ public class DemoUserController { return ApiResponse.success(userService.searchUsers(appId, keyword)); } + @GetMapping("/users/members") + public ApiResponse> listMembers( + @RequestParam String appId) { + return ApiResponse.success(userService.listMembers(appId)); + } + private String resolveUserId(Authentication auth) { if (auth == null || !auth.isAuthenticated()) { throw new BusinessException(401, "Not authenticated"); diff --git a/demo-service/src/main/java/com/xuqm/demo/repository/DemoUserRepository.java b/demo-service/src/main/java/com/xuqm/demo/repository/DemoUserRepository.java index 4c1847e..0ab3856 100644 --- a/demo-service/src/main/java/com/xuqm/demo/repository/DemoUserRepository.java +++ b/demo-service/src/main/java/com/xuqm/demo/repository/DemoUserRepository.java @@ -18,4 +18,6 @@ public interface DemoUserRepository extends JpaRepository searchByKeyword(@Param("appId") String appId, @Param("keyword") String keyword); + + List findAllByAppIdOrderByCreatedAtAsc(String appId); } diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java index 3b71f58..faa3101 100644 --- a/demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java @@ -21,7 +21,7 @@ public class DemoAppSecretClient { private final RestTemplate restTemplate; private final Map cache = new ConcurrentHashMap<>(); - @Value("${demo.tenant-service-url:http://xuqm-tenant-service:8081}") + @Value("${demo.tenant-service-url:http://192.168.116.9:8081}") private String tenantServiceUrl; @Value("${demo.internal-token:xuqm-internal-token}") diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java index f57dd18..6b00929 100644 --- a/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java @@ -35,7 +35,7 @@ public class DemoAuthService { private final RestTemplate restTemplate; private final DemoAppSecretClient appSecretClient; - @Value("${demo.im-service-url:http://xuqm-im-service:8082}") + @Value("${demo.im-service-url:http://192.168.116.9:8082}") private String imServiceUrl; public DemoAuthService(DemoUserRepository userRepository, diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java index ea6fc82..69de2e7 100644 --- a/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java @@ -79,6 +79,14 @@ public class DemoUserService { .toList(); } + @Transactional(readOnly = true) + public List listMembers(String appId) { + return userRepository.findAllByAppIdOrderByCreatedAtAsc(appId) + .stream() + .map(this::toProfile) + .toList(); + } + private UserProfile toProfile(DemoUserEntity user) { return new UserProfile( user.getAppId(), diff --git a/demo-service/src/main/resources/application.yml b/demo-service/src/main/resources/application.yml index 1fa3a3f..7ec0728 100644 --- a/demo-service/src/main/resources/application.yml +++ b/demo-service/src/main/resources/application.yml @@ -35,9 +35,9 @@ jwt: expiration: 86400000 demo: - tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081} + tenant-service-url: ${TENANT_SERVICE_URL:http://192.168.116.9: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://192.168.116.9:8082} logging: level: diff --git a/im-service/src/main/java/com/xuqm/im/cluster/ImClusterListener.java b/im-service/src/main/java/com/xuqm/im/cluster/ImClusterListener.java index cf22bae..50118b3 100644 --- a/im-service/src/main/java/com/xuqm/im/cluster/ImClusterListener.java +++ b/im-service/src/main/java/com/xuqm/im/cluster/ImClusterListener.java @@ -25,9 +25,33 @@ public class ImClusterListener implements MessageListener { public void onMessage(Message message, byte[] pattern) { try { ClusterMessage envelope = objectMapper.readValue(message.getBody(), ClusterMessage.class); - messagingTemplate.convertAndSend(envelope.destination(), envelope.message()); + send(envelope); } catch (Exception e) { log.error("Failed to process cluster message from Redis", e); } } + + private void send(ClusterMessage envelope) { + String destination = envelope.destination(); + log.debug("cluster delivery destination={} messageId={} chatType={} msgType={} from={} to={}", + destination, + envelope.message().getId(), + envelope.message().getChatType(), + envelope.message().getMsgType(), + envelope.message().getFromUserId(), + envelope.message().getToId()); + if (destination.startsWith("/user/")) { + String remainder = destination.substring("/user/".length()); + int separator = remainder.indexOf('/'); + if (separator > 0) { + String userId = remainder.substring(0, separator); + String userDestination = remainder.substring(separator); + log.debug("convertAndSendToUser userId={} destination={}", userId, userDestination); + messagingTemplate.convertAndSendToUser(userId, userDestination, envelope.message()); + return; + } + } + log.debug("convertAndSend destination={}", destination); + messagingTemplate.convertAndSend(destination, envelope.message()); + } } diff --git a/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java b/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java index 0753404..0130064 100644 --- a/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java +++ b/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java @@ -4,6 +4,7 @@ import com.xuqm.common.security.JwtAuthFilter; import com.xuqm.common.security.JwtUtil; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -12,6 +13,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @EnableWebSecurity @@ -27,8 +33,10 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> {}) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/im/auth/**", "/ws/**", "/actuator/**").permitAll() .anyRequest().authenticated() ) @@ -40,4 +48,22 @@ public class SecurityConfig { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "http://127.0.0.1:*", + "http://192.168.116.9:*" + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Location")); + config.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImFriendRequestEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImFriendRequestEntity.java index 1b32192..574b023 100644 --- a/im-service/src/main/java/com/xuqm/im/entity/ImFriendRequestEntity.java +++ b/im-service/src/main/java/com/xuqm/im/entity/ImFriendRequestEntity.java @@ -1,5 +1,7 @@ package com.xuqm.im.entity; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Index; @@ -53,9 +55,11 @@ public class ImFriendRequestEntity extends BaseIdEntity { public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) public LocalDateTime getReviewedAt() { return reviewedAt; } public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } } diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java index 6287ef8..3439ccc 100644 --- a/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java +++ b/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java @@ -1,6 +1,8 @@ package com.xuqm.im.entity; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.xuqm.im.json.EpochMillisLocalDateTimeDeserializer; import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -61,6 +63,7 @@ public class ImMessageEntity { @Column(nullable = false) @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) + @JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class) private LocalDateTime createdAt; public String getId() { return id; } @@ -94,6 +97,7 @@ public class ImMessageEntity { public void setGroupReadCount(Integer groupReadCount) { this.groupReadCount = groupReadCount; } @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) + @JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class) public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } } diff --git a/im-service/src/main/java/com/xuqm/im/json/EpochMillisLocalDateTimeDeserializer.java b/im-service/src/main/java/com/xuqm/im/json/EpochMillisLocalDateTimeDeserializer.java new file mode 100644 index 0000000..9e22ee0 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/json/EpochMillisLocalDateTimeDeserializer.java @@ -0,0 +1,19 @@ +package com.xuqm.im.json; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +public class EpochMillisLocalDateTimeDeserializer extends JsonDeserializer { + + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + long epochMillis = p.getValueAsLong(); + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/model/SendMessageRequest.java b/im-service/src/main/java/com/xuqm/im/model/SendMessageRequest.java index 1380abd..ad2a999 100644 --- a/im-service/src/main/java/com/xuqm/im/model/SendMessageRequest.java +++ b/im-service/src/main/java/com/xuqm/im/model/SendMessageRequest.java @@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public record SendMessageRequest( + String messageId, @NotBlank String toId, @NotNull ImMessageEntity.ChatType chatType, @NotNull ImMessageEntity.MsgType msgType, diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImFriendRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImFriendRepository.java index 94044a0..5798ee6 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/ImFriendRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/ImFriendRepository.java @@ -13,6 +13,8 @@ public interface ImFriendRepository extends JpaRepository Optional findByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId); + boolean existsByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId); + @Transactional void deleteByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId); } diff --git a/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java b/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java index 6a7e46d..72ccbf9 100644 --- a/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java +++ b/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java @@ -1,9 +1,14 @@ package com.xuqm.im.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.xuqm.common.exception.BusinessException; +import com.xuqm.im.cluster.ImClusterPublisher; import com.xuqm.im.entity.ImFriendRequestEntity; +import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.repository.ImFriendRequestRepository; import com.xuqm.im.repository.ImFriendRepository; +import com.xuqm.im.repository.ImMessageRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,16 +21,25 @@ public class FriendRequestService { private final ImFriendRequestRepository requestRepository; private final ImFriendRepository friendRepository; + private final ImMessageRepository messageRepository; + private final ImClusterPublisher clusterPublisher; + private final ObjectMapper objectMapper; public FriendRequestService(ImFriendRequestRepository requestRepository, - ImFriendRepository friendRepository) { + ImFriendRepository friendRepository, + ImMessageRepository messageRepository, + ImClusterPublisher clusterPublisher, + ObjectMapper objectMapper) { this.requestRepository = requestRepository; this.friendRepository = friendRepository; + this.messageRepository = messageRepository; + this.clusterPublisher = clusterPublisher; + this.objectMapper = objectMapper; } @Transactional public ImFriendRequestEntity send(String appId, String fromUserId, String toUserId, String remark) { - return requestRepository.findByAppIdAndFromUserIdAndToUserId(appId, fromUserId, toUserId) + ImFriendRequestEntity saved = requestRepository.findByAppIdAndFromUserIdAndToUserId(appId, fromUserId, toUserId) .orElseGet(() -> { ImFriendRequestEntity entity = new ImFriendRequestEntity(); entity.setId(UUID.randomUUID().toString()); @@ -37,6 +51,18 @@ public class FriendRequestService { entity.setCreatedAt(LocalDateTime.now()); return requestRepository.save(entity); }); + if (!ImFriendRequestEntity.Status.PENDING.name().equals(saved.getStatus())) { + return saved; + } + publishNotification( + saved, + saved.getFromUserId(), + saved.getToUserId(), + "FRIEND_REQUEST", + "好友申请", + buildDescription("好友申请", saved.getRemark()) + ); + return saved; } @Transactional @@ -51,6 +77,14 @@ public class FriendRequestService { friendRepository .findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId()) .orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId())); + publishNotification( + request, + request.getToUserId(), + request.getFromUserId(), + "FRIEND_REQUEST_STATUS", + "好友申请已通过", + buildDescription("好友申请已通过", request.getRemark()) + ); return request; } @@ -59,11 +93,22 @@ public class FriendRequestService { ImFriendRequestEntity request = getRequest(appId, requestId, operatorId); request.setStatus(ImFriendRequestEntity.Status.REJECTED.name()); request.setReviewedAt(LocalDateTime.now()); - return requestRepository.save(request); + ImFriendRequestEntity saved = requestRepository.save(request); + publishNotification( + saved, + saved.getToUserId(), + saved.getFromUserId(), + "FRIEND_REQUEST_STATUS", + "好友申请已拒绝", + buildDescription("好友申请已拒绝", saved.getRemark()) + ); + return saved; } public List incoming(String appId, String userId) { - return requestRepository.findByAppIdAndToUserId(appId, userId); + return requestRepository.findByAppIdAndToUserId(appId, userId).stream() + .filter(request -> ImFriendRequestEntity.Status.PENDING.name().equals(request.getStatus())) + .toList(); } public List outgoing(String appId, String userId) { @@ -86,4 +131,53 @@ public class FriendRequestService { entity.setFriendId(friendId); return friendRepository.save(entity); } + + private void publishNotification( + ImFriendRequestEntity request, + String fromUserId, + String toUserId, + String type, + String title, + String content + ) { + ImMessageEntity message = new ImMessageEntity(); + message.setId(UUID.randomUUID().toString()); + message.setAppId(request.getAppId()); + message.setFromUserId(fromUserId); + message.setToId(toUserId); + message.setChatType(ImMessageEntity.ChatType.SINGLE); + message.setMsgType(ImMessageEntity.MsgType.NOTIFY); + message.setContent(buildNotificationContent(type, title, content, request)); + message.setStatus(ImMessageEntity.MsgStatus.SENT); + message.setCreatedAt(LocalDateTime.now()); + ImMessageEntity saved = messageRepository.save(message); + clusterPublisher.publish("/user/" + toUserId + "/queue/messages", saved); + } + + private String buildNotificationContent( + String type, + String title, + String content, + ImFriendRequestEntity request + ) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("type", type); + node.put("title", title); + node.put("content", content); + node.put("requestId", request.getId()); + node.put("fromUserId", request.getFromUserId()); + node.put("toUserId", request.getToUserId()); + node.put("status", request.getStatus()); + if (request.getRemark() != null) { + node.put("remark", request.getRemark()); + } + return node.toString(); + } + + private String buildDescription(String prefix, String remark) { + if (remark == null || remark.isBlank()) { + return prefix; + } + return prefix + ":" + remark; + } } diff --git a/im-service/src/main/java/com/xuqm/im/service/ImAppSecretClient.java b/im-service/src/main/java/com/xuqm/im/service/ImAppSecretClient.java index edf0399..4ddb733 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImAppSecretClient.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImAppSecretClient.java @@ -21,7 +21,7 @@ public class ImAppSecretClient { private final RestTemplate restTemplate = new RestTemplate(); private final Map cache = new ConcurrentHashMap<>(); - @Value("${im.tenant-service-url:http://xuqm-tenant-service:8081}") + @Value("${im.tenant-service-url:http://192.168.116.9:8081}") private String tenantServiceUrl; @Value("${im.internal-token:xuqm-internal-token}") diff --git a/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java b/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java new file mode 100644 index 0000000..aeaeb55 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java @@ -0,0 +1,58 @@ +package com.xuqm.im.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class ImFeatureConfigClient { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final RestTemplate restTemplate; + + @Value("${im.tenant-service-url:http://192.168.116.9:8081}") + private String tenantServiceUrl; + + @Value("${im.internal-token:xuqm-internal-token}") + private String internalToken; + + public ImFeatureConfigClient() { + this.restTemplate = new RestTemplate(); + } + + public boolean allowStrangerMessage(String appId) { + String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl) + .path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}") + .buildAndExpand(appId, "ANDROID", "IM") + .toUriString(); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + try { + ResponseEntity 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) { + String config = body.path("data").path("config").asText(""); + if (config.isBlank()) { + return false; + } + return OBJECT_MAPPER.readTree(config).path("allowStrangerMessage").asBoolean(false); + } + } catch (Exception e) { + return false; + } + return false; + } +} diff --git a/im-service/src/main/java/com/xuqm/im/service/ImPushBridgeClient.java b/im-service/src/main/java/com/xuqm/im/service/ImPushBridgeClient.java index 67e101b..4d9346a 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImPushBridgeClient.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImPushBridgeClient.java @@ -19,7 +19,7 @@ public class ImPushBridgeClient { private final HttpClient httpClient = HttpClient.newHttpClient(); private final ObjectMapper objectMapper; - @Value("${im.push-service-url:http://xuqm-push-service:8083}") + @Value("${im.push-service-url:http://192.168.116.9:8083}") private String pushServiceUrl; @Value("${im.internal-token:xuqm-internal-token}") diff --git a/im-service/src/main/java/com/xuqm/im/service/MessageService.java b/im-service/src/main/java/com/xuqm/im/service/MessageService.java index ffb9fbe..f645e1e 100644 --- a/im-service/src/main/java/com/xuqm/im/service/MessageService.java +++ b/im-service/src/main/java/com/xuqm/im/service/MessageService.java @@ -8,12 +8,15 @@ import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.WebhookConfigEntity; import com.xuqm.im.model.ConversationView; import com.xuqm.im.model.SendMessageRequest; +import com.xuqm.im.repository.ImFriendRepository; import com.xuqm.im.repository.WebhookConfigRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.net.URI; import java.net.http.HttpClient; @@ -30,6 +33,8 @@ import java.util.UUID; @Service public class MessageService { + private static final Logger log = LoggerFactory.getLogger(MessageService.class); + private final ImMessageRepository messageRepository; private final WebhookConfigRepository webhookRepository; private final KeywordFilterService keywordFilterService; @@ -38,6 +43,8 @@ public class MessageService { private final BlacklistService blacklistService; private final ConversationStateService conversationStateService; private final ImPushBridgeClient pushBridgeClient; + private final ImFeatureConfigClient featureConfigClient; + private final ImFriendRepository friendRepository; private final ObjectMapper objectMapper; @Value("${im.webhook-timeout-ms:3000}") @@ -51,6 +58,8 @@ public class MessageService { BlacklistService blacklistService, ConversationStateService conversationStateService, ImPushBridgeClient pushBridgeClient, + ImFeatureConfigClient featureConfigClient, + ImFriendRepository friendRepository, ObjectMapper objectMapper) { this.messageRepository = messageRepository; this.webhookRepository = webhookRepository; @@ -60,6 +69,8 @@ public class MessageService { this.blacklistService = blacklistService; this.conversationStateService = conversationStateService; this.pushBridgeClient = pushBridgeClient; + this.featureConfigClient = featureConfigClient; + this.friendRepository = friendRepository; this.objectMapper = objectMapper; } @@ -82,10 +93,15 @@ public class MessageService { } } else if (blacklistService.isEitherBlocked(appId, fromUserId, req.toId())) { throw new BusinessException(403, "已被拉黑,无法发送消息"); + } else if (!isFriend(appId, fromUserId, req.toId()) + && !featureConfigClient.allowStrangerMessage(appId)) { + throw new BusinessException(403, "仅允许好友之间发送消息"); } ImMessageEntity message = new ImMessageEntity(); - message.setId(UUID.randomUUID().toString()); + message.setId(req.messageId() != null && !req.messageId().isBlank() + ? req.messageId() + : UUID.randomUUID().toString()); message.setAppId(appId); message.setFromUserId(fromUserId); message.setToId(req.toId()); @@ -103,8 +119,12 @@ public class MessageService { String destination = req.chatType() == ImMessageEntity.ChatType.SINGLE ? "/user/" + req.toId() + "/queue/messages" : "/topic/group/" + req.toId(); + log.debug("send message appId={} from={} to={} chatType={} msgType={} destination={}", + appId, fromUserId, req.toId(), req.chatType(), req.msgType(), destination); clusterPublisher.publish(destination, saved); if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) { + log.debug("echo message back to sender appId={} from={} to={}", + appId, fromUserId, req.toId()); clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", saved); conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId())); pushBridgeClient.notifyUsers( @@ -132,6 +152,11 @@ public class MessageService { return saved; } + private boolean isFriend(String appId, String userId, String friendId) { + return friendRepository.existsByAppIdAndUserIdAndFriendId(appId, userId, friendId) + || friendRepository.existsByAppIdAndUserIdAndFriendId(appId, friendId, userId); + } + public ImMessageEntity revoke(String appId, String messageId, String requestUserId) { ImMessageEntity message = messageRepository.findById(messageId) .orElseThrow(() -> new BusinessException(404, "消息不存在")); @@ -146,11 +171,14 @@ public class MessageService { ImMessageEntity saved = messageRepository.save(message); if (saved.getChatType() == ImMessageEntity.ChatType.SINGLE) { + log.debug("revoke single messageId={} destinationTo={} destinationFrom={}", + saved.getId(), saved.getToId(), saved.getFromUserId()); clusterPublisher.publish("/user/" + saved.getToId() + "/queue/messages", saved); if (!saved.getFromUserId().equals(saved.getToId())) { clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved); } } else { + log.debug("revoke group messageId={} groupId={}", saved.getId(), saved.getToId()); clusterPublisher.publish("/topic/group/" + saved.getToId(), saved); } return saved; @@ -166,11 +194,14 @@ public class MessageService { message.setMsgType(ImMessageEntity.MsgType.REVOKED); ImMessageEntity saved = messageRepository.save(message); if (saved.getChatType() == ImMessageEntity.ChatType.SINGLE) { + log.debug("admin revoke single messageId={} destinationTo={} destinationFrom={}", + saved.getId(), saved.getToId(), saved.getFromUserId()); clusterPublisher.publish("/user/" + saved.getToId() + "/queue/messages", saved); if (!saved.getFromUserId().equals(saved.getToId())) { clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved); } } else { + log.debug("admin revoke group messageId={} groupId={}", saved.getId(), saved.getToId()); clusterPublisher.publish("/topic/group/" + saved.getToId(), saved); } return saved; diff --git a/im-service/src/main/java/com/xuqm/im/ws/ChatController.java b/im-service/src/main/java/com/xuqm/im/ws/ChatController.java index f687ad7..457681b 100644 --- a/im-service/src/main/java/com/xuqm/im/ws/ChatController.java +++ b/im-service/src/main/java/com/xuqm/im/ws/ChatController.java @@ -23,7 +23,7 @@ public class ChatController { if (principal == null) return; String userId = principal.getName(); SendMessageRequest req = new SendMessageRequest( - request.toId(), request.chatType(), request.msgType(), + request.messageId(), request.toId(), request.chatType(), request.msgType(), request.content(), request.mentionedUserIds() ); messageService.send(request.appId(), userId, req); @@ -36,7 +36,7 @@ public class ChatController { } public record WsMessageRequest( - String appId, String toId, + String appId, String messageId, String toId, ImMessageEntity.ChatType chatType, ImMessageEntity.MsgType msgType, String content, String mentionedUserIds diff --git a/im-service/src/main/resources/application.yml b/im-service/src/main/resources/application.yml index b7c798f..cfa4025 100644 --- a/im-service/src/main/resources/application.yml +++ b/im-service/src/main/resources/application.yml @@ -44,9 +44,9 @@ jwt: expiration: 86400000 im: - tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081} + tenant-service-url: ${TENANT_SERVICE_URL:http://192.168.116.9:8081} internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} - push-service-url: ${PUSH_SERVICE_URL:http://xuqm-push-service:8083} + push-service-url: ${PUSH_SERVICE_URL:http://192.168.116.9:8083} multi-login: true message-history-days: 30 webhook-timeout-ms: 3000 diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index a88498e..4fcb9a4 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -12,9 +12,11 @@ import org.springframework.web.bind.annotation.PathVariable; 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.PutMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/apps/{appId}/services") @@ -51,6 +53,22 @@ public class FeatureServiceController { featureServiceManager.disable(appId, platform, serviceType))); } + @PutMapping("/config") + public ResponseEntity> updateConfig( + @PathVariable String appId, + @RequestParam FeatureServiceEntity.Platform platform, + @RequestParam FeatureServiceEntity.ServiceType serviceType, + @RequestParam boolean allowStrangerMessage, + @AuthenticationPrincipal String tenantId) { + appService.getById(appId, tenantId); + return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig( + appId, + platform, + serviceType, + featureServiceManager.buildAllowStrangerConfig(allowStrangerMessage) + ))); + } + /** Submit an activation request for ops approval. */ @PostMapping("/request-activation") public ResponseEntity> requestActivation( diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java index 2dce4ea..effb604 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java @@ -2,13 +2,16 @@ package com.xuqm.tenant.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.service.SdkAppProvisioningService; +import com.xuqm.tenant.service.FeatureServiceManager; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @@ -18,12 +21,15 @@ import java.util.Map; public class InternalSdkController { private final SdkAppProvisioningService provisioningService; + private final FeatureServiceManager featureServiceManager; @Value("${sdk.internal-token:xuqm-internal-token}") private String internalToken; - public InternalSdkController(SdkAppProvisioningService provisioningService) { + public InternalSdkController(SdkAppProvisioningService provisioningService, + FeatureServiceManager featureServiceManager) { this.provisioningService = provisioningService; + this.featureServiceManager = featureServiceManager; } @GetMapping("/apps/{appId}/secret") @@ -40,4 +46,22 @@ public class InternalSdkController { "appSecret", app.getAppSecret() ))); } + + @GetMapping("/apps/{appId}/services/{platform}/{serviceType}") + public ResponseEntity>> getService( + @PathVariable String appId, + @PathVariable FeatureServiceEntity.Platform platform, + @PathVariable FeatureServiceEntity.ServiceType serviceType, + @RequestHeader(value = "X-Internal-Token", required = false) String token) { + if (token == null || !internalToken.equals(token)) { + return ResponseEntity.status(403) + .body(ApiResponse.error(403, "Forbidden")); + } + provisioningService.resolveApp(appId); + FeatureServiceEntity service = featureServiceManager.getOrFail(appId, platform, serviceType); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "enabled", service.isEnabled(), + "config", service.getConfig() == null ? "" : service.getConfig() + ))); + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java index f5b3324..551169d 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java @@ -1,17 +1,24 @@ package com.xuqm.tenant.entity; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import java.time.LocalDateTime; +import java.security.SecureRandom; +import java.util.Base64; @Entity @Table(name = "t_feature_service") public class FeatureServiceEntity { + private static final SecureRandom RANDOM = new SecureRandom(); + public enum Platform { ANDROID, IOS, HARMONY } public enum ServiceType { IM, PUSH, UPDATE } @@ -35,6 +42,10 @@ public class FeatureServiceEntity { @Column(columnDefinition = "TEXT") private String config; + @JsonIgnore + @Column(name = "secret_key", nullable = false, length = 128) + private String secretKey; + @Column(nullable = false) private LocalDateTime createdAt; @@ -56,6 +67,20 @@ public class FeatureServiceEntity { public String getConfig() { return config; } public void setConfig(String config) { this.config = config; } + @JsonIgnore + public String getSecretKey() { return secretKey; } + public void setSecretKey(String secretKey) { this.secretKey = secretKey; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + @PrePersist + @PreUpdate + void ensureSecretKey() { + if (secretKey == null || secretKey.isBlank()) { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + secretKey = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index f7a2c27..ea70ad6 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -1,5 +1,8 @@ package com.xuqm.tenant.service; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.xuqm.common.exception.BusinessException; import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity; @@ -18,11 +21,14 @@ public class FeatureServiceManager { private final FeatureServiceRepository repository; private final ServiceActivationRequestRepository requestRepository; + private final ObjectMapper objectMapper; public FeatureServiceManager(FeatureServiceRepository repository, - ServiceActivationRequestRepository requestRepository) { + ServiceActivationRequestRepository requestRepository, + ObjectMapper objectMapper) { this.repository = repository; this.requestRepository = requestRepository; + this.objectMapper = objectMapper; } public List listByApp(String appId) { @@ -129,4 +135,36 @@ public class FeatureServiceManager { .orElseThrow(() -> new BusinessException(404, "服务未配置")); } + @Transactional + public FeatureServiceEntity updateConfig(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType, + String config) { + FeatureServiceEntity entity = getOrFail(appId, platform, serviceType); + entity.setConfig(config); + return repository.save(entity); + } + + public boolean allowStrangerMessage(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + FeatureServiceEntity entity = repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) + .orElse(null); + if (entity == null || entity.getConfig() == null || entity.getConfig().isBlank()) { + return false; + } + try { + JsonNode node = objectMapper.readTree(entity.getConfig()); + return node.path("allowStrangerMessage").asBoolean(false); + } catch (Exception e) { + return false; + } + } + + public String buildAllowStrangerConfig(boolean allowStrangerMessage) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("allowStrangerMessage", allowStrangerMessage); + return node.toString(); + } + }