diff --git a/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java b/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java index 4faf403..50a7bc0 100644 --- a/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java +++ b/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java @@ -39,7 +39,7 @@ public class SecurityConfig { .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/api/im/auth/**", "/ws/**", "/actuator/**").permitAll() + .requestMatchers("/api/im/auth/**", "/api/im/internal/**", "/ws/**", "/actuator/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); diff --git a/im-service/src/main/java/com/xuqm/im/controller/InternalPresenceController.java b/im-service/src/main/java/com/xuqm/im/controller/InternalPresenceController.java new file mode 100644 index 0000000..cbe2472 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/controller/InternalPresenceController.java @@ -0,0 +1,73 @@ +package com.xuqm.im.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.common.security.JwtUtil; +import com.xuqm.im.service.UserPresenceService; +import io.jsonwebtoken.Claims; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/im/internal/presence") +public class InternalPresenceController { + + private final JwtUtil jwtUtil; + private final UserPresenceService presenceService; + + @Value("${im.internal-token:xuqm-internal-token}") + private String internalToken; + + public InternalPresenceController(JwtUtil jwtUtil, UserPresenceService presenceService) { + this.jwtUtil = jwtUtil; + this.presenceService = presenceService; + } + + @PostMapping("/resolve-token") + public ResponseEntity> resolveToken( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @RequestBody TokenRequest request) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + try { + Claims claims = jwtUtil.parse(request.token()); + String userId = claims.getSubject(); + String appId = claims.get("appId", String.class); + return ResponseEntity.ok(ApiResponse.success(status(appId, userId))); + } catch (Exception e) { + return ResponseEntity.badRequest().body(ApiResponse.error(400, "Invalid IM token")); + } + } + + @GetMapping("/users/{userId}") + public ResponseEntity> userStatus( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @RequestParam String appId, + @PathVariable String userId) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + return ResponseEntity.ok(ApiResponse.success(status(appId, userId))); + } + + private PresenceStatus status(String appId, String userId) { + boolean online = presenceService.isOnline(userId); + return new PresenceStatus(appId, userId, online, presenceService.lastSeenAt(userId)); + } + + private boolean isAllowed(String token) { + return token != null && internalToken.equals(token); + } + + public record TokenRequest(String token) {} + + public record PresenceStatus(String appId, String userId, boolean online, long lastSeenAt) {} +} diff --git a/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java b/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java index 6f95b1e..d340512 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java @@ -73,6 +73,10 @@ public class ImFeatureConfigClient { return readConfig(appId).path("multiClientConversationDeleteSync").asBoolean(false); } + public boolean allowMultiDeviceLogin(String appId) { + return readConfig(appId).path("allowMultiDeviceLogin").asBoolean(true); + } + private JsonNode readConfig(String appId) { String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl) .path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}") diff --git a/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java b/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java index 6f87812..b4354b2 100644 --- a/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java +++ b/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java @@ -1,10 +1,14 @@ package com.xuqm.push.controller; import com.xuqm.common.model.ApiResponse; +import com.xuqm.push.entity.DeviceLoginLogEntity; +import com.xuqm.push.service.PushDiagnosticsService; import com.xuqm.push.service.PushDispatcher; import jakarta.validation.constraints.NotBlank; +import org.springframework.data.domain.Page; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -18,12 +22,14 @@ import java.util.List; public class InternalPushController { private final PushDispatcher pushDispatcher; + private final PushDiagnosticsService diagnosticsService; @Value("${push.internal-token:xuqm-internal-token}") private String internalToken; - public InternalPushController(PushDispatcher pushDispatcher) { + public InternalPushController(PushDispatcher pushDispatcher, PushDiagnosticsService diagnosticsService) { this.pushDispatcher = pushDispatcher; + this.diagnosticsService = diagnosticsService; } @PostMapping("/notify") @@ -37,6 +43,55 @@ public class InternalPushController { return ResponseEntity.ok(ApiResponse.ok()); } + @GetMapping("/diagnostics/search") + public ResponseEntity> searchByToken( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @org.springframework.web.bind.annotation.RequestParam String queryToken, + @org.springframework.web.bind.annotation.RequestParam(required = false) String appId) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + return ResponseEntity.ok(ApiResponse.success(diagnosticsService.searchByToken(queryToken, appId))); + } + + @GetMapping("/device-logs") + public ResponseEntity>> deviceLogs( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @org.springframework.web.bind.annotation.RequestParam String appId, + @org.springframework.web.bind.annotation.RequestParam String userId, + @org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page, + @org.springframework.web.bind.annotation.RequestParam(defaultValue = "20") int size) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + Page result = diagnosticsService.deviceLogs(appId, userId, page, size); + return ResponseEntity.ok(ApiResponse.success(java.util.Map.of( + "content", result.getContent(), + "total", result.getTotalElements(), + "totalPages", result.getTotalPages() + ))); + } + + @PostMapping("/test-offline") + public ResponseEntity> testOffline( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @RequestBody TestOfflineRequest request) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + PushDiagnosticsService.TestPushResult result = diagnosticsService.sendTestOfflineMessage( + request.appId(), + request.userId(), + request.title(), + request.body(), + request.payload()); + return ResponseEntity.ok(ApiResponse.success(result)); + } + + private boolean isAllowed(String token) { + return token != null && internalToken.equals(token); + } + public record NotifyRequest( @NotBlank String appId, List<@NotBlank String> userIds, @@ -44,4 +99,12 @@ public class InternalPushController { @NotBlank String body, String payload ) {} + + public record TestOfflineRequest( + @NotBlank String appId, + @NotBlank String userId, + @NotBlank String title, + @NotBlank String body, + String payload + ) {} } diff --git a/push-service/src/main/java/com/xuqm/push/controller/PushController.java b/push-service/src/main/java/com/xuqm/push/controller/PushController.java index ccb9872..f641dae 100644 --- a/push-service/src/main/java/com/xuqm/push/controller/PushController.java +++ b/push-service/src/main/java/com/xuqm/push/controller/PushController.java @@ -27,8 +27,14 @@ public class PushController { @RequestParam @NotBlank String appId, @RequestParam @NotBlank String userId, @RequestParam @NotNull DeviceTokenEntity.Vendor vendor, - @RequestParam @NotBlank String token) { - pushDispatcher.registerToken(appId, userId, vendor, token); + @RequestParam @NotBlank String token, + @RequestParam(required = false) String platform, + @RequestParam(required = false) String deviceId, + @RequestParam(required = false) String brand, + @RequestParam(required = false) String model, + @RequestParam(required = false) String osVersion, + @RequestParam(required = false) String appVersion) { + pushDispatcher.registerToken(appId, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion); return ResponseEntity.ok(ApiResponse.ok()); } @@ -36,8 +42,9 @@ public class PushController { public ResponseEntity> receivePush( @RequestParam @NotBlank String appId, @RequestParam @NotBlank String userId, + @RequestParam(required = false) String deviceId, @RequestParam boolean enabled) { - pushDispatcher.setReceivePush(appId, userId, enabled); + pushDispatcher.setReceivePush(appId, userId, deviceId, enabled); return ResponseEntity.ok(ApiResponse.ok()); } @@ -56,8 +63,9 @@ public class PushController { public ResponseEntity> unregister( @RequestParam @NotBlank String appId, @RequestParam @NotBlank String userId, - @RequestParam @NotNull DeviceTokenEntity.Vendor vendor) { - pushDispatcher.unregisterToken(appId, userId, vendor); + @RequestParam @NotNull DeviceTokenEntity.Vendor vendor, + @RequestParam(required = false) String deviceId) { + pushDispatcher.unregisterToken(appId, userId, vendor, deviceId); return ResponseEntity.ok(ApiResponse.ok()); } } diff --git a/push-service/src/main/java/com/xuqm/push/entity/DeviceLoginLogEntity.java b/push-service/src/main/java/com/xuqm/push/entity/DeviceLoginLogEntity.java new file mode 100644 index 0000000..ff8382f --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/entity/DeviceLoginLogEntity.java @@ -0,0 +1,114 @@ +package com.xuqm.push.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "push_device_login_log", indexes = { + @Index(name = "idx_push_device_log_user_time", columnList = "appId,userId,createdAt"), + @Index(name = "idx_push_device_log_token", columnList = "tokenHash") +}) +public class DeviceLoginLogEntity { + + public enum EventType { + REGISTER, UNREGISTER, RECEIVE_PUSH_UPDATE + } + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 128) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private DeviceTokenEntity.Vendor vendor; + + @Column(nullable = false, length = 128) + private String tokenHash; + + @Column(nullable = false, length = 128) + private String tokenPreview; + + @Column(length = 32) + private String platform; + + @Column(nullable = false, length = 128) + private String deviceId; + + @Column(length = 64) + private String brand; + + @Column(length = 128) + private String model; + + @Column(length = 64) + private String osVersion; + + @Column(length = 64) + private String appVersion; + + @Column(nullable = false) + private boolean receivePush; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private EventType eventType; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public DeviceTokenEntity.Vendor getVendor() { return vendor; } + public void setVendor(DeviceTokenEntity.Vendor vendor) { this.vendor = vendor; } + + public String getTokenHash() { return tokenHash; } + public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; } + + public String getTokenPreview() { return tokenPreview; } + public void setTokenPreview(String tokenPreview) { this.tokenPreview = tokenPreview; } + + public String getPlatform() { return platform; } + public void setPlatform(String platform) { this.platform = platform; } + + public String getDeviceId() { return deviceId; } + public void setDeviceId(String deviceId) { this.deviceId = deviceId; } + + public String getBrand() { return brand; } + public void setBrand(String brand) { this.brand = brand; } + + public String getModel() { return model; } + public void setModel(String model) { this.model = model; } + + public String getOsVersion() { return osVersion; } + public void setOsVersion(String osVersion) { this.osVersion = osVersion; } + + public String getAppVersion() { return appVersion; } + public void setAppVersion(String appVersion) { this.appVersion = appVersion; } + + public boolean isReceivePush() { return receivePush; } + public void setReceivePush(boolean receivePush) { this.receivePush = receivePush; } + + public EventType getEventType() { return eventType; } + public void setEventType(EventType eventType) { this.eventType = eventType; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java b/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java index be07c3d..2ad77a9 100644 --- a/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java +++ b/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java @@ -11,7 +11,7 @@ import java.time.LocalDateTime; @Entity @Table(name = "push_device_token", - uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "vendor"})) + uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "deviceId"})) public class DeviceTokenEntity { public enum Vendor { @@ -34,9 +34,30 @@ public class DeviceTokenEntity { @Column(nullable = false, length = 512) private String token; + @Column(length = 32) + private String platform; + + @Column(length = 128) + private String deviceId; + + @Column(length = 64) + private String brand; + + @Column(length = 128) + private String model; + + @Column(length = 64) + private String osVersion; + + @Column(length = 64) + private String appVersion; + @Column(nullable = false) private boolean receivePush = true; + @Column + private LocalDateTime lastLoginAt; + @Column(nullable = false) private LocalDateTime createdAt; @@ -58,9 +79,30 @@ public class DeviceTokenEntity { public String getToken() { return token; } public void setToken(String token) { this.token = token; } + public String getPlatform() { return platform; } + public void setPlatform(String platform) { this.platform = platform; } + + public String getDeviceId() { return deviceId; } + public void setDeviceId(String deviceId) { this.deviceId = deviceId; } + + public String getBrand() { return brand; } + public void setBrand(String brand) { this.brand = brand; } + + public String getModel() { return model; } + public void setModel(String model) { this.model = model; } + + public String getOsVersion() { return osVersion; } + public void setOsVersion(String osVersion) { this.osVersion = osVersion; } + + public String getAppVersion() { return appVersion; } + public void setAppVersion(String appVersion) { this.appVersion = appVersion; } + public boolean isReceivePush() { return receivePush; } public void setReceivePush(boolean receivePush) { this.receivePush = receivePush; } + public LocalDateTime getLastLoginAt() { return lastLoginAt; } + public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } diff --git a/push-service/src/main/java/com/xuqm/push/repository/DeviceLoginLogRepository.java b/push-service/src/main/java/com/xuqm/push/repository/DeviceLoginLogRepository.java new file mode 100644 index 0000000..ec607e1 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/repository/DeviceLoginLogRepository.java @@ -0,0 +1,10 @@ +package com.xuqm.push.repository; + +import com.xuqm.push.entity.DeviceLoginLogEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DeviceLoginLogRepository extends JpaRepository { + Page findByAppIdAndUserIdOrderByCreatedAtDesc(String appId, String userId, Pageable pageable); +} diff --git a/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java b/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java index 4680fb5..20b1f58 100644 --- a/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java +++ b/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java @@ -8,8 +8,15 @@ import java.util.Optional; public interface DeviceTokenRepository extends JpaRepository { List findByAppIdAndUserIdAndReceivePushTrue(String appId, String userId); + Optional findFirstByAppIdAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc( + String appId, String userId); + List findByAppIdAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc(String appId, String userId); Optional findByAppIdAndUserIdAndVendor( String appId, String userId, DeviceTokenEntity.Vendor vendor); + Optional findByAppIdAndUserIdAndDeviceId(String appId, String userId, String deviceId); + Optional findFirstByToken(String token); List findByAppIdAndUserId(String appId, String userId); + List findByAppIdAndUserIdOrderByLastLoginAtDescUpdatedAtDesc(String appId, String userId); void deleteByAppIdAndUserIdAndVendor(String appId, String userId, DeviceTokenEntity.Vendor vendor); + void deleteByAppIdAndUserIdAndDeviceId(String appId, String userId, String deviceId); } diff --git a/push-service/src/main/java/com/xuqm/push/service/ImPresenceClient.java b/push-service/src/main/java/com/xuqm/push/service/ImPresenceClient.java new file mode 100644 index 0000000..e64e8c5 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/ImPresenceClient.java @@ -0,0 +1,90 @@ +package com.xuqm.push.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Map; +import java.util.Optional; + +@Component +public class ImPresenceClient { + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper; + + @Value("${push.im-service-base-url:http://im-service:8082}") + private String imServiceBaseUrl; + + @Value("${push.internal-token:xuqm-internal-token}") + private String internalToken; + + public ImPresenceClient(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public Optional resolveToken(String token) { + try { + HttpHeaders headers = internalHeaders(); + HttpEntity request = new HttpEntity<>( + objectMapper.writeValueAsString(Map.of("token", token)), + headers); + String body = restTemplate.postForObject( + imServiceBaseUrl + "/api/im/internal/presence/resolve-token", + request, + String.class); + return parse(body); + } catch (Exception ignored) { + return Optional.empty(); + } + } + + public Optional userStatus(String appId, String userId) { + try { + HttpHeaders headers = internalHeaders(); + HttpEntity request = new HttpEntity<>(headers); + String uri = UriComponentsBuilder.fromHttpUrl(imServiceBaseUrl + "/api/im/internal/presence/users/{userId}") + .queryParam("appId", appId) + .build(userId) + .toString(); + String body = restTemplate.exchange( + uri, + org.springframework.http.HttpMethod.GET, + request, + String.class).getBody(); + return parse(body); + } catch (Exception ignored) { + return Optional.empty(); + } + } + + private HttpHeaders internalHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Internal-Token", internalToken); + return headers; + } + + private Optional parse(String body) throws Exception { + if (body == null || body.isBlank()) { + return Optional.empty(); + } + JsonNode data = objectMapper.readTree(body).path("data"); + if (data.isMissingNode() || data.isNull()) { + return Optional.empty(); + } + return Optional.of(new PresenceStatus( + data.path("appId").asText(null), + data.path("userId").asText(null), + data.path("online").asBoolean(false), + data.path("lastSeenAt").asLong(0L))); + } + + public record PresenceStatus(String appId, String userId, boolean online, long lastSeenAt) {} +} diff --git a/push-service/src/main/java/com/xuqm/push/service/PushDiagnosticsService.java b/push-service/src/main/java/com/xuqm/push/service/PushDiagnosticsService.java new file mode 100644 index 0000000..0432196 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/PushDiagnosticsService.java @@ -0,0 +1,157 @@ +package com.xuqm.push.service; + +import com.xuqm.common.security.JwtUtil; +import com.xuqm.push.entity.DeviceLoginLogEntity; +import com.xuqm.push.entity.DeviceTokenEntity; +import com.xuqm.push.repository.DeviceLoginLogRepository; +import com.xuqm.push.repository.DeviceTokenRepository; +import io.jsonwebtoken.Claims; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Service +public class PushDiagnosticsService { + + private final DeviceTokenRepository tokenRepository; + private final DeviceLoginLogRepository logRepository; + private final ImPresenceClient presenceClient; + private final PushDispatcher pushDispatcher; + private final JwtUtil jwtUtil; + + public PushDiagnosticsService(DeviceTokenRepository tokenRepository, + DeviceLoginLogRepository logRepository, + ImPresenceClient presenceClient, + PushDispatcher pushDispatcher, + JwtUtil jwtUtil) { + this.tokenRepository = tokenRepository; + this.logRepository = logRepository; + this.presenceClient = presenceClient; + this.pushDispatcher = pushDispatcher; + this.jwtUtil = jwtUtil; + } + + public PushTokenDiagnostics searchByToken(String token, String appIdHint) { + Optional tokenMatch = tokenRepository.findFirstByToken(token); + String appId = appIdHint; + String userId = null; + String tokenType = "UNKNOWN"; + + if (tokenMatch.isPresent()) { + DeviceTokenEntity device = tokenMatch.get(); + appId = device.getAppId(); + userId = device.getUserId(); + tokenType = "PUSH"; + } else { + Optional resolved = presenceClient.resolveToken(token); + if (resolved.isPresent()) { + appId = resolved.get().appId(); + userId = resolved.get().userId(); + tokenType = "IM"; + } else { + try { + Claims claims = jwtUtil.parse(token); + userId = claims.getSubject(); + String claimAppId = claims.get("appId", String.class); + appId = claimAppId == null || claimAppId.isBlank() ? appIdHint : claimAppId; + tokenType = "IM"; + } catch (Exception ignored) { + // Keep UNKNOWN and return an empty diagnostic. + } + } + } + + if (appId == null || appId.isBlank() || userId == null || userId.isBlank()) { + return new PushTokenDiagnostics(tokenType, appId, userId, false, 0L, false, null, List.of(), List.of()); + } + + ImPresenceClient.PresenceStatus presence = presenceClient.userStatus(appId, userId) + .orElse(new ImPresenceClient.PresenceStatus(appId, userId, false, 0L)); + List devices = tokenRepository.findByAppIdAndUserIdOrderByLastLoginAtDescUpdatedAtDesc(appId, userId); + List deliverableDevices = pushDispatcher.selectedPushTargets(appId, userId) + .stream() + .map(DeviceInfo::from) + .toList(); + DeviceInfo deliverable = deliverableDevices.isEmpty() ? null : deliverableDevices.get(0); + boolean canSendOffline = !presence.online() && !deliverableDevices.isEmpty(); + return new PushTokenDiagnostics( + tokenType, + appId, + userId, + presence.online(), + presence.lastSeenAt(), + canSendOffline, + deliverable, + deliverableDevices, + devices.stream().map(DeviceInfo::from).toList()); + } + + public TestPushResult sendTestOfflineMessage(String appId, String userId, String title, String body, String payload) { + List targets = pushDispatcher.selectedPushTargets(appId, userId) + .stream() + .map(DeviceInfo::from) + .toList(); + if (!targets.isEmpty()) { + pushDispatcher.pushToUser(appId, userId, title, body, payload); + } + return new TestPushResult(appId, userId, !targets.isEmpty(), targets.size(), targets); + } + + public Page deviceLogs(String appId, String userId, int page, int size) { + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), 200); + return logRepository.findByAppIdAndUserIdOrderByCreatedAtDesc(appId, userId, PageRequest.of(safePage, safeSize)); + } + + public record PushTokenDiagnostics( + String tokenType, + String appId, + String userId, + boolean online, + long lastSeenAt, + boolean canSendOfflineMessage, + DeviceInfo deliverableDevice, + List deliverableDevices, + List devices) {} + + public record TestPushResult( + String appId, + String userId, + boolean sent, + int targetCount, + List targets) {} + + public record DeviceInfo( + String id, + String vendor, + String tokenPreview, + String platform, + String deviceId, + String brand, + String model, + String osVersion, + String appVersion, + boolean receivePush, + LocalDateTime lastLoginAt, + LocalDateTime updatedAt) { + static DeviceInfo from(DeviceTokenEntity entity) { + return new DeviceInfo( + entity.getId(), + entity.getVendor().name(), + PushDispatcher.previewToken(entity.getToken()), + entity.getPlatform(), + entity.getDeviceId(), + entity.getBrand(), + entity.getModel(), + entity.getOsVersion(), + entity.getAppVersion(), + entity.isReceivePush(), + entity.getLastLoginAt(), + entity.getUpdatedAt()); + } + } +} diff --git a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java index bbd6eae..202593e 100644 --- a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java +++ b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java @@ -1,6 +1,8 @@ package com.xuqm.push.service; import com.xuqm.push.entity.DeviceTokenEntity; +import com.xuqm.push.entity.DeviceLoginLogEntity; +import com.xuqm.push.repository.DeviceLoginLogRepository; import com.xuqm.push.repository.DeviceTokenRepository; import com.xuqm.push.service.provider.PushProvider; import org.slf4j.Logger; @@ -8,6 +10,9 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.time.LocalDateTime; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.Map; import java.util.Optional; @@ -20,25 +25,40 @@ public class PushDispatcher { private static final Logger log = LoggerFactory.getLogger(PushDispatcher.class); private final DeviceTokenRepository tokenRepository; + private final DeviceLoginLogRepository logRepository; + private final TenantImConfigClient imConfigClient; private final Map providers; - public PushDispatcher(DeviceTokenRepository tokenRepository, List providerList) { + public PushDispatcher(DeviceTokenRepository tokenRepository, + DeviceLoginLogRepository logRepository, + TenantImConfigClient imConfigClient, + List providerList) { this.tokenRepository = tokenRepository; + this.logRepository = logRepository; + this.imConfigClient = imConfigClient; this.providers = providerList.stream() .collect(Collectors.toMap(PushProvider::vendorName, p -> p)); } public void pushToUser(String appId, String userId, String title, String body, String payload) { - List tokens = tokenRepository.findByAppIdAndUserIdAndReceivePushTrue(appId, userId); - for (DeviceTokenEntity t : tokens) { + List targets = selectTargets(appId, userId); + if (targets.isEmpty()) { + log.info("Skip push to {}@{}: no receive-enabled device", userId, appId); + return; + } + for (DeviceTokenEntity t : targets) { PushProvider provider = providers.get(t.getVendor().name()); if (provider != null) { boolean ok = provider.send(appId, t.getToken(), title, body, payload); - log.info("Push to {}@{} via {}: {}", userId, appId, t.getVendor(), ok ? "OK" : "FAIL"); + log.info("Push to {}@{} via {} deviceId={}: {}", userId, appId, t.getVendor(), t.getDeviceId(), ok ? "OK" : "FAIL"); } } } + public List selectedPushTargets(String appId, String userId) { + return selectTargets(appId, userId); + } + public void pushToUsers(String appId, List userIds, String title, String body, String payload) { if (userIds == null || userIds.isEmpty()) { return; @@ -48,33 +68,142 @@ public class PushDispatcher { } } - public void registerToken(String appId, String userId, DeviceTokenEntity.Vendor vendor, String token) { - Optional existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor); + private List selectTargets(String appId, String userId) { + List devices = tokenRepository + .findByAppIdAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc(appId, userId); + if (devices.isEmpty()) { + return List.of(); + } + if (!imConfigClient.allowMultiDeviceLogin(appId)) { + return List.of(devices.get(0)); + } + return devices.stream() + .collect(Collectors.toMap( + DeviceTokenEntity::getVendor, + device -> device, + (first, ignored) -> first, + java.util.LinkedHashMap::new)) + .values() + .stream() + .toList(); + } + + public void registerToken(String appId, + String userId, + DeviceTokenEntity.Vendor vendor, + String token, + String platform, + String deviceId, + String brand, + String model, + String osVersion, + String appVersion) { + String resolvedDeviceId = normalizeDeviceId(deviceId, vendor, token); + Optional existing = tokenRepository.findByAppIdAndUserIdAndDeviceId(appId, userId, resolvedDeviceId); + if (existing.isEmpty() && (deviceId == null || deviceId.isBlank())) { + existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor); + } + LocalDateTime now = LocalDateTime.now(); DeviceTokenEntity entity = existing.orElseGet(() -> { DeviceTokenEntity e = new DeviceTokenEntity(); e.setId(UUID.randomUUID().toString()); e.setAppId(appId); e.setUserId(userId); e.setVendor(vendor); - e.setCreatedAt(LocalDateTime.now()); + e.setCreatedAt(now); return e; }); + entity.setVendor(vendor); entity.setToken(token); + entity.setPlatform(blankToNull(platform)); + entity.setDeviceId(resolvedDeviceId); + entity.setBrand(blankToNull(brand)); + entity.setModel(blankToNull(model)); + entity.setOsVersion(blankToNull(osVersion)); + entity.setAppVersion(blankToNull(appVersion)); entity.setReceivePush(true); - entity.setUpdatedAt(LocalDateTime.now()); - tokenRepository.save(entity); + entity.setLastLoginAt(now); + entity.setUpdatedAt(now); + DeviceTokenEntity saved = tokenRepository.save(entity); + recordLog(saved, DeviceLoginLogEntity.EventType.REGISTER); } - public void setReceivePush(String appId, String userId, boolean enabled) { - List tokens = tokenRepository.findByAppIdAndUserId(appId, userId); + public void setReceivePush(String appId, String userId, String deviceId, boolean enabled) { + List tokens = deviceId == null || deviceId.isBlank() + ? tokenRepository.findByAppIdAndUserId(appId, userId) + : tokenRepository.findByAppIdAndUserIdAndDeviceId(appId, userId, deviceId).stream().toList(); for (DeviceTokenEntity token : tokens) { token.setReceivePush(enabled); token.setUpdatedAt(LocalDateTime.now()); + recordLog(token, DeviceLoginLogEntity.EventType.RECEIVE_PUSH_UPDATE); } tokenRepository.saveAll(tokens); } - public void unregisterToken(String appId, String userId, DeviceTokenEntity.Vendor vendor) { + public void unregisterToken(String appId, String userId, DeviceTokenEntity.Vendor vendor, String deviceId) { + if (deviceId != null && !deviceId.isBlank()) { + tokenRepository.findByAppIdAndUserIdAndDeviceId(appId, userId, deviceId) + .ifPresent(entity -> recordLog(entity, DeviceLoginLogEntity.EventType.UNREGISTER)); + tokenRepository.deleteByAppIdAndUserIdAndDeviceId(appId, userId, deviceId); + return; + } + tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor) + .ifPresent(entity -> recordLog(entity, DeviceLoginLogEntity.EventType.UNREGISTER)); tokenRepository.deleteByAppIdAndUserIdAndVendor(appId, userId, vendor); } + + private void recordLog(DeviceTokenEntity token, DeviceLoginLogEntity.EventType eventType) { + DeviceLoginLogEntity entity = new DeviceLoginLogEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(token.getAppId()); + entity.setUserId(token.getUserId()); + entity.setVendor(token.getVendor()); + entity.setTokenHash(hashToken(token.getToken())); + entity.setTokenPreview(previewToken(token.getToken())); + entity.setPlatform(token.getPlatform()); + entity.setDeviceId(token.getDeviceId()); + entity.setBrand(token.getBrand()); + entity.setModel(token.getModel()); + entity.setOsVersion(token.getOsVersion()); + entity.setAppVersion(token.getAppVersion()); + entity.setReceivePush(token.isReceivePush()); + entity.setEventType(eventType); + entity.setCreatedAt(LocalDateTime.now()); + logRepository.save(entity); + } + + private String normalizeDeviceId(String deviceId, DeviceTokenEntity.Vendor vendor, String token) { + if (deviceId != null && !deviceId.isBlank()) { + return deviceId.trim(); + } + return vendor.name() + ":" + hashToken(token).substring(0, 24); + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + public static String previewToken(String token) { + if (token == null || token.isBlank()) { + return ""; + } + if (token.length() <= 16) { + return token; + } + return token.substring(0, 8) + "..." + token.substring(token.length() - 8); + } } diff --git a/push-service/src/main/java/com/xuqm/push/service/TenantImConfigClient.java b/push-service/src/main/java/com/xuqm/push/service/TenantImConfigClient.java new file mode 100644 index 0000000..eea9d0d --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/TenantImConfigClient.java @@ -0,0 +1,52 @@ +package com.xuqm.push.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +public class TenantImConfigClient { + + private static final Logger log = LoggerFactory.getLogger(TenantImConfigClient.class); + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper; + + @Value("${push.tenant-service-base-url:http://tenant-service:8081}") + private String tenantServiceBaseUrl; + + @Value("${push.internal-token:xuqm-internal-token}") + private String internalToken; + + public TenantImConfigClient(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public boolean allowMultiDeviceLogin(String appId) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + ResponseEntity response = restTemplate.exchange( + tenantServiceBaseUrl + "/api/internal/sdk/apps/" + appId + "/services/ANDROID/IM", + HttpMethod.GET, + new HttpEntity<>(headers), + JsonNode.class); + JsonNode body = response.getBody(); + String config = body == null ? "" : body.path("data").path("config").asText(""); + if (!config.isBlank()) { + return objectMapper.readTree(config).path("allowMultiDeviceLogin").asBoolean(true); + } + } catch (Exception e) { + log.warn("load tenant im config failed appId={} reason={}", appId, e.getMessage()); + } + return true; + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index 4cf5cc2..8e7bb15 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -91,6 +91,7 @@ public class FeatureServiceController { req == null ? null : req.messageRecallMinutes(), req == null ? null : req.historyRetentionDays(), req == null ? null : req.conversationPullLimit(), + req == null ? null : req.allowMultiDeviceLogin(), req == null ? null : req.multiClientConversationDeleteSync()); case UPDATE -> featureServiceManager.buildUpdateConfig( appId, @@ -110,26 +111,26 @@ public class FeatureServiceController { case PUSH -> featureServiceManager.buildPushConfig( appId, platform, - req == null ? null : req.huaweiAppId(), - req == null ? null : req.huaweiAppSecret(), - req == null ? null : req.xiaomiAppId(), - req == null ? null : req.xiaomiAppKey(), - req == null ? null : req.xiaomiAppSecret(), - req == null ? null : req.oppoAppId(), - req == null ? null : req.oppoAppKey(), - req == null ? null : req.oppoMasterSecret(), - req == null ? null : req.vivoAppId(), - req == null ? null : req.vivoAppKey(), - req == null ? null : req.vivoAppSecret(), - req == null ? null : req.honorAppId(), - req == null ? null : req.honorClientId(), - req == null ? null : req.honorClientSecret(), - req == null ? null : req.apnsTeamId(), - req == null ? null : req.apnsKeyId(), - req == null ? null : req.apnsBundleId(), - req == null ? null : req.apnsKeyPath(), - req != null && Boolean.TRUE.equals(req.apnsSandbox()), - req == null ? null : req.fcmServiceAccountJson()); + req == null ? null : req.huaweiAppIdValue(), + req == null ? null : req.huaweiAppSecretValue(), + req == null ? null : req.xiaomiAppIdValue(), + req == null ? null : req.xiaomiAppKeyValue(), + req == null ? null : req.xiaomiAppSecretValue(), + req == null ? null : req.oppoAppIdValue(), + req == null ? null : req.oppoAppKeyValue(), + req == null ? null : req.oppoMasterSecretValue(), + req == null ? null : req.vivoAppIdValue(), + req == null ? null : req.vivoAppKeyValue(), + req == null ? null : req.vivoAppSecretValue(), + req == null ? null : req.honorAppIdValue(), + req == null ? null : req.honorClientIdValue(), + req == null ? null : req.honorClientSecretValue(), + req == null ? null : req.apnsTeamIdValue(), + req == null ? null : req.apnsKeyIdValue(), + req == null ? null : req.apnsBundleIdValue(), + req == null ? null : req.apnsKeyPathValue(), + req != null && req.apnsSandboxValue(), + req == null ? null : req.fcmServiceAccountJsonValue()); }; FeatureServiceEntity saved = featureServiceManager.updateConfig( appId, platform, serviceType, config); @@ -190,6 +191,7 @@ public class FeatureServiceController { Integer messageRecallMinutes, Integer historyRetentionDays, Integer conversationPullLimit, + Boolean allowMultiDeviceLogin, Boolean multiClientConversationDeleteSync, List defaultStoreTargets, String defaultPublishMode, @@ -222,6 +224,63 @@ public class FeatureServiceController { String apnsBundleId, String apnsKeyPath, Boolean apnsSandbox, - String fcmServiceAccountJson + String fcmServiceAccountJson, + PushVendorConfig huawei, + PushVendorConfig xiaomi, + PushVendorConfig oppo, + PushVendorConfig vivo, + PushVendorConfig honor, + PushVendorConfig apns, + PushVendorConfig fcm + ) { + public String huaweiAppIdValue() { return firstText(huaweiAppId, huawei == null ? null : huawei.appId()); } + public String huaweiAppSecretValue() { return firstText(huaweiAppSecret, huawei == null ? null : huawei.appSecret()); } + public String xiaomiAppIdValue() { return firstText(xiaomiAppId, xiaomi == null ? null : xiaomi.appId()); } + public String xiaomiAppKeyValue() { return firstText(xiaomiAppKey, xiaomi == null ? null : xiaomi.appKey()); } + public String xiaomiAppSecretValue() { return firstText(xiaomiAppSecret, xiaomi == null ? null : xiaomi.appSecret()); } + public String oppoAppIdValue() { return firstText(oppoAppId, oppo == null ? null : oppo.appId()); } + public String oppoAppKeyValue() { return firstText(oppoAppKey, oppo == null ? null : oppo.appKey()); } + public String oppoMasterSecretValue() { return firstText(oppoMasterSecret, oppo == null ? null : oppo.masterSecret()); } + public String vivoAppIdValue() { return firstText(vivoAppId, vivo == null ? null : vivo.appId()); } + public String vivoAppKeyValue() { return firstText(vivoAppKey, vivo == null ? null : vivo.appKey()); } + public String vivoAppSecretValue() { return firstText(vivoAppSecret, vivo == null ? null : vivo.appSecret()); } + public String honorAppIdValue() { return firstText(honorAppId, honor == null ? null : honor.appId()); } + public String honorClientIdValue() { return firstText(honorClientId, honor == null ? null : honor.clientId()); } + public String honorClientSecretValue() { return firstText(honorClientSecret, honor == null ? null : honor.clientSecret()); } + public String apnsTeamIdValue() { return firstText(apnsTeamId, apns == null ? null : apns.teamId()); } + public String apnsKeyIdValue() { return firstText(apnsKeyId, apns == null ? null : apns.keyId()); } + public String apnsBundleIdValue() { return firstText(apnsBundleId, apns == null ? null : apns.bundleId()); } + public String apnsKeyPathValue() { return firstText(apnsKeyPath, apns == null ? null : apns.keyPath()); } + public boolean apnsSandboxValue() { + if (apnsSandbox != null) { + return apnsSandbox; + } + return apns != null && Boolean.TRUE.equals(apns.sandbox()); + } + public String fcmServiceAccountJsonValue() { + return firstText(fcmServiceAccountJson, fcm == null ? null : fcm.serviceAccountJson()); + } + + private static String firstText(String first, String second) { + if (first != null) { + return first; + } + return second; + } + } + + public record PushVendorConfig( + String appId, + String appKey, + String appSecret, + String masterSecret, + String clientId, + String clientSecret, + String teamId, + String keyId, + String bundleId, + String keyPath, + Boolean sandbox, + String serviceAccountJson ) {} } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java index 6c160dd..fe5b464 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java @@ -10,6 +10,7 @@ import com.xuqm.tenant.service.FeatureServiceManager; import com.xuqm.tenant.entity.RiskConfigEntity; import com.xuqm.tenant.entity.SensitiveWordEntity; import com.xuqm.tenant.service.OpsService; +import com.xuqm.tenant.service.OpsPushDiagnosticsClient; import com.xuqm.tenant.service.RiskControlService; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; @@ -34,12 +35,15 @@ public class OpsController { private final OpsService opsService; private final FeatureServiceManager featureServiceManager; private final RiskControlService riskControlService; + private final OpsPushDiagnosticsClient pushDiagnosticsClient; public OpsController(OpsService opsService, FeatureServiceManager featureServiceManager, - RiskControlService riskControlService) { + RiskControlService riskControlService, + OpsPushDiagnosticsClient pushDiagnosticsClient) { this.opsService = opsService; this.featureServiceManager = featureServiceManager; this.riskControlService = riskControlService; + this.pushDiagnosticsClient = pushDiagnosticsClient; } @PostMapping("/api/auth/ops/login") @@ -160,6 +164,35 @@ public class OpsController { ))); } + @GetMapping("/api/ops/push/search") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> searchPushByToken( + @RequestParam String token, + @RequestParam(required = false) String appId) { + return ResponseEntity.ok(ApiResponse.success(pushDiagnosticsClient.searchByToken(token, appId))); + } + + @GetMapping("/api/ops/push/device-logs") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> pushDeviceLogs( + @RequestParam String appId, + @RequestParam String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(ApiResponse.success(pushDiagnosticsClient.deviceLogs(appId, userId, page, size))); + } + + @PostMapping("/api/ops/push/test-offline") + @PreAuthorize("hasAuthority('ROLE_OPS')") + public ResponseEntity>> sendPushTestOffline(@RequestBody Map body) { + return ResponseEntity.ok(ApiResponse.success(pushDiagnosticsClient.sendTestOffline( + body.get("appId"), + body.get("userId"), + body.getOrDefault("title", "XuqmGroup Push 测试"), + body.getOrDefault("body", "这是一条离线推送测试消息"), + body.getOrDefault("payload", "{}")))); + } + /* ---------- 风控配置 ---------- */ @GetMapping("/api/ops/risk/rules") @PreAuthorize("hasAuthority('ROLE_OPS')") diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java index 56987f3..7cbc4b9 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java @@ -66,6 +66,10 @@ public class SdkConfigController { .findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE) .map(feature -> parseConfig(feature.getConfig())) .orElseGet(objectMapper::createObjectNode); + JsonNode pushConfig = featureServiceRepository + .findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.PUSH) + .map(feature -> parseConfig(feature.getConfig())) + .orElseGet(objectMapper::createObjectNode); boolean updateEnabled = featureServiceRepository .findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE) .map(FeatureServiceEntity::isEnabled) @@ -92,7 +96,8 @@ public class SdkConfigController { updateConfig.path("defaultGrayPercent").asInt(0), updateConfig.path("defaultPackageName").asText(""), updateConfig.path("defaultAppStoreUrl").asText(""), - updateConfig.path("defaultMarketUrl").asText("") + updateConfig.path("defaultMarketUrl").asText(""), + pushConfig ); return ResponseEntity.ok(ApiResponse.success(response)); @@ -115,7 +120,8 @@ public class SdkConfigController { int updateDefaultGrayPercent, String updateDefaultPackageName, String updateDefaultAppStoreUrl, - String updateDefaultMarketUrl + String updateDefaultMarketUrl, + JsonNode pushConfig ) {} private JsonNode parseConfig(String config) { diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index 55b1e67..666c5b2 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -280,6 +280,7 @@ public class FeatureServiceManager { Integer messageRecallMinutes, Integer historyRetentionDays, Integer conversationPullLimit, + Boolean allowMultiDeviceLogin, Boolean multiClientConversationDeleteSync) { ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.IM).deepCopy(); if (!node.has("allowStrangerMessage")) { @@ -309,6 +310,9 @@ public class FeatureServiceManager { if (!node.has("multiClientConversationDeleteSync")) { node.put("multiClientConversationDeleteSync", false); } + if (!node.has("allowMultiDeviceLogin")) { + node.put("allowMultiDeviceLogin", true); + } if (allowStrangerMessage != null) { node.put("allowStrangerMessage", allowStrangerMessage); } @@ -342,6 +346,9 @@ public class FeatureServiceManager { if (conversationPullLimit != null) { node.put("conversationPullLimit", Math.min(Math.max(conversationPullLimit, 1), 500)); } + if (allowMultiDeviceLogin != null) { + node.put("allowMultiDeviceLogin", allowMultiDeviceLogin); + } if (multiClientConversationDeleteSync != null) { node.put("multiClientConversationDeleteSync", multiClientConversationDeleteSync); } @@ -358,6 +365,7 @@ public class FeatureServiceManager { node.put("messageRecallMinutes", 2); node.put("historyRetentionDays", 7); node.put("conversationPullLimit", 100); + node.put("allowMultiDeviceLogin", true); node.put("multiClientConversationDeleteSync", false); return node.toString(); } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/OpsPushDiagnosticsClient.java b/tenant-service/src/main/java/com/xuqm/tenant/service/OpsPushDiagnosticsClient.java new file mode 100644 index 0000000..d9f8620 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/OpsPushDiagnosticsClient.java @@ -0,0 +1,85 @@ +package com.xuqm.tenant.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; + +import java.util.Map; + +@Service +public class OpsPushDiagnosticsClient { + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper; + + @Value("${ops.push-service-base-url:http://push-service:8083}") + private String pushServiceBaseUrl; + + @Value("${sdk.internal-token:xuqm-internal-token}") + private String internalToken; + + public OpsPushDiagnosticsClient(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public Map searchByToken(String token, String appId) { + String uri = UriComponentsBuilder.fromHttpUrl(pushServiceBaseUrl + "/api/push/internal/diagnostics/search") + .queryParam("queryToken", token) + .queryParamIfPresent("appId", appId == null || appId.isBlank() ? java.util.Optional.empty() : java.util.Optional.of(appId)) + .build() + .toUriString(); + return dataMap(exchange(uri)); + } + + public Map deviceLogs(String appId, String userId, int page, int size) { + String uri = UriComponentsBuilder.fromHttpUrl(pushServiceBaseUrl + "/api/push/internal/device-logs") + .queryParam("appId", appId) + .queryParam("userId", userId) + .queryParam("page", page) + .queryParam("size", size) + .build() + .toUriString(); + return dataMap(exchange(uri)); + } + + public Map sendTestOffline(String appId, String userId, String title, String body, String payload) { + String uri = pushServiceBaseUrl + "/api/push/internal/test-offline"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + headers.setContentType(MediaType.APPLICATION_JSON); + Map request = new java.util.LinkedHashMap<>(); + request.put("appId", appId); + request.put("userId", userId); + request.put("title", title); + request.put("body", body); + request.put("payload", payload); + String response = restTemplate.postForObject(uri, new HttpEntity<>(request, headers), String.class); + return dataMap(response); + } + + private String exchange(String uri) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + return restTemplate.exchange(uri, HttpMethod.GET, new HttpEntity<>(headers), String.class).getBody(); + } + + private Map dataMap(String body) { + try { + JsonNode data = objectMapper.readTree(body).path("data"); + if (data.isMissingNode() || data.isNull()) { + return Map.of(); + } + return objectMapper.convertValue(data, new TypeReference<>() {}); + } catch (Exception e) { + return Map.of(); + } + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java index e85dab4..dbd621f 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java @@ -128,7 +128,7 @@ public class SdkAppProvisioningService { feature.setServiceType(FeatureServiceEntity.ServiceType.IM); feature.setEnabled(true); feature.setConfig(""" - {"allowStrangerMessage":false,"allowFriendRequest":true,"friendRequestMode":"REQUIRE_CONFIRM","allowGroupJoinRequest":true,"blacklistSendSuccess":true,"messageRecallMinutes":2,"historyRetentionDays":7,"conversationPullLimit":100,"multiClientConversationDeleteSync":false} + {"allowStrangerMessage":false,"allowFriendRequest":true,"friendRequestMode":"REQUIRE_CONFIRM","allowGroupJoinRequest":true,"blacklistSendSuccess":true,"messageRecallMinutes":2,"historyRetentionDays":7,"conversationPullLimit":100,"allowMultiDeviceLogin":true,"multiClientConversationDeleteSync":false} """.trim()); feature.setCreatedAt(LocalDateTime.now()); return featureServiceRepository.save(feature);