XuqmGroup-Server/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java

358 行
15 KiB
Java

2026-04-21 22:07:29 +08:00
package com.xuqm.push.service;
import com.xuqm.push.entity.DeviceTokenEntity;
import com.xuqm.push.entity.DeviceLoginLogEntity;
import com.xuqm.push.repository.DeviceLoginLogRepository;
2026-04-21 22:07:29 +08:00
import com.xuqm.push.repository.DeviceTokenRepository;
import com.xuqm.push.service.provider.PushProvider;
import com.xuqm.push.service.provider.PushSendOptions;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
2026-04-21 22:07:29 +08:00
import org.slf4j.Logger;
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;
2026-04-21 22:07:29 +08:00
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
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 TenantPushConfigClient pushConfigClient;
private final ObjectMapper objectMapper;
2026-04-21 22:07:29 +08:00
private final Map<String, PushProvider> providers;
public PushDispatcher(DeviceTokenRepository tokenRepository,
DeviceLoginLogRepository logRepository,
TenantImConfigClient imConfigClient,
TenantPushConfigClient pushConfigClient,
ObjectMapper objectMapper,
List<PushProvider> providerList) {
2026-04-21 22:07:29 +08:00
this.tokenRepository = tokenRepository;
this.logRepository = logRepository;
this.imConfigClient = imConfigClient;
this.pushConfigClient = pushConfigClient;
this.objectMapper = objectMapper;
2026-04-21 22:07:29 +08:00
this.providers = providerList.stream()
.collect(Collectors.toMap(PushProvider::vendorName, p -> p));
}
2026-05-07 19:39:42 +08:00
public void pushToUser(String appKey, String userId, String title, String body, String payload) {
List<DeviceTokenEntity> targets = selectTargets(appKey, userId);
if (targets.isEmpty()) {
2026-05-07 19:39:42 +08:00
log.info("Skip push to {}@{}: no receive-enabled device", userId, appKey);
return;
}
for (DeviceTokenEntity t : targets) {
2026-04-21 22:07:29 +08:00
PushProvider provider = providers.get(t.getVendor().name());
if (provider != null) {
2026-05-07 19:39:42 +08:00
boolean ok = provider.send(appKey, t.getToken(), title, body, payload, resolveOptions(appKey, payload, t.getVendor()));
log.info("Push to {}@{} via {} deviceId={}: {}", userId, appKey, t.getVendor(), t.getDeviceId(), ok ? "OK" : "FAIL");
2026-04-21 22:07:29 +08:00
}
}
}
2026-05-07 19:39:42 +08:00
private PushSendOptions resolveOptions(String appKey, String payload, DeviceTokenEntity.Vendor vendor) {
String routeType = routeType(payload);
String platform = platformForVendor(vendor, payload);
2026-05-07 19:39:42 +08:00
return pushConfigClient.loadServiceConfig(appKey, platform, "PUSH")
.map(config -> profileFor(config, vendor.name(), routeType)
.map(profile -> new PushSendOptions(
profile.path("key").asText(""),
routeType,
profile.path("channelId").asText(""),
profile.path("category").asText(""),
profile.path("threadIdentifier").asText(""),
profile.path("interruptionLevel").asText(""),
profile.path("importance").asText(""),
readBoolean(profile, "badge"),
readBoolean(profile, "sound"),
readBoolean(profile, "vibration"),
readInteger(profile, "notifyType"),
mapPriority(profile.path("importance").asText(""))))
.orElseGet(() -> new PushSendOptions("", routeType, "", "", "", "", "", null, null, null, null, "")))
.orElseGet(() -> new PushSendOptions("", routeType, "", "", "", "", "", null, null, null, null, ""));
}
private String platformForVendor(DeviceTokenEntity.Vendor vendor, String payload) {
if (payload == null || payload.isBlank()) {
return defaultPlatformForVendor(vendor);
}
try {
JsonNode node = objectMapper.readTree(payload);
String platform = node.path("platform").asText("");
if (!platform.isBlank()) {
return platform.toUpperCase();
}
} catch (Exception ignored) {
}
return defaultPlatformForVendor(vendor);
}
private String defaultPlatformForVendor(DeviceTokenEntity.Vendor vendor) {
return switch (vendor) {
case APNS -> "IOS";
case HARMONY -> "HARMONY";
default -> "ANDROID";
};
}
private String routeType(String payload) {
if (payload == null || payload.isBlank()) {
return "IM_MESSAGE";
}
try {
JsonNode node = objectMapper.readTree(payload);
String explicit = node.path("pushRoute").asText("");
if (!explicit.isBlank()) {
return explicit;
}
String msgType = node.path("msgType").asText("");
return "NOTIFY".equalsIgnoreCase(msgType) ? "SYSTEM_NOTICE" : "IM_MESSAGE";
} catch (Exception ignored) {
return "IM_MESSAGE";
}
}
private java.util.Optional<JsonNode> profileFor(JsonNode config, String vendor, String routeType) {
JsonNode profiles = config.path("profiles");
if (profiles == null || !profiles.isArray()) {
return java.util.Optional.empty();
}
JsonNode fallback = null;
for (JsonNode profile : profiles) {
if (!profile.path("enabled").asBoolean(true)) {
continue;
}
if (!vendor.equalsIgnoreCase(profile.path("vendor").asText(""))) {
continue;
}
String profileRouteType = profile.path("routeType").asText("");
if (profileRouteType.isBlank() || "DEFAULT".equalsIgnoreCase(profileRouteType)) {
if (fallback == null) {
fallback = profile;
}
continue;
}
if (!routeType.equalsIgnoreCase(profileRouteType)) {
continue;
}
return java.util.Optional.of(profile);
}
return java.util.Optional.ofNullable(fallback);
}
private Boolean readBoolean(JsonNode node, String key) {
if (node == null || node.isNull()) {
return null;
}
JsonNode value = node.get(key);
if (value == null || value.isNull()) {
return null;
}
return value.asBoolean();
}
private Integer readInteger(JsonNode node, String key) {
if (node == null || node.isNull()) {
return null;
}
JsonNode value = node.get(key);
if (value == null || value.isNull()) {
return null;
}
if (value.isInt() || value.isLong() || value.isNumber()) {
return value.asInt();
}
String text = value.asText("");
if (text.isBlank()) {
return null;
}
try {
return Integer.parseInt(text.trim());
} catch (NumberFormatException e) {
return null;
}
}
private String mapPriority(String importance) {
return switch (importance == null ? "" : importance.trim().toUpperCase()) {
case "HIGH", "MAX" -> "HIGH";
case "LOW", "MIN" -> "LOW";
default -> "DEFAULT";
};
}
2026-05-07 19:39:42 +08:00
public List<DeviceTokenEntity> selectedPushTargets(String appKey, String userId) {
return selectTargets(appKey, userId);
}
2026-05-07 19:39:42 +08:00
public void pushToUsers(String appKey, List<String> userIds, String title, String body, String payload) {
if (userIds == null || userIds.isEmpty()) {
return;
}
for (String userId : userIds) {
2026-05-07 19:39:42 +08:00
pushToUser(appKey, userId, title, body, payload);
}
}
2026-05-07 19:39:42 +08:00
private List<DeviceTokenEntity> selectTargets(String appKey, String userId) {
List<DeviceTokenEntity> devices = tokenRepository
.findByAppKeyAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc(appKey, userId);
if (devices.isEmpty()) {
return List.of();
}
2026-05-07 19:39:42 +08:00
String mode = imConfigClient.multiDeviceLoginMode(appKey);
return switch (mode) {
case "SINGLE_DEVICE" -> List.of(devices.get(0));
case "SAME_PLATFORM_ONE" -> devices.stream()
.collect(Collectors.toMap(
d -> d.getPlatform() != null ? d.getPlatform() : d.getVendor().name(),
d -> d,
(first, ignored) -> first,
java.util.LinkedHashMap::new))
.values().stream().toList();
default -> devices.stream()
.collect(Collectors.toMap(
DeviceTokenEntity::getVendor,
d -> d,
(first, ignored) -> first,
java.util.LinkedHashMap::new))
.values().stream().toList();
};
}
2026-05-07 19:39:42 +08:00
public void registerToken(String appKey,
String userId,
DeviceTokenEntity.Vendor vendor,
String token,
String platform,
String deviceId,
String brand,
String model,
String osVersion,
String appVersion,
String romVersion) {
String resolvedDeviceId = normalizeDeviceId(deviceId, vendor, token);
Optional<DeviceTokenEntity> existing = tokenRepository.findByAppKeyAndUserIdAndDeviceId(appKey, userId, resolvedDeviceId);
if (existing.isEmpty() && (deviceId == null || deviceId.isBlank())) {
existing = tokenRepository.findByAppKeyAndUserIdAndVendor(appKey, userId, vendor);
}
LocalDateTime now = LocalDateTime.now();
2026-04-21 22:07:29 +08:00
DeviceTokenEntity entity = existing.orElseGet(() -> {
DeviceTokenEntity e = new DeviceTokenEntity();
e.setId(UUID.randomUUID().toString());
e.setAppKey(appKey);
2026-04-21 22:07:29 +08:00
e.setUserId(userId);
e.setVendor(vendor);
e.setCreatedAt(now);
2026-04-21 22:07:29 +08:00
return e;
});
entity.setVendor(vendor);
2026-04-21 22:07:29 +08:00
entity.setToken(token);
entity.setPlatform(blankToNull(platform));
entity.setDeviceId(resolvedDeviceId);
entity.setBrand(blankToNull(brand));
entity.setModel(blankToNull(model));
entity.setOsVersion(blankToNull(osVersion));
entity.setRomVersion(blankToNull(romVersion));
entity.setAppVersion(blankToNull(appVersion));
entity.setReceivePush(true);
entity.setLastLoginAt(now);
entity.setUpdatedAt(now);
DeviceTokenEntity saved = tokenRepository.save(entity);
recordLog(saved, DeviceLoginLogEntity.EventType.REGISTER);
2026-04-21 22:07:29 +08:00
}
2026-05-07 19:39:42 +08:00
public void setReceivePush(String appKey, String userId, String deviceId, boolean enabled) {
List<DeviceTokenEntity> tokens = deviceId == null || deviceId.isBlank()
? tokenRepository.findByAppKeyAndUserId(appKey, userId)
: tokenRepository.findByAppKeyAndUserIdAndDeviceId(appKey, 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);
}
2026-05-07 19:39:42 +08:00
public void unregisterToken(String appKey, String userId, DeviceTokenEntity.Vendor vendor, String deviceId) {
if (deviceId != null && !deviceId.isBlank()) {
tokenRepository.findByAppKeyAndUserIdAndDeviceId(appKey, userId, deviceId)
.ifPresent(entity -> recordLog(entity, DeviceLoginLogEntity.EventType.UNREGISTER));
tokenRepository.deleteByAppKeyAndUserIdAndDeviceId(appKey, userId, deviceId);
return;
}
tokenRepository.findByAppKeyAndUserIdAndVendor(appKey, userId, vendor)
.ifPresent(entity -> recordLog(entity, DeviceLoginLogEntity.EventType.UNREGISTER));
tokenRepository.deleteByAppKeyAndUserIdAndVendor(appKey, userId, vendor);
}
private void recordLog(DeviceTokenEntity token, DeviceLoginLogEntity.EventType eventType) {
DeviceLoginLogEntity entity = new DeviceLoginLogEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppKey(token.getAppKey());
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);
}
2026-04-21 22:07:29 +08:00
}