diff --git a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java index 1da82f5..1c1d02c 100644 --- a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java +++ b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java @@ -1010,6 +1010,16 @@ public final class XuqmImServerSdk { return response.data(); } + public void leaveGroup(String groupId) { + request( + "DELETE", + buildUri("/api/im/groups/" + encode(groupId) + "/members/me", appQuery()), + null, + authorizedHeaders(), + new TypeReference>() {} + ); + } + public GroupView updateGroupAttributes(String groupId, Map attributes) { ApiResponse response = request( "PUT", @@ -1199,11 +1209,18 @@ public final class XuqmImServerSdk { return response.data(); } - public GroupView modifyGroupMemberInfo(String groupId, String userId, String nickName) { + public GroupView modifyGroupMemberInfo(String groupId, String userId, String nickName, String role) { + Map body = new LinkedHashMap<>(); + if (nickName != null) { + body.put("nickName", nickName); + } + if (role != null) { + body.put("role", role); + } ApiResponse response = request( "PUT", buildUri("/api/im/groups/" + encode(groupId) + "/members/" + encode(userId) + "/info", appQuery()), - Map.of("nickName", nickName), + body, authorizedHeaders(), new TypeReference<>() {} ); diff --git a/im-service/src/main/java/com/xuqm/im/config/WebSocketConfig.java b/im-service/src/main/java/com/xuqm/im/config/WebSocketConfig.java index f66f6df..625ebc4 100644 --- a/im-service/src/main/java/com/xuqm/im/config/WebSocketConfig.java +++ b/im-service/src/main/java/com/xuqm/im/config/WebSocketConfig.java @@ -1,6 +1,7 @@ package com.xuqm.im.config; import com.xuqm.common.security.JwtUtil; +import com.xuqm.im.service.UserPresenceService; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; @@ -23,9 +24,11 @@ import java.util.List; public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { private final JwtUtil jwtUtil; + private final UserPresenceService userPresenceService; - public WebSocketConfig(JwtUtil jwtUtil) { + public WebSocketConfig(JwtUtil jwtUtil, UserPresenceService userPresenceService) { this.jwtUtil = jwtUtil; + this.userPresenceService = userPresenceService; } @Override @@ -51,7 +54,10 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { public Message preSend(Message message, MessageChannel channel) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor( 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"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); @@ -61,8 +67,14 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { new UsernamePasswordAuthenticationToken(userId, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); 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; } diff --git a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java index a08ff6b..3cbb092 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java @@ -10,10 +10,14 @@ import com.xuqm.im.entity.ImFriendRequestEntity; import com.xuqm.im.entity.ImGroupJoinRequestEntity; import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.KeywordFilterEntity; +import com.xuqm.im.entity.WebhookAlertEntity; import com.xuqm.im.entity.WebhookConfigEntity; +import com.xuqm.im.entity.WebhookDeliveryEntity; import com.xuqm.im.repository.ImAccountRepository; import com.xuqm.im.repository.ImGroupRepository; 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.BlacklistService; 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.MessageService; import com.xuqm.im.service.OperationLogService; +import com.xuqm.im.service.UserPresenceService; import com.xuqm.im.service.WebhookConfigService; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.ResponseEntity; import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.messaging.simp.user.SimpUserRegistry; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -53,12 +57,14 @@ public class ImAdminController { private final ImGroupService groupService; private final MessageService messageService; private final WebhookConfigService webhookConfigService; + private final WebhookDeliveryRepository webhookDeliveryRepository; + private final WebhookAlertRepository webhookAlertRepository; private final KeywordFilterService keywordFilterService; private final GlobalMuteService globalMuteService; private final OperationLogService operationLogService; - private final SimpUserRegistry simpUserRegistry; private final SimpMessagingTemplate messagingTemplate; private final StringRedisTemplate redisTemplate; + private final UserPresenceService userPresenceService; public ImAdminController(ImAccountRepository accountRepository, ImGroupRepository groupRepository, @@ -69,12 +75,14 @@ public class ImAdminController { ImGroupService groupService, MessageService messageService, WebhookConfigService webhookConfigService, + WebhookDeliveryRepository webhookDeliveryRepository, + WebhookAlertRepository webhookAlertRepository, KeywordFilterService keywordFilterService, GlobalMuteService globalMuteService, OperationLogService operationLogService, - SimpUserRegistry simpUserRegistry, SimpMessagingTemplate messagingTemplate, - StringRedisTemplate redisTemplate) { + StringRedisTemplate redisTemplate, + UserPresenceService userPresenceService) { this.accountRepository = accountRepository; this.groupRepository = groupRepository; this.messageRepository = messageRepository; @@ -84,12 +92,14 @@ public class ImAdminController { this.groupService = groupService; this.messageService = messageService; this.webhookConfigService = webhookConfigService; + this.webhookDeliveryRepository = webhookDeliveryRepository; + this.webhookAlertRepository = webhookAlertRepository; this.keywordFilterService = keywordFilterService; this.globalMuteService = globalMuteService; this.operationLogService = operationLogService; - this.simpUserRegistry = simpUserRegistry; this.messagingTemplate = messagingTemplate; this.redisTemplate = redisTemplate; + this.userPresenceService = userPresenceService; } /** List all registered IM users for the given appId. */ @@ -531,6 +541,74 @@ public class ImAdminController { return ResponseEntity.ok(ApiResponse.ok()); } + @GetMapping("/webhook-deliveries") + public ResponseEntity>> 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 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>> listWebhookAlerts( + @RequestParam String appId, + @RequestParam(required = false) Boolean acknowledged, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page 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> 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>> getWebhookHealth( + @RequestParam String appId, + @PathVariable String id) { + WebhookConfigEntity webhook = webhookConfigService.get(appId, id); + Map 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") public ResponseEntity>> listKeywordFilters(@RequestParam String appId) { return ResponseEntity.ok(ApiResponse.success(keywordFilterService.list(appId))); @@ -593,13 +671,16 @@ public class ImAdminController { @GetMapping("/users/state") @PreAuthorize("hasAuthority('ROLE_OPS')") - public ResponseEntity>> queryUserState( + public ResponseEntity>> queryUserState( @RequestParam String userIds) { - Map result = new LinkedHashMap<>(); + Map result = new LinkedHashMap<>(); for (String userId : userIds.split(",")) { String trimmed = userId.trim(); if (!trimmed.isBlank()) { - result.put(trimmed, simpUserRegistry.getUser(trimmed) != null); + Map 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)); diff --git a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java index bf23d61..ec92e1b 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java @@ -5,6 +5,7 @@ import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.model.EditMessageRequest; import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.service.MessageService; +import com.xuqm.im.service.OfflineMessageSyncService; import jakarta.validation.Valid; import org.springframework.format.annotation.DateTimeFormat; 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.RestController; 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.util.List; +import java.util.Map; @RestController @RequestMapping("/api/im/messages") public class MessageController { 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.messageRepository = messageRepository; + this.offlineMessageSyncService = offlineMessageSyncService; } @PostMapping("/send") @@ -84,4 +95,35 @@ public class MessageController { return ResponseEntity.ok(ApiResponse.success( messageService.groupHistory(appId, groupId, userId, msgType, keyword, startTime, endTime, page, size))); } + + @GetMapping("/search") + public ResponseEntity>> 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>> 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>> syncOfflineMessages( + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(offlineMessageSyncService.syncAndReturn(appId, userId))); + } } diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImOfflineMessageEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImOfflineMessageEntity.java new file mode 100644 index 0000000..0fef5b9 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/ImOfflineMessageEntity.java @@ -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; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/WebhookAlertEntity.java b/im-service/src/main/java/com/xuqm/im/entity/WebhookAlertEntity.java new file mode 100644 index 0000000..47e70b3 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/WebhookAlertEntity.java @@ -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; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/WebhookConfigEntity.java b/im-service/src/main/java/com/xuqm/im/entity/WebhookConfigEntity.java index 89d6c7a..6670917 100644 --- a/im-service/src/main/java/com/xuqm/im/entity/WebhookConfigEntity.java +++ b/im-service/src/main/java/com/xuqm/im/entity/WebhookConfigEntity.java @@ -31,6 +31,12 @@ public class WebhookConfigEntity { @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) private LocalDateTime createdAt; + @Column(nullable = false) + private int consecutiveFailures; + + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) + private LocalDateTime lastFailureAt; + public String getId() { return id; } public void setId(String id) { this.id = id; } @@ -49,4 +55,11 @@ public class WebhookConfigEntity { @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) public LocalDateTime getCreatedAt() { return 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; } } diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java index 547377a..e8e7512 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java @@ -160,6 +160,41 @@ public interface ImMessageRepository extends JpaRepository= :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 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(""" select count(m) from ImMessageEntity m where m.appId = :appId diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImOfflineMessageRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImOfflineMessageRepository.java new file mode 100644 index 0000000..079dc6a --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/ImOfflineMessageRepository.java @@ -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 { + + List findByAppIdAndUserIdAndDeliveredFalse(String appId, String userId); + + @Modifying + @Query("UPDATE ImOfflineMessageEntity o SET o.delivered = true WHERE o.id IN ?1") + void markDeliveredByIds(List 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); +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/WebhookAlertRepository.java b/im-service/src/main/java/com/xuqm/im/repository/WebhookAlertRepository.java new file mode 100644 index 0000000..dea4194 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/WebhookAlertRepository.java @@ -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 { + + Page findByAppId(String appId, Pageable pageable); + + Page 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); +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/WebhookDeliveryRepository.java b/im-service/src/main/java/com/xuqm/im/repository/WebhookDeliveryRepository.java index 881691f..a8f4a07 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/WebhookDeliveryRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/WebhookDeliveryRepository.java @@ -14,6 +14,8 @@ public interface WebhookDeliveryRepository extends JpaRepository findByAppId(String appId, Pageable pageable); + Page findByAppIdAndSuccess(String appId, boolean success, Pageable pageable); + List findByCallbackId(String callbackId); @Query("SELECT COUNT(d) FROM WebhookDeliveryEntity d WHERE d.appId = ?1 AND d.success = true AND d.createdAt >= ?2") diff --git a/im-service/src/main/java/com/xuqm/im/service/ImPushBridge.java b/im-service/src/main/java/com/xuqm/im/service/ImPushBridge.java index c9b1593..d3a4e5b 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImPushBridge.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImPushBridge.java @@ -7,7 +7,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; -import org.springframework.messaging.simp.user.SimpUserRegistry; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; @@ -19,7 +18,7 @@ public class ImPushBridge { private static final Logger log = LoggerFactory.getLogger(ImPushBridge.class); - private final SimpUserRegistry userRegistry; + private final UserPresenceService userPresenceService; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; @@ -29,8 +28,8 @@ public class ImPushBridge { @Value("${im.internal-token:xuqm-internal-token}") private String internalToken; - public ImPushBridge(SimpUserRegistry userRegistry, ObjectMapper objectMapper) { - this.userRegistry = userRegistry; + public ImPushBridge(UserPresenceService userPresenceService, ObjectMapper objectMapper) { + this.userPresenceService = userPresenceService; this.restTemplate = new RestTemplate(); this.objectMapper = objectMapper; } @@ -72,6 +71,6 @@ public class ImPushBridge { } private boolean isOnline(String userId) { - return userRegistry.getUser(userId) != null; + return userPresenceService.isOnline(userId); } } diff --git a/im-service/src/main/java/com/xuqm/im/service/MessageService.java b/im-service/src/main/java/com/xuqm/im/service/MessageService.java index 537901c..a9cd0b5 100644 --- a/im-service/src/main/java/com/xuqm/im/service/MessageService.java +++ b/im-service/src/main/java/com/xuqm/im/service/MessageService.java @@ -42,6 +42,8 @@ public class MessageService { private final ImFeatureConfigClient featureConfigClient; private final ImFriendRepository friendRepository; private final WebhookDispatchService webhookDispatchService; + private final OfflineMessageSyncService offlineMessageSyncService; + private final UserPresenceService userPresenceService; private final ObjectMapper objectMapper; public MessageService(ImMessageRepository messageRepository, @@ -55,6 +57,8 @@ public class MessageService { ImFeatureConfigClient featureConfigClient, ImFriendRepository friendRepository, WebhookDispatchService webhookDispatchService, + OfflineMessageSyncService offlineMessageSyncService, + UserPresenceService userPresenceService, ObjectMapper objectMapper) { this.messageRepository = messageRepository; this.keywordFilterService = keywordFilterService; @@ -67,6 +71,8 @@ public class MessageService { this.featureConfigClient = featureConfigClient; this.friendRepository = friendRepository; this.webhookDispatchService = webhookDispatchService; + this.offlineMessageSyncService = offlineMessageSyncService; + this.userPresenceService = userPresenceService; this.objectMapper = objectMapper; } @@ -131,7 +137,12 @@ public class MessageService { if (!receiverBlocksSender) { log.debug("deliver message to receiver appId={} from={} to={}", 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())); imPushBridge.sendOfflinePushToUsers( appId, diff --git a/im-service/src/main/java/com/xuqm/im/service/OfflineMessageSyncService.java b/im-service/src/main/java/com/xuqm/im/service/OfflineMessageSyncService.java new file mode 100644 index 0000000..3f492bc --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/OfflineMessageSyncService.java @@ -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 offlineMessages = offlineMessageRepository + .findByAppIdAndUserIdAndDeliveredFalse(appId, userId); + if (offlineMessages.isEmpty()) { + return; + } + + List 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 syncAndReturn(String appId, String userId) { + List offlineMessages = offlineMessageRepository + .findByAppIdAndUserIdAndDeliveredFalse(appId, userId); + List result = new ArrayList<>(); + List 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; + } +} diff --git a/im-service/src/main/java/com/xuqm/im/service/UserPresenceService.java b/im-service/src/main/java/com/xuqm/im/service/UserPresenceService.java new file mode 100644 index 0000000..9108b37 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/UserPresenceService.java @@ -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 filterOnline(Iterable userIds) { + return redisTemplate.keys(PRESENCE_PREFIX + "*").stream() + .map(k -> k.substring(PRESENCE_PREFIX.length())) + .collect(Collectors.toSet()); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/service/WebhookConfigService.java b/im-service/src/main/java/com/xuqm/im/service/WebhookConfigService.java index 562a349..6af9ee4 100644 --- a/im-service/src/main/java/com/xuqm/im/service/WebhookConfigService.java +++ b/im-service/src/main/java/com/xuqm/im/service/WebhookConfigService.java @@ -22,6 +22,11 @@ public class WebhookConfigService { 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) { WebhookConfigEntity entity = new WebhookConfigEntity(); entity.setId(UUID.randomUUID().toString()); diff --git a/im-service/src/main/java/com/xuqm/im/service/WebhookDispatchService.java b/im-service/src/main/java/com/xuqm/im/service/WebhookDispatchService.java index b4e4e90..fdc9657 100644 --- a/im-service/src/main/java/com/xuqm/im/service/WebhookDispatchService.java +++ b/im-service/src/main/java/com/xuqm/im/service/WebhookDispatchService.java @@ -2,9 +2,11 @@ package com.xuqm.im.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.im.entity.WebhookAlertEntity; import com.xuqm.im.entity.WebhookConfigEntity; import com.xuqm.im.entity.WebhookDeliveryEntity; import com.xuqm.im.model.WebhookCallbackEnvelope; +import com.xuqm.im.repository.WebhookAlertRepository; import com.xuqm.im.repository.WebhookConfigRepository; import com.xuqm.im.repository.WebhookDeliveryRepository; import org.slf4j.Logger; @@ -34,9 +36,11 @@ public class WebhookDispatchService { private static final Logger log = LoggerFactory.getLogger(WebhookDispatchService.class); private static final int MAX_RETRIES = 3; private static final long[] RETRY_DELAYS_MS = {1000L, 5000L, 15000L}; + private static final int ALERT_THRESHOLD = 10; private final WebhookConfigRepository webhookRepository; private final WebhookDeliveryRepository deliveryRepository; + private final WebhookAlertRepository alertRepository; private final ImAppSecretClient appSecretClient; private final ObjectMapper objectMapper; @@ -45,10 +49,12 @@ public class WebhookDispatchService { public WebhookDispatchService(WebhookConfigRepository webhookRepository, WebhookDeliveryRepository deliveryRepository, + WebhookAlertRepository alertRepository, ImAppSecretClient appSecretClient, ObjectMapper objectMapper) { this.webhookRepository = webhookRepository; this.deliveryRepository = deliveryRepository; + this.alertRepository = alertRepository; this.appSecretClient = appSecretClient; this.objectMapper = objectMapper; } @@ -120,6 +126,11 @@ public class WebhookDispatchService { if (response.statusCode() >= 200 && response.statusCode() < 300) { delivery.setSuccess(true); 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={}", appId, callbackEvent, webhook.getUrl(), attempt, response.statusCode()); return; @@ -149,12 +160,39 @@ public class WebhookDispatchService { break; } } else { - log.error("Webhook max retries exceeded appId={} event={} url={}", - appId, callbackEvent, webhook.getUrl()); + handleMaxRetriesExceeded(appId, callbackEvent, webhook); } } } + 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) { String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body); return hmacSha256Hex(appSecret, payload); diff --git a/im-service/src/main/java/com/xuqm/im/ws/ChatController.java b/im-service/src/main/java/com/xuqm/im/ws/ChatController.java index 457681b..47f19c8 100644 --- a/im-service/src/main/java/com/xuqm/im/ws/ChatController.java +++ b/im-service/src/main/java/com/xuqm/im/ws/ChatController.java @@ -3,6 +3,8 @@ package com.xuqm.im.ws; import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.model.SendMessageRequest; 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.Payload; import org.springframework.stereotype.Controller; @@ -13,9 +15,15 @@ import java.security.Principal; public class ChatController { 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.offlineMessageSyncService = offlineMessageSyncService; + this.userPresenceService = userPresenceService; } @MessageMapping("/chat.send") @@ -35,6 +43,14 @@ public class ChatController { 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( String appId, String messageId, String toId, ImMessageEntity.ChatType chatType, @@ -43,4 +59,6 @@ public class ChatController { ) {} public record WsRevokeRequest(String appId, String messageId) {} + + public record WsSyncRequest(String appId) {} } diff --git a/push-service/src/main/java/com/xuqm/push/controller/PushController.java b/push-service/src/main/java/com/xuqm/push/controller/PushController.java index 1287377..ccb9872 100644 --- a/push-service/src/main/java/com/xuqm/push/controller/PushController.java +++ b/push-service/src/main/java/com/xuqm/push/controller/PushController.java @@ -6,6 +6,7 @@ import com.xuqm.push.service.PushDispatcher; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; 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.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -50,4 +51,13 @@ public class PushController { pushDispatcher.pushToUser(appId, userId, title, body, payload); return ResponseEntity.ok(ApiResponse.ok()); } + + @DeleteMapping("/unregister") + public ResponseEntity> unregister( + @RequestParam @NotBlank String appId, + @RequestParam @NotBlank String userId, + @RequestParam @NotNull DeviceTokenEntity.Vendor vendor) { + pushDispatcher.unregisterToken(appId, userId, vendor); + return ResponseEntity.ok(ApiResponse.ok()); + } } diff --git a/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java b/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java index d48c4f7..be07c3d 100644 --- a/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java +++ b/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java @@ -15,7 +15,7 @@ import java.time.LocalDateTime; public class DeviceTokenEntity { public enum Vendor { - HUAWEI, XIAOMI, OPPO, VIVO, HONOR, FCM, APNS + HUAWEI, XIAOMI, OPPO, VIVO, HONOR, HARMONY, FCM, APNS } @Id diff --git a/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java b/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java index 7d1808e..4680fb5 100644 --- a/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java +++ b/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java @@ -11,4 +11,5 @@ public interface DeviceTokenRepository extends JpaRepository findByAppIdAndUserIdAndVendor( String appId, String userId, DeviceTokenEntity.Vendor vendor); List findByAppIdAndUserId(String appId, String userId); + void deleteByAppIdAndUserIdAndVendor(String appId, String userId, DeviceTokenEntity.Vendor vendor); } diff --git a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java index cdd8d3c..bbd6eae 100644 --- a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java +++ b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java @@ -73,4 +73,8 @@ public class PushDispatcher { } tokenRepository.saveAll(tokens); } + + public void unregisterToken(String appId, String userId, DeviceTokenEntity.Vendor vendor) { + tokenRepository.deleteByAppIdAndUserIdAndVendor(appId, userId, vendor); + } } diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/HarmonyPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/HarmonyPushProvider.java new file mode 100644 index 0000000..6d10158 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/provider/HarmonyPushProvider.java @@ -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 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 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 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; + } +} diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/HonorPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/HonorPushProvider.java index 8b778ea..975fa03 100644 --- a/push-service/src/main/java/com/xuqm/push/service/provider/HonorPushProvider.java +++ b/push-service/src/main/java/com/xuqm/push/service/provider/HonorPushProvider.java @@ -25,10 +25,10 @@ public class HonorPushProvider implements PushProvider { @Value("${push.huawei.app-secret:}") 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; - @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 final TenantPushConfigClient configClient; diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/OppoPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/OppoPushProvider.java new file mode 100644 index 0000000..72e069f --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/provider/OppoPushProvider.java @@ -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 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 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 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 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 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) {} +} diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/VivoPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/VivoPushProvider.java new file mode 100644 index 0000000..a7d41b8 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/provider/VivoPushProvider.java @@ -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 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 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 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 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 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) {} +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index cd348c3..4cf5cc2 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -167,6 +167,20 @@ public class FeatureServiceController { return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId))); } + @PostMapping("/{id}/regenerate-key") + public ResponseEntity> 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( Boolean allowStrangerMessage, Boolean allowFriendRequest, diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java index 566d660..6c160dd 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java @@ -7,13 +7,19 @@ import com.xuqm.tenant.entity.OperationLogEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.TenantEntity; 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.RiskControlService; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; 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.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; 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.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -27,10 +33,13 @@ public class OpsController { private final OpsService opsService; 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.featureServiceManager = featureServiceManager; + this.riskControlService = riskControlService; } @PostMapping("/api/auth/ops/login") @@ -150,4 +159,59 @@ public class OpsController { "totalPages", result.getTotalPages() ))); } + + /* ---------- 风控配置 ---------- */ + @GetMapping("/api/ops/risk/rules") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity> getRiskConfig() { + return ResponseEntity.ok(ApiResponse.success(riskControlService.getConfig())); + } + + @PostMapping("/api/ops/risk/rules") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity> saveRiskConfig(@RequestBody RiskConfigEntity req) { + return ResponseEntity.ok(ApiResponse.success(riskControlService.saveConfig(req))); + } + + /* ---------- 敏感词 ---------- */ + @GetMapping("/api/ops/risk/sensitive-words") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> listSensitiveWords( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page 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> 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> 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> 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> deleteSensitiveWord(@PathVariable String id) { + riskControlService.deleteWord(id); + return ResponseEntity.ok(ApiResponse.ok()); + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/RiskConfigEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/RiskConfigEntity.java new file mode 100644 index 0000000..25777a5 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/RiskConfigEntity.java @@ -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; } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/SensitiveWordEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/SensitiveWordEntity.java new file mode 100644 index 0000000..1624ead --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/SensitiveWordEntity.java @@ -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; } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/RiskConfigRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/RiskConfigRepository.java new file mode 100644 index 0000000..84806c9 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/RiskConfigRepository.java @@ -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 { + Optional findFirstByOrderByUpdatedAtDesc(); +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/SensitiveWordRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/SensitiveWordRepository.java new file mode 100644 index 0000000..f7fba4e --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/SensitiveWordRepository.java @@ -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 { + Page findByOrderByUpdatedAtDesc(Pageable pageable); + Optional findByWord(String word); +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index 47c67de..55b1e67 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -518,6 +518,17 @@ public class FeatureServiceManager { 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 parseStoreTargets(String json) { if (json == null || json.isBlank()) { return List.of(); diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java index fb0b826..00d91f8 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java @@ -21,6 +21,7 @@ import org.springframework.stereotype.Service; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -120,11 +121,27 @@ public class OpsService { LocalDateTime todayEnd = todayStart.plusDays(1); long todayNew = tenantRepository.countByCreatedAtBetween(todayStart, todayEnd); long activeApps = appRepository.count(); + + List> 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 services = featureServiceRepository.findAll(); + Map serviceDistribution = services.stream() + .filter(FeatureServiceEntity::isEnabled) + .collect(java.util.stream.Collectors.groupingBy( + s -> s.getServiceType().name(), java.util.stream.Collectors.counting())); + return Map.of( "totalTenants", totalTenants, "todayNew", todayNew, "activeApps", activeApps, - "onlineUsers", 0 + "onlineUsers", 0, + "dailyTrend", dailyTrend, + "serviceDistribution", serviceDistribution ); } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/RiskControlService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/RiskControlService.java new file mode 100644 index 0000000..8443dcd --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/RiskControlService.java @@ -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 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); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index c1960ff..84ce463 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -49,7 +49,8 @@ public class AppVersionController { public ResponseEntity>> checkUpdate( @RequestParam String appId, @RequestParam AppVersionEntity.Platform platform, - @RequestParam int currentVersionCode) { + @RequestParam int currentVersionCode, + @RequestParam(required = false) String userId) { Optional latest = versionRepository .findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( @@ -63,6 +64,22 @@ public class AppVersionController { } 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()) ? v.getAppStoreUrl() : appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE); diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java index c896879..9879ff4 100644 --- a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java +++ b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java @@ -50,6 +50,8 @@ public class StoreSubmissionService { private static final Logger log = LoggerFactory.getLogger(StoreSubmissionService.class); private static final ObjectMapper mapper = new ObjectMapper(); 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 AppVersionRepository versionRepo; @@ -280,11 +282,131 @@ public class StoreSubmissionService { 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 creds) throws Exception { - // Honor uses the same Connect API as Huawei — reuse implementation - submitToHuawei(v, file, creds); + String clientId = require(creds, "clientId", "HONOR"); + 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 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 body = new LinkedMultiValueMap<>(); + body.add("grant_type", "client_credentials"); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + ResponseEntity resp = rest.postForEntity( + HONOR_IAM + "/auth/token", new HttpEntity<>(body, headers), Map.class); + Map 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 resp = rest.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), Map.class); + Map body = resp.getBody(); + assertHonorSuccess(body, "get-app-id"); + List> list = (List>) 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 honorGetUploadUrl(String token, int appId, File file, String fileSha256) { + HttpHeaders headers = honorHeaders(token); + headers.setContentType(MediaType.APPLICATION_JSON); + List> files = List.of(Map.of( + "fileName", file.getName(), + "fileType", 100, + "fileSize", file.length(), + "fileSha256", fileSha256 + )); + ResponseEntity resp = rest.postForEntity( + HONOR_API + "/openapi/v1/publish/get-file-upload-url?appId=" + appId, + new HttpEntity<>(files, headers), Map.class); + Map body = resp.getBody(); + assertHonorSuccess(body, "get-file-upload-url"); + List> list = (List>) 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 body = new LinkedMultiValueMap<>(); + body.add("file", new FileSystemResource(file)); + ResponseEntity 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 body = Map.of("bindingFileList", List.of(Map.of("objectId", objectId))); + ResponseEntity 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 body = new LinkedHashMap<>(); + body.put("releaseType", 1); // 1 = 全网发布 + if (changeLog != null && !changeLog.isBlank()) { + body.put("testComment", changeLog); + } + ResponseEntity 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 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 ───────────────────────────────────────────────────────── @@ -686,6 +808,18 @@ public class StoreSubmissionService { 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 { return mapper.writeValueAsString(value); }