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(资料页/拉黑/举报失效), + * 本次按"以最近修改时间为准"的裁决统一到前端约定。 + * + *

字段对应关系: + *

*/ @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} 按其中的字段名直接取值。 + * + *

历史缺陷(本测试防回归): + *

    + *
  1. 后端会话 DTO 曾使用 {@code otherUserId/otherDisplayName/otherAvatarUrl}, + * 而前端只读 {@code targetUserId/targetDisplayName/targetAvatarUrl}, + * 导致聊天页取不到对方 userId —— 资料页、拉黑、举报全部失效。
  2. + *
  3. 后端消息响应只给 {@code createdAt}(ISO 字符串),而前端读 + * {@code Message.sentAt: number}(epoch 毫秒),导致聊天页时间分隔恒为 0 而不显示。
  4. + *
  5. 会话对象缺少 {@code relationStatus},前端 {@code conversation-item} 无法 + * 按 {@code relationStatus === 'blocked'} 做灰显与"已拉黑"提示。
  6. + *
+ */ +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, +};