revert(order): 移除订单站内聊天,沟通统一走官方客服
- 删除 pages/order/chat、聊天接口/仓储方法/order_messages 建表与客户端 API - 买家「联系大神」、大神「联系老板」、大神详情「在线客服」、组局「私聊」统一改为 openSupportSheet(读取后台 customer_service 配置的客服微信/热线) - 保留派单实时通知与雷达/大厅轮询 - 114 套件/742 用例全绿;聊天接口线上已 404
这个提交包含在:
@@ -159,21 +159,6 @@ export async function initDatabase(): Promise<void> {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// 订单维度站内聊天消息(仅交易双方可见,敏感词/联系方式在服务端拦截)
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS order_messages (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
order_id VARCHAR(64) NOT NULL,
|
||||
sender_id INT NOT NULL,
|
||||
receiver_id INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
read_at DATETIME NULL,
|
||||
INDEX idx_order_created (order_id, created_at),
|
||||
INDEX idx_receiver_read (receiver_id, read_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
const extraOrderCols = [
|
||||
"ALTER TABLE orders ADD COLUMN buyer_id INT DEFAULT 1001 AFTER id",
|
||||
"ALTER TABLE orders ADD COLUMN expert_id INT DEFAULT 101 AFTER buyer_id",
|
||||
|
||||
@@ -1686,66 +1686,6 @@ export class MysqlRepository {
|
||||
};
|
||||
}
|
||||
|
||||
/** 订单聊天上下文:仅买家与已认领大神可参与 */
|
||||
async getOrderChatContext(orderId: string): Promise<any | null> {
|
||||
const own = await this.getOrderOwnership(orderId);
|
||||
if (!own) return null;
|
||||
return {
|
||||
orderId: own.id,
|
||||
orderNo: own.orderNo,
|
||||
buyerId: own.buyerId,
|
||||
expertUserId: Number(own.claimedBy) || 0,
|
||||
status: own.status,
|
||||
amount: own.amount,
|
||||
companionName: own.companionName,
|
||||
};
|
||||
}
|
||||
|
||||
async listOrderMessages(orderId: string, limit = 100): Promise<any[]> {
|
||||
const safeLimit = Math.min(200, Math.max(1, Math.round(Number(limit) || 100)));
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT id, order_id as orderId, sender_id as senderId, receiver_id as receiverId,
|
||||
content, created_at as createdAt, read_at as readAt
|
||||
FROM order_messages
|
||||
WHERE order_id = ?
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?
|
||||
`, [orderId, safeLimit]);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async createOrderMessage(orderId: string, senderId: number, receiverId: number, content: string): Promise<any> {
|
||||
const id = `msg_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`;
|
||||
await pool.query(
|
||||
'INSERT INTO order_messages (id, order_id, sender_id, receiver_id, content) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, orderId, senderId, receiverId, content]
|
||||
);
|
||||
return {
|
||||
id,
|
||||
orderId,
|
||||
senderId: Number(senderId),
|
||||
receiverId: Number(receiverId),
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
readAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
async markOrderMessagesRead(orderId: string, readerId: number): Promise<void> {
|
||||
await pool.query(
|
||||
'UPDATE order_messages SET read_at = NOW() WHERE order_id = ? AND receiver_id = ? AND read_at IS NULL',
|
||||
[orderId, readerId]
|
||||
);
|
||||
}
|
||||
|
||||
async countUnreadOrderMessages(orderId: string, receiverId: number): Promise<number> {
|
||||
const [rows]: any = await pool.query(
|
||||
'SELECT COUNT(*) as c FROM order_messages WHERE order_id = ? AND receiver_id = ? AND read_at IS NULL',
|
||||
[orderId, receiverId]
|
||||
);
|
||||
return Number(rows?.[0]?.c || 0);
|
||||
}
|
||||
|
||||
// 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口)
|
||||
async applyCompleteOrder(orderId: string): Promise<any> {
|
||||
const [cur]: any = await pool.query('SELECT status FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]);
|
||||
|
||||
@@ -1548,106 +1548,6 @@ app.post('/api/orders/:id/claim', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 订单维度站内聊天(仅交易双方;敏感词与手机号在服务端拦截)=====
|
||||
async function resolveOrderChat(req: Request, orderId: string) {
|
||||
const uid = resolveSecureUserId(req, Number(req.body?.userId || req.query.userId || 0), '订单聊天');
|
||||
const ctx = await repo.getOrderChatContext(orderId);
|
||||
if (!ctx) {
|
||||
const e: any = new Error('订单不存在');
|
||||
e.status = 404;
|
||||
e.code = 40400;
|
||||
throw e;
|
||||
}
|
||||
if (!ctx.expertUserId) {
|
||||
const e: any = new Error('订单尚未被大神接单,暂不能聊天');
|
||||
e.status = 409;
|
||||
e.code = 40900;
|
||||
throw e;
|
||||
}
|
||||
const isBuyer = uid === ctx.buyerId;
|
||||
const isExpert = uid === ctx.expertUserId;
|
||||
if (!isBuyer && !isExpert) {
|
||||
const e: any = new Error('仅交易双方可查看该订单会话');
|
||||
e.status = 403;
|
||||
e.code = 40300;
|
||||
throw e;
|
||||
}
|
||||
return { uid, ctx, isBuyer, peerId: isBuyer ? ctx.expertUserId : ctx.buyerId };
|
||||
}
|
||||
|
||||
app.get('/api/orders/:id/chat', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { uid, ctx, peerId } = await resolveOrderChat(req, String(req.params.id));
|
||||
const messages = await repo.listOrderMessages(ctx.orderId);
|
||||
const peer: any = await repo.getUserById(peerId);
|
||||
await repo.markOrderMessagesRead(ctx.orderId, uid);
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: {
|
||||
order: {
|
||||
id: ctx.orderId,
|
||||
orderNo: ctx.orderNo,
|
||||
status: ctx.status,
|
||||
amount: ctx.amount,
|
||||
title: ctx.companionName
|
||||
},
|
||||
peer: {
|
||||
id: peerId,
|
||||
nickname: peer?.nickname || '对方',
|
||||
avatar: peer?.avatar || ''
|
||||
},
|
||||
messages,
|
||||
unread: 0
|
||||
}
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(err.status || 500).json({ code: err.code || 50000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orders/:id/chat/messages', rateLimit({ name: 'order_chat', windowMs: 60 * 1000, max: 40, key: rateWriteKey }), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { uid, ctx, peerId } = await resolveOrderChat(req, String(req.params.id));
|
||||
const content = String(req.body?.content || '').trim();
|
||||
if (!content) {
|
||||
return res.status(400).json({ code: 40000, message: '消息内容不能为空' });
|
||||
}
|
||||
if (content.length > 500) {
|
||||
return res.status(400).json({ code: 40000, message: '单条消息不能超过 500 字' });
|
||||
}
|
||||
if (/1[3-9]\d{9}/.test(content)) {
|
||||
return res.status(400).json({ code: 40000, message: '为保障资金与人身安全,请勿在站内发送手机号' });
|
||||
}
|
||||
const sec = contentSecurityService.checkText(content);
|
||||
if (!sec.pass) {
|
||||
return res.status(400).json({ code: sec.code || 40000, message: sec.message || '消息包含违规内容' });
|
||||
}
|
||||
|
||||
const message = await repo.createOrderMessage(ctx.orderId, uid, peerId, content);
|
||||
const sender: any = await repo.getUserById(uid);
|
||||
pushUserNotification(
|
||||
peerId,
|
||||
'CHAT',
|
||||
'收到一条订单消息',
|
||||
`${sender?.nickname || '对方'}:${content.slice(0, 40)}`,
|
||||
`/pages/order/chat?orderId=${ctx.orderId}`
|
||||
);
|
||||
res.json({ code: 20000, data: message, message: '已发送' });
|
||||
} catch (err: any) {
|
||||
res.status(err.status || 400).json({ code: err.code || 40000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/orders/:id/chat/read', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { uid, ctx } = await resolveOrderChat(req, String(req.params.id));
|
||||
await repo.markOrderMessagesRead(ctx.orderId, uid);
|
||||
res.json({ code: 20000, data: { ok: true } });
|
||||
} catch (err: any) {
|
||||
res.status(err.status || 400).json({ code: err.code || 40000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 大神今日接单数据汇总 (落盘 MySQL)
|
||||
app.get('/api/expert/stats/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -3,47 +3,19 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第九十九批:订单站内聊天 + 派单实时通知。
|
||||
* 第九十九批:派单实时化;站内聊天已按产品要求移除(沟通统一走官方客服)。
|
||||
*/
|
||||
describe('第九十九批订单聊天与派单实时化', () => {
|
||||
describe('第九十九批派单实时化与客服沟通约束', () => {
|
||||
const root = path.resolve(__dirname, '../../');
|
||||
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8');
|
||||
|
||||
const mysql = read('server/src/db/mysql.ts');
|
||||
const repo = read('server/src/db/repository.ts');
|
||||
const indexCode = read('server/src/index.ts');
|
||||
const apiCode = read('pwuniapp/common/api.js');
|
||||
const chatPage = read('pwuniapp/pages/order/chat.vue');
|
||||
const mysql = read('server/src/db/mysql.ts');
|
||||
const pagesJson = read('pwuniapp/pages.json');
|
||||
const radar = read('pwuniapp/pages/expert/radar.vue');
|
||||
const hall = read('pwuniapp/pages/expert/hall.vue');
|
||||
|
||||
it('订单消息表必须落库且带索引', () => {
|
||||
expect(mysql).toContain('CREATE TABLE IF NOT EXISTS order_messages');
|
||||
expect(mysql).toContain('idx_order_created');
|
||||
expect(mysql).toContain('idx_receiver_read');
|
||||
});
|
||||
|
||||
it('聊天接口必须校验交易双方身份', () => {
|
||||
expect(indexCode).toContain("app.get('/api/orders/:id/chat'");
|
||||
expect(indexCode).toContain("app.post('/api/orders/:id/chat/messages'");
|
||||
expect(indexCode).toContain('仅交易双方可查看该订单会话');
|
||||
expect(indexCode).toContain('订单尚未被大神接单,暂不能聊天');
|
||||
});
|
||||
|
||||
it('消息必须过滤手机号与违规内容,并推送站内通知', () => {
|
||||
expect(indexCode).toContain("请勿在站内发送手机号");
|
||||
expect(indexCode).toContain('contentSecurityService.checkText(content)');
|
||||
expect(indexCode).toContain("pushUserNotification(\n peerId,\n 'CHAT'");
|
||||
});
|
||||
|
||||
it('小程序聊天页与接口已接线', () => {
|
||||
expect(pagesJson).toContain('pages/order/chat');
|
||||
expect(apiCode).toContain('export async function getOrderChat');
|
||||
expect(apiCode).toContain('export async function sendOrderMessage');
|
||||
expect(chatPage).toContain('setInterval');
|
||||
expect(chatPage).toContain('sendOrderMessage');
|
||||
});
|
||||
const orderList = read('pwuniapp/pages/order/list.vue');
|
||||
const workspace = read('pwuniapp/pages/expert/workspace.vue');
|
||||
|
||||
it('新派单必须通知在线认证大神,雷达/大厅自动轮询', () => {
|
||||
expect(indexCode).toContain('notifyNearbyExpertsForOrder');
|
||||
@@ -55,9 +27,27 @@ describe('第九十九批订单聊天与派单实时化', () => {
|
||||
expect(hall).toContain('}, 30000);');
|
||||
});
|
||||
|
||||
it('通知跳转会正确处理接单页 tab 路由', () => {
|
||||
it('新单通知跳转会正确处理接单页 tab 路由', () => {
|
||||
const notifications = read('pwuniapp/pages/user/notifications.vue');
|
||||
expect(notifications).toContain("'/pages/expert/radar'");
|
||||
expect(notifications).toContain("'/pages/expert/hall'");
|
||||
});
|
||||
|
||||
it('站内聊天必须彻底移除(页面/接口/表/客户端方法)', () => {
|
||||
expect(fs.existsSync(path.join(root, 'pwuniapp/pages/order/chat.vue'))).toBe(false);
|
||||
expect(pagesJson).not.toContain('pages/order/chat');
|
||||
expect(indexCode).not.toContain('/chat/messages');
|
||||
expect(mysql).not.toContain('order_messages');
|
||||
const apiCode = read('pwuniapp/common/api.js');
|
||||
expect(apiCode).not.toContain('sendOrderMessage');
|
||||
});
|
||||
|
||||
it('买卖双方沟通统一走官方客服(配置化)', () => {
|
||||
const support = read('pwuniapp/common/support.js');
|
||||
expect(support).toContain('openSupportSheet');
|
||||
expect(support).toContain('customer_service');
|
||||
expect(orderList).toContain('openSupportSheet');
|
||||
expect(workspace).toContain('openSupportSheet');
|
||||
expect(orderList).not.toContain('pages/order/chat');
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户