feat(sample): 添加示例应用的核心功能模块
- 实现环境配置管理,支持外部和本地主机模式切换 - 集成Demo API接口,包含登录、注册、文件上传等功能 - 构建附件处理仓库,支持图片、视频、音频和文件发送 - 开发认证仓库,管理用户会话和IM令牌刷新机制 - 添加语音录制功能,支持实时音频消息录制 - 创建依赖注入容器,统一管理应用组件实例 - 实现登录界面,提供用户认证交互功能 - 开发聊天界面,集成消息收发和媒体处理功能
这个提交包含在:
父节点
763c097289
当前提交
962e1dc722
@ -56,6 +56,12 @@ public class DemoUserController {
|
||||
return ApiResponse.success(userService.searchUsers(appId, keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/users/members")
|
||||
public ApiResponse<List<DemoUserService.UserProfile>> 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");
|
||||
|
||||
@ -18,4 +18,6 @@ public interface DemoUserRepository extends JpaRepository<DemoUserEntity, String
|
||||
"(LOWER(u.userId) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
|
||||
"LOWER(u.nickname) LIKE LOWER(CONCAT('%', :keyword, '%')))")
|
||||
List<DemoUserEntity> searchByKeyword(@Param("appId") String appId, @Param("keyword") String keyword);
|
||||
|
||||
List<DemoUserEntity> findAllByAppIdOrderByCreatedAtAsc(String appId);
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ 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}")
|
||||
@Value("${demo.tenant-service-url:http://192.168.116.9:8081}")
|
||||
private String tenantServiceUrl;
|
||||
|
||||
@Value("${demo.internal-token:xuqm-internal-token}")
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -79,6 +79,14 @@ public class DemoUserService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<UserProfile> listMembers(String appId) {
|
||||
return userRepository.findAllByAppIdOrderByCreatedAtAsc(appId)
|
||||
.stream()
|
||||
.map(this::toProfile)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private UserProfile toProfile(DemoUserEntity user) {
|
||||
return new UserProfile(
|
||||
user.getAppId(),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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<LocalDateTime> {
|
||||
|
||||
@Override
|
||||
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
|
||||
long epochMillis = p.getValueAsLong();
|
||||
return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -13,6 +13,8 @@ public interface ImFriendRepository extends JpaRepository<ImFriendEntity, Long>
|
||||
|
||||
Optional<ImFriendEntity> findByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId);
|
||||
|
||||
boolean existsByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId);
|
||||
|
||||
@Transactional
|
||||
void deleteByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId);
|
||||
}
|
||||
|
||||
@ -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<ImFriendRequestEntity> 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<ImFriendRequestEntity> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ 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}")
|
||||
@Value("${im.tenant-service-url:http://192.168.116.9:8081}")
|
||||
private String tenantServiceUrl;
|
||||
|
||||
@Value("${im.internal-token:xuqm-internal-token}")
|
||||
|
||||
@ -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<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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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}")
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<ApiResponse<FeatureServiceEntity>> 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<ApiResponse<ServiceActivationRequestEntity>> requestActivation(
|
||||
|
||||
@ -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<ApiResponse<Map<String, Object>>> 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()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<FeatureServiceEntity> 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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户