feat(sample): 添加示例应用的核心功能模块

- 实现环境配置管理,支持外部和本地主机模式切换
- 集成Demo API接口,包含登录、注册、文件上传等功能
- 构建附件处理仓库,支持图片、视频、音频和文件发送
- 开发认证仓库,管理用户会话和IM令牌刷新机制
- 添加语音录制功能,支持实时音频消息录制
- 创建依赖注入容器,统一管理应用组件实例
- 实现登录界面,提供用户认证交互功能
- 开发聊天界面,集成消息收发和媒体处理功能
这个提交包含在:
XuqmGroup 2026-04-28 16:08:07 +08:00
父节点 763c097289
当前提交 962e1dc722
共有 24 个文件被更改,包括 402 次插入18 次删除

查看文件

@ -56,6 +56,12 @@ public class DemoUserController {
return ApiResponse.success(userService.searchUsers(appId, keyword)); 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) { private String resolveUserId(Authentication auth) {
if (auth == null || !auth.isAuthenticated()) { if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(401, "Not authenticated"); 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.userId) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
"LOWER(u.nickname) LIKE LOWER(CONCAT('%', :keyword, '%')))") "LOWER(u.nickname) LIKE LOWER(CONCAT('%', :keyword, '%')))")
List<DemoUserEntity> searchByKeyword(@Param("appId") String appId, @Param("keyword") String 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 RestTemplate restTemplate;
private final Map<String, String> cache = new ConcurrentHashMap<>(); 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; private String tenantServiceUrl;
@Value("${demo.internal-token:xuqm-internal-token}") @Value("${demo.internal-token:xuqm-internal-token}")

查看文件

@ -35,7 +35,7 @@ public class DemoAuthService {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final DemoAppSecretClient appSecretClient; 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; private String imServiceUrl;
public DemoAuthService(DemoUserRepository userRepository, public DemoAuthService(DemoUserRepository userRepository,

查看文件

@ -79,6 +79,14 @@ public class DemoUserService {
.toList(); .toList();
} }
@Transactional(readOnly = true)
public List<UserProfile> listMembers(String appId) {
return userRepository.findAllByAppIdOrderByCreatedAtAsc(appId)
.stream()
.map(this::toProfile)
.toList();
}
private UserProfile toProfile(DemoUserEntity user) { private UserProfile toProfile(DemoUserEntity user) {
return new UserProfile( return new UserProfile(
user.getAppId(), user.getAppId(),

查看文件

@ -35,9 +35,9 @@ jwt:
expiration: 86400000 expiration: 86400000
demo: 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} 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: logging:
level: level:

查看文件

@ -25,9 +25,33 @@ public class ImClusterListener implements MessageListener {
public void onMessage(Message message, byte[] pattern) { public void onMessage(Message message, byte[] pattern) {
try { try {
ClusterMessage envelope = objectMapper.readValue(message.getBody(), ClusterMessage.class); ClusterMessage envelope = objectMapper.readValue(message.getBody(), ClusterMessage.class);
messagingTemplate.convertAndSend(envelope.destination(), envelope.message()); send(envelope);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to process cluster message from Redis", 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 com.xuqm.common.security.JwtUtil;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 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.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 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 @Configuration
@EnableWebSecurity @EnableWebSecurity
@ -27,8 +33,10 @@ public class SecurityConfig {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> {})
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/im/auth/**", "/ws/**", "/actuator/**").permitAll() .requestMatchers("/api/im/auth/**", "/ws/**", "/actuator/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
@ -40,4 +48,22 @@ public class SecurityConfig {
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); 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; 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.Index; import jakarta.persistence.Index;
@ -53,9 +55,11 @@ public class ImFriendRequestEntity extends BaseIdEntity {
public String getStatus() { return status; } public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; } public void setStatus(String 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; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getReviewedAt() { return reviewedAt; } public LocalDateTime getReviewedAt() { return reviewedAt; }
public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; }
} }

查看文件

@ -1,6 +1,8 @@
package com.xuqm.im.entity; package com.xuqm.im.entity;
import com.fasterxml.jackson.databind.annotation.JsonSerialize; 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 com.xuqm.im.json.EpochMillisLocalDateTimeSerializer;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
@ -61,6 +63,7 @@ public class ImMessageEntity {
@Column(nullable = false) @Column(nullable = false)
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.class)
private LocalDateTime createdAt; private LocalDateTime createdAt;
public String getId() { return id; } public String getId() { return id; }
@ -94,6 +97,7 @@ public class ImMessageEntity {
public void setGroupReadCount(Integer groupReadCount) { this.groupReadCount = groupReadCount; } public void setGroupReadCount(Integer groupReadCount) { this.groupReadCount = groupReadCount; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
@JsonDeserialize(using = EpochMillisLocalDateTimeDeserializer.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,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; import jakarta.validation.constraints.NotNull;
public record SendMessageRequest( public record SendMessageRequest(
String messageId,
@NotBlank String toId, @NotBlank String toId,
@NotNull ImMessageEntity.ChatType chatType, @NotNull ImMessageEntity.ChatType chatType,
@NotNull ImMessageEntity.MsgType msgType, @NotNull ImMessageEntity.MsgType msgType,

查看文件

@ -13,6 +13,8 @@ public interface ImFriendRepository extends JpaRepository<ImFriendEntity, Long>
Optional<ImFriendEntity> findByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId); Optional<ImFriendEntity> findByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId);
boolean existsByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId);
@Transactional @Transactional
void deleteByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId); void deleteByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId);
} }

查看文件

@ -1,9 +1,14 @@
package com.xuqm.im.service; 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.common.exception.BusinessException;
import com.xuqm.im.cluster.ImClusterPublisher;
import com.xuqm.im.entity.ImFriendRequestEntity; import com.xuqm.im.entity.ImFriendRequestEntity;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.repository.ImFriendRequestRepository; import com.xuqm.im.repository.ImFriendRequestRepository;
import com.xuqm.im.repository.ImFriendRepository; import com.xuqm.im.repository.ImFriendRepository;
import com.xuqm.im.repository.ImMessageRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -16,16 +21,25 @@ public class FriendRequestService {
private final ImFriendRequestRepository requestRepository; private final ImFriendRequestRepository requestRepository;
private final ImFriendRepository friendRepository; private final ImFriendRepository friendRepository;
private final ImMessageRepository messageRepository;
private final ImClusterPublisher clusterPublisher;
private final ObjectMapper objectMapper;
public FriendRequestService(ImFriendRequestRepository requestRepository, public FriendRequestService(ImFriendRequestRepository requestRepository,
ImFriendRepository friendRepository) { ImFriendRepository friendRepository,
ImMessageRepository messageRepository,
ImClusterPublisher clusterPublisher,
ObjectMapper objectMapper) {
this.requestRepository = requestRepository; this.requestRepository = requestRepository;
this.friendRepository = friendRepository; this.friendRepository = friendRepository;
this.messageRepository = messageRepository;
this.clusterPublisher = clusterPublisher;
this.objectMapper = objectMapper;
} }
@Transactional @Transactional
public ImFriendRequestEntity send(String appId, String fromUserId, String toUserId, String remark) { 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(() -> { .orElseGet(() -> {
ImFriendRequestEntity entity = new ImFriendRequestEntity(); ImFriendRequestEntity entity = new ImFriendRequestEntity();
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());
@ -37,6 +51,18 @@ public class FriendRequestService {
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
return requestRepository.save(entity); 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 @Transactional
@ -51,6 +77,14 @@ public class FriendRequestService {
friendRepository friendRepository
.findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId()) .findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId())
.orElseGet(() -> friendEntity(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; return request;
} }
@ -59,11 +93,22 @@ public class FriendRequestService {
ImFriendRequestEntity request = getRequest(appId, requestId, operatorId); ImFriendRequestEntity request = getRequest(appId, requestId, operatorId);
request.setStatus(ImFriendRequestEntity.Status.REJECTED.name()); request.setStatus(ImFriendRequestEntity.Status.REJECTED.name());
request.setReviewedAt(LocalDateTime.now()); 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) { 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) { public List<ImFriendRequestEntity> outgoing(String appId, String userId) {
@ -86,4 +131,53 @@ public class FriendRequestService {
entity.setFriendId(friendId); entity.setFriendId(friendId);
return friendRepository.save(entity); 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 RestTemplate restTemplate = new RestTemplate();
private final Map<String, String> cache = new ConcurrentHashMap<>(); 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; private String tenantServiceUrl;
@Value("${im.internal-token:xuqm-internal-token}") @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 HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper; 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; private String pushServiceUrl;
@Value("${im.internal-token:xuqm-internal-token}") @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.entity.WebhookConfigEntity;
import com.xuqm.im.model.ConversationView; import com.xuqm.im.model.ConversationView;
import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.repository.ImFriendRepository;
import com.xuqm.im.repository.WebhookConfigRepository; import com.xuqm.im.repository.WebhookConfigRepository;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@ -30,6 +33,8 @@ import java.util.UUID;
@Service @Service
public class MessageService { public class MessageService {
private static final Logger log = LoggerFactory.getLogger(MessageService.class);
private final ImMessageRepository messageRepository; private final ImMessageRepository messageRepository;
private final WebhookConfigRepository webhookRepository; private final WebhookConfigRepository webhookRepository;
private final KeywordFilterService keywordFilterService; private final KeywordFilterService keywordFilterService;
@ -38,6 +43,8 @@ public class MessageService {
private final BlacklistService blacklistService; private final BlacklistService blacklistService;
private final ConversationStateService conversationStateService; private final ConversationStateService conversationStateService;
private final ImPushBridgeClient pushBridgeClient; private final ImPushBridgeClient pushBridgeClient;
private final ImFeatureConfigClient featureConfigClient;
private final ImFriendRepository friendRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@Value("${im.webhook-timeout-ms:3000}") @Value("${im.webhook-timeout-ms:3000}")
@ -51,6 +58,8 @@ public class MessageService {
BlacklistService blacklistService, BlacklistService blacklistService,
ConversationStateService conversationStateService, ConversationStateService conversationStateService,
ImPushBridgeClient pushBridgeClient, ImPushBridgeClient pushBridgeClient,
ImFeatureConfigClient featureConfigClient,
ImFriendRepository friendRepository,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.webhookRepository = webhookRepository; this.webhookRepository = webhookRepository;
@ -60,6 +69,8 @@ public class MessageService {
this.blacklistService = blacklistService; this.blacklistService = blacklistService;
this.conversationStateService = conversationStateService; this.conversationStateService = conversationStateService;
this.pushBridgeClient = pushBridgeClient; this.pushBridgeClient = pushBridgeClient;
this.featureConfigClient = featureConfigClient;
this.friendRepository = friendRepository;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@ -82,10 +93,15 @@ public class MessageService {
} }
} else if (blacklistService.isEitherBlocked(appId, fromUserId, req.toId())) { } else if (blacklistService.isEitherBlocked(appId, fromUserId, req.toId())) {
throw new BusinessException(403, "已被拉黑,无法发送消息"); throw new BusinessException(403, "已被拉黑,无法发送消息");
} else if (!isFriend(appId, fromUserId, req.toId())
&& !featureConfigClient.allowStrangerMessage(appId)) {
throw new BusinessException(403, "仅允许好友之间发送消息");
} }
ImMessageEntity message = new ImMessageEntity(); 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.setAppId(appId);
message.setFromUserId(fromUserId); message.setFromUserId(fromUserId);
message.setToId(req.toId()); message.setToId(req.toId());
@ -103,8 +119,12 @@ public class MessageService {
String destination = req.chatType() == ImMessageEntity.ChatType.SINGLE String destination = req.chatType() == ImMessageEntity.ChatType.SINGLE
? "/user/" + req.toId() + "/queue/messages" ? "/user/" + req.toId() + "/queue/messages"
: "/topic/group/" + req.toId(); : "/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); clusterPublisher.publish(destination, saved);
if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) { 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); clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", saved);
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId())); conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId()));
pushBridgeClient.notifyUsers( pushBridgeClient.notifyUsers(
@ -132,6 +152,11 @@ public class MessageService {
return saved; 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) { public ImMessageEntity revoke(String appId, String messageId, String requestUserId) {
ImMessageEntity message = messageRepository.findById(messageId) ImMessageEntity message = messageRepository.findById(messageId)
.orElseThrow(() -> new BusinessException(404, "消息不存在")); .orElseThrow(() -> new BusinessException(404, "消息不存在"));
@ -146,11 +171,14 @@ public class MessageService {
ImMessageEntity saved = messageRepository.save(message); ImMessageEntity saved = messageRepository.save(message);
if (saved.getChatType() == ImMessageEntity.ChatType.SINGLE) { 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); clusterPublisher.publish("/user/" + saved.getToId() + "/queue/messages", saved);
if (!saved.getFromUserId().equals(saved.getToId())) { if (!saved.getFromUserId().equals(saved.getToId())) {
clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved); clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved);
} }
} else { } else {
log.debug("revoke group messageId={} groupId={}", saved.getId(), saved.getToId());
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved); clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
} }
return saved; return saved;
@ -166,11 +194,14 @@ public class MessageService {
message.setMsgType(ImMessageEntity.MsgType.REVOKED); message.setMsgType(ImMessageEntity.MsgType.REVOKED);
ImMessageEntity saved = messageRepository.save(message); ImMessageEntity saved = messageRepository.save(message);
if (saved.getChatType() == ImMessageEntity.ChatType.SINGLE) { 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); clusterPublisher.publish("/user/" + saved.getToId() + "/queue/messages", saved);
if (!saved.getFromUserId().equals(saved.getToId())) { if (!saved.getFromUserId().equals(saved.getToId())) {
clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved); clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved);
} }
} else { } else {
log.debug("admin revoke group messageId={} groupId={}", saved.getId(), saved.getToId());
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved); clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
} }
return saved; return saved;

查看文件

@ -23,7 +23,7 @@ public class ChatController {
if (principal == null) return; if (principal == null) return;
String userId = principal.getName(); String userId = principal.getName();
SendMessageRequest req = new SendMessageRequest( SendMessageRequest req = new SendMessageRequest(
request.toId(), request.chatType(), request.msgType(), request.messageId(), request.toId(), request.chatType(), request.msgType(),
request.content(), request.mentionedUserIds() request.content(), request.mentionedUserIds()
); );
messageService.send(request.appId(), userId, req); messageService.send(request.appId(), userId, req);
@ -36,7 +36,7 @@ public class ChatController {
} }
public record WsMessageRequest( public record WsMessageRequest(
String appId, String toId, String appId, String messageId, String toId,
ImMessageEntity.ChatType chatType, ImMessageEntity.ChatType chatType,
ImMessageEntity.MsgType msgType, ImMessageEntity.MsgType msgType,
String content, String mentionedUserIds String content, String mentionedUserIds

查看文件

@ -44,9 +44,9 @@ jwt:
expiration: 86400000 expiration: 86400000
im: 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} 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 multi-login: true
message-history-days: 30 message-history-days: 30
webhook-timeout-ms: 3000 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.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;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/apps/{appId}/services") @RequestMapping("/api/apps/{appId}/services")
@ -51,6 +53,22 @@ public class FeatureServiceController {
featureServiceManager.disable(appId, platform, serviceType))); 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. */ /** Submit an activation request for ops approval. */
@PostMapping("/request-activation") @PostMapping("/request-activation")
public ResponseEntity<ApiResponse<ServiceActivationRequestEntity>> requestActivation( public ResponseEntity<ApiResponse<ServiceActivationRequestEntity>> requestActivation(

查看文件

@ -2,13 +2,16 @@ 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.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.service.SdkAppProvisioningService; import com.xuqm.tenant.service.SdkAppProvisioningService;
import com.xuqm.tenant.service.FeatureServiceManager;
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;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
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.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Map; import java.util.Map;
@ -18,12 +21,15 @@ import java.util.Map;
public class InternalSdkController { public class InternalSdkController {
private final SdkAppProvisioningService provisioningService; private final SdkAppProvisioningService provisioningService;
private final FeatureServiceManager featureServiceManager;
@Value("${sdk.internal-token:xuqm-internal-token}") @Value("${sdk.internal-token:xuqm-internal-token}")
private String internalToken; private String internalToken;
public InternalSdkController(SdkAppProvisioningService provisioningService) { public InternalSdkController(SdkAppProvisioningService provisioningService,
FeatureServiceManager featureServiceManager) {
this.provisioningService = provisioningService; this.provisioningService = provisioningService;
this.featureServiceManager = featureServiceManager;
} }
@GetMapping("/apps/{appId}/secret") @GetMapping("/apps/{appId}/secret")
@ -40,4 +46,22 @@ public class InternalSdkController {
"appSecret", app.getAppSecret() "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; package com.xuqm.tenant.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EnumType; import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated; import jakarta.persistence.Enumerated;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.security.SecureRandom;
import java.util.Base64;
@Entity @Entity
@Table(name = "t_feature_service") @Table(name = "t_feature_service")
public class FeatureServiceEntity { public class FeatureServiceEntity {
private static final SecureRandom RANDOM = new SecureRandom();
public enum Platform { ANDROID, IOS, HARMONY } public enum Platform { ANDROID, IOS, HARMONY }
public enum ServiceType { IM, PUSH, UPDATE } public enum ServiceType { IM, PUSH, UPDATE }
@ -35,6 +42,10 @@ public class FeatureServiceEntity {
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String config; private String config;
@JsonIgnore
@Column(name = "secret_key", nullable = false, length = 128)
private String secretKey;
@Column(nullable = false) @Column(nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -56,6 +67,20 @@ public class FeatureServiceEntity {
public String getConfig() { return config; } public String getConfig() { return config; }
public void setConfig(String config) { this.config = 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 LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = 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; 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.common.exception.BusinessException;
import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
@ -18,11 +21,14 @@ public class FeatureServiceManager {
private final FeatureServiceRepository repository; private final FeatureServiceRepository repository;
private final ServiceActivationRequestRepository requestRepository; private final ServiceActivationRequestRepository requestRepository;
private final ObjectMapper objectMapper;
public FeatureServiceManager(FeatureServiceRepository repository, public FeatureServiceManager(FeatureServiceRepository repository,
ServiceActivationRequestRepository requestRepository) { ServiceActivationRequestRepository requestRepository,
ObjectMapper objectMapper) {
this.repository = repository; this.repository = repository;
this.requestRepository = requestRepository; this.requestRepository = requestRepository;
this.objectMapper = objectMapper;
} }
public List<FeatureServiceEntity> listByApp(String appId) { public List<FeatureServiceEntity> listByApp(String appId) {
@ -129,4 +135,36 @@ public class FeatureServiceManager {
.orElseThrow(() -> new BusinessException(404, "服务未配置")); .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();
}
} }