feat(android-sdk): 添加完整的IM客户端SDK实现

- 实现了Android SDK的完整IM功能接口,包括消息、群组、好友、会话等核心功能
- 添加了消息收发、历史记录、撤回编辑等完整的消息操作能力
- 实现了群组管理功能,包括创建、成员管理、权限设置等操作
- 添加了好友关系链管理,支持添加、删除、分组等操作
- 实现了会话管理功能,包括置顶、免打扰、已读状态等
- 添加了黑名单、资料管理、搜索等辅助功能
- 补齐了批量操作接口,提升客户端操作效率
- 实现了WebSocket连接管理和事件监听机制
- 添加了离线消息同步和状态管理功能
这个提交包含在:
XuqmGroup 2026-05-02 22:57:55 +08:00
父节点 d27607d14e
当前提交 d2dea0c332
共有 37 个文件被更改,包括 1461 次插入31 次删除

查看文件

@ -1010,6 +1010,16 @@ public final class XuqmImServerSdk {
return response.data(); return response.data();
} }
public void leaveGroup(String groupId) {
request(
"DELETE",
buildUri("/api/im/groups/" + encode(groupId) + "/members/me", appQuery()),
null,
authorizedHeaders(),
new TypeReference<ApiResponse<Void>>() {}
);
}
public GroupView updateGroupAttributes(String groupId, Map<String, Object> attributes) { public GroupView updateGroupAttributes(String groupId, Map<String, Object> attributes) {
ApiResponse<GroupView> response = request( ApiResponse<GroupView> response = request(
"PUT", "PUT",
@ -1199,11 +1209,18 @@ public final class XuqmImServerSdk {
return response.data(); return response.data();
} }
public GroupView modifyGroupMemberInfo(String groupId, String userId, String nickName) { public GroupView modifyGroupMemberInfo(String groupId, String userId, String nickName, String role) {
Map<String, String> body = new LinkedHashMap<>();
if (nickName != null) {
body.put("nickName", nickName);
}
if (role != null) {
body.put("role", role);
}
ApiResponse<GroupView> response = request( ApiResponse<GroupView> response = request(
"PUT", "PUT",
buildUri("/api/im/groups/" + encode(groupId) + "/members/" + encode(userId) + "/info", appQuery()), buildUri("/api/im/groups/" + encode(groupId) + "/members/" + encode(userId) + "/info", appQuery()),
Map.of("nickName", nickName), body,
authorizedHeaders(), authorizedHeaders(),
new TypeReference<>() {} new TypeReference<>() {}
); );

查看文件

@ -1,6 +1,7 @@
package com.xuqm.im.config; package com.xuqm.im.config;
import com.xuqm.common.security.JwtUtil; import com.xuqm.common.security.JwtUtil;
import com.xuqm.im.service.UserPresenceService;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message; import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel; import org.springframework.messaging.MessageChannel;
@ -23,9 +24,11 @@ import java.util.List;
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final UserPresenceService userPresenceService;
public WebSocketConfig(JwtUtil jwtUtil) { public WebSocketConfig(JwtUtil jwtUtil, UserPresenceService userPresenceService) {
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
this.userPresenceService = userPresenceService;
} }
@Override @Override
@ -51,7 +54,10 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
public Message<?> preSend(Message<?> message, MessageChannel channel) { public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor( StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(
message, StompHeaderAccessor.class); message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { if (accessor == null) {
return message;
}
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization"); String token = accessor.getFirstNativeHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) { if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7); token = token.substring(7);
@ -61,8 +67,14 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
new UsernamePasswordAuthenticationToken(userId, null, new UsernamePasswordAuthenticationToken(userId, null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))); List.of(new SimpleGrantedAuthority("ROLE_USER")));
accessor.setUser(auth); accessor.setUser(auth);
userPresenceService.markOnline(userId);
} }
} }
} else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
Object user = accessor.getUser();
if (user instanceof UsernamePasswordAuthenticationToken auth) {
userPresenceService.markOffline(auth.getName());
}
} }
return message; return message;
} }

查看文件

@ -10,10 +10,14 @@ import com.xuqm.im.entity.ImFriendRequestEntity;
import com.xuqm.im.entity.ImGroupJoinRequestEntity; import com.xuqm.im.entity.ImGroupJoinRequestEntity;
import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.KeywordFilterEntity; import com.xuqm.im.entity.KeywordFilterEntity;
import com.xuqm.im.entity.WebhookAlertEntity;
import com.xuqm.im.entity.WebhookConfigEntity; import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.entity.WebhookDeliveryEntity;
import com.xuqm.im.repository.ImAccountRepository; import com.xuqm.im.repository.ImAccountRepository;
import com.xuqm.im.repository.ImGroupRepository; import com.xuqm.im.repository.ImGroupRepository;
import com.xuqm.im.repository.ImMessageRepository; import com.xuqm.im.repository.ImMessageRepository;
import com.xuqm.im.repository.WebhookAlertRepository;
import com.xuqm.im.repository.WebhookDeliveryRepository;
import com.xuqm.im.service.ImAccountService; import com.xuqm.im.service.ImAccountService;
import com.xuqm.im.service.BlacklistService; import com.xuqm.im.service.BlacklistService;
import com.xuqm.im.service.FriendRequestService; import com.xuqm.im.service.FriendRequestService;
@ -22,13 +26,13 @@ import com.xuqm.im.service.GlobalMuteService;
import com.xuqm.im.service.KeywordFilterService; import com.xuqm.im.service.KeywordFilterService;
import com.xuqm.im.service.MessageService; import com.xuqm.im.service.MessageService;
import com.xuqm.im.service.OperationLogService; import com.xuqm.im.service.OperationLogService;
import com.xuqm.im.service.UserPresenceService;
import com.xuqm.im.service.WebhookConfigService; import com.xuqm.im.service.WebhookConfigService;
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.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -53,12 +57,14 @@ public class ImAdminController {
private final ImGroupService groupService; private final ImGroupService groupService;
private final MessageService messageService; private final MessageService messageService;
private final WebhookConfigService webhookConfigService; private final WebhookConfigService webhookConfigService;
private final WebhookDeliveryRepository webhookDeliveryRepository;
private final WebhookAlertRepository webhookAlertRepository;
private final KeywordFilterService keywordFilterService; private final KeywordFilterService keywordFilterService;
private final GlobalMuteService globalMuteService; private final GlobalMuteService globalMuteService;
private final OperationLogService operationLogService; private final OperationLogService operationLogService;
private final SimpUserRegistry simpUserRegistry;
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
private final StringRedisTemplate redisTemplate; private final StringRedisTemplate redisTemplate;
private final UserPresenceService userPresenceService;
public ImAdminController(ImAccountRepository accountRepository, public ImAdminController(ImAccountRepository accountRepository,
ImGroupRepository groupRepository, ImGroupRepository groupRepository,
@ -69,12 +75,14 @@ public class ImAdminController {
ImGroupService groupService, ImGroupService groupService,
MessageService messageService, MessageService messageService,
WebhookConfigService webhookConfigService, WebhookConfigService webhookConfigService,
WebhookDeliveryRepository webhookDeliveryRepository,
WebhookAlertRepository webhookAlertRepository,
KeywordFilterService keywordFilterService, KeywordFilterService keywordFilterService,
GlobalMuteService globalMuteService, GlobalMuteService globalMuteService,
OperationLogService operationLogService, OperationLogService operationLogService,
SimpUserRegistry simpUserRegistry,
SimpMessagingTemplate messagingTemplate, SimpMessagingTemplate messagingTemplate,
StringRedisTemplate redisTemplate) { StringRedisTemplate redisTemplate,
UserPresenceService userPresenceService) {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
@ -84,12 +92,14 @@ public class ImAdminController {
this.groupService = groupService; this.groupService = groupService;
this.messageService = messageService; this.messageService = messageService;
this.webhookConfigService = webhookConfigService; this.webhookConfigService = webhookConfigService;
this.webhookDeliveryRepository = webhookDeliveryRepository;
this.webhookAlertRepository = webhookAlertRepository;
this.keywordFilterService = keywordFilterService; this.keywordFilterService = keywordFilterService;
this.globalMuteService = globalMuteService; this.globalMuteService = globalMuteService;
this.operationLogService = operationLogService; this.operationLogService = operationLogService;
this.simpUserRegistry = simpUserRegistry;
this.messagingTemplate = messagingTemplate; this.messagingTemplate = messagingTemplate;
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
this.userPresenceService = userPresenceService;
} }
/** List all registered IM users for the given appId. */ /** List all registered IM users for the given appId. */
@ -531,6 +541,74 @@ public class ImAdminController {
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@GetMapping("/webhook-deliveries")
public ResponseEntity<ApiResponse<Page<WebhookDeliveryEntity>>> listWebhookDeliveries(
@RequestParam String appId,
@RequestParam(required = false) String callbackEvent,
@RequestParam(required = false) Boolean success,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<WebhookDeliveryEntity> result;
PageRequest pageable = PageRequest.of(page, size);
if (callbackEvent != null && !callbackEvent.isBlank()) {
result = webhookDeliveryRepository.findByAppIdAndCallbackEvent(appId, callbackEvent, pageable);
} else if (success != null) {
result = webhookDeliveryRepository.findByAppIdAndSuccess(appId, success, pageable);
} else {
result = webhookDeliveryRepository.findByAppId(appId, pageable);
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@GetMapping("/webhook-alerts")
public ResponseEntity<ApiResponse<Page<WebhookAlertEntity>>> listWebhookAlerts(
@RequestParam String appId,
@RequestParam(required = false) Boolean acknowledged,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<WebhookAlertEntity> result;
PageRequest pageable = PageRequest.of(page, size);
if (acknowledged != null) {
result = webhookAlertRepository.findByAppIdAndAcknowledged(appId, acknowledged, pageable);
} else {
result = webhookAlertRepository.findByAppId(appId, pageable);
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping("/webhook-alerts/{id}/acknowledge")
public ResponseEntity<ApiResponse<WebhookAlertEntity>> acknowledgeWebhookAlert(
@RequestParam String appId,
@PathVariable String id,
@AuthenticationPrincipal String operatorId) {
WebhookAlertEntity alert = webhookAlertRepository.findById(id)
.orElseThrow(() -> new BusinessException(404, "告警不存在"));
if (!alert.getAppId().equals(appId)) {
throw new BusinessException(403, "无权操作");
}
alert.setAcknowledged(true);
alert.setAcknowledgedAt(LocalDateTime.now());
WebhookAlertEntity saved = webhookAlertRepository.save(alert);
operationLogService.record(appId, operatorId, "ACK_WEBHOOK_ALERT", "WEBHOOK_ALERT", id, null);
return ResponseEntity.ok(ApiResponse.success(saved));
}
@GetMapping("/webhooks/{id}/health")
public ResponseEntity<ApiResponse<Map<String, Object>>> getWebhookHealth(
@RequestParam String appId,
@PathVariable String id) {
WebhookConfigEntity webhook = webhookConfigService.get(appId, id);
Map<String, Object> health = new LinkedHashMap<>();
health.put("webhookId", webhook.getId());
health.put("url", webhook.getUrl());
health.put("enabled", webhook.isEnabled());
health.put("consecutiveFailures", webhook.getConsecutiveFailures());
health.put("lastFailureAt", webhook.getLastFailureAt());
long unacknowledgedAlerts = webhookAlertRepository.countUnacknowledgedByAppId(appId);
health.put("unacknowledgedAlerts", unacknowledgedAlerts);
return ResponseEntity.ok(ApiResponse.success(health));
}
@GetMapping("/keyword-filters") @GetMapping("/keyword-filters")
public ResponseEntity<ApiResponse<List<KeywordFilterEntity>>> listKeywordFilters(@RequestParam String appId) { public ResponseEntity<ApiResponse<List<KeywordFilterEntity>>> listKeywordFilters(@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(keywordFilterService.list(appId))); return ResponseEntity.ok(ApiResponse.success(keywordFilterService.list(appId)));
@ -593,13 +671,16 @@ public class ImAdminController {
@GetMapping("/users/state") @GetMapping("/users/state")
@PreAuthorize("hasAuthority('ROLE_OPS')") @PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Boolean>>> queryUserState( public ResponseEntity<ApiResponse<Map<String, Object>>> queryUserState(
@RequestParam String userIds) { @RequestParam String userIds) {
Map<String, Boolean> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
for (String userId : userIds.split(",")) { for (String userId : userIds.split(",")) {
String trimmed = userId.trim(); String trimmed = userId.trim();
if (!trimmed.isBlank()) { if (!trimmed.isBlank()) {
result.put(trimmed, simpUserRegistry.getUser(trimmed) != null); Map<String, Object> state = new LinkedHashMap<>();
state.put("online", userPresenceService.isOnline(trimmed));
state.put("lastSeenAt", userPresenceService.lastSeenAt(trimmed));
result.put(trimmed, state);
} }
} }
return ResponseEntity.ok(ApiResponse.success(result)); return ResponseEntity.ok(ApiResponse.success(result));

查看文件

@ -5,6 +5,7 @@ import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.model.EditMessageRequest; import com.xuqm.im.model.EditMessageRequest;
import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.service.MessageService; import com.xuqm.im.service.MessageService;
import com.xuqm.im.service.OfflineMessageSyncService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -17,17 +18,27 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/im/messages") @RequestMapping("/api/im/messages")
public class MessageController { public class MessageController {
private final MessageService messageService; private final MessageService messageService;
private final com.xuqm.im.repository.ImMessageRepository messageRepository;
private final OfflineMessageSyncService offlineMessageSyncService;
public MessageController(MessageService messageService) { public MessageController(MessageService messageService,
com.xuqm.im.repository.ImMessageRepository messageRepository,
OfflineMessageSyncService offlineMessageSyncService) {
this.messageService = messageService; this.messageService = messageService;
this.messageRepository = messageRepository;
this.offlineMessageSyncService = offlineMessageSyncService;
} }
@PostMapping("/send") @PostMapping("/send")
@ -84,4 +95,35 @@ public class MessageController {
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
messageService.groupHistory(appId, groupId, userId, msgType, keyword, startTime, endTime, page, size))); messageService.groupHistory(appId, groupId, userId, msgType, keyword, startTime, endTime, page, size)));
} }
@GetMapping("/search")
public ResponseEntity<ApiResponse<Page<ImMessageEntity>>> search(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestParam(required = false) ImMessageEntity.ChatType chatType,
@RequestParam(required = false) ImMessageEntity.MsgType msgType,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(
messageRepository.searchByKeywordForUser(
appId, userId, chatType, msgType, keyword, startTime, endTime, PageRequest.of(page, size))));
}
@GetMapping("/offline/count")
public ResponseEntity<ApiResponse<Map<String, Object>>> offlineMessageCount(
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
long count = offlineMessageSyncService.countUndelivered(appId, userId);
return ResponseEntity.ok(ApiResponse.success(Map.of("count", count)));
}
@PostMapping("/offline")
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> syncOfflineMessages(
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(offlineMessageSyncService.syncAndReturn(appId, userId)));
}
} }

查看文件

@ -0,0 +1,48 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "im_offline_message")
public class ImOfflineMessageEntity {
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 64)
private String userId;
@Column(nullable = false, length = 64)
private String messageId;
@Column(nullable = false)
private boolean delivered;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public boolean isDelivered() { return delivered; }
public void setDelivered(boolean delivered) { this.delivered = delivered; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,70 @@
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.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "im_webhook_alert")
public class WebhookAlertEntity {
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 64)
private String webhookId;
@Column(nullable = false, length = 512)
private String webhookUrl;
@Column(nullable = false, length = 32)
private String alertType;
@Column(length = 512)
private String description;
@Column(nullable = false)
private boolean acknowledged;
@Column(nullable = false)
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime createdAt;
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime acknowledgedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getWebhookId() { return webhookId; }
public void setWebhookId(String webhookId) { this.webhookId = webhookId; }
public String getWebhookUrl() { return webhookUrl; }
public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; }
public String getAlertType() { return alertType; }
public void setAlertType(String alertType) { this.alertType = alertType; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isAcknowledged() { return acknowledged; }
public void setAcknowledged(boolean acknowledged) { this.acknowledged = acknowledged; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getAcknowledgedAt() { return acknowledgedAt; }
public void setAcknowledgedAt(LocalDateTime acknowledgedAt) { this.acknowledgedAt = acknowledgedAt; }
}

查看文件

@ -31,6 +31,12 @@ public class WebhookConfigEntity {
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@Column(nullable = false)
private int consecutiveFailures;
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime lastFailureAt;
public String getId() { return id; } public String getId() { return id; }
public void setId(String id) { this.id = id; } public void setId(String id) { this.id = id; }
@ -49,4 +55,11 @@ public class WebhookConfigEntity {
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) @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; }
public int getConsecutiveFailures() { return consecutiveFailures; }
public void setConsecutiveFailures(int consecutiveFailures) { this.consecutiveFailures = consecutiveFailures; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getLastFailureAt() { return lastFailureAt; }
public void setLastFailureAt(LocalDateTime lastFailureAt) { this.lastFailureAt = lastFailureAt; }
} }

查看文件

@ -160,6 +160,41 @@ public interface ImMessageRepository extends JpaRepository<ImMessageEntity, Stri
@Param("endTime") LocalDateTime endTime, @Param("endTime") LocalDateTime endTime,
Pageable pageable); Pageable pageable);
@Query("""
select m from ImMessageEntity m
where m.appId = :appId
and (:chatType is null or m.chatType = :chatType)
and (:msgType is null or m.msgType = :msgType)
and (:keyword is null or :keyword = '' or
lower(m.content) like lower(concat('%', :keyword, '%')) or
lower(coalesce(m.mentionedUserIds, '')) like lower(concat('%', :keyword, '%')) or
lower(m.fromUserId) like lower(concat('%', :keyword, '%')) or
lower(m.toId) like lower(concat('%', :keyword, '%')))
and (:startTime is null or m.createdAt >= :startTime)
and (:endTime is null or m.createdAt <= :endTime)
and (
m.fromUserId = :userId
or m.toId = :userId
or (m.chatType = com.xuqm.im.entity.ImMessageEntity$ChatType.GROUP
and m.toId in (
select g.id from ImGroupEntity g
where g.appId = :appId
and function('JSON_CONTAINS', g.memberIds, function('JSON_QUOTE', :userId)) = 1
)
)
)
order by m.createdAt desc
""")
Page<ImMessageEntity> searchByKeywordForUser(
@Param("appId") String appId,
@Param("userId") String userId,
@Param("chatType") ImMessageEntity.ChatType chatType,
@Param("msgType") ImMessageEntity.MsgType msgType,
@Param("keyword") String keyword,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
Pageable pageable);
@Query(""" @Query("""
select count(m) from ImMessageEntity m select count(m) from ImMessageEntity m
where m.appId = :appId where m.appId = :appId

查看文件

@ -0,0 +1,24 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.ImOfflineMessageEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ImOfflineMessageRepository extends JpaRepository<ImOfflineMessageEntity, String> {
List<ImOfflineMessageEntity> findByAppIdAndUserIdAndDeliveredFalse(String appId, String userId);
@Modifying
@Query("UPDATE ImOfflineMessageEntity o SET o.delivered = true WHERE o.id IN ?1")
void markDeliveredByIds(List<String> ids);
@Query("SELECT COUNT(o) FROM ImOfflineMessageEntity o WHERE o.appId = ?1 AND o.userId = ?2 AND o.delivered = false")
long countUndeliveredByAppIdAndUserId(String appId, String userId);
void deleteByAppIdAndUserIdAndDeliveredTrue(String appId, String userId);
}

查看文件

@ -0,0 +1,17 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.WebhookAlertEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface WebhookAlertRepository extends JpaRepository<WebhookAlertEntity, String> {
Page<WebhookAlertEntity> findByAppId(String appId, Pageable pageable);
Page<WebhookAlertEntity> findByAppIdAndAcknowledged(String appId, boolean acknowledged, Pageable pageable);
@Query("SELECT COUNT(a) FROM WebhookAlertEntity a WHERE a.appId = ?1 AND a.acknowledged = false")
long countUnacknowledgedByAppId(String appId);
}

查看文件

@ -14,6 +14,8 @@ public interface WebhookDeliveryRepository extends JpaRepository<WebhookDelivery
Page<WebhookDeliveryEntity> findByAppId(String appId, Pageable pageable); Page<WebhookDeliveryEntity> findByAppId(String appId, Pageable pageable);
Page<WebhookDeliveryEntity> findByAppIdAndSuccess(String appId, boolean success, Pageable pageable);
List<WebhookDeliveryEntity> findByCallbackId(String callbackId); List<WebhookDeliveryEntity> findByCallbackId(String callbackId);
@Query("SELECT COUNT(d) FROM WebhookDeliveryEntity d WHERE d.appId = ?1 AND d.success = true AND d.createdAt >= ?2") @Query("SELECT COUNT(d) FROM WebhookDeliveryEntity d WHERE d.appId = ?1 AND d.success = true AND d.createdAt >= ?2")

查看文件

@ -7,7 +7,6 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
@ -19,7 +18,7 @@ public class ImPushBridge {
private static final Logger log = LoggerFactory.getLogger(ImPushBridge.class); private static final Logger log = LoggerFactory.getLogger(ImPushBridge.class);
private final SimpUserRegistry userRegistry; private final UserPresenceService userPresenceService;
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@ -29,8 +28,8 @@ public class ImPushBridge {
@Value("${im.internal-token:xuqm-internal-token}") @Value("${im.internal-token:xuqm-internal-token}")
private String internalToken; private String internalToken;
public ImPushBridge(SimpUserRegistry userRegistry, ObjectMapper objectMapper) { public ImPushBridge(UserPresenceService userPresenceService, ObjectMapper objectMapper) {
this.userRegistry = userRegistry; this.userPresenceService = userPresenceService;
this.restTemplate = new RestTemplate(); this.restTemplate = new RestTemplate();
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@ -72,6 +71,6 @@ public class ImPushBridge {
} }
private boolean isOnline(String userId) { private boolean isOnline(String userId) {
return userRegistry.getUser(userId) != null; return userPresenceService.isOnline(userId);
} }
} }

查看文件

@ -42,6 +42,8 @@ public class MessageService {
private final ImFeatureConfigClient featureConfigClient; private final ImFeatureConfigClient featureConfigClient;
private final ImFriendRepository friendRepository; private final ImFriendRepository friendRepository;
private final WebhookDispatchService webhookDispatchService; private final WebhookDispatchService webhookDispatchService;
private final OfflineMessageSyncService offlineMessageSyncService;
private final UserPresenceService userPresenceService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public MessageService(ImMessageRepository messageRepository, public MessageService(ImMessageRepository messageRepository,
@ -55,6 +57,8 @@ public class MessageService {
ImFeatureConfigClient featureConfigClient, ImFeatureConfigClient featureConfigClient,
ImFriendRepository friendRepository, ImFriendRepository friendRepository,
WebhookDispatchService webhookDispatchService, WebhookDispatchService webhookDispatchService,
OfflineMessageSyncService offlineMessageSyncService,
UserPresenceService userPresenceService,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.keywordFilterService = keywordFilterService; this.keywordFilterService = keywordFilterService;
@ -67,6 +71,8 @@ public class MessageService {
this.featureConfigClient = featureConfigClient; this.featureConfigClient = featureConfigClient;
this.friendRepository = friendRepository; this.friendRepository = friendRepository;
this.webhookDispatchService = webhookDispatchService; this.webhookDispatchService = webhookDispatchService;
this.offlineMessageSyncService = offlineMessageSyncService;
this.userPresenceService = userPresenceService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@ -131,7 +137,12 @@ public class MessageService {
if (!receiverBlocksSender) { if (!receiverBlocksSender) {
log.debug("deliver message to receiver appId={} from={} to={}", log.debug("deliver message to receiver appId={} from={} to={}",
appId, fromUserId, req.toId()); appId, fromUserId, req.toId());
clusterPublisher.publish("/user/" + req.toId() + "/queue/messages", saved); boolean receiverOnline = userPresenceService.isOnline(req.toId());
if (receiverOnline) {
clusterPublisher.publish("/user/" + req.toId() + "/queue/messages", saved);
} else {
offlineMessageSyncService.storeOfflineMessage(appId, req.toId(), saved.getId());
}
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()));
imPushBridge.sendOfflinePushToUsers( imPushBridge.sendOfflinePushToUsers(
appId, appId,

查看文件

@ -0,0 +1,97 @@
package com.xuqm.im.service;
import com.xuqm.im.cluster.ImClusterPublisher;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.ImOfflineMessageEntity;
import com.xuqm.im.repository.ImMessageRepository;
import com.xuqm.im.repository.ImOfflineMessageRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Service
public class OfflineMessageSyncService {
private static final Logger log = LoggerFactory.getLogger(OfflineMessageSyncService.class);
private final ImOfflineMessageRepository offlineMessageRepository;
private final ImMessageRepository messageRepository;
private final ImClusterPublisher clusterPublisher;
public OfflineMessageSyncService(ImOfflineMessageRepository offlineMessageRepository,
ImMessageRepository messageRepository,
ImClusterPublisher clusterPublisher) {
this.offlineMessageRepository = offlineMessageRepository;
this.messageRepository = messageRepository;
this.clusterPublisher = clusterPublisher;
}
@Transactional
public void storeOfflineMessage(String appId, String userId, String messageId) {
ImOfflineMessageEntity entity = new ImOfflineMessageEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId);
entity.setUserId(userId);
entity.setMessageId(messageId);
entity.setDelivered(false);
entity.setCreatedAt(LocalDateTime.now());
offlineMessageRepository.save(entity);
log.debug("Stored offline message appId={} userId={} messageId={}", appId, userId, messageId);
}
@Transactional
public void syncAndDeliver(String appId, String userId) {
List<ImOfflineMessageEntity> offlineMessages = offlineMessageRepository
.findByAppIdAndUserIdAndDeliveredFalse(appId, userId);
if (offlineMessages.isEmpty()) {
return;
}
List<String> deliveredIds = new ArrayList<>();
for (ImOfflineMessageEntity offline : offlineMessages) {
ImMessageEntity message = messageRepository.findById(offline.getMessageId()).orElse(null);
if (message != null) {
clusterPublisher.publish("/user/" + userId + "/queue/messages", message);
deliveredIds.add(offline.getId());
log.debug("Delivered offline message appId={} userId={} messageId={}", appId, userId, message.getId());
}
}
if (!deliveredIds.isEmpty()) {
offlineMessageRepository.markDeliveredByIds(deliveredIds);
log.info("Synced {} offline messages for appId={} userId={}", deliveredIds.size(), appId, userId);
}
offlineMessageRepository.deleteByAppIdAndUserIdAndDeliveredTrue(appId, userId);
}
public long countUndelivered(String appId, String userId) {
return offlineMessageRepository.countUndeliveredByAppIdAndUserId(appId, userId);
}
@Transactional
public List<ImMessageEntity> syncAndReturn(String appId, String userId) {
List<ImOfflineMessageEntity> offlineMessages = offlineMessageRepository
.findByAppIdAndUserIdAndDeliveredFalse(appId, userId);
List<ImMessageEntity> result = new ArrayList<>();
List<String> deliveredIds = new ArrayList<>();
for (ImOfflineMessageEntity offline : offlineMessages) {
ImMessageEntity message = messageRepository.findById(offline.getMessageId()).orElse(null);
if (message != null) {
result.add(message);
deliveredIds.add(offline.getId());
}
}
if (!deliveredIds.isEmpty()) {
offlineMessageRepository.markDeliveredByIds(deliveredIds);
}
offlineMessageRepository.deleteByAppIdAndUserIdAndDeliveredTrue(appId, userId);
return result;
}
}

查看文件

@ -0,0 +1,74 @@
package com.xuqm.im.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class UserPresenceService {
private static final Logger log = LoggerFactory.getLogger(UserPresenceService.class);
private static final String PRESENCE_PREFIX = "im:presence:";
private static final String LAST_SEEN_PREFIX = "im:last-seen:";
private static final Duration PRESENCE_TTL = Duration.ofMinutes(5);
private final StringRedisTemplate redisTemplate;
public UserPresenceService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void markOnline(String userId) {
String key = PRESENCE_PREFIX + userId;
redisTemplate.opsForValue().set(key, String.valueOf(Instant.now().toEpochMilli()), PRESENCE_TTL);
log.debug("User marked online userId={}", userId);
}
public void markOffline(String userId) {
String presenceKey = PRESENCE_PREFIX + userId;
String lastSeenKey = LAST_SEEN_PREFIX + userId;
String lastSeen = redisTemplate.opsForValue().get(presenceKey);
if (lastSeen != null) {
redisTemplate.opsForValue().set(lastSeenKey, lastSeen, Duration.ofDays(7));
}
redisTemplate.delete(presenceKey);
log.debug("User marked offline userId={}", userId);
}
public void heartbeat(String userId) {
String key = PRESENCE_PREFIX + userId;
redisTemplate.opsForValue().set(key, String.valueOf(Instant.now().toEpochMilli()), PRESENCE_TTL);
}
public boolean isOnline(String userId) {
return Boolean.TRUE.equals(redisTemplate.hasKey(PRESENCE_PREFIX + userId));
}
public long lastSeenAt(String userId) {
String value = redisTemplate.opsForValue().get(PRESENCE_PREFIX + userId);
if (value != null) {
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {}
}
String lastSeen = redisTemplate.opsForValue().get(LAST_SEEN_PREFIX + userId);
if (lastSeen != null) {
try {
return Long.parseLong(lastSeen);
} catch (NumberFormatException ignored) {}
}
return 0L;
}
public Set<String> filterOnline(Iterable<String> userIds) {
return redisTemplate.keys(PRESENCE_PREFIX + "*").stream()
.map(k -> k.substring(PRESENCE_PREFIX.length()))
.collect(Collectors.toSet());
}
}

查看文件

@ -22,6 +22,11 @@ public class WebhookConfigService {
return repository.findByAppId(appId); return repository.findByAppId(appId);
} }
public WebhookConfigEntity get(String appId, String id) {
return repository.findByIdAndAppId(id, appId)
.orElseThrow(() -> new BusinessException(404, "回调配置不存在"));
}
public WebhookConfigEntity create(String appId, String url, String secret, Boolean enabled) { public WebhookConfigEntity create(String appId, String url, String secret, Boolean enabled) {
WebhookConfigEntity entity = new WebhookConfigEntity(); WebhookConfigEntity entity = new WebhookConfigEntity();
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());

查看文件

@ -2,9 +2,11 @@ package com.xuqm.im.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.im.entity.WebhookAlertEntity;
import com.xuqm.im.entity.WebhookConfigEntity; import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.entity.WebhookDeliveryEntity; import com.xuqm.im.entity.WebhookDeliveryEntity;
import com.xuqm.im.model.WebhookCallbackEnvelope; import com.xuqm.im.model.WebhookCallbackEnvelope;
import com.xuqm.im.repository.WebhookAlertRepository;
import com.xuqm.im.repository.WebhookConfigRepository; import com.xuqm.im.repository.WebhookConfigRepository;
import com.xuqm.im.repository.WebhookDeliveryRepository; import com.xuqm.im.repository.WebhookDeliveryRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -34,9 +36,11 @@ public class WebhookDispatchService {
private static final Logger log = LoggerFactory.getLogger(WebhookDispatchService.class); private static final Logger log = LoggerFactory.getLogger(WebhookDispatchService.class);
private static final int MAX_RETRIES = 3; private static final int MAX_RETRIES = 3;
private static final long[] RETRY_DELAYS_MS = {1000L, 5000L, 15000L}; private static final long[] RETRY_DELAYS_MS = {1000L, 5000L, 15000L};
private static final int ALERT_THRESHOLD = 10;
private final WebhookConfigRepository webhookRepository; private final WebhookConfigRepository webhookRepository;
private final WebhookDeliveryRepository deliveryRepository; private final WebhookDeliveryRepository deliveryRepository;
private final WebhookAlertRepository alertRepository;
private final ImAppSecretClient appSecretClient; private final ImAppSecretClient appSecretClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@ -45,10 +49,12 @@ public class WebhookDispatchService {
public WebhookDispatchService(WebhookConfigRepository webhookRepository, public WebhookDispatchService(WebhookConfigRepository webhookRepository,
WebhookDeliveryRepository deliveryRepository, WebhookDeliveryRepository deliveryRepository,
WebhookAlertRepository alertRepository,
ImAppSecretClient appSecretClient, ImAppSecretClient appSecretClient,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.webhookRepository = webhookRepository; this.webhookRepository = webhookRepository;
this.deliveryRepository = deliveryRepository; this.deliveryRepository = deliveryRepository;
this.alertRepository = alertRepository;
this.appSecretClient = appSecretClient; this.appSecretClient = appSecretClient;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@ -120,6 +126,11 @@ public class WebhookDispatchService {
if (response.statusCode() >= 200 && response.statusCode() < 300) { if (response.statusCode() >= 200 && response.statusCode() < 300) {
delivery.setSuccess(true); delivery.setSuccess(true);
deliveryRepository.save(delivery); deliveryRepository.save(delivery);
if (webhook.getConsecutiveFailures() > 0) {
webhook.setConsecutiveFailures(0);
webhook.setLastFailureAt(null);
webhookRepository.save(webhook);
}
log.info("Webhook delivered appId={} event={} url={} attempt={} status={}", log.info("Webhook delivered appId={} event={} url={} attempt={} status={}",
appId, callbackEvent, webhook.getUrl(), attempt, response.statusCode()); appId, callbackEvent, webhook.getUrl(), attempt, response.statusCode());
return; return;
@ -149,12 +160,39 @@ public class WebhookDispatchService {
break; break;
} }
} else { } else {
log.error("Webhook max retries exceeded appId={} event={} url={}", handleMaxRetriesExceeded(appId, callbackEvent, webhook);
appId, callbackEvent, webhook.getUrl());
} }
} }
} }
private void handleMaxRetriesExceeded(String appId, String callbackEvent, WebhookConfigEntity webhook) {
int failures = webhook.getConsecutiveFailures() + 1;
webhook.setConsecutiveFailures(failures);
webhook.setLastFailureAt(LocalDateTime.now());
webhookRepository.save(webhook);
log.error("Webhook max retries exceeded appId={} event={} url={} consecutiveFailures={}",
appId, callbackEvent, webhook.getUrl(), failures);
if (failures >= ALERT_THRESHOLD && webhook.isEnabled()) {
webhook.setEnabled(false);
webhookRepository.save(webhook);
log.warn("Webhook auto-disabled after {} consecutive failures appId={} url={}",
ALERT_THRESHOLD, appId, webhook.getUrl());
WebhookAlertEntity alert = new WebhookAlertEntity();
alert.setId(UUID.randomUUID().toString());
alert.setAppId(appId);
alert.setWebhookId(webhook.getId());
alert.setWebhookUrl(webhook.getUrl());
alert.setAlertType("AUTO_DISABLED");
alert.setDescription("Webhook 在连续 " + ALERT_THRESHOLD + " 次投递失败后已自动禁用。事件:" + callbackEvent);
alert.setAcknowledged(false);
alert.setCreatedAt(LocalDateTime.now());
alertRepository.save(alert);
}
}
private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) { private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) {
String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body); String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body);
return hmacSha256Hex(appSecret, payload); return hmacSha256Hex(appSecret, payload);

查看文件

@ -3,6 +3,8 @@ package com.xuqm.im.ws;
import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.service.MessageService; import com.xuqm.im.service.MessageService;
import com.xuqm.im.service.OfflineMessageSyncService;
import com.xuqm.im.service.UserPresenceService;
import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
@ -13,9 +15,15 @@ import java.security.Principal;
public class ChatController { public class ChatController {
private final MessageService messageService; private final MessageService messageService;
private final OfflineMessageSyncService offlineMessageSyncService;
private final UserPresenceService userPresenceService;
public ChatController(MessageService messageService) { public ChatController(MessageService messageService,
OfflineMessageSyncService offlineMessageSyncService,
UserPresenceService userPresenceService) {
this.messageService = messageService; this.messageService = messageService;
this.offlineMessageSyncService = offlineMessageSyncService;
this.userPresenceService = userPresenceService;
} }
@MessageMapping("/chat.send") @MessageMapping("/chat.send")
@ -35,6 +43,14 @@ public class ChatController {
messageService.revoke(request.appId(), request.messageId(), principal.getName()); messageService.revoke(request.appId(), request.messageId(), principal.getName());
} }
@MessageMapping("/chat.sync")
public void sync(@Payload WsSyncRequest request, Principal principal) {
if (principal == null) return;
String userId = principal.getName();
userPresenceService.heartbeat(userId);
offlineMessageSyncService.syncAndDeliver(request.appId(), userId);
}
public record WsMessageRequest( public record WsMessageRequest(
String appId, String messageId, String toId, String appId, String messageId, String toId,
ImMessageEntity.ChatType chatType, ImMessageEntity.ChatType chatType,
@ -43,4 +59,6 @@ public class ChatController {
) {} ) {}
public record WsRevokeRequest(String appId, String messageId) {} public record WsRevokeRequest(String appId, String messageId) {}
public record WsSyncRequest(String appId) {}
} }

查看文件

@ -6,6 +6,7 @@ import com.xuqm.push.service.PushDispatcher;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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;
@ -50,4 +51,13 @@ public class PushController {
pushDispatcher.pushToUser(appId, userId, title, body, payload); pushDispatcher.pushToUser(appId, userId, title, body, payload);
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@DeleteMapping("/unregister")
public ResponseEntity<ApiResponse<Void>> unregister(
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor) {
pushDispatcher.unregisterToken(appId, userId, vendor);
return ResponseEntity.ok(ApiResponse.ok());
}
} }

查看文件

@ -15,7 +15,7 @@ import java.time.LocalDateTime;
public class DeviceTokenEntity { public class DeviceTokenEntity {
public enum Vendor { public enum Vendor {
HUAWEI, XIAOMI, OPPO, VIVO, HONOR, FCM, APNS HUAWEI, XIAOMI, OPPO, VIVO, HONOR, HARMONY, FCM, APNS
} }
@Id @Id

查看文件

@ -11,4 +11,5 @@ public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity,
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor( Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
String appId, String userId, DeviceTokenEntity.Vendor vendor); String appId, String userId, DeviceTokenEntity.Vendor vendor);
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId); List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId);
void deleteByAppIdAndUserIdAndVendor(String appId, String userId, DeviceTokenEntity.Vendor vendor);
} }

查看文件

@ -73,4 +73,8 @@ public class PushDispatcher {
} }
tokenRepository.saveAll(tokens); tokenRepository.saveAll(tokens);
} }
public void unregisterToken(String appId, String userId, DeviceTokenEntity.Vendor vendor) {
tokenRepository.deleteByAppIdAndUserIdAndVendor(appId, userId, vendor);
}
} }

查看文件

@ -0,0 +1,109 @@
package com.xuqm.push.service.provider;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.push.service.TenantPushConfigClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
/**
* HarmonyOS Push Kit provider.
*
* HarmonyOS Push Kit shares the same OAuth endpoint with Huawei HMS,
* but uses a dedicated push API path. The provider loads tenant-specific
* credentials under the "harmony" key in the push service config.
*/
@Component
public class HarmonyPushProvider implements PushProvider {
private static final Logger log = LoggerFactory.getLogger(HarmonyPushProvider.class);
@Value("${push.harmony.app-id:}")
private String envAppId;
@Value("${push.harmony.app-secret:}")
private String envAppSecret;
@Value("${push.harmony.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}")
private String tokenUrl;
@Value("${push.harmony.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}")
private String pushUrl;
private final TenantPushConfigClient configClient;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
public HarmonyPushProvider(TenantPushConfigClient configClient) {
this.configClient = configClient;
}
@Override
public String vendorName() {
return "HARMONY";
}
@Override
public boolean send(String appId, String token, String title, String body, String payload) {
String resolvedAppId = resolveConfig(appId, "appId", envAppId);
String resolvedAppSecret = resolveConfig(appId, "appSecret", envAppSecret);
if (resolvedAppId.isBlank() || resolvedAppSecret.isBlank()) {
log.warn("Harmony push not configured");
return false;
}
try {
String accessToken = getAccessToken(resolvedAppId, resolvedAppSecret);
String url = pushUrl.replace("{appId}", resolvedAppId);
Map<String, Object> message = Map.of(
"message", Map.of(
"token", new String[]{token},
"notification", Map.of("title", title, "body", body),
"data", payload != null ? payload : "{}"
)
);
String requestBody = objectMapper.writeValueAsString(message);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + accessToken)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return response.statusCode() == 200;
} catch (Exception e) {
log.error("Harmony push failed: {}", e.getMessage());
return false;
}
}
private String getAccessToken(String appId, String appSecret) throws Exception {
String form = "grant_type=client_credentials&client_id=" + appId + "&client_secret=" + appSecret;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tokenUrl))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
Map<?, ?> json = objectMapper.readValue(response.body(), Map.class);
return (String) json.get("access_token");
}
private String resolveConfig(String appId, String key, String fallback) {
JsonNode config = configClient.loadServiceConfig(appId, "HARMONY", "PUSH")
.map(node -> node.path("harmony"))
.orElse(null);
if (config == null) {
return fallback == null ? "" : fallback;
}
String value = config.path(key).asText("");
return value.isBlank() ? (fallback == null ? "" : fallback) : value;
}
}

查看文件

@ -25,10 +25,10 @@ public class HonorPushProvider implements PushProvider {
@Value("${push.huawei.app-secret:}") @Value("${push.huawei.app-secret:}")
private String envAppSecret; private String envAppSecret;
@Value("${push.huawei.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}") @Value("${push.honor.token-url:https://oauth-login.cloud.honor.com/oauth2/v2/token}")
private String tokenUrl; private String tokenUrl;
@Value("${push.huawei.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}") @Value("${push.honor.push-url:https://push-api.cloud.honor.com/v1/{appId}/messages:send}")
private String pushUrl; private String pushUrl;
private final TenantPushConfigClient configClient; private final TenantPushConfigClient configClient;

查看文件

@ -0,0 +1,111 @@
package com.xuqm.push.service.provider;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.push.service.TenantPushConfigClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class OppoPushProvider implements PushProvider {
private static final Logger log = LoggerFactory.getLogger(OppoPushProvider.class);
private static final String AUTH_URL = "https://api.push.oppomobile.com/server/v1/auth";
private static final String PUSH_URL = "https://api.push.oppomobile.com/server/v1/message/notification/unicast";
private final TenantPushConfigClient configClient;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, TokenCache> tokenCache = new ConcurrentHashMap<>();
public OppoPushProvider(TenantPushConfigClient configClient) {
this.configClient = configClient;
}
@Override
public String vendorName() {
return "OPPO";
}
@Override
public boolean send(String appId, String token, String title, String body, String payload) {
String appKey = resolveConfig(appId, "appKey");
String masterSecret = resolveConfig(appId, "masterSecret");
if (appKey.isBlank() || masterSecret.isBlank()) {
log.warn("OPPO push not configured");
return false;
}
try {
String authToken = getAccessToken(appKey, masterSecret);
String messageId = appId + "_" + System.currentTimeMillis();
Map<String, Object> message = Map.of(
"message", Map.of(
"app_message_id", messageId,
"title", title,
"content", body,
"target_type", 2,
"target_value", token
)
);
String requestBody = objectMapper.writeValueAsString(message);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(PUSH_URL))
.header("Content-Type", "application/json")
.header("auth_token", authToken)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonNode json = objectMapper.readTree(response.body());
return json.path("code").asInt(-1) == 0;
}
return false;
} catch (Exception e) {
log.error("OPPO push failed: {}", e.getMessage());
return false;
}
}
private String getAccessToken(String appKey, String masterSecret) throws Exception {
TokenCache cached = tokenCache.get(appKey);
if (cached != null && cached.expiresAt > Instant.now().getEpochSecond()) {
return cached.token;
}
Map<String, String> body = Map.of("app_key", appKey, "master_secret", masterSecret);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(AUTH_URL))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode json = objectMapper.readTree(response.body());
String token = json.path("data").path("auth_token").asText("");
long createTime = json.path("data").path("create_time").asLong(Instant.now().getEpochSecond());
tokenCache.put(appKey, new TokenCache(token, createTime + 86400));
return token;
}
private String resolveConfig(String appId, String key) {
JsonNode config = configClient.loadServiceConfig(appId, "ANDROID", "PUSH")
.map(node -> node.path("oppo"))
.orElse(null);
if (config == null) {
return "";
}
String value = config.path(key).asText("");
return value.isBlank() ? "" : value;
}
private record TokenCache(String token, long expiresAt) {}
}

查看文件

@ -0,0 +1,107 @@
package com.xuqm.push.service.provider;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.push.service.TenantPushConfigClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class VivoPushProvider implements PushProvider {
private static final Logger log = LoggerFactory.getLogger(VivoPushProvider.class);
private static final String AUTH_URL = "https://api-push.vivo.com.cn/message/auth";
private static final String PUSH_URL = "https://api-push.vivo.com.cn/message/send";
private final TenantPushConfigClient configClient;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, TokenCache> tokenCache = new ConcurrentHashMap<>();
public VivoPushProvider(TenantPushConfigClient configClient) {
this.configClient = configClient;
}
@Override
public String vendorName() {
return "VIVO";
}
@Override
public boolean send(String appId, String token, String title, String body, String payload) {
String appKey = resolveConfig(appId, "appKey");
String appIdConfig = resolveConfig(appId, "appId");
if (appKey.isBlank() || appIdConfig.isBlank()) {
log.warn("Vivo push not configured");
return false;
}
try {
String authToken = getAccessToken(appIdConfig, appKey);
Map<String, Object> message = Map.of(
"regId", token,
"title", title,
"content", body,
"notifyType", 1
);
String requestBody = objectMapper.writeValueAsString(message);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(PUSH_URL))
.header("Content-Type", "application/json")
.header("authToken", authToken)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonNode json = objectMapper.readTree(response.body());
return json.path("result").asInt(-1) == 0;
}
return false;
} catch (Exception e) {
log.error("Vivo push failed: {}", e.getMessage());
return false;
}
}
private String getAccessToken(String appId, String appKey) throws Exception {
TokenCache cached = tokenCache.get(appId);
if (cached != null && cached.expiresAt > Instant.now().getEpochSecond()) {
return cached.token;
}
Map<String, String> body = Map.of("appId", appId, "appKey", appKey);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(AUTH_URL))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode json = objectMapper.readTree(response.body());
String token = json.path("authToken").asText("");
long expiresAt = Instant.now().getEpochSecond() + 3600;
tokenCache.put(appId, new TokenCache(token, expiresAt));
return token;
}
private String resolveConfig(String appId, String key) {
JsonNode config = configClient.loadServiceConfig(appId, "ANDROID", "PUSH")
.map(node -> node.path("vivo"))
.orElse(null);
if (config == null) {
return "";
}
String value = config.path(key).asText("");
return value.isBlank() ? "" : value;
}
private record TokenCache(String token, long expiresAt) {}
}

查看文件

@ -167,6 +167,20 @@ public class FeatureServiceController {
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId))); return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId)));
} }
@PostMapping("/{id}/regenerate-key")
public ResponseEntity<ApiResponse<FeatureServiceEntity>> regenerateKey(
@PathVariable String appId,
@PathVariable String id,
@AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId);
FeatureServiceEntity updated = featureServiceManager.regenerateSecretKey(id);
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", updated.getId(), "REGENERATE_KEY", java.util.Map.of(
"platform", updated.getPlatform().name(),
"serviceType", updated.getServiceType().name()
));
return ResponseEntity.ok(ApiResponse.success(updated));
}
public record FeatureServiceConfigRequest( public record FeatureServiceConfigRequest(
Boolean allowStrangerMessage, Boolean allowStrangerMessage,
Boolean allowFriendRequest, Boolean allowFriendRequest,

查看文件

@ -7,13 +7,19 @@ import com.xuqm.tenant.entity.OperationLogEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.service.FeatureServiceManager; import com.xuqm.tenant.service.FeatureServiceManager;
import com.xuqm.tenant.entity.RiskConfigEntity;
import com.xuqm.tenant.entity.SensitiveWordEntity;
import com.xuqm.tenant.service.OpsService; import com.xuqm.tenant.service.OpsService;
import com.xuqm.tenant.service.RiskControlService;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable; 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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
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;
@ -27,10 +33,13 @@ public class OpsController {
private final OpsService opsService; private final OpsService opsService;
private final FeatureServiceManager featureServiceManager; private final FeatureServiceManager featureServiceManager;
private final RiskControlService riskControlService;
public OpsController(OpsService opsService, FeatureServiceManager featureServiceManager) { public OpsController(OpsService opsService, FeatureServiceManager featureServiceManager,
RiskControlService riskControlService) {
this.opsService = opsService; this.opsService = opsService;
this.featureServiceManager = featureServiceManager; this.featureServiceManager = featureServiceManager;
this.riskControlService = riskControlService;
} }
@PostMapping("/api/auth/ops/login") @PostMapping("/api/auth/ops/login")
@ -150,4 +159,59 @@ public class OpsController {
"totalPages", result.getTotalPages() "totalPages", result.getTotalPages()
))); )));
} }
/* ---------- 风控配置 ---------- */
@GetMapping("/api/ops/risk/rules")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<RiskConfigEntity>> getRiskConfig() {
return ResponseEntity.ok(ApiResponse.success(riskControlService.getConfig()));
}
@PostMapping("/api/ops/risk/rules")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<RiskConfigEntity>> saveRiskConfig(@RequestBody RiskConfigEntity req) {
return ResponseEntity.ok(ApiResponse.success(riskControlService.saveConfig(req)));
}
/* ---------- 敏感词 ---------- */
@GetMapping("/api/ops/risk/sensitive-words")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> listSensitiveWords(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<SensitiveWordEntity> result = riskControlService.listWords(page, size);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"content", result.getContent(),
"total", result.getTotalElements(),
"totalPages", result.getTotalPages()
)));
}
@PostMapping("/api/ops/risk/sensitive-words")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<SensitiveWordEntity>> createSensitiveWord(@RequestBody SensitiveWordEntity req) {
return ResponseEntity.ok(ApiResponse.success(riskControlService.createWord(req)));
}
@PutMapping("/api/ops/risk/sensitive-words/{id}")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<SensitiveWordEntity>> updateSensitiveWord(
@PathVariable String id, @RequestBody SensitiveWordEntity req) {
return ResponseEntity.ok(ApiResponse.success(riskControlService.updateWord(id, req)));
}
@PatchMapping("/api/ops/risk/sensitive-words/{id}/toggle")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Void>> toggleSensitiveWord(
@PathVariable String id, @RequestParam boolean enabled) {
riskControlService.toggleWord(id, enabled);
return ResponseEntity.ok(ApiResponse.ok());
}
@DeleteMapping("/api/ops/risk/sensitive-words/{id}")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Void>> deleteSensitiveWord(@PathVariable String id) {
riskControlService.deleteWord(id);
return ResponseEntity.ok(ApiResponse.ok());
}
} }

查看文件

@ -0,0 +1,48 @@
package com.xuqm.tenant.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_risk_config")
public class RiskConfigEntity {
@Id
private String id;
@Column(nullable = false)
private int ipRateLimit = 300;
@Column(nullable = false)
private int loginFailThreshold = 5;
@Column(nullable = false)
private int loginLockMinutes = 30;
@Column(nullable = false)
private boolean abnormalDetection = true;
@Column(nullable = false)
private LocalDateTime updatedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public int getIpRateLimit() { return ipRateLimit; }
public void setIpRateLimit(int ipRateLimit) { this.ipRateLimit = ipRateLimit; }
public int getLoginFailThreshold() { return loginFailThreshold; }
public void setLoginFailThreshold(int loginFailThreshold) { this.loginFailThreshold = loginFailThreshold; }
public int getLoginLockMinutes() { return loginLockMinutes; }
public void setLoginLockMinutes(int loginLockMinutes) { this.loginLockMinutes = loginLockMinutes; }
public boolean isAbnormalDetection() { return abnormalDetection; }
public void setAbnormalDetection(boolean abnormalDetection) { this.abnormalDetection = abnormalDetection; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,54 @@
package com.xuqm.tenant.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_sensitive_word")
public class SensitiveWordEntity {
@Id
private String id;
@Column(nullable = false, length = 256, unique = true)
private String word;
@Column(nullable = false, length = 16)
private String level;
@Column(nullable = false, length = 64)
private String category;
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getWord() { return word; }
public void setWord(String word) { this.word = word; }
public String getLevel() { return level; }
public void setLevel(String level) { this.level = level; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.tenant.repository;
import com.xuqm.tenant.entity.RiskConfigEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RiskConfigRepository extends JpaRepository<RiskConfigEntity, String> {
Optional<RiskConfigEntity> findFirstByOrderByUpdatedAtDesc();
}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.tenant.repository;
import com.xuqm.tenant.entity.SensitiveWordEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SensitiveWordRepository extends JpaRepository<SensitiveWordEntity, String> {
Page<SensitiveWordEntity> findByOrderByUpdatedAtDesc(Pageable pageable);
Optional<SensitiveWordEntity> findByWord(String word);
}

查看文件

@ -518,6 +518,17 @@ public class FeatureServiceManager {
return node.toString(); return node.toString();
} }
@Transactional
public FeatureServiceEntity regenerateSecretKey(String serviceId) {
FeatureServiceEntity entity = repository.findById(serviceId)
.orElseThrow(() -> new BusinessException(404, "服务不存在"));
byte[] bytes = new byte[32];
java.security.SecureRandom random = new java.security.SecureRandom();
random.nextBytes(bytes);
entity.setSecretKey(java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));
return repository.save(entity);
}
public List<String> parseStoreTargets(String json) { public List<String> parseStoreTargets(String json) {
if (json == null || json.isBlank()) { if (json == null || json.isBlank()) {
return List.of(); return List.of();

查看文件

@ -21,6 +21,7 @@ import org.springframework.stereotype.Service;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -120,11 +121,27 @@ public class OpsService {
LocalDateTime todayEnd = todayStart.plusDays(1); LocalDateTime todayEnd = todayStart.plusDays(1);
long todayNew = tenantRepository.countByCreatedAtBetween(todayStart, todayEnd); long todayNew = tenantRepository.countByCreatedAtBetween(todayStart, todayEnd);
long activeApps = appRepository.count(); long activeApps = appRepository.count();
List<Map<String, Object>> dailyTrend = new ArrayList<>();
for (int i = 6; i >= 0; i--) {
LocalDate d = LocalDate.now().minusDays(i);
long count = tenantRepository.countByCreatedAtBetween(d.atStartOfDay(), d.plusDays(1).atStartOfDay());
dailyTrend.add(Map.of("date", d.toString(), "count", count));
}
List<FeatureServiceEntity> services = featureServiceRepository.findAll();
Map<String, Long> serviceDistribution = services.stream()
.filter(FeatureServiceEntity::isEnabled)
.collect(java.util.stream.Collectors.groupingBy(
s -> s.getServiceType().name(), java.util.stream.Collectors.counting()));
return Map.of( return Map.of(
"totalTenants", totalTenants, "totalTenants", totalTenants,
"todayNew", todayNew, "todayNew", todayNew,
"activeApps", activeApps, "activeApps", activeApps,
"onlineUsers", 0 "onlineUsers", 0,
"dailyTrend", dailyTrend,
"serviceDistribution", serviceDistribution
); );
} }

查看文件

@ -0,0 +1,103 @@
package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.tenant.entity.RiskConfigEntity;
import com.xuqm.tenant.entity.SensitiveWordEntity;
import com.xuqm.tenant.repository.RiskConfigRepository;
import com.xuqm.tenant.repository.SensitiveWordRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
public class RiskControlService {
private final RiskConfigRepository riskConfigRepository;
private final SensitiveWordRepository sensitiveWordRepository;
public RiskControlService(RiskConfigRepository riskConfigRepository,
SensitiveWordRepository sensitiveWordRepository) {
this.riskConfigRepository = riskConfigRepository;
this.sensitiveWordRepository = sensitiveWordRepository;
}
public RiskConfigEntity getConfig() {
return riskConfigRepository.findFirstByOrderByUpdatedAtDesc()
.orElseGet(() -> {
RiskConfigEntity cfg = new RiskConfigEntity();
cfg.setId(UUID.randomUUID().toString());
cfg.setUpdatedAt(LocalDateTime.now());
return riskConfigRepository.save(cfg);
});
}
@Transactional
public RiskConfigEntity saveConfig(RiskConfigEntity req) {
RiskConfigEntity entity = riskConfigRepository.findFirstByOrderByUpdatedAtDesc()
.orElseGet(() -> {
RiskConfigEntity cfg = new RiskConfigEntity();
cfg.setId(UUID.randomUUID().toString());
return cfg;
});
entity.setIpRateLimit(req.getIpRateLimit());
entity.setLoginFailThreshold(req.getLoginFailThreshold());
entity.setLoginLockMinutes(req.getLoginLockMinutes());
entity.setAbnormalDetection(req.isAbnormalDetection());
entity.setUpdatedAt(LocalDateTime.now());
return riskConfigRepository.save(entity);
}
public Page<SensitiveWordEntity> listWords(int page, int size) {
return sensitiveWordRepository.findByOrderByUpdatedAtDesc(
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")));
}
@Transactional
public SensitiveWordEntity createWord(SensitiveWordEntity req) {
sensitiveWordRepository.findByWord(req.getWord()).ifPresent(e -> {
throw new BusinessException(409, "敏感词已存在");
});
SensitiveWordEntity entity = new SensitiveWordEntity();
entity.setId(UUID.randomUUID().toString());
entity.setWord(req.getWord());
entity.setLevel(req.getLevel());
entity.setCategory(req.getCategory());
entity.setEnabled(req.isEnabled());
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
return sensitiveWordRepository.save(entity);
}
@Transactional
public SensitiveWordEntity updateWord(String id, SensitiveWordEntity req) {
SensitiveWordEntity entity = sensitiveWordRepository.findById(id)
.orElseThrow(() -> new BusinessException(404, "敏感词不存在"));
entity.setWord(req.getWord());
entity.setLevel(req.getLevel());
entity.setCategory(req.getCategory());
entity.setEnabled(req.isEnabled());
entity.setUpdatedAt(LocalDateTime.now());
return sensitiveWordRepository.save(entity);
}
@Transactional
public void toggleWord(String id, boolean enabled) {
SensitiveWordEntity entity = sensitiveWordRepository.findById(id)
.orElseThrow(() -> new BusinessException(404, "敏感词不存在"));
entity.setEnabled(enabled);
entity.setUpdatedAt(LocalDateTime.now());
sensitiveWordRepository.save(entity);
}
@Transactional
public void deleteWord(String id) {
SensitiveWordEntity entity = sensitiveWordRepository.findById(id)
.orElseThrow(() -> new BusinessException(404, "敏感词不存在"));
sensitiveWordRepository.delete(entity);
}
}

查看文件

@ -49,7 +49,8 @@ public class AppVersionController {
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate( public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
@RequestParam String appId, @RequestParam String appId,
@RequestParam AppVersionEntity.Platform platform, @RequestParam AppVersionEntity.Platform platform,
@RequestParam int currentVersionCode) { @RequestParam int currentVersionCode,
@RequestParam(required = false) String userId) {
Optional<AppVersionEntity> latest = versionRepository Optional<AppVersionEntity> latest = versionRepository
.findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( .findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
@ -63,6 +64,22 @@ public class AppVersionController {
} }
AppVersionEntity v = latest.get(); AppVersionEntity v = latest.get();
// Gray release filtering
if (v.isGrayEnabled() && userId != null && !userId.isBlank()) {
boolean inGray = false;
if ("MEMBERS".equals(v.getGrayMode()) && v.getGrayMemberIds() != null) {
inGray = v.getGrayMemberIds().contains(userId);
} else {
// PERCENT mode: deterministic hash-based sampling
int hash = Math.abs(userId.hashCode()) % 100;
inGray = hash < v.getGrayPercent();
}
if (!inGray) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
}
String appStoreJumpUrl = hasText(v.getAppStoreUrl()) String appStoreJumpUrl = hasText(v.getAppStoreUrl())
? v.getAppStoreUrl() ? v.getAppStoreUrl()
: appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE); : appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE);

查看文件

@ -50,6 +50,8 @@ public class StoreSubmissionService {
private static final Logger log = LoggerFactory.getLogger(StoreSubmissionService.class); private static final Logger log = LoggerFactory.getLogger(StoreSubmissionService.class);
private static final ObjectMapper mapper = new ObjectMapper(); private static final ObjectMapper mapper = new ObjectMapper();
private static final String HUAWEI_API = "https://connect-api.cloud.huawei.com"; private static final String HUAWEI_API = "https://connect-api.cloud.huawei.com";
private static final String HONOR_API = "https://appmarket-openapi-drcn.cloud.honor.com";
private static final String HONOR_IAM = "https://iam.developer.honor.com";
private final RestTemplate rest = new RestTemplate(); private final RestTemplate rest = new RestTemplate();
private final AppVersionRepository versionRepo; private final AppVersionRepository versionRepo;
@ -280,11 +282,131 @@ public class StoreSubmissionService {
return h; return h;
} }
// Honor AppGallery (same API as Huawei) // Honor AppGallery
// Reference: https://developer.honor.com/cn/doc/guides/101359
private void submitToHonor(AppVersionEntity v, File file, Map<String, String> creds) throws Exception { private void submitToHonor(AppVersionEntity v, File file, Map<String, String> creds) throws Exception {
// Honor uses the same Connect API as Huawei reuse implementation String clientId = require(creds, "clientId", "HONOR");
submitToHuawei(v, file, creds); String clientSecret = require(creds, "clientSecret", "HONOR");
String packageName = requirePackageName(v);
// 1. OAuth token (form-urlencoded)
String token = honorGetToken(clientId, clientSecret);
// 2. Resolve appId from package name
int honorAppId = honorGetAppId(token, packageName);
// 3. Request file upload URL (need SHA256)
String fileSha256 = sha256Hex(file);
Map<String, Object> uploadInfo = honorGetUploadUrl(token, honorAppId, file, fileSha256);
long objectId = ((Number) uploadInfo.get("objectId")).longValue();
// 4. Upload file via multipart
honorUploadFile(token, honorAppId, objectId, file);
// 5. Bind APK file info
honorUpdateFileInfo(token, honorAppId, objectId);
// 6. Submit for review
honorSubmit(token, honorAppId, v.getChangeLog());
}
private String honorGetToken(String clientId, String clientSecret) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "client_credentials");
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
ResponseEntity<Map> resp = rest.postForEntity(
HONOR_IAM + "/auth/token", new HttpEntity<>(body, headers), Map.class);
Map<String, Object> result = resp.getBody();
if (result == null || result.get("access_token") == null)
throw new RuntimeException("Honor: failed to get access token");
return result.get("access_token").toString();
}
@SuppressWarnings("unchecked")
private int honorGetAppId(String token, String packageName) {
HttpHeaders headers = honorHeaders(token);
String url = HONOR_API + "/openapi/v1/publish/get-app-id?pkgName=" + packageName;
ResponseEntity<Map> resp = rest.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), Map.class);
Map<String, Object> body = resp.getBody();
assertHonorSuccess(body, "get-app-id");
List<Map<String, Object>> list = (List<Map<String, Object>>) body.get("data");
if (list == null || list.isEmpty()) throw new RuntimeException("Honor: app not found for " + packageName);
return ((Number) list.get(0).get("appId")).intValue();
}
@SuppressWarnings("unchecked")
private Map<String, Object> honorGetUploadUrl(String token, int appId, File file, String fileSha256) {
HttpHeaders headers = honorHeaders(token);
headers.setContentType(MediaType.APPLICATION_JSON);
List<Map<String, Object>> files = List.of(Map.of(
"fileName", file.getName(),
"fileType", 100,
"fileSize", file.length(),
"fileSha256", fileSha256
));
ResponseEntity<Map> resp = rest.postForEntity(
HONOR_API + "/openapi/v1/publish/get-file-upload-url?appId=" + appId,
new HttpEntity<>(files, headers), Map.class);
Map<String, Object> body = resp.getBody();
assertHonorSuccess(body, "get-file-upload-url");
List<Map<String, Object>> list = (List<Map<String, Object>>) body.get("data");
if (list == null || list.isEmpty()) throw new RuntimeException("Honor: empty upload url response");
return list.get(0);
}
private void honorUploadFile(String token, int appId, long objectId, File file) {
HttpHeaders headers = honorHeaders(token);
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", new FileSystemResource(file));
ResponseEntity<Map> resp = rest.postForEntity(
HONOR_API + "/openapi/v1/publish/file-upload?appId=" + appId + "&objectId=" + objectId,
new HttpEntity<>(body, headers), Map.class);
assertHonorSuccess(resp.getBody(), "file-upload");
}
private void honorUpdateFileInfo(String token, int appId, long objectId) {
HttpHeaders headers = honorHeaders(token);
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = Map.of("bindingFileList", List.of(Map.of("objectId", objectId)));
ResponseEntity<Map> resp = rest.postForEntity(
HONOR_API + "/openapi/v1/publish/update-file-info?appId=" + appId,
new HttpEntity<>(body, headers), Map.class);
assertHonorSuccess(resp.getBody(), "update-file-info");
}
private void honorSubmit(String token, int appId, String changeLog) {
HttpHeaders headers = honorHeaders(token);
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new LinkedHashMap<>();
body.put("releaseType", 1); // 1 = 全网发布
if (changeLog != null && !changeLog.isBlank()) {
body.put("testComment", changeLog);
}
ResponseEntity<Map> resp = rest.postForEntity(
HONOR_API + "/openapi/v1/publish/submit-audit?appId=" + appId,
new HttpEntity<>(body, headers), Map.class);
assertHonorSuccess(resp.getBody(), "submit-audit");
}
private HttpHeaders honorHeaders(String token) {
HttpHeaders h = new HttpHeaders();
h.set("Authorization", "Bearer " + token);
return h;
}
@SuppressWarnings("unchecked")
private void assertHonorSuccess(Map<String, Object> body, String step) {
if (body == null) throw new RuntimeException("Honor: empty response for " + step);
Object code = body.get("code");
if (code == null || !"0".equals(String.valueOf(code))) {
String msg = body.get("msg") != null ? body.get("msg").toString() : "unknown error";
throw new RuntimeException("Honor " + step + " failed: " + msg);
}
} }
// Xiaomi Market // Xiaomi Market
@ -686,6 +808,18 @@ public class StoreSubmissionService {
return HexFormat.of().formatHex(digest.digest()); return HexFormat.of().formatHex(digest.digest());
} }
private String sha256Hex(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream inputStream = new FileInputStream(file)) {
byte[] buffer = new byte[8192];
int len;
while ((len = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, len);
}
}
return HexFormat.of().formatHex(digest.digest());
}
private String asJsonString(Object value) throws Exception { private String asJsonString(Object value) throws Exception {
return mapper.writeValueAsString(value); return mapper.writeValueAsString(value);
} }