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));
}
@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();
}
}