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
+23 -3
View File
@@ -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,
),
});