fix(chat): 会话/消息响应契约对齐小程序,修复聊天页字段断链
## 问题(三处真实断链)
后端 chat 模块字段名与小程序契约不一致,但自动化测试此前未覆盖,故一直未被发现:
1. 会话 DTO 用 otherUserId/otherDisplayName/otherAvatarUrl,
而前端(types/index.d.ts + pages/chat/chat.ts + components/conversation-item)
只读 targetUserId/targetDisplayName/targetAvatarUrl。
→ chat.ts 取不到对方 userId(无兜底,落到空串),
导致"查看资料 / 拉黑 / 举报"点击后提示"暂无更多信息"并失效。
2. 消息响应只给 createdAt(ISO 字符串),前端读 Message.sentAt: number(epoch 毫秒)。
→ chat.ts 时间分隔 ts 恒为 0,消息时间分隔与时间文案不显示。
3. 会话对象缺 relationStatus,前端 conversation-item 依赖它做灰显与"已拉黑"提示。
## 裁决依据
按"以文件修改时间为准"的裁决统一到前端约定:
- 前端 types/index.d.ts 被 86 个页面引用,是声明式契约
- pages/chat/chat.ts(09-04)是最新的真实消费者
- targetUserId 已是项目内既有惯例(ActivityMatchItemDTO)
- admin-web 不消费 /conversations,无第二方受影响
## 变更
- ConversationDTO:字段改名 target*;新增 relationStatus;lastMessageAt 改 epoch 毫秒
- ChatService.toConversationDTO:同步映射;relationStatus 按 Block > unmatched > mutual_match > neutral 推导
- Constants:新增 RELATION_STATUS_NEUTRAL / MUTUAL_MATCH / UNMATCHED(不硬编码)
- MessageResponse:新增 sentAt(epoch 毫秒)
- miniapp/utils/datetime.js(新增):收敛时间格式化,
补上模板依赖但全仓从未计算的 lastMessageAtText
- messageStore:会话进入 store 时统一装饰 lastMessageAtText
- chat.ts:_formatTime 改为复用共享工具;移除已失效的 otherDisplayName 兜底
## 验证
- 新增 ChatContractJsonTest(4 例)锁定字段名、sentAt 数值类型、
并断言 other*/status 旧字段名不再出现
- 后端 294 tests, 0 failures, 0 errors(原 290 + 新增 4)
- 小程序 tsc --noEmit 通过
遗留(未在本轮处理):平台尚未实现头像 URL 解析(user_photos 只存 fileKey),
targetAvatarUrl 保持 null,前端 ui-avatar 回退昵称首字,不影响展示。
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package com.danyang.match.platform.chat;
|
||||
|
||||
import com.danyang.match.platform.chat.controller.MessageController;
|
||||
import com.danyang.match.platform.chat.dto.ConversationDTO;
|
||||
import com.danyang.match.platform.chat.entity.Message;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 聊天接口 JSON 契约测试。
|
||||
*
|
||||
* <p>契约权威来源:小程序 {@code miniapp/types/index.d.ts}。该文件被 86 个小程序页面引用,
|
||||
* 且 {@code pages/chat/chat.ts}、{@code components/conversation-item} 按其中的字段名直接取值。
|
||||
*
|
||||
* <p>历史缺陷(本测试防回归):
|
||||
* <ol>
|
||||
* <li>后端会话 DTO 曾使用 {@code otherUserId/otherDisplayName/otherAvatarUrl},
|
||||
* 而前端只读 {@code targetUserId/targetDisplayName/targetAvatarUrl},
|
||||
* 导致聊天页取不到对方 userId —— 资料页、拉黑、举报全部失效。</li>
|
||||
* <li>后端消息响应只给 {@code createdAt}(ISO 字符串),而前端读
|
||||
* {@code Message.sentAt: number}(epoch 毫秒),导致聊天页时间分隔恒为 0 而不显示。</li>
|
||||
* <li>会话对象缺少 {@code relationStatus},前端 {@code conversation-item} 无法
|
||||
* 按 {@code relationStatus === 'blocked'} 做灰显与"已拉黑"提示。</li>
|
||||
* </ol>
|
||||
*/
|
||||
class ChatContractJsonTest {
|
||||
|
||||
/** 与 Spring Boot 应用上下文一致的 Jackson 配置(Instant 需要 JSR310 模块) */
|
||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
|
||||
private static final List<String> CONVERSATION_FIELDS = List.of(
|
||||
"conversationId",
|
||||
"matchId",
|
||||
"targetUserId",
|
||||
"targetDisplayName",
|
||||
"targetAvatarUrl",
|
||||
"lastMessage",
|
||||
"lastMessageAt",
|
||||
"unreadCount",
|
||||
"relationStatus",
|
||||
"canSendMessage");
|
||||
|
||||
@Test
|
||||
@DisplayName("契约: 会话响应字段名与小程序 types/index.d.ts 完全一致")
|
||||
void conversationFieldsMatchMiniappContract() throws Exception {
|
||||
ConversationDTO dto = new ConversationDTO();
|
||||
dto.setConversationId("conv-1");
|
||||
dto.setTargetUserId("usr-peer");
|
||||
dto.setTargetDisplayName("王女士");
|
||||
dto.setLastMessage("周末有时间吗?");
|
||||
dto.setLastMessageAt(Instant.parse("2026-09-15T10:00:00Z").toEpochMilli());
|
||||
dto.setUnreadCount(2);
|
||||
dto.setRelationStatus("mutual_match");
|
||||
dto.setCanSendMessage(true);
|
||||
|
||||
JsonNode json = mapper.readTree(mapper.writeValueAsString(dto));
|
||||
|
||||
for (String field : CONVERSATION_FIELDS) {
|
||||
assertTrue(json.has(field), "会话响应缺少前端依赖的字段: " + field);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("契约: 会话响应不得再出现 other* 旧字段名与 status 旧字段")
|
||||
void conversationHasNoLegacyFieldNames() throws Exception {
|
||||
ConversationDTO dto = new ConversationDTO();
|
||||
dto.setConversationId("conv-1");
|
||||
dto.setTargetUserId("usr-peer");
|
||||
dto.setRelationStatus("blocked");
|
||||
|
||||
JsonNode json = mapper.readTree(mapper.writeValueAsString(dto));
|
||||
|
||||
assertFalse(json.has("otherUserId"), "otherUserId 是废弃字段名,前端从不读取");
|
||||
assertFalse(json.has("otherDisplayName"), "otherDisplayName 是废弃字段名");
|
||||
assertFalse(json.has("otherAvatarUrl"), "otherAvatarUrl 是废弃字段名");
|
||||
assertFalse(json.has("status"), "会话 status 已由 relationStatus 取代");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("契约: lastMessageAt 必须是 epoch 毫秒数值而非 ISO 字符串")
|
||||
void lastMessageAtIsEpochMillis() throws Exception {
|
||||
long epochMillis = Instant.parse("2026-09-15T10:00:00Z").toEpochMilli();
|
||||
ConversationDTO dto = new ConversationDTO();
|
||||
dto.setLastMessageAt(epochMillis);
|
||||
|
||||
JsonNode json = mapper.readTree(mapper.writeValueAsString(dto));
|
||||
|
||||
assertTrue(json.get("lastMessageAt").isNumber(),
|
||||
"前端 Conversation.lastMessageAt 声明为 number,后端必须给 epoch 毫秒");
|
||||
assertEquals(epochMillis, json.get("lastMessageAt").asLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("契约: 消息响应必须提供 sentAt(epoch 毫秒),前端据此算时间分隔")
|
||||
void messageResponseProvidesSentAt() throws Exception {
|
||||
Instant created = Instant.parse("2026-09-15T10:00:00Z");
|
||||
Message m = new Message();
|
||||
m.setId("msg-1");
|
||||
m.setConversationId("conv-1");
|
||||
m.setSenderId("usr-peer");
|
||||
m.setMessageType("text");
|
||||
m.setContent("你好");
|
||||
m.setStatus("sent");
|
||||
m.setCreatedAt(created);
|
||||
|
||||
JsonNode json = mapper.readTree(
|
||||
mapper.writeValueAsString(MessageController.MessageResponse.from(m, "usr-me")));
|
||||
|
||||
assertTrue(json.has("sentAt"), "消息响应缺少前端依赖的 sentAt");
|
||||
assertTrue(json.get("sentAt").isNumber(), "sentAt 必须是 epoch 毫秒数值");
|
||||
assertEquals(created.toEpochMilli(), json.get("sentAt").asLong());
|
||||
assertEquals("other", json.get("bubbleType").asText(), "非本人消息 bubbleType 应为 other");
|
||||
assertEquals("msg-1", json.get("messageId").asText());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user