feat(push): 添加推送SDK和消息服务实现
- 实现了 Android 推送 SDK,支持华为、小米、Oppo、Vivo、荣耀、FCM 等厂商推送 - 添加了推送配置管理和设备注册功能 - 实现了推送令牌管理和用户绑定功能 - 添加了消息发送、撤回、编辑等核心消息服务功能 - 实现了单聊和群聊消息历史记录管理 - 添加了消息读取回执和群组消息状态同步 - 实现了消息过滤、黑名单和权限控制 - 添加了离线消息推送和消息预览功能 - 实现了消息 Webhook 回调机制
这个提交包含在:
父节点
249da309a3
当前提交
824f11c7ea
@ -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());
|
||||
}
|
||||
|
||||
@ -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<String, PushProvider> providers;
|
||||
|
||||
public PushDispatcher(DeviceTokenRepository tokenRepository,
|
||||
DeviceLoginLogRepository logRepository,
|
||||
TenantImConfigClient imConfigClient,
|
||||
TenantPushConfigClient pushConfigClient,
|
||||
ObjectMapper objectMapper,
|
||||
List<PushProvider> 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<DeviceTokenEntity> selectedPushTargets(String appId, String userId) {
|
||||
return selectTargets(appId, userId);
|
||||
}
|
||||
|
||||
@ -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<String, Object> 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<String, Object> 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<String, Object> message = Map.of("message", bodyMap);
|
||||
String requestBody = objectMapper.writeValueAsString(message);
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
|
||||
@ -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<String, Object> message = Map.of(
|
||||
"message", Map.of(
|
||||
"token", new String[]{token},
|
||||
"notification", Map.of("title", title, "body", body),
|
||||
"data", payload != null ? payload : "{}"
|
||||
)
|
||||
);
|
||||
Map<String, Object> 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<String, Object> message = Map.of("message", bodyMap);
|
||||
String requestBody = objectMapper.writeValueAsString(message);
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
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")
|
||||
|
||||
@ -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()); }
|
||||
|
||||
@ -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)
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户