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>
这个提交包含在:
XuqmGroup 2026-04-25 17:27:06 +08:00
父节点 526f3cf944
当前提交 8011fe591a
共有 6 个文件被更改,包括 220 次插入1 次删除

查看文件

@ -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.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
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(
String appId, String toId, Pageable pageable);
Page<ImMessageEntity> findByAppIdAndFromUserIdAndToIdOrderByCreatedAtDesc(

查看文件

@ -6,7 +6,6 @@ import com.xuqm.im.cluster.ImClusterPublisher;
import com.xuqm.im.entity.ImMessageEntity;
import com.xuqm.im.entity.WebhookConfigEntity;
import com.xuqm.im.model.SendMessageRequest;
import com.xuqm.im.repository.ImMessageRepository;
import com.xuqm.im.repository.WebhookConfigRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
@ -19,6 +18,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDateTime;
import com.xuqm.im.repository.ImMessageRepository;
import java.util.List;
import java.util.UUID;
@ -109,6 +109,10 @@ public class MessageService {
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
protected void dispatchWebhooks(String appId, ImMessageEntity message) {
List<WebhookConfigEntity> webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId);