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
};