2026-06-11 12:25:16 +08:00
|
|
|
|
package com.xuqm.update.service;
|
|
|
|
|
|
|
|
|
|
|
|
import com.fasterxml.jackson.core.type.TypeReference;
|
2026-06-17 12:21:54 +08:00
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
2026-06-11 12:25:16 +08:00
|
|
|
|
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();
|
2026-06-17 12:21:54 +08:00
|
|
|
|
for (String rawUserId : userIds) {
|
|
|
|
|
|
final String userId = rawUserId.trim();
|
2026-06-11 12:25:16 +08:00
|
|
|
|
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) {}
|
|
|
|
|
|
}
|