feat(sdk): 更新 SDK 设计文档和 API 重构
- 添加 expiresAt 和 refreshUserSig 参数支持自动续签 - 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化 - 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发 - 重构 RN SDK 文档结构,简化安装和使用方式 - 更新统一登录流程,支持 profile 信息传递 - 添加 IM 数据库自动隔离功能 - 修复 Android 群消息聚合问题 - 补充自动化测试验证和错误处理机制
这个提交包含在:
父节点
8c7528c3e9
当前提交
dd465becea
@ -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")
|
||||
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());
|
||||
}
|
||||
|
||||
@ -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.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<String> 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
|
||||
|
||||
@ -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 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<String> 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<String> memberIds = groupService.memberIds(groupService.get(saved.getToId()));
|
||||
pushBridgeClient.notifyUsers(
|
||||
imPushBridge.sendOfflinePushToUsers(
|
||||
appId,
|
||||
memberIds.stream()
|
||||
.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.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<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
||||
if (webhooks.isEmpty()) {
|
||||
@ -61,10 +75,32 @@ public class WebhookDispatchService {
|
||||
);
|
||||
String body = objectMapper.writeValueAsString(envelope);
|
||||
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()
|
||||
.connectTimeout(Duration.ofMillis(webhookTimeoutMs))
|
||||
.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 {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(webhook.getUrl()))
|
||||
@ -76,13 +112,46 @@ public class WebhookDispatchService {
|
||||
.header("X-App-Signature", signature)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.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) {
|
||||
// 准备失败不影响主流程
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
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}
|
||||
|
||||
@ -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<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")
|
||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> 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<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;
|
||||
|
||||
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<AppEntity, String> {
|
||||
Optional<AppEntity> findByAppKey(String appKey);
|
||||
boolean existsByPackageNameAndTenantId(String packageName, String tenantId);
|
||||
long count();
|
||||
Page<AppEntity> findByNameContainingIgnoreCaseOrAppKeyContainingIgnoreCase(String name, String appKey, Pageable pageable);
|
||||
}
|
||||
|
||||
@ -12,4 +12,6 @@ public interface OperationLogRepository extends JpaRepository<OperationLogEntity
|
||||
String tenantId,
|
||||
String moduleType,
|
||||
Pageable pageable);
|
||||
|
||||
Page<OperationLogEntity> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
}
|
||||
|
||||
@ -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<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) {
|
||||
TenantEntity tenant = tenantRepository.findById(tenantId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("租户不存在"));
|
||||
@ -92,6 +138,39 @@ public class OpsService {
|
||||
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) {
|
||||
if (opsAdminRepository.findByUsername(username).isPresent()) return;
|
||||
OpsAdminEntity admin = new OpsAdminEntity();
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户