数据库: - db/schema_phase1.sql 16 张业务表(类目/服务/地址/师傅/订单/流水/取证/报价/评价/券/钱包/提现/结算/消息) - db/seed_phase1.sql 15 类目 + 32 服务项目 + 新人券(幂等) 后端 API (application/api/controller): - Base/Auth/Home/Address/Order/Master/Review - app/common/library/OrderStatus 11 状态机 + 流转矩阵 - application/api/common.php PHP 8.5 兼容垫片(E_DEPRECATED 不再升级为异常) 测试: - tests/e2e_flow.js 用户下单→支付→师傅抢单→履约→验收→结算→评价 全链路 65 项断言 - 含异常用例:越权/重复操作/非法状态流转/2h 未联系自动回流
339 行
19 KiB
JavaScript
339 行
19 KiB
JavaScript
/**
|
||
* 优匠优服 · 全链路 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;
|
||
});
|
||
|
||
|