feat(android-sdk): 添加完整的IM客户端SDK实现
- 实现了Android SDK的完整IM功能接口,包括消息、群组、好友、会话等核心功能 - 添加了消息收发、历史记录、撤回编辑等完整的消息操作能力 - 实现了群组管理功能,包括创建、成员管理、权限设置等操作 - 添加了好友关系链管理,支持添加、删除、分组等操作 - 实现了会话管理功能,包括置顶、免打扰、已读状态等 - 添加了黑名单、资料管理、搜索等辅助功能 - 补齐了批量操作接口,提升客户端操作效率 - 实现了WebSocket连接管理和事件监听机制 - 添加了离线消息同步和状态管理功能
这个提交包含在:
父节点
d27607d14e
当前提交
d2dea0c332
@ -1010,6 +1010,16 @@ public final class XuqmImServerSdk {
|
|||||||
return response.data();
|
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) {
|
public GroupView updateGroupAttributes(String groupId, Map<String, Object> attributes) {
|
||||||
ApiResponse<GroupView> response = request(
|
ApiResponse<GroupView> response = request(
|
||||||
"PUT",
|
"PUT",
|
||||||
@ -1199,11 +1209,18 @@ public final class XuqmImServerSdk {
|
|||||||
return response.data();
|
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(
|
ApiResponse<GroupView> response = request(
|
||||||
"PUT",
|
"PUT",
|
||||||
buildUri("/api/im/groups/" + encode(groupId) + "/members/" + encode(userId) + "/info", appQuery()),
|
buildUri("/api/im/groups/" + encode(groupId) + "/members/" + encode(userId) + "/info", appQuery()),
|
||||||
Map.of("nickName", nickName),
|
body,
|
||||||
authorizedHeaders(),
|
authorizedHeaders(),
|
||||||
new TypeReference<>() {}
|
new TypeReference<>() {}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.xuqm.im.config;
|
package com.xuqm.im.config;
|
||||||
|
|
||||||
import com.xuqm.common.security.JwtUtil;
|
import com.xuqm.common.security.JwtUtil;
|
||||||
|
import com.xuqm.im.service.UserPresenceService;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.messaging.Message;
|
import org.springframework.messaging.Message;
|
||||||
import org.springframework.messaging.MessageChannel;
|
import org.springframework.messaging.MessageChannel;
|
||||||
@ -23,9 +24,11 @@ import java.util.List;
|
|||||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||||
|
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
|
private final UserPresenceService userPresenceService;
|
||||||
|
|
||||||
public WebSocketConfig(JwtUtil jwtUtil) {
|
public WebSocketConfig(JwtUtil jwtUtil, UserPresenceService userPresenceService) {
|
||||||
this.jwtUtil = jwtUtil;
|
this.jwtUtil = jwtUtil;
|
||||||
|
this.userPresenceService = userPresenceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -51,7 +54,10 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
|||||||
public Message<?> preSend(Message<?> message, MessageChannel channel) {
|
public Message<?> preSend(Message<?> message, MessageChannel channel) {
|
||||||
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(
|
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(
|
||||||
message, StompHeaderAccessor.class);
|
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");
|
String token = accessor.getFirstNativeHeader("Authorization");
|
||||||
if (token != null && token.startsWith("Bearer ")) {
|
if (token != null && token.startsWith("Bearer ")) {
|
||||||
token = token.substring(7);
|
token = token.substring(7);
|
||||||
@ -61,8 +67,14 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
|||||||
new UsernamePasswordAuthenticationToken(userId, null,
|
new UsernamePasswordAuthenticationToken(userId, null,
|
||||||
List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||||
accessor.setUser(auth);
|
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;
|
return message;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,14 @@ import com.xuqm.im.entity.ImFriendRequestEntity;
|
|||||||
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
|
import com.xuqm.im.entity.ImGroupJoinRequestEntity;
|
||||||
import com.xuqm.im.entity.ImMessageEntity;
|
import com.xuqm.im.entity.ImMessageEntity;
|
||||||
import com.xuqm.im.entity.KeywordFilterEntity;
|
import com.xuqm.im.entity.KeywordFilterEntity;
|
||||||
|
import com.xuqm.im.entity.WebhookAlertEntity;
|
||||||
import com.xuqm.im.entity.WebhookConfigEntity;
|
import com.xuqm.im.entity.WebhookConfigEntity;
|
||||||
|
import com.xuqm.im.entity.WebhookDeliveryEntity;
|
||||||
import com.xuqm.im.repository.ImAccountRepository;
|
import com.xuqm.im.repository.ImAccountRepository;
|
||||||
import com.xuqm.im.repository.ImGroupRepository;
|
import com.xuqm.im.repository.ImGroupRepository;
|
||||||
import com.xuqm.im.repository.ImMessageRepository;
|
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.ImAccountService;
|
||||||
import com.xuqm.im.service.BlacklistService;
|
import com.xuqm.im.service.BlacklistService;
|
||||||
import com.xuqm.im.service.FriendRequestService;
|
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.KeywordFilterService;
|
||||||
import com.xuqm.im.service.MessageService;
|
import com.xuqm.im.service.MessageService;
|
||||||
import com.xuqm.im.service.OperationLogService;
|
import com.xuqm.im.service.OperationLogService;
|
||||||
|
import com.xuqm.im.service.UserPresenceService;
|
||||||
import com.xuqm.im.service.WebhookConfigService;
|
import com.xuqm.im.service.WebhookConfigService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.messaging.simp.user.SimpUserRegistry;
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@ -53,12 +57,14 @@ public class ImAdminController {
|
|||||||
private final ImGroupService groupService;
|
private final ImGroupService groupService;
|
||||||
private final MessageService messageService;
|
private final MessageService messageService;
|
||||||
private final WebhookConfigService webhookConfigService;
|
private final WebhookConfigService webhookConfigService;
|
||||||
|
private final WebhookDeliveryRepository webhookDeliveryRepository;
|
||||||
|
private final WebhookAlertRepository webhookAlertRepository;
|
||||||
private final KeywordFilterService keywordFilterService;
|
private final KeywordFilterService keywordFilterService;
|
||||||
private final GlobalMuteService globalMuteService;
|
private final GlobalMuteService globalMuteService;
|
||||||
private final OperationLogService operationLogService;
|
private final OperationLogService operationLogService;
|
||||||
private final SimpUserRegistry simpUserRegistry;
|
|
||||||
private final SimpMessagingTemplate messagingTemplate;
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final UserPresenceService userPresenceService;
|
||||||
|
|
||||||
public ImAdminController(ImAccountRepository accountRepository,
|
public ImAdminController(ImAccountRepository accountRepository,
|
||||||
ImGroupRepository groupRepository,
|
ImGroupRepository groupRepository,
|
||||||
@ -69,12 +75,14 @@ public class ImAdminController {
|
|||||||
ImGroupService groupService,
|
ImGroupService groupService,
|
||||||
MessageService messageService,
|
MessageService messageService,
|
||||||
WebhookConfigService webhookConfigService,
|
WebhookConfigService webhookConfigService,
|
||||||
|
WebhookDeliveryRepository webhookDeliveryRepository,
|
||||||
|
WebhookAlertRepository webhookAlertRepository,
|
||||||
KeywordFilterService keywordFilterService,
|
KeywordFilterService keywordFilterService,
|
||||||
GlobalMuteService globalMuteService,
|
GlobalMuteService globalMuteService,
|
||||||
OperationLogService operationLogService,
|
OperationLogService operationLogService,
|
||||||
SimpUserRegistry simpUserRegistry,
|
|
||||||
SimpMessagingTemplate messagingTemplate,
|
SimpMessagingTemplate messagingTemplate,
|
||||||
StringRedisTemplate redisTemplate) {
|
StringRedisTemplate redisTemplate,
|
||||||
|
UserPresenceService userPresenceService) {
|
||||||
this.accountRepository = accountRepository;
|
this.accountRepository = accountRepository;
|
||||||
this.groupRepository = groupRepository;
|
this.groupRepository = groupRepository;
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
@ -84,12 +92,14 @@ public class ImAdminController {
|
|||||||
this.groupService = groupService;
|
this.groupService = groupService;
|
||||||
this.messageService = messageService;
|
this.messageService = messageService;
|
||||||
this.webhookConfigService = webhookConfigService;
|
this.webhookConfigService = webhookConfigService;
|
||||||
|
this.webhookDeliveryRepository = webhookDeliveryRepository;
|
||||||
|
this.webhookAlertRepository = webhookAlertRepository;
|
||||||
this.keywordFilterService = keywordFilterService;
|
this.keywordFilterService = keywordFilterService;
|
||||||
this.globalMuteService = globalMuteService;
|
this.globalMuteService = globalMuteService;
|
||||||
this.operationLogService = operationLogService;
|
this.operationLogService = operationLogService;
|
||||||
this.simpUserRegistry = simpUserRegistry;
|
|
||||||
this.messagingTemplate = messagingTemplate;
|
this.messagingTemplate = messagingTemplate;
|
||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.userPresenceService = userPresenceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List all registered IM users for the given appId. */
|
/** List all registered IM users for the given appId. */
|
||||||
@ -531,6 +541,74 @@ public class ImAdminController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok());
|
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")
|
@GetMapping("/keyword-filters")
|
||||||
public ResponseEntity<ApiResponse<List<KeywordFilterEntity>>> listKeywordFilters(@RequestParam String appId) {
|
public ResponseEntity<ApiResponse<List<KeywordFilterEntity>>> listKeywordFilters(@RequestParam String appId) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(keywordFilterService.list(appId)));
|
return ResponseEntity.ok(ApiResponse.success(keywordFilterService.list(appId)));
|
||||||
@ -593,13 +671,16 @@ public class ImAdminController {
|
|||||||
|
|
||||||
@GetMapping("/users/state")
|
@GetMapping("/users/state")
|
||||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Boolean>>> queryUserState(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> queryUserState(
|
||||||
@RequestParam String userIds) {
|
@RequestParam String userIds) {
|
||||||
Map<String, Boolean> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
for (String userId : userIds.split(",")) {
|
for (String userId : userIds.split(",")) {
|
||||||
String trimmed = userId.trim();
|
String trimmed = userId.trim();
|
||||||
if (!trimmed.isBlank()) {
|
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));
|
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.EditMessageRequest;
|
||||||
import com.xuqm.im.model.SendMessageRequest;
|
import com.xuqm.im.model.SendMessageRequest;
|
||||||
import com.xuqm.im.service.MessageService;
|
import com.xuqm.im.service.MessageService;
|
||||||
|
import com.xuqm.im.service.OfflineMessageSyncService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.format.annotation.DateTimeFormat;
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
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.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/im/messages")
|
@RequestMapping("/api/im/messages")
|
||||||
public class MessageController {
|
public class MessageController {
|
||||||
|
|
||||||
private final MessageService messageService;
|
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.messageService = messageService;
|
||||||
|
this.messageRepository = messageRepository;
|
||||||
|
this.offlineMessageSyncService = offlineMessageSyncService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/send")
|
@PostMapping("/send")
|
||||||
@ -84,4 +95,35 @@ public class MessageController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
messageService.groupHistory(appId, groupId, userId, msgType, keyword, startTime, endTime, page, size)));
|
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)
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int consecutiveFailures;
|
||||||
|
|
||||||
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
|
private LocalDateTime lastFailureAt;
|
||||||
|
|
||||||
public String getId() { return id; }
|
public String getId() { return id; }
|
||||||
public void setId(String id) { this.id = id; }
|
public void setId(String id) { this.id = id; }
|
||||||
|
|
||||||
@ -49,4 +55,11 @@ public class WebhookConfigEntity {
|
|||||||
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = 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,
|
@Param("endTime") LocalDateTime endTime,
|
||||||
Pageable pageable);
|
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("""
|
@Query("""
|
||||||
select count(m) from ImMessageEntity m
|
select count(m) from ImMessageEntity m
|
||||||
where m.appId = :appId
|
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> findByAppId(String appId, Pageable pageable);
|
||||||
|
|
||||||
|
Page<WebhookDeliveryEntity> findByAppIdAndSuccess(String appId, boolean success, Pageable pageable);
|
||||||
|
|
||||||
List<WebhookDeliveryEntity> findByCallbackId(String callbackId);
|
List<WebhookDeliveryEntity> findByCallbackId(String callbackId);
|
||||||
|
|
||||||
@Query("SELECT COUNT(d) FROM WebhookDeliveryEntity d WHERE d.appId = ?1 AND d.success = true AND d.createdAt >= ?2")
|
@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.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.messaging.simp.user.SimpUserRegistry;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
@ -19,7 +18,7 @@ public class ImPushBridge {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(ImPushBridge.class);
|
private static final Logger log = LoggerFactory.getLogger(ImPushBridge.class);
|
||||||
|
|
||||||
private final SimpUserRegistry userRegistry;
|
private final UserPresenceService userPresenceService;
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@ -29,8 +28,8 @@ public class ImPushBridge {
|
|||||||
@Value("${im.internal-token:xuqm-internal-token}")
|
@Value("${im.internal-token:xuqm-internal-token}")
|
||||||
private String internalToken;
|
private String internalToken;
|
||||||
|
|
||||||
public ImPushBridge(SimpUserRegistry userRegistry, ObjectMapper objectMapper) {
|
public ImPushBridge(UserPresenceService userPresenceService, ObjectMapper objectMapper) {
|
||||||
this.userRegistry = userRegistry;
|
this.userPresenceService = userPresenceService;
|
||||||
this.restTemplate = new RestTemplate();
|
this.restTemplate = new RestTemplate();
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
@ -72,6 +71,6 @@ public class ImPushBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean isOnline(String userId) {
|
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 ImFeatureConfigClient featureConfigClient;
|
||||||
private final ImFriendRepository friendRepository;
|
private final ImFriendRepository friendRepository;
|
||||||
private final WebhookDispatchService webhookDispatchService;
|
private final WebhookDispatchService webhookDispatchService;
|
||||||
|
private final OfflineMessageSyncService offlineMessageSyncService;
|
||||||
|
private final UserPresenceService userPresenceService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public MessageService(ImMessageRepository messageRepository,
|
public MessageService(ImMessageRepository messageRepository,
|
||||||
@ -55,6 +57,8 @@ public class MessageService {
|
|||||||
ImFeatureConfigClient featureConfigClient,
|
ImFeatureConfigClient featureConfigClient,
|
||||||
ImFriendRepository friendRepository,
|
ImFriendRepository friendRepository,
|
||||||
WebhookDispatchService webhookDispatchService,
|
WebhookDispatchService webhookDispatchService,
|
||||||
|
OfflineMessageSyncService offlineMessageSyncService,
|
||||||
|
UserPresenceService userPresenceService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.messageRepository = messageRepository;
|
this.messageRepository = messageRepository;
|
||||||
this.keywordFilterService = keywordFilterService;
|
this.keywordFilterService = keywordFilterService;
|
||||||
@ -67,6 +71,8 @@ public class MessageService {
|
|||||||
this.featureConfigClient = featureConfigClient;
|
this.featureConfigClient = featureConfigClient;
|
||||||
this.friendRepository = friendRepository;
|
this.friendRepository = friendRepository;
|
||||||
this.webhookDispatchService = webhookDispatchService;
|
this.webhookDispatchService = webhookDispatchService;
|
||||||
|
this.offlineMessageSyncService = offlineMessageSyncService;
|
||||||
|
this.userPresenceService = userPresenceService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,7 +137,12 @@ public class MessageService {
|
|||||||
if (!receiverBlocksSender) {
|
if (!receiverBlocksSender) {
|
||||||
log.debug("deliver message to receiver appId={} from={} to={}",
|
log.debug("deliver message to receiver appId={} from={} to={}",
|
||||||
appId, fromUserId, req.toId());
|
appId, fromUserId, req.toId());
|
||||||
clusterPublisher.publish("/user/" + req.toId() + "/queue/messages", saved);
|
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()));
|
conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId()));
|
||||||
imPushBridge.sendOfflinePushToUsers(
|
imPushBridge.sendOfflinePushToUsers(
|
||||||
appId,
|
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);
|
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) {
|
public WebhookConfigEntity create(String appId, String url, String secret, Boolean enabled) {
|
||||||
WebhookConfigEntity entity = new WebhookConfigEntity();
|
WebhookConfigEntity entity = new WebhookConfigEntity();
|
||||||
entity.setId(UUID.randomUUID().toString());
|
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.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.xuqm.im.entity.WebhookAlertEntity;
|
||||||
import com.xuqm.im.entity.WebhookConfigEntity;
|
import com.xuqm.im.entity.WebhookConfigEntity;
|
||||||
import com.xuqm.im.entity.WebhookDeliveryEntity;
|
import com.xuqm.im.entity.WebhookDeliveryEntity;
|
||||||
import com.xuqm.im.model.WebhookCallbackEnvelope;
|
import com.xuqm.im.model.WebhookCallbackEnvelope;
|
||||||
|
import com.xuqm.im.repository.WebhookAlertRepository;
|
||||||
import com.xuqm.im.repository.WebhookConfigRepository;
|
import com.xuqm.im.repository.WebhookConfigRepository;
|
||||||
import com.xuqm.im.repository.WebhookDeliveryRepository;
|
import com.xuqm.im.repository.WebhookDeliveryRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@ -34,9 +36,11 @@ public class WebhookDispatchService {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(WebhookDispatchService.class);
|
private static final Logger log = LoggerFactory.getLogger(WebhookDispatchService.class);
|
||||||
private static final int MAX_RETRIES = 3;
|
private static final int MAX_RETRIES = 3;
|
||||||
private static final long[] RETRY_DELAYS_MS = {1000L, 5000L, 15000L};
|
private static final long[] RETRY_DELAYS_MS = {1000L, 5000L, 15000L};
|
||||||
|
private static final int ALERT_THRESHOLD = 10;
|
||||||
|
|
||||||
private final WebhookConfigRepository webhookRepository;
|
private final WebhookConfigRepository webhookRepository;
|
||||||
private final WebhookDeliveryRepository deliveryRepository;
|
private final WebhookDeliveryRepository deliveryRepository;
|
||||||
|
private final WebhookAlertRepository alertRepository;
|
||||||
private final ImAppSecretClient appSecretClient;
|
private final ImAppSecretClient appSecretClient;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@ -45,10 +49,12 @@ public class WebhookDispatchService {
|
|||||||
|
|
||||||
public WebhookDispatchService(WebhookConfigRepository webhookRepository,
|
public WebhookDispatchService(WebhookConfigRepository webhookRepository,
|
||||||
WebhookDeliveryRepository deliveryRepository,
|
WebhookDeliveryRepository deliveryRepository,
|
||||||
|
WebhookAlertRepository alertRepository,
|
||||||
ImAppSecretClient appSecretClient,
|
ImAppSecretClient appSecretClient,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.webhookRepository = webhookRepository;
|
this.webhookRepository = webhookRepository;
|
||||||
this.deliveryRepository = deliveryRepository;
|
this.deliveryRepository = deliveryRepository;
|
||||||
|
this.alertRepository = alertRepository;
|
||||||
this.appSecretClient = appSecretClient;
|
this.appSecretClient = appSecretClient;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
@ -120,6 +126,11 @@ public class WebhookDispatchService {
|
|||||||
if (response.statusCode() >= 200 && response.statusCode() < 300) {
|
if (response.statusCode() >= 200 && response.statusCode() < 300) {
|
||||||
delivery.setSuccess(true);
|
delivery.setSuccess(true);
|
||||||
deliveryRepository.save(delivery);
|
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={}",
|
log.info("Webhook delivered appId={} event={} url={} attempt={} status={}",
|
||||||
appId, callbackEvent, webhook.getUrl(), attempt, response.statusCode());
|
appId, callbackEvent, webhook.getUrl(), attempt, response.statusCode());
|
||||||
return;
|
return;
|
||||||
@ -149,12 +160,39 @@ public class WebhookDispatchService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.error("Webhook max retries exceeded appId={} event={} url={}",
|
handleMaxRetriesExceeded(appId, callbackEvent, webhook);
|
||||||
appId, callbackEvent, webhook.getUrl());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) {
|
||||||
String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body);
|
String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body);
|
||||||
return hmacSha256Hex(appSecret, payload);
|
return hmacSha256Hex(appSecret, payload);
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package com.xuqm.im.ws;
|
|||||||
import com.xuqm.im.entity.ImMessageEntity;
|
import com.xuqm.im.entity.ImMessageEntity;
|
||||||
import com.xuqm.im.model.SendMessageRequest;
|
import com.xuqm.im.model.SendMessageRequest;
|
||||||
import com.xuqm.im.service.MessageService;
|
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.MessageMapping;
|
||||||
import org.springframework.messaging.handler.annotation.Payload;
|
import org.springframework.messaging.handler.annotation.Payload;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
@ -13,9 +15,15 @@ import java.security.Principal;
|
|||||||
public class ChatController {
|
public class ChatController {
|
||||||
|
|
||||||
private final MessageService messageService;
|
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.messageService = messageService;
|
||||||
|
this.offlineMessageSyncService = offlineMessageSyncService;
|
||||||
|
this.userPresenceService = userPresenceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@MessageMapping("/chat.send")
|
@MessageMapping("/chat.send")
|
||||||
@ -35,6 +43,14 @@ public class ChatController {
|
|||||||
messageService.revoke(request.appId(), request.messageId(), principal.getName());
|
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(
|
public record WsMessageRequest(
|
||||||
String appId, String messageId, String toId,
|
String appId, String messageId, String toId,
|
||||||
ImMessageEntity.ChatType chatType,
|
ImMessageEntity.ChatType chatType,
|
||||||
@ -43,4 +59,6 @@ public class ChatController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record WsRevokeRequest(String appId, String messageId) {}
|
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.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@ -50,4 +51,13 @@ public class PushController {
|
|||||||
pushDispatcher.pushToUser(appId, userId, title, body, payload);
|
pushDispatcher.pushToUser(appId, userId, title, body, payload);
|
||||||
return ResponseEntity.ok(ApiResponse.ok());
|
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 class DeviceTokenEntity {
|
||||||
|
|
||||||
public enum Vendor {
|
public enum Vendor {
|
||||||
HUAWEI, XIAOMI, OPPO, VIVO, HONOR, FCM, APNS
|
HUAWEI, XIAOMI, OPPO, VIVO, HONOR, HARMONY, FCM, APNS
|
||||||
}
|
}
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
|
|||||||
@ -11,4 +11,5 @@ public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity,
|
|||||||
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
|
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
|
||||||
String appId, String userId, DeviceTokenEntity.Vendor vendor);
|
String appId, String userId, DeviceTokenEntity.Vendor vendor);
|
||||||
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId);
|
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);
|
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:}")
|
@Value("${push.huawei.app-secret:}")
|
||||||
private String envAppSecret;
|
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;
|
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 String pushUrl;
|
||||||
|
|
||||||
private final TenantPushConfigClient configClient;
|
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)));
|
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(
|
public record FeatureServiceConfigRequest(
|
||||||
Boolean allowStrangerMessage,
|
Boolean allowStrangerMessage,
|
||||||
Boolean allowFriendRequest,
|
Boolean allowFriendRequest,
|
||||||
|
|||||||
@ -7,13 +7,19 @@ import com.xuqm.tenant.entity.OperationLogEntity;
|
|||||||
import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
|
import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
|
||||||
import com.xuqm.tenant.entity.TenantEntity;
|
import com.xuqm.tenant.entity.TenantEntity;
|
||||||
import com.xuqm.tenant.service.FeatureServiceManager;
|
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.OpsService;
|
||||||
|
import com.xuqm.tenant.service.RiskControlService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
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.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@ -27,10 +33,13 @@ public class OpsController {
|
|||||||
|
|
||||||
private final OpsService opsService;
|
private final OpsService opsService;
|
||||||
private final FeatureServiceManager featureServiceManager;
|
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.opsService = opsService;
|
||||||
this.featureServiceManager = featureServiceManager;
|
this.featureServiceManager = featureServiceManager;
|
||||||
|
this.riskControlService = riskControlService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/auth/ops/login")
|
@PostMapping("/api/auth/ops/login")
|
||||||
@ -150,4 +159,59 @@ public class OpsController {
|
|||||||
"totalPages", result.getTotalPages()
|
"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();
|
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) {
|
public List<String> parseStoreTargets(String json) {
|
||||||
if (json == null || json.isBlank()) {
|
if (json == null || json.isBlank()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -120,11 +121,27 @@ public class OpsService {
|
|||||||
LocalDateTime todayEnd = todayStart.plusDays(1);
|
LocalDateTime todayEnd = todayStart.plusDays(1);
|
||||||
long todayNew = tenantRepository.countByCreatedAtBetween(todayStart, todayEnd);
|
long todayNew = tenantRepository.countByCreatedAtBetween(todayStart, todayEnd);
|
||||||
long activeApps = appRepository.count();
|
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(
|
return Map.of(
|
||||||
"totalTenants", totalTenants,
|
"totalTenants", totalTenants,
|
||||||
"todayNew", todayNew,
|
"todayNew", todayNew,
|
||||||
"activeApps", activeApps,
|
"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(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
|
||||||
@RequestParam String appId,
|
@RequestParam String appId,
|
||||||
@RequestParam AppVersionEntity.Platform platform,
|
@RequestParam AppVersionEntity.Platform platform,
|
||||||
@RequestParam int currentVersionCode) {
|
@RequestParam int currentVersionCode,
|
||||||
|
@RequestParam(required = false) String userId) {
|
||||||
|
|
||||||
Optional<AppVersionEntity> latest = versionRepository
|
Optional<AppVersionEntity> latest = versionRepository
|
||||||
.findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
|
.findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
|
||||||
@ -63,6 +64,22 @@ public class AppVersionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppVersionEntity v = latest.get();
|
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())
|
String appStoreJumpUrl = hasText(v.getAppStoreUrl())
|
||||||
? v.getAppStoreUrl()
|
? v.getAppStoreUrl()
|
||||||
: appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE);
|
: 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 Logger log = LoggerFactory.getLogger(StoreSubmissionService.class);
|
||||||
private static final ObjectMapper mapper = new ObjectMapper();
|
private static final ObjectMapper mapper = new ObjectMapper();
|
||||||
private static final String HUAWEI_API = "https://connect-api.cloud.huawei.com";
|
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 RestTemplate rest = new RestTemplate();
|
||||||
private final AppVersionRepository versionRepo;
|
private final AppVersionRepository versionRepo;
|
||||||
@ -280,11 +282,131 @@ public class StoreSubmissionService {
|
|||||||
return h;
|
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 {
|
private void submitToHonor(AppVersionEntity v, File file, Map<String, String> creds) throws Exception {
|
||||||
// Honor uses the same Connect API as Huawei — reuse implementation
|
String clientId = require(creds, "clientId", "HONOR");
|
||||||
submitToHuawei(v, file, creds);
|
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 ─────────────────────────────────────────────────────────
|
// ── Xiaomi Market ─────────────────────────────────────────────────────────
|
||||||
@ -686,6 +808,18 @@ public class StoreSubmissionService {
|
|||||||
return HexFormat.of().formatHex(digest.digest());
|
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 {
|
private String asJsonString(Object value) throws Exception {
|
||||||
return mapper.writeValueAsString(value);
|
return mapper.writeValueAsString(value);
|
||||||
}
|
}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户