login-flow-server.js 27 KB

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