diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 2e55407..fbba9df 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -706,21 +706,6 @@ 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/common/support.js b/pwuniapp/common/support.js new file mode 100644 index 0000000..7b1b7f5 --- /dev/null +++ b/pwuniapp/common/support.js @@ -0,0 +1,60 @@ +// pwuniapp/common/support.js +// 统一客服入口:平台沟通仅通过官方客服微信/热线,不在买卖双方之间直接建立联系。 +import { getPlatformConfigs } from '@/common/api.js'; + +let cachedContact = null; +let cachedAt = 0; + +export async function getSupportContact() { + if (cachedContact && Date.now() - cachedAt < 5 * 60 * 1000) { + return cachedContact; + } + let hotline = ''; + let wechat = ''; + try { + const configs = await getPlatformConfigs(); + const cs = (configs && configs.customer_service) || {}; + hotline = String(cs.hotline || '').trim(); + wechat = String(cs.wechat || '').trim(); + } catch (_) { + // 配置拉取失败时使用空值,避免展示写死的客服账号 + } + cachedContact = { hotline, wechat }; + cachedAt = Date.now(); + return cachedContact; +} + +export async function openSupportSheet() { + const contact = await getSupportContact(); + const actions = []; + if (contact.wechat) actions.push({ key: 'wechat', label: `复制客服微信(${contact.wechat})` }); + if (contact.hotline) actions.push({ key: 'hotline', label: `拨打客服热线(${contact.hotline})` }); + + if (actions.length === 0) { + uni.showToast({ title: '客服信息暂未配置,请稍后再试', icon: 'none' }); + return; + } + + uni.showActionSheet({ + itemList: actions.map((a) => a.label), + success: (res) => { + const picked = actions[res.tapIndex]; + if (!picked) return; + if (picked.key === 'wechat') { + uni.setClipboardData({ + data: contact.wechat, + success: () => { + uni.showToast({ title: '客服微信已复制,请前往微信添加', icon: 'none', duration: 2200 }); + } + }); + } else if (picked.key === 'hotline') { + uni.makePhoneCall({ + phoneNumber: contact.hotline.replace(/[^0-9+]/g, ''), + fail: () => { + uni.showToast({ title: `客服热线:${contact.hotline}`, icon: 'none' }); + } + }); + } + } + }); +} diff --git a/pwuniapp/pages.json b/pwuniapp/pages.json index fda39fd..114905f 100644 --- a/pwuniapp/pages.json +++ b/pwuniapp/pages.json @@ -102,15 +102,7 @@ "backgroundColor": "#0A0F1D" } }, - { - "path": "pages/order/chat", - "style": { - "navigationBarTitleText": "订单沟通", - "navigationBarBackgroundColor": "#0A0F1D", - "navigationBarTextStyle": "white", - "backgroundColor": "#0A0F1D" - } - }, + { "path": "pages/user/index", "style": { diff --git a/pwuniapp/pages/activity/detail.vue b/pwuniapp/pages/activity/detail.vue index 208c79a..f1da63c 100644 --- a/pwuniapp/pages/activity/detail.vue +++ b/pwuniapp/pages/activity/detail.vue @@ -80,7 +80,7 @@ 同城组局召集人 - + @@ -422,6 +422,7 @@ import { normalizeUserId } from '@/common/api.js'; import { ICONS } from '@/common/icons.js'; +import { openSupportSheet } from '@/common/support.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { ensureLoginOrRedirect } from '@/common/role.js'; import { preloadSubscribeTemplates, requestSubscribeFor } from '@/common/subscribe.js'; @@ -711,34 +712,6 @@ const openHostProfile = () => { }); }; -const chatWithHost = () => { - uni.showActionSheet({ - itemList: [ - '复制组局信息', - '查看发起人主页' - ], - success: (res) => { - if (res.tapIndex === 0) { - const parts = [ - `【组局】${activity.value.title || ''}`, - `时间:${activity.value.startTime || '待定'}`, - `地点:${activity.value.location || '待定'}` - ]; - const contact = String(activity.value.contactPhone || '').trim(); - if (contact) parts.push(`联络方式:${contact}`); - uni.setClipboardData({ - data: parts.join(' | '), - success: () => { - uni.showToast({ title: '组局信息已复制', icon: 'success' }); - } - }); - } else if (res.tapIndex === 1) { - openHostProfile(); - } - } - }); -}; - const goToMyTicket = (code) => { if (!ensureLoginOrRedirect(`/pages/activity/detail?id=${currentActId.value}`)) { return; diff --git a/pwuniapp/pages/expert/detail.vue b/pwuniapp/pages/expert/detail.vue index 932c8c3..97592c7 100644 --- a/pwuniapp/pages/expert/detail.vue +++ b/pwuniapp/pages/expert/detail.vue @@ -262,6 +262,7 @@ import { onLoad, onShow } from '@dcloudio/uni-app'; import SkeletonScreen from '@/components/SkeletonScreen.vue'; import { getCompanionDetail, getExpertReviews, replyOrderReview, submitReport as submitReportApi, checkFavorite, addFavorite as addFavApi, removeFavorite as removeFavApi } from '@/common/api.js'; import { ICONS } from '@/common/icons.js'; +import { openSupportSheet } from '@/common/support.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { ensureLoginOrRedirect } from '@/common/role.js'; @@ -532,36 +533,7 @@ const toggleVoice = () => { }; const chatOnline = () => { - uni.showActionSheet({ - itemList: [`联系 7x24h 官方在线客服`], - success: (res) => { - if (res.tapIndex === 0) { - uni.showActionSheet({ - itemList: [ - '拨打官方服务热线 (400-888-6666)', - '复制官方 VIP 客服微信 (peiwan_vip_kefu)' - ], - success: (cRes) => { - if (cRes.tapIndex === 0) { - uni.makePhoneCall({ - phoneNumber: '4008886666', - fail: () => { - uni.showToast({ title: '服务热线: 400-888-6666', icon: 'none' }); - } - }); - } else if (cRes.tapIndex === 1) { - uni.setClipboardData({ - data: 'peiwan_vip_kefu', - success: () => { - uni.showToast({ title: '官方客服微信号已复制', icon: 'success' }); - } - }); - } - } - }); - } - } - }); + void openSupportSheet(); }; const previewImage = (index) => { diff --git a/pwuniapp/pages/expert/workspace.vue b/pwuniapp/pages/expert/workspace.vue index a3ce0cb..aa88a7b 100644 --- a/pwuniapp/pages/expert/workspace.vue +++ b/pwuniapp/pages/expert/workspace.vue @@ -466,6 +466,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'; import { onShow } from '@dcloudio/uni-app'; import { ICONS } from '@/common/icons.js'; +import { openSupportSheet } from '@/common/support.js'; import { isExpertCertified } from '@/common/role.js'; import { getCurrentThemeKey } from '@/common/theme.js'; import { @@ -1024,42 +1025,8 @@ const handleRejectOrder = (order) => { }; -const chatWithBuyer = (order) => { - const orderId = order && order.id; - if (orderId) { - uni.navigateTo({ url: `/pages/order/chat?orderId=${encodeURIComponent(orderId)}` }); - return; - } - uni.showActionSheet({ - itemList: ['联系 7x24h 官方客服'], - success: (res) => { - if (res.tapIndex === 0) { - uni.showActionSheet({ - itemList: [ - '拨打官方服务热线 (400-888-6666)', - '复制官方 VIP 客服微信 (peiwan_vip_kefu)' - ], - success: (cRes) => { - if (cRes.tapIndex === 0) { - uni.makePhoneCall({ - phoneNumber: '4008886666', - fail: () => { - uni.showToast({ title: '服务热线: 400-888-6666', icon: 'none' }); - } - }); - } else if (cRes.tapIndex === 1) { - uni.setClipboardData({ - data: 'peiwan_vip_kefu', - success: () => { - uni.showToast({ title: '官方客服微信号已复制', icon: 'success' }); - } - }); - } - } - }); - } - } - }); +const chatWithBuyer = () => { + void openSupportSheet(); }; const completeOrder = (id) => { diff --git a/pwuniapp/pages/order/chat.vue b/pwuniapp/pages/order/chat.vue deleted file mode 100644 index cfb4ab7..0000000 --- a/pwuniapp/pages/order/chat.vue +++ /dev/null @@ -1,320 +0,0 @@ - - - - - diff --git a/pwuniapp/pages/order/list.vue b/pwuniapp/pages/order/list.vue index bdbfec0..694d1f6 100644 --- a/pwuniapp/pages/order/list.vue +++ b/pwuniapp/pages/order/list.vue @@ -359,6 +359,7 @@ import { normalizeUserId } from '@/common/api.js'; import { ICONS } from '@/common/icons.js'; +import { openSupportSheet } from '@/common/support.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { ensureLoginOrRedirect } from '@/common/role.js'; import { getNavBarInfo } from '@/common/nav.js'; @@ -546,43 +547,8 @@ const payOrder = async (id) => { } }; -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) => { - if (res.tapIndex === 0) { - uni.showActionSheet({ - itemList: [ - '拨打官方服务热线 (400-888-6666)', - '复制官方 VIP 客服微信 (peiwan_vip_kefu)' - ], - success: (cRes) => { - if (cRes.tapIndex === 0) { - uni.makePhoneCall({ - phoneNumber: '4008886666', - fail: () => { - uni.showToast({ title: '服务热线: 400-888-6666', icon: 'none' }); - } - }); - } else if (cRes.tapIndex === 1) { - uni.setClipboardData({ - data: 'peiwan_vip_kefu', - success: () => { - uni.showToast({ title: '官方客服微信号已复制', icon: 'success' }); - } - }); - } - } - }); - } - } - }); +const contactExpert = () => { + void openSupportSheet(); }; // 15分钟待支付倒计时动态计算 diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index f6074cf..d845541 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -159,21 +159,6 @@ 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 964f57f..1c2b74a 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -1686,66 +1686,6 @@ 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 3b6c640..0f46959 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 { diff --git a/server/tests/order-chat-realtime.spec.ts b/server/tests/order-chat-realtime.spec.ts index e59d3df..309d720 100644 --- a/server/tests/order-chat-realtime.spec.ts +++ b/server/tests/order-chat-realtime.spec.ts @@ -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'); + }); });