findByAppKeyAndDeviceId / findByAppKeyAndToken 改为返回 List, 避免 DB 中存在多条同 appKey+deviceId 记录时 Hibernate 抛出 NonUniqueResultException(HTTP 500)。 驱逐逻辑改为 stream + forEach,将所有旧用户记录一并清除。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
393 行
17 KiB
Java
393 行
17 KiB
Java
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 com.xuqm.push.service.provider.PushSendOptions;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
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;
|
|
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;
|
|
private final Map<String, PushProvider> providers;
|
|
|
|
public PushDispatcher(DeviceTokenRepository tokenRepository,
|
|
DeviceLoginLogRepository logRepository,
|
|
TenantImConfigClient imConfigClient,
|
|
TenantPushConfigClient pushConfigClient,
|
|
ObjectMapper objectMapper,
|
|
List<PushProvider> providerList) {
|
|
this.tokenRepository = tokenRepository;
|
|
this.logRepository = logRepository;
|
|
this.imConfigClient = imConfigClient;
|
|
this.pushConfigClient = pushConfigClient;
|
|
this.objectMapper = objectMapper;
|
|
this.providers = providerList.stream()
|
|
.collect(Collectors.toMap(PushProvider::vendorName, p -> p));
|
|
}
|
|
|
|
public void pushToUser(String appKey, String userId, String title, String body, String payload) {
|
|
List<DeviceTokenEntity> targets = selectTargets(appKey, userId);
|
|
if (targets.isEmpty()) {
|
|
log.info("Skip push to {}@{}: no receive-enabled device", userId, appKey);
|
|
return;
|
|
}
|
|
for (DeviceTokenEntity t : targets) {
|
|
PushProvider provider = providers.get(t.getVendor().name());
|
|
if (provider != null) {
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
|
|
private PushSendOptions resolveOptions(String appKey, String payload, DeviceTokenEntity.Vendor vendor) {
|
|
String routeType = routeType(payload);
|
|
String platform = platformForVendor(vendor, payload);
|
|
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";
|
|
};
|
|
}
|
|
|
|
public List<DeviceTokenEntity> selectedPushTargets(String appKey, String userId) {
|
|
return selectTargets(appKey, userId);
|
|
}
|
|
|
|
public void pushToUsers(String appKey, List<String> userIds, String title, String body, String payload) {
|
|
if (userIds == null || userIds.isEmpty()) {
|
|
return;
|
|
}
|
|
for (String userId : userIds) {
|
|
pushToUser(appKey, userId, title, body, payload);
|
|
}
|
|
}
|
|
|
|
private List<DeviceTokenEntity> selectTargets(String appKey, String userId) {
|
|
List<DeviceTokenEntity> devices = tokenRepository
|
|
.findByAppKeyAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc(appKey, userId);
|
|
if (devices.isEmpty()) {
|
|
return List.of();
|
|
}
|
|
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();
|
|
};
|
|
}
|
|
|
|
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);
|
|
|
|
// Evict all other users who hold this device (by deviceId) or this token
|
|
tokenRepository.findByAppKeyAndDeviceId(appKey, resolvedDeviceId).stream()
|
|
.filter(prev -> !userId.equals(prev.getUserId()))
|
|
.forEach(prev -> {
|
|
log.info("Device {} re-registered by user={}, evicting previous user={}", resolvedDeviceId, userId, prev.getUserId());
|
|
recordLog(prev, DeviceLoginLogEntity.EventType.UNREGISTER);
|
|
tokenRepository.delete(prev);
|
|
});
|
|
tokenRepository.findByAppKeyAndToken(appKey, token).stream()
|
|
.filter(prev -> !userId.equals(prev.getUserId()))
|
|
.forEach(prev -> {
|
|
log.info("Token re-registered by user={}, evicting previous user={} deviceId={}", userId, prev.getUserId(), prev.getDeviceId());
|
|
recordLog(prev, DeviceLoginLogEntity.EventType.UNREGISTER);
|
|
tokenRepository.delete(prev);
|
|
});
|
|
|
|
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();
|
|
DeviceTokenEntity entity = existing.orElseGet(() -> {
|
|
DeviceTokenEntity e = new DeviceTokenEntity();
|
|
e.setId(UUID.randomUUID().toString());
|
|
e.setAppKey(appKey);
|
|
e.setUserId(userId);
|
|
e.setVendor(vendor);
|
|
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.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);
|
|
}
|
|
|
|
public boolean pushToSpecificDevice(String appKey, String userId, String deviceEntityId, String title, String body, String payload) {
|
|
Optional<DeviceTokenEntity> device = tokenRepository.findById(deviceEntityId)
|
|
.filter(e -> appKey.equals(e.getAppKey()) && userId.equals(e.getUserId()));
|
|
if (device.isEmpty()) {
|
|
log.warn("pushToSpecificDevice: device not found id={} userId={} appKey={}", deviceEntityId, userId, appKey);
|
|
return false;
|
|
}
|
|
DeviceTokenEntity t = device.get();
|
|
PushProvider provider = providers.get(t.getVendor().name());
|
|
if (provider == null) {
|
|
log.warn("pushToSpecificDevice: no provider for vendor={}", t.getVendor());
|
|
return false;
|
|
}
|
|
boolean ok = provider.send(appKey, t.getToken(), title, body, payload, resolveOptions(appKey, payload, t.getVendor()));
|
|
log.info("Test push to {}@{} via {} deviceId={}: {}", userId, appKey, t.getVendor(), t.getDeviceId(), ok ? "OK" : "FAIL");
|
|
return ok;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|