## 问题(三处真实断链)
后端 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 回退昵称首字,不影响展示。
281 lines
9.2 KiB
TypeScript
281 lines
9.2 KiB
TypeScript
/**
|
||
* 聊天详情页 U24(TypeScript)
|
||
*
|
||
* 不变量(AGENTS.md):
|
||
* - 第 3 条:Mutual Match 后才能聊天
|
||
* - 第 4 条:Block/Unmatch 后会话 canSendMessage=false
|
||
* - 第 6 条:单向活动意向不泄露
|
||
*/
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
import { messageStore, appStore, blockUser } from '../../stores';
|
||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||
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 分钟) */
|
||
const TIME_GAP_MS = 5 * 60 * 1000;
|
||
|
||
interface ChatData {
|
||
conversationId: string;
|
||
otherDisplayName: string;
|
||
otherUserId: string;
|
||
otherAvatarUrl: string;
|
||
canSend: boolean;
|
||
messages: Array<Message & { showTime: boolean; sentAtText: string; showAvatar: boolean; avatarUrl: string }>;
|
||
hasMore: boolean;
|
||
loading: boolean;
|
||
loadingMore: boolean;
|
||
sending: boolean;
|
||
inputText: string;
|
||
scrollIntoView: string;
|
||
statusBarHeight: number;
|
||
navBarHeight: number;
|
||
showDangerDialog: boolean;
|
||
dangerAction: string;
|
||
connectionState: string;
|
||
}
|
||
|
||
Page({
|
||
data: {
|
||
conversationId: '',
|
||
otherDisplayName: '',
|
||
otherUserId: '',
|
||
otherAvatarUrl: '',
|
||
canSend: true,
|
||
messages: [],
|
||
hasMore: true,
|
||
loading: false,
|
||
loadingMore: false,
|
||
sending: false,
|
||
inputText: '',
|
||
scrollIntoView: '',
|
||
statusBarHeight: 20,
|
||
navBarHeight: 44,
|
||
showDangerDialog: false,
|
||
dangerAction: DANGER_ACTION.BLOCK,
|
||
connectionState: 'disconnected',
|
||
},
|
||
|
||
onLoad(options: Record<string, string | undefined>) {
|
||
const conversationId = options.id || '';
|
||
if (!conversationId) {
|
||
appStore.toast('会话不存在');
|
||
wx.navigateBack();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const info = (wx.getWindowInfo && wx.getWindowInfo()) || wx.getSystemInfoSync();
|
||
this.setData({
|
||
statusBarHeight: (info as any).statusBarHeight || 20,
|
||
navBarHeight: (info as any).platform === 'android' ? 48 : 44,
|
||
});
|
||
} catch { /* 默认值 */ }
|
||
|
||
this.setData({ conversationId });
|
||
|
||
this._unsubConn = messageStore.bindToPage(this, { connectionState: 'connectionState' });
|
||
this._unsubList = messageStore.subscribe((state: any) => this._syncFromStore(state));
|
||
this._unsubRealtime = messageStore.onMessage(conversationId, (msg: Message) => {
|
||
if (msg.bubbleType === 'other') {
|
||
messageStore.markConversationRead(conversationId).catch(() => {});
|
||
}
|
||
});
|
||
|
||
messageStore.openConversation(conversationId).catch((err: unknown) => {
|
||
appStore.toastError(err instanceof Error ? err.message : '加载失败');
|
||
});
|
||
this.loadConversationMeta();
|
||
},
|
||
|
||
onUnload() {
|
||
this._unsubConn && this._unsubConn();
|
||
this._unsubList && this._unsubList();
|
||
this._unsubRealtime && this._unsubRealtime();
|
||
if (this.data.conversationId) {
|
||
messageStore.closeConversation();
|
||
}
|
||
},
|
||
|
||
async loadConversationMeta() {
|
||
try {
|
||
// @ts-ignore
|
||
const conv = await messageService.getConversation(this.data.conversationId);
|
||
if (conv) {
|
||
this.setData({
|
||
otherDisplayName: (conv as any).targetDisplayName || '用户',
|
||
otherUserId: (conv as any).targetUserId || '',
|
||
otherAvatarUrl: (conv as any).targetAvatarUrl || '',
|
||
canSend: (conv as any).canSendMessage !== false,
|
||
});
|
||
}
|
||
} catch (err: unknown) {
|
||
const local = (messageStore.state as any).conversations?.find(
|
||
(c: any) => c.conversationId === this.data.conversationId,
|
||
);
|
||
if (local) {
|
||
this.setData({
|
||
otherDisplayName: local.targetDisplayName || '用户',
|
||
otherUserId: local.targetUserId || '',
|
||
otherAvatarUrl: local.targetAvatarUrl || '',
|
||
canSend: local.canSendMessage !== false,
|
||
});
|
||
} else {
|
||
appStore.toastError(err instanceof Error ? err.message : '加载失败');
|
||
}
|
||
}
|
||
},
|
||
|
||
_syncFromStore(state: any) {
|
||
const cid = this.data.conversationId;
|
||
const rawList: Message[] = state.messagesByConversation?.[cid] || [];
|
||
const hasMore = state.hasMoreByConversation?.[cid] !== false;
|
||
const loading = state.conversationsLoading;
|
||
const messages = this._formatMessageList(rawList);
|
||
const patch: Partial<ChatData> = { messages, hasMore };
|
||
if (loading !== this.data.loading) patch.loading = loading;
|
||
this.setData(patch);
|
||
|
||
if (this._loadingMore && this._preserveMsgId) {
|
||
const idx = messages.findIndex((m) => m.messageId === this._preserveMsgId);
|
||
if (idx >= 0) this.setData({ scrollIntoView: `msg-${idx}` });
|
||
this._loadingMore = false;
|
||
this._preserveMsgId = null;
|
||
} else {
|
||
this._scrollToLast(messages);
|
||
}
|
||
},
|
||
|
||
_formatMessageList(list: Message[]) {
|
||
const otherAvatar = this.data.otherAvatarUrl;
|
||
const result: Array<Message & { showTime: boolean; sentAtText: string; showAvatar: boolean; avatarUrl: string }> = [];
|
||
let lastTs = 0;
|
||
for (let i = 0; i < list.length; i++) {
|
||
const m = list[i];
|
||
const ts = (m as any).sentAt || 0;
|
||
const showTime = i === 0 || (ts && ts - lastTs > TIME_GAP_MS);
|
||
result.push({
|
||
...m,
|
||
showTime,
|
||
sentAtText: showTime ? this._formatTime(ts) : '',
|
||
showAvatar: m.bubbleType === 'other',
|
||
avatarUrl: m.bubbleType === 'other' ? otherAvatar : '',
|
||
});
|
||
lastTs = ts;
|
||
}
|
||
return result;
|
||
},
|
||
|
||
_formatTime(ts: number | string): string {
|
||
// 复用 utils/datetime,避免与会话列表各写一份时间格式化
|
||
return formatMessageTime(ts as any);
|
||
},
|
||
|
||
_scrollToLast(list: typeof this.data.messages) {
|
||
if (!list.length) return;
|
||
const id = `msg-${list.length - 1}`;
|
||
if (id !== this.data.scrollIntoView) this.setData({ scrollIntoView: id });
|
||
},
|
||
|
||
onBack() {
|
||
const pages = getCurrentPages();
|
||
if (pages.length > 1) wx.navigateBack();
|
||
else wx.switchTab({ url: ROUTE.TAB.MESSAGES });
|
||
},
|
||
|
||
onInput(e: any) {
|
||
this.setData({ inputText: (e as any).detail.value });
|
||
},
|
||
|
||
async onSend() {
|
||
const { inputText, conversationId, sending, canSend } = this.data;
|
||
if (!canSend || !inputText.trim() || sending) return;
|
||
this.setData({ sending: true, inputText: '' });
|
||
try {
|
||
// @ts-ignore
|
||
await messageStore.sendMessage(conversationId, inputText.trim());
|
||
} catch (err: unknown) {
|
||
const code = (err as any)?.code;
|
||
if (code === BUSINESS_ERROR_CODE.MATCH_REQUIRED) {
|
||
appStore.toast('双方互相喜欢后才能开始聊天');
|
||
} else {
|
||
appStore.toastError(err instanceof Error ? err.message : '发送失败');
|
||
}
|
||
} finally {
|
||
this.setData({ sending: false });
|
||
}
|
||
},
|
||
|
||
onRetrySend(e: any) {
|
||
const messageId = (e.currentTarget.dataset as any).msgId as string | undefined;
|
||
if (!messageId) return;
|
||
// @ts-ignore
|
||
messageStore.retrySend(this.data.conversationId, messageId).catch((err: unknown) => {
|
||
appStore.toastError(err instanceof Error ? err.message : '重发失败');
|
||
});
|
||
},
|
||
|
||
async onScrollToUpper() {
|
||
const { conversationId, hasMore, loadingMore } = this.data;
|
||
if (!hasMore || loadingMore) return;
|
||
const first = this.data.messages[0];
|
||
this._loadingMore = true;
|
||
this._preserveMsgId = first?.messageId || null;
|
||
this.setData({ loadingMore: true });
|
||
try {
|
||
// @ts-ignore
|
||
await messageStore.loadMoreMessages(conversationId);
|
||
} catch (err: unknown) {
|
||
appStore.toastError(err instanceof Error ? err.message : '加载失败');
|
||
this._loadingMore = false;
|
||
this._preserveMsgId = null;
|
||
} finally {
|
||
this.setData({ loadingMore: false });
|
||
}
|
||
},
|
||
|
||
onMore() {
|
||
const { otherUserId } = this.data;
|
||
if (!otherUserId) { appStore.toast('暂无更多信息'); return; }
|
||
wx.showActionSheet({
|
||
itemList: ['查看 Ta 的资料', '举报 Ta', '拉黑 Ta'],
|
||
success: (res) => {
|
||
if (res.tapIndex === 0) wx.navigateTo({ url: to.detail(otherUserId) });
|
||
else if (res.tapIndex === 1) wx.navigateTo({ url: to.report(otherUserId) });
|
||
else if (res.tapIndex === 2) this.setData({ showDangerDialog: true, dangerAction: DANGER_ACTION.BLOCK });
|
||
},
|
||
});
|
||
},
|
||
|
||
async onConfirmDanger(e: WechatMiniprogram.CustomEvent) {
|
||
const action = (e.detail as any)?.action as string | undefined;
|
||
const { otherUserId } = this.data;
|
||
this.setData({ showDangerDialog: false });
|
||
if (action !== DANGER_ACTION.BLOCK) return;
|
||
try {
|
||
appStore.showLoading('处理中…');
|
||
await blockUser(otherUserId, '用户主动拉黑');
|
||
appStore.hideLoading();
|
||
this.setData({ canSend: false });
|
||
appStore.toastSuccess('已拉黑');
|
||
} catch (err: unknown) {
|
||
appStore.hideLoading();
|
||
appStore.toastError(err instanceof Error ? err.message : '操作失败');
|
||
}
|
||
},
|
||
|
||
onCancelDanger() {
|
||
this.setData({ showDangerDialog: false });
|
||
},
|
||
|
||
_unsubConn: (() => {}) as () => void,
|
||
_unsubList: (() => {}) as () => void,
|
||
_unsubRealtime: (() => {}) as () => void,
|
||
_loadingMore: false,
|
||
_preserveMsgId: null as string | null,
|
||
});
|