feat(sdk): 更新 SDK 设计文档和 API 重构

- 添加 expiresAt 和 refreshUserSig 参数支持自动续签
- 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化
- 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发
- 重构 RN SDK 文档结构,简化安装和使用方式
- 更新统一登录流程,支持 profile 信息传递
- 添加 IM 数据库自动隔离功能
- 修复 Android 群消息聚合问题
- 补充自动化测试验证和错误处理机制
这个提交包含在:
XuqmGroup 2026-05-01 21:27:39 +08:00
父节点 8c7528c3e9
当前提交 dd465becea
共有 16 个文件被更改,包括 824 次插入34 次删除

查看文件

@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Object[]> raw = webhookDeliveryRepository.statsByAppIdSince(appId, since);
List<Map<String, Object>> 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
));
}
}

查看文件

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

查看文件

@ -194,6 +194,12 @@ public interface ImMessageRepository extends JpaRepository<ImMessageEntity, Stri
@Query("select count(m) from ImMessageEntity m where m.appId = :appId and m.createdAt >= :since") @Query("select count(m) from ImMessageEntity m where m.appId = :appId and m.createdAt >= :since")
long countByAppIdAndCreatedAtAfter(@Param("appId") String appId, @Param("since") LocalDateTime 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) { default long countTodayByAppId(String appId) {
return countByAppIdAndCreatedAtAfter(appId, LocalDateTime.now().toLocalDate().atStartOfDay()); return countByAppIdAndCreatedAtAfter(appId, LocalDateTime.now().toLocalDate().atStartOfDay());
} }

查看文件

@ -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<WebhookDeliveryEntity, String> {
Page<WebhookDeliveryEntity> findByAppIdAndCallbackEvent(String appId, String callbackEvent, Pageable pageable);
Page<WebhookDeliveryEntity> findByAppId(String appId, Pageable pageable);
List<WebhookDeliveryEntity> 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<Object[]> statsByAppIdSince(String appId, LocalDateTime since);
}

查看文件

@ -81,7 +81,11 @@ public class ImGroupService {
group.setAdminIds(toJson(List.of(creatorId))); group.setAdminIds(toJson(List.of(creatorId)));
group.setAnnouncement(announcement); group.setAnnouncement(announcement);
group.setCreatedAt(LocalDateTime.now()); 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) { public ImGroupEntity get(String groupId) {
@ -127,7 +131,10 @@ public class ImGroupService {
} }
if (changed) { if (changed) {
group.setMemberIds(toJson(members)); 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; return group;
} }
@ -142,7 +149,10 @@ public class ImGroupService {
List<String> members = new ArrayList<>(fromJson(group.getMemberIds())); List<String> members = new ArrayList<>(fromJson(group.getMemberIds()));
members.remove(userId); members.remove(userId);
group.setMemberIds(toJson(members)); 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 @Transactional
@ -162,7 +172,10 @@ public class ImGroupService {
} }
if (changed) { if (changed) {
group.setMemberIds(toJson(members)); 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; return group;
} }
@ -180,7 +193,10 @@ public class ImGroupService {
if (announcement != null) { if (announcement != null) {
group.setAnnouncement(announcement); 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 @Transactional

查看文件

@ -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<String, String> 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<MultiValueMap<String, String>> 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<String> 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;
}
}

查看文件

@ -36,7 +36,7 @@ public class MessageService {
private final ImGroupService groupService; private final ImGroupService groupService;
private final BlacklistService blacklistService; private final BlacklistService blacklistService;
private final ConversationStateService conversationStateService; private final ConversationStateService conversationStateService;
private final ImPushBridgeClient pushBridgeClient; private final ImPushBridge imPushBridge;
private final ImFeatureConfigClient featureConfigClient; private final ImFeatureConfigClient featureConfigClient;
private final ImFriendRepository friendRepository; private final ImFriendRepository friendRepository;
private final WebhookDispatchService webhookDispatchService; private final WebhookDispatchService webhookDispatchService;
@ -49,7 +49,7 @@ public class MessageService {
ImGroupService groupService, ImGroupService groupService,
BlacklistService blacklistService, BlacklistService blacklistService,
ConversationStateService conversationStateService, ConversationStateService conversationStateService,
ImPushBridgeClient pushBridgeClient, ImPushBridge imPushBridge,
ImFeatureConfigClient featureConfigClient, ImFeatureConfigClient featureConfigClient,
ImFriendRepository friendRepository, ImFriendRepository friendRepository,
WebhookDispatchService webhookDispatchService, WebhookDispatchService webhookDispatchService,
@ -61,7 +61,7 @@ public class MessageService {
this.groupService = groupService; this.groupService = groupService;
this.blacklistService = blacklistService; this.blacklistService = blacklistService;
this.conversationStateService = conversationStateService; this.conversationStateService = conversationStateService;
this.pushBridgeClient = pushBridgeClient; this.imPushBridge = imPushBridge;
this.featureConfigClient = featureConfigClient; this.featureConfigClient = featureConfigClient;
this.friendRepository = friendRepository; this.friendRepository = friendRepository;
this.webhookDispatchService = webhookDispatchService; this.webhookDispatchService = webhookDispatchService;
@ -131,7 +131,7 @@ public class MessageService {
appId, fromUserId, req.toId()); appId, fromUserId, req.toId());
clusterPublisher.publish("/user/" + req.toId() + "/queue/messages", saved); clusterPublisher.publish("/user/" + req.toId() + "/queue/messages", saved);
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId())); conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId()));
pushBridgeClient.notifyUsers( imPushBridge.sendOfflinePushToUsers(
appId, appId,
List.of(req.toId()), List.of(req.toId()),
"新消息", "新消息",
@ -148,7 +148,7 @@ public class MessageService {
clusterPublisher.publish(destination, saved); clusterPublisher.publish(destination, saved);
List<String> memberIds = groupService.memberIds(group); List<String> memberIds = groupService.memberIds(group);
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds); conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds);
pushBridgeClient.notifyUsers( imPushBridge.sendOfflinePushToUsers(
appId, appId,
memberIds.stream() memberIds.stream()
.filter(memberId -> !memberId.equals(fromUserId)) .filter(memberId -> !memberId.equals(fromUserId))
@ -237,7 +237,7 @@ public class MessageService {
if (!saved.getFromUserId().equals(saved.getToId())) { if (!saved.getFromUserId().equals(saved.getToId())) {
clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved); clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved);
} }
pushBridgeClient.notifyUsers( imPushBridge.sendOfflinePushToUsers(
appId, appId,
List.of(saved.getToId()), List.of(saved.getToId()),
"消息已编辑", "消息已编辑",
@ -247,7 +247,7 @@ public class MessageService {
} else { } else {
clusterPublisher.publish("/topic/group/" + saved.getToId(), saved); clusterPublisher.publish("/topic/group/" + saved.getToId(), saved);
List<String> memberIds = groupService.memberIds(groupService.get(saved.getToId())); List<String> memberIds = groupService.memberIds(groupService.get(saved.getToId()));
pushBridgeClient.notifyUsers( imPushBridge.sendOfflinePushToUsers(
appId, appId,
memberIds.stream() memberIds.stream()
.filter(memberId -> !memberId.equals(saved.getFromUserId())) .filter(memberId -> !memberId.equals(saved.getFromUserId()))

查看文件

@ -3,9 +3,14 @@ package com.xuqm.im.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.im.entity.WebhookConfigEntity; import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.entity.WebhookDeliveryEntity;
import com.xuqm.im.model.WebhookCallbackEnvelope; import com.xuqm.im.model.WebhookCallbackEnvelope;
import com.xuqm.im.repository.WebhookConfigRepository; 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.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.net.URI; import java.net.URI;
@ -13,6 +18,7 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
@ -25,7 +31,12 @@ import javax.crypto.spec.SecretKeySpec;
@Component @Component
public class WebhookDispatchService { 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 WebhookConfigRepository webhookRepository;
private final WebhookDeliveryRepository deliveryRepository;
private final ImAppSecretClient appSecretClient; private final ImAppSecretClient appSecretClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@ -33,13 +44,16 @@ public class WebhookDispatchService {
private int webhookTimeoutMs; private int webhookTimeoutMs;
public WebhookDispatchService(WebhookConfigRepository webhookRepository, public WebhookDispatchService(WebhookConfigRepository webhookRepository,
WebhookDeliveryRepository deliveryRepository,
ImAppSecretClient appSecretClient, ImAppSecretClient appSecretClient,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.webhookRepository = webhookRepository; this.webhookRepository = webhookRepository;
this.deliveryRepository = deliveryRepository;
this.appSecretClient = appSecretClient; this.appSecretClient = appSecretClient;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@Async
public void dispatch(String appId, String callbackType, String callbackEvent, Object payload) { public void dispatch(String appId, String callbackType, String callbackEvent, Object payload) {
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId); List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
if (webhooks.isEmpty()) { if (webhooks.isEmpty()) {
@ -61,10 +75,32 @@ public class WebhookDispatchService {
); );
String body = objectMapper.writeValueAsString(envelope); String body = objectMapper.writeValueAsString(envelope);
String signature = signWebhook(appId, appSecret, requestTime, nonce, body); String signature = signWebhook(appId, appSecret, requestTime, nonce, body);
for (WebhookConfigEntity webhook : webhooks) {
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() HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(webhookTimeoutMs)) .connectTimeout(Duration.ofMillis(webhookTimeoutMs))
.build(); .build();
for (WebhookConfigEntity webhook : webhooks) {
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 { try {
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(webhook.getUrl())) .uri(URI.create(webhook.getUrl()))
@ -76,13 +112,46 @@ public class WebhookDispatchService {
.header("X-App-Signature", signature) .header("X-App-Signature", signature)
.POST(HttpRequest.BodyPublishers.ofString(body)) .POST(HttpRequest.BodyPublishers.ofString(body))
.build(); .build();
client.send(request, HttpResponse.BodyHandlers.ofString());
} catch (Exception e) { HttpResponse<String> 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) { } 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); 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;
}
} }

查看文件

@ -14,7 +14,9 @@ import java.time.LocalDateTime;
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "vendor"})) uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "vendor"}))
public class DeviceTokenEntity { public class DeviceTokenEntity {
public enum Vendor { HUAWEI, XIAOMI, OPPO, VIVO, HONOR, APNS, FCM } public enum Vendor {
HUAWEI, XIAOMI, OPPO, VIVO, HONOR, FCM, APNS
}
@Id @Id
private String id; private String id;

查看文件

@ -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<String, TokenCache> 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<String, Object> aps = Map.of(
"alert", Map.of("title", title, "body", body),
"sound", "default"
);
Map<String, Object> 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<String> 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) {}
}

查看文件

@ -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<String, TokenCache> 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<String, Object> 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<String> 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<String> 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) {}
}

查看文件

@ -34,11 +34,18 @@ push:
xiaomi: xiaomi:
app-secret: ${XIAOMI_APP_SECRET:} app-secret: ${XIAOMI_APP_SECRET:}
push-url: https://api.xmpush.xiaomi.com/v3/message/regid 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: apns:
key-id: ${APNS_KEY_ID:}
team-id: ${APNS_TEAM_ID:} team-id: ${APNS_TEAM_ID:}
key-path: ${APNS_KEY_PATH:} key-id: ${APNS_KEY_ID:}
bundle-id: ${APNS_BUNDLE_ID:} bundle-id: ${APNS_BUNDLE_ID:}
private-key: ${APNS_PRIVATE_KEY:}
production: false 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} tenant-service-base-url: ${TENANT_SERVICE_BASE_URL:http://tenant-service:8081}

查看文件

@ -1,6 +1,9 @@
package com.xuqm.tenant.controller; package com.xuqm.tenant.controller;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.OperationLogEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.service.FeatureServiceManager; import com.xuqm.tenant.service.FeatureServiceManager;
@ -57,6 +60,18 @@ public class OpsController {
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@GetMapping("/api/ops/tenants/{id}")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> getTenantDetail(@PathVariable String id) {
return ResponseEntity.ok(ApiResponse.success(opsService.getTenantDetail(id)));
}
@GetMapping("/api/ops/tenants/{id}/apps")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<java.util.List<AppEntity>>> listTenantApps(@PathVariable String id) {
return ResponseEntity.ok(ApiResponse.success(opsService.listTenantApps(id)));
}
@GetMapping("/api/ops/statistics") @GetMapping("/api/ops/statistics")
@PreAuthorize("hasAuthority('ROLE_OPS')") @PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> statistics() { public ResponseEntity<ApiResponse<Map<String, Object>>> statistics() {
@ -96,4 +111,43 @@ public class OpsController {
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
featureServiceManager.rejectRequest(requestId, reviewNote))); featureServiceManager.rejectRequest(requestId, reviewNote)));
} }
@GetMapping("/api/ops/apps")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> listApps(
@RequestParam(defaultValue = "") String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<AppEntity> 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<ApiResponse<Map<String, Object>>> getAppDetail(@PathVariable String id) {
return ResponseEntity.ok(ApiResponse.success(opsService.getAppDetail(id)));
}
@GetMapping("/api/ops/apps/{id}/services")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<java.util.List<FeatureServiceEntity>>> listAppServices(@PathVariable String id) {
return ResponseEntity.ok(ApiResponse.success(opsService.listAppServices(id)));
}
@GetMapping("/api/ops/operation-logs")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> listOperationLogs(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<OperationLogEntity> result = opsService.listOperationLogs(page, size);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"content", result.getContent(),
"total", result.getTotalElements(),
"totalPages", result.getTotalPages()
)));
}
} }

查看文件

@ -1,6 +1,8 @@
package com.xuqm.tenant.repository; package com.xuqm.tenant.repository;
import com.xuqm.tenant.entity.AppEntity; 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 org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
@ -11,4 +13,5 @@ public interface AppRepository extends JpaRepository<AppEntity, String> {
Optional<AppEntity> findByAppKey(String appKey); Optional<AppEntity> findByAppKey(String appKey);
boolean existsByPackageNameAndTenantId(String packageName, String tenantId); boolean existsByPackageNameAndTenantId(String packageName, String tenantId);
long count(); long count();
Page<AppEntity> findByNameContainingIgnoreCaseOrAppKeyContainingIgnoreCase(String name, String appKey, Pageable pageable);
} }

查看文件

@ -12,4 +12,6 @@ public interface OperationLogRepository extends JpaRepository<OperationLogEntity
String tenantId, String tenantId,
String moduleType, String moduleType,
Pageable pageable); Pageable pageable);
Page<OperationLogEntity> findAllByOrderByCreatedAtDesc(Pageable pageable);
} }

查看文件

@ -1,11 +1,16 @@
package com.xuqm.tenant.service; package com.xuqm.tenant.service;
import com.xuqm.common.security.JwtUtil; 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.OpsAdminEntity;
import com.xuqm.tenant.entity.OperationLogEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.repository.AppRepository; import com.xuqm.tenant.repository.AppRepository;
import com.xuqm.tenant.repository.FeatureServiceRepository;
import com.xuqm.tenant.repository.OpsAdminRepository; import com.xuqm.tenant.repository.OpsAdminRepository;
import com.xuqm.tenant.repository.OperationLogRepository;
import com.xuqm.tenant.repository.ServiceActivationRequestRepository; import com.xuqm.tenant.repository.ServiceActivationRequestRepository;
import com.xuqm.tenant.repository.TenantRepository; import com.xuqm.tenant.repository.TenantRepository;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -16,6 +21,8 @@ import org.springframework.stereotype.Service;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@ -24,19 +31,25 @@ public class OpsService {
private final TenantRepository tenantRepository; private final TenantRepository tenantRepository;
private final AppRepository appRepository; private final AppRepository appRepository;
private final FeatureServiceRepository featureServiceRepository;
private final OpsAdminRepository opsAdminRepository; private final OpsAdminRepository opsAdminRepository;
private final ServiceActivationRequestRepository requestRepository; private final ServiceActivationRequestRepository requestRepository;
private final OperationLogRepository operationLogRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
public OpsService(TenantRepository tenantRepository, AppRepository appRepository, public OpsService(TenantRepository tenantRepository, AppRepository appRepository,
FeatureServiceRepository featureServiceRepository,
OpsAdminRepository opsAdminRepository, OpsAdminRepository opsAdminRepository,
ServiceActivationRequestRepository requestRepository, ServiceActivationRequestRepository requestRepository,
OperationLogRepository operationLogRepository,
PasswordEncoder passwordEncoder, JwtUtil jwtUtil) { PasswordEncoder passwordEncoder, JwtUtil jwtUtil) {
this.tenantRepository = tenantRepository; this.tenantRepository = tenantRepository;
this.appRepository = appRepository; this.appRepository = appRepository;
this.featureServiceRepository = featureServiceRepository;
this.opsAdminRepository = opsAdminRepository; this.opsAdminRepository = opsAdminRepository;
this.requestRepository = requestRepository; this.requestRepository = requestRepository;
this.operationLogRepository = operationLogRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
} }
@ -57,6 +70,39 @@ public class OpsService {
); );
} }
public Map<String, Object> getTenantDetail(String tenantId) {
TenantEntity tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new IllegalArgumentException("租户不存在"));
List<AppEntity> 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<String, Object> 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<AppEntity> listTenantApps(String tenantId) {
tenantRepository.findById(tenantId)
.orElseThrow(() -> new IllegalArgumentException("租户不存在"));
return appRepository.findByTenantId(tenantId);
}
public void toggleStatus(String tenantId) { public void toggleStatus(String tenantId) {
TenantEntity tenant = tenantRepository.findById(tenantId) TenantEntity tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new IllegalArgumentException("租户不存在")); .orElseThrow(() -> new IllegalArgumentException("租户不存在"));
@ -92,6 +138,39 @@ public class OpsService {
return requestRepository.findAllByOrderByCreatedAtDesc(pageable); return requestRepository.findAllByOrderByCreatedAtDesc(pageable);
} }
public Page<AppEntity> 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<String, Object> getAppDetail(String appId) {
AppEntity app = appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("应用不存在"));
List<FeatureServiceEntity> services = featureServiceRepository.findByAppId(appId);
long enabledCount = services.stream().filter(FeatureServiceEntity::isEnabled).count();
Map<String, Object> 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<FeatureServiceEntity> listAppServices(String appId) {
appRepository.findById(appId)
.orElseThrow(() -> new IllegalArgumentException("应用不存在"));
return featureServiceRepository.findByAppId(appId);
}
public Page<OperationLogEntity> 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) { public void initDefaultAdmin(String username, String rawPassword) {
if (opsAdminRepository.findByUsername(username).isPresent()) return; if (opsAdminRepository.findByUsername(username).isPresent()) return;
OpsAdminEntity admin = new OpsAdminEntity(); OpsAdminEntity admin = new OpsAdminEntity();