From aca35db2005a3e2c0265a5b357b3f51a59f95823 Mon Sep 17 00:00:00 2001
From: xcbyy <2578898740@qq.com>
Date: Thu, 22 Oct 2026 08:52:12 +0800
Subject: [PATCH] =?UTF-8?q?feat(order):=20=E6=B4=BE=E5=8D=95=E5=AE=9E?=
=?UTF-8?q?=E6=97=B6=E9=80=9A=E7=9F=A5=20+=20=E8=AE=A2=E5=8D=95=E7=AB=99?=
=?UTF-8?q?=E5=86=85=E8=81=8A=E5=A4=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新派单(余额支付立即入池 / 微信支付确认后入池)向在线认证大神推送 ORDER_POOL 站内通知;雷达 20s、接单大厅 30s 自动轮询
- 新增 order_messages 表与订单会话接口:仅买家与已认领大神可访问,服务端拦截手机号与违规内容,消息落库并推送 CHAT 通知
- 新增 pages/order/chat 聊天页(3s 轮询、已读、发送),买家订单「联系大神」与大神工作台「联系老板」接入
- 114 套件/744 用例全绿;线上实测聊天/越权/手机号拦截/新单通知均通过,测试数据已清理
---
pwuniapp/common/api.js | 15 ++
pwuniapp/pages.json | 9 +
pwuniapp/pages/expert/hall.vue | 24 +-
pwuniapp/pages/expert/radar.vue | 30 ++-
pwuniapp/pages/expert/workspace.vue | 9 +-
pwuniapp/pages/order/chat.vue | 320 +++++++++++++++++++++++
pwuniapp/pages/order/list.vue | 10 +-
pwuniapp/pages/user/notifications.vue | 4 +-
server/src/db/mysql.ts | 16 +-
server/src/db/repository.ts | 61 +++++
server/src/index.ts | 144 ++++++++++
server/tests/content-security.spec.ts | 2 +-
server/tests/order-chat-realtime.spec.ts | 63 +++++
13 files changed, 698 insertions(+), 9 deletions(-)
create mode 100644 pwuniapp/pages/order/chat.vue
create mode 100644 server/tests/order-chat-realtime.spec.ts
diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js
index fbba9df..2e55407 100644
--- a/pwuniapp/common/api.js
+++ b/pwuniapp/common/api.js
@@ -706,6 +706,21 @@ export async function getOrdersPool(options = {}) {
return [];
}
+/**
+ * 订单站内聊天(仅交易双方)
+ */
+export async function getOrderChat(orderId) {
+ return http.get(`/orders/${encodeURIComponent(orderId)}/chat`);
+}
+
+export async function sendOrderMessage(orderId, content) {
+ return http.post(`/orders/${encodeURIComponent(orderId)}/chat/messages`, { content });
+}
+
+export async function markOrderChatRead(orderId) {
+ return http.post(`/orders/${encodeURIComponent(orderId)}/chat/read`, {});
+}
+
/**
* 坐标反查行政区/地址(雷达顶部定位文案)
*/
diff --git a/pwuniapp/pages.json b/pwuniapp/pages.json
index 5195144..fda39fd 100644
--- a/pwuniapp/pages.json
+++ b/pwuniapp/pages.json
@@ -102,6 +102,15 @@
"backgroundColor": "#0A0F1D"
}
},
+ {
+ "path": "pages/order/chat",
+ "style": {
+ "navigationBarTitleText": "订单沟通",
+ "navigationBarBackgroundColor": "#0A0F1D",
+ "navigationBarTextStyle": "white",
+ "backgroundColor": "#0A0F1D"
+ }
+ },
{
"path": "pages/user/index",
"style": {
diff --git a/pwuniapp/pages/expert/hall.vue b/pwuniapp/pages/expert/hall.vue
index 9360cc7..aa4d8ae 100644
--- a/pwuniapp/pages/expert/hall.vue
+++ b/pwuniapp/pages/expert/hall.vue
@@ -246,7 +246,7 @@
+
+
diff --git a/pwuniapp/pages/order/list.vue b/pwuniapp/pages/order/list.vue
index ae5629b..bdbfec0 100644
--- a/pwuniapp/pages/order/list.vue
+++ b/pwuniapp/pages/order/list.vue
@@ -87,7 +87,7 @@
-
+
@@ -546,7 +546,13 @@ const payOrder = async (id) => {
}
};
-const contactExpert = (name) => {
+const contactExpert = (order) => {
+ const orderId = order && order.id;
+ const chatAvailable = Boolean(orderId) && ['IN_SERVICE', '服务中', '履约中', '待确认', '已完成'].includes(String(order.rawStatus || order.status || ''));
+ if (chatAvailable) {
+ uni.navigateTo({ url: `/pages/order/chat?orderId=${encodeURIComponent(orderId)}` });
+ return;
+ }
uni.showActionSheet({
itemList: ['联系 7x24h 官方电竞客服'],
success: (res) => {
diff --git a/pwuniapp/pages/user/notifications.vue b/pwuniapp/pages/user/notifications.vue
index a1d6489..0491376 100644
--- a/pwuniapp/pages/user/notifications.vue
+++ b/pwuniapp/pages/user/notifications.vue
@@ -143,7 +143,9 @@ const handleOpen = async (item) => {
'/pages/activity/list',
'/pages/order/create',
'/pages/community/index',
- '/pages/user/index'
+ '/pages/user/index',
+ '/pages/expert/hall',
+ '/pages/expert/radar'
];
if (tabPages.includes(link)) {
uni.switchTab({ url: link, fail: () => {} });
diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts
index f68dc17..f6074cf 100644
--- a/server/src/db/mysql.ts
+++ b/server/src/db/mysql.ts
@@ -159,7 +159,21 @@ export async function initDatabase(): Promise {
) 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",
diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts
index d27df89..964f57f 100644
--- a/server/src/db/repository.ts
+++ b/server/src/db/repository.ts
@@ -1678,6 +1678,7 @@ export class MysqlRepository {
orderNo: r.orderNo,
buyerId: Number(r.buyerId) || 0,
expertId: Number(r.expertId) || 0,
+ claimedBy: Number(r.claimedBy) || 0,
amount: Number(r.amount) || 0,
companionName: r.companionName || '',
status: r.status,
@@ -1685,6 +1686,66 @@ export class MysqlRepository {
};
}
+ /** 订单聊天上下文:仅买家与已认领大神可参与 */
+ async getOrderChatContext(orderId: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
const [cur]: any = await pool.query('SELECT status FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]);
diff --git a/server/src/index.ts b/server/src/index.ts
index d94434e..3b6c640 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -1548,6 +1548,106 @@ 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 {
@@ -2156,6 +2256,10 @@ app.post('/api/orders', rateLimit({ name: 'order_create', windowMs: 60 * 1000, m
await assertUserNotBanned(buyerId, '预约陪玩或发单');
const order = await repo.createOrder({ ...req.body, buyerId });
+ // 平台派单(余额支付)直接进入待接单池:通知在线认证大神
+ if (String(order?.status) === '待接单') {
+ void notifyNearbyExpertsForOrder(order);
+ }
res.json({
code: 20000,
data: order,
@@ -2234,6 +2338,7 @@ app.post('/api/orders/:id/pay', async (req: Request, res: Response) => {
`订单 ${order?.order_no || orderId} 已支付,正在为您匹配附近大神;30 分钟内无人接单将自动全额退款。`,
'/pages/order/list'
);
+ void notifyNearbyExpertsForOrder(order);
return res.json({ code: 20000, data: order, message: '支付成功,已进入派单池,等待附近大神接单' });
}
pushUserNotification(buyer, 'ORDER_STATUS', '订单已开始服务', `订单 ${order?.order_no || orderId} 已进入履约中${order?.verify_code ? `,核销码 ${order.verify_code}` : ''},请按时到场。`, '/pages/order/list');
@@ -2262,6 +2367,7 @@ app.post('/api/orders/:id/pay', async (req: Request, res: Response) => {
return res.status(400).json({ code: 40000, message: `非法的目标订单状态: ${requested}` });
}
const order = await repo.updateOrderStatus(orderId, requested);
+ if (requested === '待接单') void notifyNearbyExpertsForOrder(order);
const buyer = Number(own.buyerId || 0);
pushUserNotification(buyer, 'ORDER_STATUS', '订单已开始服务', `订单 ${order?.order_no || orderId} 已进入${requested}${order?.verify_code ? `,核销码 ${order.verify_code}` : ''},请按时到场。`, '/pages/order/list');
void notifyBySubscribe('orderStatus', {
@@ -2703,6 +2809,44 @@ function pushUserNotification(userId: number, type: string, title: string, conte
.catch((e: any) => logServerError('pushUserNotification', e));
}
+/**
+ * 新派单通知:给当前在线、审核通过的认证大神推送站内消息(含红点)。
+ * 说明:大神档案暂无实时坐标,先按"在线接单中"广播;后续可结合经纬度做 5km 精确推送。
+ */
+async function notifyNearbyExpertsForOrder(order: any): Promise {
+ try {
+ const orderId = String(order?.id || '');
+ const buyerId = Number(order?.buyer_id || order?.buyerId || 0);
+ if (!orderId) return;
+ const [rows]: any = await pool.query(`
+ SELECT DISTINCT c.user_id as userId
+ FROM companions c
+ JOIN users u ON u.id = c.user_id
+ WHERE c.audit_status = '已通过'
+ AND c.status = '接单中'
+ AND c.user_id IS NOT NULL
+ AND u.status NOT IN ('已封禁', '封禁')
+ LIMIT 60
+ `);
+ const title = order?.service_title || order?.serviceTitle || '附近有新需求单';
+ const track = order?.track || '同城陪伴';
+ const amount = Number(order?.amount || 0).toFixed(2);
+ for (const row of rows || []) {
+ const uid = Number(row.userId) || 0;
+ if (!uid || uid === buyerId) continue;
+ pushUserNotification(
+ uid,
+ 'ORDER_POOL',
+ '附近有新需求单',
+ `【${track}】${title} · ¥${amount},先到先得`,
+ '/pages/expert/radar'
+ );
+ }
+ } catch (e) {
+ logServerError('notifyNearbyExpertsForOrder', e);
+ }
+}
+
async function notifyBySubscribe(
templateKey: 'orderStatus' | 'activityJoin' | 'withdrawSuccess' | 'verifyCode',
opts: { userId: number; page?: string; data: Record }
diff --git a/server/tests/content-security.spec.ts b/server/tests/content-security.spec.ts
index 1a22afc..9ecebcc 100644
--- a/server/tests/content-security.spec.ts
+++ b/server/tests/content-security.spec.ts
@@ -130,7 +130,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
const raw = fs.readFileSync(pagesJsonPath, 'utf-8');
const config = JSON.parse(raw);
expect(Array.isArray(config.pages)).toBe(true);
- expect(config.pages.length).toBe(28);
+ expect(config.pages.length).toBeGreaterThanOrEqual(28);
for (const pageItem of config.pages) {
const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`);
diff --git a/server/tests/order-chat-realtime.spec.ts b/server/tests/order-chat-realtime.spec.ts
new file mode 100644
index 0000000..e59d3df
--- /dev/null
+++ b/server/tests/order-chat-realtime.spec.ts
@@ -0,0 +1,63 @@
+// 已读取AGENTS.md
+import * as fs from 'fs';
+import * as path from 'path';
+
+/**
+ * 第九十九批:订单站内聊天 + 派单实时通知。
+ */
+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 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');
+ });
+
+ it('新派单必须通知在线认证大神,雷达/大厅自动轮询', () => {
+ expect(indexCode).toContain('notifyNearbyExpertsForOrder');
+ expect(indexCode).toContain("c.status = '接单中'");
+ expect(indexCode).toContain("pushUserNotification(\n uid,\n 'ORDER_POOL'");
+ expect(radar).toContain('startPolling');
+ expect(radar).toContain('}, 20000);');
+ expect(hall).toContain('startHallPolling');
+ expect(hall).toContain('}, 30000);');
+ });
+
+ it('通知跳转会正确处理接单页 tab 路由', () => {
+ const notifications = read('pwuniapp/pages/user/notifications.vue');
+ expect(notifications).toContain("'/pages/expert/radar'");
+ expect(notifications).toContain("'/pages/expert/hall'");
+ });
+});