login-flow-server.js 27 KB

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