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 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 Map providers; public PushDispatcher(DeviceTokenRepository tokenRepository, DeviceLoginLogRepository logRepository, TenantImConfigClient imConfigClient, List providerList) { this.tokenRepository = tokenRepository; this.logRepository = logRepository; this.imConfigClient = imConfigClient; 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); log.info("Push to {}@{} via {} deviceId={}: {}", userId, appId, t.getVendor(), t.getDeviceId(), ok ? "OK" : "FAIL"); } } } 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(); } if (!imConfigClient.allowMultiDeviceLogin(appId)) { return List.of(devices.get(0)); } return devices.stream() .collect(Collectors.toMap( DeviceTokenEntity::getVendor, device -> device, (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); } }