feat(push): 添加推送SDK和消息服务实现

- 实现了 Android 推送 SDK,支持华为、小米、Oppo、Vivo、荣耀、FCM 等厂商推送
- 添加了推送配置管理和设备注册功能
- 实现了推送令牌管理和用户绑定功能
- 添加了消息发送、撤回、编辑等核心消息服务功能
- 实现了单聊和群聊消息历史记录管理
- 添加了消息读取回执和群组消息状态同步
- 实现了消息过滤、黑名单和权限控制
- 添加了离线消息推送和消息预览功能
- 实现了消息 Webhook 回调机制
这个提交包含在:
XuqmGroup 2026-05-05 22:16:11 +08:00
父节点 249da309a3
当前提交 824f11c7ea
共有 9 个文件被更改,包括 167 次插入18 次删除

查看文件

@ -525,6 +525,7 @@ public class MessageService {
payload.put("toId", message.getToId()); payload.put("toId", message.getToId());
payload.put("chatType", message.getChatType().name()); payload.put("chatType", message.getChatType().name());
payload.put("msgType", message.getMsgType().name()); payload.put("msgType", message.getMsgType().name());
payload.put("pushRoute", message.getMsgType() == ImMessageEntity.MsgType.NOTIFY ? "SYSTEM_NOTICE" : "IM_MESSAGE");
if (message.getEditedAt() != null) { if (message.getEditedAt() != null) {
payload.put("editedAt", message.getEditedAt().toInstant(ZoneOffset.UTC).toEpochMilli()); payload.put("editedAt", message.getEditedAt().toInstant(ZoneOffset.UTC).toEpochMilli());
} }

查看文件

@ -5,6 +5,9 @@ import com.xuqm.push.entity.DeviceLoginLogEntity;
import com.xuqm.push.repository.DeviceLoginLogRepository; import com.xuqm.push.repository.DeviceLoginLogRepository;
import com.xuqm.push.repository.DeviceTokenRepository; import com.xuqm.push.repository.DeviceTokenRepository;
import com.xuqm.push.service.provider.PushProvider; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -27,15 +30,21 @@ public class PushDispatcher {
private final DeviceTokenRepository tokenRepository; private final DeviceTokenRepository tokenRepository;
private final DeviceLoginLogRepository logRepository; private final DeviceLoginLogRepository logRepository;
private final TenantImConfigClient imConfigClient; private final TenantImConfigClient imConfigClient;
private final TenantPushConfigClient pushConfigClient;
private final ObjectMapper objectMapper;
private final Map<String, PushProvider> providers; private final Map<String, PushProvider> providers;
public PushDispatcher(DeviceTokenRepository tokenRepository, public PushDispatcher(DeviceTokenRepository tokenRepository,
DeviceLoginLogRepository logRepository, DeviceLoginLogRepository logRepository,
TenantImConfigClient imConfigClient, TenantImConfigClient imConfigClient,
TenantPushConfigClient pushConfigClient,
ObjectMapper objectMapper,
List<PushProvider> providerList) { List<PushProvider> providerList) {
this.tokenRepository = tokenRepository; this.tokenRepository = tokenRepository;
this.logRepository = logRepository; this.logRepository = logRepository;
this.imConfigClient = imConfigClient; this.imConfigClient = imConfigClient;
this.pushConfigClient = pushConfigClient;
this.objectMapper = objectMapper;
this.providers = providerList.stream() this.providers = providerList.stream()
.collect(Collectors.toMap(PushProvider::vendorName, p -> p)); .collect(Collectors.toMap(PushProvider::vendorName, p -> p));
} }
@ -49,12 +58,59 @@ public class PushDispatcher {
for (DeviceTokenEntity t : targets) { for (DeviceTokenEntity t : targets) {
PushProvider provider = providers.get(t.getVendor().name()); PushProvider provider = providers.get(t.getVendor().name());
if (provider != null) { if (provider != null) {
boolean ok = provider.send(appId, t.getToken(), title, body, payload); boolean ok = provider.send(appId, t.getToken(), title, body, payload, resolveOptions(appId, payload));
log.info("Push to {}@{} via {} deviceId={}: {}", userId, appId, t.getVendor(), t.getDeviceId(), ok ? "OK" : "FAIL"); log.info("Push to {}@{} via {} deviceId={}: {}", userId, appId, t.getVendor(), t.getDeviceId(), ok ? "OK" : "FAIL");
} }
} }
} }
private PushSendOptions resolveOptions(String appId, String payload) {
String routeType = routeType(payload);
return pushConfigClient.loadServiceConfig(appId, "ANDROID", "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 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<DeviceTokenEntity> selectedPushTargets(String appId, String userId) { public List<DeviceTokenEntity> selectedPushTargets(String appId, String userId) {
return selectTargets(appId, userId); return selectTargets(appId, userId);
} }

查看文件

@ -20,6 +20,7 @@ import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@ -56,6 +57,11 @@ public class FcmPushProvider implements PushProvider {
@Override @Override
public boolean send(String appId, String token, String title, String body, String payload) { public boolean send(String appId, String token, String title, String body, String payload) {
return send(appId, token, title, body, payload, PushSendOptions.empty());
}
@Override
public boolean send(String appId, String token, String title, String body, String payload, PushSendOptions options) {
String projectId = resolveConfig(appId, "projectId", envProjectId); String projectId = resolveConfig(appId, "projectId", envProjectId);
String serviceAccountJson = resolveConfig(appId, "serviceAccountJson", envServiceAccountJson); String serviceAccountJson = resolveConfig(appId, "serviceAccountJson", envServiceAccountJson);
if (projectId.isBlank() || serviceAccountJson.isBlank()) { if (projectId.isBlank() || serviceAccountJson.isBlank()) {
@ -65,13 +71,14 @@ public class FcmPushProvider implements PushProvider {
try { try {
String accessToken = getAccessToken(projectId, serviceAccountJson); String accessToken = getAccessToken(projectId, serviceAccountJson);
String url = pushUrl.replace("{projectId}", projectId); String url = pushUrl.replace("{projectId}", projectId);
Map<String, Object> message = Map.of( Map<String, Object> bodyMap = new LinkedHashMap<>();
"message", Map.of( bodyMap.put("token", token);
"token", token, bodyMap.put("notification", Map.of("title", title, "body", body));
"notification", Map.of("title", title, "body", body), bodyMap.put("data", payload != null ? Map.of("payload", payload) : Map.of());
"data", payload != null ? Map.of("payload", payload) : Map.of() if (options != null && options.channelId() != null && !options.channelId().isBlank()) {
) bodyMap.put("android", Map.of("notification", Map.of("channel_id", options.channelId())));
); }
Map<String, Object> message = Map.of("message", bodyMap);
String requestBody = objectMapper.writeValueAsString(message); String requestBody = objectMapper.writeValueAsString(message);
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url)) .uri(URI.create(url))

查看文件

@ -12,6 +12,7 @@ import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@Component @Component
@ -46,6 +47,11 @@ public class HuaweiPushProvider implements PushProvider {
@Override @Override
public boolean send(String appId, String token, String title, String body, String payload) { public boolean send(String appId, String token, String title, String body, String payload) {
return send(appId, token, title, body, payload, PushSendOptions.empty());
}
@Override
public boolean send(String appId, String token, String title, String body, String payload, PushSendOptions options) {
String resolvedAppId = resolveConfig(appId, "appId", envAppId); String resolvedAppId = resolveConfig(appId, "appId", envAppId);
String resolvedAppSecret = resolveConfig(appId, "appSecret", envAppSecret); String resolvedAppSecret = resolveConfig(appId, "appSecret", envAppSecret);
if (resolvedAppId.isBlank() || resolvedAppSecret.isBlank()) { if (resolvedAppId.isBlank() || resolvedAppSecret.isBlank()) {
@ -55,13 +61,14 @@ public class HuaweiPushProvider implements PushProvider {
try { try {
String accessToken = getAccessToken(resolvedAppId, resolvedAppSecret); String accessToken = getAccessToken(resolvedAppId, resolvedAppSecret);
String url = pushUrl.replace("{appId}", resolvedAppId); String url = pushUrl.replace("{appId}", resolvedAppId);
Map<String, Object> message = Map.of( Map<String, Object> bodyMap = new LinkedHashMap<>();
"message", Map.of( bodyMap.put("token", new String[]{token});
"token", new String[]{token}, bodyMap.put("notification", Map.of("title", title, "body", body));
"notification", Map.of("title", title, "body", body), bodyMap.put("data", payload != null ? payload : "{}");
"data", payload != null ? payload : "{}" if (options != null && options.channelId() != null && !options.channelId().isBlank()) {
) bodyMap.put("android", Map.of("notification", Map.of("channel_id", options.channelId())));
); }
Map<String, Object> message = Map.of("message", bodyMap);
String requestBody = objectMapper.writeValueAsString(message); String requestBody = objectMapper.writeValueAsString(message);
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url)) .uri(URI.create(url))

查看文件

@ -3,4 +3,8 @@ package com.xuqm.push.service.provider;
public interface PushProvider { public interface PushProvider {
String vendorName(); String vendorName();
boolean send(String appId, String token, String title, String body, String payload); boolean send(String appId, String token, String title, String body, String payload);
default boolean send(String appId, String token, String title, String body, String payload, PushSendOptions options) {
return send(appId, token, title, body, payload);
}
} }

查看文件

@ -0,0 +1,12 @@
package com.xuqm.push.service.provider;
public record PushSendOptions(
String routeType,
String channelId,
String category,
String priority
) {
public static PushSendOptions empty() {
return new PushSendOptions("", "", "", "");
}
}

查看文件

@ -37,6 +37,11 @@ public class XiaomiPushProvider implements PushProvider {
@Override @Override
public boolean send(String appId, String token, String title, String body, String payload) { public boolean send(String appId, String token, String title, String body, String payload) {
return send(appId, token, title, body, payload, PushSendOptions.empty());
}
@Override
public boolean send(String appId, String token, String title, String body, String payload, PushSendOptions options) {
String appSecret = resolveConfig(appId, "appSecret", envAppSecret); String appSecret = resolveConfig(appId, "appSecret", envAppSecret);
if (appSecret.isBlank()) { if (appSecret.isBlank()) {
appSecret = resolveConfig(appId, "appKey", envAppSecret); appSecret = resolveConfig(appId, "appKey", envAppSecret);
@ -51,6 +56,9 @@ public class XiaomiPushProvider implements PushProvider {
+ "&description=" + URLEncoder.encode(body, StandardCharsets.UTF_8) + "&description=" + URLEncoder.encode(body, StandardCharsets.UTF_8)
+ "&restricted_package_name=com.example.app" + "&restricted_package_name=com.example.app"
+ "&notify_type=1"; + "&notify_type=1";
if (options != null && options.channelId() != null && !options.channelId().isBlank()) {
form += "&channel_id=" + URLEncoder.encode(options.channelId(), StandardCharsets.UTF_8);
}
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(pushUrl)) .uri(URI.create(pushUrl))
.header("Content-Type", "application/x-www-form-urlencoded") .header("Content-Type", "application/x-www-form-urlencoded")

查看文件

@ -1,5 +1,6 @@
package com.xuqm.tenant.controller; package com.xuqm.tenant.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.xuqm.common.model.ApiResponse; import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
@ -130,7 +131,9 @@ public class FeatureServiceController {
req == null ? null : req.apnsBundleIdValue(), req == null ? null : req.apnsBundleIdValue(),
req == null ? null : req.apnsKeyPathValue(), req == null ? null : req.apnsKeyPathValue(),
req != null && req.apnsSandboxValue(), req != null && req.apnsSandboxValue(),
req == null ? null : req.fcmServiceAccountJsonValue()); req == null ? null : req.fcmServiceAccountJsonValue(),
req == null ? null : req.channels(),
req == null ? null : req.routing());
}; };
FeatureServiceEntity saved = featureServiceManager.updateConfig( FeatureServiceEntity saved = featureServiceManager.updateConfig(
appId, platform, serviceType, config); appId, platform, serviceType, config);
@ -231,7 +234,9 @@ public class FeatureServiceController {
PushVendorConfig vivo, PushVendorConfig vivo,
PushVendorConfig honor, PushVendorConfig honor,
PushVendorConfig apns, PushVendorConfig apns,
PushVendorConfig fcm PushVendorConfig fcm,
JsonNode channels,
JsonNode routing
) { ) {
public String huaweiAppIdValue() { return firstText(huaweiAppId, huawei == null ? null : huawei.appId()); } public String huaweiAppIdValue() { return firstText(huaweiAppId, huawei == null ? null : huawei.appId()); }
public String huaweiAppSecretValue() { return firstText(huaweiAppSecret, huawei == null ? null : huawei.appSecret()); } public String huaweiAppSecretValue() { return firstText(huaweiAppSecret, huawei == null ? null : huawei.appSecret()); }

查看文件

@ -496,7 +496,9 @@ public class FeatureServiceManager {
String apnsBundleId, String apnsBundleId,
String apnsKeyPath, String apnsKeyPath,
boolean apnsSandbox, boolean apnsSandbox,
String fcmServiceAccountJson) { String fcmServiceAccountJson,
JsonNode channels,
JsonNode routing) {
ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.PUSH).deepCopy(); ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.PUSH).deepCopy();
ensureObjectNode(node, "huawei"); ensureObjectNode(node, "huawei");
ensureObjectNode(node, "xiaomi"); ensureObjectNode(node, "xiaomi");
@ -532,9 +534,56 @@ public class FeatureServiceManager {
node.with("apns").put("sandbox", apnsSandbox); node.with("apns").put("sandbox", apnsSandbox);
putText(node.with("fcm"), "serviceAccountJson", fcmServiceAccountJson); putText(node.with("fcm"), "serviceAccountJson", fcmServiceAccountJson);
if (channels != null && channels.isArray()) {
node.set("channels", channels);
} else if (!node.has("channels")) {
node.set("channels", defaultPushChannels());
}
if (routing != null && routing.isObject()) {
node.set("routing", routing);
} else if (!node.has("routing")) {
node.set("routing", defaultPushRouting());
}
return node.toString(); return node.toString();
} }
private ArrayNode defaultPushChannels() {
ArrayNode channels = objectMapper.createArrayNode();
channels.add(defaultPushChannel("im_message", "xuqm_im_message", "聊天消息", "单聊、群聊和好友消息", "HIGH"));
channels.add(defaultPushChannel("system_notice", "xuqm_system_notice", "系统通知", "系统通知和业务提醒", "DEFAULT"));
return channels;
}
private ObjectNode defaultPushChannel(String key, String channelId, String name, String description, String importance) {
ObjectNode channel = objectMapper.createObjectNode();
channel.put("key", key);
channel.put("channelId", channelId);
channel.put("version", 1);
channel.put("name", name);
channel.put("description", description);
channel.put("importance", importance);
channel.put("sound", true);
channel.put("vibration", true);
channel.put("badge", true);
return channel;
}
private ObjectNode defaultPushRouting() {
ObjectNode routing = objectMapper.createObjectNode();
routing.set("IM_MESSAGE", defaultPushRoute("im_message", "MESSAGE", "HIGH"));
routing.set("FRIEND_REQUEST", defaultPushRoute("im_message", "SOCIAL", "HIGH"));
routing.set("SYSTEM_NOTICE", defaultPushRoute("system_notice", "SYSTEM", "DEFAULT"));
return routing;
}
private ObjectNode defaultPushRoute(String channel, String category, String priority) {
ObjectNode route = objectMapper.createObjectNode();
route.put("channel", channel);
route.put("category", category);
route.put("priority", priority);
return route;
}
@Transactional @Transactional
public FeatureServiceEntity regenerateSecretKey(String serviceId) { public FeatureServiceEntity regenerateSecretKey(String serviceId) {
FeatureServiceEntity entity = repository.findById(serviceId) FeatureServiceEntity entity = repository.findById(serviceId)