const http = require('http'); const { randomBytes } = require('crypto'); const { callFmodeWecomGateway } = require('../providers/fmode-wecom-gateway'); const { SEAT_PLANS, DURATION_PLANS, QIWEI_MONTHLY_PRICE, classifySubscribeError, ERROR_CODES } = require('./subscribe-page'); const DEFAULT_FLOW_PORT = 4310; const FLOW_PAGE_STYLE = ` :root { --brand:#fa8c16; --brand-bg:#fff7e6; --border:#f0f0f0; } * { box-sizing:border-box; } body { font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif; margin:0; background:#f7f8fa; color:#333; } .wrap { max-width:960px; margin:0 auto; padding:28px 20px; } h1 { font-size:20px; margin:0 0 6px; } .steps { display:flex; gap:8px; margin:16px 0 22px; font-size:13px; } .step { flex:1; text-align:center; padding:8px; border-radius:8px; background:#eee; color:#999; } .step.active { background:var(--brand); color:#fff; } .step.done { background:#d9f7be; color:#237804; } .panel { background:#fff; border:1px solid var(--border); border-radius:12px; padding:24px; } .cards { display:flex; flex-wrap:wrap; gap:12px; margin-bottom:14px; } .card { position:relative; flex:1; min-width:110px; border:1px solid var(--border); border-radius:10px; padding:12px 14px; cursor:pointer; text-align:center; transition:all .15s; } .card:hover { border-color:var(--brand); } .card.active { border-color:var(--brand); background:var(--brand-bg); } .card .t { font-weight:600; font-size:14px; } .card .p { color:var(--brand); font-size:13px; margin-top:5px; } .card .n { color:#999; font-size:12px; margin-top:3px; } .badge { position:absolute; top:-9px; right:8px; background:var(--brand); color:#fff; font-size:11px; border-radius:8px; padding:1px 8px; } .section-title { font-size:13px; color:#666; margin:14px 0 8px; } .total { font-size:26px; color:#e64545; font-weight:700; margin:14px 0 4px; } .total small { font-size:13px; color:#999; font-weight:normal; } button.main { border:none; background:var(--brand); color:#fff; font-size:15px; padding:11px 28px; border-radius:8px; cursor:pointer; margin-top:12px; } button.main:disabled { opacity:.6; cursor:not-allowed; } .msg { margin-top:14px; font-size:13px; line-height:1.7; border-radius:8px; padding:10px 12px; display:none; } .msg.ok { display:block; background:#f0fff4; border:1px solid #b7eb8f; color:#237804; } .msg.err { display:block; background:#fff1f0; border:1px solid #ffa39e; color:#a8071a; } .center { text-align:center; } #qr { width:260px; height:260px; border:1px solid var(--border); border-radius:10px; background:#fff; padding:10px; } .hint { color:#999; font-size:13px; margin-top:10px; line-height:1.8; } input#code { font-size:22px; letter-spacing:8px; text-align:center; width:220px; padding:8px; border:1px solid #ccc; border-radius:8px; } .success { font-size:18px; color:#237804; } `; let currentServer = null; let currentContext = null; function buildFlowPageHtml({ monthlyPrice, checkoutToken }) { return ` 企微助手 · 开通与登录

企微助手 · 开通与登录

1. 开通订阅
2. 扫码登录
3. 验证码确认
4. 完成
页面加载中…
`; } function buildFallbackPageHtml({ qrcodeUrl }) { return ` 企业微信扫码登录

企业微信扫码登录

请用手机企业微信扫描下方二维码
登录二维码
正在检测扫码状态…
`; } function json(res, status, body) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(body)); } function subscribeErrorBody(httpStatus, message) { const codeKey = classifySubscribeError(httpStatus, message); const info = ERROR_CODES[codeKey] || {}; return { errorCode: codeKey, errorTitle: info.title || '操作失败', tip: info.tip || '', message: String(message || '') }; } function createCheckoutToken() { return randomBytes(24).toString('base64url'); } function validCheckoutRequest(req, ctx, body) { const contentType = String(req.headers['content-type'] || '').toLowerCase(); const origin = String(req.headers.origin || ''); const expectedOrigin = `http://127.0.0.1:${ctx.port}`; return contentType.startsWith('application/json') && origin === expectedOrigin && typeof body.checkoutToken === 'string' && body.checkoutToken === ctx.checkoutToken; } async function readBody(req) { const chunks = []; for await (const chunk of req) chunks.push(chunk); try { return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { return {}; } } function createFlowHandler(ctx) { return async (req, res) => { const url = new URL(req.url, 'http://localhost'); try { if (url.pathname === '/' || url.pathname === '/index.html') { ctx.checkoutToken = createCheckoutToken(); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(buildFlowPageHtml({ monthlyPrice: QIWEI_MONTHLY_PRICE, checkoutToken: ctx.checkoutToken })); return; } if (url.pathname === '/flow/state') { let subscribed = false; let subscribeDetail = ''; let online = false; let detail = null; let stateError = false; let monthlyPrice = QIWEI_MONTHLY_PRICE; try { const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase }); subscribed = Boolean(sub.data && sub.data.subscribed); const livePrice = Number(sub.data && sub.data.price); if (Number.isFinite(livePrice) && livePrice > 0) monthlyPrice = livePrice; if (!subscribed) subscribeDetail = '尚未开通包月订阅'; } catch (error) { stateError = true; subscribeDetail = String((error && error.message) || '订阅状态查询失败'); } if (subscribed) { try { const status = await callFmodeWecomGateway({ gatewayPath: '/login/status', httpMethod: 'GET', query: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase }); online = Boolean(status.data && status.data.online); detail = (status.data && status.data.detail) || null; } catch { online = false; } } json(res, 200, { subscribed, subscribeDetail, online, detail, stateError, monthlyPrice }); return; } if (url.pathname === '/flow/subscribe' && req.method === 'POST') { const body = await readBody(req); if (!validCheckoutRequest(req, ctx, body)) { json(res, 403, { errorCode: 'QW-PAY-403', errorTitle: '付款确认已失效', message: '本次付款确认无效或已使用。', tip: '请刷新本地流程页后重新选择套餐。' }); return; } const seats = Number(body.seats); const months = Number(body.months); const idempotencyKey = String(body.idempotencyKey || ''); if (!Number.isInteger(seats) || seats < 1 || !Number.isInteger(months) || months < 1 || months > 12 || !/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) { json(res, 400, { errorCode: 'QW-PAY-400', errorTitle: '套餐参数无效', message: '席位、购买时长或订单编号格式不正确。', tip: '请刷新本地流程页后重新选择套餐。' }); return; } const result = await callFmodeWecomGateway({ gatewayPath: '/subscribe', body: { seats, months, idempotencyKey }, token: ctx.token, apiBase: ctx.apiBase }); const data = (result && result.data) || {}; ctx.checkoutToken = createCheckoutToken(); json(res, 200, { seats: data.seats || seats, expireAt: data.expireAt || '', months: data.months || months, amount: data.amount }); return; } if (url.pathname === '/flow/start-login' && req.method === 'POST') { const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase }); const base64 = String((result.data && result.data.loginQrcodeBase64Data) || '').replace(/^data:image\/\w+;base64,/, ''); if (!base64) { json(res, 502, subscribeErrorBody(502, '网关未返回二维码')); return; } ctx.qrcodeBuffer = Buffer.from(base64, 'base64'); if (typeof ctx.onQrcode === 'function') ctx.onQrcode(ctx.qrcodeBuffer); json(res, 200, { ok: true }); return; } if (url.pathname === '/flow/qrcode') { if (!ctx.qrcodeBuffer) { json(res, 404, { message: '二维码尚未生成' }); return; } res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }); res.end(ctx.qrcodeBuffer); return; } if (url.pathname === '/flow/check') { const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase }); const data = result.data || {}; json(res, 200, { status: data.status, detail: data.detail || {} }); return; } if (url.pathname === '/flow/verify' && req.method === 'POST') { const body = await readBody(req); const code = String(body.code || '').trim(); if (!/^\d{6}$/.test(code)) { json(res, 400, { errorCode: 'QW-UP-502', message: '请输入 6 位数字验证码' }); return; } await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: { uid: ctx.uid, code }, token: ctx.token, apiBase: ctx.apiBase }); json(res, 200, { ok: true }); return; } json(res, 404, { message: 'not found' }); } catch (error) { const httpStatus = Number((error && error.httpStatus) || 502); json(res, httpStatus >= 400 && httpStatus < 600 ? httpStatus : 502, subscribeErrorBody(httpStatus, error && error.message)); } }; } function startLoginFlowServer({ token, apiBase, uid, port, onQrcode } = {}) { const listenPort = Number(port || process.env.QIWEI_FLOW_PORT) || DEFAULT_FLOW_PORT; if (currentServer && currentContext) { Object.assign(currentContext, { token, apiBase, uid, onQrcode, checkoutToken: createCheckoutToken() }); return Promise.resolve({ url: `http://127.0.0.1:${currentContext.port}/`, alreadyRunning: true }); } const ctx = { token, apiBase, uid, port: listenPort, qrcodeBuffer: null, checkoutToken: createCheckoutToken(), onQrcode }; const server = http.createServer(createFlowHandler(ctx)); return new Promise((resolve, reject) => { server.once('error', reject); server.listen(listenPort, '127.0.0.1', () => { currentServer = server; currentContext = ctx; resolve({ url: `http://127.0.0.1:${listenPort}/`, alreadyRunning: false }); }); }); } function stopLoginFlowServer() { if (currentServer) { currentServer.close(); currentServer = null; currentContext = null; } } module.exports = { DEFAULT_FLOW_PORT, FLOW_PAGE_STYLE, json, readBody, buildFallbackPageHtml, startLoginFlowServer, stopLoginFlowServer };