From dd465becead27a3bfe0fc206b6e444183dc5c274 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 1 May 2026 21:27:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E6=9B=B4=E6=96=B0=20SDK=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=E5=92=8C=20API=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 expiresAt 和 refreshUserSig 参数支持自动续签 - 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化 - 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发 - 重构 RN SDK 文档结构,简化安装和使用方式 - 更新统一登录流程,支持 profile 信息传递 - 添加 IM 数据库自动隔离功能 - 修复 Android 群消息聚合问题 - 补充自动化测试验证和错误处理机制 --- .../im/controller/StatisticsController.java | 68 ++++++++ .../xuqm/im/entity/WebhookDeliveryEntity.java | 78 +++++++++ .../im/repository/ImMessageRepository.java | 6 + .../repository/WebhookDeliveryRepository.java | 28 ++++ .../com/xuqm/im/service/ImGroupService.java | 26 ++- .../com/xuqm/im/service/ImPushBridge.java | 71 ++++++++ .../com/xuqm/im/service/MessageService.java | 14 +- .../im/service/WebhookDispatchService.java | 112 ++++++++++--- .../xuqm/push/entity/DeviceTokenEntity.java | 4 +- .../service/provider/ApnsPushProvider.java | 150 +++++++++++++++++ .../service/provider/FcmPushProvider.java | 152 ++++++++++++++++++ .../src/main/resources/application.yml | 11 +- .../xuqm/tenant/controller/OpsController.java | 54 +++++++ .../xuqm/tenant/repository/AppRepository.java | 3 + .../repository/OperationLogRepository.java | 2 + .../com/xuqm/tenant/service/OpsService.java | 79 +++++++++ 16 files changed, 824 insertions(+), 34 deletions(-) create mode 100644 im-service/src/main/java/com/xuqm/im/controller/StatisticsController.java create mode 100644 im-service/src/main/java/com/xuqm/im/entity/WebhookDeliveryEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/WebhookDeliveryRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/ImPushBridge.java create mode 100644 push-service/src/main/java/com/xuqm/push/service/provider/ApnsPushProvider.java create mode 100644 push-service/src/main/java/com/xuqm/push/service/provider/FcmPushProvider.java diff --git a/im-service/src/main/java/com/xuqm/im/controller/StatisticsController.java b/im-service/src/main/java/com/xuqm/im/controller/StatisticsController.java new file mode 100644 index 0000000..6baa940 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/controller/StatisticsController.java @@ -0,0 +1,68 @@ +package com.xuqm.im.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.im.repository.ImMessageRepository; +import com.xuqm.im.repository.WebhookDeliveryRepository; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.*; + +@RestController +@RequestMapping("/api/im/statistics") +public class StatisticsController { + + private final ImMessageRepository messageRepository; + private final WebhookDeliveryRepository webhookDeliveryRepository; + + public StatisticsController(ImMessageRepository messageRepository, + WebhookDeliveryRepository webhookDeliveryRepository) { + this.messageRepository = messageRepository; + this.webhookDeliveryRepository = webhookDeliveryRepository; + } + + @GetMapping("/messages") + public ApiResponse> messageStats( + @RequestParam String appId, + @RequestParam(required = false) Integer days) { + int d = days == null ? 7 : days; + LocalDateTime since = LocalDateTime.now().minusDays(d); + long total = messageRepository.countByAppIdAndCreatedAtAfter(appId, since); + long single = messageRepository.countByAppIdAndChatTypeAndCreatedAtAfter( + appId, com.xuqm.im.entity.ImMessageEntity.ChatType.SINGLE, since); + long group = messageRepository.countByAppIdAndChatTypeAndCreatedAtAfter( + appId, com.xuqm.im.entity.ImMessageEntity.ChatType.GROUP, since); + return ApiResponse.success(Map.of( + "totalMessages", total, + "singleMessages", single, + "groupMessages", group, + "days", d + )); + } + + @GetMapping("/webhooks") + public ApiResponse> webhookStats( + @RequestParam String appId, + @RequestParam(required = false) Integer days) { + int d = days == null ? 7 : days; + LocalDateTime since = LocalDateTime.now().minusDays(d); + long success = webhookDeliveryRepository.countSuccessfulByAppIdSince(appId, since); + long failed = webhookDeliveryRepository.countFailedByAppIdSince(appId, since); + List raw = webhookDeliveryRepository.statsByAppIdSince(appId, since); + List> eventStats = new ArrayList<>(); + for (Object[] row : raw) { + eventStats.add(Map.of( + "event", row[0], + "total", row[1], + "success", row[2] + )); + } + return ApiResponse.success(Map.of( + "successCount", success, + "failedCount", failed, + "successRate", success + failed > 0 ? (success * 100.0 / (success + failed)) : 0, + "eventStats", eventStats, + "days", d + )); + } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/WebhookDeliveryEntity.java b/im-service/src/main/java/com/xuqm/im/entity/WebhookDeliveryEntity.java new file mode 100644 index 0000000..e5737ee --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/WebhookDeliveryEntity.java @@ -0,0 +1,78 @@ +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_webhook_delivery") +public class WebhookDeliveryEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 64) + private String callbackId; + + @Column(nullable = false, length = 64) + private String callbackEvent; + + @Column(nullable = false, length = 512) + private String url; + + @Column(nullable = false) + private int httpStatus; + + @Column(length = 4000) + private String responseBody; + + @Column(length = 4000) + private String errorMessage; + + @Column(nullable = false) + private int attempt; + + @Column(nullable = false) + private boolean success; + + @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 getCallbackId() { return callbackId; } + public void setCallbackId(String callbackId) { this.callbackId = callbackId; } + + public String getCallbackEvent() { return callbackEvent; } + public void setCallbackEvent(String callbackEvent) { this.callbackEvent = callbackEvent; } + + public String getUrl() { return url; } + public void setUrl(String url) { this.url = url; } + + public int getHttpStatus() { return httpStatus; } + public void setHttpStatus(int httpStatus) { this.httpStatus = httpStatus; } + + public String getResponseBody() { return responseBody; } + public void setResponseBody(String responseBody) { this.responseBody = responseBody; } + + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + + public int getAttempt() { return attempt; } + public void setAttempt(int attempt) { this.attempt = attempt; } + + public boolean isSuccess() { return success; } + public void setSuccess(boolean success) { this.success = success; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} 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 5673dfe..df16bda 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 @@ -194,6 +194,12 @@ public interface ImMessageRepository extends JpaRepository= :since") long countByAppIdAndCreatedAtAfter(@Param("appId") String appId, @Param("since") LocalDateTime since); + @Query("select count(m) from ImMessageEntity m where m.appId = :appId and m.chatType = :chatType and m.createdAt >= :since") + long countByAppIdAndChatTypeAndCreatedAtAfter( + @Param("appId") String appId, + @Param("chatType") ImMessageEntity.ChatType chatType, + @Param("since") LocalDateTime since); + default long countTodayByAppId(String appId) { return countByAppIdAndCreatedAtAfter(appId, LocalDateTime.now().toLocalDate().atStartOfDay()); } 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 new file mode 100644 index 0000000..881691f --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/WebhookDeliveryRepository.java @@ -0,0 +1,28 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.WebhookDeliveryEntity; +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; +import java.time.LocalDateTime; +import java.util.List; + +public interface WebhookDeliveryRepository extends JpaRepository { + + Page findByAppIdAndCallbackEvent(String appId, String callbackEvent, Pageable pageable); + + Page findByAppId(String appId, 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") + long countSuccessfulByAppIdSince(String appId, LocalDateTime since); + + @Query("SELECT COUNT(d) FROM WebhookDeliveryEntity d WHERE d.appId = ?1 AND d.success = false AND d.createdAt >= ?2") + long countFailedByAppIdSince(String appId, LocalDateTime since); + + @Query("SELECT d.callbackEvent, COUNT(d), SUM(CASE WHEN d.success = true THEN 1 ELSE 0 END) " + + "FROM WebhookDeliveryEntity d WHERE d.appId = ?1 AND d.createdAt >= ?2 GROUP BY d.callbackEvent") + List statsByAppIdSince(String appId, LocalDateTime since); +} diff --git a/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java b/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java index d21e98b..2b2ca3a 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java @@ -81,7 +81,11 @@ public class ImGroupService { group.setAdminIds(toJson(List.of(creatorId))); group.setAnnouncement(announcement); group.setCreatedAt(LocalDateTime.now()); - return groupRepository.save(group); + ImGroupEntity saved = groupRepository.save(group); + webhookDispatchService.dispatch(appId, "group", "group.created", + java.util.Map.of("groupId", saved.getId(), "name", saved.getName(), + "creatorId", creatorId, "memberIds", members)); + return saved; } public ImGroupEntity get(String groupId) { @@ -127,7 +131,10 @@ public class ImGroupService { } if (changed) { group.setMemberIds(toJson(members)); - return groupRepository.save(group); + ImGroupEntity saved = groupRepository.save(group); + webhookDispatchService.dispatch(saved.getAppId(), "group", "group.member_added", + java.util.Map.of("groupId", saved.getId(), "addedUserIds", userIds, "operatorId", operatorId)); + return saved; } return group; } @@ -142,7 +149,10 @@ public class ImGroupService { List members = new ArrayList<>(fromJson(group.getMemberIds())); members.remove(userId); group.setMemberIds(toJson(members)); - return groupRepository.save(group); + ImGroupEntity saved = groupRepository.save(group); + webhookDispatchService.dispatch(saved.getAppId(), "group", "group.member_removed", + java.util.Map.of("groupId", saved.getId(), "removedUserId", userId, "operatorId", operatorId)); + return saved; } @Transactional @@ -162,7 +172,10 @@ public class ImGroupService { } if (changed) { group.setMemberIds(toJson(members)); - return groupRepository.save(group); + ImGroupEntity saved = groupRepository.save(group); + webhookDispatchService.dispatch(saved.getAppId(), "group", "group.member_removed", + java.util.Map.of("groupId", saved.getId(), "removedUserIds", userIds, "operatorId", operatorId)); + return saved; } return group; } @@ -180,7 +193,10 @@ public class ImGroupService { if (announcement != null) { group.setAnnouncement(announcement); } - return groupRepository.save(group); + ImGroupEntity saved = groupRepository.save(group); + webhookDispatchService.dispatch(saved.getAppId(), "group", "group.updated", + java.util.Map.of("groupId", saved.getId(), "name", saved.getName(), "operatorId", operatorId)); + return saved; } @Transactional 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 new file mode 100644 index 0000000..f8d7400 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/ImPushBridge.java @@ -0,0 +1,71 @@ +package com.xuqm.im.service; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +@Component +public class ImPushBridge { + + private static final Logger log = LoggerFactory.getLogger(ImPushBridge.class); + + private final SimpUserRegistry userRegistry; + private final RestTemplate restTemplate; + + @Value("${im.push-service-url:http://127.0.0.1:8083}") + private String pushServiceUrl; + + @Value("${im.internal-token:xuqm-internal-token}") + private String internalToken; + + public ImPushBridge(SimpUserRegistry userRegistry) { + this.userRegistry = userRegistry; + this.restTemplate = new RestTemplate(); + } + + public void sendOfflinePush(String appId, String userId, String title, String body, String payload) { + if (isOnline(userId)) { + return; + } + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("X-Internal-Token", internalToken); + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("appId", appId); + map.add("userId", userId); + map.add("title", title); + map.add("body", body); + if (payload != null) { + map.add("payload", payload); + } + HttpEntity> request = new HttpEntity<>(map, headers); + restTemplate.postForEntity(pushServiceUrl + "/api/push/send", request, String.class); + } catch (Exception e) { + log.warn("Failed to send offline push appId={} userId={}: {}", appId, userId, e.getMessage()); + } + } + + public void sendOfflinePushToUsers(String appId, List userIds, String title, String body, String payload) { + if (userIds == null || userIds.isEmpty()) { + return; + } + for (String userId : userIds) { + sendOfflinePush(appId, userId, title, body, payload); + } + } + + private boolean isOnline(String userId) { + return userRegistry.getUser(userId) != null; + } +} 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 f6fc384..3c27ad7 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 @@ -36,7 +36,7 @@ public class MessageService { private final ImGroupService groupService; private final BlacklistService blacklistService; private final ConversationStateService conversationStateService; - private final ImPushBridgeClient pushBridgeClient; + private final ImPushBridge imPushBridge; private final ImFeatureConfigClient featureConfigClient; private final ImFriendRepository friendRepository; private final WebhookDispatchService webhookDispatchService; @@ -49,7 +49,7 @@ public class MessageService { ImGroupService groupService, BlacklistService blacklistService, ConversationStateService conversationStateService, - ImPushBridgeClient pushBridgeClient, + ImPushBridge imPushBridge, ImFeatureConfigClient featureConfigClient, ImFriendRepository friendRepository, WebhookDispatchService webhookDispatchService, @@ -61,7 +61,7 @@ public class MessageService { this.groupService = groupService; this.blacklistService = blacklistService; this.conversationStateService = conversationStateService; - this.pushBridgeClient = pushBridgeClient; + this.imPushBridge = imPushBridge; this.featureConfigClient = featureConfigClient; this.friendRepository = friendRepository; this.webhookDispatchService = webhookDispatchService; @@ -131,7 +131,7 @@ public class MessageService { appId, fromUserId, req.toId()); clusterPublisher.publish("/user/" + req.toId() + "/queue/messages", saved); conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId())); - pushBridgeClient.notifyUsers( + imPushBridge.sendOfflinePushToUsers( appId, List.of(req.toId()), "新消息", @@ -148,7 +148,7 @@ public class MessageService { clusterPublisher.publish(destination, saved); List memberIds = groupService.memberIds(group); conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds); - pushBridgeClient.notifyUsers( + imPushBridge.sendOfflinePushToUsers( appId, memberIds.stream() .filter(memberId -> !memberId.equals(fromUserId)) @@ -237,7 +237,7 @@ public class MessageService { if (!saved.getFromUserId().equals(saved.getToId())) { clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved); } - pushBridgeClient.notifyUsers( + imPushBridge.sendOfflinePushToUsers( appId, List.of(saved.getToId()), "消息已编辑", @@ -247,7 +247,7 @@ public class MessageService { } else { clusterPublisher.publish("/topic/group/" + saved.getToId(), saved); List memberIds = groupService.memberIds(groupService.get(saved.getToId())); - pushBridgeClient.notifyUsers( + imPushBridge.sendOfflinePushToUsers( appId, memberIds.stream() .filter(memberId -> !memberId.equals(saved.getFromUserId())) 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 c4f3df6..b4e4e90 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 @@ -3,9 +3,14 @@ package com.xuqm.im.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.im.entity.WebhookConfigEntity; +import com.xuqm.im.entity.WebhookDeliveryEntity; import com.xuqm.im.model.WebhookCallbackEnvelope; import com.xuqm.im.repository.WebhookConfigRepository; +import com.xuqm.im.repository.WebhookDeliveryRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import java.net.URI; @@ -13,6 +18,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpClient; import java.time.Duration; +import java.time.LocalDateTime; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -25,7 +31,12 @@ import javax.crypto.spec.SecretKeySpec; @Component 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 final WebhookConfigRepository webhookRepository; + private final WebhookDeliveryRepository deliveryRepository; private final ImAppSecretClient appSecretClient; private final ObjectMapper objectMapper; @@ -33,13 +44,16 @@ public class WebhookDispatchService { private int webhookTimeoutMs; public WebhookDispatchService(WebhookConfigRepository webhookRepository, + WebhookDeliveryRepository deliveryRepository, ImAppSecretClient appSecretClient, ObjectMapper objectMapper) { this.webhookRepository = webhookRepository; + this.deliveryRepository = deliveryRepository; this.appSecretClient = appSecretClient; this.objectMapper = objectMapper; } + @Async public void dispatch(String appId, String callbackType, String callbackEvent, Object payload) { List webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId); if (webhooks.isEmpty()) { @@ -61,28 +75,83 @@ public class WebhookDispatchService { ); String body = objectMapper.writeValueAsString(envelope); String signature = signWebhook(appId, appSecret, requestTime, nonce, body); - HttpClient client = HttpClient.newBuilder() - .connectTimeout(Duration.ofMillis(webhookTimeoutMs)) - .build(); + for (WebhookConfigEntity webhook : webhooks) { - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(webhook.getUrl())) - .timeout(Duration.ofMillis(webhookTimeoutMs)) - .header("Content-Type", "application/json") - .header("X-App-Id", appId) - .header("X-App-Timestamp", String.valueOf(requestTime)) - .header("X-App-Nonce", nonce) - .header("X-App-Signature", signature) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(); - client.send(request, HttpResponse.BodyHandlers.ofString()); - } catch (Exception e) { - // 回调失败不影响主流程 - } + deliverWithRetry(appId, callbackId, callbackEvent, webhook, body, signature, requestTime, nonce); } } catch (Exception e) { - // 准备失败不影响主流程 + log.warn("Webhook dispatch prepare failed appId={} event={}: {}", appId, callbackEvent, e.getMessage()); + } + } + + private void deliverWithRetry(String appId, String callbackId, String callbackEvent, + WebhookConfigEntity webhook, String body, String signature, + long requestTime, String nonce) { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(webhookTimeoutMs)) + .build(); + + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + WebhookDeliveryEntity delivery = new WebhookDeliveryEntity(); + delivery.setId(UUID.randomUUID().toString()); + delivery.setAppId(appId); + delivery.setCallbackId(callbackId); + delivery.setCallbackEvent(callbackEvent); + delivery.setUrl(webhook.getUrl()); + delivery.setAttempt(attempt); + delivery.setCreatedAt(LocalDateTime.now()); + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(webhook.getUrl())) + .timeout(Duration.ofMillis(webhookTimeoutMs)) + .header("Content-Type", "application/json") + .header("X-App-Id", appId) + .header("X-App-Timestamp", String.valueOf(requestTime)) + .header("X-App-Nonce", nonce) + .header("X-App-Signature", signature) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + delivery.setHttpStatus(response.statusCode()); + delivery.setResponseBody(truncate(response.body(), 4000)); + + if (response.statusCode() >= 200 && response.statusCode() < 300) { + delivery.setSuccess(true); + deliveryRepository.save(delivery); + log.info("Webhook delivered appId={} event={} url={} attempt={} status={}", + appId, callbackEvent, webhook.getUrl(), attempt, response.statusCode()); + return; + } else { + delivery.setSuccess(false); + delivery.setErrorMessage("HTTP " + response.statusCode()); + deliveryRepository.save(delivery); + log.warn("Webhook returned non-2xx appId={} event={} url={} attempt={} status={}", + appId, callbackEvent, webhook.getUrl(), attempt, response.statusCode()); + } + } catch (Exception e) { + delivery.setSuccess(false); + delivery.setErrorMessage(truncate(e.getMessage(), 4000)); + deliveryRepository.save(delivery); + log.warn("Webhook delivery failed appId={} event={} url={} attempt={}: {}", + appId, callbackEvent, webhook.getUrl(), attempt, e.getMessage()); + } + + if (attempt < MAX_RETRIES) { + long delay = RETRY_DELAYS_MS[attempt - 1]; + log.info("Webhook retry scheduled appId={} event={} url={} delayMs={}", + appId, callbackEvent, webhook.getUrl(), delay); + try { + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } else { + log.error("Webhook max retries exceeded appId={} event={} url={}", + appId, callbackEvent, webhook.getUrl()); + } } } @@ -109,4 +178,9 @@ public class WebhookDispatchService { throw new IllegalStateException("Failed to sign webhook body", e); } } + + private String truncate(String value, int maxLength) { + if (value == null) return null; + return value.length() > maxLength ? value.substring(0, maxLength) : value; + } } 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 fa72ada..d48c4f7 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 @@ -14,7 +14,9 @@ import java.time.LocalDateTime; uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "vendor"})) public class DeviceTokenEntity { - public enum Vendor { HUAWEI, XIAOMI, OPPO, VIVO, HONOR, APNS, FCM } + public enum Vendor { + HUAWEI, XIAOMI, OPPO, VIVO, HONOR, FCM, APNS + } @Id private String id; diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/ApnsPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/ApnsPushProvider.java new file mode 100644 index 0000000..fc1e476 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/provider/ApnsPushProvider.java @@ -0,0 +1,150 @@ +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 io.jsonwebtoken.Jwts; +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.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class ApnsPushProvider implements PushProvider { + + private static final Logger log = LoggerFactory.getLogger(ApnsPushProvider.class); + + @Value("${push.apns.team-id:}") + private String envTeamId; + + @Value("${push.apns.key-id:}") + private String envKeyId; + + @Value("${push.apns.bundle-id:}") + private String envBundleId; + + @Value("${push.apns.private-key:}") + private String envPrivateKey; + + @Value("${push.apns.production:false}") + private boolean production; + + @Value("${push.apns.push-url:https://api.push.apple.com/3/device/{token}}") + private String productionPushUrl; + + @Value("${push.apns.sandbox-push-url:https://api.sandbox.push.apple.com/3/device/{token}}") + private String sandboxPushUrl; + + private final TenantPushConfigClient configClient; + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Map tokenCache = new ConcurrentHashMap<>(); + + public ApnsPushProvider(TenantPushConfigClient configClient) { + this.configClient = configClient; + } + + @Override + public String vendorName() { + return "APNS"; + } + + @Override + public boolean send(String appId, String token, String title, String body, String payload) { + String teamId = resolveConfig(appId, "teamId", envTeamId); + String keyId = resolveConfig(appId, "keyId", envKeyId); + String bundleId = resolveConfig(appId, "bundleId", envBundleId); + String privateKeyPem = resolveConfig(appId, "privateKey", envPrivateKey); + if (teamId.isBlank() || keyId.isBlank() || bundleId.isBlank() || privateKeyPem.isBlank()) { + log.warn("APNS push not configured"); + return false; + } + try { + String authToken = getAuthToken(teamId, keyId, privateKeyPem); + String url = (production ? productionPushUrl : sandboxPushUrl).replace("{token}", token); + Map aps = Map.of( + "alert", Map.of("title", title, "body", body), + "sound", "default" + ); + Map message = new java.util.LinkedHashMap<>(); + message.put("aps", aps); + if (payload != null) { + message.put("payload", payload); + } + String requestBody = objectMapper.writeValueAsString(message); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + authToken) + .header("apns-topic", bundleId) + .header("apns-push-type", "alert") + .header("apns-id", UUID.randomUUID().toString()) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } catch (Exception e) { + log.error("APNS push failed: {}", e.getMessage()); + return false; + } + } + + private String getAuthToken(String teamId, String keyId, String privateKeyPem) throws Exception { + String cacheKey = teamId + ":" + keyId; + TokenCache cache = tokenCache.get(cacheKey); + if (cache != null && cache.expiresAt > System.currentTimeMillis() + 60_000) { + return cache.token; + } + + PrivateKey privateKey = parseEcPrivateKey(privateKeyPem); + + long now = System.currentTimeMillis(); + String jwt = Jwts.builder() + .header().add("kid", keyId).and() + .issuer(teamId) + .issuedAt(new Date(now)) + .signWith(privateKey) + .compact(); + + tokenCache.put(cacheKey, new TokenCache(jwt, now + 3300_000)); + return jwt; + } + + private PrivateKey parseEcPrivateKey(String pem) throws Exception { + String clean = pem.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN EC PRIVATE KEY-----", "") + .replace("-----END EC PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.getDecoder().decode(clean); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); + KeyFactory kf = KeyFactory.getInstance("EC"); + return kf.generatePrivate(spec); + } + + private String resolveConfig(String appId, String key, String fallback) { + JsonNode config = configClient.loadServiceConfig(appId, "IOS", "PUSH") + .map(node -> node.path("apns")) + .orElse(null); + if (config == null) { + return fallback == null ? "" : fallback; + } + String value = config.path(key).asText(""); + return value.isBlank() ? (fallback == null ? "" : fallback) : value; + } + + private record TokenCache(String token, long expiresAt) {} +} diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/FcmPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/FcmPushProvider.java new file mode 100644 index 0000000..5accf55 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/provider/FcmPushProvider.java @@ -0,0 +1,152 @@ +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 io.jsonwebtoken.Jwts; +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.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class FcmPushProvider implements PushProvider { + + private static final Logger log = LoggerFactory.getLogger(FcmPushProvider.class); + + @Value("${push.fcm.project-id:}") + private String envProjectId; + + @Value("${push.fcm.service-account-json:}") + private String envServiceAccountJson; + + @Value("${push.fcm.token-url:https://oauth2.googleapis.com/token}") + private String tokenUrl; + + @Value("${push.fcm.push-url:https://fcm.googleapis.com/v1/projects/{projectId}/messages:send}") + private String pushUrl; + + private final TenantPushConfigClient configClient; + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Map tokenCache = new ConcurrentHashMap<>(); + + public FcmPushProvider(TenantPushConfigClient configClient) { + this.configClient = configClient; + } + + @Override + public String vendorName() { + return "FCM"; + } + + @Override + public boolean send(String appId, String token, String title, String body, String payload) { + String projectId = resolveConfig(appId, "projectId", envProjectId); + String serviceAccountJson = resolveConfig(appId, "serviceAccountJson", envServiceAccountJson); + if (projectId.isBlank() || serviceAccountJson.isBlank()) { + log.warn("FCM push not configured"); + return false; + } + try { + String accessToken = getAccessToken(projectId, serviceAccountJson); + String url = pushUrl.replace("{projectId}", projectId); + Map message = Map.of( + "message", Map.of( + "token", token, + "notification", Map.of("title", title, "body", body), + "data", payload != null ? Map.of("payload", payload) : Map.of() + ) + ); + 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("FCM push failed: {}", e.getMessage()); + return false; + } + } + + private String getAccessToken(String projectId, String serviceAccountJson) throws Exception { + TokenCache cache = tokenCache.get(projectId); + if (cache != null && cache.expiresAt > System.currentTimeMillis() + 60_000) { + return cache.token; + } + + JsonNode sa = objectMapper.readTree(serviceAccountJson); + String clientEmail = sa.path("client_email").asText(); + String privateKeyPem = sa.path("private_key").asText(); + + PrivateKey privateKey = parseRsaPrivateKey(privateKeyPem); + + long now = System.currentTimeMillis(); + String jwt = Jwts.builder() + .subject(clientEmail) + .issuer(clientEmail) + .claim("aud", tokenUrl) + .claim("scope", "https://www.googleapis.com/auth/firebase.messaging") + .issuedAt(new Date(now)) + .expiration(new Date(now + 3600_000)) + .signWith(privateKey) + .compact(); + + String form = "grant_type=" + URLEncoder.encode("urn:ietf:params:oauth:grant-type:jwt-bearer", StandardCharsets.UTF_8) + + "&assertion=" + jwt; + + 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); + String accessToken = (String) json.get("access_token"); + Integer expiresIn = (Integer) json.get("expires_in"); + long expiresAt = System.currentTimeMillis() + (expiresIn != null ? expiresIn : 3600) * 1000L; + tokenCache.put(projectId, new TokenCache(accessToken, expiresAt)); + return accessToken; + } + + private PrivateKey parseRsaPrivateKey(String pem) throws Exception { + String clean = pem.replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] decoded = Base64.getDecoder().decode(clean); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded); + KeyFactory kf = KeyFactory.getInstance("RSA"); + return kf.generatePrivate(spec); + } + + private String resolveConfig(String appId, String key, String fallback) { + JsonNode config = configClient.loadServiceConfig(appId, "ANDROID", "PUSH") + .map(node -> node.path("fcm")) + .orElse(null); + if (config == null) { + return fallback == null ? "" : fallback; + } + String value = config.path(key).asText(""); + return value.isBlank() ? (fallback == null ? "" : fallback) : value; + } + + private record TokenCache(String token, long expiresAt) {} +} diff --git a/push-service/src/main/resources/application.yml b/push-service/src/main/resources/application.yml index 09ff9de..a971bc1 100644 --- a/push-service/src/main/resources/application.yml +++ b/push-service/src/main/resources/application.yml @@ -34,11 +34,18 @@ push: xiaomi: app-secret: ${XIAOMI_APP_SECRET:} push-url: https://api.xmpush.xiaomi.com/v3/message/regid + fcm: + project-id: ${FCM_PROJECT_ID:} + service-account-json: ${FCM_SERVICE_ACCOUNT_JSON:} + token-url: https://oauth2.googleapis.com/token + push-url: https://fcm.googleapis.com/v1/projects/{projectId}/messages:send apns: - key-id: ${APNS_KEY_ID:} team-id: ${APNS_TEAM_ID:} - key-path: ${APNS_KEY_PATH:} + key-id: ${APNS_KEY_ID:} bundle-id: ${APNS_BUNDLE_ID:} + private-key: ${APNS_PRIVATE_KEY:} production: false + push-url: https://api.push.apple.com/3/device/{token} + sandbox-push-url: https://api.sandbox.push.apple.com/3/device/{token} tenant-service-base-url: ${TENANT_SERVICE_BASE_URL:http://tenant-service:8081} 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 58ea82e..566d660 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 @@ -1,6 +1,9 @@ package com.xuqm.tenant.controller; import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.entity.FeatureServiceEntity; +import com.xuqm.tenant.entity.OperationLogEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.service.FeatureServiceManager; @@ -57,6 +60,18 @@ public class OpsController { return ResponseEntity.ok(ApiResponse.ok()); } + @GetMapping("/api/ops/tenants/{id}") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> getTenantDetail(@PathVariable String id) { + return ResponseEntity.ok(ApiResponse.success(opsService.getTenantDetail(id))); + } + + @GetMapping("/api/ops/tenants/{id}/apps") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> listTenantApps(@PathVariable String id) { + return ResponseEntity.ok(ApiResponse.success(opsService.listTenantApps(id))); + } + @GetMapping("/api/ops/statistics") @PreAuthorize("hasAuthority('ROLE_OPS')") public ResponseEntity>> statistics() { @@ -96,4 +111,43 @@ public class OpsController { return ResponseEntity.ok(ApiResponse.success( featureServiceManager.rejectRequest(requestId, reviewNote))); } + + @GetMapping("/api/ops/apps") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> listApps( + @RequestParam(defaultValue = "") String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page result = opsService.listApps(keyword, page, size); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "content", result.getContent(), + "total", result.getTotalElements(), + "totalPages", result.getTotalPages() + ))); + } + + @GetMapping("/api/ops/apps/{id}") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> getAppDetail(@PathVariable String id) { + return ResponseEntity.ok(ApiResponse.success(opsService.getAppDetail(id))); + } + + @GetMapping("/api/ops/apps/{id}/services") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> listAppServices(@PathVariable String id) { + return ResponseEntity.ok(ApiResponse.success(opsService.listAppServices(id))); + } + + @GetMapping("/api/ops/operation-logs") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> listOperationLogs( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page result = opsService.listOperationLogs(page, size); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "content", result.getContent(), + "total", result.getTotalElements(), + "totalPages", result.getTotalPages() + ))); + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/AppRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/AppRepository.java index 096071c..e3ff1c2 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/repository/AppRepository.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/AppRepository.java @@ -1,6 +1,8 @@ package com.xuqm.tenant.repository; import com.xuqm.tenant.entity.AppEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -11,4 +13,5 @@ public interface AppRepository extends JpaRepository { Optional findByAppKey(String appKey); boolean existsByPackageNameAndTenantId(String packageName, String tenantId); long count(); + Page findByNameContainingIgnoreCaseOrAppKeyContainingIgnoreCase(String name, String appKey, Pageable pageable); } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/OperationLogRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/OperationLogRepository.java index 796be81..dcc6746 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/repository/OperationLogRepository.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/OperationLogRepository.java @@ -12,4 +12,6 @@ public interface OperationLogRepository extends JpaRepository findAllByOrderByCreatedAtDesc(Pageable pageable); } 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 a24787f..fb0b826 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 @@ -1,11 +1,16 @@ package com.xuqm.tenant.service; import com.xuqm.common.security.JwtUtil; +import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.OpsAdminEntity; +import com.xuqm.tenant.entity.OperationLogEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.repository.AppRepository; +import com.xuqm.tenant.repository.FeatureServiceRepository; import com.xuqm.tenant.repository.OpsAdminRepository; +import com.xuqm.tenant.repository.OperationLogRepository; import com.xuqm.tenant.repository.ServiceActivationRequestRepository; import com.xuqm.tenant.repository.TenantRepository; import org.springframework.data.domain.Page; @@ -16,6 +21,8 @@ import org.springframework.stereotype.Service; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -24,19 +31,25 @@ public class OpsService { private final TenantRepository tenantRepository; private final AppRepository appRepository; + private final FeatureServiceRepository featureServiceRepository; private final OpsAdminRepository opsAdminRepository; private final ServiceActivationRequestRepository requestRepository; + private final OperationLogRepository operationLogRepository; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; public OpsService(TenantRepository tenantRepository, AppRepository appRepository, + FeatureServiceRepository featureServiceRepository, OpsAdminRepository opsAdminRepository, ServiceActivationRequestRepository requestRepository, + OperationLogRepository operationLogRepository, PasswordEncoder passwordEncoder, JwtUtil jwtUtil) { this.tenantRepository = tenantRepository; this.appRepository = appRepository; + this.featureServiceRepository = featureServiceRepository; this.opsAdminRepository = opsAdminRepository; this.requestRepository = requestRepository; + this.operationLogRepository = operationLogRepository; this.passwordEncoder = passwordEncoder; this.jwtUtil = jwtUtil; } @@ -57,6 +70,39 @@ public class OpsService { ); } + public Map getTenantDetail(String tenantId) { + TenantEntity tenant = tenantRepository.findById(tenantId) + .orElseThrow(() -> new IllegalArgumentException("租户不存在")); + List apps = appRepository.findByTenantId(tenantId); + long subAccountCount = tenantRepository.countByParentId(tenantId); + long activeServiceCount = apps.stream() + .flatMap(app -> featureServiceRepository.findByAppId(app.getId()).stream()) + .filter(FeatureServiceEntity::isEnabled) + .count(); + + Map result = new LinkedHashMap<>(); + result.put("id", tenant.getId()); + result.put("username", tenant.getUsername()); + result.put("nickname", tenant.getNickname()); + result.put("email", tenant.getEmail()); + result.put("phone", tenant.getPhone()); + result.put("type", tenant.getType()); + result.put("status", tenant.getStatus()); + result.put("parentId", tenant.getParentId()); + result.put("createdAt", tenant.getCreatedAt()); + result.put("apps", apps); + result.put("appCount", apps.size()); + result.put("subAccountCount", subAccountCount); + result.put("activeServiceCount", activeServiceCount); + return result; + } + + public List listTenantApps(String tenantId) { + tenantRepository.findById(tenantId) + .orElseThrow(() -> new IllegalArgumentException("租户不存在")); + return appRepository.findByTenantId(tenantId); + } + public void toggleStatus(String tenantId) { TenantEntity tenant = tenantRepository.findById(tenantId) .orElseThrow(() -> new IllegalArgumentException("租户不存在")); @@ -92,6 +138,39 @@ public class OpsService { return requestRepository.findAllByOrderByCreatedAtDesc(pageable); } + public Page listApps(String keyword, int page, int size) { + var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + if (keyword == null || keyword.isBlank()) { + return appRepository.findAll(pageable); + } + return appRepository.findByNameContainingIgnoreCaseOrAppKeyContainingIgnoreCase(keyword, keyword, pageable); + } + + public Map getAppDetail(String appId) { + AppEntity app = appRepository.findById(appId) + .orElseThrow(() -> new IllegalArgumentException("应用不存在")); + List services = featureServiceRepository.findByAppId(appId); + long enabledCount = services.stream().filter(FeatureServiceEntity::isEnabled).count(); + Map result = new LinkedHashMap<>(); + result.put("app", app); + result.put("tenant", tenantRepository.findById(app.getTenantId()).orElse(null)); + result.put("services", services); + result.put("serviceCount", services.size()); + result.put("enabledServiceCount", enabledCount); + return result; + } + + public List listAppServices(String appId) { + appRepository.findById(appId) + .orElseThrow(() -> new IllegalArgumentException("应用不存在")); + return featureServiceRepository.findByAppId(appId); + } + + public Page listOperationLogs(int page, int size) { + var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + return operationLogRepository.findAllByOrderByCreatedAtDesc(pageable); + } + public void initDefaultAdmin(String username, String rawPassword) { if (opsAdminRepository.findByUsername(username).isPresent()) return; OpsAdminEntity admin = new OpsAdminEntity();