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 appId, String userId, String title, String body, String payload) { 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, resolveOptions(appId, payload, t.getVendor())); log.info("Push to {}@{} via {} deviceId={}: {}", userId, appId, t.getVendor(), t.getDeviceId(), ok ? "OK" : "FAIL"); } } } private PushSendOptions resolveOptions(String appId, String payload, DeviceTokenEntity.Vendor vendor) { String routeType = routeType(payload); String platform = platformForVendor(vendor, payload); return pushConfigClient.loadServiceConfig(appId, platform, "PUSH") .map(config -> { JsonNode route = config.path("routing").path(routeType); String channelKey = route.path("channel").asText(""); String channelId = effectiveChannelId(config.path("channels"), channelKey); return new PushSendOptions( routeType, channelId, route.path("category").asText(""), route.path("priority").asText("")); }) .orElseGet(() -> new PushSendOptions(routeType, "", "", "")); } 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 String effectiveChannelId(JsonNode channels, String channelKey) { if (channels == null || !channels.isArray() || channelKey == null || channelKey.isBlank()) { return ""; } for (JsonNode channel : channels) { if (channelKey.equals(channel.path("key").asText(""))) { String base = channel.path("channelId").asText(channelKey); int version = Math.max(channel.path("version").asInt(1), 1); return base + "_v" + version; } } return ""; } 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; } for (String userId : userIds) { pushToUser(appId, userId, title, body, payload); } } private List selectTargets(String appId, String userId) { List devices = tokenRepository .findByAppIdAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc(appId, userId); if (devices.isEmpty()) { return List.of(); } String mode = imConfigClient.multiDeviceLoginMode(appId); 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 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(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.setLastLoginAt(now); entity.setUpdatedAt(now); DeviceTokenEntity saved = tokenRepository.save(entity); recordLog(saved, DeviceLoginLogEntity.EventType.REGISTER); } 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, 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); } }