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:
Codex
2026-09-15 22:45:46 +08:00
parent eaf8fdf195
commit 687a156350
8 changed files with 283 additions and 25 deletions
@@ -110,6 +110,14 @@ public class MessageController {
private String content;
private String fileId;
private String status;
/**
* 发送时间,epoch 毫秒。
*
* <p>对齐小程序 {@code types/index.d.ts} 的 {@code Message.sentAt: number}。
* 前端 {@code pages/chat/chat.ts} 依赖它计算消息时间分隔与展示,
* {@code stores/messageStore.js} 用它回填本地乐观消息,故必须提供。
*/
private Long sentAt;
private java.time.Instant createdAt;
private boolean mine;
private String bubbleType;
@@ -126,6 +134,7 @@ public class MessageController {
r.fileId = m.getFileId();
r.status = m.getStatus();
r.createdAt = m.getCreatedAt();
r.sentAt = m.getCreatedAt() == null ? null : m.getCreatedAt().toEpochMilli();
r.mine = isMine;
r.bubbleType = isMine ? "self" : "other";
return r;
@@ -2,26 +2,47 @@ package com.danyang.match.platform.chat.dto;
import lombok.Data;
import java.time.Instant;
/**
* 会话列表/详情响应
* 会话列表/详情响应。
*
* 字段对齐小程序 types/index.ts 的 Conversation 契约:
* conversationId / matchId / otherUserId / otherDisplayName / otherAvatarUrl
* / lastMessage / lastMessageAt / unreadCount / status / canSendMessage
* <p>契约权威来源:小程序 {@code miniapp/types/index.d.ts} 的 {@code Conversation} 接口
* (该文件被 86 个小程序页面共同引用,且 {@code pages/chat/chat.ts}、{@code components/conversation-item}
* 均按此字段名消费)。历史上后端曾使用 {@code otherUserId/otherDisplayName/otherAvatarUrl}
* 这套字段名,与前端契约不一致,导致聊天页取不到对方 userId(资料页/拉黑/举报失效),
* 本次按"以最近修改时间为准"的裁决统一到前端约定。
*
* <p>字段对应关系:
* <ul>
* <li>{@code targetUserId} ← 原 otherUserId</li>
* <li>{@code targetDisplayName} ← 原 otherDisplayName</li>
* <li>{@code targetAvatarUrl} ← 原 otherAvatarUrl</li>
* <li>{@code relationStatus} ← 替代原 status(会话状态语义收敛为关系状态)</li>
* <li>{@code lastMessageAt} ← 由 Instant 改为 epoch 毫秒,对齐前端 {@code number}</li>
* </ul>
*/
@Data
public class ConversationDTO {
private String conversationId;
private String matchId;
private String otherUserId;
private String otherDisplayName;
private String otherAvatarUrl;
private String targetUserId;
private String targetDisplayName;
/**
* 对方头像 URL。
*
* <p>注意:平台尚未实现头像 URL 解析——{@code user_photos} 只存 {@code fileKey},
* 对外 URL 需经对象存储签名后生成,目前后端无任何接口对外暴露头像。
* 前端 {@code ui-avatar} 对空值回退为昵称首字,故此处保持 null 不影响展示。
*/
private String targetAvatarUrl;
private String lastMessage;
private Instant lastMessageAt;
/** 最后一条消息时间,epoch 毫秒。 */
private Long lastMessageAt;
private int unreadCount;
private String status;
/**
* 与对方的关系状态,取值对齐小程序 RELATION_STATUS:
* neutral / liked / passed / mutual_match / blocked / unmatched。
*/
private String relationStatus;
private boolean canSendMessage;
}
@@ -271,8 +271,8 @@ public class ChatService {
ConversationDTO dto = new ConversationDTO();
dto.setConversationId(conv.getId());
dto.setMatchId(conv.getMatchId());
dto.setStatus(conv.getStatus());
dto.setLastMessageAt(conv.getLastMessageAt());
// 时间统一为 epoch 毫秒,对齐小程序 Conversation.lastMessageAt: number
dto.setLastMessageAt(conv.getLastMessageAt() == null ? null : conv.getLastMessageAt().toEpochMilli());
// 对方
String peerId = null;
@@ -283,10 +283,10 @@ public class ChatService {
matchAlive = Constants.MATCH_STATUS_ACTIVE.equals(match.getStatus())
|| Constants.MATCH_STATUS_GETTING_TO_KNOW.equals(match.getStatus());
}
dto.setOtherUserId(peerId);
dto.setTargetUserId(peerId);
if (peerId != null) {
userProfileMapper.findByUserId(peerId).ifPresent(p -> dto.setOtherDisplayName(p.getDisplayName()));
userProfileMapper.findByUserId(peerId).ifPresent(p -> dto.setTargetDisplayName(p.getDisplayName()));
}
// 未读数
@@ -309,6 +309,20 @@ public class ChatService {
dto.setCanSendMessage(canSend && matchAlive && !blocked
&& Constants.CONVERSATION_STATUS_ACTIVE.equals(conv.getStatus()));
// 关系状态:Block 优先,其次看会话/Match 是否已解除
// 前端 components/conversation-item 依赖 relationStatus==='blocked' 做灰显与"已拉黑"提示
String relationStatus;
if (blocked) {
relationStatus = Constants.RELATION_TYPE_BLOCKED;
} else if (Constants.CONVERSATION_STATUS_UNMATCHED.equals(conv.getStatus())) {
relationStatus = Constants.RELATION_STATUS_UNMATCHED;
} else if (matchAlive) {
relationStatus = Constants.RELATION_STATUS_MUTUAL_MATCH;
} else {
relationStatus = Constants.RELATION_STATUS_NEUTRAL;
}
dto.setRelationStatus(relationStatus);
return dto;
}
}
@@ -27,6 +27,11 @@ public final class Constants {
public static final String RELATION_TYPE_PASSED = "passed";
public static final String RELATION_TYPE_BLOCKED = "blocked";
// 关系状态(对前端的统一表述,对齐小程序 RELATION_STATUS)
public static final String RELATION_STATUS_NEUTRAL = "neutral";
public static final String RELATION_STATUS_MUTUAL_MATCH = "mutual_match";
public static final String RELATION_STATUS_UNMATCHED = "unmatched";
// Match Source
public static final String MATCH_SOURCE_USER_LIKE = "user_like";
public static final String MATCH_SOURCE_ACTIVITY_MUTUAL = "activity_mutual";