XuqmGroup-Server/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java
XuqmGroup 7194126a4c fix(push): 修复设备换绑驱逐时 NonUniqueResultException
findByAppKeyAndDeviceId / findByAppKeyAndToken 改为返回 List,
避免 DB 中存在多条同 appKey+deviceId 记录时 Hibernate 抛出
NonUniqueResultException(HTTP 500)。
驱逐逻辑改为 stream + forEach,将所有旧用户记录一并清除。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:49:39 +08:00

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