login-flow-server.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. const http = require('http');
  2. const { randomBytes } = require('crypto');
  3. const { callFmodeWecomGateway } = require('../providers/fmode-wecom-gateway');
  4. const { SEAT_PLANS, DURATION_PLANS, QIWEI_MONTHLY_PRICE, classifySubscribeError, ERROR_CODES } = require('./subscribe-page');
  5. const DEFAULT_FLOW_PORT = 4310;
  6. const FLOW_PAGE_STYLE = `
  7. :root { --brand:#fa8c16; --brand-bg:#fff7e6; --border:#f0f0f0; }
  8. * { box-sizing:border-box; }
  9. body { font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif; margin:0; background:#f7f8fa; color:#333; }
  10. .wrap { max-width:960px; margin:0 auto; padding:28px 20px; }
  11. h1 { font-size:20px; margin:0 0 6px; }
  12. .steps { display:flex; gap:8px; margin:16px 0 22px; font-size:13px; }
  13. .step { flex:1; text-align:center; padding:8px; border-radius:8px; background:#eee; color:#999; }
  14. .step.active { background:var(--brand); color:#fff; }
  15. .step.done { background:#d9f7be; color:#237804; }
  16. .panel { background:#fff; border:1px solid var(--border); border-radius:12px; padding:24px; }
  17. .cards { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:14px; }
  18. .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; }
  19. .card:hover { border-color:var(--brand); }
  20. .card.active { border-color:var(--brand); background:var(--brand-bg); }
  21. .card .t { font-weight:600; font-size:14px; }
  22. .card .p { color:var(--brand); font-size:13px; margin-top:5px; }
  23. .card .n { color:#999; font-size:12px; margin-top:3px; }
  24. .badge { position:absolute; top:-9px; right:8px; background:var(--brand); color:#fff; font-size:11px; border-radius:8px; padding:1px 8px; }
  25. .section-title { font-size:13px; color:#666; margin:14px 0 8px; }
  26. .total { font-size:26px; color:#e64545; font-weight:700; margin:14px 0 4px; }
  27. .total small { font-size:13px; color:#999; font-weight:normal; }
  28. button.main { border:none; background:var(--brand); color:#fff; font-size:15px; padding:11px 28px; border-radius:8px; cursor:pointer; margin-top:12px; }
  29. button.main:disabled { opacity:.6; cursor:not-allowed; }
  30. .msg { margin-top:14px; font-size:13px; line-height:1.7; border-radius:8px; padding:10px 12px; display:none; }
  31. .msg.ok { display:block; background:#f0fff4; border:1px solid #b7eb8f; color:#237804; }
  32. .msg.err { display:block; background:#fff1f0; border:1px solid #ffa39e; color:#a8071a; }
  33. .center { text-align:center; }
  34. #qr { width:260px; height:260px; border:1px solid var(--border); border-radius:10px; background:#fff; padding:10px; }
  35. .hint { color:#999; font-size:13px; margin-top:10px; line-height:1.8; }
  36. input#code { font-size:22px; letter-spacing:8px; text-align:center; width:220px; padding:8px; border:1px solid #ccc; border-radius:8px; }
  37. .success { font-size:18px; color:#237804; }
  38. `;
  39. let currentServer = null;
  40. let currentContext = null;
  41. function buildFlowPageHtml({ monthlyPrice, checkoutToken }) {
  42. return `<!DOCTYPE html>
  43. <html lang="zh-CN">
  44. <head>
  45. <meta charset="utf-8">
  46. <title>企微助手 · 开通与登录</title>
  47. <style>${FLOW_PAGE_STYLE}</style>
  48. </head>
  49. <body>
  50. <div class="wrap">
  51. <h1>企微助手 · 开通与登录</h1>
  52. <div class="steps">
  53. <div class="step" id="s1">1. 开通订阅</div>
  54. <div class="step" id="s2">2. 扫码登录</div>
  55. <div class="step" id="s3">3. 验证码确认</div>
  56. <div class="step" id="s4">4. 完成</div>
  57. </div>
  58. <div class="panel" id="panel">页面加载中…</div>
  59. </div>
  60. <script>
  61. const PRICE = ${monthlyPrice};
  62. const CHECKOUT_TOKEN = ${JSON.stringify(String(checkoutToken || ''))};
  63. const SEATS = ${JSON.stringify(SEAT_PLANS)};
  64. const DURATIONS = ${JSON.stringify(DURATION_PLANS)};
  65. let seats = 1, months = 1, pollTimer = null, currentStep = 0, codeSubmitted = false, verifyWaits = 0, checkErrors = 0;
  66. const panel = document.getElementById('panel');
  67. function setStep(n) {
  68. currentStep = n;
  69. for (let i = 1; i <= 4; i++) {
  70. const el = document.getElementById('s' + i);
  71. el.className = 'step' + (i < n ? ' done' : i === n ? ' active' : '');
  72. }
  73. }
  74. function showMsg(type, html) {
  75. let el = document.getElementById('msg');
  76. if (!el) { el = document.createElement('div'); el.id = 'msg'; panel.appendChild(el); }
  77. el.className = 'msg ' + type;
  78. el.innerHTML = html;
  79. }
  80. async function api(path, opts) {
  81. const res = await fetch(path, opts);
  82. const data = await res.json().catch(() => ({}));
  83. return { ok: res.ok, status: res.status, data };
  84. }
  85. let subscribeKey = '';
  86. function renderSubscribe(detail) {
  87. setStep(1);
  88. subscribeKey = 'flow-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
  89. panel.innerHTML = '<div class="section-title">开通企微账号:每个账号 ¥' + PRICE + '/月,从飞马余额扣费' + (detail ? '(' + detail + ')' : '') + '</div>' +
  90. '<div class="section-title">选择账号数量(席位)</div><div class="cards" id="seat-cards"></div>' +
  91. '<div class="section-title">购买时长</div><div class="cards" id="duration-cards" style="max-width:400px"></div>' +
  92. '<div class="total" id="total"></div><div class="hint" id="detail"></div>' +
  93. '<button class="main" id="buy">开通服务(立即扣费)</button>';
  94. const renderCards = (el, items, key, current, onPick) => {
  95. el.innerHTML = '';
  96. items.forEach(item => {
  97. const div = document.createElement('div');
  98. div.className = 'card' + (item[key] === current ? ' active' : '');
  99. div.innerHTML = (item.recommended ? '<span class="badge">推荐</span>' : '') +
  100. '<div class="t">' + item.label + '</div>' +
  101. '<div class="p">¥' + (key === 'seats' ? item.seats * PRICE + '/月' : item.months * PRICE + '/号') + '</div>' +
  102. (item.note ? '<div class="n">' + item.note + '</div>' : '');
  103. div.onclick = () => onPick(item[key]);
  104. el.appendChild(div);
  105. });
  106. };
  107. const paint = () => {
  108. renderCards(document.getElementById('seat-cards'), SEATS, 'seats', seats, v => { seats = v; paint(); });
  109. renderCards(document.getElementById('duration-cards'), DURATIONS, 'months', months, v => { months = v; paint(); });
  110. document.getElementById('total').innerHTML = '¥' + (seats * months * PRICE).toLocaleString() + ' <small>/ 一次性扣费</small>';
  111. document.getElementById('detail').textContent = seats + ' 个号 × ¥' + PRICE + '/月 × ' + months + ' 个月,从飞马余额扣除';
  112. };
  113. paint();
  114. document.getElementById('buy').onclick = async () => {
  115. const btn = document.getElementById('buy');
  116. btn.disabled = true;
  117. showMsg('ok', '正在开通扣费,请稍候…');
  118. const r = await api('/flow/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ seats, months, idempotencyKey: subscribeKey, checkoutToken: CHECKOUT_TOKEN }) });
  119. if (!r.ok) {
  120. showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + '] ' + (r.data.errorTitle || '开通失败') + '</b><br>' + (r.data.message || '') + '<br>' + (r.data.tip || ''));
  121. btn.disabled = false;
  122. return;
  123. }
  124. showMsg('ok', '开通成功!席位 ' + r.data.seats + ',到期 ' + r.data.expireAt + '。正在生成登录二维码…');
  125. setTimeout(startLogin, 800);
  126. };
  127. }
  128. async function startLogin() {
  129. codeSubmitted = false;
  130. verifyWaits = 0;
  131. checkErrors = 0;
  132. setStep(2);
  133. panel.innerHTML = '<div class="center"><div class="section-title">正在生成企业微信登录二维码…</div></div>';
  134. const r = await api('/flow/start-login', { method: 'POST' });
  135. if (!r.ok) {
  136. panel.innerHTML = '<div class="center"></div>';
  137. showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + ']</b> ' + (r.data.message || '生成二维码失败') + '<br>' + (r.data.tip || '') + '<br><button class="main" onclick="startLogin()">重试</button>');
  138. return;
  139. }
  140. panel.innerHTML = '<div class="center"><img id="qr" src="/flow/qrcode?t=' + Date.now() + '">' +
  141. '<div class="hint">请用手机企业微信扫码并确认登录。二维码约 4 分钟内有效。<br>本页面每 3 秒自动检测扫码状态,无需手动刷新。</div>' +
  142. '<div class="hint" id="poll-status">等待扫码…</div></div>';
  143. poll();
  144. }
  145. function poll() {
  146. clearInterval(pollTimer);
  147. pollTimer = setInterval(async () => {
  148. const r = await api('/flow/check');
  149. const s = String(r.data.status);
  150. const el = document.getElementById('poll-status');
  151. if (!r.ok || r.data.status === undefined) {
  152. // 登录成功后上游会销毁二维码会话:先用设备在线状态判断是否已成功
  153. checkErrors++;
  154. const st = await api('/flow/state');
  155. if (st.data.online) { clearInterval(pollTimer); renderSuccess(st.data.detail || {}); return; }
  156. if (st.data.subscribed === false && !st.data.stateError) { clearInterval(pollTimer); renderSubscribe(st.data.subscribeDetail || '订阅已到期'); return; }
  157. // 二维码过期或上游瞬时异常:未提交验证码时自动重新生成二维码
  158. if (!codeSubmitted && checkErrors >= 2) { clearInterval(pollTimer); startLogin(); return; }
  159. if (codeSubmitted && checkErrors >= 10) {
  160. clearInterval(pollTimer);
  161. showMsg('err', '登录确认异常。<br><button class="main" onclick="startLogin()">重新生成二维码</button>');
  162. return;
  163. }
  164. if (el) el.textContent = '状态检测异常,重试中…';
  165. return;
  166. }
  167. checkErrors = 0;
  168. if (s === '2') { clearInterval(pollTimer); renderSuccess(r.data.detail || {}); return; }
  169. if (s === '10') {
  170. if (!codeSubmitted) { clearInterval(pollTimer); renderVerify(); return; }
  171. // 验证码已提交:不回退到输入页,继续等待登录确认,并用设备在线状态兜底
  172. verifyWaits++;
  173. const st = await api('/flow/state');
  174. if (st.data.online) { clearInterval(pollTimer); renderSuccess(st.data.detail || {}); return; }
  175. if (el) el.textContent = '验证码已提交,正在确认登录…';
  176. if (verifyWaits >= 10) {
  177. clearInterval(pollTimer);
  178. showMsg('err', '登录确认超时。可能验证码错误或已失效。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="startLogin()">重新生成二维码</button>');
  179. }
  180. return;
  181. }
  182. if (s === '4') { clearInterval(pollTimer); showMsg('err', '手机端取消了登录或二维码会话已失效。<br><button class="main" onclick="startLogin()">重新生成二维码</button>'); return; }
  183. if (el) el.textContent = s === '1' ? '已扫码,请在手机上确认…' : '等待扫码…(状态 ' + s + ')';
  184. }, 3000);
  185. }
  186. function renderVerify() {
  187. setStep(3);
  188. panel.innerHTML = '<div class="center"><div class="section-title">已扫码确认,请输入手机企业微信上显示的 6 位验证码</div>' +
  189. '<input id="code" maxlength="6" placeholder="······" inputmode="numeric" autocomplete="one-time-code">' +
  190. '<br><button class="main" id="verify">提交验证码</button>' +
  191. '<div class="hint">验证码在手机企业微信确认登录后显示,输完 6 位自动提交</div></div>';
  192. const input = document.getElementById('code');
  193. input.focus();
  194. const submit = async () => {
  195. const code = input.value.trim();
  196. if (!/^\\d{6}$/.test(code)) { showMsg('err', '请输入 6 位数字验证码。'); return; }
  197. const btn = document.getElementById('verify');
  198. btn.disabled = true;
  199. showMsg('ok', '正在校验…');
  200. const r = await api('/flow/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
  201. if (!r.ok) { btn.disabled = false; showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + ']</b> ' + (r.data.message || '验证码校验失败') + ',请重试。'); return; }
  202. codeSubmitted = true;
  203. verifyWaits = 0;
  204. panel.innerHTML = '<div class="center"><div class="section-title">验证码已提交,正在确认登录…</div><div class="hint" id="poll-status">确认中,每 3 秒自动检测…</div></div>';
  205. poll();
  206. };
  207. document.getElementById('verify').onclick = submit;
  208. input.onkeydown = (e) => { if (e.key === 'Enter') submit(); };
  209. input.oninput = () => { if (/^\\d{6}$/.test(input.value.trim())) submit(); };
  210. }
  211. function renderSuccess(detail) {
  212. setStep(4);
  213. panel.innerHTML = '<div class="center"><div class="success">✔ 企业微信登录成功</div>' +
  214. '<div class="hint">账号:' + (detail.nickname || detail.userId || '未知') + (detail.corpName ? '(' + detail.corpName + ')' : '') + '<br>现在可以回到对话中使用企微接口能力(qiwei_api_call、消息、会议等)。</div></div>';
  215. }
  216. async function boot() {
  217. const r = await api('/flow/state');
  218. if (r.data.online) { renderSuccess(r.data.detail || {}); return; }
  219. if (r.data.subscribed) { startLogin(); return; }
  220. if (r.data.stateError) {
  221. panel.innerHTML = '<div class="center"></div>';
  222. showMsg('err', '网络异常,暂时无法获取订阅状态(不会重复扣费)。<br><button class="main" onclick="boot()">重试</button>');
  223. return;
  224. }
  225. renderSubscribe(r.data.subscribeDetail || '');
  226. }
  227. boot();
  228. </script>
  229. </body>
  230. </html>`;
  231. }
  232. function buildFallbackPageHtml({ qrcodeUrl }) {
  233. return `<!DOCTYPE html>
  234. <html lang="zh-CN">
  235. <head>
  236. <meta charset="utf-8">
  237. <title>企业微信扫码登录</title>
  238. <style>${FLOW_PAGE_STYLE}</style>
  239. </head>
  240. <body>
  241. <div class="wrap">
  242. <h1>企业微信扫码登录</h1>
  243. <div class="panel" id="panel">
  244. <div class="center">
  245. <div class="section-title">请用手机企业微信扫描下方二维码</div>
  246. <img id="qr" src="${qrcodeUrl}?t=${Date.now()}" alt="登录二维码">
  247. <div class="hint" id="poll-status">正在检测扫码状态…</div>
  248. </div>
  249. </div>
  250. </div>
  251. <script>
  252. let pollTimer = null, codeSubmitted = false, verifyWaits = 0, checkErrors = 0;
  253. const panel = document.getElementById('panel');
  254. function showMsg(type, html) {
  255. let el = document.getElementById('msg');
  256. if (!el) { el = document.createElement('div'); el.id = 'msg'; panel.appendChild(el); }
  257. el.className = 'msg ' + type;
  258. el.innerHTML = html;
  259. }
  260. async function api(path, opts) {
  261. const res = await fetch(path, opts);
  262. const data = await res.json().catch(() => ({}));
  263. return { ok: res.ok, status: res.status, data };
  264. }
  265. function renderVerify() {
  266. panel.innerHTML = '<div class="center"><div class="section-title">已扫码确认,请输入手机企业微信上显示的 6 位验证码</div>' +
  267. '<input id="code" maxlength="6" placeholder="······" inputmode="numeric" autocomplete="one-time-code">' +
  268. '<br><button class="main" id="verify">提交验证码</button>' +
  269. '<div class="hint">验证码在手机企业微信确认登录后显示,输完 6 位自动提交</div></div>';
  270. const input = document.getElementById('code');
  271. input.focus();
  272. const submit = async () => {
  273. const code = input.value.trim();
  274. if (!/^\\d{6}$/.test(code)) { showMsg('err', '请输入 6 位数字验证码。'); return; }
  275. const btn = document.getElementById('verify');
  276. btn.disabled = true;
  277. showMsg('ok', '正在校验…');
  278. const r = await api('/flow/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
  279. if (!r.ok) { btn.disabled = false; showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + ']</b> ' + (r.data.message || '验证码校验失败') + ',请重试。'); return; }
  280. codeSubmitted = true;
  281. verifyWaits = 0;
  282. panel.innerHTML = '<div class="center"><div class="section-title">验证码已提交,正在确认登录…</div><div class="hint" id="poll-status">确认中,每 3 秒自动检测…</div></div>';
  283. poll();
  284. };
  285. document.getElementById('verify').onclick = submit;
  286. input.onkeydown = (e) => { if (e.key === 'Enter') submit(); };
  287. input.oninput = () => { if (/^\\d{6}$/.test(input.value.trim())) submit(); };
  288. }
  289. function renderSuccess(detail) {
  290. panel.innerHTML = '<div class="center"><div class="success">✔ 企业微信登录成功</div>' +
  291. '<div class="hint">账号:' + (detail.nickname || detail.userId || '未知') + (detail.corpName ? '(' + detail.corpName + ')' : '') + '<br>现在可以回到对话中使用企微接口能力。</div></div>';
  292. }
  293. async function refreshQr() {
  294. showMsg('ok', '正在重新生成二维码…');
  295. const r = await api('/flow/refresh', { method: 'POST' });
  296. if (!r.ok) {
  297. showMsg('err', '<b>重新生成失败</b><br>' + (r.data.message || '') + '<br><button class="main" onclick="refreshQr()">重试</button>');
  298. return;
  299. }
  300. codeSubmitted = false;
  301. verifyWaits = 0;
  302. checkErrors = 0;
  303. document.getElementById('qr').src = '/flow/qrcode?t=' + Date.now();
  304. panel.innerHTML = '<div class="center"><div class="section-title">请用手机企业微信扫描下方二维码</div>' +
  305. '<img id="qr" src="/flow/qrcode?t=' + Date.now() + '" alt="登录二维码">' +
  306. '<div class="hint" id="poll-status">正在检测扫码状态…</div></div>';
  307. poll();
  308. }
  309. function poll() {
  310. clearInterval(pollTimer);
  311. pollTimer = setInterval(async () => {
  312. const r = await api('/flow/check');
  313. const s = String(r.data.status);
  314. const el = document.getElementById('poll-status');
  315. if (!r.ok || r.data.status === undefined) {
  316. checkErrors++;
  317. if (!codeSubmitted && checkErrors >= 2) { clearInterval(pollTimer); refreshQr(); return; }
  318. if (codeSubmitted && checkErrors >= 10) {
  319. clearInterval(pollTimer);
  320. showMsg('err', '登录确认异常。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="refreshQr()">重新生成二维码</button>');
  321. return;
  322. }
  323. if (el) el.textContent = '状态检测异常,重试中…';
  324. return;
  325. }
  326. checkErrors = 0;
  327. if (s === '2') { clearInterval(pollTimer); renderSuccess(r.data.detail || {}); return; }
  328. if (s === '10') {
  329. if (!codeSubmitted) { clearInterval(pollTimer); renderVerify(); return; }
  330. verifyWaits++;
  331. if (el) el.textContent = '验证码已提交,正在确认登录…';
  332. if (verifyWaits >= 10) {
  333. clearInterval(pollTimer);
  334. showMsg('err', '登录确认超时。可能验证码错误或已失效。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="refreshQr()">重新生成二维码</button>');
  335. }
  336. return;
  337. }
  338. if (s === '4') { clearInterval(pollTimer); showMsg('err', '手机端取消了登录或二维码会话已失效。<br><button class="main" onclick="refreshQr()">重新生成二维码</button>'); return; }
  339. if (el) el.textContent = s === '1' ? '已扫码,请在手机上确认…' : '等待扫码…(状态 ' + s + ')';
  340. }, 3000);
  341. }
  342. poll();
  343. </script>
  344. </body>
  345. </html>`;
  346. }
  347. function json(res, status, body) {
  348. res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  349. res.end(JSON.stringify(body));
  350. }
  351. function subscribeErrorBody(httpStatus, message) {
  352. const codeKey = classifySubscribeError(httpStatus, message);
  353. const info = ERROR_CODES[codeKey] || {};
  354. return { errorCode: codeKey, errorTitle: info.title || '操作失败', tip: info.tip || '', message: String(message || '') };
  355. }
  356. function createCheckoutToken() {
  357. return randomBytes(24).toString('base64url');
  358. }
  359. function validCheckoutRequest(req, ctx, body) {
  360. const contentType = String(req.headers['content-type'] || '').toLowerCase();
  361. const origin = String(req.headers.origin || '');
  362. const expectedOrigin = `http://127.0.0.1:${ctx.port}`;
  363. return contentType.startsWith('application/json') &&
  364. origin === expectedOrigin &&
  365. typeof body.checkoutToken === 'string' &&
  366. body.checkoutToken === ctx.checkoutToken;
  367. }
  368. async function readBody(req) {
  369. const chunks = [];
  370. for await (const chunk of req) chunks.push(chunk);
  371. try {
  372. return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
  373. } catch {
  374. return {};
  375. }
  376. }
  377. function createFlowHandler(ctx) {
  378. return async (req, res) => {
  379. const url = new URL(req.url, 'http://localhost');
  380. try {
  381. if (url.pathname === '/' || url.pathname === '/index.html') {
  382. ctx.checkoutToken = createCheckoutToken();
  383. res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
  384. res.end(buildFlowPageHtml({ monthlyPrice: QIWEI_MONTHLY_PRICE, checkoutToken: ctx.checkoutToken }));
  385. return;
  386. }
  387. if (url.pathname === '/flow/state') {
  388. let subscribed = false;
  389. let subscribeDetail = '';
  390. let online = false;
  391. let detail = null;
  392. let stateError = false;
  393. try {
  394. const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase });
  395. subscribed = Boolean(sub.data && sub.data.subscribed);
  396. if (!subscribed) subscribeDetail = '尚未开通包月订阅';
  397. } catch (error) {
  398. stateError = true;
  399. subscribeDetail = String((error && error.message) || '订阅状态查询失败');
  400. }
  401. if (subscribed) {
  402. try {
  403. const status = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
  404. online = Boolean(status.data && status.data.online);
  405. detail = (status.data && status.data.detail) || null;
  406. } catch {
  407. online = false;
  408. }
  409. }
  410. json(res, 200, { subscribed, subscribeDetail, online, detail, stateError });
  411. return;
  412. }
  413. if (url.pathname === '/flow/subscribe' && req.method === 'POST') {
  414. const body = await readBody(req);
  415. if (!validCheckoutRequest(req, ctx, body)) {
  416. json(res, 403, {
  417. errorCode: 'QW-PAY-403',
  418. errorTitle: '付款确认已失效',
  419. message: '本次付款确认无效或已使用。',
  420. tip: '请刷新本地流程页后重新选择套餐。'
  421. });
  422. return;
  423. }
  424. const seats = Number(body.seats);
  425. const months = Number(body.months);
  426. const idempotencyKey = String(body.idempotencyKey || '');
  427. if (!Number.isInteger(seats) || seats < 1 || !Number.isInteger(months) || months < 1 || months > 12 || !/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) {
  428. json(res, 400, {
  429. errorCode: 'QW-PAY-400',
  430. errorTitle: '套餐参数无效',
  431. message: '席位、购买时长或订单编号格式不正确。',
  432. tip: '请刷新本地流程页后重新选择套餐。'
  433. });
  434. return;
  435. }
  436. const result = await callFmodeWecomGateway({
  437. gatewayPath: '/subscribe',
  438. body: { seats, months, idempotencyKey },
  439. token: ctx.token,
  440. apiBase: ctx.apiBase
  441. });
  442. const data = (result && result.data) || {};
  443. ctx.checkoutToken = createCheckoutToken();
  444. json(res, 200, {
  445. seats: data.seats || seats,
  446. expireAt: data.expireAt || '',
  447. months: data.months || months,
  448. amount: data.amount
  449. });
  450. return;
  451. }
  452. if (url.pathname === '/flow/start-login' && req.method === 'POST') {
  453. const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
  454. const base64 = String((result.data && result.data.loginQrcodeBase64Data) || '').replace(/^data:image\/\w+;base64,/, '');
  455. if (!base64) {
  456. json(res, 502, subscribeErrorBody(502, '网关未返回二维码'));
  457. return;
  458. }
  459. ctx.qrcodeBuffer = Buffer.from(base64, 'base64');
  460. if (typeof ctx.onQrcode === 'function') ctx.onQrcode(ctx.qrcodeBuffer);
  461. json(res, 200, { ok: true });
  462. return;
  463. }
  464. if (url.pathname === '/flow/qrcode') {
  465. if (!ctx.qrcodeBuffer) {
  466. json(res, 404, { message: '二维码尚未生成' });
  467. return;
  468. }
  469. res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
  470. res.end(ctx.qrcodeBuffer);
  471. return;
  472. }
  473. if (url.pathname === '/flow/check') {
  474. const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
  475. const data = result.data || {};
  476. json(res, 200, { status: data.status, detail: data.detail || {} });
  477. return;
  478. }
  479. if (url.pathname === '/flow/verify' && req.method === 'POST') {
  480. const body = await readBody(req);
  481. const code = String(body.code || '').trim();
  482. if (!/^\d{6}$/.test(code)) {
  483. json(res, 400, { errorCode: 'QW-UP-502', message: '请输入 6 位数字验证码' });
  484. return;
  485. }
  486. await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: { uid: ctx.uid, code }, token: ctx.token, apiBase: ctx.apiBase });
  487. json(res, 200, { ok: true });
  488. return;
  489. }
  490. json(res, 404, { message: 'not found' });
  491. } catch (error) {
  492. const httpStatus = Number((error && error.httpStatus) || 502);
  493. json(res, httpStatus >= 400 && httpStatus < 600 ? httpStatus : 502, subscribeErrorBody(httpStatus, error && error.message));
  494. }
  495. };
  496. }
  497. function startLoginFlowServer({ token, apiBase, uid, port, onQrcode } = {}) {
  498. const listenPort = Number(port || process.env.QIWEI_FLOW_PORT) || DEFAULT_FLOW_PORT;
  499. if (currentServer && currentContext) {
  500. Object.assign(currentContext, { token, apiBase, uid, onQrcode, checkoutToken: createCheckoutToken() });
  501. return Promise.resolve({ url: `http://127.0.0.1:${currentContext.port}/`, alreadyRunning: true });
  502. }
  503. const ctx = { token, apiBase, uid, port: listenPort, qrcodeBuffer: null, checkoutToken: createCheckoutToken(), onQrcode };
  504. const server = http.createServer(createFlowHandler(ctx));
  505. return new Promise((resolve, reject) => {
  506. server.once('error', reject);
  507. server.listen(listenPort, '127.0.0.1', () => {
  508. currentServer = server;
  509. currentContext = ctx;
  510. resolve({ url: `http://127.0.0.1:${listenPort}/`, alreadyRunning: false });
  511. });
  512. });
  513. }
  514. function stopLoginFlowServer() {
  515. if (currentServer) {
  516. currentServer.close();
  517. currentServer = null;
  518. currentContext = null;
  519. }
  520. }
  521. module.exports = {
  522. DEFAULT_FLOW_PORT,
  523. FLOW_PAGE_STYLE,
  524. json,
  525. readBody,
  526. buildFallbackPageHtml,
  527. startLoginFlowServer,
  528. stopLoginFlowServer
  529. };