XuqmGroup-Server/update-service/src/main/java/com/xuqm/update/service/GrayMemberService.java

460 行
20 KiB
Java

package com.xuqm.update.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
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<GrayMemberView> listMembers(String appKey) {
List<AppGrayMemberEntity> members = memberRepo.findByAppKeyOrderByUserIdAsc(appKey);
List<AppGrayTagEntity> allTags = tagRepo.findByAppKeyOrderByTagNameAscUserIdAsc(appKey);
Map<String, Set<String>> 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<String> userIds, String name) {
LocalDateTime now = LocalDateTime.now();
for (String userId : userIds) {
userId = userId.trim();
if (userId.isEmpty()) continue;
Optional<AppGrayMemberEntity> 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<AppGrayTagEntity> 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<SyncMember> members) {
LocalDateTime now = LocalDateTime.now();
int added = 0, updated = 0, removed = 0;
// 1. 标记所有 SYNC 成员为 inactive
memberRepo.deactivateAllSynced(appKey);
// 2. 遍历新列表
Set<String> seenIds = new HashSet<>();
for (SyncMember sm : members) {
String userId = sm.userId().trim();
if (userId.isEmpty() || seenIds.contains(userId)) continue;
seenIds.add(userId);
Optional<AppGrayMemberEntity> 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<AppGrayMemberEntity> 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<ImPushUserClient.ImAccount> accounts = imPushUserClient.fetchImAccounts(appKey);
List<SyncMember> members = accounts.stream()
.map(a -> new SyncMember(a.userId(), a.nickname()))
.toList();
return syncMembers(appKey, members);
}
// ─────────────────────────────────────────────────────────────────────────
// 标签管理
// ─────────────────────────────────────────────────────────────────────────
/** 列出所有标签及成员数 */
public List<TagView> listTags(String appKey) {
List<String> 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<String> userIds) {
LocalDateTime now = LocalDateTime.now();
for (String rawUserId : userIds) {
final String userId = rawUserId.trim();
if (userId.isEmpty()) continue;
// 检查是否已有此标签关系
List<AppGrayTagEntity> 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<String> userIds) {
createTag(appKey, tagName, userIds);
}
/** 从标签移除成员 */
@Transactional
public void removeMembersFromTag(String appKey, String tagName, List<String> 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<String> tagNames, List<String> extraIds) {
Set<String> 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<String, Object> 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<Map<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<String> 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<SyncMember> 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<String> 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<String> tags
) {}
public record TagView(String tagName, long memberCount) {}
}