package com.xuqm.tenant.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.xuqm.common.exception.BusinessException; import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity.Status; import com.xuqm.tenant.repository.AppRepository; import com.xuqm.tenant.repository.FeatureServiceRepository; import com.xuqm.tenant.repository.ServiceActivationRequestRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.UUID; @Service public class FeatureServiceManager { private final FeatureServiceRepository repository; private final ServiceActivationRequestRepository requestRepository; private final AppRepository appRepository; private final ObjectMapper objectMapper; public FeatureServiceManager(FeatureServiceRepository repository, ServiceActivationRequestRepository requestRepository, AppRepository appRepository, ObjectMapper objectMapper) { this.repository = repository; this.requestRepository = requestRepository; this.appRepository = appRepository; this.objectMapper = objectMapper; } public List listByApp(String appId) { List services = repository.findByAppId(appId); if (services.isEmpty()) { return services; } List normalized = new ArrayList<>(); for (FeatureServiceEntity.ServiceType serviceType : List.of( FeatureServiceEntity.ServiceType.IM, FeatureServiceEntity.ServiceType.PUSH, FeatureServiceEntity.ServiceType.UPDATE)) { services.stream() .filter(service -> service.getServiceType() == serviceType) .findFirst() .ifPresent(normalized::add); } return normalized.isEmpty() ? services : normalized; } /** * Submit an activation request. Disabling is immediate; enabling requires ops approval. * IM / PUSH / UPDATE are app-wide, so duplicate checks ignore platform. */ @Transactional public ServiceActivationRequestEntity submitActivationRequest( String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType, String applyReason) { if (isAppWideService(serviceType)) { requestRepository.findFirstByAppIdAndServiceTypeOrderByCreatedAtDesc(appId, serviceType) .ifPresent(req -> { if (req.getStatus() == Status.PENDING) { throw new BusinessException(400, "已有待审核的开通申请,请等待运营人员处理"); } }); } ServiceActivationRequestEntity req = new ServiceActivationRequestEntity(); req.setId(UUID.randomUUID().toString()); req.setAppId(appId); req.setPlatform(platform); req.setServiceType(serviceType); req.setStatus(Status.PENDING); req.setApplyReason(applyReason); req.setCreatedAt(LocalDateTime.now()); return requestRepository.save(req); } /** * Disable a service immediately (no approval needed). */ @Transactional public FeatureServiceEntity disable(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { if (isAppWideService(serviceType)) { List services = repository.findByAppIdAndServiceType(appId, serviceType); if (services.isEmpty()) { throw new BusinessException(404, "服务未开通"); } services.forEach(service -> service.setEnabled(false)); repository.saveAll(services); return services.get(0); } FeatureServiceEntity entity = repository .findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) .orElseThrow(() -> new BusinessException(404, "服务未开通")); entity.setEnabled(false); return repository.save(entity); } /** * Called by ops when approving an activation request. */ @Transactional public ServiceActivationRequestEntity approveRequest(String requestId, String reviewNote) { ServiceActivationRequestEntity req = requestRepository.findById(requestId) .orElseThrow(() -> new BusinessException(404, "申请不存在")); if (req.getStatus() != Status.PENDING) { throw new BusinessException(400, "申请已处理"); } req.setStatus(Status.APPROVED); req.setReviewNote(reviewNote); req.setReviewedAt(LocalDateTime.now()); requestRepository.save(req); // Normalize to appKey so SdkConfigController queries are consistent String normalizedAppId = appRepository.findById(req.getAppId()) .map(app -> app.getAppKey()) .orElse(req.getAppId()); if (isAppWideService(req.getServiceType())) { List services = repository.findByAppIdAndServiceType(normalizedAppId, req.getServiceType()); if (services.isEmpty()) { for (FeatureServiceEntity.Platform platform : FeatureServiceEntity.Platform.values()) { FeatureServiceEntity created = new FeatureServiceEntity(); created.setId(UUID.randomUUID().toString()); created.setAppId(normalizedAppId); created.setPlatform(platform); created.setServiceType(req.getServiceType()); created.setEnabled(true); created.setCreatedAt(LocalDateTime.now()); repository.save(created); } } else { services.forEach(service -> service.setEnabled(true)); repository.saveAll(services); } return req; } FeatureServiceEntity entity = repository .findByAppIdAndPlatformAndServiceType(normalizedAppId, req.getPlatform(), req.getServiceType()) .orElseGet(() -> { FeatureServiceEntity e = new FeatureServiceEntity(); e.setId(UUID.randomUUID().toString()); e.setAppId(normalizedAppId); e.setPlatform(req.getPlatform()); e.setServiceType(req.getServiceType()); e.setCreatedAt(LocalDateTime.now()); return e; }); entity.setEnabled(true); repository.save(entity); return req; } /** * Called by ops when rejecting an activation request. */ @Transactional public ServiceActivationRequestEntity rejectRequest(String requestId, String reviewNote) { ServiceActivationRequestEntity req = requestRepository.findById(requestId) .orElseThrow(() -> new BusinessException(404, "申请不存在")); if (req.getStatus() != Status.PENDING) { throw new BusinessException(400, "申请已处理"); } req.setStatus(Status.REJECTED); req.setReviewNote(reviewNote); req.setReviewedAt(LocalDateTime.now()); return requestRepository.save(req); } public List listRequestsByApp(String appId) { return requestRepository.findByAppIdOrderByCreatedAtDesc(appId); } public FeatureServiceEntity getOrFail(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { if (serviceType == FeatureServiceEntity.ServiceType.IM) { return repository.findByAppIdAndServiceType(appId, serviceType) .stream() .findFirst() .orElseThrow(() -> new BusinessException(404, "服务未配置")); } return repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) .orElseThrow(() -> new BusinessException(404, "服务未配置")); } @Transactional public FeatureServiceEntity updateConfig(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType, String config) { if (serviceType == FeatureServiceEntity.ServiceType.IM) { List services = repository.findByAppIdAndServiceType(appId, serviceType); if (services.isEmpty()) { throw new BusinessException(404, "服务未配置"); } services.forEach(service -> service.setConfig(config)); repository.saveAll(services); return services.get(0); } FeatureServiceEntity entity = getOrFail(appId, platform, serviceType); entity.setConfig(config); return repository.save(entity); } public FeatureServiceEntity getByPlatform(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { return getOrFail(appId, platform, serviceType); } public boolean allowStrangerMessage(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { return readConfigNode(appId, platform, serviceType).path("allowStrangerMessage").asBoolean(false); } public boolean allowFriendRequest(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { return readConfigNode(appId, platform, serviceType).path("allowFriendRequest").asBoolean(true); } public boolean allowGroupJoinRequest(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { return readConfigNode(appId, platform, serviceType).path("allowGroupJoinRequest").asBoolean(true); } public String friendRequestMode(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { String mode = readConfigNode(appId, platform, serviceType).path("friendRequestMode").asText(""); return switch (mode == null ? "" : mode.trim().toUpperCase()) { case "DIRECT_ACCEPT", "DISALLOW" -> mode.trim().toUpperCase(); default -> "REQUIRE_CONFIRM"; }; } public boolean blacklistSendSuccess(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { return readConfigNode(appId, platform, serviceType).path("blacklistSendSuccess").asBoolean(true); } public int messageRecallMinutes(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { int minutes = readConfigNode(appId, platform, serviceType).path("messageRecallMinutes").asInt(2); return Math.max(minutes, 0); } public int conversationPullLimit(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { int limit = readConfigNode(appId, platform, serviceType).path("conversationPullLimit").asInt(100); return Math.min(Math.max(limit, 1), 500); } public int historyRetentionDays(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { int days = readConfigNode(appId, platform, serviceType).path("historyRetentionDays").asInt(7); return Math.max(days, 1); } public String buildImConfig(String appId, FeatureServiceEntity.Platform platform, Boolean allowStrangerMessage, Boolean allowFriendRequest, String friendRequestMode, Boolean allowGroupJoinRequest, Boolean blacklistSendSuccess, Integer messageRecallMinutes, Integer historyRetentionDays, Integer conversationPullLimit, Boolean allowMultiDeviceLogin, Boolean multiClientConversationDeleteSync) { ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.IM).deepCopy(); if (!node.has("allowStrangerMessage")) { node.put("allowStrangerMessage", false); } if (!node.has("allowFriendRequest")) { node.put("allowFriendRequest", true); } if (!node.has("friendRequestMode")) { node.put("friendRequestMode", "REQUIRE_CONFIRM"); } if (!node.has("allowGroupJoinRequest")) { node.put("allowGroupJoinRequest", true); } if (!node.has("blacklistSendSuccess")) { node.put("blacklistSendSuccess", true); } if (!node.has("messageRecallMinutes")) { node.put("messageRecallMinutes", 2); } if (!node.has("historyRetentionDays")) { node.put("historyRetentionDays", 7); } if (!node.has("conversationPullLimit")) { node.put("conversationPullLimit", 100); } if (!node.has("multiClientConversationDeleteSync")) { node.put("multiClientConversationDeleteSync", false); } if (!node.has("allowMultiDeviceLogin")) { node.put("allowMultiDeviceLogin", true); } if (allowStrangerMessage != null) { node.put("allowStrangerMessage", allowStrangerMessage); } if (allowFriendRequest != null) { node.put("allowFriendRequest", allowFriendRequest); } String effectiveFriendRequestMode = null; if (friendRequestMode != null && !friendRequestMode.isBlank()) { effectiveFriendRequestMode = normalizeFriendRequestMode(friendRequestMode); } else if (allowFriendRequest != null && !allowFriendRequest) { effectiveFriendRequestMode = "DISALLOW"; } if (effectiveFriendRequestMode != null) { node.put("friendRequestMode", effectiveFriendRequestMode); if ("DISALLOW".equals(effectiveFriendRequestMode)) { node.put("allowFriendRequest", false); } } if (allowGroupJoinRequest != null) { node.put("allowGroupJoinRequest", allowGroupJoinRequest); } if (blacklistSendSuccess != null) { node.put("blacklistSendSuccess", blacklistSendSuccess); } if (messageRecallMinutes != null) { node.put("messageRecallMinutes", Math.max(messageRecallMinutes, 0)); } if (historyRetentionDays != null) { node.put("historyRetentionDays", Math.max(historyRetentionDays, 1)); } if (conversationPullLimit != null) { node.put("conversationPullLimit", Math.min(Math.max(conversationPullLimit, 1), 500)); } if (allowMultiDeviceLogin != null) { node.put("allowMultiDeviceLogin", allowMultiDeviceLogin); } if (multiClientConversationDeleteSync != null) { node.put("multiClientConversationDeleteSync", multiClientConversationDeleteSync); } return node.toString(); } public String buildAllowStrangerConfig(boolean allowStrangerMessage) { ObjectNode node = objectMapper.createObjectNode(); node.put("allowStrangerMessage", allowStrangerMessage); node.put("allowFriendRequest", true); node.put("friendRequestMode", "REQUIRE_CONFIRM"); node.put("allowGroupJoinRequest", true); node.put("blacklistSendSuccess", true); node.put("messageRecallMinutes", 2); node.put("historyRetentionDays", 7); node.put("conversationPullLimit", 100); node.put("allowMultiDeviceLogin", true); node.put("multiClientConversationDeleteSync", false); return node.toString(); } public String buildUpdateConfig(String appId, FeatureServiceEntity.Platform platform, List defaultStoreTargets, String defaultPublishMode, Boolean defaultPublishImmediately, String defaultScheduledPublishAt, Boolean defaultAutoPublishAfterReview, String defaultWebhookUrl, Boolean defaultForceUpdate, Boolean defaultGrayEnabled, Integer defaultGrayPercent, String defaultPackageName, String defaultAppStoreUrl, String defaultMarketUrl) { ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.UPDATE).deepCopy(); if (!node.has("defaultStoreTargets")) { node.putArray("defaultStoreTargets"); } if (!node.has("defaultPublishMode")) { node.put("defaultPublishMode", "MANUAL"); } if (!node.has("defaultPublishImmediately")) { node.put("defaultPublishImmediately", false); } if (!node.has("defaultScheduledPublishAt")) { node.put("defaultScheduledPublishAt", ""); } if (!node.has("defaultAutoPublishAfterReview")) { node.put("defaultAutoPublishAfterReview", false); } if (!node.has("defaultWebhookUrl")) { node.put("defaultWebhookUrl", ""); } if (!node.has("defaultForceUpdate")) { node.put("defaultForceUpdate", false); } if (!node.has("defaultGrayEnabled")) { node.put("defaultGrayEnabled", false); } if (!node.has("defaultGrayPercent")) { node.put("defaultGrayPercent", 0); } if (!node.has("defaultPackageName")) { node.put("defaultPackageName", ""); } if (!node.has("defaultAppStoreUrl")) { node.put("defaultAppStoreUrl", ""); } if (!node.has("defaultMarketUrl")) { node.put("defaultMarketUrl", ""); } if (defaultStoreTargets != null) { node.remove("defaultStoreTargets"); ArrayNode array = node.putArray("defaultStoreTargets"); defaultStoreTargets.stream() .filter(v -> v != null && !v.isBlank()) .map(v -> v.trim().toUpperCase()) .forEach(array::add); } if (defaultPublishMode != null && !defaultPublishMode.isBlank()) { node.put("defaultPublishMode", normalizeReleaseMode(defaultPublishMode)); } if (defaultPublishImmediately != null) { node.put("defaultPublishImmediately", defaultPublishImmediately); } if (defaultScheduledPublishAt != null) { node.put("defaultScheduledPublishAt", defaultScheduledPublishAt.trim()); } if (defaultAutoPublishAfterReview != null) { node.put("defaultAutoPublishAfterReview", defaultAutoPublishAfterReview); } if (defaultWebhookUrl != null) { node.put("defaultWebhookUrl", defaultWebhookUrl.trim()); } if (defaultForceUpdate != null) { node.put("defaultForceUpdate", defaultForceUpdate); } if (defaultGrayEnabled != null) { node.put("defaultGrayEnabled", defaultGrayEnabled); } if (defaultGrayPercent != null) { node.put("defaultGrayPercent", Math.min(Math.max(defaultGrayPercent, 0), 100)); } if (defaultPackageName != null) { node.put("defaultPackageName", defaultPackageName.trim()); } if (defaultAppStoreUrl != null) { node.put("defaultAppStoreUrl", defaultAppStoreUrl.trim()); } if (defaultMarketUrl != null) { node.put("defaultMarketUrl", defaultMarketUrl.trim()); } return node.toString(); } public String buildPushConfig(String appId, FeatureServiceEntity.Platform platform, String huaweiAppId, String huaweiAppSecret, String xiaomiAppId, String xiaomiAppKey, String xiaomiAppSecret, String oppoAppId, String oppoAppKey, String oppoMasterSecret, String vivoAppId, String vivoAppKey, String vivoAppSecret, String honorAppId, String honorClientId, String honorClientSecret, String apnsTeamId, String apnsKeyId, String apnsBundleId, String apnsKeyPath, boolean apnsSandbox, String fcmServiceAccountJson, JsonNode channels, JsonNode routing) { ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.PUSH).deepCopy(); ensureObjectNode(node, "huawei"); ensureObjectNode(node, "xiaomi"); ensureObjectNode(node, "oppo"); ensureObjectNode(node, "vivo"); ensureObjectNode(node, "honor"); ensureObjectNode(node, "apns"); ensureObjectNode(node, "fcm"); putText(node.with("huawei"), "appId", huaweiAppId); putText(node.with("huawei"), "appSecret", huaweiAppSecret); putText(node.with("xiaomi"), "appId", xiaomiAppId); putText(node.with("xiaomi"), "appKey", xiaomiAppKey); putText(node.with("xiaomi"), "appSecret", xiaomiAppSecret); putText(node.with("oppo"), "appId", oppoAppId); putText(node.with("oppo"), "appKey", oppoAppKey); putText(node.with("oppo"), "masterSecret", oppoMasterSecret); putText(node.with("vivo"), "appId", vivoAppId); putText(node.with("vivo"), "appKey", vivoAppKey); putText(node.with("vivo"), "appSecret", vivoAppSecret); putText(node.with("honor"), "appId", honorAppId); putText(node.with("honor"), "clientId", honorClientId); putText(node.with("honor"), "clientSecret", honorClientSecret); putText(node.with("apns"), "teamId", apnsTeamId); putText(node.with("apns"), "keyId", apnsKeyId); putText(node.with("apns"), "bundleId", apnsBundleId); putText(node.with("apns"), "keyPath", apnsKeyPath); 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) .orElseThrow(() -> new BusinessException(404, "服务不存在")); byte[] bytes = new byte[32]; java.security.SecureRandom random = new java.security.SecureRandom(); random.nextBytes(bytes); entity.setSecretKey(java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)); return repository.save(entity); } public List parseStoreTargets(String json) { if (json == null || json.isBlank()) { return List.of(); } try { return objectMapper.readValue(json, new com.fasterxml.jackson.core.type.TypeReference>() {}); } catch (Exception e) { return List.of(); } } private String normalizeFriendRequestMode(String mode) { String normalized = mode == null ? "" : mode.trim().toUpperCase(); return switch (normalized) { case "DIRECT_ACCEPT", "DISALLOW" -> normalized; default -> "REQUIRE_CONFIRM"; }; } private String normalizeReleaseMode(String mode) { String normalized = mode == null ? "" : mode.trim().toUpperCase(); return switch (normalized) { case "NOW", "SCHEDULED", "AUTO_REVIEW" -> normalized; default -> "MANUAL"; }; } private boolean isAppWideService(FeatureServiceEntity.ServiceType serviceType) { return serviceType == FeatureServiceEntity.ServiceType.IM || serviceType == FeatureServiceEntity.ServiceType.PUSH || serviceType == FeatureServiceEntity.ServiceType.UPDATE; } private JsonNode readConfigNode(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { FeatureServiceEntity entity; if (serviceType == FeatureServiceEntity.ServiceType.IM) { entity = repository.findByAppIdAndServiceType(appId, serviceType) .stream() .findFirst() .orElse(null); } else { entity = repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) .orElse(null); } if (entity == null || entity.getConfig() == null || entity.getConfig().isBlank()) { return objectMapper.createObjectNode(); } try { JsonNode node = objectMapper.readTree(entity.getConfig()); return node == null ? objectMapper.createObjectNode() : node; } catch (Exception e) { return objectMapper.createObjectNode(); } } private void ensureObjectNode(ObjectNode root, String field) { if (!root.has(field) || !root.get(field).isObject()) { root.putObject(field); } } private void putText(ObjectNode node, String field, String value) { if (value != null) { node.put(field, value.trim()); } } }