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 providers; public PushDispatcher(DeviceTokenRepository tokenRepository, DeviceLoginLogRepository logRepository, TenantImConfigClient imConfigClient, TenantPushConfigClient pushConfigClient, ObjectMapper objectMapper, List 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 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 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 selectedPushTargets(String appKey, String userId) { return selectTargets(appKey, userId); } public void pushToUsers(String appKey, List 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 selectTargets(String appKey, String userId) { List 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); Optional 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 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 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); } }