2026-04-21 22:07:29 +08:00
|
|
|
package com.xuqm.push.service;
|
|
|
|
|
|
|
|
|
|
import com.xuqm.push.entity.DeviceTokenEntity;
|
2026-05-05 17:54:59 +08:00
|
|
|
import com.xuqm.push.entity.DeviceLoginLogEntity;
|
|
|
|
|
import com.xuqm.push.repository.DeviceLoginLogRepository;
|
2026-04-21 22:07:29 +08:00
|
|
|
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;
|
2026-05-05 17:54:59 +08:00
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
|
|
import java.security.MessageDigest;
|
|
|
|
|
import java.security.NoSuchAlgorithmException;
|
2026-04-21 22:07:29 +08:00
|
|
|
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;
|
2026-05-05 17:54:59 +08:00
|
|
|
private final DeviceLoginLogRepository logRepository;
|
|
|
|
|
private final TenantImConfigClient imConfigClient;
|
2026-04-21 22:07:29 +08:00
|
|
|
private final Map<String, PushProvider> providers;
|
|
|
|
|
|
2026-05-05 17:54:59 +08:00
|
|
|
public PushDispatcher(DeviceTokenRepository tokenRepository,
|
|
|
|
|
DeviceLoginLogRepository logRepository,
|
|
|
|
|
TenantImConfigClient imConfigClient,
|
|
|
|
|
List<PushProvider> providerList) {
|
2026-04-21 22:07:29 +08:00
|
|
|
this.tokenRepository = tokenRepository;
|
2026-05-05 17:54:59 +08:00
|
|
|
this.logRepository = logRepository;
|
|
|
|
|
this.imConfigClient = imConfigClient;
|
2026-04-21 22:07:29 +08:00
|
|
|
this.providers = providerList.stream()
|
|
|
|
|
.collect(Collectors.toMap(PushProvider::vendorName, p -> p));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void pushToUser(String appId, String userId, String title, String body, String payload) {
|
2026-05-05 17:54:59 +08:00
|
|
|
List<DeviceTokenEntity> targets = selectTargets(appId, userId);
|
|
|
|
|
if (targets.isEmpty()) {
|
|
|
|
|
log.info("Skip push to {}@{}: no receive-enabled device", userId, appId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for (DeviceTokenEntity t : targets) {
|
2026-04-21 22:07:29 +08:00
|
|
|
PushProvider provider = providers.get(t.getVendor().name());
|
|
|
|
|
if (provider != null) {
|
2026-04-30 09:49:05 +08:00
|
|
|
boolean ok = provider.send(appId, t.getToken(), title, body, payload);
|
2026-05-05 17:54:59 +08:00
|
|
|
log.info("Push to {}@{} via {} deviceId={}: {}", userId, appId, t.getVendor(), t.getDeviceId(), ok ? "OK" : "FAIL");
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 17:54:59 +08:00
|
|
|
public List<DeviceTokenEntity> selectedPushTargets(String appId, String userId) {
|
|
|
|
|
return selectTargets(appId, userId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 09:45:20 +08:00
|
|
|
public void pushToUsers(String appId, List<String> userIds, String title, String body, String payload) {
|
|
|
|
|
if (userIds == null || userIds.isEmpty()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for (String userId : userIds) {
|
|
|
|
|
pushToUser(appId, userId, title, body, payload);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 17:54:59 +08:00
|
|
|
private List<DeviceTokenEntity> selectTargets(String appId, String userId) {
|
|
|
|
|
List<DeviceTokenEntity> 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<DeviceTokenEntity> existing = tokenRepository.findByAppIdAndUserIdAndDeviceId(appId, userId, resolvedDeviceId);
|
|
|
|
|
if (existing.isEmpty() && (deviceId == null || deviceId.isBlank())) {
|
|
|
|
|
existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor);
|
|
|
|
|
}
|
|
|
|
|
LocalDateTime now = LocalDateTime.now();
|
2026-04-21 22:07:29 +08:00
|
|
|
DeviceTokenEntity entity = existing.orElseGet(() -> {
|
|
|
|
|
DeviceTokenEntity e = new DeviceTokenEntity();
|
|
|
|
|
e.setId(UUID.randomUUID().toString());
|
|
|
|
|
e.setAppId(appId);
|
|
|
|
|
e.setUserId(userId);
|
|
|
|
|
e.setVendor(vendor);
|
2026-05-05 17:54:59 +08:00
|
|
|
e.setCreatedAt(now);
|
2026-04-21 22:07:29 +08:00
|
|
|
return e;
|
|
|
|
|
});
|
2026-05-05 17:54:59 +08:00
|
|
|
entity.setVendor(vendor);
|
2026-04-21 22:07:29 +08:00
|
|
|
entity.setToken(token);
|
2026-05-05 17:54:59 +08:00
|
|
|
entity.setPlatform(blankToNull(platform));
|
|
|
|
|
entity.setDeviceId(resolvedDeviceId);
|
|
|
|
|
entity.setBrand(blankToNull(brand));
|
|
|
|
|
entity.setModel(blankToNull(model));
|
|
|
|
|
entity.setOsVersion(blankToNull(osVersion));
|
|
|
|
|
entity.setAppVersion(blankToNull(appVersion));
|
2026-04-29 12:33:25 +08:00
|
|
|
entity.setReceivePush(true);
|
2026-05-05 17:54:59 +08:00
|
|
|
entity.setLastLoginAt(now);
|
|
|
|
|
entity.setUpdatedAt(now);
|
|
|
|
|
DeviceTokenEntity saved = tokenRepository.save(entity);
|
|
|
|
|
recordLog(saved, DeviceLoginLogEntity.EventType.REGISTER);
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|
2026-04-29 12:33:25 +08:00
|
|
|
|
2026-05-05 17:54:59 +08:00
|
|
|
public void setReceivePush(String appId, String userId, String deviceId, boolean enabled) {
|
|
|
|
|
List<DeviceTokenEntity> tokens = deviceId == null || deviceId.isBlank()
|
|
|
|
|
? tokenRepository.findByAppIdAndUserId(appId, userId)
|
|
|
|
|
: tokenRepository.findByAppIdAndUserIdAndDeviceId(appId, userId, deviceId).stream().toList();
|
2026-04-29 12:33:25 +08:00
|
|
|
for (DeviceTokenEntity token : tokens) {
|
|
|
|
|
token.setReceivePush(enabled);
|
|
|
|
|
token.setUpdatedAt(LocalDateTime.now());
|
2026-05-05 17:54:59 +08:00
|
|
|
recordLog(token, DeviceLoginLogEntity.EventType.RECEIVE_PUSH_UPDATE);
|
2026-04-29 12:33:25 +08:00
|
|
|
}
|
|
|
|
|
tokenRepository.saveAll(tokens);
|
|
|
|
|
}
|
2026-05-02 22:57:55 +08:00
|
|
|
|
2026-05-05 17:54:59 +08:00
|
|
|
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));
|
2026-05-02 22:57:55 +08:00
|
|
|
tokenRepository.deleteByAppIdAndUserIdAndVendor(appId, userId, vendor);
|
|
|
|
|
}
|
2026-05-05 17:54:59 +08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-04-21 22:07:29 +08:00
|
|
|
}
|