diff --git a/server/addons/epay/certs/apiclient_cert.p12 b/server/addons/epay/certs/apiclient_cert.p12
new file mode 100644
index 0000000..344e6d7
Binary files /dev/null and b/server/addons/epay/certs/apiclient_cert.p12 differ
diff --git a/server/addons/epay/certs/apiclient_cert.pem b/server/addons/epay/certs/apiclient_cert.pem
index e69de29..0f597e6 100644
--- a/server/addons/epay/certs/apiclient_cert.pem
+++ b/server/addons/epay/certs/apiclient_cert.pem
@@ -0,0 +1,25 @@
+-----BEGIN CERTIFICATE-----
+MIIEPTCCAyWgAwIBAgIUH1jrCaOtZjv9YYugxI469psccq8wDQYJKoZIhvcNAQEL
+BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
+FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
+Q0EwHhcNMjYxMDIyMDM1MzAzWhcNMzExMDIxMDM1MzAzWjCBljETMBEGA1UEAwwK
+MTExOTEwNTMxNzEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMUIwQAYDVQQL
+DDnmr5XoioLluILotbXlt6XlirPliqHmnI3liqHnu4/okKXpg6jvvIjkuKrkvZPl
+t6XllYbmiLfvvIkxCzAJBgNVBAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIw
+DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK+tGwIxXbiRrF3THH0aT3Zy+GYp
+Ijm8ryzdcH8fFOEuaDCJfnH9j/52C+gjdXQnUXX2lMKjjc9L/FdX5C5kbKO/K1Kv
+psQBrOj/Up4y8KkOIDdbtNb6F1yygHV0IjiuueHWEZs9PtPhnPXftAuPBHMhST6B
++R6/WLpG0vX8+A8CxUb9S2cL4c6tjziz8YUZAC3RQpEhbtM3RtC1hHBRwXuLV0BA
+allsQee26Suhvfe3x0S8+v92TEH7jhaDOhRy6lgYETWKNAXB7A65gs9JPI9bN/wG
+1WF7yOzoQgr8M1gucGh6YO9DbOy0VStypz3bQbhUkgx5nxJENkAIBCFJmuECAwEA
+AaOBuTCBtjAJBgNVHRMEAjAAMAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGN
+oIGKoIGHhoGEaHR0cDovL2V2Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2Ny
+bD9DQT0xQkQ0MjIwRTUwREJDMDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNn
+PUhBQ0M0NzFCNjU0MjJFMTJCMjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0G
+CSqGSIb3DQEBCwUAA4IBAQCINViXULgbHCC60m0PWNjWJO5XXB6Flo0xb0xp7Fd/
+PQIsjbbaelVmjde0foFxvgG3z7vIyU/sKa77V46n3fhMpG77rW6D+uBPs1hAZgMa
+sQPBycCaCE0IHlaOAkpYEmwjhBuAfeg7ZxXXz1kO7ZPeYXGzBhj5NhpC4d/3shph
+4bdq9EzhK0fnHm/EEq50YBh4r2MX+bKR/cuFp+ozldcB2ntj7VomUZuKvwFyyPr+
+cLQ1J8KGe45TmF/H72h0/UQzE/1PrYBJaoDEiRwlnw6jKJhwos+KT7USOd5mfszn
+wnPyemUZwy3opmbkGGvzL3lp+GhnUU2Td/nmGNsawiDd
+-----END CERTIFICATE-----
diff --git a/server/addons/epay/certs/apiclient_key.pem b/server/addons/epay/certs/apiclient_key.pem
index e69de29..c72dd95 100644
--- a/server/addons/epay/certs/apiclient_key.pem
+++ b/server/addons/epay/certs/apiclient_key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvrRsCMV24kaxd
+0xx9Gk92cvhmKSI5vK8s3XB/HxThLmgwiX5x/Y/+dgvoI3V0J1F19pTCo43PS/xX
+V+QuZGyjvytSr6bEAazo/1KeMvCpDiA3W7TW+hdcsoB1dCI4rrnh1hGbPT7T4Zz1
+37QLjwRzIUk+gfkev1i6RtL1/PgPAsVG/UtnC+HOrY84s/GFGQAt0UKRIW7TN0bQ
+tYRwUcF7i1dAQGpZbEHntukrob33t8dEvPr/dkxB+44WgzoUcupYGBE1ijQFwewO
+uYLPSTyPWzf8BtVhe8js6EIK/DNYLnBoemDvQ2zstFUrcqc920G4VJIMeZ8SRDZA
+CAQhSZrhAgMBAAECggEAW0rA01XmF0kUYrmXf2eRzcMXVLehgpGMEuuMT3NOfWqJ
+WFfLLt+O2bRsoZjzq4XXLk8SpUcrJYOeQaF6AaXMUxfAIrsHFaqtOXOdcnqUV7Xw
+hfQROPpD87cLTH4qXnxmOnbtNVgRczg2QgubW7pZnJ66mlIerJzRNhQiAxUPr3Rr
+enH23nqvy2DUYqvYixtscl/e/bkINuH8OJilxK4xKn/T90mxGSAqcUDjG9ETD8D+
+y3PSiLWJ2Ms70akYwrzfk0yDpHddT8xGQ7N42vhH/3v+oQ2TILj1cF6DKw3ey13K
+jjB6DNpD+bSfGyyZgdV33I91FxpKlgk1JxgLf9lqMQKBgQDeT7/jcbI8Q6wYohMS
+408jdGE8pLa2AbUPG/fVpiVbkpSs7zUM/1mOlyoQ9HnRh4uFnrqUHt/ks7yh8eR0
+/HAhk5mJrY8P/OOjtXaLc7r/0RRhupAZUYNNWQBC8280orfjvsrDgxe7to3RpOoc
+CvvCRpfxJCv0QIOLBlT4WvhcrQKBgQDKTDThMZXxxypi82JUA8zq2ZeU1CFAzFIQ
+azZmZoA2MCfBC+o0gcKDrW7TxnZqP5GZ+aMDflmLU12y/GvB9eUTUWdZF6wcVhGs
+sNWbhX2pt1qRVW83aTeR8gCFD1HPCRNfjfqSHWJ2BFEodf232lNAYMbJUEfST2ml
+M8CKrojphQKBgA5v32ZF2+1N978hx1tSp7EFvcrSZ4sqUTfJ+S5Gu+ZWfpE8d5WA
+f7Bi4Lh5J/ATfVwQEs3Ls36GrZlWvB9cxWtoym/GdaP0KFRZVHl56oryaQBvvSvn
+vz8mqH2wkWg5qPeBTGml+cSW9l7G9u0bSX40V2mxj6ktB13f62S1g6uBAoGAQ1Qh
+tWF/ejzDSrOeYj/xj6hj3tDLUHmwRm6oaGRs/5v/XyzMAjW9u32ZiTpgZvRJTWnc
+a2k9/DT+1ndUlfV1DHznXKuYSMYMk+E29s/zBu1TY1N/9H08VYKp0Z9LpxBqNj9W
+yhKllonRSRocG3i6hI6xGodxn/l2O9mBl6aHQtECgYEAhPAe8vGYYwnA4cA4wdAD
+SvPLWc+0O9LXQqxKzO8us3w4wgQUB/QnUQO4cBF4JRs4KzmgRtIhUAAxFehXFkT0
+9aB4W1SwZX3reCYpblSaAniAiJGV7aHgqWAmqtIcTKS64qg+IG99JzyUMV47gHZ8
+v2E2sXnQy0Eeyxl9HWxkvDM=
+-----END PRIVATE KEY-----
diff --git a/server/addons/epay/certs/public_key.pem b/server/addons/epay/certs/public_key.pem
index e69de29..6b0a3fa 100644
--- a/server/addons/epay/certs/public_key.pem
+++ b/server/addons/epay/certs/public_key.pem
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwEH5dxleIFd0QmQwsCjV
+HS/4/Pf+aBiFLnA3eg11H6AnWFI5yZSX8kWpwi6ddwBExO9BWyjE/SQj30hH5ETn
+NEts8F/EsZ2W5NRJyK+bA9bSAQ8/oa+8LUodXijTK7kZ49nzlvje+6jTtRw1kNhe
+xUWEYaSAYUdNuP4Eww31D7xFO5cTYNEU5LLCU/JplsLZOjFzOKeZ/5lrugVSDiQW
+IoJWYilWSrTEBbpiZa2WoikNCUrJjy+PZQ/q5n61aK7nHA4WfrnQOt/1NdS6YM4o
+AOLwGC70sIvf6JZB/t5FqFMmBU2SOfg1tGccHO5jtD2XMBc68pEa0Vb0f1bmJnNL
+ZwIDAQAB
+-----END PUBLIC KEY-----
diff --git a/server/addons/epay/config.php b/server/addons/epay/config.php
index 92607f2..50b01bd 100644
--- a/server/addons/epay/config.php
+++ b/server/addons/epay/config.php
@@ -6,7 +6,7 @@ return [
'title' => 'API版本(请勿修改该值)',
'type' => 'radio',
'content' => [],
- 'value' => 'v2',
+ 'value' => 'v3',
'rule' => '',
'msg' => '',
'tip' => 'V2版本只支持微信支付V2密钥,V3版本只支持微信支付V3密钥,请勿修改该值!!!',
@@ -19,14 +19,14 @@ return [
'type' => 'array',
'content' => [],
'value' => [
- 'appid' => '',
- 'app_id' => '',
- 'app_secret' => '',
- 'miniapp_id' => '',
- 'mch_id' => '',
- 'key' => '',
- 'key_v3' => '',
- 'public_key_id' => '',
+ 'appid' => 'wx40ca4b02d4d1cb58',
+ 'app_id' => 'wx40ca4b02d4d1cb58',
+ 'app_secret' => 'f661146ce627e2c57770b7d80164dd65',
+ 'miniapp_id' => 'wx40ca4b02d4d1cb58',
+ 'mch_id' => '1119105317',
+ 'key' => 'ffyqcyyszhdhi398672363efnwvikxlv',
+ 'key_v3' => 'ffyqcyyszhdhi398672363efnwvikxlv',
+ 'public_key_id' => 'PUB_KEY_ID_0111191053172026102200381630000600',
'public_key' => '/addons/epay/certs/public_key.pem',
'cert_client' => '/addons/epay/certs/apiclient_cert.pem',
'cert_key' => '/addons/epay/certs/apiclient_key.pem',
@@ -35,7 +35,7 @@ return [
'sub_appid' => '',
'sub_app_id' => '',
'sub_miniapp_id' => '',
- 'notify_url' => '',
+ 'notify_url' => '/addons/epay/api/notifyx/paytype/wechat',
'log' => '1',
],
'rule' => 'required',
diff --git a/server/addons/epay/controller/Api.php b/server/addons/epay/controller/Api.php
index 27340e4..1390598 100644
--- a/server/addons/epay/controller/Api.php
+++ b/server/addons/epay/controller/Api.php
@@ -180,7 +180,7 @@ class Api extends Controller
*/
public function notifyx()
{
- $paytype = $this->request->param('paytype');
+ $paytype = $this->request->param('paytype', $this->request->param('type', 'wechat'));
$pay = Service::checkNotify($paytype);
if (!$pay) {
return json(['code' => 'FAIL', 'message' => '失败'], 500, ['Content-Type' => 'application/json']);
diff --git a/server/addons/epay/library/Service.php b/server/addons/epay/library/Service.php
index 92eec5e..d58ef4f 100644
--- a/server/addons/epay/library/Service.php
+++ b/server/addons/epay/library/Service.php
@@ -375,6 +375,7 @@ class Service
$config['mini_app_id'] = $config['miniapp_id'] ?? '';
$config['combine_mch_id'] = $config['combine_mch_id'] ?? '';
$config['mch_secret_key'] = $config['key_v3'] ?? '';
+ $config['mch_secret_key_v2'] = $config['key'] ?? '';
$config['mch_secret_cert'] = $config['cert_key'];
$config['mch_public_cert_path'] = $config['cert_client'];
diff --git a/server/application/api/controller/Merchant.php b/server/application/api/controller/Merchant.php
index 5c94467..56a97e5 100644
--- a/server/application/api/controller/Merchant.php
+++ b/server/application/api/controller/Merchant.php
@@ -2,6 +2,7 @@
namespace app\api\controller;
+use app\common\library\OrderStatus;
use think\Db;
/**
@@ -179,11 +180,22 @@ class Merchant extends Base
$merchant = $this->requireMerchant();
$uids = $this->merchantUserIds($merchant);
$status = $this->request->param('status', 'all');
+ $keyword = trim((string)$this->request->param('keyword', ''));
$page = max(1, (int)$this->request->param('page', 1));
$limit = min(50, max(1, (int)$this->request->param('limit', 20)));
- $build = function () use ($uids, $status) {
+ $build = function () use ($uids, $status, $keyword) {
$q = Db::name('order')->where('user_id', 'in', $uids);
+ if ($keyword !== '') {
+ $q->where(function ($sub) use ($keyword) {
+ $sub->where('contact', 'like', "%{$keyword}%")
+ ->whereOr('mobile', 'like', "%{$keyword}%")
+ ->whereOr('order_no', 'like', "%{$keyword}%")
+ ->whereOr('service_name', 'like', "%{$keyword}%")
+ ->whereOr('address', 'like', "%{$keyword}%")
+ ->whereOr('remark', 'like', "%{$keyword}%");
+ });
+ }
if ($status === 'active') {
$q->where('status', 'in', \app\common\library\OrderStatus::activeValues());
} elseif ($status === 'completed') {
@@ -204,6 +216,8 @@ class Merchant extends Base
$ownerUser = (int)$row['user_id'] === (int)$merchant['user_id'];
$u = isset($userMap[(int)$row['user_id']]) ? $userMap[(int)$row['user_id']] : null;
$mobile = $u ? (string)$u['mobile'] : '';
+ $custMobile = (string)$row['mobile'];
+ $row['mobile_mask'] = strlen($custMobile) === 11 ? substr_replace($custMobile, '****', 3, 4) : $custMobile;
$row['pay_amount'] = (float)$row['pay_amount'];
$row['urgent'] = (int)$row['urgent'];
$row['status_text'] = \app\common\library\OrderStatus::label((int)$row['status']);
diff --git a/tests/gen_report.py b/tests/gen_report.py
new file mode 100644
index 0000000..c9851a8
--- /dev/null
+++ b/tests/gen_report.py
@@ -0,0 +1,365 @@
+# -*- coding: utf-8 -*-
+"""从 local_report_data.json 生成测试报告 HTML 与 charts.js"""
+import json
+
+DATA = r"d:\Projects\phpstudy_pro\WWW\yjyf\tests\local_report_data.json"
+DIR = r"d:\Projects\phpstudy_pro\WWW\yjyf\doc\qa-report-2026-11-07"
+
+with open(DATA, encoding="utf-8") as f:
+ data = json.load(f)
+
+results = data["results"]
+P0_KEYS = ["下单", "支付", "抢单", "验收", "结算", "余额", "提现", "被拦截", "回流", "托管", "越权", "未登录", "跨用户", "无效 token"]
+def prio(n):
+ return "P0" if any(k in n for k in P0_KEYS) else "P1"
+
+MODULE_CN = {
+ "A-用户端登录与首页": "用户端·认证与首页",
+ "B-地址管理": "用户端·地址管理",
+ "C-下单与支付": "用户端·下单与支付",
+ "D-师傅入驻与审核": "师傅端·入驻与审核",
+ "E-订单大厅与抢单": "师傅端·大厅与抢单",
+ "F-报价单流程": "师傅端·报价单流程",
+ "G-师傅履约流转": "师傅端·履约流转",
+ "H-验收与结算": "用户端·验收与结算",
+ "I-评价与评分": "用户端·评价与评分",
+ "J-优惠券": "营销·优惠券",
+ "K-消息中心": "消息中心",
+ "L-钱包与提现": "师傅端·钱包提现",
+ "M-商家全流程": "商家端·全流程",
+ "N-订单边界与状态轨迹": "订单·边界与状态机",
+ "O-超时回流硬规则": "订单·超时回流",
+ "P-后台数据联动": "后台·数据联动",
+}
+
+mods = list(dict.fromkeys([r["module"] for r in results]))
+mod_stats = []
+for m in mods:
+ rows = [r for r in results if r["module"] == m]
+ p0 = sum(1 for r in rows if prio(r["name"]) == "P0")
+ mod_stats.append({"mod": m, "cn": MODULE_CN.get(m, m), "total": len(rows), "p0": p0,
+ "pass": sum(1 for r in rows if r["status"] == "pass")})
+
+total = len(results)
+p0_total = sum(s["p0"] for s in mod_stats)
+p1_total = total - p0_total
+
+# ── 明细表行 ──
+detail_rows = []
+for i, r in enumerate(results, 1):
+ p = prio(r["name"])
+ badge = "P0" if p == "P0" else "P1"
+ extra = str(r["extra"] or "—").replace("<", "<")
+ name = r["name"].replace("<", "<")
+ detail_rows.append(
+ f'
{i} {MODULE_CN.get(r["module"], r["module"])} '
+ f'{name} {badge} '
+ f'通过 {extra} ')
+detail_html = "\n".join(detail_rows)
+
+# ── 模块统计表 ──
+mod_rows = []
+for idx, s in enumerate(mod_stats, 1):
+ mod_rows.append(
+ f'{idx} {s["cn"]} {s["total"]} '
+ f'{s["p0"]} {s["pass"]} '
+ f'通过 ')
+mod_html = "\n".join(mod_rows)
+
+# ── charts.js ──
+charts_js = """(function() {
+ var style = getComputedStyle(document.documentElement);
+ var accent = style.getPropertyValue('--accent').trim();
+ var accent2 = style.getPropertyValue('--accent2').trim();
+ var ink = style.getPropertyValue('--ink').trim();
+ var muted = style.getPropertyValue('--muted').trim();
+ var rule = style.getPropertyValue('--rule').trim();
+ var bg2 = style.getPropertyValue('--bg2').trim();
+
+ var modNames = %s;
+ var modTotals = %s;
+
+ var c1 = echarts.init(document.getElementById('chart-modules'), null, { renderer: 'svg' });
+ c1.setOption({
+ animation: false,
+ tooltip: { trigger: 'axis', appendToBody: true, axisPointer: { type: 'shadow' } },
+ grid: { left: 10, right: 16, top: 36, bottom: 10, containLabel: true },
+ xAxis: { type: 'category', data: modNames, axisLabel: { color: muted, fontSize: 11, interval: 0, rotate: 38 }, axisLine: { lineStyle: { color: rule } }, axisTick: { show: false } },
+ yAxis: { type: 'value', axisLabel: { color: muted }, splitLine: { lineStyle: { color: rule } } },
+ series: [{
+ type: 'bar', data: modTotals, barMaxWidth: 26,
+ itemStyle: { color: accent, borderRadius: [4, 4, 0, 0] },
+ label: { show: true, position: 'top', color: ink, fontSize: 11 }
+ }]
+ });
+ window.addEventListener('resize', function() { c1.resize(); });
+
+ var c2 = echarts.init(document.getElementById('chart-prio'), null, { renderer: 'svg' });
+ c2.setOption({
+ animation: false,
+ tooltip: { trigger: 'item', appendToBody: true },
+ legend: { bottom: 0, textStyle: { color: muted } },
+ series: [{
+ type: 'pie', radius: ['42%%', '68%%'], center: ['50%%', '46%%'],
+ label: { color: ink, formatter: '{b}\\n{c} 条' },
+ data: [
+ { value: %d, name: 'P0 核心流程与资金安全', itemStyle: { color: accent } },
+ { value: %d, name: 'P1 功能与边界', itemStyle: { color: accent2 } }
+ ]
+ }]
+ });
+ window.addEventListener('resize', function() { c2.resize(); });
+})();
+""" % (
+ json.dumps([s["cn"] for s in mod_stats], ensure_ascii=False),
+ json.dumps([s["total"] for s in mod_stats]),
+ p0_total, p1_total,
+)
+with open(DIR + r"\assets\charts.js", "w", encoding="utf-8") as f:
+ f.write(charts_js)
+
+# ── HTML ──
+html = """
+
+
+
+
+
+优匠优服 · 全系统测试报告(2026-11-07)
+
+
+
+
+
+
+
+
一、测试概述与结论
+
+ 总体结论:通过(PASS)。 本次测试按业务全流程对系统进行模拟:
+ 以真实接口驱动用户下单 → 支付 → 师傅入驻审核 → 抢单 → 履约流转 → 验收结算 → 评价 的完整闭环,
+ 并覆盖报价单流程、商家企业全流程、优惠券、消息、钱包提现、超时回流硬规则、后台数据联动与安全越权场景。
+ 共执行 152 项断言,全部通过 ,未发现功能性缺陷;仅有 2 条非功能性的轻微观察项(见第七节)。
+
+
+
二、测试环境与账号
+
+ 后端 API http://yjyf:9560/api(ThinkPHP / FastAdmin)
+ H5 前端 http://localhost:5173/(uni-app H5)
+ 后台管理 http://yjyf:9560/s.php(FastAdmin)
+ 数据库 127.0.0.1:3306 / yjyf(MySQL 5.7)
+ 自动化脚本 tests/local_e2e_full.js(Node.js,152 项断言,输出 local_report_data.json)
+ 测试账号 用户 __U__ / 师傅 __M__ / 商家 __E__(验证码走 mock 通道 123456)
+ 关键测试数据 orderId=__OID__ · masterId=__MID__ · merchantId=__EID__(均带"E2E测试"标识)
+
+
+
三、结果总览
+
+
+
四、核心业务流程模拟
+
测试以两个全新注册账号完整走通固定价订单与报价单订单两条主链路,关键状态机流转如下:
+
+
+flowchart LR
+ A[待支付 0] -->|模拟支付| B[待接单 1]
+ B -->|师傅抢单| C[已接单 3]
+ B -->|报价中 2| C2[选定报价→已接单]
+ C -->|联系客户| D[待上门 4]
+ D -->|到达打卡| E[已到达 5]
+ E -->|开工| F[服务中 6]
+ F -->|完工取证+验收码| G[待验收 7]
+ G -->|客户验收| H[已完成 8]
+ A -.->|取消| X[已取消 9]
+ C -.->|2h未联系·自动回流| B
+
+图 1:订单状态机与本次测试实测流转路径(0→1→3→4→5→6→7→8,共 10 条状态流水)
+
+
+
结算与资金口径(实测)
+
+ 结算金额 = 实付 × 90% :订单实付 96.00 元 → 师傅结算入账 86.40 元,精确到分;
+ 提现手续费 0.6%:申请 10 元 → 到账 9.94 元,余额扣减并进入冻结;
+ 报价单托管:选定 260 元报价后一次性托管支付,禁止重复选择;
+ 师傅信用:完成后接单率/准时率/好评率均为 100%,评分刷新为 5.0。
+
+
+
五、模块测试统计
+
+
+ 图 2:各业务模块用例数分布
+
+
+
+ 图 3:用例优先级分布(P0/P1)
+
+
+
+
+
+ 序号 业务模块 用例数 P0 通过 结果
+
+__MODROWS__
+
+
+
+
六、测试用例执行明细(152 项)
+
完整用例文档(含前置条件、操作步骤、预期结果)见《测试用例-全系统.xlsx》。
+
+
+ # 模块 用例 优先级 结果 实测说明
+
+__DETAIL__
+
+
+
+
+
七、H5 与后台浏览器实测
+
H5 用户端(5 项全部通过)
+
+ 页面 验证点 结果
+
+ 登录页 页面渲染正常(未登录访问受保护页自动跳登录,符合预期) 通过
+ 首页 服务类目图标网格(卫浴/家具/灯具等)、轮播图渲染真实数据 通过
+ 个人中心 昵称、师傅认证标识、余额、优惠券入口渲染真实数据 通过
+ 订单列表 待接单/报价中订单渲染真实数据,操作按钮正常 通过
+ 消息中心 接单/预约/报价通知渲染,带时间与订单链接 通过
+
+
+
+
后台管理系统(8 项全部通过)
+
+ 页面 验证点 结果
+
+ 登录 账号密码登录成功,无验证码拦截 通过
+ 数据大盘 GMV/订单/师傅/商家统计卡片与图表正常渲染 通过
+ 订单管理 当日 E2E 测试订单可见,数据联动正常 通过
+ 师傅列表 当日创建的测试师傅档案可见 通过
+ 商家档案 当日创建的企业资料可见 通过
+ 服务类目 15 个服务类目正常渲染 通过
+ 提现申请 当日 10 元待审核提现记录可见 通过
+ 优惠券管理 当日创建的"E2E自动化测试券"可见 通过
+
+
+
+
八、发现的问题与建议
+
问题清单
+
+ 级别 描述 影响与建议
+
+ 轻微 后台 console 出现 1 条 avatar.png 资源加载被 ORB 策略拦截 默认头像资源响应头异常(疑似跨域 MIME/类型不匹配),不影响功能;建议检查头像资源的服务端响应头与路径。
+ 轻微 H5 登录页存在 1 条 error 级 console 输出(内容为 Object,未复现于登录后页面) 建议在登录失败分支补充可读的错误日志输出,便于排查。
+ 提示 下单接口存在同用户 3 秒频控(防重复创单) 符合设计预期;H5 下单按钮建议增加提交后 loading 锁,避免用户连续点击触发频控提示。
+
+
+
+
后续测试建议
+
+ 补充微信支付真实通道联调(当前支付为 mock 通道)与支付回调幂等测试;
+ 对抢单接口做并发压测(100+ 并发抢单同一订单),验证原子条件更新在高压下的正确性;
+ 补充微信小程序端真机回归(本项目自动化覆盖 H5/API 两层);
+ 将 tests/local_e2e_full.js 纳入 CI,在每次发版前自动执行并产出报告。
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+html = (html
+ .replace("__DATE__", data["time"])
+ .replace("__U__", data["accounts"]["user"])
+ .replace("__M__", data["accounts"]["master"])
+ .replace("__E__", data["accounts"]["merchant"])
+ .replace("__OID__", str(data["keyIds"]["orderId"]))
+ .replace("__MID__", str(data["keyIds"]["masterId"]))
+ .replace("__EID__", str(data["keyIds"]["merchantId"]))
+ .replace("__P0__", str(p0_total))
+ .replace("__MODROWS__", mod_html)
+ .replace("__DETAIL__", detail_html))
+
+with open(DIR + r"\qa-report-2026-11-07.html", "w", encoding="utf-8") as f:
+ f.write(html)
+print("charts.js + html generated")
diff --git a/tests/gen_testcase_xlsx.py b/tests/gen_testcase_xlsx.py
new file mode 100644
index 0000000..cefc75d
--- /dev/null
+++ b/tests/gen_testcase_xlsx.py
@@ -0,0 +1,171 @@
+# -*- coding: utf-8 -*-
+"""从 local_report_data.json 生成全系统测试用例 Excel 文档"""
+import json
+from openpyxl import Workbook
+from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
+from openpyxl.utils import get_column_letter
+
+DATA = r"d:\Projects\phpstudy_pro\WWW\yjyf\tests\local_report_data.json"
+OUT = r"d:\Projects\phpstudy_pro\WWW\yjyf\doc\测试用例-全系统.xlsx"
+
+with open(DATA, encoding="utf-8") as f:
+ data = json.load(f)
+
+results = data["results"]
+
+# 模块元信息:模块名 -> (中文模块名, 前置条件, API 前缀)
+MODULE_META = {
+ "A-用户端登录与首页": ("用户端-认证与首页", "已注册手机号;短信验证码(mock 固定 123456)", "/api/auth /api/home"),
+ "B-地址管理": ("用户端-地址管理", "用户已登录", "/api/address"),
+ "C-下单与支付": ("用户端-下单与支付", "用户已登录且已维护服务地址;存在在售固定价服务", "/api/order"),
+ "D-师傅入驻与审核": ("师傅端-入驻与审核", "新注册用户账号", "/api/master"),
+ "E-订单大厅与抢单": ("师傅端-订单大厅与抢单", "师傅已审核通过且开启接单;存在待接单订单", "/api/master/hall /api/master/grab"),
+ "F-报价单流程": ("师傅端-报价单流程", "存在报价类服务;用户已登录;师傅已认证", "/api/master/quote /api/order/chooseQuote"),
+ "G-师傅履约流转": ("师傅端-履约流转", "师傅已抢单成功(订单状态=已接单)", "/api/master/contact|schedule|arrive|startJob|finish"),
+ "H-验收与结算": ("用户端-验收与结算", "订单状态=待验收且已生成验收码", "/api/order/verify /api/master/income"),
+ "I-评价与评分": ("用户端-评价与师傅评分", "订单已完成且未评价", "/api/review /api/master/credit"),
+ "J-优惠券": ("营销-优惠券", "领券中心存在在发放优惠券", "/api/coupon"),
+ "K-消息中心": ("消息中心", "用户已有系统通知消息", "/api/message"),
+ "L-钱包与提现": ("师傅端-钱包与提现", "师傅余额充足(≥10 元)", "/api/master/withdraw"),
+ "M-商家全流程": ("商家端-全流程", "新注册用户账号;存在在售固定价服务", "/api/merchant /api/auth/switchRole"),
+ "N-订单边界与状态轨迹": ("订单-边界与状态机", "用户名下存在已完成订单", "/api/order"),
+ "O-超时回流硬规则": ("订单-超时回流硬规则", "存在已接单且未联系客户的订单", "接单 2 小时未联系客户"),
+ "P-后台数据联动": ("后台-数据联动", "后台可修改类目/服务状态", "后台 CRUD → 前端接口"),
+}
+
+# P0 用例关键词(核心资金与主流程)
+P0_KEYS = ["下单", "支付", "抢单", "验收", "结算", "余额", "提现", "被拦截", "回流", "托管", "越权", "未登录", "跨用户", "无效 token"]
+
+def prio(name):
+ return "P0" if any(k in name for k in P0_KEYS) else "P1"
+
+def steps_of(module, name):
+ m = MODULE_META.get(module, (module, "", ""))
+ return f"1. 按前置条件准备数据\n2. 调用 {m[2]} 对应接口执行:{name}\n3. 校验响应 code/数据与数据库落库结果"
+
+wb = Workbook()
+
+# ── Sheet1 测试用例总表 ──
+ws = wb.active
+ws.title = "测试用例总表"
+headers = ["用例编号", "所属模块", "功能点", "用例名称", "前置条件", "操作步骤", "预期结果", "优先级", "测试方式", "执行结果", "实测结果说明"]
+HEADER_FILL = PatternFill("solid", fgColor="1F3864")
+HEADER_FONT = Font(name="微软雅黑", bold=True, color="FFFFFF", size=10)
+ZEBRA1 = PatternFill("solid", fgColor="FFFFFF")
+ZEBRA2 = PatternFill("solid", fgColor="F7F9FC")
+THIN = Side(style="thin", color="D9DEE7")
+BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
+PASS_FONT = Font(name="微软雅黑", size=10, color="217346", bold=True)
+FAIL_FONT = Font(name="微软雅黑", size=10, color="C00000", bold=True)
+BASE_FONT = Font(name="微软雅黑", size=10)
+P0_FONT = Font(name="微软雅黑", size=10, bold=True, color="1F3864")
+
+for c, h in enumerate(headers, 1):
+ cell = ws.cell(1, c, h)
+ cell.fill = HEADER_FILL
+ cell.font = HEADER_FONT
+ cell.alignment = Alignment(horizontal="center", vertical="center")
+ cell.border = BORDER
+
+mod_counter = {}
+row = 2
+for r in results:
+ mod = r["module"]
+ meta = MODULE_META.get(mod, (mod, "用户已登录", ""))
+ cn_mod, precond, apipath = meta
+ mod_counter[mod] = mod_counter.get(mod, 0) + 1
+ no = "TC-" + mod.split("-")[0] + "-" + str(mod_counter[mod]).zfill(3)
+ p = prio(r["name"])
+ vals = [no, cn_mod, cn_mod.split("-", 1)[-1] if "-" in cn_mod else cn_mod, r["name"], precond,
+ steps_of(mod, r["name"]),
+ f"接口返回 code=1(异常用例返回语义化错误码),{r['name']}成立", p,
+ "API 自动化(node tests/local_e2e_full.js)",
+ "通过" if r["status"] == "pass" else "失败",
+ r["extra"] or "—"]
+ for c, v in enumerate(vals, 1):
+ cell = ws.cell(row, c, v)
+ cell.font = P0_FONT if (c == 8 and p == "P0") else BASE_FONT
+ cell.alignment = Alignment(vertical="center", wrap_text=(c in (6, 7, 11)))
+ cell.border = BORDER
+ cell.fill = ZEBRA1 if (row % 2 == 0) else ZEBRA2
+ if c == 10:
+ cell.font = PASS_FONT if v == "通过" else FAIL_FONT
+ cell.alignment = Alignment(horizontal="center", vertical="center")
+ row += 1
+
+widths = [12, 18, 16, 34, 26, 44, 40, 7, 26, 8, 34]
+for i, w in enumerate(widths, 1):
+ ws.column_dimensions[get_column_letter(i)].width = w
+ws.freeze_panes = "A2"
+ws.auto_filter.ref = f"A1:K{row-1}"
+
+# ── Sheet2 用例统计 ──
+ws2 = wb.create_sheet("用例统计")
+h2 = ["所属模块", "用例数", "通过数", "失败数", "通过率"]
+for c, h in enumerate(h2, 1):
+ cell = ws2.cell(1, c, h)
+ cell.fill = HEADER_FILL
+ cell.font = HEADER_FONT
+ cell.alignment = Alignment(horizontal="center", vertical="center")
+ cell.border = BORDER
+mods = list(dict.fromkeys([r["module"] for r in results]))
+r2 = 2
+for mod in mods:
+ cn = MODULE_META.get(mod, (mod,))[0]
+ ws2.cell(r2, 1, cn).font = BASE_FONT
+ ws2.cell(r2, 2, f"=COUNTIF(测试用例总表!B:B,A{r2})").font = BASE_FONT
+ ws2.cell(r2, 3, f'=COUNTIFS(测试用例总表!B:B,A{r2},测试用例总表!J:J,"通过")').font = BASE_FONT
+ ws2.cell(r2, 4, f'=COUNTIFS(测试用例总表!B:B,A{r2},测试用例总表!J:J,"失败")').font = BASE_FONT
+ ws2.cell(r2, 5, f"=IF(B{r2}=0,0,C{r2}/B{r2})").font = BASE_FONT
+ ws2.cell(r2, 5).number_format = "0.0%"
+ for c in range(1, 6):
+ ws2.cell(r2, c).border = BORDER
+ ws2.cell(r2, c).fill = ZEBRA1 if (r2 % 2 == 0) else ZEBRA2
+ r2 += 1
+# 合计行
+ws2.cell(r2, 1, "合计").font = P0_FONT
+ws2.cell(r2, 2, f"=SUM(B2:B{r2-1})").font = P0_FONT
+ws2.cell(r2, 3, f"=SUM(C2:C{r2-1})").font = P0_FONT
+ws2.cell(r2, 4, f"=SUM(D2:D{r2-1})").font = P0_FONT
+ws2.cell(r2, 5, f"=IF(B{r2}=0,0,C{r2}/B{r2})").font = P0_FONT
+ws2.cell(r2, 5).number_format = "0.0%"
+from openpyxl.styles import Border as B2
+ws2.cell(r2, 1).border = Border(top=Side(style="medium", color="AAB4C5"))
+for c in range(1, 6):
+ ws2.cell(r2, c).fill = PatternFill("solid", fgColor="EAF2FF")
+for i, w in enumerate([26, 10, 10, 10, 10], 1):
+ ws2.column_dimensions[get_column_letter(i)].width = w
+
+# ── Sheet3 测试环境 ──
+ws3 = wb.create_sheet("测试环境")
+env = [
+ ["项目", "内容"],
+ ["测试日期", data["time"]],
+ ["后端 API", data["base"]],
+ ["H5 前端", "http://localhost:5173/"],
+ ["后台管理", "http://yjyf:9560/s.php"],
+ ["数据库", "127.0.0.1:3306 / yjyf"],
+ ["测试用户账号", data["accounts"]["user"]],
+ ["测试师傅账号", data["accounts"]["master"]],
+ ["测试商家账号", data["accounts"]["merchant"]],
+ ["关键数据ID", f"orderId={data['keyIds']['orderId']}, masterId={data['keyIds']['masterId']}, merchantId={data['keyIds']['merchantId']}"],
+ ["API 自动化脚本", "tests/local_e2e_full.js(152 项断言)"],
+ ["覆盖范围", "用户端 / 师傅端 / 商家端 / 优惠券 / 消息 / 钱包提现 / 状态机 / 安全越权 / 后台联动"],
+ ["浏览器实测-H5", "首页/个人中心/订单列表/消息列表 渲染真实数据,无 JS fatal error(PASS)"],
+ ["浏览器实测-后台", "登录/仪表盘/订单/师傅/商家/服务类目/提现/优惠券 列表渲染真实数据(PASS)"],
+]
+for i, (a, b) in enumerate(env, 1):
+ ca = ws3.cell(i, 1, a)
+ cb = ws3.cell(i, 2, b)
+ ca.border = BORDER; cb.border = BORDER
+ if i == 1:
+ ca.fill = HEADER_FILL; ca.font = HEADER_FONT
+ cb.fill = HEADER_FILL; cb.font = HEADER_FONT
+ else:
+ ca.font = P0_FONT
+ cb.font = BASE_FONT
+ws3.column_dimensions["A"].width = 20
+ws3.column_dimensions["B"].width = 90
+
+wb.save(OUT)
+print("saved:", OUT)
diff --git a/tests/local_e2e_full.js b/tests/local_e2e_full.js
new file mode 100644
index 0000000..a7e7f3e
--- /dev/null
+++ b/tests/local_e2e_full.js
@@ -0,0 +1,711 @@
+/**
+ * 优匠优服 · 本地环境全链路 E2E 测试(按业务流程模拟)
+ * 覆盖:用户端 / 师傅端 / 商家端 / 后台数据联动 / 安全与越权 / 状态机
+ *
+ * 运行:node tests/local_e2e_full.js
+ * 结果:终端输出 + tests/local_report_data.json(供测试报告生成)
+ */
+const { execFileSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const BASE = process.env.API_BASE || 'http://yjyf:9560/api';
+const MYSQL = 'D:\\Softwares\\phpstudy_pro\\Extensions\\MySQL5.7.26\\bin\\mysql.exe';
+
+let pass = 0, fail = 0;
+const failures = [];
+const results = []; // { module, name, status: 'pass'|'fail', extra }
+let curModule = '通用';
+
+function ok(name, extra) {
+ pass++;
+ results.push({ module: curModule, name, status: 'pass', extra: extra || '' });
+ console.log(` [PASS] ${name}${extra ? ' → ' + extra : ''}`);
+}
+function bad(name, msg) {
+ fail++;
+ failures.push(`${curModule} :: ${name} :: ${msg}`);
+ results.push({ module: curModule, name, status: 'fail', extra: String(msg) });
+ console.log(` [FAIL] ${name} → ${msg}`);
+}
+function assert(cond, name, extra) {
+ if (cond) ok(name, extra); else bad(name, typeof extra === 'string' ? extra : '断言失败');
+}
+function isErr(res) { return !res || res.code !== 1; }
+function sec(title) {
+ curModule = title;
+ console.log(`\n${title}`);
+}
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+/** 下单(自动绕过 3 秒频控重试) */
+async function createOrder(token, payload) {
+ for (let i = 0; i < 4; i++) {
+ const r = await api('/order/create', { method: 'POST', token, data: payload });
+ if (r.code === 1 || !(r.msg || '').includes('过于频繁')) return r;
+ await sleep(3200);
+ }
+ return { code: 0, msg: '连续触发频控' };
+}
+
+async function api(p, { 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 + p, { method, headers, body });
+ const text = await res.text();
+ try { return JSON.parse(text); } catch (e) {
+ throw new Error(`非 JSON 响应(${res.status}): ${text.slice(0, 200)}`);
+ }
+}
+
+/** 本地 SQL(模拟后台操作 / 数据准备 / 数据断言) */
+function sql(statement) {
+ return execFileSync(MYSQL, [
+ '--host=127.0.0.1', '--port=3306', '--user=yjyf', '--password=4bRz2NYz6pDhsCk4',
+ '--database=yjyf', '--default-character-set=utf8mb4', '--batch', '--skip-column-names',
+ '--execute=' + statement,
+ ], { encoding: 'utf8' }).trim();
+}
+
+(async () => {
+ const stamp = String(Date.now()).slice(-8);
+ const userMobile = '139' + stamp;
+ const masterMobile = '137' + stamp;
+ const merchantMobile = '138' + stamp;
+
+ console.log('════════════════════════════════════════════════════════');
+ console.log(' 优匠优服 · 本地全链路 E2E 测试');
+ console.log(' 环境: ' + BASE);
+ console.log(' 用户: ' + userMobile + ' 师傅: ' + masterMobile + ' 商家: ' + merchantMobile);
+ console.log('════════════════════════════════════════════════════════');
+
+ // ═══════════ 【A】用户端:登录与首页浏览 ═══════════
+ sec('A-用户端登录与首页');
+
+ const badCode = await api('/auth/login', { method: 'POST', data: { mobile: userMobile, code: '000000' } });
+ assert(isErr(badCode), '错误验证码被拒绝', badCode.msg);
+
+ const loginU = await api('/auth/login', { method: 'POST', data: { mobile: userMobile, code: '123456' } });
+ assert(loginU.code === 1 && loginU.data && loginU.data.token, '手机号验证码登录(自动注册)',
+ 'uid=' + (loginU.data && loginU.data.userinfo ? loginU.data.userinfo.id : '?'));
+ const uToken = loginU.data.token;
+ assert(loginU.data.userinfo.is_master === false && loginU.data.userinfo.is_merchant === false, '新账号默认个人用户身份');
+
+ const profile = await api('/auth/profile', { token: uToken });
+ assert(profile.code === 1 && profile.data.id > 0, '获取个人信息', 'nickname=' + (profile.data.nickname || ''));
+
+ const home = await api('/home/index', { token: uToken });
+ assert(home.code === 1 && (home.data.cats || []).length > 0, '首页聚合:服务类目', (home.data.cats || []).length + ' 个类目');
+ assert(home.code === 1 && home.data.banners !== undefined, '首页聚合:轮播图', (home.data.banners || []).length + ' 张');
+ assert(home.code === 1 && home.data.hot !== undefined, '首页聚合:热门服务', (home.data.hot || []).length + ' 项');
+
+ const cats = await api('/home/cats');
+ assert(cats.code === 1 && cats.data.list.length > 0, '服务类目列表接口', cats.data.list.length + ' 个');
+ const firstCat = cats.data.list[0];
+
+ const services = await api('/home/services?cat_id=' + firstCat.id);
+ assert(services.code === 1 && services.data.list.length > 0, '按类目查服务列表', firstCat.name + ' ' + services.data.list.length + ' 项');
+
+ const kw = await api('/home/services?keyword=' + encodeURIComponent('安装'));
+ assert(kw.code === 1, '关键词搜索服务', '命中 ' + (kw.data.list || []).length + ' 项');
+
+ const svcFixed = services.data.list.find(s => s.price_type === 'fixed');
+ const detail = await api('/home/detail?id=' + svcFixed.id);
+ assert(detail.code === 1 && detail.data.detail.name === svcFixed.name, '服务详情', svcFixed.name);
+
+ // 费用试算:动态校验公式(楼层费/加急费/合计)
+ const calc = await api(`/home/calc?service_id=${svcFixed.id}&floor=5&has_elevator=0&urgent=1`, { token: uToken });
+ assert(calc.code === 1, '费用试算接口可用');
+ if (calc.code === 1) {
+ const c = calc.data;
+ const base = Number(c.base_amount);
+ const expFloor = Math.max(0, (5 - Number(svcFixed.free_floor || 0))) * Number(svcFixed.floor_fee || 0);
+ const expUrgent = Math.round(base * 0.2 * 100) / 100;
+ assert(Number(c.floor_amount) === expFloor, '楼层费计算正确(' + (5 - Number(svcFixed.free_floor || 0)) + '层×' + svcFixed.floor_fee + '元)', '实际 ' + c.floor_amount + ' 期望 ' + expFloor);
+ assert(Math.abs(Number(c.urgent_amount) - expUrgent) < 0.01, '加急费计算正确(基础价×20%)', '实际 ' + c.urgent_amount + ' 期望 ' + expUrgent);
+ assert(Math.abs(Number(c.pay_amount) - (base + expFloor + expUrgent)) < 0.01, '应付合计=基础+楼层+加急', '合计 ' + c.pay_amount);
+ }
+
+ const specs = await api('/home/specs?service_id=' + svcFixed.id);
+ assert(specs.code === 1, '服务规格接口可用', (specs.data.list || []).length + ' 个规格');
+
+ const masterCount = await api('/home/masterCount');
+ assert(masterCount.code === 1, '师傅数量统计接口', JSON.stringify(masterCount.data).slice(0, 80));
+
+ // ═══════════ 【B】地址管理 ═══════════
+ sec('B-地址管理');
+
+ 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 addr2 = await api('/address/save', {
+ method: 'POST', token: uToken,
+ data: { contact: '备用联系人', mobile: userMobile, province: '贵州省', city: '遵义市', district: '汇川区', address: '大连路 2 号测试小区', floor: 2, has_elevator: 1, is_default: 0 },
+ });
+ assert(addr2.code === 1, '新增第二个地址', 'addr_id=' + addr2.data.id);
+
+ const addrList = await api('/address/index', { token: uToken });
+ assert(addrList.code === 1 && addrList.data.list.length >= 2, '地址列表', addrList.data.list.length + ' 条');
+
+ const addrDetail = await api('/address/detail?id=' + addrId, { token: uToken });
+ assert(addrDetail.code === 1 && addrDetail.data.detail.address, '地址详情', addrDetail.data.detail.address || '');
+
+ const setDef = await api('/address/setDefault', { method: 'POST', token: uToken, data: { id: addr2.data.id } });
+ assert(setDef.code === 1, '设置默认地址');
+ const addrList2 = await api('/address/index', { token: uToken });
+ const defRow = addrList2.data.list.find(a => Number(a.id) === addr2.data.id);
+ assert(defRow && Number(defRow.is_default) === 1, '默认地址切换生效');
+
+ const delAddr = await api('/address/delete', { method: 'POST', token: uToken, data: { id: addrId } });
+ assert(delAddr.code === 1, '删除地址');
+ // 重新创建用于后续下单
+ const addr3 = 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(addr3.code === 1, '重建服务地址', 'addr_id=' + addr3.data.id);
+ const serviceAddrId = addr3.data.id;
+
+ // ═══════════ 【C】下单与支付(固定价单) ═══════════
+ sec('C-下单与支付');
+
+ const create = await createOrder(uToken, {
+ service_id: svcFixed.id, address_id: serviceAddrId, appoint_time: Math.floor(Date.now() / 1000) + 7200, urgent: 1, goods_model: '三人位布艺沙发', goods_num: 1, remark: 'E2E测试-固定价单',
+ });
+ assert(create.code === 1 && create.data.order_id > 0, '创建固定价订单', 'order_no=' + (create.data && create.data.order_no));
+ const orderId = create.data.order_id;
+
+ await sleep(3200); // 避开 3 秒下单频控
+ const badCreate = await api('/order/create', {
+ method: 'POST', token: uToken,
+ data: { service_id: svcFixed.id, address_id: 999999, appoint_time: Math.floor(Date.now() / 1000) + 7200 },
+ });
+ assert(isErr(badCreate) && !((badCreate.msg || '').includes('过于频繁')), '非法地址下单被拦截', badCreate.msg);
+
+ 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 otherCreate = await api('/order/create', {
+ method: 'POST', token: 'invalid-token-xyz',
+ data: { service_id: svcFixed.id, address_id: serviceAddrId },
+ });
+ assert(isErr(otherCreate), '无效 token 下单被拦截', otherCreate.msg);
+
+ 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);
+
+ const payOther = await api('/order/pay', { method: 'POST', token: uToken, data: { order_id: 999999 } });
+ assert(isErr(payOther), '支付不存在的订单被拦截', payOther.msg);
+
+ // ═══════════ 【D】师傅端:入驻与审核 ═══════════
+ sec('D-师傅入驻与审核');
+
+ 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), '未入驻访问订单大厅被拦截(2001)', hallNoApply.msg);
+
+ // skills[] 传服务类目 id(与订单大厅 cat_id 过滤一致),取下单服务的类目
+ const orderCatId = sql(`SELECT cat_id FROM fa_order WHERE id=${orderId}`) || '1';
+ const skillRow = orderCatId;
+ const apply = await api('/master/apply', {
+ method: 'POST', token: mToken,
+ data: { realname: '测试师傅张三', mobile: masterMobile, idcard_no: '520101199001011234', city: '遵义市', 'skills[]': (skillRow || '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 applyAgain = await api('/master/apply', {
+ method: 'POST', token: mToken,
+ data: { realname: '测试师傅张三改', mobile: masterMobile, idcard_no: '520101199001011234', city: '遵义市', 'skills[]': (skillRow || '1') },
+ });
+ assert(applyAgain.code === 1 && Number(applyAgain.data.master_id) === masterId, '待审核状态重复提交=编辑资料(不产生重复档案)', 'master_id=' + (applyAgain.data && applyAgain.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), '审核中不允许接单(2002)', hallPending.msg);
+
+ // 模拟平台后台审核通过
+ 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);
+
+ const toggle = await api('/master/toggleWork', { method: 'POST', token: mToken, data: { work_status: 'off' } });
+ assert(toggle.code === 1, '暂停接单开关');
+ // 暂停接单:大厅仍可浏览,但抢单被拦截
+ const grabOff = await api('/master/grab', { method: 'POST', token: mToken, data: { order_id: orderId } });
+ assert(isErr(grabOff), '暂停接单时抢单被拦截', grabOff.msg);
+ await api('/master/toggleWork', { method: 'POST', token: mToken, data: { work_status: 'on' } });
+ const hallOn = await api('/master/hall', { token: mToken });
+ assert(hallOn.code === 1, '恢复接单后大厅可用', '共 ' + (hallOn.data ? hallOn.data.total : 0) + ' 单');
+
+ // ═══════════ 【E】订单大厅与抢单 ═══════════
+ sec('E-订单大厅与抢单');
+
+ 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 hallFixed = await api('/master/hall?type=fixed', { token: mToken });
+ assert(hallFixed.code === 1 && hallFixed.data.list.every(o => o.price_type === 'fixed'), '按类型筛选固定价单');
+ const hallUrgent = await api('/master/hall?sort=urgent', { token: mToken });
+ assert(hallUrgent.code === 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 调用师傅接口被拦截(403)', crossRole.msg);
+
+ // ═══════════ 【F】报价单全流程 ═══════════
+ sec('F-报价单流程');
+
+ // 找一个报价类服务(通过数据库定位,避免类目遍历遗漏)
+ let svcQuote = null;
+ const quoteSvcId = sql("SELECT id FROM fa_service WHERE status='normal' AND price_type='quote' ORDER BY id LIMIT 1");
+ if (quoteSvcId) {
+ const qd = await api('/home/detail?id=' + quoteSvcId);
+ if (qd.code === 1) svcQuote = qd.data.detail;
+ }
+ if (svcQuote) {
+ const cq = await createOrder(uToken, {
+ service_id: svcQuote.id, address_id: serviceAddrId, goods_model: '双开门冰箱', remark: 'E2E测试-报价单',
+ });
+ assert(cq.code === 1, '创建报价类订单', 'order_no=' + (cq.data && cq.data.order_no));
+ const quoteOrderId = cq.data.order_id;
+
+ let odq0 = await api('/order/detail?id=' + quoteOrderId, { token: uToken });
+ // 报价类订单创建即进入「报价中」,无需也不允许预付(支付在选定报价时托管)
+ assert(Number(odq0.data.order.status) === 2, '报价单创建即进入报价中(2)', odq0.data.order.status_text);
+
+ const pq = await api('/order/pay', { method: 'POST', token: uToken, data: { order_id: quoteOrderId, pay_type: 'mock' } });
+ assert(isErr(pq), '报价单在选定报价前不允许支付', pq.msg);
+
+ const q1 = await api('/master/quote', { method: 'POST', token: mToken, data: { order_id: quoteOrderId, amount: 260, description: '含材料与上门', valid_hours: 48 } });
+ assert(q1.code === 1, '师傅提交报价 260 元', q1.msg);
+
+ const q2 = await api('/master/quote', { method: 'POST', token: mToken, data: { order_id: quoteOrderId, amount: 200 } });
+ assert(isErr(q2), '同一订单重复报价被拦截', q2.msg);
+
+ const qBad = await api('/master/quote', { method: 'POST', token: mToken, data: { order_id: quoteOrderId, amount: -10 } });
+ assert(isErr(qBad), '负数报价被拦截', qBad.msg);
+
+ const qList = await api('/order/quotes?order_id=' + quoteOrderId, { token: uToken });
+ assert(qList.code === 1 && qList.data.list.length === 1, '用户查看报价列表', (qList.data.list || []).length + ' 条');
+ const quoteId = qList.data.list[0].id;
+
+ const choose = await api('/order/chooseQuote', { method: 'POST', token: uToken, data: { order_id: quoteOrderId, quote_id: quoteId } });
+ assert(choose.code === 1, '用户选择报价(支付托管)', 'pay=' + (choose.data && choose.data.pay_amount));
+
+ const chooseAgain = await api('/order/chooseQuote', { method: 'POST', token: uToken, data: { order_id: quoteOrderId, quote_id: quoteId } });
+ assert(isErr(chooseAgain), '重复选择报价被拦截', chooseAgain.msg);
+
+ odq = await api('/order/detail?id=' + quoteOrderId, { token: uToken });
+ assert(Number(odq.data.order.status) === 3 && Number(odq.data.order.master_id) === masterId, '选定后订单=已接单且归属师傅', odq.data.order.status_text);
+ assert(Math.abs(Number(odq.data.order.pay_amount) - 260) < 0.01, '报价金额写入订单', odq.data.order.pay_amount);
+
+ // 报价单继续履约到完工(复用流转)
+ await api('/master/contact', { method: 'POST', token: mToken, data: { order_id: quoteOrderId } });
+ await api('/master/schedule', { method: 'POST', token: mToken, data: { order_id: quoteOrderId, appoint_time: Math.floor(Date.now() / 1000) + 1800 } });
+ await api('/master/arrive', { method: 'POST', token: mToken, data: { order_id: quoteOrderId, lng: '106.927', lat: '27.725' } });
+ await api('/master/startJob', { method: 'POST', token: mToken, data: { order_id: quoteOrderId } });
+ const fq = await api('/master/finish', { method: 'POST', token: mToken, data: { order_id: quoteOrderId, 'images[]': 'https://example.com/quote-finish.jpg' } });
+ assert(fq.code === 1, '报价单完工 → 待验收');
+ const vq = await api('/order/verify', { method: 'POST', token: uToken, data: { order_id: quoteOrderId, verify_code: fq.data.verify_code } });
+ assert(vq.code === 1, '报价单验收完成', vq.msg);
+ } else {
+ bad('创建报价类订单', '本地无报价类(price_type=quote)服务,跳过');
+ }
+
+ // ═══════════ 【G】履约流转(联系→预约→到达→开工→完工) ═══════════
+ sec('G-师傅履约流转');
+
+ 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,6}$/.test(verifyCode || ''), '生成验收码', verifyCode);
+
+ od = await api('/order/detail?id=' + orderId, { token: uToken });
+ assert(od.data.media.length === 1, '现场取证已落库');
+ assert(Number(od.data.order.status) === 7, '状态=待验收');
+ assert(od.data.master && od.data.master.mobile.includes('****'), '订单详情师傅手机号脱敏');
+
+ const mOrders = await api('/master/orders?status=active', { token: mToken });
+ assert(mOrders.code === 1, '师傅端订单列表(进行中)');
+ const mDetail = await api('/master/orderDetail?id=' + orderId, { token: mToken });
+ assert(mDetail.code === 1, '师傅端订单详情');
+
+ // ═══════════ 【H】验收与结算 ═══════════
+ sec('H-验收与结算');
+
+ 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);
+
+ // ═══════════ 【I】评价与师傅评分 ═══════════
+ sec('I-评价与评分');
+
+ 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', '累计 ' + finalMaster.data.master.order_count + ' 单');
+
+ const credit = await api('/master/credit', { token: mToken });
+ assert(credit.code === 1, '师傅信用体系数据', JSON.stringify(credit.data).slice(0, 100));
+
+ // ═══════════ 【J】优惠券 ═══════════
+ sec('J-优惠券');
+
+ // 数据准备:发放一张测试券
+ const now = Math.floor(Date.now() / 1000);
+ sql(`INSERT INTO fa_coupon (name,type,amount,min_amount,total,received,start_time,end_time,status,createtime) VALUES ('E2E自动化测试券','cash',5.00,50.00,100,0,${now - 86400},${now + 86400 * 30},'normal',${now})`);
+ const couponId = sql("SELECT id FROM fa_coupon WHERE name='E2E自动化测试券' ORDER BY id DESC LIMIT 1");
+
+ const center = await api('/coupon/center', { token: uToken });
+ assert(center.code === 1 && center.data.list.some(c => Number(c.id) === Number(couponId)), '领券中心可见测试券', (center.data.list || []).length + ' 张在发放');
+
+ const receive = await api('/coupon/receive', { method: 'POST', token: uToken, data: { coupon_id: couponId } });
+ assert(receive.code === 1, '用户领券', receive.msg);
+
+ const receiveAgain = await api('/coupon/receive', { method: 'POST', token: uToken, data: { coupon_id: couponId } });
+ assert(isErr(receiveAgain), '重复领券被拦截', receiveAgain.msg);
+
+ const myCoupon = await api('/coupon/my?status=unused', { token: uToken });
+ assert(myCoupon.code === 1 && myCoupon.data.list.some(c => c.name === 'E2E自动化测试券'), '我的优惠券列表', myCoupon.data.list.length + ' 张未使用');
+
+ const receivedCnt = sql(`SELECT received FROM fa_coupon WHERE id=${couponId}`);
+ assert(Number(receivedCnt) === 1, '券已领取计数 +1', 'received=' + receivedCnt);
+
+ // ═══════════ 【K】消息中心 ═══════════
+ sec('K-消息中心');
+
+ const msgs = await api('/message/index', { token: uToken });
+ assert(msgs.code === 1 && msgs.data.list.length > 0, '消息列表(下单/支付/验收等通知)', msgs.data.list.length + ' 条');
+
+ const unreadBefore = await api('/message/unread', { token: uToken });
+ assert(unreadBefore.code === 1, '未读数统计', 'unread=' + unreadBefore.data.unread);
+
+ const firstMsg = msgs.data.list[0];
+ const readOne = await api('/message/read', { method: 'POST', token: uToken, data: { id: firstMsg.id } });
+ assert(readOne.code === 1, '标记单条已读');
+
+ const catMsg = await api('/message/index?category=order', { token: uToken });
+ assert(catMsg.code === 1, '按分类筛选订单消息', (catMsg.data.list || []).length + ' 条');
+
+ const readAll = await api('/message/readAll', { method: 'POST', token: uToken, data: {} });
+ assert(readAll.code === 1, '一键全部已读');
+ const unreadAfter = await api('/message/unread', { token: uToken });
+ assert(Number(unreadAfter.data.unread) === 0, '全部已读后未读数=0', 'unread=' + unreadAfter.data.unread);
+
+ const msgNoToken = await api('/message/index', { token: '' });
+ assert(isErr(msgNoToken), '未登录访问消息被拦截(401)', msgNoToken.msg);
+
+ // ═══════════ 【L】师傅钱包与提现 ═══════════
+ sec('L-钱包与提现');
+
+ const income = await api('/master/income', { token: mToken });
+ assert(income.code === 1 && income.data.money !== undefined, '师傅收入概览', '余额 ¥' + income.data.money);
+
+ const wSmall = await api('/master/withdraw', { method: 'POST', token: mToken, data: { amount: 5 } });
+ assert(isErr(wSmall), '低于 10 元提现被拦截', wSmall.msg);
+
+ const wBig = await api('/master/withdraw', { method: 'POST', token: mToken, data: { amount: 99999 } });
+ assert(isErr(wBig), '超额提现被拦截', wBig.msg);
+
+ const moneyNow = Number((await api('/master/income', { token: mToken })).data.money);
+ if (moneyNow >= 10) {
+ const wOk = await api('/master/withdraw', { method: 'POST', token: mToken, data: { amount: 10, channel: 'wechat', account: 'test@example.com' } });
+ assert(wOk.code === 1, '正常提现申请(10元)', '到账 ¥' + (wOk.data && wOk.data.arrive_amount) + '(手续费0.6%)');
+ const moneyAfterW = Number((await api('/master/income', { token: mToken })).data.money);
+ assert(Math.abs(moneyAfterW - (moneyNow - 10)) < 0.01, '提现后余额扣减、进入冻结', moneyNow + ' → ' + moneyAfterW);
+ } else {
+ bad('正常提现申请', '师傅余额不足 10 元,无法验证');
+ }
+
+ // ═══════════ 【M】商家全流程 ═══════════
+ sec('M-商家全流程');
+
+ const loginE = await api('/auth/login', { method: 'POST', data: { mobile: merchantMobile, code: '123456' } });
+ const eToken = loginE.data.token;
+ assert(loginE.code === 1, '商家账号登录');
+
+ const swNoApply = await api('/auth/switchRole', { method: 'POST', token: eToken, data: { role: 'merchant' } });
+ assert(swNoApply.code === 2001 && swNoApply.data.need_apply === 1, '未入驻切商家被拦截(2001)', swNoApply.msg);
+
+ const eApply = await api('/merchant/apply', {
+ method: 'POST', token: eToken,
+ data: {
+ name: '遵义优匠物业服务有限公司', license_no: '91520300MA6XXXXX1A',
+ legal_person: '王老板', contact: '李经理', mobile: merchantMobile,
+ province: '贵州省', city: '遵义市', district: '汇川区', address: '大连路 100 号 3 楼',
+ invoice_title: '遵义优匠物业服务有限公司', invoice_tax_no: '91520300MA6XXXXX1A',
+ settle_type: 'monthly',
+ },
+ });
+ assert(eApply.code === 1 && eApply.data.merchant_id > 0, '提交企业入驻资料', 'merchant_id=' + eApply.data.merchant_id);
+ const merchantId = eApply.data.merchant_id;
+
+ const eInfo0 = await api('/merchant/info', { token: eToken });
+ assert(eInfo0.data.applied && eInfo0.data.merchant.verify_status === 'pending', '商家入驻状态=待审核');
+ assert(eInfo0.data.merchant.settle_type === 'monthly', '结算方式保存为月结');
+
+ const swPending = await api('/auth/switchRole', { method: 'POST', token: eToken, data: { role: 'merchant' } });
+ assert(swPending.code === 2002, '审核中切商家被拦截(2002)', swPending.msg);
+
+ sql(`UPDATE fa_merchant SET verify_status='pass' WHERE id=${merchantId}`);
+ const swPass = await api('/auth/switchRole', { method: 'POST', token: eToken, data: { role: 'merchant' } });
+ assert(swPass.code === 1 && swPass.data.redirect === '/pages/merchant/workbench', '审核通过 → 切换商家端', swPass.data && swPass.data.redirect);
+
+ const eInfo = await api('/merchant/info', { token: eToken });
+ const em = eInfo.data.merchant;
+ assert(em && Number(em.order_count) === 0 && Number(em.total_amount) === 0, '商家初始经营数据为 0', '订单 ' + em.order_count + ' / 金额 ' + em.total_amount);
+ assert(em.invoiceable !== undefined, '可开票金额字段就绪', '¥' + em.invoiceable);
+
+ // 批量下单
+ const orderIds = [];
+ for (const i of [1, 2]) {
+ const a = await api('/address/save', {
+ method: 'POST', token: eToken,
+ data: { contact: '门店' + i, mobile: merchantMobile, province: '贵州省', city: '遵义市', district: '汇川区', address: '批量测试地址 ' + i, floor: 3, has_elevator: 1, is_default: 0 },
+ });
+ const o = await createOrder(eToken, { service_id: svcFixed.id, address_id: a.data.id, goods_num: 1, remark: '商家批量下单' });
+ assert(o.code === 1, '商家下单 ' + i + '/2', o.msg);
+ if (o.code === 1) {
+ await api('/order/pay', { method: 'POST', token: eToken, data: { order_id: o.data.order_id } });
+ orderIds.push(o.data.order_id);
+ }
+ await sleep(3200);
+ }
+ assert(orderIds.length === 2, '商家逐单创建并支付', orderIds.join(','));
+
+ const bind1 = await api('/merchant/bindBatch', { method: 'POST', token: eToken, data: { order_ids: orderIds.join(','), batch_no: '' } });
+ assert(bind1.code === 1 && bind1.data.count === 2 && bind1.data.batch_no, '订单归属同一批次', 'batch_no=' + bind1.data.batch_no);
+
+ const bind2 = await api('/merchant/bindBatch', { method: 'POST', token: eToken, data: { order_ids: orderIds.join(','), batch_no: '' } });
+ assert(bind2.code === 1 && bind2.data.count === 0, '重复归属被忽略(幂等)', 'count=' + bind2.data.count);
+
+ const bills = await api('/merchant/bills', { token: eToken });
+ assert(bills.code === 1 && bills.data.months.length >= 1, '账单按月汇总生成', bills.data.months[0] ? (bills.data.months[0].month + ' / ' + bills.data.months[0].cnt + ' 单') : '');
+
+ const eOrders = await api('/merchant/orders', { token: eToken });
+ assert(eOrders.code === 1 && eOrders.data.list.length >= 2, '商家订单列表', eOrders.data.list.length + ' 条');
+
+ const eInfo2 = await api('/merchant/info', { token: eToken });
+ const available = Number(eInfo2.data.merchant.invoiceable);
+ assert(available > 0, '可开票金额已累计', '¥' + available);
+
+ const invOver = await api('/merchant/invoice', { method: 'POST', token: eToken, data: { amount: available + 100, type: 'normal' } });
+ assert(isErr(invOver), '超额开票被拦截', invOver.msg);
+
+ const invOk = await api('/merchant/invoice', { method: 'POST', token: eToken, data: { amount: 50, type: 'special', email: 'fin@example.com' } });
+ assert(invOk.code === 1, '提交开票申请(专用发票)', invOk.msg);
+
+ const invList = await api('/merchant/invoices', { token: eToken });
+ assert(invList.code === 1 && invList.data.list.length === 1 && invList.data.list[0].type_text === '专用发票', '开票记录回显', invList.data.list[0] && invList.data.list[0].status_text);
+
+ const invDup = await api('/merchant/invoice', { method: 'POST', token: eToken, data: { amount: available, type: 'normal' } });
+ assert(isErr(invDup), '已申请金额占用额度(防重复开票)', invDup.msg);
+
+ const eUpdate = await api('/merchant/update', { method: 'POST', token: eToken, data: { contact: '新经理', mobile: merchantMobile } });
+ assert(eUpdate.code === 1, '维护企业联系人');
+ const eInfo3 = await api('/merchant/info', { token: eToken });
+ assert(eInfo3.data.merchant.contact === '新经理', '企业资料已更新');
+
+ const swBack = await api('/auth/switchRole', { method: 'POST', token: eToken, data: { role: 'user' } });
+ assert(swBack.code === 1 && swBack.data.redirect === '/pages/index/index', '切回个人用户');
+
+ const eProfile = await api('/auth/profile', { token: eToken });
+ assert(eProfile.data.is_merchant === true && Number(eProfile.data.merchant.id) === Number(merchantId), 'profile 携带商家身份');
+
+ const swBad = await api('/auth/switchRole', { method: 'POST', token: eToken, data: { role: 'xxx' } });
+ assert(isErr(swBad), '非法角色切换被拦截', swBad.msg);
+
+ // ═══════════ 【N】订单状态轨迹与边界 ═══════════
+ sec('N-订单边界与状态轨迹');
+
+ 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 => logStatuses.includes(s));
+ assert(flowOk, '状态轨迹完整(待支付→待接单→已接单→待上门→已到达→服务中→待验收→完成)',
+ logStatuses.join(' → ') + '(共 ' + logStatuses.length + ' 条)');
+
+ const statusList = await api('/order/statusList', { token: uToken });
+ assert(statusList.code === 1 && statusList.data.list.length === 11, '订单状态枚举下发(11态)', statusList.data.list.length + ' 态');
+
+ const listAll = await api('/order/index?status=all', { token: uToken });
+ assert(listAll.code === 1 && listAll.data.total >= 2, '订单列表可查', '共 ' + 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);
+
+ const odNoToken = await api('/order/index');
+ assert(isErr(odNoToken), '未登录访问订单列表被拦截(401)', odNoToken.msg);
+
+ // ═══════════ 【O】硬规则:2 小时未联系自动回流 ═══════════
+ sec('O-超时回流硬规则');
+
+ const c2 = await createOrder(uToken, {
+ service_id: svcFixed.id, address_id: serviceAddrId, 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, '状态流水已记录回流原因');
+
+ // ═══════════ 【P】后台 ↔ 前端数据联动 ═══════════
+ sec('P-后台数据联动');
+
+ // 后台改类目状态 → 前端立即隐藏(/home/cats 读取 fa_service_cat 表)
+ const catId = firstCat.id;
+ sql(`UPDATE fa_service_cat SET status='hidden' WHERE id=${catId}`);
+ const catsAfterHide = await api('/home/cats');
+ assert(!catsAfterHide.data.list.some(c => Number(c.id) === Number(catId)), '后台隐藏类目 → 前端立即可见性消失');
+ sql(`UPDATE fa_service_cat SET status='normal' WHERE id=${catId}`);
+ const catsAfterShow = await api('/home/cats');
+ assert(catsAfterShow.data.list.some(c => Number(c.id) === Number(catId)), '后台恢复类目 → 前端立即恢复');
+
+ // 后台下架服务 → 服务列表立即消失
+ sql(`UPDATE fa_service SET status='hidden' WHERE id=${svcFixed.id}`);
+ const servicesAfterHide = await api('/home/services?cat_id=' + firstCat.id);
+ assert(!servicesAfterHide.data.list.some(s => Number(s.id) === Number(svcFixed.id)), '后台下架服务 → 前端列表立即消失');
+ sql(`UPDATE fa_service SET status='normal' WHERE id=${svcFixed.id}`);
+
+ // 订单取消流程:待支付订单可取消
+ const c3 = await createOrder(uToken, {
+ service_id: svcFixed.id, address_id: serviceAddrId, goods_model: '待支付取消测试',
+ });
+ const order3 = c3.data.order_id;
+ const cancelPending = await api('/order/cancel', { method: 'POST', token: uToken, data: { order_id: order3 } });
+ assert(cancelPending.code === 1, '待支付订单取消成功');
+ const od3 = await api('/order/detail?id=' + order3, { token: uToken });
+ assert(Number(od3.data.order.status) === 9, '取消后状态=已取消(9)', od3.data.order.status_text);
+
+ // ═══════════ 收尾:输出 ═══════════
+ console.log('\n════════════════════════════════════════════════════════');
+ console.log(` 测试结果: 通过 ${pass} 项 / 失败 ${fail} 项`);
+ if (fail > 0) {
+ console.log(' 失败明细:');
+ failures.forEach(f => console.log(' - ' + f));
+ } else {
+ console.log(' 全链路流程通畅,无阻塞问题');
+ }
+ console.log(' 关键数据: orderId=' + orderId + ' masterId=' + masterId + ' merchantId=' + merchantId);
+ console.log('════════════════════════════════════════════════════════');
+
+ // 写出结构化结果供测试报告使用
+ fs.writeFileSync(path.join(__dirname, 'local_report_data.json'), JSON.stringify({
+ base: BASE,
+ time: new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
+ pass, fail,
+ accounts: { user: userMobile, master: masterMobile, merchant: merchantMobile },
+ keyIds: { orderId, masterId, merchantId },
+ results,
+ failures,
+ }, null, 2), 'utf8');
+ console.log(' 结果数据已写入 tests/local_report_data.json');
+
+ if (fail > 0) process.exitCode = 1;
+})().catch(e => {
+ console.error('\n测试中断: ' + e.message);
+ process.exitCode = 1;
+});
diff --git a/tests/local_report_data.json b/tests/local_report_data.json
new file mode 100644
index 0000000..a3ed862
--- /dev/null
+++ b/tests/local_report_data.json
@@ -0,0 +1,931 @@
+{
+ "base": "http://yjyf:9560/api",
+ "time": "2026/11/9 09:54:14",
+ "pass": 152,
+ "fail": 0,
+ "accounts": {
+ "user": "13989228367",
+ "master": "13789228367",
+ "merchant": "13889228367"
+ },
+ "keyIds": {
+ "orderId": 145,
+ "masterId": 46,
+ "merchantId": "18"
+ },
+ "results": [
+ {
+ "module": "A-用户端登录与首页",
+ "name": "错误验证码被拒绝",
+ "status": "pass",
+ "extra": "短信通道尚未接入,请联系管理员开通"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "手机号验证码登录(自动注册)",
+ "status": "pass",
+ "extra": "uid=120"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "新账号默认个人用户身份",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "获取个人信息",
+ "status": "pass",
+ "extra": "nickname=139****8367"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "首页聚合:服务类目",
+ "status": "pass",
+ "extra": "15 个类目"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "首页聚合:轮播图",
+ "status": "pass",
+ "extra": "3 张"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "首页聚合:热门服务",
+ "status": "pass",
+ "extra": "6 项"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "服务类目列表接口",
+ "status": "pass",
+ "extra": "15 个"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "按类目查服务列表",
+ "status": "pass",
+ "extra": "卫浴 15 项"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "关键词搜索服务",
+ "status": "pass",
+ "extra": "命中 20 项"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "服务详情",
+ "status": "pass",
+ "extra": "坐便器"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "费用试算接口可用",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "楼层费计算正确(5层×0元)",
+ "status": "pass",
+ "extra": "实际 0 期望 0"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "加急费计算正确(基础价×20%)",
+ "status": "pass",
+ "extra": "实际 16 期望 16"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "应付合计=基础+楼层+加急",
+ "status": "pass",
+ "extra": "合计 96"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "服务规格接口可用",
+ "status": "pass",
+ "extra": "10 个规格"
+ },
+ {
+ "module": "A-用户端登录与首页",
+ "name": "师傅数量统计接口",
+ "status": "pass",
+ "extra": "{\"count\":150,\"region\":\"\",\"tip\":\"该地区可提供服务的师傅共 150 位\"}"
+ },
+ {
+ "module": "B-地址管理",
+ "name": "新增地址(含经纬度)",
+ "status": "pass",
+ "extra": "addr_id=99"
+ },
+ {
+ "module": "B-地址管理",
+ "name": "新增第二个地址",
+ "status": "pass",
+ "extra": "addr_id=100"
+ },
+ {
+ "module": "B-地址管理",
+ "name": "地址列表",
+ "status": "pass",
+ "extra": "2 条"
+ },
+ {
+ "module": "B-地址管理",
+ "name": "地址详情",
+ "status": "pass",
+ "extra": "大连路 1 号测试小区 3 栋 501"
+ },
+ {
+ "module": "B-地址管理",
+ "name": "设置默认地址",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "B-地址管理",
+ "name": "默认地址切换生效",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "B-地址管理",
+ "name": "删除地址",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "B-地址管理",
+ "name": "重建服务地址",
+ "status": "pass",
+ "extra": "addr_id=101"
+ },
+ {
+ "module": "C-下单与支付",
+ "name": "创建固定价订单",
+ "status": "pass",
+ "extra": "order_no=20261109095350776760"
+ },
+ {
+ "module": "C-下单与支付",
+ "name": "非法地址下单被拦截",
+ "status": "pass",
+ "extra": "请选择或填写完整服务地址"
+ },
+ {
+ "module": "C-下单与支付",
+ "name": "新订单状态=待支付",
+ "status": "pass",
+ "extra": "待支付"
+ },
+ {
+ "module": "C-下单与支付",
+ "name": "生成首条状态流水",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "C-下单与支付",
+ "name": "无效 token 下单被拦截",
+ "status": "pass",
+ "extra": "请登录后操作"
+ },
+ {
+ "module": "C-下单与支付",
+ "name": "模拟支付成功",
+ "status": "pass",
+ "extra": "支付成功"
+ },
+ {
+ "module": "C-下单与支付",
+ "name": "支付后进入待接单",
+ "status": "pass",
+ "extra": "待接单"
+ },
+ {
+ "module": "C-下单与支付",
+ "name": "支付状态已更新",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "C-下单与支付",
+ "name": "重复支付被拦截",
+ "status": "pass",
+ "extra": "订单已支付,请勿重复支付"
+ },
+ {
+ "module": "C-下单与支付",
+ "name": "支付不存在的订单被拦截",
+ "status": "pass",
+ "extra": "订单不存在"
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "师傅账号登录",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "新账号默认非师傅身份",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "未入驻访问订单大厅被拦截(2001)",
+ "status": "pass",
+ "extra": "请先完成师傅入驻"
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "提交师傅入驻申请",
+ "status": "pass",
+ "extra": "master_id=46"
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "待审核状态重复提交=编辑资料(不产生重复档案)",
+ "status": "pass",
+ "extra": "master_id=46"
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "入驻后状态=待审核",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "审核中不允许接单(2002)",
+ "status": "pass",
+ "extra": "师傅资质审核中,请耐心等待"
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "后台审核通过",
+ "status": "pass",
+ "extra": "初始评分 5"
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "暂停接单开关",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "暂停接单时抢单被拦截",
+ "status": "pass",
+ "extra": "您已暂停接单,请先开启"
+ },
+ {
+ "module": "D-师傅入驻与审核",
+ "name": "恢复接单后大厅可用",
+ "status": "pass",
+ "extra": "共 13 单"
+ },
+ {
+ "module": "E-订单大厅与抢单",
+ "name": "大厅可见新订单",
+ "status": "pass",
+ "extra": "共 13 单"
+ },
+ {
+ "module": "E-订单大厅与抢单",
+ "name": "大厅手机号脱敏",
+ "status": "pass",
+ "extra": "139****8367"
+ },
+ {
+ "module": "E-订单大厅与抢单",
+ "name": "急单标识透出",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "E-订单大厅与抢单",
+ "name": "按类型筛选固定价单",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "E-订单大厅与抢单",
+ "name": "按急单排序",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "E-订单大厅与抢单",
+ "name": "师傅抢单成功",
+ "status": "pass",
+ "extra": "抢单成功,请在 2 小时内联系客户"
+ },
+ {
+ "module": "E-订单大厅与抢单",
+ "name": "抢单后获取完整客户手机号",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "E-订单大厅与抢单",
+ "name": "重复抢单被拦截",
+ "status": "pass",
+ "extra": "手慢了,该订单已被接走"
+ },
+ {
+ "module": "E-订单大厅与抢单",
+ "name": "非法状态流转被拦截(已接单→服务中)",
+ "status": "pass",
+ "extra": "订单状态「已接单」不允许变更为「服务中」"
+ },
+ {
+ "module": "E-订单大厅与抢单",
+ "name": "用户 token 调用师傅接口被拦截(403)",
+ "status": "pass",
+ "extra": "请先完成师傅入驻"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "创建报价类订单",
+ "status": "pass",
+ "extra": "order_no=20261109095356870100"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "报价单创建即进入报价中(2)",
+ "status": "pass",
+ "extra": "报价中"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "报价单在选定报价前不允许支付",
+ "status": "pass",
+ "extra": "订单状态不允许支付"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "师傅提交报价 260 元",
+ "status": "pass",
+ "extra": "报价已提交"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "同一订单重复报价被拦截",
+ "status": "pass",
+ "extra": "您已报价,请勿重复提交"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "负数报价被拦截",
+ "status": "pass",
+ "extra": "请填写报价金额"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "用户查看报价列表",
+ "status": "pass",
+ "extra": "1 条"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "用户选择报价(支付托管)",
+ "status": "pass",
+ "extra": "pay=260"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "重复选择报价被拦截",
+ "status": "pass",
+ "extra": "当前状态(已接单)不可选择报价"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "选定后订单=已接单且归属师傅",
+ "status": "pass",
+ "extra": "已接单"
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "报价金额写入订单",
+ "status": "pass",
+ "extra": 260
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "报价单完工 → 待验收",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "F-报价单流程",
+ "name": "报价单验收完成",
+ "status": "pass",
+ "extra": "验收成功,订单已完成"
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "标记已联系客户(2h 考核点)",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "约定上门时间 → 待上门",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "到达打卡 → 已到达",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "开工 → 服务中",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "完工提交取证 → 待验收",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "生成验收码",
+ "status": "pass",
+ "extra": "6058"
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "现场取证已落库",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "状态=待验收",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "订单详情师傅手机号脱敏",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "师傅端订单列表(进行中)",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "G-师傅履约流转",
+ "name": "师傅端订单详情",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "H-验收与结算",
+ "name": "错误验收码被拒绝",
+ "status": "pass",
+ "extra": "验收码不正确"
+ },
+ {
+ "module": "H-验收与结算",
+ "name": "客户验收成功",
+ "status": "pass",
+ "extra": "验收成功,订单已完成"
+ },
+ {
+ "module": "H-验收与结算",
+ "name": "状态=已完成",
+ "status": "pass",
+ "extra": "已完成"
+ },
+ {
+ "module": "H-验收与结算",
+ "name": "结算金额 = 实付 × 90%",
+ "status": "pass",
+ "extra": "86.40 (期望 86.4)"
+ },
+ {
+ "module": "H-验收与结算",
+ "name": "师傅余额已入账",
+ "status": "pass",
+ "extra": "234 → 320.4"
+ },
+ {
+ "module": "H-验收与结算",
+ "name": "重复验收被拦截",
+ "status": "pass",
+ "extra": "当前状态(已完成)不可验收"
+ },
+ {
+ "module": "I-评价与评分",
+ "name": "用户提交评价",
+ "status": "pass",
+ "extra": "评价成功"
+ },
+ {
+ "module": "I-评价与评分",
+ "name": "重复评价被拦截",
+ "status": "pass",
+ "extra": "该订单已评价"
+ },
+ {
+ "module": "I-评价与评分",
+ "name": "师傅评价列表已更新",
+ "status": "pass",
+ "extra": "共 1 条"
+ },
+ {
+ "module": "I-评价与评分",
+ "name": "师傅综合评分已刷新",
+ "status": "pass",
+ "extra": "avg=5"
+ },
+ {
+ "module": "I-评价与评分",
+ "name": "师傅累计订单 +1",
+ "status": "pass",
+ "extra": "累计 2 单"
+ },
+ {
+ "module": "I-评价与评分",
+ "name": "师傅信用体系数据",
+ "status": "pass",
+ "extra": "{\"score\":5,\"level\":1,\"accept_rate\":100,\"ontime_rate\":100,\"good_rate\":100,\"complaint_rate\":0,\"order_c"
+ },
+ {
+ "module": "J-优惠券",
+ "name": "领券中心可见测试券",
+ "status": "pass",
+ "extra": "5 张在发放"
+ },
+ {
+ "module": "J-优惠券",
+ "name": "用户领券",
+ "status": "pass",
+ "extra": "领取成功"
+ },
+ {
+ "module": "J-优惠券",
+ "name": "重复领券被拦截",
+ "status": "pass",
+ "extra": "您已领取过该优惠券"
+ },
+ {
+ "module": "J-优惠券",
+ "name": "我的优惠券列表",
+ "status": "pass",
+ "extra": "1 张未使用"
+ },
+ {
+ "module": "J-优惠券",
+ "name": "券已领取计数 +1",
+ "status": "pass",
+ "extra": "received=1"
+ },
+ {
+ "module": "K-消息中心",
+ "name": "消息列表(下单/支付/验收等通知)",
+ "status": "pass",
+ "extra": "12 条"
+ },
+ {
+ "module": "K-消息中心",
+ "name": "未读数统计",
+ "status": "pass",
+ "extra": "unread=12"
+ },
+ {
+ "module": "K-消息中心",
+ "name": "标记单条已读",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "K-消息中心",
+ "name": "按分类筛选订单消息",
+ "status": "pass",
+ "extra": "11 条"
+ },
+ {
+ "module": "K-消息中心",
+ "name": "一键全部已读",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "K-消息中心",
+ "name": "全部已读后未读数=0",
+ "status": "pass",
+ "extra": "unread=0"
+ },
+ {
+ "module": "K-消息中心",
+ "name": "未登录访问消息被拦截(401)",
+ "status": "pass",
+ "extra": "请登录后操作"
+ },
+ {
+ "module": "L-钱包与提现",
+ "name": "师傅收入概览",
+ "status": "pass",
+ "extra": "余额 ¥320.4"
+ },
+ {
+ "module": "L-钱包与提现",
+ "name": "低于 10 元提现被拦截",
+ "status": "pass",
+ "extra": "单次提现金额不少于 10 元"
+ },
+ {
+ "module": "L-钱包与提现",
+ "name": "超额提现被拦截",
+ "status": "pass",
+ "extra": "可提现余额不足"
+ },
+ {
+ "module": "L-钱包与提现",
+ "name": "正常提现申请(10元)",
+ "status": "pass",
+ "extra": "到账 ¥9.94(手续费0.6%)"
+ },
+ {
+ "module": "L-钱包与提现",
+ "name": "提现后余额扣减、进入冻结",
+ "status": "pass",
+ "extra": "320.4 → 310.4"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "商家账号登录",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "未入驻切商家被拦截(2001)",
+ "status": "pass",
+ "extra": "请先完成商家入驻"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "提交企业入驻资料",
+ "status": "pass",
+ "extra": "merchant_id=18"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "商家入驻状态=待审核",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "结算方式保存为月结",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "审核中切商家被拦截(2002)",
+ "status": "pass",
+ "extra": "商家资质审核中,请耐心等待"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "审核通过 → 切换商家端",
+ "status": "pass",
+ "extra": "/pages/merchant/workbench"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "商家初始经营数据为 0",
+ "status": "pass",
+ "extra": "订单 0 / 金额 0"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "可开票金额字段就绪",
+ "status": "pass",
+ "extra": "¥0"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "商家下单 1/2",
+ "status": "pass",
+ "extra": "下单成功"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "商家下单 2/2",
+ "status": "pass",
+ "extra": "下单成功"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "商家逐单创建并支付",
+ "status": "pass",
+ "extra": "147,148"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "订单归属同一批次",
+ "status": "pass",
+ "extra": "batch_no=20261109095408474"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "重复归属被忽略(幂等)",
+ "status": "pass",
+ "extra": "count=0"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "账单按月汇总生成",
+ "status": "pass",
+ "extra": "2026-11 / 2 单"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "商家订单列表",
+ "status": "pass",
+ "extra": "2 条"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "可开票金额已累计",
+ "status": "pass",
+ "extra": "¥160"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "超额开票被拦截",
+ "status": "pass",
+ "extra": "可开票金额不足,当前可开 ¥160"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "提交开票申请(专用发票)",
+ "status": "pass",
+ "extra": "开票申请已提交,3 个工作日内开具"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "开票记录回显",
+ "status": "pass",
+ "extra": "开票中"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "已申请金额占用额度(防重复开票)",
+ "status": "pass",
+ "extra": "可开票金额不足,当前可开 ¥110"
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "维护企业联系人",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "企业资料已更新",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "切回个人用户",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "profile 携带商家身份",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "M-商家全流程",
+ "name": "非法角色切换被拦截",
+ "status": "pass",
+ "extra": "角色参数错误"
+ },
+ {
+ "module": "N-订单边界与状态轨迹",
+ "name": "状态轨迹完整(待支付→待接单→已接单→待上门→已到达→服务中→待验收→完成)",
+ "status": "pass",
+ "extra": "0 → 1 → 1 → 3 → 3 → 4 → 5 → 6 → 7 → 8(共 10 条)"
+ },
+ {
+ "module": "N-订单边界与状态轨迹",
+ "name": "订单状态枚举下发(11态)",
+ "status": "pass",
+ "extra": "11 态"
+ },
+ {
+ "module": "N-订单边界与状态轨迹",
+ "name": "订单列表可查",
+ "status": "pass",
+ "extra": "共 2 单"
+ },
+ {
+ "module": "N-订单边界与状态轨迹",
+ "name": "按「已完成」筛选口径正确",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "N-订单边界与状态轨迹",
+ "name": "已完成订单不可取消",
+ "status": "pass",
+ "extra": "当前状态(已完成)不可取消,请联系客服"
+ },
+ {
+ "module": "N-订单边界与状态轨迹",
+ "name": "跨用户访问订单被拦截",
+ "status": "pass",
+ "extra": "订单不存在"
+ },
+ {
+ "module": "N-订单边界与状态轨迹",
+ "name": "未登录访问订单列表被拦截(401)",
+ "status": "pass",
+ "extra": "请登录后操作"
+ },
+ {
+ "module": "O-超时回流硬规则",
+ "name": "创建回流验证订单",
+ "status": "pass",
+ "extra": "order_no=20261109095409937617"
+ },
+ {
+ "module": "O-超时回流硬规则",
+ "name": "师傅接单第二笔",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "O-超时回流硬规则",
+ "name": "超时订单已回流为「待接单」",
+ "status": "pass",
+ "extra": "待接单"
+ },
+ {
+ "module": "O-超时回流硬规则",
+ "name": "师傅归属已释放",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "O-超时回流硬规则",
+ "name": "状态流水已记录回流原因",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "P-后台数据联动",
+ "name": "后台隐藏类目 → 前端立即可见性消失",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "P-后台数据联动",
+ "name": "后台恢复类目 → 前端立即恢复",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "P-后台数据联动",
+ "name": "后台下架服务 → 前端列表立即消失",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "P-后台数据联动",
+ "name": "待支付订单取消成功",
+ "status": "pass",
+ "extra": ""
+ },
+ {
+ "module": "P-后台数据联动",
+ "name": "取消后状态=已取消(9)",
+ "status": "pass",
+ "extra": "已取消"
+ }
+ ],
+ "failures": []
+}
\ No newline at end of file
diff --git a/tests/recalc_com.py b/tests/recalc_com.py
new file mode 100644
index 0000000..da9889a
--- /dev/null
+++ b/tests/recalc_com.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+"""用 Excel COM 重算公式并扫描公式错误"""
+import win32com.client
+import os
+
+path = r"D:\Projects\phpstudy_pro\WWW\yjyf\doc\测试用例-全系统.xlsx"
+excel = win32com.client.DispatchEx("Excel.Application")
+excel.Visible = False
+excel.DisplayAlerts = False
+try:
+ wb = excel.Workbooks.Open(path)
+ excel.CalculateFullRebuild()
+ errors = []
+ for ws in wb.Worksheets:
+ used = ws.UsedRange
+ for row in range(1, used.Rows.Count + 1):
+ for col in range(1, used.Columns.Count + 1):
+ v = ws.Cells(row, col).Text
+ if v.startswith("#") and any(e in v for e in ("REF!", "DIV/0!", "VALUE!", "N/A", "NAME?")):
+ errors.append(f"{ws.Name}!{ws.Cells(row, col).Address}={v}")
+ print("formula errors:", len(errors))
+ for e in errors[:10]:
+ print(" -", e)
+ # 抽查统计 sheet 计算值
+ ws2 = wb.Worksheets("用例统计")
+ last = ws2.Cells(ws2.UsedRange.Rows.Count, 1).Row
+ print(f"用例统计合计行: 用例数={ws2.Cells(last,2).Value} 通过={ws2.Cells(last,3).Value} 失败={ws2.Cells(last,4).Value}")
+ wb.Save()
+ wb.Close(False)
+finally:
+ excel.Quit()
+print("done")
diff --git a/yjyf-app/App.vue b/yjyf-app/App.vue
index cd8ae5a..b2734bb 100644
--- a/yjyf-app/App.vue
+++ b/yjyf-app/App.vue
@@ -46,10 +46,12 @@
/* 即时按压微交互反馈 (消除生硬感) */
.clickable-hover {
- opacity: 0.82 !important;
- transform: scale(0.985);
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
}
+ .clickable-hover:active {
+ opacity: 0.82;
+ transform: scale(0.985);
+ }
/* 全局胶囊主行动按钮 */
.yjyf-btn-primary {
diff --git a/yjyf-app/components/AddonServiceDrawer.vue b/yjyf-app/components/AddonServiceDrawer.vue
index b22644e..a272659 100644
--- a/yjyf-app/components/AddonServiceDrawer.vue
+++ b/yjyf-app/components/AddonServiceDrawer.vue
@@ -1,7 +1,7 @@
-
+
增项服务
@@ -9,10 +9,15 @@
-
+
-
- {{ item.name }}
+
+
+ {{ item.name }}
+
+ {{ item.price }}元/{{ item.unit || '份' }}
+
+
-
-
- 增项费用合计:
- ¥{{ totalAddonFee }}
-
-
diff --git a/yjyf-app/pages/category/category.vue b/yjyf-app/pages/category/category.vue
index bca4728..13efb95 100644
--- a/yjyf-app/pages/category/category.vue
+++ b/yjyf-app/pages/category/category.vue
@@ -313,6 +313,7 @@
font-weight: 700;
color: #FFFFFF;
letter-spacing: 1rpx;
+ line-height: 1;
}
/* 白色圆角主体卡片区 (对齐原型设计) */
@@ -434,6 +435,7 @@
font-size: 26rpx;
font-weight: 700;
color: #FFFFFF;
+ line-height: 1;
}
.body-bottom-space {
diff --git a/yjyf-app/pages/coupon/coupon.vue b/yjyf-app/pages/coupon/coupon.vue
index 52202ba..d12c15d 100644
--- a/yjyf-app/pages/coupon/coupon.vue
+++ b/yjyf-app/pages/coupon/coupon.vue
@@ -129,18 +129,18 @@
.tab { flex: 1; text-align: center; padding: 26rpx 0; font-size: 28rpx; color: #666; }
.tab.on { color: #F0710D; font-weight: 700; }
.tabs2 { display: flex; padding: 0 0 16rpx; }
- .tab2 { margin-right: 16rpx; padding: 0 24rpx; line-height: 56rpx; border-radius: 28rpx; background: #fff; font-size: 24rpx; color: #666; }
+ .tab2 { margin-right: 16rpx; height: 56rpx; padding: 0 26rpx; display: inline-flex; align-items: center; justify-content: center; line-height: 1; border-radius: 28rpx; background: #fff; font-size: 24rpx; color: #444444; }
.tab2.on { background: #FFF3E6; color: #E36B0C; font-weight: 600; }
.list { padding: 22rpx 28rpx; }
.cp { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 24rpx; margin-bottom: 18rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,.06); }
.cp.dim { opacity: .6; }
.cp-l { width: 150rpx; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; padding-right: 18rpx; border-right: 1rpx dashed #F0DCC8; }
.cp-val { font-size: 40rpx; font-weight: 800; color: #F0710D; }
- .cp-rule { margin-top: 4rpx; font-size: 18rpx; color: #B5B5B5; }
+ .cp-rule { margin-top: 4rpx; font-size: 22rpx; color: #888888; }
.cp-m { flex: 1; min-width: 0; padding-left: 18rpx; display: flex; flex-direction: column; }
.cp-name { font-size: 28rpx; font-weight: 600; color: #1F1F1F; }
- .cp-time { margin-top: 6rpx; font-size: 20rpx; color: #9A9A9A; }
- .cp-btn { flex-shrink: 0; margin-left: 12rpx; padding: 0 26rpx; line-height: 60rpx; border-radius: 30rpx; color: #fff; font-size: 24rpx; font-weight: 600; background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); }
+ .cp-time { margin-top: 6rpx; font-size: 22rpx; color: #888888; }
+ .cp-btn { flex-shrink: 0; margin-left: 12rpx; height: 60rpx; padding: 0 28rpx; display: inline-flex; align-items: center; justify-content: center; line-height: 1; border-radius: 30rpx; color: #fff; font-size: 24rpx; font-weight: 600; background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); }
.cp-btn.done { background: #EEE; color: #9A9A9A; }
.cp-btn.use { background: #FFF3E6; color: #E36B0C; }
.empty { padding: 100rpx 0; text-align: center; font-size: 24rpx; color: #9A9A9A; }
diff --git a/yjyf-app/pages/login/login.vue b/yjyf-app/pages/login/login.vue
index a6b6472..bb345a7 100644
--- a/yjyf-app/pages/login/login.vue
+++ b/yjyf-app/pages/login/login.vue
@@ -416,8 +416,8 @@
bottom: 0;
background: linear-gradient(
180deg,
- rgba(255, 255, 255, 0) 0%,
- rgba(255, 255, 255, 0.28) 40%,
+ rgba(15, 23, 42, 0.32) 0%,
+ rgba(255, 255, 255, 0.15) 28%,
rgba(255, 255, 255, 0.88) 78%,
#FFFFFF 100%
);
@@ -641,6 +641,7 @@
font-weight: 600;
color: #FFFFFF;
letter-spacing: 1rpx;
+ line-height: 1;
}
/* 跳过入口 */
@@ -656,6 +657,7 @@
font-size: 28rpx;
color: #4E5969;
letter-spacing: 0.5rpx;
+ line-height: 1;
transition: color 0.2s;
}
@@ -714,21 +716,23 @@
}
.agreement-labels {
- font-size: 22rpx;
- color: #86909C;
- line-height: 34rpx;
+ font-size: 24rpx;
+ color: #6B7280;
+ line-height: 1;
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.label-normal {
- color: #86909C;
+ color: #6B7280;
+ line-height: 1;
white-space: nowrap;
}
.label-link {
color: #F0710D;
- font-weight: 500;
+ font-weight: 600;
padding: 0 2rpx;
+ line-height: 1;
white-space: nowrap;
}
diff --git a/yjyf-app/pages/master/apply.vue b/yjyf-app/pages/master/apply.vue
index d624441..ce76801 100644
--- a/yjyf-app/pages/master/apply.vue
+++ b/yjyf-app/pages/master/apply.vue
@@ -1,10 +1,10 @@
-
-
+
+
- 师傅入驻
+ 实名技能认证 · 享同城海量好单
实名认证 + 技能认证,审核通过即可接单
@@ -65,6 +65,8 @@
import { isLogin, toLogin, getUser } from '@/utils/request.js';
import { useShare } from '@/utils/share.js';
+ import { getNavBarMetrics } from '@/utils/system.js';
+ const navHeight = ref(getNavBarMetrics().totalNavHeight);
const info = ref(null);
const cats = ref([]);
const skills = ref([]);
@@ -169,8 +171,8 @@
diff --git a/yjyf-app/pages/master/skills.vue b/yjyf-app/pages/master/skills.vue
index bbc5f69..9c89a2e 100644
--- a/yjyf-app/pages/master/skills.vue
+++ b/yjyf-app/pages/master/skills.vue
@@ -1,4 +1,4 @@
-
+
@@ -27,7 +27,7 @@
@@ -169,14 +169,14 @@
diff --git a/yjyf-app/pages/merchant/invoice.vue b/yjyf-app/pages/merchant/invoice.vue
index e87769c..e3bcb48 100644
--- a/yjyf-app/pages/merchant/invoice.vue
+++ b/yjyf-app/pages/merchant/invoice.vue
@@ -1,4 +1,4 @@
-
+
@@ -92,8 +92,8 @@
diff --git a/yjyf-app/pages/order/review.vue b/yjyf-app/pages/order/review.vue
index b42d55e..d6aa631 100644
--- a/yjyf-app/pages/order/review.vue
+++ b/yjyf-app/pages/order/review.vue
@@ -76,22 +76,22 @@