diff --git a/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java b/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java
index 957ca84..0d9f3bb 100644
--- a/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java
+++ b/backend/src/main/java/com/danyang/match/platform/chat/controller/MessageController.java
@@ -110,6 +110,14 @@ public class MessageController {
private String content;
private String fileId;
private String status;
+ /**
+ * 发送时间,epoch 毫秒。
+ *
+ *
对齐小程序 {@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;
diff --git a/backend/src/main/java/com/danyang/match/platform/chat/dto/ConversationDTO.java b/backend/src/main/java/com/danyang/match/platform/chat/dto/ConversationDTO.java
index 09ce350..9a3a712 100644
--- a/backend/src/main/java/com/danyang/match/platform/chat/dto/ConversationDTO.java
+++ b/backend/src/main/java/com/danyang/match/platform/chat/dto/ConversationDTO.java
@@ -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
+ *
契约权威来源:小程序 {@code miniapp/types/index.d.ts} 的 {@code Conversation} 接口
+ * (该文件被 86 个小程序页面共同引用,且 {@code pages/chat/chat.ts}、{@code components/conversation-item}
+ * 均按此字段名消费)。历史上后端曾使用 {@code otherUserId/otherDisplayName/otherAvatarUrl}
+ * 这套字段名,与前端契约不一致,导致聊天页取不到对方 userId(资料页/拉黑/举报失效),
+ * 本次按"以最近修改时间为准"的裁决统一到前端约定。
+ *
+ *
字段对应关系:
+ *
+ * - {@code targetUserId} ← 原 otherUserId
+ * - {@code targetDisplayName} ← 原 otherDisplayName
+ * - {@code targetAvatarUrl} ← 原 otherAvatarUrl
+ * - {@code relationStatus} ← 替代原 status(会话状态语义收敛为关系状态)
+ * - {@code lastMessageAt} ← 由 Instant 改为 epoch 毫秒,对齐前端 {@code number}
+ *
*/
@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。
+ *
+ * 注意:平台尚未实现头像 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;
}
diff --git a/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java b/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java
index cbacb84..f3dbf5a 100644
--- a/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java
+++ b/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java
@@ -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;
}
}
diff --git a/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java b/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java
index 9f9d21a..704aadc 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/constant/Constants.java
@@ -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";
diff --git a/backend/src/test/java/com/danyang/match/platform/chat/ChatContractJsonTest.java b/backend/src/test/java/com/danyang/match/platform/chat/ChatContractJsonTest.java
new file mode 100644
index 0000000..1b683a4
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/chat/ChatContractJsonTest.java
@@ -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 契约测试。
+ *
+ *
契约权威来源:小程序 {@code miniapp/types/index.d.ts}。该文件被 86 个小程序页面引用,
+ * 且 {@code pages/chat/chat.ts}、{@code components/conversation-item} 按其中的字段名直接取值。
+ *
+ *
历史缺陷(本测试防回归):
+ *
+ * - 后端会话 DTO 曾使用 {@code otherUserId/otherDisplayName/otherAvatarUrl},
+ * 而前端只读 {@code targetUserId/targetDisplayName/targetAvatarUrl},
+ * 导致聊天页取不到对方 userId —— 资料页、拉黑、举报全部失效。
+ * - 后端消息响应只给 {@code createdAt}(ISO 字符串),而前端读
+ * {@code Message.sentAt: number}(epoch 毫秒),导致聊天页时间分隔恒为 0 而不显示。
+ * - 会话对象缺少 {@code relationStatus},前端 {@code conversation-item} 无法
+ * 按 {@code relationStatus === 'blocked'} 做灰显与"已拉黑"提示。
+ *
+ */
+class ChatContractJsonTest {
+
+ /** 与 Spring Boot 应用上下文一致的 Jackson 配置(Instant 需要 JSR310 模块) */
+ private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
+
+ private static final List 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());
+ }
+}
diff --git a/miniapp/pages/chat/chat.ts b/miniapp/pages/chat/chat.ts
index cb623ea..aa2d791 100644
--- a/miniapp/pages/chat/chat.ts
+++ b/miniapp/pages/chat/chat.ts
@@ -12,6 +12,8 @@ import { messageStore, appStore, blockUser } from '../../stores';
import { messageService } from '../../services';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { to, ROUTE, DANGER_ACTION, BUSINESS_ERROR_CODE } from '../../constants';
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import { formatMessageTime } from '../../utils/datetime';
import type { Message } from '../../types';
/** 超过该间隔则显示时间分隔(5 分钟) */
@@ -105,7 +107,7 @@ Page({
const conv = await messageService.getConversation(this.data.conversationId);
if (conv) {
this.setData({
- otherDisplayName: (conv as any).targetDisplayName || (conv as any).otherDisplayName || '用户',
+ otherDisplayName: (conv as any).targetDisplayName || '用户',
otherUserId: (conv as any).targetUserId || '',
otherAvatarUrl: (conv as any).targetAvatarUrl || '',
canSend: (conv as any).canSendMessage !== false,
@@ -169,12 +171,8 @@ Page({
},
_formatTime(ts: number | string): string {
- if (!ts) return '';
- const d = new Date(Number(ts));
- const now = new Date();
- const hm = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
- if (d.toDateString() === now.toDateString()) return hm;
- return `${d.getMonth() + 1}/${d.getDate()} ${hm}`;
+ // 复用 utils/datetime,避免与会话列表各写一份时间格式化
+ return formatMessageTime(ts as any);
},
_scrollToLast(list: typeof this.data.messages) {
diff --git a/miniapp/stores/messageStore.js b/miniapp/stores/messageStore.js
index 9f1db0e..c7ece2b 100644
--- a/miniapp/stores/messageStore.js
+++ b/miniapp/stores/messageStore.js
@@ -18,6 +18,7 @@
*/
const BaseStore = require('./baseStore');
const { messageService } = require('../services');
+const { formatConversationTime } = require('../utils/datetime');
const { STORAGE_KEYS, MESSAGE_SEND_STATUS, CHAT_CONNECTION } = require('../constants');
/** 单会话内存缓存上限(避免无限增长) */
@@ -66,7 +67,7 @@ class MessageStore extends BaseStore {
try {
const result = await messageService.getConversations({ pageSize: 20 });
this.setState({
- conversations: result.items || [],
+ conversations: this._decorateConversations(result.items),
conversationsCursor: result.nextCursor || null,
conversationsHasMore: !!result.nextCursor,
conversationsLoading: false,
@@ -89,7 +90,7 @@ class MessageStore extends BaseStore {
pageSize: 20,
});
this.setState({
- conversations: [...this.state.conversations, ...(result.items || [])],
+ conversations: [...this.state.conversations, ...this._decorateConversations(result.items)],
conversationsCursor: result.nextCursor || null,
conversationsHasMore: !!result.nextCursor,
conversationsLoading: false,
@@ -371,11 +372,30 @@ class MessageStore extends BaseStore {
});
}
+ /**
+ * 为会话补上模板所需的展示字段。
+ *
+ * components/conversation-item/index.wxml 读取 `lastMessageAtText`,
+ * 该字段由服务端不产出(后端只给 epoch 毫秒的 lastMessageAt),
+ * 故在进入 store 时统一格式化一次,避免每个页面各自实现。
+ */
+ _decorateConversations(items) {
+ return (items || []).map((c) => ({
+ ...c,
+ lastMessageAtText: formatConversationTime(c.lastMessageAt),
+ }));
+ }
+
_patchConversationLastMessage(conversationId, content, sentAt) {
this.setState({
conversations: this.state.conversations.map((c) =>
c.conversationId === conversationId
- ? { ...c, lastMessage: content, lastMessageAt: sentAt }
+ ? {
+ ...c,
+ lastMessage: content,
+ lastMessageAt: sentAt,
+ lastMessageAtText: formatConversationTime(sentAt),
+ }
: c,
),
});
diff --git a/miniapp/utils/datetime.js b/miniapp/utils/datetime.js
new file mode 100644
index 0000000..8776cff
--- /dev/null
+++ b/miniapp/utils/datetime.js
@@ -0,0 +1,68 @@
+/**
+ * 时间格式化工具
+ *
+ * 背景:会话列表模板(components/conversation-item/index.wxml)读取
+ * `conversation.lastMessageAtText` 做展示,但此前全仓无任何地方计算该字段,
+ * 导致会话列表时间恒为空;聊天页 pages/chat/chat.ts 又自带一份 _formatTime。
+ * 本模块把这两处时间格式化收敛到一处,避免重复实现。
+ *
+ * 约定:后端时间字段一律为 epoch 毫秒(number),与
+ * types/index.d.ts 的 `lastMessageAt?: number` / `Message.sentAt: number` 对齐。
+ */
+
+/** 补零 */
+function pad2(n) {
+ return n < 10 ? '0' + n : String(n);
+}
+
+/** 归一化为 Date,非法输入返回 null */
+function toDate(ts) {
+ if (ts === null || ts === undefined || ts === '') return null;
+ var d = ts instanceof Date ? ts : new Date(Number(ts));
+ return isNaN(d.getTime()) ? null : d;
+}
+
+/** 同一天 */
+function isSameDay(a, b) {
+ return a.getFullYear() === b.getFullYear()
+ && a.getMonth() === b.getMonth()
+ && a.getDate() === b.getDate();
+}
+
+/**
+ * 会话列表时间:刚刚 / HH:mm / 昨天 / M/D / YYYY/M/D
+ * @param {number|Date} ts
+ * @returns {string}
+ */
+function formatConversationTime(ts) {
+ var d = toDate(ts);
+ if (!d) return '';
+ var now = new Date();
+ var diff = now.getTime() - d.getTime();
+ if (diff >= 0 && diff < 60 * 1000) return '刚刚';
+ if (isSameDay(d, now)) return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
+ var yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
+ if (isSameDay(d, yesterday)) return '昨天';
+ if (d.getFullYear() === now.getFullYear()) return (d.getMonth() + 1) + '/' + d.getDate();
+ return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate();
+}
+
+/**
+ * 聊天气泡时间:今天 HH:mm(省略日期),否则 M/D HH:mm
+ * 与 pages/chat/chat.ts 原有逻辑保持一致。
+ * @param {number|Date} ts
+ * @returns {string}
+ */
+function formatMessageTime(ts) {
+ var d = toDate(ts);
+ if (!d) return '';
+ var now = new Date();
+ var hm = pad2(d.getHours()) + ':' + pad2(d.getMinutes());
+ if (isSameDay(d, now)) return hm;
+ return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + hm;
+}
+
+module.exports = {
+ formatConversationTime: formatConversationTime,
+ formatMessageTime: formatMessageTime,
+};