fix(wxlogin): 修复微信登录 40029 + 新增商家(企业账号)全流程

微信登录修复:
- 根因:manifest.json 的 mp-weixin.appid 为空 → 开发者工具用测试号,code 与服务端 appid 不匹配
- 修复:填入 wx40ca4b02d4d1cb58
- WxLogin 增加错误码语义映射(40029 appid 不一致 / 40125 secret 错 / 45011 频繁 / 40226 风控)
- 登录页新增手机号登录兜底通道(微信不可用时业务不被卡住)

商家(企业账号):
- 新表 fa_merchant / fa_merchant_order / fa_invoice(纯新增,DB 40→45 表)
- Merchant 控制器:apply/info/update/bills/invoices/invoice/bindBatch
  入驻含企业信息+营业执照+开票信息+结算方式(预付/月结)
  账单按月汇总、开票额度按已付-已开票校验、批量下单按批次归属
- Auth::switchRole 支持 merchant(2001/2002/2003 语义码 + redirect)
- Auth::profile 增加 is_merchant / merchant

前端:
- 登录页角色扩为 业主/师傅/商家 三类
- pages/merchant/apply 企业入驻;workbench 商家工作台(经营数据+按月账单+近期订单+六宫格)
- pages/merchant/batch 批量下单(多地址×多数量)→ 逐单建单支付 → 绑定批次
- pages/merchant/invoice 开票申请与记录
- pages/mine 增加商家入驻/切换商家端入口;角色徽章三态

验证:163 项断言全通过(page_flow 47 + e2e 65 + phase2 27 + phase3 24)
这个提交包含在:
linda
2026-10-28 16:22:20 +08:00
父节点 6ff685afc9
当前提交 4e73c8164a
共有 15 个文件被更改,包括 1264 次插入 和 22 次删除

查看文件

@@ -37,7 +37,7 @@ const sql = (s) => execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@8.137.181.6
sec('微信授权登录');
let r = await api('/auth/wxlogin', { method: 'POST', data: { code: 'invalid_code_x' } });
chk(r.code !== 1 && /微信登录失败/.test(r.msg || ''), '无效 code 被微信侧拒绝并回显原因', r.msg);
chk(r.code !== 1 && /微信登录失败|appid/.test(r.msg || ''), '无效 code 被微信侧拒绝并给出排查指引', r.msg);
r = await api('/auth/wxlogin', { method: 'POST', data: {} });
chk(r.code !== 1, '缺少 code 时参数校验生效', r.msg);

136
tests/phase3_merchant.js 普通文件
查看文件

@@ -0,0 +1,136 @@
/**
* Phase 3 验证:商家(企业账号)注册 / 登录 / 全业务流程
* 运行:node tests/phase3_merchant.js
*/
const { execFileSync } = require('child_process');
const BASE = process.env.API_BASE || 'https://yjyf.gzyakj.com.cn/api';
let pass = 0, fail = 0;
const failures = [];
const ok = (m, e) => { pass++; console.log(' ✅ ' + m + (e ? ' → ' + e : '')); };
const bad = (m, e) => { fail++; failures.push(m + ' :: ' + e); console.log(' ❌ ' + m + ' → ' + e); };
const chk = (c, m, e) => (c ? ok(m, e) : bad(m, e || '断言失败'));
const sec = (t) => console.log('\n▌' + t);
async function api(path, opt) {
const o = opt || {};
const headers = {};
if (o.token) headers.token = o.token;
let body;
if (o.data) {
headers['content-type'] = 'application/x-www-form-urlencoded';
body = new URLSearchParams(o.data).toString();
}
const res = await fetch(BASE + path, { method: o.method || 'GET', headers, body });
const text = await res.text();
try { return JSON.parse(text); } catch (e) { throw new Error('非 JSON: ' + text.slice(0, 120)); }
}
const sql = (s) => execFileSync('ssh', ['-o', 'BatchMode=yes', 'root@8.137.181.63', `mysql -uyjyf -pyjyf yjyf -e "${s}"`], { encoding: 'utf8' }).trim();
(async () => {
const stamp = String(Date.now()).slice(-8);
const mobile = '138' + stamp;
console.log('═══════════════════════════════════════════════════');
console.log(' Phase 3 验证:商家注册 / 登录 / 全业务流程');
console.log('═══════════════════════════════════════════════════');
sec('账号与身份');
const login = await api('/auth/login', { method: 'POST', data: { mobile: mobile, code: '123456' } });
const token = login.data.token;
chk(login.code === 1, '手机号登录(微信不可用时的兜底通道)');
chk(login.data.userinfo.is_merchant === false, '新账号默认个人用户');
let r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'merchant' } });
chk(r.code === 2001 && r.data.need_apply === 1, '未入驻切商家被拦截(2001)', r.msg);
sec('商家入驻');
r = await api('/merchant/apply', {
method: 'POST', token: token,
data: {
name: '遵义优匠物业服务有限公司', license_no: '91520300MA6XXXXX1A',
legal_person: '王老板', contact: '李经理', mobile: mobile,
province: '贵州省', city: '遵义市', district: '汇川区', address: '大连路 100 号 3 楼',
invoice_title: '遵义优匠物业服务有限公司', invoice_tax_no: '91520300MA6XXXXX1A',
settle_type: 'monthly'
}
});
chk(r.code === 1 && r.data.merchant_id > 0, '提交企业资料', 'merchant_id=' + r.data.merchant_id);
const merchantId = r.data.merchant_id;
r = await api('/merchant/info', { token: token });
chk(r.data.applied && r.data.merchant.verify_status === 'pending', '入驻后状态=待审核');
chk(r.data.merchant.settle_type === 'monthly', '结算方式保存为月结');
r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'merchant' } });
chk(r.code === 2002, '审核中切商家被拦截(2002)', r.msg);
sql(`UPDATE fa_merchant SET verify_status='pass' WHERE id=${merchantId}`);
r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'merchant' } });
chk(r.code === 1 && r.data.redirect === '/pages/merchant/workbench', '审核通过 → 切换商家端', r.data && r.data.redirect);
sec('工作台数据');
r = await api('/merchant/info', { token: token });
const m = r.data.merchant;
chk(m && Number(m.order_count) === 0 && Number(m.total_amount) === 0, '初始经营数据为 0', '订单 ' + m.order_count + ' / 金额 ' + m.total_amount);
chk(m.invoiceable !== undefined, '可开票金额字段就绪', '¥' + m.invoiceable);
sec('批量下单');
const svc = (await api('/home/services?cat_id=1')).data.list.find(s => s.price_type === 'fixed');
const orderIds = [];
for (const i of [1, 2]) {
const addr = await api('/address/save', {
method: 'POST', token: token,
data: { contact: '门店' + i, mobile: mobile, province: '贵州省', city: '遵义市', district: '汇川区', address: '批量测试地址 ' + i, floor: 3, has_elevator: 1, is_default: 0 }
});
const o = await api('/order/create', { method: 'POST', token: token, data: { service_id: svc.id, address_id: addr.data.id, goods_num: 1, remark: '商家批量下单' } });
await api('/order/pay', { method: 'POST', token: token, data: { order_id: o.data.order_id } });
orderIds.push(o.data.order_id);
}
chk(orderIds.length === 2, '逐单创建并支付', orderIds.join(','));
r = await api('/merchant/bindBatch', { method: 'POST', token: token, data: { order_ids: orderIds.join(','), batch_no: '' } });
chk(r.code === 1 && r.data.count === 2 && r.data.batch_no, '归属到同一批次', 'batch_no=' + r.data.batch_no + ' count=' + r.data.count);
r = await api('/merchant/bindBatch', { method: 'POST', token: token, data: { order_ids: orderIds.join(','), batch_no: '' } });
chk(r.code === 1 && r.data.count === 0, '重复归属被忽略(幂等)', 'count=' + r.data.count);
sec('账单中心');
r = await api('/merchant/bills', { token: token });
chk(r.code === 1 && r.data.months.length >= 1, '按月汇总生成', r.data.months[0] && (r.data.months[0].month + ' / ' + r.data.months[0].cnt + ' 单 / ¥' + r.data.months[0].amount));
chk(r.data.orders.length === 2, '近期订单明细', r.data.orders.length + ' 条');
sec('开票');
r = await api('/merchant/info', { token: token });
const available = Number(r.data.merchant.invoiceable);
chk(available > 0, '可开票金额已累计', '¥' + available);
r = await api('/merchant/invoice', { method: 'POST', token: token, data: { amount: available + 100, type: 'normal' } });
chk(r.code !== 1, '超额开票被拦截', r.msg);
r = await api('/merchant/invoice', { method: 'POST', token: token, data: { amount: 50, type: 'special', email: 'fin@example.com' } });
chk(r.code === 1, '提交开票申请(专用发票)', r.msg);
r = await api('/merchant/invoices', { token: token });
chk(r.code === 1 && r.data.list.length === 1 && r.data.list[0].type_text === '专用发票', '开票记录回显', r.data.list[0] && r.data.list[0].status_text);
r = await api('/merchant/invoice', { method: 'POST', token: token, data: { amount: available, type: 'normal' } });
chk(r.code !== 1, '已申请金额占用额度(防重复开票)', r.msg);
sec('资料维护与身份切回');
r = await api('/merchant/update', { method: 'POST', token: token, data: { contact: '新经理', mobile: mobile } });
chk(r.code === 1, '维护联系人', r.msg);
r = await api('/merchant/info', { token: token });
chk(r.data.merchant.contact === '新经理', '资料已更新');
r = await api('/auth/switchRole', { method: 'POST', token: token, data: { role: 'user' } });
chk(r.code === 1 && r.data.redirect === '/YJYF/YJYF', '切回个人用户', r.data && r.data.redirect);
r = await api('/auth/profile', { token: token });
chk(r.data.is_merchant === true && Number(r.data.merchant.id) === Number(merchantId), 'profile 携带商家身份', 'merchant_id=' + r.data.merchant.id);
console.log('\n═══════════════════════════════════════════════════');
console.log(` 结果:通过 ${pass} 项 / 失败 ${fail} 项`);
if (fail) { console.log(' 失败明细:'); failures.forEach(f => console.log(' - ' + f)); process.exitCode = 1; }
else console.log(' 🎉 商家注册/登录/批量下单/对账/开票 全流程通过');
console.log('═══════════════════════════════════════════════════');
})().catch(e => { console.error('\n💥 中断: ' + e.message); process.exitCode = 1; });