feat(android-sdk): 添加完整的IM客户端SDK实现

- 实现了Android SDK的完整IM功能接口,包括消息、群组、好友、会话等核心功能
- 添加了消息收发、历史记录、撤回编辑等完整的消息操作能力
- 实现了群组管理功能,包括创建、成员管理、权限设置等操作
- 添加了好友关系链管理,支持添加、删除、分组等操作
- 实现了会话管理功能,包括置顶、免打扰、已读状态等
- 添加了黑名单、资料管理、搜索等辅助功能
- 补齐了批量操作接口,提升客户端操作效率
- 实现了WebSocket连接管理和事件监听机制
- 添加了离线消息同步和状态管理功能
这个提交包含在:
XuqmGroup 2026-05-02 22:57:55 +08:00
父节点 d27607d14e
当前提交 d2dea0c332
共有 37 个文件被更改,包括 1461 次插入31 次删除

查看文件

@ -1010,6 +1010,16 @@ public final class XuqmImServerSdk {
return response.data();
}
public void leaveGroup(String groupId) {
request(
"DELETE",
buildUri("/api/im/groups/" + encode(groupId) + "/members/me", appQuery()),
null,
authorizedHeaders(),
new TypeReference<ApiResponse<Void>>() {}
);
}
public GroupView updateGroupAttributes(String groupId, Map<String, Object> attributes) {
ApiResponse<GroupView> response = request(
"PUT",
@ -1199,11 +1209,18 @@ public final class XuqmImServerSdk {
return response.data();
}
public GroupView modifyGroupMemberInfo(String groupId, String userId, String nickName) {
public GroupView modifyGroupMemberInfo(String groupId, String userId, String nickName, String role) {
Map<String, String> body = new LinkedHashMap<>();
if (nickName != null) {
body.put("nickName", nickName);
}
if (role != null) {
body.put("role", role);
}
ApiResponse<GroupView> response = request(
"PUT",
buildUri("/api/im/groups/" + encode(groupId) + "/members/" + encode(userId) + "/info", appQuery()),
Map.of("nickName", nickName),
body,
authorizedHeaders(),
new TypeReference<>() {}
);

查看文件

@ -1,6 +1,7 @@
package com.xuqm.im.config;
import com.xuqm.common.security.JwtUtil;
import com.xuqm.im.service.UserPresenceService;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
@ -23,9 +24,11 @@ import java.util.List;
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final JwtUtil jwtUtil;
private final UserPresenceService userPresenceService;
public WebSocketConfig(JwtUtil jwtUtil) {
public WebSocketConfig(JwtUtil jwtUtil, UserPresenceService userPresenceService) {
this.jwtUtil = jwtUtil;
this.userPresenceService = userPresenceService;
}
@Override
@ -51,7 +54,10 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(
message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
if (accessor == null) {
return message;
}
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
@ -61,8 +67,14 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
new UsernamePasswordAuthenticationToken(userId, null,
List.of(new SimpleGrantedAuthority("ROLE_USER")));
accessor.setUser(auth);
userPresenceService.markOnline(userId);
}
}
} else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
Object user = accessor.getUser();
if (user instanceof UsernamePasswordAuthenticationToken auth) {
userPresenceService.markOffline(auth.getName());
}
}
return message;
}

查看文件

@ -10,10 +10,14 @@ import com.xuqm.im.entity.ImFriendRequestEntity;
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.KeywordFilterEntity;
import com.xuqm.im.entity.WebhookAlertEntity;
import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.entity.WebhookDeliveryEntity;
import com.xuqm.im.repository.ImAccountRepository;
import com.xuqm.im.repository.ImGroupRepository;
import com.xuqm.im.repository.ImMessageRepository;
import com.xuqm.im.repository.WebhookAlertRepository;
import com.xuqm.im.repository.WebhookDeliveryRepository;
import com.xuqm.im.service.ImAccountService;
import com.xuqm.im.service.BlacklistService;
import com.xuqm.im.service.FriendRequestService;
@ -22,13 +26,13 @@ import com.xuqm.im.service.GlobalMuteService;
import com.xuqm.im.service.KeywordFilterService;
import com.xuqm.im.service.MessageService;
import com.xuqm.im.service.OperationLogService;
import com.xuqm.im.service.UserPresenceService;
import com.xuqm.im.service.WebhookConfigService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@ -53,12 +57,14 @@ public class ImAdminController {
private final ImGroupService groupService;
private final MessageService messageService;
private final WebhookConfigService webhookConfigService;
private final WebhookDeliveryRepository webhookDeliveryRepository;
private final WebhookAlertRepository webhookAlertRepository;
private final KeywordFilterService keywordFilterService;
private final GlobalMuteService globalMuteService;
private final OperationLogService operationLogService;
private final SimpUserRegistry simpUserRegistry;
private final SimpMessagingTemplate messagingTemplate;
private final StringRedisTemplate redisTemplate;
private final UserPresenceService userPresenceService;
public ImAdminController(ImAccountRepository accountRepository,
ImGroupRepository groupRepository,
@ -69,12 +75,14 @@ public class ImAdminController {
ImGroupService groupService,
MessageService messageService,
WebhookConfigService webhookConfigService,
WebhookDeliveryRepository webhookDeliveryRepository,
WebhookAlertRepository webhookAlertRepository,
KeywordFilterService keywordFilterService,
GlobalMuteService globalMuteService,
OperationLogService operationLogService,
SimpUserRegistry simpUserRegistry,
SimpMessagingTemplate messagingTemplate,
StringRedisTemplate redisTemplate) {
StringRedisTemplate redisTemplate,
UserPresenceService userPresenceService) {
this.accountRepository = accountRepository;
this.groupRepository = groupRepository;
this.messageRepository = messageRepository;
@ -84,12 +92,14 @@ public class ImAdminController {
this.groupService = groupService;
this.messageService = messageService;
this.webhookConfigService = webhookConfigService;
this.webhookDeliveryRepository = webhookDeliveryRepository;
this.webhookAlertRepository = webhookAlertRepository;
this.keywordFilterService = keywordFilterService;
this.globalMuteService = globalMuteService;
this.operationLogService = operationLogService;
this.simpUserRegistry = simpUserRegistry;
this.messagingTemplate = messagingTemplate;
this.redisTemplate = redisTemplate;
this.userPresenceService = userPresenceService;
}
/** List all registered IM users for the given appId. */
@ -531,6 +541,74 @@ public class ImAdminController {
return ResponseEntity.ok(ApiResponse.ok());
}
@GetMapping("/webhook-deliveries")
public ResponseEntity<ApiResponse<Page<WebhookDeliveryEntity>>> listWebhookDeliveries(
@RequestParam String appId,
@RequestParam(required = false) String callbackEvent,
@RequestParam(required = false) Boolean success,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<WebhookDeliveryEntity> result;
PageRequest pageable = PageRequest.of(page, size);
if (callbackEvent != null && !callbackEvent.isBlank()) {
result = webhookDeliveryRepository.findByAppIdAndCallbackEvent(appId, callbackEvent, pageable);
} else if (success != null) {
result = webhookDeliveryRepository.findByAppIdAndSuccess(appId, success, pageable);
} else {
result = webhookDeliveryRepository.findByAppId(appId, pageable);
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@GetMapping("/webhook-alerts")
public ResponseEntity<ApiResponse<Page<WebhookAlertEntity>>> listWebhookAlerts(
@RequestParam String appId,
@RequestParam(required = false) Boolean acknowledged,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<WebhookAlertEntity> result;
PageRequest pageable = PageRequest.of(page, size);
if (acknowledged != null) {
result = webhookAlertRepository.findByAppIdAndAcknowledged(appId, acknowledged, pageable);
} else {
result = webhookAlertRepository.findByAppId(appId, pageable);
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping("/webhook-alerts/{id}/acknowledge")
public ResponseEntity<ApiResponse<WebhookAlertEntity>> acknowledgeWebhookAlert(
@RequestParam String appId,
@PathVariable String id,
@AuthenticationPrincipal String operatorId) {
WebhookAlertEntity alert = webhookAlertRepository.findById(id)
.orElseThrow(() -> new BusinessException(404, "告警不存在"));
if (!alert.getAppId().equals(appId)) {
throw new BusinessException(403, "无权操作");
}
alert.setAcknowledged(true);
alert.setAcknowledgedAt(LocalDateTime.now());
WebhookAlertEntity saved = webhookAlertRepository.save(alert);
operationLogService.record(appId, operatorId, "ACK_WEBHOOK_ALERT", "WEBHOOK_ALERT", id, null);
return ResponseEntity.ok(ApiResponse.success(saved));
}
@GetMapping("/webhooks/{id}/health")
public ResponseEntity<ApiResponse<Map<String, Object>>> getWebhookHealth(
@RequestParam String appId,
@PathVariable String id) {
WebhookConfigEntity webhook = webhookConfigService.get(appId, id);
Map<String, Object> health = new LinkedHashMap<>();
health.put("webhookId", webhook.getId());
health.put("url", webhook.getUrl());
health.put("enabled", webhook.isEnabled());
health.put("consecutiveFailures", webhook.getConsecutiveFailures());
health.put("lastFailureAt", webhook.getLastFailureAt());
long unacknowledgedAlerts = webhookAlertRepository.countUnacknowledgedByAppId(appId);
health.put("unacknowledgedAlerts", unacknowledgedAlerts);
return ResponseEntity.ok(ApiResponse.success(health));
}
@GetMapping("/keyword-filters")
public ResponseEntity<ApiResponse<List<KeywordFilterEntity>>> listKeywordFilters(@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(keywordFilterService.list(appId)));
@ -593,13 +671,16 @@ public class ImAdminController {
@GetMapping("/users/state")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Boolean>>> queryUserState(
public ResponseEntity<ApiResponse<Map<String, Object>>> queryUserState(
@RequestParam String userIds) {
Map<String, Boolean> result = new LinkedHashMap<>();
Map<String, Object> result = new LinkedHashMap<>();
for (String userId : userIds.split(",")) {
String trimmed = userId.trim();
if (!trimmed.isBlank()) {
result.put(trimmed, simpUserRegistry.getUser(trimmed) != null);
Map<String, Object> state = new LinkedHashMap<>();
state.put("online", userPresenceService.isOnline(trimmed));
state.put("lastSeenAt", userPresenceService.lastSeenAt(trimmed));
result.put(trimmed, state);
}
}
return ResponseEntity.ok(ApiResponse.success(result));

查看文件

@ -5,6 +5,7 @@ import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.model.EditMessageRequest;
import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.service.MessageService;
import com.xuqm.im.service.OfflineMessageSyncService;
import jakarta.validation.Valid;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
@ -17,17 +18,27 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/im/messages")
public class MessageController {
private final MessageService messageService;
private final com.xuqm.im.repository.ImMessageRepository messageRepository;
private final OfflineMessageSyncService offlineMessageSyncService;
public MessageController(MessageService messageService) {
public MessageController(MessageService messageService,
com.xuqm.im.repository.ImMessageRepository messageRepository,
OfflineMessageSyncService offlineMessageSyncService) {
this.messageService = messageService;
this.messageRepository = messageRepository;
this.offlineMessageSyncService = offlineMessageSyncService;
}
@PostMapping("/send")
@ -84,4 +95,35 @@ public class MessageController {
return ResponseEntity.ok(ApiResponse.success(
messageService.groupHistory(appId, groupId, userId, msgType, keyword, startTime, endTime, page, size)));
}
@GetMapping("/search")
public ResponseEntity<ApiResponse<Page<ImMessageEntity>>> search(
@AuthenticationPrincipal String userId,
@RequestParam String appId,
@RequestParam(required = false) ImMessageEntity.ChatType chatType,
@RequestParam(required = false) ImMessageEntity.MsgType msgType,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(
messageRepository.searchByKeywordForUser(
appId, userId, chatType, msgType, keyword, startTime, endTime, PageRequest.of(page, size))));
}
@GetMapping("/offline/count")
public ResponseEntity<ApiResponse<Map<String, Object>>> offlineMessageCount(
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
long count = offlineMessageSyncService.countUndelivered(appId, userId);
return ResponseEntity.ok(ApiResponse.success(Map.of("count", count)));
}
@PostMapping("/offline")
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> syncOfflineMessages(
@AuthenticationPrincipal String userId,
@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(offlineMessageSyncService.syncAndReturn(appId, userId)));
}
}

查看文件

@ -0,0 +1,48 @@
package com.xuqm.im.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "im_offline_message")
public class ImOfflineMessageEntity {
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 64)
private String userId;
@Column(nullable = false, length = 64)
private String messageId;
@Column(nullable = false)
private boolean delivered;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public boolean isDelivered() { return delivered; }
public void setDelivered(boolean delivered) { this.delivered = delivered; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,70 @@
package com.xuqm.im.entity;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "im_webhook_alert")
public class WebhookAlertEntity {
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 64)
private String webhookId;
@Column(nullable = false, length = 512)
private String webhookUrl;
@Column(nullable = false, length = 32)
private String alertType;
@Column(length = 512)
private String description;
@Column(nullable = false)
private boolean acknowledged;
@Column(nullable = false)
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime createdAt;
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime acknowledgedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getWebhookId() { return webhookId; }
public void setWebhookId(String webhookId) { this.webhookId = webhookId; }
public String getWebhookUrl() { return webhookUrl; }
public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; }
public String getAlertType() { return alertType; }
public void setAlertType(String alertType) { this.alertType = alertType; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isAcknowledged() { return acknowledged; }
public void setAcknowledged(boolean acknowledged) { this.acknowledged = acknowledged; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getAcknowledgedAt() { return acknowledgedAt; }
public void setAcknowledgedAt(LocalDateTime acknowledgedAt) { this.acknowledgedAt = acknowledgedAt; }
}

查看文件

@ -31,6 +31,12 @@ public class WebhookConfigEntity {
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime createdAt;
@Column(nullable = false)
private int consecutiveFailures;
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
private LocalDateTime lastFailureAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
@ -49,4 +55,11 @@ public class WebhookConfigEntity {
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public int getConsecutiveFailures() { return consecutiveFailures; }
public void setConsecutiveFailures(int consecutiveFailures) { this.consecutiveFailures = consecutiveFailures; }
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
public LocalDateTime getLastFailureAt() { return lastFailureAt; }
public void setLastFailureAt(LocalDateTime lastFailureAt) { this.lastFailureAt = lastFailureAt; }
}

查看文件

@ -160,6 +160,41 @@ public interface ImMessageRepository extends JpaRepository<ImMessageEntity, Stri
@Param("endTime") LocalDateTime endTime,
Pageable pageable);
@Query("""
select m from ImMessageEntity m
where m.appId = :appId
and (:chatType is null or m.chatType = :chatType)
and (:msgType is null or m.msgType = :msgType)
and (:keyword is null or :keyword = '' or
lower(m.content) like lower(concat('%', :keyword, '%')) or
lower(coalesce(m.mentionedUserIds, '')) like lower(concat('%', :keyword, '%')) or
lower(m.fromUserId) like lower(concat('%', :keyword, '%')) or
lower(m.toId) like lower(concat('%', :keyword, '%')))
and (:startTime is null or m.createdAt >= :startTime)
and (:endTime is null or m.createdAt <= :endTime)
and (
m.fromUserId = :userId
or m.toId = :userId
or (m.chatType = com.xuqm.im.entity.ImMessageEntity$ChatType.GROUP
and m.toId in (
select g.id from ImGroupEntity g
where g.appId = :appId
and function('JSON_CONTAINS', g.memberIds, function('JSON_QUOTE', :userId)) = 1
)
)
)
order by m.createdAt desc
""")
Page<ImMessageEntity> searchByKeywordForUser(
@Param("appId") String appId,
@Param("userId") String userId,
@Param("chatType") ImMessageEntity.ChatType chatType,
@Param("msgType") ImMessageEntity.MsgType msgType,
@Param("keyword") String keyword,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
Pageable pageable);
@Query("""
select count(m) from ImMessageEntity m
where m.appId = :appId

查看文件

@ -0,0 +1,24 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.ImOfflineMessageEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ImOfflineMessageRepository extends JpaRepository<ImOfflineMessageEntity, String> {
List<ImOfflineMessageEntity> findByAppIdAndUserIdAndDeliveredFalse(String appId, String userId);
@Modifying
@Query("UPDATE ImOfflineMessageEntity o SET o.delivered = true WHERE o.id IN ?1")
void markDeliveredByIds(List<String> ids);
@Query("SELECT COUNT(o) FROM ImOfflineMessageEntity o WHERE o.appId = ?1 AND o.userId = ?2 AND o.delivered = false")
long countUndeliveredByAppIdAndUserId(String appId, String userId);
void deleteByAppIdAndUserIdAndDeliveredTrue(String appId, String userId);
}

查看文件

@ -0,0 +1,17 @@
package com.xuqm.im.repository;
import com.xuqm.im.entity.WebhookAlertEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface WebhookAlertRepository extends JpaRepository<WebhookAlertEntity, String> {
Page<WebhookAlertEntity> findByAppId(String appId, Pageable pageable);
Page<WebhookAlertEntity> findByAppIdAndAcknowledged(String appId, boolean acknowledged, Pageable pageable);
@Query("SELECT COUNT(a) FROM WebhookAlertEntity a WHERE a.appId = ?1 AND a.acknowledged = false")
long countUnacknowledgedByAppId(String appId);
}

查看文件

@ -14,6 +14,8 @@ public interface WebhookDeliveryRepository extends JpaRepository<WebhookDelivery
Page<WebhookDeliveryEntity> findByAppId(String appId, Pageable pageable);
Page<WebhookDeliveryEntity> findByAppIdAndSuccess(String appId, boolean success, Pageable pageable);
List<WebhookDeliveryEntity> findByCallbackId(String callbackId);
@Query("SELECT COUNT(d) FROM WebhookDeliveryEntity d WHERE d.appId = ?1 AND d.success = true AND d.createdAt >= ?2")

查看文件

@ -7,7 +7,6 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
@ -19,7 +18,7 @@ public class ImPushBridge {
private static final Logger log = LoggerFactory.getLogger(ImPushBridge.class);
private final SimpUserRegistry userRegistry;
private final UserPresenceService userPresenceService;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@ -29,8 +28,8 @@ public class ImPushBridge {
@Value("${im.internal-token:xuqm-internal-token}")
private String internalToken;
public ImPushBridge(SimpUserRegistry userRegistry, ObjectMapper objectMapper) {
this.userRegistry = userRegistry;
public ImPushBridge(UserPresenceService userPresenceService, ObjectMapper objectMapper) {
this.userPresenceService = userPresenceService;
this.restTemplate = new RestTemplate();
this.objectMapper = objectMapper;
}
@ -72,6 +71,6 @@ public class ImPushBridge {
}
private boolean isOnline(String userId) {
return userRegistry.getUser(userId) != null;
return userPresenceService.isOnline(userId);
}
}

查看文件

@ -42,6 +42,8 @@ public class MessageService {
private final ImFeatureConfigClient featureConfigClient;
private final ImFriendRepository friendRepository;
private final WebhookDispatchService webhookDispatchService;
private final OfflineMessageSyncService offlineMessageSyncService;
private final UserPresenceService userPresenceService;
private final ObjectMapper objectMapper;
public MessageService(ImMessageRepository messageRepository,
@ -55,6 +57,8 @@ public class MessageService {
ImFeatureConfigClient featureConfigClient,
ImFriendRepository friendRepository,
WebhookDispatchService webhookDispatchService,
OfflineMessageSyncService offlineMessageSyncService,
UserPresenceService userPresenceService,
ObjectMapper objectMapper) {
this.messageRepository = messageRepository;
this.keywordFilterService = keywordFilterService;
@ -67,6 +71,8 @@ public class MessageService {
this.featureConfigClient = featureConfigClient;
this.friendRepository = friendRepository;
this.webhookDispatchService = webhookDispatchService;
this.offlineMessageSyncService = offlineMessageSyncService;
this.userPresenceService = userPresenceService;
this.objectMapper = objectMapper;
}
@ -131,7 +137,12 @@ public class MessageService {
if (!receiverBlocksSender) {
log.debug("deliver message to receiver appId={} from={} to={}",
appId, fromUserId, req.toId());
boolean receiverOnline = userPresenceService.isOnline(req.toId());
if (receiverOnline) {
clusterPublisher.publish("/user/" + req.toId() + "/queue/messages", saved);
} else {
offlineMessageSyncService.storeOfflineMessage(appId, req.toId(), saved.getId());
}
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId()));
imPushBridge.sendOfflinePushToUsers(
appId,

查看文件

@ -0,0 +1,97 @@
package com.xuqm.im.service;
import com.xuqm.im.cluster.ImClusterPublisher;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.ImOfflineMessageEntity;
import com.xuqm.im.repository.ImMessageRepository;
import com.xuqm.im.repository.ImOfflineMessageRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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 OfflineMessageSyncService {
private static final Logger log = LoggerFactory.getLogger(OfflineMessageSyncService.class);
private final ImOfflineMessageRepository offlineMessageRepository;
private final ImMessageRepository messageRepository;
private final ImClusterPublisher clusterPublisher;
public OfflineMessageSyncService(ImOfflineMessageRepository offlineMessageRepository,
ImMessageRepository messageRepository,
ImClusterPublisher clusterPublisher) {
this.offlineMessageRepository = offlineMessageRepository;
this.messageRepository = messageRepository;
this.clusterPublisher = clusterPublisher;
}
@Transactional
public void storeOfflineMessage(String appId, String userId, String messageId) {
ImOfflineMessageEntity entity = new ImOfflineMessageEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId);
entity.setUserId(userId);
entity.setMessageId(messageId);
entity.setDelivered(false);
entity.setCreatedAt(LocalDateTime.now());
offlineMessageRepository.save(entity);
log.debug("Stored offline message appId={} userId={} messageId={}", appId, userId, messageId);
}
@Transactional
public void syncAndDeliver(String appId, String userId) {
List<ImOfflineMessageEntity> offlineMessages = offlineMessageRepository
.findByAppIdAndUserIdAndDeliveredFalse(appId, userId);
if (offlineMessages.isEmpty()) {
return;
}
List<String> deliveredIds = new ArrayList<>();
for (ImOfflineMessageEntity offline : offlineMessages) {
ImMessageEntity message = messageRepository.findById(offline.getMessageId()).orElse(null);
if (message != null) {
clusterPublisher.publish("/user/" + userId + "/queue/messages", message);
deliveredIds.add(offline.getId());
log.debug("Delivered offline message appId={} userId={} messageId={}", appId, userId, message.getId());
}
}
if (!deliveredIds.isEmpty()) {
offlineMessageRepository.markDeliveredByIds(deliveredIds);
log.info("Synced {} offline messages for appId={} userId={}", deliveredIds.size(), appId, userId);
}
offlineMessageRepository.deleteByAppIdAndUserIdAndDeliveredTrue(appId, userId);
}
public long countUndelivered(String appId, String userId) {
return offlineMessageRepository.countUndeliveredByAppIdAndUserId(appId, userId);
}
@Transactional
public List<ImMessageEntity> syncAndReturn(String appId, String userId) {
List<ImOfflineMessageEntity> offlineMessages = offlineMessageRepository
.findByAppIdAndUserIdAndDeliveredFalse(appId, userId);
List<ImMessageEntity> result = new ArrayList<>();
List<String> deliveredIds = new ArrayList<>();
for (ImOfflineMessageEntity offline : offlineMessages) {
ImMessageEntity message = messageRepository.findById(offline.getMessageId()).orElse(null);
if (message != null) {
result.add(message);
deliveredIds.add(offline.getId());
}
}
if (!deliveredIds.isEmpty()) {
offlineMessageRepository.markDeliveredByIds(deliveredIds);
}
offlineMessageRepository.deleteByAppIdAndUserIdAndDeliveredTrue(appId, userId);
return result;
}
}

查看文件

@ -0,0 +1,74 @@
package com.xuqm.im.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class UserPresenceService {
private static final Logger log = LoggerFactory.getLogger(UserPresenceService.class);
private static final String PRESENCE_PREFIX = "im:presence:";
private static final String LAST_SEEN_PREFIX = "im:last-seen:";
private static final Duration PRESENCE_TTL = Duration.ofMinutes(5);
private final StringRedisTemplate redisTemplate;
public UserPresenceService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void markOnline(String userId) {
String key = PRESENCE_PREFIX + userId;
redisTemplate.opsForValue().set(key, String.valueOf(Instant.now().toEpochMilli()), PRESENCE_TTL);
log.debug("User marked online userId={}", userId);
}
public void markOffline(String userId) {
String presenceKey = PRESENCE_PREFIX + userId;
String lastSeenKey = LAST_SEEN_PREFIX + userId;
String lastSeen = redisTemplate.opsForValue().get(presenceKey);
if (lastSeen != null) {
redisTemplate.opsForValue().set(lastSeenKey, lastSeen, Duration.ofDays(7));
}
redisTemplate.delete(presenceKey);
log.debug("User marked offline userId={}", userId);
}
public void heartbeat(String userId) {
String key = PRESENCE_PREFIX + userId;
redisTemplate.opsForValue().set(key, String.valueOf(Instant.now().toEpochMilli()), PRESENCE_TTL);
}
public boolean isOnline(String userId) {
return Boolean.TRUE.equals(redisTemplate.hasKey(PRESENCE_PREFIX + userId));
}
public long lastSeenAt(String userId) {
String value = redisTemplate.opsForValue().get(PRESENCE_PREFIX + userId);
if (value != null) {
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {}
}
String lastSeen = redisTemplate.opsForValue().get(LAST_SEEN_PREFIX + userId);
if (lastSeen != null) {
try {
return Long.parseLong(lastSeen);
} catch (NumberFormatException ignored) {}
}
return 0L;
}
public Set<String> filterOnline(Iterable<String> userIds) {
return redisTemplate.keys(PRESENCE_PREFIX + "*").stream()
.map(k -> k.substring(PRESENCE_PREFIX.length()))
.collect(Collectors.toSet());
}
}

查看文件

@ -22,6 +22,11 @@ public class WebhookConfigService {
return repository.findByAppId(appId);
}
public WebhookConfigEntity get(String appId, String id) {
return repository.findByIdAndAppId(id, appId)
.orElseThrow(() -> new BusinessException(404, "回调配置不存在"));
}
public WebhookConfigEntity create(String appId, String url, String secret, Boolean enabled) {
WebhookConfigEntity entity = new WebhookConfigEntity();
entity.setId(UUID.randomUUID().toString());

查看文件

@ -2,9 +2,11 @@ package com.xuqm.im.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.im.entity.WebhookAlertEntity;
import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.entity.WebhookDeliveryEntity;
import com.xuqm.im.model.WebhookCallbackEnvelope;
import com.xuqm.im.repository.WebhookAlertRepository;
import com.xuqm.im.repository.WebhookConfigRepository;
import com.xuqm.im.repository.WebhookDeliveryRepository;
import org.slf4j.Logger;
@ -34,9 +36,11 @@ public class WebhookDispatchService {
private static final Logger log = LoggerFactory.getLogger(WebhookDispatchService.class);
private static final int MAX_RETRIES = 3;
private static final long[] RETRY_DELAYS_MS = {1000L, 5000L, 15000L};
private static final int ALERT_THRESHOLD = 10;
private final WebhookConfigRepository webhookRepository;
private final WebhookDeliveryRepository deliveryRepository;
private final WebhookAlertRepository alertRepository;
private final ImAppSecretClient appSecretClient;
private final ObjectMapper objectMapper;
@ -45,10 +49,12 @@ public class WebhookDispatchService {
public WebhookDispatchService(WebhookConfigRepository webhookRepository,
WebhookDeliveryRepository deliveryRepository,
WebhookAlertRepository alertRepository,
ImAppSecretClient appSecretClient,
ObjectMapper objectMapper) {
this.webhookRepository = webhookRepository;
this.deliveryRepository = deliveryRepository;
this.alertRepository = alertRepository;
this.appSecretClient = appSecretClient;
this.objectMapper = objectMapper;
}
@ -120,6 +126,11 @@ public class WebhookDispatchService {
if (response.statusCode() >= 200 && response.statusCode() < 300) {
delivery.setSuccess(true);
deliveryRepository.save(delivery);
if (webhook.getConsecutiveFailures() > 0) {
webhook.setConsecutiveFailures(0);
webhook.setLastFailureAt(null);
webhookRepository.save(webhook);
}
log.info("Webhook delivered appId={} event={} url={} attempt={} status={}",
appId, callbackEvent, webhook.getUrl(), attempt, response.statusCode());
return;
@ -149,12 +160,39 @@ public class WebhookDispatchService {
break;
}
} else {
log.error("Webhook max retries exceeded appId={} event={} url={}",
appId, callbackEvent, webhook.getUrl());
handleMaxRetriesExceeded(appId, callbackEvent, webhook);
}
}
}
private void handleMaxRetriesExceeded(String appId, String callbackEvent, WebhookConfigEntity webhook) {
int failures = webhook.getConsecutiveFailures() + 1;
webhook.setConsecutiveFailures(failures);
webhook.setLastFailureAt(LocalDateTime.now());
webhookRepository.save(webhook);
log.error("Webhook max retries exceeded appId={} event={} url={} consecutiveFailures={}",
appId, callbackEvent, webhook.getUrl(), failures);
if (failures >= ALERT_THRESHOLD && webhook.isEnabled()) {
webhook.setEnabled(false);
webhookRepository.save(webhook);
log.warn("Webhook auto-disabled after {} consecutive failures appId={} url={}",
ALERT_THRESHOLD, appId, webhook.getUrl());
WebhookAlertEntity alert = new WebhookAlertEntity();
alert.setId(UUID.randomUUID().toString());
alert.setAppId(appId);
alert.setWebhookId(webhook.getId());
alert.setWebhookUrl(webhook.getUrl());
alert.setAlertType("AUTO_DISABLED");
alert.setDescription("Webhook 在连续 " + ALERT_THRESHOLD + " 次投递失败后已自动禁用。事件:" + callbackEvent);
alert.setAcknowledged(false);
alert.setCreatedAt(LocalDateTime.now());
alertRepository.save(alert);
}
}
private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) {
String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body);
return hmacSha256Hex(appSecret, payload);

查看文件

@ -3,6 +3,8 @@ package com.xuqm.im.ws;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.service.MessageService;
import com.xuqm.im.service.OfflineMessageSyncService;
import com.xuqm.im.service.UserPresenceService;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Controller;
@ -13,9 +15,15 @@ import java.security.Principal;
public class ChatController {
private final MessageService messageService;
private final OfflineMessageSyncService offlineMessageSyncService;
private final UserPresenceService userPresenceService;
public ChatController(MessageService messageService) {
public ChatController(MessageService messageService,
OfflineMessageSyncService offlineMessageSyncService,
UserPresenceService userPresenceService) {
this.messageService = messageService;
this.offlineMessageSyncService = offlineMessageSyncService;
this.userPresenceService = userPresenceService;
}
@MessageMapping("/chat.send")
@ -35,6 +43,14 @@ public class ChatController {
messageService.revoke(request.appId(), request.messageId(), principal.getName());
}
@MessageMapping("/chat.sync")
public void sync(@Payload WsSyncRequest request, Principal principal) {
if (principal == null) return;
String userId = principal.getName();
userPresenceService.heartbeat(userId);
offlineMessageSyncService.syncAndDeliver(request.appId(), userId);
}
public record WsMessageRequest(
String appId, String messageId, String toId,
ImMessageEntity.ChatType chatType,
@ -43,4 +59,6 @@ public class ChatController {
) {}
public record WsRevokeRequest(String appId, String messageId) {}
public record WsSyncRequest(String appId) {}
}

查看文件

@ -6,6 +6,7 @@ import com.xuqm.push.service.PushDispatcher;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -50,4 +51,13 @@ public class PushController {
pushDispatcher.pushToUser(appId, userId, title, body, payload);
return ResponseEntity.ok(ApiResponse.ok());
}
@DeleteMapping("/unregister")
public ResponseEntity<ApiResponse<Void>> unregister(
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor) {
pushDispatcher.unregisterToken(appId, userId, vendor);
return ResponseEntity.ok(ApiResponse.ok());
}
}

查看文件

@ -15,7 +15,7 @@ import java.time.LocalDateTime;
public class DeviceTokenEntity {
public enum Vendor {
HUAWEI, XIAOMI, OPPO, VIVO, HONOR, FCM, APNS
HUAWEI, XIAOMI, OPPO, VIVO, HONOR, HARMONY, FCM, APNS
}
@Id

查看文件

@ -11,4 +11,5 @@ public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity,
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
String appId, String userId, DeviceTokenEntity.Vendor vendor);
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId);
void deleteByAppIdAndUserIdAndVendor(String appId, String userId, DeviceTokenEntity.Vendor vendor);
}

查看文件

@ -73,4 +73,8 @@ public class PushDispatcher {
}
tokenRepository.saveAll(tokens);
}
public void unregisterToken(String appId, String userId, DeviceTokenEntity.Vendor vendor) {
tokenRepository.deleteByAppIdAndUserIdAndVendor(appId, userId, vendor);
}
}

查看文件

@ -0,0 +1,109 @@
package com.xuqm.push.service.provider;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.push.service.TenantPushConfigClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
/**
* HarmonyOS Push Kit provider.
*
* HarmonyOS Push Kit shares the same OAuth endpoint with Huawei HMS,
* but uses a dedicated push API path. The provider loads tenant-specific
* credentials under the "harmony" key in the push service config.
*/
@Component
public class HarmonyPushProvider implements PushProvider {
private static final Logger log = LoggerFactory.getLogger(HarmonyPushProvider.class);
@Value("${push.harmony.app-id:}")
private String envAppId;
@Value("${push.harmony.app-secret:}")
private String envAppSecret;
@Value("${push.harmony.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}")
private String tokenUrl;
@Value("${push.harmony.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}")
private String pushUrl;
private final TenantPushConfigClient configClient;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
public HarmonyPushProvider(TenantPushConfigClient configClient) {
this.configClient = configClient;
}
@Override
public String vendorName() {
return "HARMONY";
}
@Override
public boolean send(String appId, String token, String title, String body, String payload) {
String resolvedAppId = resolveConfig(appId, "appId", envAppId);
String resolvedAppSecret = resolveConfig(appId, "appSecret", envAppSecret);
if (resolvedAppId.isBlank() || resolvedAppSecret.isBlank()) {
log.warn("Harmony push not configured");
return false;
}
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 : "{}"
)
);
String requestBody = objectMapper.writeValueAsString(message);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + accessToken)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return response.statusCode() == 200;
} catch (Exception e) {
log.error("Harmony push failed: {}", e.getMessage());
return false;
}
}
private String getAccessToken(String appId, String appSecret) throws Exception {
String form = "grant_type=client_credentials&client_id=" + appId + "&client_secret=" + appSecret;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tokenUrl))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
Map<?, ?> json = objectMapper.readValue(response.body(), Map.class);
return (String) json.get("access_token");
}
private String resolveConfig(String appId, String key, String fallback) {
JsonNode config = configClient.loadServiceConfig(appId, "HARMONY", "PUSH")
.map(node -> node.path("harmony"))
.orElse(null);
if (config == null) {
return fallback == null ? "" : fallback;
}
String value = config.path(key).asText("");
return value.isBlank() ? (fallback == null ? "" : fallback) : value;
}
}

查看文件

@ -25,10 +25,10 @@ public class HonorPushProvider implements PushProvider {
@Value("${push.huawei.app-secret:}")
private String envAppSecret;
@Value("${push.huawei.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}")
@Value("${push.honor.token-url:https://oauth-login.cloud.honor.com/oauth2/v2/token}")
private String tokenUrl;
@Value("${push.huawei.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}")
@Value("${push.honor.push-url:https://push-api.cloud.honor.com/v1/{appId}/messages:send}")
private String pushUrl;
private final TenantPushConfigClient configClient;

查看文件

@ -0,0 +1,111 @@
package com.xuqm.push.service.provider;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.push.service.TenantPushConfigClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class OppoPushProvider implements PushProvider {
private static final Logger log = LoggerFactory.getLogger(OppoPushProvider.class);
private static final String AUTH_URL = "https://api.push.oppomobile.com/server/v1/auth";
private static final String PUSH_URL = "https://api.push.oppomobile.com/server/v1/message/notification/unicast";
private final TenantPushConfigClient configClient;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, TokenCache> tokenCache = new ConcurrentHashMap<>();
public OppoPushProvider(TenantPushConfigClient configClient) {
this.configClient = configClient;
}
@Override
public String vendorName() {
return "OPPO";
}
@Override
public boolean send(String appId, String token, String title, String body, String payload) {
String appKey = resolveConfig(appId, "appKey");
String masterSecret = resolveConfig(appId, "masterSecret");
if (appKey.isBlank() || masterSecret.isBlank()) {
log.warn("OPPO push not configured");
return false;
}
try {
String authToken = getAccessToken(appKey, masterSecret);
String messageId = appId + "_" + System.currentTimeMillis();
Map<String, Object> message = Map.of(
"message", Map.of(
"app_message_id", messageId,
"title", title,
"content", body,
"target_type", 2,
"target_value", token
)
);
String requestBody = objectMapper.writeValueAsString(message);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(PUSH_URL))
.header("Content-Type", "application/json")
.header("auth_token", authToken)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonNode json = objectMapper.readTree(response.body());
return json.path("code").asInt(-1) == 0;
}
return false;
} catch (Exception e) {
log.error("OPPO push failed: {}", e.getMessage());
return false;
}
}
private String getAccessToken(String appKey, String masterSecret) throws Exception {
TokenCache cached = tokenCache.get(appKey);
if (cached != null && cached.expiresAt > Instant.now().getEpochSecond()) {
return cached.token;
}
Map<String, String> body = Map.of("app_key", appKey, "master_secret", masterSecret);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(AUTH_URL))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode json = objectMapper.readTree(response.body());
String token = json.path("data").path("auth_token").asText("");
long createTime = json.path("data").path("create_time").asLong(Instant.now().getEpochSecond());
tokenCache.put(appKey, new TokenCache(token, createTime + 86400));
return token;
}
private String resolveConfig(String appId, String key) {
JsonNode config = configClient.loadServiceConfig(appId, "ANDROID", "PUSH")
.map(node -> node.path("oppo"))
.orElse(null);
if (config == null) {
return "";
}
String value = config.path(key).asText("");
return value.isBlank() ? "" : value;
}
private record TokenCache(String token, long expiresAt) {}
}

查看文件

@ -0,0 +1,107 @@
package com.xuqm.push.service.provider;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.push.service.TenantPushConfigClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class VivoPushProvider implements PushProvider {
private static final Logger log = LoggerFactory.getLogger(VivoPushProvider.class);
private static final String AUTH_URL = "https://api-push.vivo.com.cn/message/auth";
private static final String PUSH_URL = "https://api-push.vivo.com.cn/message/send";
private final TenantPushConfigClient configClient;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, TokenCache> tokenCache = new ConcurrentHashMap<>();
public VivoPushProvider(TenantPushConfigClient configClient) {
this.configClient = configClient;
}
@Override
public String vendorName() {
return "VIVO";
}
@Override
public boolean send(String appId, String token, String title, String body, String payload) {
String appKey = resolveConfig(appId, "appKey");
String appIdConfig = resolveConfig(appId, "appId");
if (appKey.isBlank() || appIdConfig.isBlank()) {
log.warn("Vivo push not configured");
return false;
}
try {
String authToken = getAccessToken(appIdConfig, appKey);
Map<String, Object> message = Map.of(
"regId", token,
"title", title,
"content", body,
"notifyType", 1
);
String requestBody = objectMapper.writeValueAsString(message);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(PUSH_URL))
.header("Content-Type", "application/json")
.header("authToken", authToken)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonNode json = objectMapper.readTree(response.body());
return json.path("result").asInt(-1) == 0;
}
return false;
} catch (Exception e) {
log.error("Vivo push failed: {}", e.getMessage());
return false;
}
}
private String getAccessToken(String appId, String appKey) throws Exception {
TokenCache cached = tokenCache.get(appId);
if (cached != null && cached.expiresAt > Instant.now().getEpochSecond()) {
return cached.token;
}
Map<String, String> body = Map.of("appId", appId, "appKey", appKey);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(AUTH_URL))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode json = objectMapper.readTree(response.body());
String token = json.path("authToken").asText("");
long expiresAt = Instant.now().getEpochSecond() + 3600;
tokenCache.put(appId, new TokenCache(token, expiresAt));
return token;
}
private String resolveConfig(String appId, String key) {
JsonNode config = configClient.loadServiceConfig(appId, "ANDROID", "PUSH")
.map(node -> node.path("vivo"))
.orElse(null);
if (config == null) {
return "";
}
String value = config.path(key).asText("");
return value.isBlank() ? "" : value;
}
private record TokenCache(String token, long expiresAt) {}
}

查看文件

@ -167,6 +167,20 @@ public class FeatureServiceController {
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId)));
}
@PostMapping("/{id}/regenerate-key")
public ResponseEntity<ApiResponse<FeatureServiceEntity>> regenerateKey(
@PathVariable String appId,
@PathVariable String id,
@AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId);
FeatureServiceEntity updated = featureServiceManager.regenerateSecretKey(id);
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", updated.getId(), "REGENERATE_KEY", java.util.Map.of(
"platform", updated.getPlatform().name(),
"serviceType", updated.getServiceType().name()
));
return ResponseEntity.ok(ApiResponse.success(updated));
}
public record FeatureServiceConfigRequest(
Boolean allowStrangerMessage,
Boolean allowFriendRequest,

查看文件

@ -7,13 +7,19 @@ import com.xuqm.tenant.entity.OperationLogEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.service.FeatureServiceManager;
import com.xuqm.tenant.entity.RiskConfigEntity;
import com.xuqm.tenant.entity.SensitiveWordEntity;
import com.xuqm.tenant.service.OpsService;
import com.xuqm.tenant.service.RiskControlService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -27,10 +33,13 @@ public class OpsController {
private final OpsService opsService;
private final FeatureServiceManager featureServiceManager;
private final RiskControlService riskControlService;
public OpsController(OpsService opsService, FeatureServiceManager featureServiceManager) {
public OpsController(OpsService opsService, FeatureServiceManager featureServiceManager,
RiskControlService riskControlService) {
this.opsService = opsService;
this.featureServiceManager = featureServiceManager;
this.riskControlService = riskControlService;
}
@PostMapping("/api/auth/ops/login")
@ -150,4 +159,59 @@ public class OpsController {
"totalPages", result.getTotalPages()
)));
}
/* ---------- 风控配置 ---------- */
@GetMapping("/api/ops/risk/rules")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<RiskConfigEntity>> getRiskConfig() {
return ResponseEntity.ok(ApiResponse.success(riskControlService.getConfig()));
}
@PostMapping("/api/ops/risk/rules")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<RiskConfigEntity>> saveRiskConfig(@RequestBody RiskConfigEntity req) {
return ResponseEntity.ok(ApiResponse.success(riskControlService.saveConfig(req)));
}
/* ---------- 敏感词 ---------- */
@GetMapping("/api/ops/risk/sensitive-words")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> listSensitiveWords(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<SensitiveWordEntity> result = riskControlService.listWords(page, size);
return ResponseEntity.ok(ApiResponse.success(Map.of(
"content", result.getContent(),
"total", result.getTotalElements(),
"totalPages", result.getTotalPages()
)));
}
@PostMapping("/api/ops/risk/sensitive-words")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<SensitiveWordEntity>> createSensitiveWord(@RequestBody SensitiveWordEntity req) {
return ResponseEntity.ok(ApiResponse.success(riskControlService.createWord(req)));
}
@PutMapping("/api/ops/risk/sensitive-words/{id}")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<SensitiveWordEntity>> updateSensitiveWord(
@PathVariable String id, @RequestBody SensitiveWordEntity req) {
return ResponseEntity.ok(ApiResponse.success(riskControlService.updateWord(id, req)));
}
@PatchMapping("/api/ops/risk/sensitive-words/{id}/toggle")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Void>> toggleSensitiveWord(
@PathVariable String id, @RequestParam boolean enabled) {
riskControlService.toggleWord(id, enabled);
return ResponseEntity.ok(ApiResponse.ok());
}
@DeleteMapping("/api/ops/risk/sensitive-words/{id}")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Void>> deleteSensitiveWord(@PathVariable String id) {
riskControlService.deleteWord(id);
return ResponseEntity.ok(ApiResponse.ok());
}
}

查看文件

@ -0,0 +1,48 @@
package com.xuqm.tenant.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_risk_config")
public class RiskConfigEntity {
@Id
private String id;
@Column(nullable = false)
private int ipRateLimit = 300;
@Column(nullable = false)
private int loginFailThreshold = 5;
@Column(nullable = false)
private int loginLockMinutes = 30;
@Column(nullable = false)
private boolean abnormalDetection = true;
@Column(nullable = false)
private LocalDateTime updatedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public int getIpRateLimit() { return ipRateLimit; }
public void setIpRateLimit(int ipRateLimit) { this.ipRateLimit = ipRateLimit; }
public int getLoginFailThreshold() { return loginFailThreshold; }
public void setLoginFailThreshold(int loginFailThreshold) { this.loginFailThreshold = loginFailThreshold; }
public int getLoginLockMinutes() { return loginLockMinutes; }
public void setLoginLockMinutes(int loginLockMinutes) { this.loginLockMinutes = loginLockMinutes; }
public boolean isAbnormalDetection() { return abnormalDetection; }
public void setAbnormalDetection(boolean abnormalDetection) { this.abnormalDetection = abnormalDetection; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,54 @@
package com.xuqm.tenant.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "t_sensitive_word")
public class SensitiveWordEntity {
@Id
private String id;
@Column(nullable = false, length = 256, unique = true)
private String word;
@Column(nullable = false, length = 16)
private String level;
@Column(nullable = false, length = 64)
private String category;
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private LocalDateTime updatedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getWord() { return word; }
public void setWord(String word) { this.word = word; }
public String getLevel() { return level; }
public void setLevel(String level) { this.level = level; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.tenant.repository;
import com.xuqm.tenant.entity.RiskConfigEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RiskConfigRepository extends JpaRepository<RiskConfigEntity, String> {
Optional<RiskConfigEntity> findFirstByOrderByUpdatedAtDesc();
}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.tenant.repository;
import com.xuqm.tenant.entity.SensitiveWordEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SensitiveWordRepository extends JpaRepository<SensitiveWordEntity, String> {
Page<SensitiveWordEntity> findByOrderByUpdatedAtDesc(Pageable pageable);
Optional<SensitiveWordEntity> findByWord(String word);
}

查看文件

@ -518,6 +518,17 @@ public class FeatureServiceManager {
return node.toString();
}
@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<String> parseStoreTargets(String json) {
if (json == null || json.isBlank()) {
return List.of();

查看文件

@ -21,6 +21,7 @@ import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -120,11 +121,27 @@ public class OpsService {
LocalDateTime todayEnd = todayStart.plusDays(1);
long todayNew = tenantRepository.countByCreatedAtBetween(todayStart, todayEnd);
long activeApps = appRepository.count();
List<Map<String, Object>> dailyTrend = new ArrayList<>();
for (int i = 6; i >= 0; i--) {
LocalDate d = LocalDate.now().minusDays(i);
long count = tenantRepository.countByCreatedAtBetween(d.atStartOfDay(), d.plusDays(1).atStartOfDay());
dailyTrend.add(Map.of("date", d.toString(), "count", count));
}
List<FeatureServiceEntity> services = featureServiceRepository.findAll();
Map<String, Long> serviceDistribution = services.stream()
.filter(FeatureServiceEntity::isEnabled)
.collect(java.util.stream.Collectors.groupingBy(
s -> s.getServiceType().name(), java.util.stream.Collectors.counting()));
return Map.of(
"totalTenants", totalTenants,
"todayNew", todayNew,
"activeApps", activeApps,
"onlineUsers", 0
"onlineUsers", 0,
"dailyTrend", dailyTrend,
"serviceDistribution", serviceDistribution
);
}

查看文件

@ -0,0 +1,103 @@
package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.tenant.entity.RiskConfigEntity;
import com.xuqm.tenant.entity.SensitiveWordEntity;
import com.xuqm.tenant.repository.RiskConfigRepository;
import com.xuqm.tenant.repository.SensitiveWordRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
public class RiskControlService {
private final RiskConfigRepository riskConfigRepository;
private final SensitiveWordRepository sensitiveWordRepository;
public RiskControlService(RiskConfigRepository riskConfigRepository,
SensitiveWordRepository sensitiveWordRepository) {
this.riskConfigRepository = riskConfigRepository;
this.sensitiveWordRepository = sensitiveWordRepository;
}
public RiskConfigEntity getConfig() {
return riskConfigRepository.findFirstByOrderByUpdatedAtDesc()
.orElseGet(() -> {
RiskConfigEntity cfg = new RiskConfigEntity();
cfg.setId(UUID.randomUUID().toString());
cfg.setUpdatedAt(LocalDateTime.now());
return riskConfigRepository.save(cfg);
});
}
@Transactional
public RiskConfigEntity saveConfig(RiskConfigEntity req) {
RiskConfigEntity entity = riskConfigRepository.findFirstByOrderByUpdatedAtDesc()
.orElseGet(() -> {
RiskConfigEntity cfg = new RiskConfigEntity();
cfg.setId(UUID.randomUUID().toString());
return cfg;
});
entity.setIpRateLimit(req.getIpRateLimit());
entity.setLoginFailThreshold(req.getLoginFailThreshold());
entity.setLoginLockMinutes(req.getLoginLockMinutes());
entity.setAbnormalDetection(req.isAbnormalDetection());
entity.setUpdatedAt(LocalDateTime.now());
return riskConfigRepository.save(entity);
}
public Page<SensitiveWordEntity> listWords(int page, int size) {
return sensitiveWordRepository.findByOrderByUpdatedAtDesc(
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")));
}
@Transactional
public SensitiveWordEntity createWord(SensitiveWordEntity req) {
sensitiveWordRepository.findByWord(req.getWord()).ifPresent(e -> {
throw new BusinessException(409, "敏感词已存在");
});
SensitiveWordEntity entity = new SensitiveWordEntity();
entity.setId(UUID.randomUUID().toString());
entity.setWord(req.getWord());
entity.setLevel(req.getLevel());
entity.setCategory(req.getCategory());
entity.setEnabled(req.isEnabled());
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
return sensitiveWordRepository.save(entity);
}
@Transactional
public SensitiveWordEntity updateWord(String id, SensitiveWordEntity req) {
SensitiveWordEntity entity = sensitiveWordRepository.findById(id)
.orElseThrow(() -> new BusinessException(404, "敏感词不存在"));
entity.setWord(req.getWord());
entity.setLevel(req.getLevel());
entity.setCategory(req.getCategory());
entity.setEnabled(req.isEnabled());
entity.setUpdatedAt(LocalDateTime.now());
return sensitiveWordRepository.save(entity);
}
@Transactional
public void toggleWord(String id, boolean enabled) {
SensitiveWordEntity entity = sensitiveWordRepository.findById(id)
.orElseThrow(() -> new BusinessException(404, "敏感词不存在"));
entity.setEnabled(enabled);
entity.setUpdatedAt(LocalDateTime.now());
sensitiveWordRepository.save(entity);
}
@Transactional
public void deleteWord(String id) {
SensitiveWordEntity entity = sensitiveWordRepository.findById(id)
.orElseThrow(() -> new BusinessException(404, "敏感词不存在"));
sensitiveWordRepository.delete(entity);
}
}

查看文件

@ -49,7 +49,8 @@ public class AppVersionController {
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
@RequestParam String appId,
@RequestParam AppVersionEntity.Platform platform,
@RequestParam int currentVersionCode) {
@RequestParam int currentVersionCode,
@RequestParam(required = false) String userId) {
Optional<AppVersionEntity> latest = versionRepository
.findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
@ -63,6 +64,22 @@ public class AppVersionController {
}
AppVersionEntity v = latest.get();
// Gray release filtering
if (v.isGrayEnabled() && userId != null && !userId.isBlank()) {
boolean inGray = false;
if ("MEMBERS".equals(v.getGrayMode()) && v.getGrayMemberIds() != null) {
inGray = v.getGrayMemberIds().contains(userId);
} else {
// PERCENT mode: deterministic hash-based sampling
int hash = Math.abs(userId.hashCode()) % 100;
inGray = hash < v.getGrayPercent();
}
if (!inGray) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
}
String appStoreJumpUrl = hasText(v.getAppStoreUrl())
? v.getAppStoreUrl()
: appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE);

查看文件

@ -50,6 +50,8 @@ public class StoreSubmissionService {
private static final Logger log = LoggerFactory.getLogger(StoreSubmissionService.class);
private static final ObjectMapper mapper = new ObjectMapper();
private static final String HUAWEI_API = "https://connect-api.cloud.huawei.com";
private static final String HONOR_API = "https://appmarket-openapi-drcn.cloud.honor.com";
private static final String HONOR_IAM = "https://iam.developer.honor.com";
private final RestTemplate rest = new RestTemplate();
private final AppVersionRepository versionRepo;
@ -280,11 +282,131 @@ public class StoreSubmissionService {
return h;
}
// Honor AppGallery (same API as Huawei)
// Honor AppGallery
// Reference: https://developer.honor.com/cn/doc/guides/101359
private void submitToHonor(AppVersionEntity v, File file, Map<String, String> creds) throws Exception {
// Honor uses the same Connect API as Huawei reuse implementation
submitToHuawei(v, file, creds);
String clientId = require(creds, "clientId", "HONOR");
String clientSecret = require(creds, "clientSecret", "HONOR");
String packageName = requirePackageName(v);
// 1. OAuth token (form-urlencoded)
String token = honorGetToken(clientId, clientSecret);
// 2. Resolve appId from package name
int honorAppId = honorGetAppId(token, packageName);
// 3. Request file upload URL (need SHA256)
String fileSha256 = sha256Hex(file);
Map<String, Object> uploadInfo = honorGetUploadUrl(token, honorAppId, file, fileSha256);
long objectId = ((Number) uploadInfo.get("objectId")).longValue();
// 4. Upload file via multipart
honorUploadFile(token, honorAppId, objectId, file);
// 5. Bind APK file info
honorUpdateFileInfo(token, honorAppId, objectId);
// 6. Submit for review
honorSubmit(token, honorAppId, v.getChangeLog());
}
private String honorGetToken(String clientId, String clientSecret) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "client_credentials");
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
ResponseEntity<Map> resp = rest.postForEntity(
HONOR_IAM + "/auth/token", new HttpEntity<>(body, headers), Map.class);
Map<String, Object> result = resp.getBody();
if (result == null || result.get("access_token") == null)
throw new RuntimeException("Honor: failed to get access token");
return result.get("access_token").toString();
}
@SuppressWarnings("unchecked")
private int honorGetAppId(String token, String packageName) {
HttpHeaders headers = honorHeaders(token);
String url = HONOR_API + "/openapi/v1/publish/get-app-id?pkgName=" + packageName;
ResponseEntity<Map> resp = rest.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), Map.class);
Map<String, Object> body = resp.getBody();
assertHonorSuccess(body, "get-app-id");
List<Map<String, Object>> list = (List<Map<String, Object>>) body.get("data");
if (list == null || list.isEmpty()) throw new RuntimeException("Honor: app not found for " + packageName);
return ((Number) list.get(0).get("appId")).intValue();
}
@SuppressWarnings("unchecked")
private Map<String, Object> honorGetUploadUrl(String token, int appId, File file, String fileSha256) {
HttpHeaders headers = honorHeaders(token);
headers.setContentType(MediaType.APPLICATION_JSON);
List<Map<String, Object>> files = List.of(Map.of(
"fileName", file.getName(),
"fileType", 100,
"fileSize", file.length(),
"fileSha256", fileSha256
));
ResponseEntity<Map> resp = rest.postForEntity(
HONOR_API + "/openapi/v1/publish/get-file-upload-url?appId=" + appId,
new HttpEntity<>(files, headers), Map.class);
Map<String, Object> body = resp.getBody();
assertHonorSuccess(body, "get-file-upload-url");
List<Map<String, Object>> list = (List<Map<String, Object>>) body.get("data");
if (list == null || list.isEmpty()) throw new RuntimeException("Honor: empty upload url response");
return list.get(0);
}
private void honorUploadFile(String token, int appId, long objectId, File file) {
HttpHeaders headers = honorHeaders(token);
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", new FileSystemResource(file));
ResponseEntity<Map> resp = rest.postForEntity(
HONOR_API + "/openapi/v1/publish/file-upload?appId=" + appId + "&objectId=" + objectId,
new HttpEntity<>(body, headers), Map.class);
assertHonorSuccess(resp.getBody(), "file-upload");
}
private void honorUpdateFileInfo(String token, int appId, long objectId) {
HttpHeaders headers = honorHeaders(token);
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = Map.of("bindingFileList", List.of(Map.of("objectId", objectId)));
ResponseEntity<Map> resp = rest.postForEntity(
HONOR_API + "/openapi/v1/publish/update-file-info?appId=" + appId,
new HttpEntity<>(body, headers), Map.class);
assertHonorSuccess(resp.getBody(), "update-file-info");
}
private void honorSubmit(String token, int appId, String changeLog) {
HttpHeaders headers = honorHeaders(token);
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new LinkedHashMap<>();
body.put("releaseType", 1); // 1 = 全网发布
if (changeLog != null && !changeLog.isBlank()) {
body.put("testComment", changeLog);
}
ResponseEntity<Map> resp = rest.postForEntity(
HONOR_API + "/openapi/v1/publish/submit-audit?appId=" + appId,
new HttpEntity<>(body, headers), Map.class);
assertHonorSuccess(resp.getBody(), "submit-audit");
}
private HttpHeaders honorHeaders(String token) {
HttpHeaders h = new HttpHeaders();
h.set("Authorization", "Bearer " + token);
return h;
}
@SuppressWarnings("unchecked")
private void assertHonorSuccess(Map<String, Object> body, String step) {
if (body == null) throw new RuntimeException("Honor: empty response for " + step);
Object code = body.get("code");
if (code == null || !"0".equals(String.valueOf(code))) {
String msg = body.get("msg") != null ? body.get("msg").toString() : "unknown error";
throw new RuntimeException("Honor " + step + " failed: " + msg);
}
}
// Xiaomi Market
@ -686,6 +808,18 @@ public class StoreSubmissionService {
return HexFormat.of().formatHex(digest.digest());
}
private String sha256Hex(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream inputStream = new FileInputStream(file)) {
byte[] buffer = new byte[8192];
int len;
while ((len = inputStream.read(buffer)) != -1) {
digest.update(buffer, 0, len);
}
}
return HexFormat.of().formatHex(digest.digest());
}
private String asJsonString(Object value) throws Exception {
return mapper.writeValueAsString(value);
}