package com.xuqm.update.service; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.update.config.TenantAppSecretClient; import com.xuqm.update.entity.AppGrayMemberEntity; import com.xuqm.update.entity.AppGrayTagEntity; import com.xuqm.update.repository.AppGrayMemberRepository; import com.xuqm.update.repository.AppGrayTagRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.*; import java.util.stream.Collectors; /** * 灰度成员管理服务。 * 负责成员 CRUD、同步、标签管理、成员解析。 */ @Service public class GrayMemberService { private static final Logger log = LoggerFactory.getLogger(GrayMemberService.class); private final AppGrayMemberRepository memberRepo; private final AppGrayTagRepository tagRepo; private final ObjectMapper objectMapper; private final TenantAppSecretClient appSecretClient; private final ImPushUserClient imPushUserClient; private final RestTemplate restTemplate = new RestTemplate(); public GrayMemberService(AppGrayMemberRepository memberRepo, AppGrayTagRepository tagRepo, ObjectMapper objectMapper, TenantAppSecretClient appSecretClient, ImPushUserClient imPushUserClient) { this.memberRepo = memberRepo; this.tagRepo = tagRepo; this.objectMapper = objectMapper; this.appSecretClient = appSecretClient; this.imPushUserClient = imPushUserClient; } // ───────────────────────────────────────────────────────────────────────── // 成员 CRUD // ───────────────────────────────────────────────────────────────────────── /** 列出所有成员(含标签信息) */ public List listMembers(String appKey) { List members = memberRepo.findByAppKeyOrderByUserIdAsc(appKey); List allTags = tagRepo.findByAppKeyOrderByTagNameAscUserIdAsc(appKey); Map> userTags = new HashMap<>(); for (AppGrayTagEntity tag : allTags) { userTags.computeIfAbsent(tag.getUserId(), k -> new LinkedHashSet<>()).add(tag.getTagName()); } return members.stream().map(m -> new GrayMemberView( m.getUserId(), m.getName() != null ? m.getName() : "", m.getSource().name(), m.isActive(), userTags.getOrDefault(m.getUserId(), Set.of()) )).toList(); } /** 手动添加成员 */ @Transactional public void addMembers(String appKey, List userIds, String name) { LocalDateTime now = LocalDateTime.now(); for (String userId : userIds) { userId = userId.trim(); if (userId.isEmpty()) continue; Optional existing = memberRepo.findByAppKeyAndUserId(appKey, userId); if (existing.isPresent()) { AppGrayMemberEntity m = existing.get(); m.setActive(true); if (name != null && !name.isBlank()) m.setName(name.trim()); m.setUpdatedAt(now); memberRepo.save(m); } else { AppGrayMemberEntity m = new AppGrayMemberEntity(); m.setId(UUID.randomUUID().toString()); m.setAppKey(appKey); m.setUserId(userId); m.setName(name != null ? name.trim() : ""); m.setSource(AppGrayMemberEntity.Source.MANUAL); m.setActive(true); m.setUpdatedAt(now); memberRepo.save(m); } } } /** 删除成员(连同标签一起清理) */ @Transactional public void deleteMember(String appKey, String userId) { memberRepo.findByAppKeyAndUserId(appKey, userId).ifPresent(m -> { memberRepo.delete(m); // 清理该用户的所有标签 List tags = tagRepo.findByAppKeyAndTagName(appKey, null); tagRepo.findByAppKeyOrderByTagNameAscUserIdAsc(appKey).stream() .filter(t -> t.getUserId().equals(userId)) .forEach(tagRepo::delete); }); } // ───────────────────────────────────────────────────────────────────────── // 同步(集成方回调 / IM 导入) // ───────────────────────────────────────────────────────────────────────── /** * 同步成员列表。保留已有标签。 * * 流程: * 1. 标记所有 SYNC 成员为 inactive * 2. 遍历新列表:已存在 → active=true;不存在 → 新增 * 3. 删除 SYNC + inactive 的记录(从列表中移除的人) */ @Transactional public SyncResult syncMembers(String appKey, List members) { LocalDateTime now = LocalDateTime.now(); int added = 0, updated = 0, removed = 0; // 1. 标记所有 SYNC 成员为 inactive memberRepo.deactivateAllSynced(appKey); // 2. 遍历新列表 Set seenIds = new HashSet<>(); for (SyncMember sm : members) { String userId = sm.userId().trim(); if (userId.isEmpty() || seenIds.contains(userId)) continue; seenIds.add(userId); Optional existing = memberRepo.findByAppKeyAndUserId(appKey, userId); if (existing.isPresent()) { AppGrayMemberEntity m = existing.get(); m.setActive(true); if (sm.name() != null && !sm.name().isBlank()) m.setName(sm.name()); m.setUpdatedAt(now); memberRepo.save(m); updated++; } else { AppGrayMemberEntity m = new AppGrayMemberEntity(); m.setId(UUID.randomUUID().toString()); m.setAppKey(appKey); m.setUserId(userId); m.setName(sm.name() != null ? sm.name() : ""); m.setSource(AppGrayMemberEntity.Source.SYNC); m.setActive(true); m.setUpdatedAt(now); memberRepo.save(m); added++; } } // 3. 删除 SYNC + inactive List toRemove = memberRepo.findByAppKeyAndSource(appKey, AppGrayMemberEntity.Source.SYNC) .stream().filter(m -> !m.isActive()).toList(); removed = toRemove.size(); memberRepo.deleteAll(toRemove); log.info("Gray sync for {}: added={}, updated={}, removed={}", appKey, added, updated, removed); return new SyncResult(added, updated, removed); } /** * 从 IM 服务导入成员。 * 获取 IM 账号列表并同步为灰度成员(无标签)。 */ public SyncResult importFromIm(String appKey) { List accounts = imPushUserClient.fetchImAccounts(appKey); List members = accounts.stream() .map(a -> new SyncMember(a.userId(), a.nickname())) .toList(); return syncMembers(appKey, members); } // ───────────────────────────────────────────────────────────────────────── // 标签管理 // ───────────────────────────────────────────────────────────────────────── /** 列出所有标签及成员数 */ public List listTags(String appKey) { List tagNames = tagRepo.findDistinctTagNamesByAppKey(appKey); return tagNames.stream().map(name -> { long count = tagRepo.countByAppKeyAndTagName(appKey, name); return new TagView(name, count); }).toList(); } /** 创建标签并添加成员 */ @Transactional public void createTag(String appKey, String tagName, List userIds) { LocalDateTime now = LocalDateTime.now(); for (String userId : userIds) { userId = userId.trim(); if (userId.isEmpty()) continue; // 检查是否已有此标签关系 List existing = tagRepo.findByAppKeyAndTagName(appKey, tagName); boolean alreadyExists = existing.stream().anyMatch(t -> t.getUserId().equals(userId)); if (!alreadyExists) { AppGrayTagEntity tag = new AppGrayTagEntity(); tag.setId(UUID.randomUUID().toString()); tag.setAppKey(appKey); tag.setTagName(tagName.trim()); tag.setUserId(userId); tag.setCreatedAt(now); tagRepo.save(tag); } // 确保成员存在 ensureMemberExists(appKey, userId, now); } } /** 向标签添加成员 */ @Transactional public void addMembersToTag(String appKey, String tagName, List userIds) { createTag(appKey, tagName, userIds); } /** 从标签移除成员 */ @Transactional public void removeMembersFromTag(String appKey, String tagName, List userIds) { tagRepo.deleteByAppKeyAndTagNameAndUserIdIn(appKey, tagName, userIds); } /** 删除标签(不删除成员) */ @Transactional public void deleteTag(String appKey, String tagName) { tagRepo.deleteByAppKeyAndTagName(appKey, tagName); } // ───────────────────────────────────────────────────────────────────────── // 成员解析(发版时调用) // ───────────────────────────────────────────────────────────────────────── /** * 解析灰度成员列表。 * 将标签选择 + 额外成员合并为最终的 userId 列表。 * * @param appKey 应用标识 * @param tagNames 选中的标签名列表 * @param extraIds 额外指定的 userId 列表 * @return 去重后的 userId 列表(JSON 字符串) */ public String resolveMemberIds(String appKey, List tagNames, List extraIds) { Set all = new LinkedHashSet<>(); // 展开标签 if (tagNames != null) { for (String tag : tagNames) { all.addAll(tagRepo.findUserIdsByAppKeyAndTagName(appKey, tag)); } } // 合并额外成员 if (extraIds != null) { extraIds.stream().map(String::trim).filter(s -> !s.isEmpty()).forEach(all::add); } try { return objectMapper.writeValueAsString(new ArrayList<>(all)); } catch (Exception e) { return "[]"; } } // ───────────────────────────────────────────────────────────────────────── // 发布时回调 // ───────────────────────────────────────────────────────────────────────── /** * 发布灰度时回调集成方接口获取成员列表。 * 使用 AppSecret 对请求签名。 * * @return 同步结果(包含新同步的成员),回调失败返回 null */ public SyncResult callPublishCallback(String appKey, String callbackUrl, String platform, String versionName, int versionCode, boolean forceUpdate) { String appSecret = appSecretClient.getAppSecret(appKey); if (appSecret == null) { log.warn("Cannot call publish callback: AppSecret not found for {}", appKey); return null; } try { long timestamp = System.currentTimeMillis() / 1000; String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16); // 构建请求体 Map body = new LinkedHashMap<>(); body.put("appKey", appKey); body.put("platform", platform); body.put("versionName", versionName); body.put("versionCode", versionCode); body.put("forceUpdate", forceUpdate); body.put("timestamp", timestamp); // HMAC-SHA256 签名 String signInput = appKey + "\n" + timestamp + "\n" + nonce; String signature = hmacSha256Hex(appSecret, signInput); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("X-Xuqm-App-Key", appKey); headers.set("X-Xuqm-Timestamp", String.valueOf(timestamp)); headers.set("X-Xuqm-Nonce", nonce); headers.set("X-Xuqm-Signature", signature); HttpEntity> request = new HttpEntity<>(body, headers); ResponseEntity response = restTemplate.exchange( callbackUrl, HttpMethod.POST, request, String.class); if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { log.warn("Publish callback returned non-2xx: {}", response.getStatusCode()); return null; } // 解析响应 return parseCallbackResponse(appKey, response.getBody()); } catch (Exception e) { log.warn("Publish callback failed for {}: {}", appKey, e.getMessage()); return null; } } /** * 解析集成方回调响应,同步成员并可选创建标签。 * 支持格式: * - {"memberIds": ["id1", "id2"]} * - {"groups": [{"tagName": "测试组", "userIds": ["id1"]}]} * - {"members": [{"userId": "id1", "name": "张三"}]} */ private SyncResult parseCallbackResponse(String appKey, String responseBody) { try { JsonNode root = objectMapper.readTree(responseBody); // 收集所有成员 List members = new ArrayList<>(); if (root.has("memberIds") && root.get("memberIds").isArray()) { for (JsonNode node : root.get("memberIds")) { String uid = node.asText(""); if (!uid.isBlank()) members.add(new SyncMember(uid, "")); } } else if (root.has("members") && root.get("members").isArray()) { for (JsonNode node : root.get("members")) { String uid = node.path("userId").asText(""); String name = node.path("name").asText(""); if (!uid.isBlank()) members.add(new SyncMember(uid, name)); } } else if (root.has("groups") && root.get("groups").isArray()) { // 分组格式:同步成员并创建标签 for (JsonNode group : root.get("groups")) { String tagName = group.path("tagName").asText(""); if (group.has("userIds") && group.get("userIds").isArray()) { for (JsonNode uidNode : group.get("userIds")) { String uid = uidNode.asText(""); if (!uid.isBlank()) members.add(new SyncMember(uid, "")); } } } } if (members.isEmpty()) { log.info("Publish callback returned empty member list for {}", appKey); return new SyncResult(0, 0, 0); } // 同步成员 SyncResult result = syncMembers(appKey, members); // 如果有 groups 格式,创建标签 if (root.has("groups") && root.get("groups").isArray()) { LocalDateTime now = LocalDateTime.now(); for (JsonNode group : root.get("groups")) { String tagName = group.path("tagName").asText("").trim(); if (tagName.isBlank() || !group.has("userIds")) continue; List userIds = new ArrayList<>(); for (JsonNode uidNode : group.get("userIds")) { String uid = uidNode.asText(""); if (!uid.isBlank()) userIds.add(uid); } if (!userIds.isEmpty()) { createTag(appKey, tagName, userIds); } } } return result; } catch (Exception e) { log.warn("Failed to parse publish callback response: {}", e.getMessage()); return null; } } private String hmacSha256Hex(String key, String data) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : hash) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (Exception e) { throw new RuntimeException("HMAC-SHA256 failed", e); } } // ───────────────────────────────────────────────────────────────────────── // 内部方法 // ───────────────────────────────────────────────────────────────────────── private void ensureMemberExists(String appKey, String userId, LocalDateTime now) { if (memberRepo.findByAppKeyAndUserId(appKey, userId).isEmpty()) { AppGrayMemberEntity m = new AppGrayMemberEntity(); m.setId(UUID.randomUUID().toString()); m.setAppKey(appKey); m.setUserId(userId); m.setSource(AppGrayMemberEntity.Source.SYNC); m.setActive(true); m.setUpdatedAt(now); memberRepo.save(m); } } // ───────────────────────────────────────────────────────────────────────── // 数据类型 // ───────────────────────────────────────────────────────────────────────── public record SyncMember(String userId, String name) {} public record SyncResult(int added, int updated, int removed) {} public record GrayMemberView( String userId, String name, String source, boolean active, Set tags ) {} public record TagView(String tagName, long memberCount) {} }