feat(api): 全链路后端 + 数据库设计 + E2E 测试(65 项断言全通过)

数据库:
- 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 未联系自动回流
这个提交包含在:
linda
2026-10-28 15:45:51 +08:00
父节点 ce2a102909
当前提交 376ce7b5c9
共有 284 个文件被更改,包括 62525 次插入 和 3 次删除

338
tests/e2e_flow.js 普通文件
查看文件

@@ -0,0 +1,338 @@
/**
* 优匠优服 · 全链路 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;
});