|
@@ -0,0 +1,369 @@
|
|
|
|
|
+const http = require('http');
|
|
|
|
|
+const { callFmodeWecomGateway } = require('../providers/fmode-wecom-gateway');
|
|
|
|
|
+const { SEAT_PLANS, DURATION_PLANS, QIWE_MONTHLY_PRICE, classifySubscribeError, ERROR_CODES } = require('./subscribe-page');
|
|
|
|
|
+
|
|
|
|
|
+const DEFAULT_FLOW_PORT = 4310;
|
|
|
|
|
+
|
|
|
|
|
+let currentServer = null;
|
|
|
|
|
+let currentContext = null;
|
|
|
|
|
+
|
|
|
|
|
+function buildFlowPageHtml({ monthlyPrice }) {
|
|
|
|
|
+ return `<!DOCTYPE html>
|
|
|
|
|
+<html lang="zh-CN">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="utf-8">
|
|
|
|
|
+<title>企微助手 · 开通与登录</title>
|
|
|
|
|
+<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; }
|
|
|
|
|
+</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>
|
|
|
|
|
+const PRICE = ${monthlyPrice};
|
|
|
|
|
+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) {
|
|
|
|
|
+ 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 }) });
|
|
|
|
|
+ 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++;
|
|
|
|
|
+ if (!codeSubmitted && checkErrors >= 2) { clearInterval(pollTimer); startLogin(); 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">' +
|
|
|
|
|
+ '<br><button class="main" id="verify">提交验证码</button></div>';
|
|
|
|
|
+ document.getElementById('verify').onclick = async () => {
|
|
|
|
|
+ const code = document.getElementById('code').value.trim();
|
|
|
|
|
+ if (!/^\\d{6}$/.test(code)) { showMsg('err', '请输入 6 位数字验证码。'); return; }
|
|
|
|
|
+ showMsg('ok', '正在校验…');
|
|
|
|
|
+ const r = await api('/flow/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
|
|
|
|
|
+ if (!r.ok) { 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();
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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>现在可以回到对话中使用企微接口能力(qiwe_api_call、消息、会议等)。</div></div>';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+(async () => {
|
|
|
|
|
+ const r = await api('/flow/state');
|
|
|
|
|
+ if (r.data.online) { renderSuccess(r.data.detail || {}); return; }
|
|
|
|
|
+ if (r.data.subscribed) { startLogin(); return; }
|
|
|
|
|
+ renderSubscribe(r.data.subscribeDetail || '');
|
|
|
|
|
+})();
|
|
|
|
|
+</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 || '') };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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') {
|
|
|
|
|
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
|
|
|
+ res.end(buildFlowPageHtml({ monthlyPrice: QIWE_MONTHLY_PRICE }));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (url.pathname === '/flow/state') {
|
|
|
|
|
+ let subscribed = false;
|
|
|
|
|
+ let subscribeDetail = '';
|
|
|
|
|
+ let online = false;
|
|
|
|
|
+ let detail = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase });
|
|
|
|
|
+ subscribed = Boolean(sub.data && sub.data.subscribed);
|
|
|
|
|
+ if (!subscribed) subscribeDetail = '尚未开通包月订阅';
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ 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 });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (url.pathname === '/flow/subscribe' && req.method === 'POST') {
|
|
|
|
|
+ const body = await readBody(req);
|
|
|
|
|
+ const seats = Math.max(1, Number(body.seats) || 1);
|
|
|
|
|
+ const months = Math.max(1, Math.min(12, Number(body.months) || 1));
|
|
|
|
|
+ const idempotencyKey = typeof body.idempotencyKey === 'string' && body.idempotencyKey ? body.idempotencyKey : '';
|
|
|
|
|
+ let last = null;
|
|
|
|
|
+ for (let i = 0; i < months; i++) {
|
|
|
|
|
+ last = await callFmodeWecomGateway({
|
|
|
|
|
+ gatewayPath: '/subscribe',
|
|
|
|
|
+ body: idempotencyKey ? { seats, idempotencyKey: `${idempotencyKey}-m${i + 1}` } : { seats },
|
|
|
|
|
+ token: ctx.token,
|
|
|
|
|
+ apiBase: ctx.apiBase
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ const data = (last && last.data) || {};
|
|
|
|
|
+ json(res, 200, { seats: data.seats || seats, expireAt: data.expireAt || '', months });
|
|
|
|
|
+ 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.QIWE_FLOW_PORT) || DEFAULT_FLOW_PORT;
|
|
|
|
|
+ if (currentServer && currentContext) {
|
|
|
|
|
+ Object.assign(currentContext, { token, apiBase, uid, onQrcode });
|
|
|
|
|
+ return Promise.resolve({ url: `http://127.0.0.1:${currentContext.port}/`, alreadyRunning: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ const ctx = { token, apiBase, uid, port: listenPort, qrcodeBuffer: null, 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,
|
|
|
|
|
+ startLoginFlowServer,
|
|
|
|
|
+ stopLoginFlowServer
|
|
|
|
|
+};
|