/** * 优匠优服 · 全链路 E2E 测试(产品测试视角) * 覆盖:用户下单 → 支付 → 师傅抢单 → 履约流转 → 用户验收 → 结算 → 评价 * 同时包含:越权、非法状态流转、重复操作等异常用例 * * 运行:node tests/e2e_flow.js */ const { execFileSync } = require('child_process'); const BASE = process.env.API_BASE || 'https://yjyf.gzyakj.com.cn/api'; const SSH_HOST = 'root@8.137.181.63'; const SSH_DB = 'mysql -uyjyf -pyjyf yjyf -e'; let pass = 0; let fail = 0; const failures = []; function ok(name, extra) { pass++; console.log(` ✅ ${name}${extra ? ' → ' + extra : ''}`); } function bad(name, msg) { fail++; failures.push(`${name} :: ${msg}`); console.log(` ❌ ${name} → ${msg}`); } function assert(cond, name, extra) { if (cond) ok(name, extra); else bad(name, typeof extra === 'string' ? extra : '断言失败'); } /** * 响应码契约(与 FastAdmin 一致): * 1 = 成功 * 0 = 通用失败 * 2001/2002/2003/401/403 = 带语义的失败(未入驻/审核中/被拒/未登录/无权限) */ function isErr(res) { return !res || res.code !== 1; } function section(title) { console.log(`\n${title}`); } async function api(path, { method = 'GET', data, token } = {}) { const headers = {}; if (token) headers.token = token; let body; if (data) { headers['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(data).toString(); } const res = await fetch(BASE + path, { method, headers, body }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch (e) { throw new Error(`非 JSON 响应(${res.status}): ${text.slice(0, 200)}`); } return json; } function sql(statement) { return execFileSync('ssh', ['-o', 'BatchMode=yes', SSH_HOST, `${SSH_DB} "${statement}"`], { encoding: 'utf8', }).trim(); } (async () => { const stamp = String(Date.now()).slice(-8); const userMobile = '139' + stamp; const masterMobile = '137' + stamp; console.log('════════════════════════════════════════════════════════'); console.log(' 优匠优服 · 全链路 E2E 测试'); console.log(' 环境: ' + BASE); console.log(' 用户: ' + userMobile + ' 师傅: ' + masterMobile); console.log('════════════════════════════════════════════════════════'); // ───────────────────────────────────────────── section('【1】用户端:登录与浏览'); // ───────────────────────────────────────────── const loginU = await api('/auth/login', { method: 'POST', data: { mobile: userMobile, code: '123456' } }); assert(loginU.code === 1 && loginU.data && loginU.data.token, '用户手机号登录', 'token=' + (loginU.data && loginU.data.token ? 'ok' : 'none') + (loginU.code !== 1 ? ' | ' + loginU.msg : '')); const uToken = loginU.data.token; const badCode = await api('/auth/login', { method: 'POST', data: { mobile: userMobile, code: '000000' } }); assert(isErr(badCode), '错误验证码被拒绝', badCode.msg); const home = await api('/home/index', { token: uToken }); assert(home.code === 1 && home.data.cats.length === 15, '首页返回 15 个服务类目', '实际 ' + (home.data.cats || []).length); const services = await api('/home/services?cat_id=1'); assert(services.code === 1 && services.data.list.length > 0, '类目服务列表', '家具类 ' + (services.data.list || []).length + ' 项'); const svc = services.data.list.find(s => s.price_type === 'fixed') || services.data.list[0]; const detail = await api('/home/detail?id=' + svc.id); assert(detail.code === 1 && detail.data.detail.name === svc.name, '服务详情', svc.name); // 金额口径:80 基础价 + 无电梯 5 层(免 1 层, 5 元/层) + 加急 20% const calc = await api(`/home/calc?service_id=${svc.id}&floor=5&has_elevator=0&urgent=1`, { token: uToken }); const c = calc.data; assert(calc.code === 1, '费用试算接口可用'); assert(c.floor_amount === 20, '楼层费计算正确(4层×5元)', '实际 ' + c.floor_amount); assert(c.urgent_amount === 16, '加急费计算正确(80×20%)', '实际 ' + c.urgent_amount); assert(c.pay_amount === 116, '应付合计正确(80+20+16)', '实际 ' + c.pay_amount); // ───────────────────────────────────────────── section('【2】用户端:地址与下单'); // ───────────────────────────────────────────── const addr = await api('/address/save', { method: 'POST', token: uToken, data: { contact: '测试客户', mobile: userMobile, province: '贵州省', city: '遵义市', district: '汇川区', address: '大连路 1 号测试小区 3 栋 501', floor: 5, has_elevator: 0, is_default: 1, lng: '106.927', lat: '27.725' }, }); assert(addr.code === 1 && addr.data.id > 0, '新增服务地址(带地图经纬度)', 'addr_id=' + addr.data.id); const addrId = addr.data.id; const addrList = await api('/address/index', { token: uToken }); assert(addrList.code === 1 && addrList.data.list.length === 1 && Number(addrList.data.list[0].is_default) === 1, '地址默认标记生效'); const create = await api('/order/create', { method: 'POST', token: uToken, data: { service_id: svc.id, address_id: addrId, appoint_time: Math.floor(Date.now() / 1000) + 7200, urgent: 1, goods_model: '三人位布艺沙发', goods_num: 1, remark: '需要带工具,家里有狗' }, }); assert(create.code === 1 && create.data.order_id > 0, '创建订单', 'order_no=' + (create.data && create.data.order_no)); const orderId = create.data.order_id; let od = await api('/order/detail?id=' + orderId, { token: uToken }); assert(Number(od.data.order.status) === 0 && od.data.order.status_text === '待支付', '新订单状态=待支付', od.data.order.status_text); assert(od.data.logs.length === 1, '生成首条状态流水'); const pay = await api('/order/pay', { method: 'POST', token: uToken, data: { order_id: orderId, pay_type: 'mock' } }); assert(pay.code === 1, '模拟支付成功', pay.msg); od = await api('/order/detail?id=' + orderId, { token: uToken }); assert(Number(od.data.order.status) === 1 && od.data.order.status_text === '待接单', '支付后进入待接单', od.data.order.status_text); assert(Number(od.data.order.pay_status) === 1, '支付状态已更新'); const payAgain = await api('/order/pay', { method: 'POST', token: uToken, data: { order_id: orderId } }); assert(isErr(payAgain), '重复支付被拦截', payAgain.msg); // ───────────────────────────────────────────── section('【3】师傅端:入驻与审核'); // ───────────────────────────────────────────── const loginM = await api('/auth/login', { method: 'POST', data: { mobile: masterMobile, code: '123456' } }); assert(loginM.code === 1 && loginM.data.token, '师傅账号登录'); const mToken = loginM.data.token; assert(loginM.data.userinfo.is_master === false, '新账号默认非师傅身份'); const hallNoApply = await api('/master/hall', { token: mToken }); assert(isErr(hallNoApply), '未入驻访问订单大厅被拦截', hallNoApply.msg); const apply = await api('/master/apply', { method: 'POST', token: mToken, data: { realname: '测试师傅张三', mobile: masterMobile, idcard_no: '520101199001011234', city: '遵义市', 'skills[]': 1 }, }); assert(apply.code === 1 && apply.data.master_id > 0, '提交师傅入驻申请', 'master_id=' + (apply.data && apply.data.master_id)); const masterId = apply.data.master_id; const infoPending = await api('/master/info', { token: mToken }); assert(infoPending.data.master.verify_status === 'pending', '入驻后状态=待审核'); const hallPending = await api('/master/hall', { token: mToken }); assert(isErr(hallPending), '审核中不允许接单', hallPending.msg); // 模拟平台后台审核通过(生产环境由 FastAdmin 后台操作) sql(`UPDATE fa_master SET verify_status='pass' WHERE id=${masterId}`); const infoPass = await api('/master/info', { token: mToken }); assert(infoPass.data.master.verify_status === 'pass', '后台审核通过', '初始评分 ' + infoPass.data.master.score); // ───────────────────────────────────────────── section('【4】师傅端:订单大厅与抢单'); // ───────────────────────────────────────────── const hall = await api('/master/hall', { token: mToken }); assert(hall.code === 1 && hall.data.list.some(o => Number(o.id) === orderId), '大厅可见该订单', '共 ' + hall.data.total + ' 单'); const hallOrder = hall.data.list.find(o => Number(o.id) === orderId); assert(hallOrder.mobile.includes('****'), '大厅手机号脱敏', hallOrder.mobile); assert(Number(hallOrder.urgent) === 1, '急单标识透出'); const grab = await api('/master/grab', { method: 'POST', token: mToken, data: { order_id: orderId } }); assert(grab.code === 1, '师傅抢单成功', grab.msg); assert(grab.data.user_mobile === userMobile, '抢单后获取完整客户手机号'); const grabAgain = await api('/master/grab', { method: 'POST', token: mToken, data: { order_id: orderId } }); assert(isErr(grabAgain), '重复抢单被拦截', grabAgain.msg); const badFlow = await api('/master/startJob', { method: 'POST', token: mToken, data: { order_id: orderId } }); assert(isErr(badFlow), '非法状态流转被拦截(已接单→服务中)', badFlow.msg); const crossRole = await api('/master/arrive', { method: 'POST', token: uToken, data: { order_id: orderId } }); assert(isErr(crossRole), '用户 token 调用师傅接口被拦截', crossRole.msg); // ───────────────────────────────────────────── section('【5】师傅端:履约流转(联系→预约→到达→开工→完工)'); // ───────────────────────────────────────────── const contact = await api('/master/contact', { method: 'POST', token: mToken, data: { order_id: orderId } }); assert(contact.code === 1, '标记已联系客户(2h 考核)'); const schedule = await api('/master/schedule', { method: 'POST', token: mToken, data: { order_id: orderId, appoint_time: Math.floor(Date.now() / 1000) + 3600 }, }); assert(schedule.code === 1, '约定上门时间 → 待上门'); const arrive = await api('/master/arrive', { method: 'POST', token: mToken, data: { order_id: orderId, lng: '106.927', lat: '27.725' } }); assert(arrive.code === 1, '到达打卡 → 已到达'); const start = await api('/master/startJob', { method: 'POST', token: mToken, data: { order_id: orderId } }); assert(start.code === 1, '开工 → 服务中'); const finish = await api('/master/finish', { method: 'POST', token: mToken, data: { order_id: orderId, 'images[]': 'https://example.com/finish-1.jpg' }, }); assert(finish.code === 1, '完工提交取证 → 待验收'); const verifyCode = finish.data.verify_code; assert(/^\d{4}$/.test(verifyCode || ''), '生成 4 位验收码', verifyCode); od = await api('/order/detail?id=' + orderId, { token: uToken }); assert(od.data.media.length === 1, '现场取证已落库'); assert((od.data.media[0].watermark || '').includes('大连路'), '取证带水印(时间+地址)'); assert(Number(od.data.order.status) === 7, '状态=待验收'); assert(od.data.master && od.data.master.mobile.includes('****'), '订单详情师傅手机号脱敏'); // ───────────────────────────────────────────── section('【6】用户端:验收与结算'); // ───────────────────────────────────────────── const wrongVerify = await api('/order/verify', { method: 'POST', token: uToken, data: { order_id: orderId, verify_code: '0000' } }); assert(isErr(wrongVerify), '错误验收码被拒绝', wrongVerify.msg); const incomeBefore = await api('/master/income', { token: mToken }); const moneyBefore = Number(incomeBefore.data.money); const verify = await api('/order/verify', { method: 'POST', token: uToken, data: { order_id: orderId, verify_code: verifyCode } }); assert(verify.code === 1, '客户验收成功', verify.msg); od = await api('/order/detail?id=' + orderId, { token: uToken }); const finalOrder = od.data.order; assert(Number(finalOrder.status) === 8, '状态=已完成', finalOrder.status_text); const expectSettle = Math.round(Number(finalOrder.pay_amount) * 0.9 * 100) / 100; assert(Math.abs(Number(finalOrder.settle_amount) - expectSettle) < 0.01, '结算金额 = 实付 × 90%', finalOrder.settle_amount + ' (期望 ' + expectSettle + ')'); const incomeAfter = await api('/master/income', { token: mToken }); assert(Math.abs(Number(incomeAfter.data.money) - (moneyBefore + expectSettle)) < 0.01, '师傅余额已入账', moneyBefore + ' → ' + incomeAfter.data.money); const verifyAgain = await api('/order/verify', { method: 'POST', token: uToken, data: { order_id: orderId } }); assert(isErr(verifyAgain), '重复验收被拦截', verifyAgain.msg); // ───────────────────────────────────────────── section('【7】用户端:评价与师傅评分刷新'); // ───────────────────────────────────────────── const review = await api('/review/create', { method: 'POST', token: uToken, data: { order_id: orderId, score: 5, content: '师傅很专业,安装很稳', tags: '准时,专业' }, }); assert(review.code === 1, '用户提交评价', review.msg); const reviewAgain = await api('/review/create', { method: 'POST', token: uToken, data: { order_id: orderId, score: 4 } }); assert(isErr(reviewAgain), '重复评价被拦截', reviewAgain.msg); const mReviews = await api('/review/masterList?master_id=' + masterId, { token: mToken }); assert(mReviews.code === 1 && mReviews.data.total === 1, '师傅评价列表已更新', '共 ' + mReviews.data.total + ' 条'); assert(Number(mReviews.data.avg_score) === 5, '师傅综合评分已刷新', 'avg=' + mReviews.data.avg_score); const finalMaster = await api('/master/info', { token: mToken }); assert(Number(finalMaster.data.master.order_count) === 1, '师傅累计订单 +1'); // ───────────────────────────────────────────── section('【8】收尾:状态轨迹与边界校验'); // ───────────────────────────────────────────── od = await api('/order/detail?id=' + orderId, { token: uToken }); const logStatuses = od.data.logs.map(l => Number(l.status)); const expectFlow = [0, 1, 3, 4, 5, 6, 7, 8]; const flowOk = expectFlow.every((s, i) => logStatuses.includes(s)) && logStatuses.length >= 8; assert(flowOk, '状态轨迹完整(待支付→待接单→已接单→待上门→已到达→服务中→待验收→完成)', logStatuses.join(' → ') + '(共 ' + logStatuses.length + ' 条)'); const listAll = await api('/order/index?status=all', { token: uToken }); assert(listAll.code === 1 && listAll.data.total >= 1, '订单列表可查', '共 ' + listAll.data.total + ' 单'); const listDone = await api('/order/index?status=completed', { token: uToken }); assert(listDone.data.list.every(o => Number(o.status) === 8), '按「已完成」筛选口径正确'); const cancelDone = await api('/order/cancel', { method: 'POST', token: uToken, data: { order_id: orderId } }); assert(isErr(cancelDone), '已完成订单不可取消', cancelDone.msg); const otherOrder = await api('/order/detail?id=1', { token: mToken }); assert(isErr(otherOrder), '跨用户访问订单被拦截', otherOrder.msg); // ───────────────────────────────────────────── section('【9】硬规则:接单 2 小时未联系客户 → 订单自动回流大厅'); // ───────────────────────────────────────────── const c2 = await api('/order/create', { method: 'POST', token: uToken, data: { service_id: svc.id, address_id: addrId, urgent: 0, goods_model: '测试回流单' }, }); assert(c2.code === 1, '创建第二笔订单', 'order_no=' + (c2.data && c2.data.order_no)); const order2 = c2.data.order_id; await api('/order/pay', { method: 'POST', token: uToken, data: { order_id: order2 } }); const g2 = await api('/master/grab', { method: 'POST', token: mToken, data: { order_id: order2 } }); assert(g2.code === 1, '师傅接单第二笔'); // 把接单时间回拨到 2 小时零 1 分之前,且保持"未联系客户" sql(`UPDATE fa_order SET accept_time=${Math.floor(Date.now() / 1000) - 7260}, contact_time=0 WHERE id=${order2}`); await api('/master/hall', { token: mToken }); // 触发回收 const od2 = await api('/order/detail?id=' + order2, { token: uToken }); assert(Number(od2.data.order.status) === 1, '超时订单已回流为「待接单」', od2.data.order.status_text); assert(Number(od2.data.order.master_id) === 0, '师傅归属已释放'); const recycled = od2.data.logs.some(l => (l.remark || '').includes('自动回流')); assert(recycled, '状态流水已记录回流原因'); // 边界校验:未登录访问需登录接口 const noToken = await api('/order/index'); assert(isErr(noToken), '未登录访问订单列表被拦截', noToken.msg); // ───────────────────────────────────────────── console.log('\n════════════════════════════════════════════════════════'); console.log(` 测试结果: 通过 ${pass} 项 / 失败 ${fail} 项`); if (fail > 0) { console.log(' 失败明细:'); failures.forEach(f => console.log(' - ' + f)); process.exitCode = 1; } else { console.log(' 🎉 全链路流程通畅,无阻塞问题'); } console.log(' 订单号 orderId=' + orderId + ' masterId=' + masterId); console.log('════════════════════════════════════════════════════════'); })().catch(e => { console.error('\n💥 测试中断: ' + e.message); process.exitCode = 1; });