feat(im-service): add friend system and conversations endpoint
- Add im_friends table with bi-directional add/remove (FriendController) - Add GET /api/im/conversations: returns per-conversation latest message summaries using UNION of SINGLE/GROUP queries with correct peer ID computation - Add ConversationSummary projection interface in ImMessageRepository Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
526f3cf944
当前提交
8011fe591a
@ -0,0 +1,33 @@
|
|||||||
|
package com.xuqm.im.controller;
|
||||||
|
|
||||||
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.im.repository.ImMessageRepository;
|
||||||
|
import com.xuqm.im.service.MessageService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/im")
|
||||||
|
public class ConversationController {
|
||||||
|
|
||||||
|
private final MessageService messageService;
|
||||||
|
|
||||||
|
public ConversationController(MessageService messageService) {
|
||||||
|
this.messageService = messageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/conversations")
|
||||||
|
public ResponseEntity<ApiResponse<List<ImMessageRepository.ConversationSummary>>> conversations(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(messageService.conversations(appId, userId, size)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
package com.xuqm.im.controller;
|
||||||
|
|
||||||
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.im.entity.ImFriendEntity;
|
||||||
|
import com.xuqm.im.repository.ImFriendRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/im/friends")
|
||||||
|
public class FriendController {
|
||||||
|
|
||||||
|
private final ImFriendRepository friendRepository;
|
||||||
|
|
||||||
|
public FriendController(ImFriendRepository friendRepository) {
|
||||||
|
this.friendRepository = friendRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<String>>> listFriends(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId) {
|
||||||
|
List<String> friendIds = friendRepository.findByAppIdAndUserId(appId, userId)
|
||||||
|
.stream()
|
||||||
|
.map(ImFriendEntity::getFriendId)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(friendIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<ImFriendEntity>> addFriend(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String appId,
|
||||||
|
@RequestParam String friendId) {
|
||||||
|
// Insert userId -> friendId if not already present
|
||||||
|
ImFriendEntity forward = friendRepository
|
||||||
|
.findByAppIdAndUserIdAndFriendId(appId, userId, friendId)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
ImFriendEntity e = new ImFriendEntity();
|
||||||
|
e.setAppId(appId);
|
||||||
|
e.setUserId(userId);
|
||||||
|
e.setFriendId(friendId);
|
||||||
|
return friendRepository.save(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert friendId -> userId bi-directionally if not already present
|
||||||
|
friendRepository.findByAppIdAndUserIdAndFriendId(appId, friendId, userId)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
ImFriendEntity e = new ImFriendEntity();
|
||||||
|
e.setAppId(appId);
|
||||||
|
e.setUserId(friendId);
|
||||||
|
e.setFriendId(userId);
|
||||||
|
return friendRepository.save(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(forward));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{friendId}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> removeFriend(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String friendId,
|
||||||
|
@RequestParam String appId) {
|
||||||
|
// Remove both directions
|
||||||
|
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, userId, friendId);
|
||||||
|
friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, friendId, userId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
package com.xuqm.im.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.UniqueConstraint;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "im_friends",
|
||||||
|
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "friendId"}))
|
||||||
|
public class ImFriendEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 64)
|
||||||
|
private String appId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 128)
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 128)
|
||||||
|
private String friendId;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long 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 getFriendId() { return friendId; }
|
||||||
|
public void setFriendId(String friendId) { this.friendId = friendId; }
|
||||||
|
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.xuqm.im.repository;
|
||||||
|
|
||||||
|
import com.xuqm.im.entity.ImFriendEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ImFriendRepository extends JpaRepository<ImFriendEntity, Long> {
|
||||||
|
|
||||||
|
List<ImFriendEntity> findByAppIdAndUserId(String appId, String userId);
|
||||||
|
|
||||||
|
Optional<ImFriendEntity> findByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void deleteByAppIdAndUserIdAndFriendId(String appId, String userId, String friendId);
|
||||||
|
}
|
||||||
@ -7,8 +7,44 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface ImMessageRepository extends JpaRepository<ImMessageEntity, String> {
|
public interface ImMessageRepository extends JpaRepository<ImMessageEntity, String> {
|
||||||
|
|
||||||
|
interface ConversationSummary {
|
||||||
|
String getTargetId();
|
||||||
|
String getChatType();
|
||||||
|
LocalDateTime getLastTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
SELECT target_id AS targetId, chat_type AS chatType, last_time AS lastTime
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
CASE WHEN from_user_id = :userId THEN to_id ELSE from_user_id END AS target_id,
|
||||||
|
chat_type,
|
||||||
|
MAX(created_at) AS last_time
|
||||||
|
FROM im_message
|
||||||
|
WHERE app_id = :appId AND chat_type = 'SINGLE'
|
||||||
|
AND (from_user_id = :userId OR to_id = :userId)
|
||||||
|
GROUP BY target_id, chat_type
|
||||||
|
UNION ALL
|
||||||
|
SELECT to_id AS target_id, chat_type, MAX(created_at) AS last_time
|
||||||
|
FROM im_message
|
||||||
|
WHERE app_id = :appId AND chat_type = 'GROUP'
|
||||||
|
AND to_id IN (
|
||||||
|
SELECT id FROM im_group
|
||||||
|
WHERE app_id = :appId AND FIND_IN_SET(:userId, member_ids) > 0
|
||||||
|
)
|
||||||
|
GROUP BY to_id, chat_type
|
||||||
|
) combined
|
||||||
|
ORDER BY last_time DESC
|
||||||
|
LIMIT :size
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<ConversationSummary> findConversations(
|
||||||
|
@Param("appId") String appId,
|
||||||
|
@Param("userId") String userId,
|
||||||
|
@Param("size") int size);
|
||||||
Page<ImMessageEntity> findByAppIdAndToIdOrderByCreatedAtDesc(
|
Page<ImMessageEntity> findByAppIdAndToIdOrderByCreatedAtDesc(
|
||||||
String appId, String toId, Pageable pageable);
|
String appId, String toId, Pageable pageable);
|
||||||
Page<ImMessageEntity> findByAppIdAndFromUserIdAndToIdOrderByCreatedAtDesc(
|
Page<ImMessageEntity> findByAppIdAndFromUserIdAndToIdOrderByCreatedAtDesc(
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import com.xuqm.im.cluster.ImClusterPublisher;
|
|||||||
import com.xuqm.im.entity.ImMessageEntity;
|
import com.xuqm.im.entity.ImMessageEntity;
|
||||||
import com.xuqm.im.entity.WebhookConfigEntity;
|
import com.xuqm.im.entity.WebhookConfigEntity;
|
||||||
import com.xuqm.im.model.SendMessageRequest;
|
import com.xuqm.im.model.SendMessageRequest;
|
||||||
import com.xuqm.im.repository.ImMessageRepository;
|
|
||||||
import com.xuqm.im.repository.WebhookConfigRepository;
|
import com.xuqm.im.repository.WebhookConfigRepository;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@ -19,6 +18,7 @@ import java.net.http.HttpClient;
|
|||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import com.xuqm.im.repository.ImMessageRepository;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -109,6 +109,10 @@ public class MessageService {
|
|||||||
appId, userId, toId, PageRequest.of(page, size));
|
appId, userId, toId, PageRequest.of(page, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<ImMessageRepository.ConversationSummary> conversations(String appId, String userId, int size) {
|
||||||
|
return messageRepository.findConversations(appId, userId, size);
|
||||||
|
}
|
||||||
|
|
||||||
@Async
|
@Async
|
||||||
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
|
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
|
||||||
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户