From 824f11c7eaaa557130867e545d5166352242a4e7 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 5 May 2026 22:16:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(push):=20=E6=B7=BB=E5=8A=A0=E6=8E=A8?= =?UTF-8?q?=E9=80=81SDK=E5=92=8C=E6=B6=88=E6=81=AF=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了 Android 推送 SDK,支持华为、小米、Oppo、Vivo、荣耀、FCM 等厂商推送 - 添加了推送配置管理和设备注册功能 - 实现了推送令牌管理和用户绑定功能 - 添加了消息发送、撤回、编辑等核心消息服务功能 - 实现了单聊和群聊消息历史记录管理 - 添加了消息读取回执和群组消息状态同步 - 实现了消息过滤、黑名单和权限控制 - 添加了离线消息推送和消息预览功能 - 实现了消息 Webhook 回调机制 --- .../com/xuqm/im/service/MessageService.java | 1 + .../com/xuqm/push/service/PushDispatcher.java | 58 ++++++++++++++++++- .../service/provider/FcmPushProvider.java | 21 ++++--- .../service/provider/HuaweiPushProvider.java | 21 ++++--- .../push/service/provider/PushProvider.java | 4 ++ .../service/provider/PushSendOptions.java | 12 ++++ .../service/provider/XiaomiPushProvider.java | 8 +++ .../controller/FeatureServiceController.java | 9 ++- .../tenant/service/FeatureServiceManager.java | 51 +++++++++++++++- 9 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 push-service/src/main/java/com/xuqm/push/service/provider/PushSendOptions.java diff --git a/im-service/src/main/java/com/xuqm/im/service/MessageService.java b/im-service/src/main/java/com/xuqm/im/service/MessageService.java index a9cd0b5..e39d883 100644 --- a/im-service/src/main/java/com/xuqm/im/service/MessageService.java +++ b/im-service/src/main/java/com/xuqm/im/service/MessageService.java @@ -525,6 +525,7 @@ public class MessageService { payload.put("toId", message.getToId()); payload.put("chatType", message.getChatType().name()); payload.put("msgType", message.getMsgType().name()); + payload.put("pushRoute", message.getMsgType() == ImMessageEntity.MsgType.NOTIFY ? "SYSTEM_NOTICE" : "IM_MESSAGE"); if (message.getEditedAt() != null) { payload.put("editedAt", message.getEditedAt().toInstant(ZoneOffset.UTC).toEpochMilli()); } diff --git a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java index 202593e..e1b8647 100644 --- a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java +++ b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java @@ -5,6 +5,9 @@ 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; @@ -27,15 +30,21 @@ public class PushDispatcher { 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)); } @@ -49,12 +58,59 @@ public class PushDispatcher { for (DeviceTokenEntity t : targets) { PushProvider provider = providers.get(t.getVendor().name()); 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"); } } } + 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 selectedPushTargets(String appId, String userId) { return selectTargets(appId, userId); } diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/FcmPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/FcmPushProvider.java index 5accf55..36dda81 100644 --- a/push-service/src/main/java/com/xuqm/push/service/provider/FcmPushProvider.java +++ b/push-service/src/main/java/com/xuqm/push/service/provider/FcmPushProvider.java @@ -20,6 +20,7 @@ import java.security.PrivateKey; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; import java.util.Date; +import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -56,6 +57,11 @@ public class FcmPushProvider implements PushProvider { @Override 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 serviceAccountJson = resolveConfig(appId, "serviceAccountJson", envServiceAccountJson); if (projectId.isBlank() || serviceAccountJson.isBlank()) { @@ -65,13 +71,14 @@ public class FcmPushProvider implements PushProvider { try { String accessToken = getAccessToken(projectId, serviceAccountJson); String url = pushUrl.replace("{projectId}", projectId); - Map message = Map.of( - "message", Map.of( - "token", token, - "notification", Map.of("title", title, "body", body), - "data", payload != null ? Map.of("payload", payload) : Map.of() - ) - ); + Map bodyMap = new LinkedHashMap<>(); + bodyMap.put("token", token); + bodyMap.put("notification", Map.of("title", title, "body", body)); + bodyMap.put("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 message = Map.of("message", bodyMap); String requestBody = objectMapper.writeValueAsString(message); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java index 1aefb40..1508b8b 100644 --- a/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java +++ b/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java @@ -12,6 +12,7 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.LinkedHashMap; import java.util.Map; @Component @@ -46,6 +47,11 @@ public class HuaweiPushProvider implements PushProvider { @Override 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 resolvedAppSecret = resolveConfig(appId, "appSecret", envAppSecret); if (resolvedAppId.isBlank() || resolvedAppSecret.isBlank()) { @@ -55,13 +61,14 @@ public class HuaweiPushProvider implements PushProvider { try { String accessToken = getAccessToken(resolvedAppId, resolvedAppSecret); String url = pushUrl.replace("{appId}", resolvedAppId); - Map message = Map.of( - "message", Map.of( - "token", new String[]{token}, - "notification", Map.of("title", title, "body", body), - "data", payload != null ? payload : "{}" - ) - ); + Map bodyMap = new LinkedHashMap<>(); + bodyMap.put("token", new String[]{token}); + bodyMap.put("notification", Map.of("title", title, "body", body)); + bodyMap.put("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 message = Map.of("message", bodyMap); String requestBody = objectMapper.writeValueAsString(message); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java index c8c6096..be0d2e6 100644 --- a/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java +++ b/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java @@ -3,4 +3,8 @@ package com.xuqm.push.service.provider; public interface PushProvider { String vendorName(); 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); + } } diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/PushSendOptions.java b/push-service/src/main/java/com/xuqm/push/service/provider/PushSendOptions.java new file mode 100644 index 0000000..ac9af4d --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/provider/PushSendOptions.java @@ -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("", "", "", ""); + } +} diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java index f4bdcf5..0f11508 100644 --- a/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java +++ b/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java @@ -37,6 +37,11 @@ public class XiaomiPushProvider implements PushProvider { @Override 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); if (appSecret.isBlank()) { appSecret = resolveConfig(appId, "appKey", envAppSecret); @@ -51,6 +56,9 @@ public class XiaomiPushProvider implements PushProvider { + "&description=" + URLEncoder.encode(body, StandardCharsets.UTF_8) + "&restricted_package_name=com.example.app" + "¬ify_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() .uri(URI.create(pushUrl)) .header("Content-Type", "application/x-www-form-urlencoded") diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index 8e7bb15..b243dc7 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -1,5 +1,6 @@ package com.xuqm.tenant.controller; +import com.fasterxml.jackson.databind.JsonNode; import com.xuqm.common.model.ApiResponse; import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity; @@ -130,7 +131,9 @@ public class FeatureServiceController { req == null ? null : req.apnsBundleIdValue(), req == null ? null : req.apnsKeyPathValue(), 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( appId, platform, serviceType, config); @@ -231,7 +234,9 @@ public class FeatureServiceController { PushVendorConfig vivo, PushVendorConfig honor, PushVendorConfig apns, - PushVendorConfig fcm + PushVendorConfig fcm, + JsonNode channels, + JsonNode routing ) { public String huaweiAppIdValue() { return firstText(huaweiAppId, huawei == null ? null : huawei.appId()); } public String huaweiAppSecretValue() { return firstText(huaweiAppSecret, huawei == null ? null : huawei.appSecret()); } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index 8c3709b..41d2e22 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -496,7 +496,9 @@ public class FeatureServiceManager { String apnsBundleId, String apnsKeyPath, boolean apnsSandbox, - String fcmServiceAccountJson) { + String fcmServiceAccountJson, + JsonNode channels, + JsonNode routing) { ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.PUSH).deepCopy(); ensureObjectNode(node, "huawei"); ensureObjectNode(node, "xiaomi"); @@ -532,9 +534,56 @@ public class FeatureServiceManager { node.with("apns").put("sandbox", apnsSandbox); 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(); } + 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 public FeatureServiceEntity regenerateSecretKey(String serviceId) { FeatureServiceEntity entity = repository.findById(serviceId)