refactor(backend): P0 阻断修复 + 按最新修改时间收敛重复模块与契约

## P0 阻断修复
- 编译:修复实体类与服务类同名导致 Lombok 注解轮次中止的问题
  (MatchmakerService/CounselingService -> *Record),196 个编译错误归零
- 权限矩阵:platform_admin 按设计文档收紧为拒绝 5 项高危权限(不改测试阈值)
- 启动:修复 8 组 MyBatis-Plus 同名 bean 冲突(ConflictingBeanDefinition)
  与 URL 路由歧义(Ambiguous mapping),应用可正常启动

## 模块收敛(裁决规则:以文件修改时间为准)
- messaging 包整体删除,能力迁移至 chat 包
  * 会话列表/详情/未读端点、ConversationDTO、CursorPage
  * 消息游标分页统一为 { items, nextCursor }
- relationship 的 Match/UserRelation entity 与 mapper 合并进 match 包
- MatchmakerUserController 合并进 MatchmakerController,删除冗余 Controller
- 补齐 INV-004:chat sendText 增加双向 Block 校验,拉黑后禁止发送

## 契约对齐
- 红娘服务提交请求兼容前端 { requirement } 字段
- 消息与会话分页契约前后端统一

## 测试
- 迁移 6 个风控用例至 ChatSafetyServiceTest
- 清理 messaging 测试包遗留:6 个真实常量断言归位 chat 包,
  移除 2 个 assertTrue(true) 占位假测试(Block 真实行为已由
  P0FixVerificationTest / RelationshipBlockPriorityPhase08Test 覆盖)
- schema-h2.sql 对齐 PostgreSQL 真实表结构(matches 补列、
  conversation_members 改复合主键)

结果:290 tests, 0 failures, 0 errors, 3 skipped, BUILD SUCCESS
This commit is contained in:
Codex
2026-09-15 22:29:26 +08:00
parent 9d73087eb0
commit eaf8fdf195
67 changed files with 1136 additions and 1743 deletions
@@ -0,0 +1,78 @@
package com.danyang.match.platform.chat;
import com.danyang.match.platform.shared.constant.Constants;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 聊天/消息契约常量测试。
*
* <p>由原 {@code messaging} 包的 {@code MessagingServiceTest} 与
* {@code MessagingServiceBlockTest} 合并而来。原包主代码已按"最新修改时间为准"的裁决
* 删除、能力迁移至 {@code chat} 包,本测试随之归位到存活的 {@code chat} 包。
*
* <p>说明:原两个文件中的 {@code assertTrue(true)} 占位用例已移除——它们不校验任何行为,
* 只会虚增用例数并给出虚假信心。Block 的真实行为由集成测试
* {@code P0FixVerificationTest#testBlockPreventsChat} 与
* {@code RelationshipBlockPriorityPhase08Test} 覆盖。
*/
class ChatContractConstantsTest {
// ==================== 消息类型 ====================
@Test
@DisplayName("契约: 消息类型常量")
void testMessageTypeConstants() {
assertEquals("text", Constants.MESSAGE_TYPE_TEXT);
assertEquals("image", Constants.MESSAGE_TYPE_IMAGE);
assertEquals("system", Constants.MESSAGE_TYPE_SYSTEM);
assertEquals("risk_notice", Constants.MESSAGE_TYPE_RISK_NOTICE);
}
// ==================== 消息状态 ====================
@Test
@DisplayName("契约: 消息状态常量")
void testMessageStatusConstants() {
assertEquals("sent", Constants.MESSAGE_STATUS_SENT);
assertEquals("delivered", Constants.MESSAGE_STATUS_DELIVERED);
assertEquals("failed", Constants.MESSAGE_STATUS_FAILED);
}
// ==================== 会话状态 ====================
@Test
@DisplayName("契约: 会话状态常量")
void testConversationStatusConstants() {
assertEquals("active", Constants.CONVERSATION_STATUS_ACTIVE);
assertEquals("unmatched", Constants.CONVERSATION_STATUS_UNMATCHED);
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
}
// ==================== INV-003: Block 优先级最高 ====================
@Test
@DisplayName("INV-003: Block 关系与 Blocked 会话状态常量存在")
void testBlockConstants() {
assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED);
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
}
@Test
@DisplayName("INV-003: Block 禁止新消息,但不删除历史消息")
void testBlockedConversationKeepsHistory() {
// 不变量:Block 禁止新建消息,但历史消息保留(会话标记 blocked 而非物理删除)
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
assertEquals("active", Constants.CONVERSATION_STATUS_ACTIVE);
}
@Test
@DisplayName("INV-003: Block 覆盖 Mutual Match(Block 优先级最高)")
void testBlockOverridesMutualMatch() {
// 即使 Match.status=active,若任一方 Block 另一方,发消息仍被拒绝
assertEquals("active", Constants.MATCH_STATUS_ACTIVE);
assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED);
}
}
@@ -0,0 +1,77 @@
package com.danyang.match.platform.chat.service;
import com.danyang.match.platform.chat.service.ChatSafetyService.ScanResult;
import com.danyang.match.platform.shared.constant.Constants;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* 聊天内容风控测试
*
* 由原 messaging/MessageRiskServiceTest 迁移而来:
* messaging 模块(V1.0 遗留)已按"最近修改时间优先"原则清理,
* 风控能力由 Phase 10 的 ChatSafetyService 承接,测试随之迁移,避免覆盖丢失。
*/
class ChatSafetyServiceTest {
/** scanContent 不依赖 Redis,传 null 安全 */
private final ChatSafetyService safetyService = new ChatSafetyService(null);
@Test
@DisplayName("正常内容不拦截")
void testNormalContent() {
ScanResult r1 = safetyService.scanContent("你好,很高兴认识你");
assertEquals(Constants.RISK_LEVEL_NORMAL, r1.riskLevel);
assertFalse(r1.blocked);
ScanResult r2 = safetyService.scanContent("周末有空一起喝咖啡吗?");
assertEquals(Constants.RISK_LEVEL_NORMAL, r2.riskLevel);
assertFalse(r2.blocked);
}
@Test
@DisplayName("高风险关键词命中即拦截")
void testHighRiskKeywords() {
ScanResult r1 = safetyService.scanContent("加我微信转账,银行卡号1234567890");
assertEquals(Constants.RISK_LEVEL_HIGH, r1.riskLevel);
assertTrue(r1.blocked);
ScanResult r2 = safetyService.scanContent("我这边有投资理财,高收益,稳赚不赔");
assertEquals(Constants.RISK_LEVEL_HIGH, r2.riskLevel);
assertTrue(r2.blocked);
}
@Test
@DisplayName("联系方式正则命中为 warning,不阻断但标记")
void testContactPatternWarning() {
ScanResult r = safetyService.scanContent("我的电话是13812345678,方便联系");
assertEquals(Constants.RISK_LEVEL_WARNING, r.riskLevel);
assertFalse(r.blocked);
assertNotNull(r.reason);
}
@Test
@DisplayName("空内容视为无风险")
void testEmptyContent() {
assertFalse(safetyService.scanContent("").blocked);
assertFalse(safetyService.scanContent(null).blocked);
}
@Test
@DisplayName("风险提示文案生成")
void testRiskNoticeGeneration() {
String highNotice = ChatSafetyService.buildRiskNoticeBody("检测到可能的诈骗/违规内容");
assertNotNull(highNotice);
assertFalse(highNotice.isBlank());
}
@Test
@DisplayName("风险等级到消息状态的映射")
void testRiskLevelToStatus() {
assertEquals("allowed", ChatSafetyService.riskLevelToStatus(Constants.RISK_LEVEL_NORMAL));
assertEquals("allowed_marked", ChatSafetyService.riskLevelToStatus(Constants.RISK_LEVEL_WARNING));
assertEquals("blocked_send", ChatSafetyService.riskLevelToStatus(Constants.RISK_LEVEL_HIGH));
}
}
@@ -1,7 +1,7 @@
package com.danyang.match.platform.counseling;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.counseling.entity.CounselingService;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.counseling.service.CounselingServiceApp;
@@ -45,7 +45,7 @@ class CounselingDataScopeTest {
@Test
void counselorCannotScheduleServiceAssignedToAnotherCounselor() {
CounselingService counseling = assignedService("service-1", "counselor-a");
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
assertThrows(AccessDeniedException.class, () -> service.scheduleCounseling(
@@ -59,7 +59,7 @@ class CounselingDataScopeTest {
@Test
void assignedCounselorCanScheduleOwnService() {
CounselingService counseling = assignedService("service-1", "counselor-a");
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
when(counselingMapper.selectById("service-1")).thenReturn(counseling);
var result = service.scheduleCounseling("service-1", new OperatorPrincipal("counselor-a", java.util.Set.of("counseling.service.process")),
@@ -71,7 +71,7 @@ class CounselingDataScopeTest {
@Test
void assignedListDoesNotExposeSensitiveFollowupContentByDefault() {
CounselingService counseling = assignedService("service-1", "counselor-a");
CounselingServiceRecord counseling = assignedService("service-1", "counselor-a");
when(counselingMapper.findByAssignedOperator("counselor-a")).thenReturn(List.of(counseling));
var result = service.getCounselorAssignedServices("counselor-a");
@@ -81,8 +81,8 @@ class CounselingDataScopeTest {
verifyNoInteractions(followupMapper);
}
private CounselingService assignedService(String id, String operatorId) {
CounselingService service = new CounselingService();
private CounselingServiceRecord assignedService(String id, String operatorId) {
CounselingServiceRecord service = new CounselingServiceRecord();
service.setId(id);
service.setUserId("user-1");
service.setAssignedOperatorId(operatorId);
@@ -11,11 +11,12 @@ import static org.junit.jupiter.api.Assertions.*;
/**
* P25-T02: Migration 最终验证
* 验证所有 V001-V033 迁移文件存在、命名规范、语法正确
* 验证所有 V001-V035 迁移文件存在、命名规范、语法正确
*/
class MigrationValidationTest {
private static final int LATEST_MIGRATION = 33;
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
private static final int LATEST_MIGRATION = 35;
private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/";
@@ -2,18 +2,18 @@ package com.danyang.match.platform.invariants;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
import com.danyang.match.platform.messaging.mapper.ConversationMemberMapper;
import com.danyang.match.platform.messaging.mapper.MessageMapper;
import com.danyang.match.platform.chat.mapper.ConversationMapper;
import com.danyang.match.platform.chat.mapper.ConversationMemberMapper;
import com.danyang.match.platform.chat.mapper.MessageMapper;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.messaging.service.MessagingService;
import com.danyang.match.platform.relationship.entity.Match;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.match.entity.Match;
import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest;
import com.danyang.match.platform.relationship.entity.UserRelation;
import com.danyang.match.platform.relationship.mapper.MatchMapper;
import com.danyang.match.platform.match.entity.UserRelation;
import com.danyang.match.platform.match.mapper.MatchMapper;
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.relationship.service.RelationshipStatusService;
import com.danyang.match.platform.shared.constant.Constants;
@@ -44,7 +44,7 @@ class P0FixVerificationTest {
@Autowired private MessageMapper messageMapper;
@Autowired private RelationshipSuccessRequestMapper successRequestMapper;
@Autowired private ActivityRegistrationMapper registrationMapper;
@Autowired private MessagingService messagingService;
@Autowired private ChatService chatService;
@Autowired private RelationshipService relationshipService;
@Autowired private RelationshipStatusService statusService;
@Autowired private AuditLogMapper auditLogMapper;
@@ -104,7 +104,7 @@ class P0FixVerificationTest {
assertTrue(relationMapper.findByRelation(userA, userB, Constants.RELATION_TYPE_BLOCKED).isPresent());
assertThrows(RuntimeException.class,
() -> messagingService.sendMessage(userB, conversationId, "测试消息", "blocked-message"));
() -> chatService.sendText(conversationId, userB, "测试消息", "blocked-message"));
}
@Test
@@ -207,8 +207,8 @@ class P0FixVerificationTest {
// 3. 验证Conversation已自动创建
var convs = conversationMapper.selectList(
new LambdaQueryWrapper<com.danyang.match.platform.messaging.entity.Conversation>()
.eq(com.danyang.match.platform.messaging.entity.Conversation::getMatchId, match.getId()));
new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>()
.eq(com.danyang.match.platform.chat.entity.Conversation::getMatchId, match.getId()));
assertFalse(convs.isEmpty(), "Match后应自动创建Conversation");
}
@@ -40,10 +40,17 @@ class PermissionMatrixTest {
superAdmin.put("data.export", true);
PERMISSION_MATRIX.put("super_admin", superAdmin);
// platform_admin: 大部分权限,无permission.manage
// platform_admin: 平台日常运营管理员(设计文档第十二章)
// 可管理:用户 / 审核 / 红娘 / 活动 / 风控 / 统计
// 遵循最小权限,以下5项拒绝:
// user.sensitive.view — 第十二章:完整身份证查看"仍应单独授权",不随角色捆绑
// risk.user.ban — 第六十九章:永久封禁属严重高风险,至少由更高权限角色操作
// organization.manage — 第十一章:组织管理属 super_admin
// permission.manage — 第十一章:权限管理属 super_admin
// data.export — 第一百零三章:导出须单独授权,"不要能看列表就能导出"
Map<String, Boolean> platformAdmin = new HashMap<>();
platformAdmin.put("user.view", true);
platformAdmin.put("user.sensitive.view", true);
platformAdmin.put("user.sensitive.view", false);
platformAdmin.put("user.status.change", true);
platformAdmin.put("review.view", true);
platformAdmin.put("review.process", true);
@@ -55,11 +62,11 @@ class PermissionMatrixTest {
platformAdmin.put("activity.review_registration", true);
platformAdmin.put("risk.report.view", true);
platformAdmin.put("risk.report.process", true);
platformAdmin.put("risk.user.ban", true);
platformAdmin.put("organization.manage", true);
platformAdmin.put("permission.manage", false); // 不能管理权限
platformAdmin.put("risk.user.ban", false);
platformAdmin.put("organization.manage", false);
platformAdmin.put("permission.manage", false);
platformAdmin.put("analytics.view", true);
platformAdmin.put("data.export", true);
platformAdmin.put("data.export", false);
PERMISSION_MATRIX.put("platform_admin", platformAdmin);
// organization_admin: 本机构范围内
@@ -9,8 +9,8 @@ import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.relationship.entity.UserRelation;
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
import com.danyang.match.platform.match.entity.UserRelation;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
@@ -1,59 +0,0 @@
package com.danyang.match.platform.messaging.safety;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MessageRiskServiceTest {
private final MessageRiskService riskService = new MessageRiskService(null);
@Test
void testNormalContent() {
assertNull(riskService.detectRiskLevel("你好,很高兴认识你"));
assertNull(riskService.detectRiskLevel("周末有空一起喝咖啡吗?"));
}
@Test
void testHighRiskKeywords() {
// 2个以上高风险关键词 → high
assertEquals("high", riskService.detectRiskLevel("我这边有个投资渠道,年化收益30%,稳赚不赔"));
assertEquals("high", riskService.detectRiskLevel("加我微信转账,银行卡号1234567890"));
}
@Test
void testWarningKeywords() {
// 1个高风险关键词 → warning
assertEquals("warning", riskService.detectRiskLevel("可以加你微信吗?"));
assertEquals("warning", riskService.detectRiskLevel("我这边有投资机会"));
}
@Test
void testMediumRiskAggregation() {
// 3个以上中等风险关键词 → warning
String content = "加微信加QQ加群";
assertEquals("warning", riskService.detectRiskLevel(content));
}
@Test
void testEmptyContent() {
assertNull(riskService.detectRiskLevel(""));
assertNull(riskService.detectRiskLevel(null));
}
@Test
void testRiskNoticeGeneration() {
String highNotice = riskService.generateRiskNotice("high", java.util.List.of("投资", "稳赚"));
assertNotNull(highNotice);
assertTrue(highNotice.contains("高风险"));
String warnNotice = riskService.generateRiskNotice("warning", java.util.List.of("转账"));
assertNotNull(warnNotice);
assertTrue(warnNotice.contains("谨慎"));
}
@Test
void testNullRiskLevel() {
assertNull(riskService.generateRiskNotice(null, java.util.List.of()));
}
}
@@ -1,44 +0,0 @@
package com.danyang.match.platform.messaging;
import com.danyang.match.platform.shared.constant.Constants;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* P0-1 验证:Block 后聊天阻断
*
* 不变量(INV-003):Block优先级最高 — 拉黑后聊天停止、推荐排除、搜索不可见
*/
class MessagingServiceBlockTest {
@Test
@DisplayName("INV-003: Block状态常量存在")
void testBlockConstants() {
assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED);
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
}
@Test
@DisplayName("INV-003: Blocked Conversation状态阻止发消息")
void testBlockedConversationPreventsChat() {
// 实现说明:MessagingService.getConversation() 中检查双向Block关系,
// 任意一方Block对方则canSend=false,isBlocked()返回true
assertTrue(true); // 占位,完整集成测试需DB
}
@Test
@DisplayName("INV-003: 已有聊天在Block后仍可读取历史记录")
void testBlockedButCanReadHistory() {
// 不变量:Block禁止新建消息,但不删除历史消息
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
assertEquals("active", Constants.CONVERSATION_STATUS_ACTIVE);
}
@Test
@DisplayName("INV-003: Block覆盖Mutual Match(Block优先级最高)")
void testBlockOverridesMutualMatch() {
// 即使Match.status=active,若任一方Block另一方,发消息仍被拒绝
assertEquals("active", Constants.MATCH_STATUS_ACTIVE);
assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED);
}
}
@@ -1,38 +0,0 @@
package com.danyang.match.platform.messaging;
import com.danyang.match.platform.shared.constant.Constants;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MessagingServiceTest {
@Test
void testMessageTypeConstants() {
assertEquals("text", Constants.MESSAGE_TYPE_TEXT);
assertEquals("image", Constants.MESSAGE_TYPE_IMAGE);
assertEquals("system", Constants.MESSAGE_TYPE_SYSTEM);
assertEquals("risk_notice", Constants.MESSAGE_TYPE_RISK_NOTICE);
}
@Test
void testMessageStatusConstants() {
assertEquals("sent", Constants.MESSAGE_STATUS_SENT);
assertEquals("delivered", Constants.MESSAGE_STATUS_DELIVERED);
assertEquals("failed", Constants.MESSAGE_STATUS_FAILED);
}
@Test
void testConversationStatusConstants() {
assertEquals("active", Constants.CONVERSATION_STATUS_ACTIVE);
assertEquals("unmatched", Constants.CONVERSATION_STATUS_UNMATCHED);
assertEquals("blocked", Constants.CONVERSATION_STATUS_BLOCKED);
}
@Test
void testNoStrangerChat() {
// Core invariant: 未Mutual不可聊天
// 这由Conversation创建时绑定到Match来保证
assertTrue(true); // 占位,完整测试需数据库连接
}
}
@@ -2,11 +2,11 @@ package com.danyang.match.platform.relationship;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.messaging.service.MessagingService;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.relationship.entity.UserRelation;
import com.danyang.match.platform.relationship.mapper.MatchMapper;
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
import com.danyang.match.platform.match.entity.UserRelation;
import com.danyang.match.platform.match.mapper.MatchMapper;
import com.danyang.match.platform.match.mapper.UserRelationMapper;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.mapper.UserMapper;
@@ -27,7 +27,7 @@ class RelationshipBlockPriorityPhase08Test {
relationMapper = mock(UserRelationMapper.class);
service = new RelationshipService(relationMapper, mock(MatchMapper.class),
mock(UserProfileMapper.class), mock(RecommendationRecordMapper.class),
mock(AuditLogMapper.class), mock(MessagingService.class), mock(UserMapper.class));
mock(AuditLogMapper.class), mock(ChatService.class), mock(UserMapper.class));
}
@Test
+6 -4
View File
@@ -14,6 +14,7 @@ CREATE TABLE user_relations (
);
CREATE TABLE matches (
id VARCHAR(36) PRIMARY KEY, user_a_id VARCHAR(36), user_b_id VARCHAR(36), status VARCHAR(32), source VARCHAR(32),
source_id VARCHAR(64), last_activity_at TIMESTAMP WITH TIME ZONE, unmatched_by VARCHAR(36), episode INT,
matched_at TIMESTAMP WITH TIME ZONE, unmatched_at TIMESTAMP WITH TIME ZONE, successful_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
);
@@ -27,11 +28,12 @@ CREATE TABLE conversations (
last_message_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
);
-- 复合主键 (conversation_id, user_id),与 V004/V008 真实表结构一致,无 id 列
CREATE TABLE conversation_members (
id VARCHAR(36) PRIMARY KEY, conversation_id VARCHAR(36), user_id VARCHAR(36), last_read_message_id VARCHAR(36),
unread_count INT, can_send BOOLEAN, created_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
UNIQUE (conversation_id, user_id)
conversation_id VARCHAR(36), user_id VARCHAR(36), last_read_message_id VARCHAR(36),
unread_count INT, can_send BOOLEAN, joined_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
PRIMARY KEY (conversation_id, user_id)
);
CREATE TABLE messages (
id VARCHAR(36) PRIMARY KEY, conversation_id VARCHAR(36), sender_id VARCHAR(36), client_message_id VARCHAR(128),