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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user