| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566 |
- const http = require('http');
- const { randomBytes } = require('crypto');
- const { callFmodeWecomGateway } = require('../providers/fmode-wecom-gateway');
- const { SEAT_PLANS, DURATION_PLANS, QIWEI_MONTHLY_PRICE, classifySubscribeError, ERROR_CODES } = require('./subscribe-page');
- const DEFAULT_FLOW_PORT = 4310;
- const FLOW_PAGE_STYLE = `
- :root { --brand:#fa8c16; --brand-bg:#fff7e6; --border:#f0f0f0; }
- * { box-sizing:border-box; }
- body { font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif; margin:0; background:#f7f8fa; color:#333; }
- .wrap { max-width:960px; margin:0 auto; padding:28px 20px; }
- h1 { font-size:20px; margin:0 0 6px; }
- .steps { display:flex; gap:8px; margin:16px 0 22px; font-size:13px; }
- .step { flex:1; text-align:center; padding:8px; border-radius:8px; background:#eee; color:#999; }
- .step.active { background:var(--brand); color:#fff; }
- .step.done { background:#d9f7be; color:#237804; }
- .panel { background:#fff; border:1px solid var(--border); border-radius:12px; padding:24px; }
- .cards { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:14px; }
- .card { position:relative; flex:1; min-width:110px; border:1px solid var(--border); border-radius:10px; padding:12px 14px; cursor:pointer; text-align:center; transition:all .15s; }
- .card:hover { border-color:var(--brand); }
- .card.active { border-color:var(--brand); background:var(--brand-bg); }
- .card .t { font-weight:600; font-size:14px; }
- .card .p { color:var(--brand); font-size:13px; margin-top:5px; }
- .card .n { color:#999; font-size:12px; margin-top:3px; }
- .badge { position:absolute; top:-9px; right:8px; background:var(--brand); color:#fff; font-size:11px; border-radius:8px; padding:1px 8px; }
- .section-title { font-size:13px; color:#666; margin:14px 0 8px; }
- .total { font-size:26px; color:#e64545; font-weight:700; margin:14px 0 4px; }
- .total small { font-size:13px; color:#999; font-weight:normal; }
- button.main { border:none; background:var(--brand); color:#fff; font-size:15px; padding:11px 28px; border-radius:8px; cursor:pointer; margin-top:12px; }
- button.main:disabled { opacity:.6; cursor:not-allowed; }
- .msg { margin-top:14px; font-size:13px; line-height:1.7; border-radius:8px; padding:10px 12px; display:none; }
- .msg.ok { display:block; background:#f0fff4; border:1px solid #b7eb8f; color:#237804; }
- .msg.err { display:block; background:#fff1f0; border:1px solid #ffa39e; color:#a8071a; }
- .center { text-align:center; }
- #qr { width:260px; height:260px; border:1px solid var(--border); border-radius:10px; background:#fff; padding:10px; }
- .hint { color:#999; font-size:13px; margin-top:10px; line-height:1.8; }
- input#code { font-size:22px; letter-spacing:8px; text-align:center; width:220px; padding:8px; border:1px solid #ccc; border-radius:8px; }
- .success { font-size:18px; color:#237804; }
- `;
- let currentServer = null;
- let currentContext = null;
- function buildFlowPageHtml({ monthlyPrice, checkoutToken }) {
- return `<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8">
- <title>企微助手 · 开通与登录</title>
- <style>${FLOW_PAGE_STYLE}</style>
- </head>
- <body>
- <div class="wrap">
- <h1>企微助手 · 开通与登录</h1>
- <div class="steps">
- <div class="step" id="s1">1. 开通订阅</div>
- <div class="step" id="s2">2. 扫码登录</div>
- <div class="step" id="s3">3. 验证码确认</div>
- <div class="step" id="s4">4. 完成</div>
- </div>
- <div class="panel" id="panel">页面加载中…</div>
- </div>
- <script>
- let PRICE = Number(${monthlyPrice}) || 500;
- const CHECKOUT_TOKEN = ${JSON.stringify(String(checkoutToken || ''))};
- const SEATS = ${JSON.stringify(SEAT_PLANS)};
- const DURATIONS = ${JSON.stringify(DURATION_PLANS)};
- let seats = 1, months = 1, pollTimer = null, currentStep = 0, codeSubmitted = false, verifyWaits = 0, checkErrors = 0;
- const panel = document.getElementById('panel');
- function setStep(n) {
- currentStep = n;
- for (let i = 1; i <= 4; i++) {
- const el = document.getElementById('s' + i);
- el.className = 'step' + (i < n ? ' done' : i === n ? ' active' : '');
- }
- }
- function showMsg(type, html) {
- let el = document.getElementById('msg');
- if (!el) { el = document.createElement('div'); el.id = 'msg'; panel.appendChild(el); }
- el.className = 'msg ' + type;
- el.innerHTML = html;
- }
- async function api(path, opts) {
- const res = await fetch(path, opts);
- const data = await res.json().catch(() => ({}));
- return { ok: res.ok, status: res.status, data };
- }
- let subscribeKey = '';
- function renderSubscribe(detail, monthlyPrice) {
- const livePrice = Number(monthlyPrice);
- if (Number.isFinite(livePrice) && livePrice > 0) PRICE = livePrice;
- setStep(1);
- subscribeKey = 'flow-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
- panel.innerHTML = '<div class="section-title">开通企微账号:每个账号 ¥' + PRICE + '/月,从飞马余额扣费' + (detail ? '(' + detail + ')' : '') + '</div>' +
- '<div class="section-title">选择账号数量(席位)</div><div class="cards" id="seat-cards"></div>' +
- '<div class="section-title">购买时长</div><div class="cards" id="duration-cards" style="max-width:400px"></div>' +
- '<div class="total" id="total"></div><div class="hint" id="detail"></div>' +
- '<button class="main" id="buy">开通服务(立即扣费)</button>';
- const renderCards = (el, items, key, current, onPick) => {
- el.innerHTML = '';
- items.forEach(item => {
- const div = document.createElement('div');
- div.className = 'card' + (item[key] === current ? ' active' : '');
- div.innerHTML = (item.recommended ? '<span class="badge">推荐</span>' : '') +
- '<div class="t">' + item.label + '</div>' +
- '<div class="p">¥' + (key === 'seats' ? item.seats * PRICE + '/月' : item.months * PRICE + '/号') + '</div>' +
- (item.note ? '<div class="n">' + item.note + '</div>' : '');
- div.onclick = () => onPick(item[key]);
- el.appendChild(div);
- });
- };
- const paint = () => {
- renderCards(document.getElementById('seat-cards'), SEATS, 'seats', seats, v => { seats = v; paint(); });
- renderCards(document.getElementById('duration-cards'), DURATIONS, 'months', months, v => { months = v; paint(); });
- document.getElementById('total').innerHTML = '¥' + (seats * months * PRICE).toLocaleString() + ' <small>/ 一次性扣费</small>';
- document.getElementById('detail').textContent = seats + ' 个号 × ¥' + PRICE + '/月 × ' + months + ' 个月,从飞马余额扣除';
- };
- paint();
- document.getElementById('buy').onclick = async () => {
- const btn = document.getElementById('buy');
- btn.disabled = true;
- showMsg('ok', '正在开通扣费,请稍候…');
- const r = await api('/flow/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ seats, months, idempotencyKey: subscribeKey, checkoutToken: CHECKOUT_TOKEN }) });
- if (!r.ok) {
- showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + '] ' + (r.data.errorTitle || '开通失败') + '</b><br>' + (r.data.message || '') + '<br>' + (r.data.tip || ''));
- btn.disabled = false;
- return;
- }
- showMsg('ok', '开通成功!席位 ' + r.data.seats + ',到期 ' + r.data.expireAt + '。正在生成登录二维码…');
- setTimeout(startLogin, 800);
- };
- }
- async function startLogin() {
- codeSubmitted = false;
- verifyWaits = 0;
- checkErrors = 0;
- setStep(2);
- panel.innerHTML = '<div class="center"><div class="section-title">正在生成企业微信登录二维码…</div></div>';
- const r = await api('/flow/start-login', { method: 'POST' });
- if (!r.ok) {
- panel.innerHTML = '<div class="center"></div>';
- showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + ']</b> ' + (r.data.message || '生成二维码失败') + '<br>' + (r.data.tip || '') + '<br><button class="main" onclick="startLogin()">重试</button>');
- return;
- }
- panel.innerHTML = '<div class="center"><img id="qr" src="/flow/qrcode?t=' + Date.now() + '">' +
- '<div class="hint">请用手机企业微信扫码并确认登录。二维码约 4 分钟内有效。<br>本页面每 3 秒自动检测扫码状态,无需手动刷新。</div>' +
- '<div class="hint" id="poll-status">等待扫码…</div></div>';
- poll();
- }
- function poll() {
- clearInterval(pollTimer);
- pollTimer = setInterval(async () => {
- const r = await api('/flow/check');
- const s = String(r.data.status);
- const el = document.getElementById('poll-status');
- if (!r.ok || r.data.status === undefined) {
- // 登录成功后上游会销毁二维码会话:先用设备在线状态判断是否已成功
- checkErrors++;
- const st = await api('/flow/state');
- if (st.data.online) { clearInterval(pollTimer); renderSuccess(st.data.detail || {}); return; }
- if (st.data.subscribed === false && !st.data.stateError) { clearInterval(pollTimer); renderSubscribe(st.data.subscribeDetail || '订阅已到期', st.data.monthlyPrice); return; }
- // 二维码过期或上游瞬时异常:未提交验证码时自动重新生成二维码
- if (!codeSubmitted && checkErrors >= 2) { clearInterval(pollTimer); startLogin(); return; }
- if (codeSubmitted && checkErrors >= 10) {
- clearInterval(pollTimer);
- showMsg('err', '登录确认异常。<br><button class="main" onclick="startLogin()">重新生成二维码</button>');
- return;
- }
- if (el) el.textContent = '状态检测异常,重试中…';
- return;
- }
- checkErrors = 0;
- if (s === '2') { clearInterval(pollTimer); renderSuccess(r.data.detail || {}); return; }
- if (s === '10') {
- if (!codeSubmitted) { clearInterval(pollTimer); renderVerify(); return; }
- // 验证码已提交:不回退到输入页,继续等待登录确认,并用设备在线状态兜底
- verifyWaits++;
- const st = await api('/flow/state');
- if (st.data.online) { clearInterval(pollTimer); renderSuccess(st.data.detail || {}); return; }
- if (el) el.textContent = '验证码已提交,正在确认登录…';
- if (verifyWaits >= 10) {
- clearInterval(pollTimer);
- showMsg('err', '登录确认超时。可能验证码错误或已失效。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="startLogin()">重新生成二维码</button>');
- }
- return;
- }
- if (s === '4') { clearInterval(pollTimer); showMsg('err', '手机端取消了登录或二维码会话已失效。<br><button class="main" onclick="startLogin()">重新生成二维码</button>'); return; }
- if (el) el.textContent = s === '1' ? '已扫码,请在手机上确认…' : '等待扫码…(状态 ' + s + ')';
- }, 3000);
- }
- function renderVerify() {
- setStep(3);
- panel.innerHTML = '<div class="center"><div class="section-title">已扫码确认,请输入手机企业微信上显示的 6 位验证码</div>' +
- '<input id="code" maxlength="6" placeholder="······" inputmode="numeric" autocomplete="one-time-code">' +
- '<br><button class="main" id="verify">提交验证码</button>' +
- '<div class="hint">验证码在手机企业微信确认登录后显示,输完 6 位自动提交</div></div>';
- const input = document.getElementById('code');
- input.focus();
- const submit = async () => {
- const code = input.value.trim();
- if (!/^\\d{6}$/.test(code)) { showMsg('err', '请输入 6 位数字验证码。'); return; }
- const btn = document.getElementById('verify');
- btn.disabled = true;
- showMsg('ok', '正在校验…');
- const r = await api('/flow/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
- if (!r.ok) { btn.disabled = false; showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + ']</b> ' + (r.data.message || '验证码校验失败') + ',请重试。'); return; }
- codeSubmitted = true;
- verifyWaits = 0;
- panel.innerHTML = '<div class="center"><div class="section-title">验证码已提交,正在确认登录…</div><div class="hint" id="poll-status">确认中,每 3 秒自动检测…</div></div>';
- poll();
- };
- document.getElementById('verify').onclick = submit;
- input.onkeydown = (e) => { if (e.key === 'Enter') submit(); };
- input.oninput = () => { if (/^\\d{6}$/.test(input.value.trim())) submit(); };
- }
- function renderSuccess(detail) {
- setStep(4);
- panel.innerHTML = '<div class="center"><div class="success">✔ 企业微信登录成功</div>' +
- '<div class="hint">账号:' + (detail.nickname || detail.userId || '未知') + (detail.corpName ? '(' + detail.corpName + ')' : '') + '<br>现在可以回到对话中使用企微接口能力(qiwei_api_call、消息、会议等)。</div></div>';
- }
- async function boot() {
- const r = await api('/flow/state');
- if (r.data.online) { renderSuccess(r.data.detail || {}); return; }
- if (r.data.subscribed) { startLogin(); return; }
- if (r.data.stateError) {
- panel.innerHTML = '<div class="center"></div>';
- showMsg('err', '网络异常,暂时无法获取订阅状态(不会重复扣费)。<br><button class="main" onclick="boot()">重试</button>');
- return;
- }
- renderSubscribe(r.data.subscribeDetail || '', r.data.monthlyPrice);
- }
- boot();
- </script>
- </body>
- </html>`;
- }
- function buildFallbackPageHtml({ qrcodeUrl }) {
- return `<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8">
- <title>企业微信扫码登录</title>
- <style>${FLOW_PAGE_STYLE}</style>
- </head>
- <body>
- <div class="wrap">
- <h1>企业微信扫码登录</h1>
- <div class="panel" id="panel">
- <div class="center">
- <div class="section-title">请用手机企业微信扫描下方二维码</div>
- <img id="qr" src="${qrcodeUrl}?t=${Date.now()}" alt="登录二维码">
- <div class="hint" id="poll-status">正在检测扫码状态…</div>
- </div>
- </div>
- </div>
- <script>
- let pollTimer = null, codeSubmitted = false, verifyWaits = 0, checkErrors = 0;
- const panel = document.getElementById('panel');
- function showMsg(type, html) {
- let el = document.getElementById('msg');
- if (!el) { el = document.createElement('div'); el.id = 'msg'; panel.appendChild(el); }
- el.className = 'msg ' + type;
- el.innerHTML = html;
- }
- async function api(path, opts) {
- const res = await fetch(path, opts);
- const data = await res.json().catch(() => ({}));
- return { ok: res.ok, status: res.status, data };
- }
- function renderVerify() {
- panel.innerHTML = '<div class="center"><div class="section-title">已扫码确认,请输入手机企业微信上显示的 6 位验证码</div>' +
- '<input id="code" maxlength="6" placeholder="······" inputmode="numeric" autocomplete="one-time-code">' +
- '<br><button class="main" id="verify">提交验证码</button>' +
- '<div class="hint">验证码在手机企业微信确认登录后显示,输完 6 位自动提交</div></div>';
- const input = document.getElementById('code');
- input.focus();
- const submit = async () => {
- const code = input.value.trim();
- if (!/^\\d{6}$/.test(code)) { showMsg('err', '请输入 6 位数字验证码。'); return; }
- const btn = document.getElementById('verify');
- btn.disabled = true;
- showMsg('ok', '正在校验…');
- const r = await api('/flow/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
- if (!r.ok) { btn.disabled = false; showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + ']</b> ' + (r.data.message || '验证码校验失败') + ',请重试。'); return; }
- codeSubmitted = true;
- verifyWaits = 0;
- panel.innerHTML = '<div class="center"><div class="section-title">验证码已提交,正在确认登录…</div><div class="hint" id="poll-status">确认中,每 3 秒自动检测…</div></div>';
- poll();
- };
- document.getElementById('verify').onclick = submit;
- input.onkeydown = (e) => { if (e.key === 'Enter') submit(); };
- input.oninput = () => { if (/^\\d{6}$/.test(input.value.trim())) submit(); };
- }
- function renderSuccess(detail) {
- panel.innerHTML = '<div class="center"><div class="success">✔ 企业微信登录成功</div>' +
- '<div class="hint">账号:' + (detail.nickname || detail.userId || '未知') + (detail.corpName ? '(' + detail.corpName + ')' : '') + '<br>现在可以回到对话中使用企微接口能力。</div></div>';
- }
- async function refreshQr() {
- showMsg('ok', '正在重新生成二维码…');
- const r = await api('/flow/refresh', { method: 'POST' });
- if (!r.ok) {
- showMsg('err', '<b>重新生成失败</b><br>' + (r.data.message || '') + '<br><button class="main" onclick="refreshQr()">重试</button>');
- return;
- }
- codeSubmitted = false;
- verifyWaits = 0;
- checkErrors = 0;
- document.getElementById('qr').src = '/flow/qrcode?t=' + Date.now();
- panel.innerHTML = '<div class="center"><div class="section-title">请用手机企业微信扫描下方二维码</div>' +
- '<img id="qr" src="/flow/qrcode?t=' + Date.now() + '" alt="登录二维码">' +
- '<div class="hint" id="poll-status">正在检测扫码状态…</div></div>';
- poll();
- }
- function poll() {
- clearInterval(pollTimer);
- pollTimer = setInterval(async () => {
- const r = await api('/flow/check');
- const s = String(r.data.status);
- const el = document.getElementById('poll-status');
- if (!r.ok || r.data.status === undefined) {
- checkErrors++;
- if (!codeSubmitted && checkErrors >= 2) { clearInterval(pollTimer); refreshQr(); return; }
- if (codeSubmitted && checkErrors >= 10) {
- clearInterval(pollTimer);
- showMsg('err', '登录确认异常。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="refreshQr()">重新生成二维码</button>');
- return;
- }
- if (el) el.textContent = '状态检测异常,重试中…';
- return;
- }
- checkErrors = 0;
- if (s === '2') { clearInterval(pollTimer); renderSuccess(r.data.detail || {}); return; }
- if (s === '10') {
- if (!codeSubmitted) { clearInterval(pollTimer); renderVerify(); return; }
- verifyWaits++;
- if (el) el.textContent = '验证码已提交,正在确认登录…';
- if (verifyWaits >= 10) {
- clearInterval(pollTimer);
- showMsg('err', '登录确认超时。可能验证码错误或已失效。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="refreshQr()">重新生成二维码</button>');
- }
- return;
- }
- if (s === '4') { clearInterval(pollTimer); showMsg('err', '手机端取消了登录或二维码会话已失效。<br><button class="main" onclick="refreshQr()">重新生成二维码</button>'); return; }
- if (el) el.textContent = s === '1' ? '已扫码,请在手机上确认…' : '等待扫码…(状态 ' + s + ')';
- }, 3000);
- }
- poll();
- </script>
- </body>
- </html>`;
- }
- function json(res, status, body) {
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
- res.end(JSON.stringify(body));
- }
- function subscribeErrorBody(httpStatus, message) {
- const codeKey = classifySubscribeError(httpStatus, message);
- const info = ERROR_CODES[codeKey] || {};
- return { errorCode: codeKey, errorTitle: info.title || '操作失败', tip: info.tip || '', message: String(message || '') };
- }
- function createCheckoutToken() {
- return randomBytes(24).toString('base64url');
- }
- function validCheckoutRequest(req, ctx, body) {
- const contentType = String(req.headers['content-type'] || '').toLowerCase();
- const origin = String(req.headers.origin || '');
- const expectedOrigin = `http://127.0.0.1:${ctx.port}`;
- return contentType.startsWith('application/json') &&
- origin === expectedOrigin &&
- typeof body.checkoutToken === 'string' &&
- body.checkoutToken === ctx.checkoutToken;
- }
- async function readBody(req) {
- const chunks = [];
- for await (const chunk of req) chunks.push(chunk);
- try {
- return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
- } catch {
- return {};
- }
- }
- function createFlowHandler(ctx) {
- return async (req, res) => {
- const url = new URL(req.url, 'http://localhost');
- try {
- if (url.pathname === '/' || url.pathname === '/index.html') {
- ctx.checkoutToken = createCheckoutToken();
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
- res.end(buildFlowPageHtml({ monthlyPrice: QIWEI_MONTHLY_PRICE, checkoutToken: ctx.checkoutToken }));
- return;
- }
- if (url.pathname === '/flow/state') {
- let subscribed = false;
- let subscribeDetail = '';
- let online = false;
- let detail = null;
- let stateError = false;
- let monthlyPrice = QIWEI_MONTHLY_PRICE;
- try {
- const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase });
- subscribed = Boolean(sub.data && sub.data.subscribed);
- const livePrice = Number(sub.data && sub.data.price);
- if (Number.isFinite(livePrice) && livePrice > 0) monthlyPrice = livePrice;
- if (!subscribed) subscribeDetail = '尚未开通包月订阅';
- } catch (error) {
- stateError = true;
- subscribeDetail = String((error && error.message) || '订阅状态查询失败');
- }
- if (subscribed) {
- try {
- const status = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
- online = Boolean(status.data && status.data.online);
- detail = (status.data && status.data.detail) || null;
- } catch {
- online = false;
- }
- }
- json(res, 200, { subscribed, subscribeDetail, online, detail, stateError, monthlyPrice });
- return;
- }
- if (url.pathname === '/flow/subscribe' && req.method === 'POST') {
- const body = await readBody(req);
- if (!validCheckoutRequest(req, ctx, body)) {
- json(res, 403, {
- errorCode: 'QW-PAY-403',
- errorTitle: '付款确认已失效',
- message: '本次付款确认无效或已使用。',
- tip: '请刷新本地流程页后重新选择套餐。'
- });
- return;
- }
- const seats = Number(body.seats);
- const months = Number(body.months);
- const idempotencyKey = String(body.idempotencyKey || '');
- if (!Number.isInteger(seats) || seats < 1 || !Number.isInteger(months) || months < 1 || months > 12 || !/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) {
- json(res, 400, {
- errorCode: 'QW-PAY-400',
- errorTitle: '套餐参数无效',
- message: '席位、购买时长或订单编号格式不正确。',
- tip: '请刷新本地流程页后重新选择套餐。'
- });
- return;
- }
- const result = await callFmodeWecomGateway({
- gatewayPath: '/subscribe',
- body: { seats, months, idempotencyKey },
- token: ctx.token,
- apiBase: ctx.apiBase
- });
- const data = (result && result.data) || {};
- ctx.checkoutToken = createCheckoutToken();
- json(res, 200, {
- seats: data.seats || seats,
- expireAt: data.expireAt || '',
- months: data.months || months,
- amount: data.amount
- });
- return;
- }
- if (url.pathname === '/flow/start-login' && req.method === 'POST') {
- const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
- const base64 = String((result.data && result.data.loginQrcodeBase64Data) || '').replace(/^data:image\/\w+;base64,/, '');
- if (!base64) {
- json(res, 502, subscribeErrorBody(502, '网关未返回二维码'));
- return;
- }
- ctx.qrcodeBuffer = Buffer.from(base64, 'base64');
- if (typeof ctx.onQrcode === 'function') ctx.onQrcode(ctx.qrcodeBuffer);
- json(res, 200, { ok: true });
- return;
- }
- if (url.pathname === '/flow/qrcode') {
- if (!ctx.qrcodeBuffer) {
- json(res, 404, { message: '二维码尚未生成' });
- return;
- }
- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
- res.end(ctx.qrcodeBuffer);
- return;
- }
- if (url.pathname === '/flow/check') {
- const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
- const data = result.data || {};
- json(res, 200, { status: data.status, detail: data.detail || {} });
- return;
- }
- if (url.pathname === '/flow/verify' && req.method === 'POST') {
- const body = await readBody(req);
- const code = String(body.code || '').trim();
- if (!/^\d{6}$/.test(code)) {
- json(res, 400, { errorCode: 'QW-UP-502', message: '请输入 6 位数字验证码' });
- return;
- }
- await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: { uid: ctx.uid, code }, token: ctx.token, apiBase: ctx.apiBase });
- json(res, 200, { ok: true });
- return;
- }
- json(res, 404, { message: 'not found' });
- } catch (error) {
- const httpStatus = Number((error && error.httpStatus) || 502);
- json(res, httpStatus >= 400 && httpStatus < 600 ? httpStatus : 502, subscribeErrorBody(httpStatus, error && error.message));
- }
- };
- }
- function startLoginFlowServer({ token, apiBase, uid, port, onQrcode } = {}) {
- const listenPort = Number(port || process.env.QIWEI_FLOW_PORT) || DEFAULT_FLOW_PORT;
- if (currentServer && currentContext) {
- Object.assign(currentContext, { token, apiBase, uid, onQrcode, checkoutToken: createCheckoutToken() });
- return Promise.resolve({ url: `http://127.0.0.1:${currentContext.port}/`, alreadyRunning: true });
- }
- const ctx = { token, apiBase, uid, port: listenPort, qrcodeBuffer: null, checkoutToken: createCheckoutToken(), onQrcode };
- const server = http.createServer(createFlowHandler(ctx));
- return new Promise((resolve, reject) => {
- server.once('error', reject);
- server.listen(listenPort, '127.0.0.1', () => {
- currentServer = server;
- currentContext = ctx;
- resolve({ url: `http://127.0.0.1:${listenPort}/`, alreadyRunning: false });
- });
- });
- }
- function stopLoginFlowServer() {
- if (currentServer) {
- currentServer.close();
- currentServer = null;
- currentContext = null;
- }
- }
- module.exports = {
- DEFAULT_FLOW_PORT,
- FLOW_PAGE_STYLE,
- json,
- readBody,
- buildFallbackPageHtml,
- startLoginFlowServer,
- stopLoginFlowServer
- };
|