Просмотр исходного кода

企微助手:新增本地端口登录流程页(套餐开通/扫码/验证码自动推进)与订阅错误码模块

gangvy 2 месяцев назад
Родитель
Сommit
815fd09054

+ 59 - 0
claude-code/claude-code-qiwe-assistant/docs/specs/qiwe-subscription-flow.md

@@ -0,0 +1,59 @@
+# 企微登录两步流程与订阅套餐页规范
+
+## 流程页服务(默认模式)
+
+`qiwe_login_start` 默认在本地端口(`QIWE_FLOW_PORT`,默认 4310)启动
+流程页服务(`mcp/src/core/login-flow-server.js`)并自动打开浏览器:
+
+- 页面加载时请求 `/flow/state` 自动判断当前所处步骤
+  (已登录→完成页;已订阅→直接出码;未订阅→套餐页);
+- 套餐页点「开通服务」→ `/flow/subscribe` 扣费成功后自动进入出码步骤;
+- 出码后每 3 秒自动轮询 `/flow/check`,无需手动刷新;
+- 状态 10 自动切到验证码输入页,提交 `/flow/verify` 后继续轮询;
+- 状态 2 显示登录成功页。
+
+鉴权 token 仅保存在流程页服务进程内存中,不写入 HTML。
+传 `flowUi=false` 回退到静态文件模式(下述两步流程)。
+
+## 流程总览(静态模式)
+
+1. **第 1 步:开通订阅(付费)**
+   - `qiwe_login_start` 会先查询 `GET /subscribe/status`;
+   - 未开通/已到期时,不直接报错,而是生成套餐选择页
+     `outputs/subscription/qiwe-subscribe.html` 并自动打开浏览器;
+   - 页面上选择席位(1/3/10/20/50 个号)与时长(1/6/12 个月),
+     点击「开通服务」后调用 `POST /subscribe`,按
+     `席位数 × ¥200/月 × 月数` 从飞马余额一次性扣费;
+   - 开通成功后重新调用 `qiwe_login_start` 进入第 2 步。
+
+2. **第 2 步:扫码登录**
+   - `qiwe_login_start` 生成二维码(`outputs/login/qiwe-login-qrcode.png` + 预览页);
+   - 手机企业微信扫码确认后,`qiwe_login_check` 轮询状态;
+   - 状态 10 时用 `qiwe_login_verify` 提交 6 位验证码。
+
+## 价格
+
+- 单价:每个账号(席位)**¥200/月**(`QIWE_MONTHLY_PRICE`)。
+- 页面套餐:1 号 ¥200/月、3 号 ¥600/月、10 号 ¥2000/月(推荐)、
+  20 号 ¥4000/月、50 号 ¥10000/月;时长 1/6/12 个月。
+
+## 错误码设计
+
+| 错误码 | HTTP | 含义 | 用户提示 |
+| --- | --- | --- | --- |
+| QW-AUTH-401 | 401 | 鉴权失败 | token 无效或过期,检查 QIWE_AUTH_TOKEN 后重试 |
+| QW-PAY-402 | 402 | 飞马余额不足 | 余额不足以完成扣费,请先充值飞马余额 |
+| QW-SUB-402 | 402 | 订阅未开通或已到期 | 在套餐页选择席位并点击开通 |
+| QW-SEAT-403 | 403 | 席位已满 | 增购席位或停用闲置设备 |
+| QW-UP-502 | 502/503 | 网关或企微服务不可用 | 稍后重试 |
+
+对应模块:`mcp/src/core/subscribe-page.js`(`ERROR_CODES` /
+`classifySubscribeError` / `saveSubscribePage`)。
+
+## MCP 工具侧状态
+
+- `needs_subscription`:`qiwe_login_start` 检测到未订阅时返回,
+  `summary.subscribePage` 指向套餐页路径;
+- `needs_seat`:席位不足;
+- `needs_auth`:鉴权失败;
+- `needs_verify_code`:扫码后需要 6 位验证码。

+ 369 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js

@@ -0,0 +1,369 @@
+const http = require('http');
+const { callFmodeWecomGateway } = require('../providers/fmode-wecom-gateway');
+const { SEAT_PLANS, DURATION_PLANS, QIWE_MONTHLY_PRICE, classifySubscribeError, ERROR_CODES } = require('./subscribe-page');
+
+const DEFAULT_FLOW_PORT = 4310;
+
+let currentServer = null;
+let currentContext = null;
+
+function buildFlowPageHtml({ monthlyPrice }) {
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<title>企微助手 · 开通与登录</title>
+<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; }
+</style>
+</head>
+<body>
+<div class="wrap">
+  <h1>企微助手 · 开通与登录</h1>
+  <div class="steps">
+    <div class="step" id="s1">1. 开通订阅</div>
+    <div class="step" id="s2">2. 扫码登录</div>
+    <div class="step" id="s3">3. 验证码确认</div>
+    <div class="step" id="s4">4. 完成</div>
+  </div>
+  <div class="panel" id="panel">页面加载中…</div>
+</div>
+<script>
+const PRICE = ${monthlyPrice};
+const SEATS = ${JSON.stringify(SEAT_PLANS)};
+const DURATIONS = ${JSON.stringify(DURATION_PLANS)};
+let seats = 1, months = 1, pollTimer = null, currentStep = 0, codeSubmitted = false, verifyWaits = 0, checkErrors = 0;
+
+const panel = document.getElementById('panel');
+
+function setStep(n) {
+  currentStep = n;
+  for (let i = 1; i <= 4; i++) {
+    const el = document.getElementById('s' + i);
+    el.className = 'step' + (i < n ? ' done' : i === n ? ' active' : '');
+  }
+}
+
+function showMsg(type, html) {
+  let el = document.getElementById('msg');
+  if (!el) { el = document.createElement('div'); el.id = 'msg'; panel.appendChild(el); }
+  el.className = 'msg ' + type;
+  el.innerHTML = html;
+}
+
+async function api(path, opts) {
+  const res = await fetch(path, opts);
+  const data = await res.json().catch(() => ({}));
+  return { ok: res.ok, status: res.status, data };
+}
+
+let subscribeKey = '';
+
+function renderSubscribe(detail) {
+  setStep(1);
+  subscribeKey = 'flow-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
+  panel.innerHTML = '<div class="section-title">开通企微账号:每个账号 ¥' + PRICE + '/月,从飞马余额扣费' + (detail ? '(' + detail + ')' : '') + '</div>' +
+    '<div class="section-title">选择账号数量(席位)</div><div class="cards" id="seat-cards"></div>' +
+    '<div class="section-title">购买时长</div><div class="cards" id="duration-cards" style="max-width:400px"></div>' +
+    '<div class="total" id="total"></div><div class="hint" id="detail"></div>' +
+    '<button class="main" id="buy">开通服务(立即扣费)</button>';
+  const renderCards = (el, items, key, current, onPick) => {
+    el.innerHTML = '';
+    items.forEach(item => {
+      const div = document.createElement('div');
+      div.className = 'card' + (item[key] === current ? ' active' : '');
+      div.innerHTML = (item.recommended ? '<span class="badge">推荐</span>' : '') +
+        '<div class="t">' + item.label + '</div>' +
+        '<div class="p">¥' + (key === 'seats' ? item.seats * PRICE + '/月' : item.months * PRICE + '/号') + '</div>' +
+        (item.note ? '<div class="n">' + item.note + '</div>' : '');
+      div.onclick = () => onPick(item[key]);
+      el.appendChild(div);
+    });
+  };
+  const paint = () => {
+    renderCards(document.getElementById('seat-cards'), SEATS, 'seats', seats, v => { seats = v; paint(); });
+    renderCards(document.getElementById('duration-cards'), DURATIONS, 'months', months, v => { months = v; paint(); });
+    document.getElementById('total').innerHTML = '¥' + (seats * months * PRICE).toLocaleString() + ' <small>/ 一次性扣费</small>';
+    document.getElementById('detail').textContent = seats + ' 个号 × ¥' + PRICE + '/月 × ' + months + ' 个月,从飞马余额扣除';
+  };
+  paint();
+  document.getElementById('buy').onclick = async () => {
+    const btn = document.getElementById('buy');
+    btn.disabled = true;
+    showMsg('ok', '正在开通扣费,请稍候…');
+    const r = await api('/flow/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ seats, months, idempotencyKey: subscribeKey }) });
+    if (!r.ok) {
+      showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + '] ' + (r.data.errorTitle || '开通失败') + '</b><br>' + (r.data.message || '') + '<br>' + (r.data.tip || ''));
+      btn.disabled = false;
+      return;
+    }
+    showMsg('ok', '开通成功!席位 ' + r.data.seats + ',到期 ' + r.data.expireAt + '。正在生成登录二维码…');
+    setTimeout(startLogin, 800);
+  };
+}
+
+async function startLogin() {
+  codeSubmitted = false;
+  verifyWaits = 0;
+  checkErrors = 0;
+  setStep(2);
+  panel.innerHTML = '<div class="center"><div class="section-title">正在生成企业微信登录二维码…</div></div>';
+  const r = await api('/flow/start-login', { method: 'POST' });
+  if (!r.ok) {
+    panel.innerHTML = '<div class="center"></div>';
+    showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + ']</b> ' + (r.data.message || '生成二维码失败') + '<br>' + (r.data.tip || '') + '<br><button class="main" onclick="startLogin()">重试</button>');
+    return;
+  }
+  panel.innerHTML = '<div class="center"><img id="qr" src="/flow/qrcode?t=' + Date.now() + '">' +
+    '<div class="hint">请用手机企业微信扫码并确认登录。二维码约 4 分钟内有效。<br>本页面每 3 秒自动检测扫码状态,无需手动刷新。</div>' +
+    '<div class="hint" id="poll-status">等待扫码…</div></div>';
+  poll();
+}
+
+function poll() {
+  clearInterval(pollTimer);
+  pollTimer = setInterval(async () => {
+    const r = await api('/flow/check');
+    const s = String(r.data.status);
+    const el = document.getElementById('poll-status');
+    if (!r.ok || r.data.status === undefined) {
+      // 二维码过期或上游瞬时异常:未提交验证码时自动重新生成二维码
+      checkErrors++;
+      if (!codeSubmitted && checkErrors >= 2) { clearInterval(pollTimer); startLogin(); return; }
+      if (el) el.textContent = '状态检测异常,重试中…';
+      return;
+    }
+    checkErrors = 0;
+    if (s === '2') { clearInterval(pollTimer); renderSuccess(r.data.detail || {}); return; }
+    if (s === '10') {
+      if (!codeSubmitted) { clearInterval(pollTimer); renderVerify(); return; }
+      // 验证码已提交:不回退到输入页,继续等待登录确认,并用设备在线状态兜底
+      verifyWaits++;
+      const st = await api('/flow/state');
+      if (st.data.online) { clearInterval(pollTimer); renderSuccess(st.data.detail || {}); return; }
+      if (el) el.textContent = '验证码已提交,正在确认登录…';
+      if (verifyWaits >= 10) {
+        clearInterval(pollTimer);
+        showMsg('err', '登录确认超时。可能验证码错误或已失效。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="startLogin()">重新生成二维码</button>');
+      }
+      return;
+    }
+    if (s === '4') { clearInterval(pollTimer); showMsg('err', '手机端取消了登录或二维码会话已失效。<br><button class="main" onclick="startLogin()">重新生成二维码</button>'); return; }
+    if (el) el.textContent = s === '1' ? '已扫码,请在手机上确认…' : '等待扫码…(状态 ' + s + ')';
+  }, 3000);
+}
+
+function renderVerify() {
+  setStep(3);
+  panel.innerHTML = '<div class="center"><div class="section-title">已扫码确认,请输入手机企业微信上显示的 6 位验证码</div>' +
+    '<input id="code" maxlength="6" placeholder="······" inputmode="numeric">' +
+    '<br><button class="main" id="verify">提交验证码</button></div>';
+  document.getElementById('verify').onclick = async () => {
+    const code = document.getElementById('code').value.trim();
+    if (!/^\\d{6}$/.test(code)) { showMsg('err', '请输入 6 位数字验证码。'); return; }
+    showMsg('ok', '正在校验…');
+    const r = await api('/flow/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
+    if (!r.ok) { showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + ']</b> ' + (r.data.message || '验证码校验失败') + ',请重试。'); return; }
+    codeSubmitted = true;
+    verifyWaits = 0;
+    panel.innerHTML = '<div class="center"><div class="section-title">验证码已提交,正在确认登录…</div><div class="hint" id="poll-status">确认中,每 3 秒自动检测…</div></div>';
+    poll();
+  };
+}
+
+function renderSuccess(detail) {
+  setStep(4);
+  panel.innerHTML = '<div class="center"><div class="success">✔ 企业微信登录成功</div>' +
+    '<div class="hint">账号:' + (detail.nickname || detail.userId || '未知') + (detail.corpName ? '(' + detail.corpName + ')' : '') + '<br>现在可以回到对话中使用企微接口能力(qiwe_api_call、消息、会议等)。</div></div>';
+}
+
+(async () => {
+  const r = await api('/flow/state');
+  if (r.data.online) { renderSuccess(r.data.detail || {}); return; }
+  if (r.data.subscribed) { startLogin(); return; }
+  renderSubscribe(r.data.subscribeDetail || '');
+})();
+</script>
+</body>
+</html>`;
+}
+
+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 || '') };
+}
+
+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') {
+        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+        res.end(buildFlowPageHtml({ monthlyPrice: QIWE_MONTHLY_PRICE }));
+        return;
+      }
+      if (url.pathname === '/flow/state') {
+        let subscribed = false;
+        let subscribeDetail = '';
+        let online = false;
+        let detail = null;
+        try {
+          const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase });
+          subscribed = Boolean(sub.data && sub.data.subscribed);
+          if (!subscribed) subscribeDetail = '尚未开通包月订阅';
+        } catch (error) {
+          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 });
+        return;
+      }
+      if (url.pathname === '/flow/subscribe' && req.method === 'POST') {
+        const body = await readBody(req);
+        const seats = Math.max(1, Number(body.seats) || 1);
+        const months = Math.max(1, Math.min(12, Number(body.months) || 1));
+        const idempotencyKey = typeof body.idempotencyKey === 'string' && body.idempotencyKey ? body.idempotencyKey : '';
+        let last = null;
+        for (let i = 0; i < months; i++) {
+          last = await callFmodeWecomGateway({
+            gatewayPath: '/subscribe',
+            body: idempotencyKey ? { seats, idempotencyKey: `${idempotencyKey}-m${i + 1}` } : { seats },
+            token: ctx.token,
+            apiBase: ctx.apiBase
+          });
+        }
+        const data = (last && last.data) || {};
+        json(res, 200, { seats: data.seats || seats, expireAt: data.expireAt || '', months });
+        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.QIWE_FLOW_PORT) || DEFAULT_FLOW_PORT;
+  if (currentServer && currentContext) {
+    Object.assign(currentContext, { token, apiBase, uid, onQrcode });
+    return Promise.resolve({ url: `http://127.0.0.1:${currentContext.port}/`, alreadyRunning: true });
+  }
+  const ctx = { token, apiBase, uid, port: listenPort, qrcodeBuffer: null, 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,
+  startLoginFlowServer,
+  stopLoginFlowServer
+};

+ 198 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/subscribe-page.js

@@ -0,0 +1,198 @@
+const fs = require('fs');
+const { latestPath } = require('./output-paths');
+
+const QIWE_MONTHLY_PRICE = 200;
+
+const SEAT_PLANS = [
+  { seats: 1, label: '1 个号', note: '个人试用' },
+  { seats: 3, label: '3 个号', note: '小团队' },
+  { seats: 10, label: '10 个号', note: '成长团队', recommended: true },
+  { seats: 20, label: '20 个号', note: '中型团队' },
+  { seats: 50, label: '50 个号', note: '大型团队' }
+];
+
+const DURATION_PLANS = [
+  { months: 1, label: '1 个月' },
+  { months: 6, label: '6 个月' },
+  { months: 12, label: '12 个月' }
+];
+
+const ERROR_CODES = {
+  'QW-AUTH-401': { http: 401, title: '认证失败', tip: '鉴权 token 无效或已过期,请检查 QIWE_AUTH_TOKEN 后重试。' },
+  'QW-PAY-402': { http: 402, title: '飞马余额不足', tip: '账户余额不足以完成本次订阅扣费,请先充值飞马余额。' },
+  'QW-SUB-402': { http: 402, title: '订阅未开通或已到期', tip: '请在套餐页面选择席位并点击开通,扣费成功后即可继续登录。' },
+  'QW-SEAT-403': { http: 403, title: '席位已满', tip: '当前订阅席位已全部占用,请增购席位或停用闲置设备。' },
+  'QW-UP-502': { http: 502, title: '企业微信服务暂时不可用', tip: '网关或上游服务异常,请稍后重试。' }
+};
+
+function classifySubscribeError(httpStatus, message) {
+  const text = String(message || '');
+  if (httpStatus === 401 || /认证失败|unauthorized/i.test(text)) return 'QW-AUTH-401';
+  if (/余额不足/.test(text)) return 'QW-PAY-402';
+  if (httpStatus === 402) return 'QW-SUB-402';
+  if (httpStatus === 403 || /席位/.test(text)) return 'QW-SEAT-403';
+  return 'QW-UP-502';
+}
+
+function buildSubscribePageHtml({ apiBase, token, monthlyPrice = QIWE_MONTHLY_PRICE }) {
+  const plansJson = JSON.stringify(SEAT_PLANS);
+  const durationsJson = JSON.stringify(DURATION_PLANS);
+  const errorsJson = JSON.stringify(ERROR_CODES);
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<title>企微助手 · 套餐与服务</title>
+<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:1080px; margin:0 auto; padding:32px 24px; }
+  h1 { font-size:22px; margin:0 0 4px; }
+  .sub { color:#999; font-size:13px; margin-bottom:24px; }
+  .layout { display:flex; gap:24px; align-items:flex-start; }
+  .main { flex:1; background:#fff; border:1px solid var(--border); border-radius:12px; padding:24px; }
+  .section-title { font-size:14px; color:#666; margin:18px 0 10px; }
+  .cards { display:flex; flex-wrap:wrap; gap:12px; }
+  .card { position:relative; flex:1; min-width:120px; border:1px solid var(--border); border-radius:10px; padding:14px 16px; cursor:pointer; text-align:center; background:#fff; transition:all .15s; }
+  .card:hover { border-color:var(--brand); }
+  .card.active { border-color:var(--brand); background:var(--brand-bg); box-shadow:0 2px 8px rgba(250,140,22,.12); }
+  .card .t { font-size:15px; font-weight:600; }
+  .card .p { color:var(--brand); font-size:14px; margin-top:6px; }
+  .card .n { color:#999; font-size:12px; margin-top:4px; }
+  .badge { position:absolute; top:-9px; right:10px; background:var(--brand); color:#fff; font-size:11px; border-radius:8px; padding:1px 8px; }
+  .side { width:300px; background:#fff; border:1px solid var(--border); border-radius:12px; padding:22px; }
+  .side h2 { font-size:14px; color:#666; margin:0 0 12px; font-weight:normal; }
+  .total { font-size:30px; color:#e64545; font-weight:700; }
+  .total small { font-size:13px; color:#999; font-weight:normal; }
+  .detail { color:#999; font-size:12px; margin:8px 0 18px; line-height:1.8; }
+  button.buy { width:100%; border:none; background:var(--brand); color:#fff; font-size:16px; padding:12px 0; border-radius:8px; cursor:pointer; }
+  button.buy: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; }
+  .tips { background:var(--brand-bg); border:1px solid #ffe7ba; border-radius:10px; padding:12px 16px; font-size:13px; color:#ad6800; margin-bottom:18px; }
+</style>
+</head>
+<body>
+<div class="wrap">
+  <h1>套餐与服务</h1>
+  <div class="sub">开通企微账号:每个账号 ¥${monthlyPrice}/月,从飞马余额扣费,开通后立即生效</div>
+  <div class="layout">
+    <div class="main">
+      <div class="tips">首次使用需先开通包月订阅,开通成功后再进行扫码登录(第 2 步)。订阅按「席位数 × ¥${monthlyPrice} × 月数」一次性扣除飞马余额。</div>
+      <div class="section-title">选择账号数量(席位)</div>
+      <div class="cards" id="seat-cards"></div>
+      <div class="section-title">购买时长</div>
+      <div class="cards" id="duration-cards" style="max-width:420px"></div>
+    </div>
+    <div class="side">
+      <h2>订单预览</h2>
+      <div class="total" id="total">¥0 <small>/ 一次性扣费</small></div>
+      <div class="detail" id="detail"></div>
+      <button class="buy" id="buy">开通服务(立即扣费)</button>
+      <div class="msg" id="msg"></div>
+    </div>
+  </div>
+</div>
+<script>
+const API_BASE = ${JSON.stringify(String(apiBase || '').replace(/\/$/, ''))};
+const TOKEN = ${JSON.stringify(String(token || ''))};
+const PRICE = ${monthlyPrice};
+const SEATS = ${plansJson};
+const DURATIONS = ${durationsJson};
+const ERRORS = ${errorsJson};
+let seats = 1, months = 1;
+
+function renderCards(el, items, key, current, onPick) {
+  el.innerHTML = '';
+  items.forEach(item => {
+    const div = document.createElement('div');
+    div.className = 'card' + (item[key] === current ? ' active' : '');
+    div.innerHTML = (item.recommended ? '<span class="badge">推荐</span>' : '') +
+      '<div class="t">' + item.label + '</div>' +
+      '<div class="p">¥' + (key === 'seats' ? item.seats * PRICE + '/月' : (item.months * PRICE) + '/号') + '</div>' +
+      (item.note ? '<div class="n">' + item.note + '</div>' : '');
+    div.onclick = () => onPick(item[key]);
+    el.appendChild(div);
+  });
+}
+
+function render() {
+  renderCards(document.getElementById('seat-cards'), SEATS, 'seats', seats, v => { seats = v; render(); });
+  renderCards(document.getElementById('duration-cards'), DURATIONS, 'months', months, v => { months = v; render(); });
+  const total = seats * months * PRICE;
+  document.getElementById('total').innerHTML = '¥' + total.toLocaleString() + ' <small>/ 一次性扣费</small>';
+  document.getElementById('detail').textContent = seats + ' 个号 × ¥' + PRICE + '/月 × ' + months + ' 个月,从飞马余额扣除';
+}
+
+function showMsg(type, html) {
+  const el = document.getElementById('msg');
+  el.className = 'msg ' + type;
+  el.innerHTML = html;
+}
+
+document.getElementById('buy').onclick = async () => {
+  const btn = document.getElementById('buy');
+  btn.disabled = true;
+  showMsg('ok', '正在开通,请稍候…');
+  try {
+    let lastData = null;
+    for (let i = 0; i < months; i++) {
+      const res = await fetch(API_BASE + '/subscribe', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
+        body: JSON.stringify({ seats })
+      });
+      lastData = await res.json().catch(() => ({}));
+      if (!res.ok || (lastData.code !== undefined && ![0, 200].includes(Number(lastData.code)))) {
+        const message = lastData.mess || lastData.msg || lastData.message || ('HTTP ' + res.status);
+        const codeKey = classify(res.status, message);
+        const info = ERRORS[codeKey];
+        showMsg('err', '<b>[' + codeKey + '] ' + info.title + '</b><br>' + message + '<br>' + info.tip);
+        btn.disabled = false;
+        return;
+      }
+    }
+    const d = (lastData && lastData.data) || {};
+    showMsg('ok', '<b>开通成功!</b><br>席位:' + (d.seats || seats) + ',到期时间:' + (d.expireAt || '已延长 ' + months + ' 个月') + '。<br>请回到对话中重新执行 qiwe_login_start 继续扫码登录。');
+  } catch (e) {
+    showMsg('err', '<b>[QW-UP-502] 网络请求失败</b><br>' + e.message + '<br>请确认网关服务可用后重试。');
+    btn.disabled = false;
+  }
+};
+
+function classify(status, message) {
+  const text = String(message || '');
+  if (status === 401 || /认证失败/.test(text)) return 'QW-AUTH-401';
+  if (/余额不足/.test(text)) return 'QW-PAY-402';
+  if (status === 402) return 'QW-SUB-402';
+  if (status === 403 || /席位/.test(text)) return 'QW-SEAT-403';
+  return 'QW-UP-502';
+}
+
+render();
+</script>
+</body>
+</html>`;
+}
+
+function saveSubscribePage({ apiBase, token, monthlyPrice } = {}) {
+  try {
+    const htmlPath = latestPath('subscription', 'qiwe-subscribe.html');
+    fs.writeFileSync(htmlPath, buildSubscribePageHtml({ apiBase, token, monthlyPrice }), 'utf8');
+    return htmlPath;
+  } catch {
+    return '';
+  }
+}
+
+module.exports = {
+  QIWE_MONTHLY_PRICE,
+  SEAT_PLANS,
+  DURATION_PLANS,
+  ERROR_CODES,
+  classifySubscribeError,
+  buildSubscribePageHtml,
+  saveSubscribePage
+};

+ 5 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/server.js

@@ -127,15 +127,17 @@ function createServer() {
     {
     {
       title: '开始企业微信扫码登录',
       title: '开始企业微信扫码登录',
       description: [
       description: [
-        '通过 Fmode 网关转发的企业微信接口创建或复用 uid 对应的设备并生成企业微信登录二维码。',
-        '二维码保存到 outputs/login/qiwe-login-qrcode.png,并默认打开预览页;传 openBrowser=false 可关闭。',
+        '登录两步流程入口:默认在本地端口(默认 4310)启动流程页服务并打开浏览器,页面自动推进:未开通订阅时先展示套餐页(每号 ¥200/月,飞马余额扣费),开通后自动生成二维码并每 3 秒轮询扫码状态,需要验证码时页面提示输入。',
+        '传 flowUi=false 回退到静态模式:校验订阅(未开通返回 needs_subscription 并生成套餐页)后生成二维码保存到 outputs/login/qiwe-login-qrcode.png(openBrowser=false 可关闭自动打开)。',
         '需要有效企微包月订阅和可用席位;无需提供企业微信接口访问凭据、代理或设备参数。'
         '需要有效企微包月订阅和可用席位;无需提供企业微信接口访问凭据、代理或设备参数。'
       ].join(' '),
       ].join(' '),
       inputSchema: {
       inputSchema: {
         uid: z.string().optional(),
         uid: z.string().optional(),
         authToken: z.string().optional(),
         authToken: z.string().optional(),
         apiBase: z.string().optional(),
         apiBase: z.string().optional(),
-        openBrowser: z.boolean().optional()
+        openBrowser: z.boolean().optional(),
+        flowUi: z.boolean().optional(),
+        flowPort: z.number().int().optional()
       }
       }
     },
     },
     wrap(qiweLoginStart)
     wrap(qiweLoginStart)

+ 77 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwe-login-run.js

@@ -10,6 +10,8 @@ const {
 const { callFmodeWecomGateway, redactSecret } = require('../providers/fmode-wecom-gateway');
 const { callFmodeWecomGateway, redactSecret } = require('../providers/fmode-wecom-gateway');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { latestPath } = require('../core/output-paths');
 const { latestPath } = require('../core/output-paths');
+const { saveSubscribePage, QIWE_MONTHLY_PRICE } = require('../core/subscribe-page');
+const { startLoginFlowServer } = require('../core/login-flow-server');
 
 
 const QR_STATUS = {
 const QR_STATUS = {
   '-1': '未登录,需要扫码登录',
   '-1': '未登录,需要扫码登录',
@@ -174,12 +176,84 @@ async function qiweLoginStatus(input = {}) {
   }
   }
 }
 }
 
 
+function subscriptionRequiredResult({ apiBase, token, uid, detail }) {
+  const pagePath = saveSubscribePage({ apiBase, token });
+  const opened = Boolean(pagePath && openInBrowser(pagePath));
+  return {
+    status: 'needs_subscription',
+    assistantMessage: [
+      `登录前需要先开通企微包月订阅:每个账号 ¥${QIWE_MONTHLY_PRICE}/月,从飞马余额扣费。`,
+      opened
+        ? `已自动打开套餐选择页:${pagePath},请在页面上选择席位与时长并点击「开通服务」。`
+        : pagePath
+          ? `请打开套餐选择页完成开通:${pagePath}`
+          : '套餐页生成失败,可直接调用 qiwe_subscribe 开通。',
+      '开通成功后重新调用 qiwe_login_start 进入扫码登录(第 2 步)。',
+      detail ? `订阅状态:${detail}` : ''
+    ].filter(Boolean).join('\n'),
+    summary: { stage: 'loginStart', errorKind: 'subscription_required', uid, subscribePage: pagePath || null, browserOpened: opened, monthlyPrice: QIWE_MONTHLY_PRICE },
+    data: { uid, subscribePage: pagePath || null },
+    files: pagePath ? [pagePath] : [],
+    nextActions: ['在套餐页开通订阅(或调用 qiwe_subscribe)', '开通后重新调用 qiwe_login_start'],
+    warnings: [],
+    errors: []
+  };
+}
+
 async function qiweLoginStart(input = {}) {
 async function qiweLoginStart(input = {}) {
   const token = readQiweAuthToken(input);
   const token = readQiweAuthToken(input);
   if (!token) return authRequiredResult();
   if (!token) return authRequiredResult();
   const uid = ensureQiweUid(input);
   const uid = ensureQiweUid(input);
   const apiBase = readQiweApiBase(input);
   const apiBase = readQiweApiBase(input);
   if (input.persistConfig !== false) saveQiweClientConfig({ uid, apiBase });
   if (input.persistConfig !== false) saveQiweClientConfig({ uid, apiBase });
+  if (input.flowUi !== false) {
+    try {
+      const { url } = await startLoginFlowServer({
+        token,
+        apiBase,
+        uid,
+        port: input.flowPort,
+        onQrcode: (buffer) => {
+          try {
+            fs.writeFileSync(latestPath('login', 'qiwe-login-qrcode.png'), buffer);
+          } catch {}
+        }
+      });
+      const opened = input.openBrowser === false ? false : openInBrowser(url);
+      return okResult({
+        assistantMessage: [
+          `已启动企微登录流程页服务:${url}${opened ? '(已自动打开浏览器)' : ''}。`,
+          `页面会自动完成两步流程:未开通订阅时先展示套餐页(每号 ¥${QIWE_MONTHLY_PRICE}/月,飞马余额扣费),开通后自动生成二维码并每 3 秒自动检测扫码状态,需要验证码时页面会提示输入。`,
+          '也可以用 qiwe_login_check 在对话中轮询登录状态。'
+        ].join('\n'),
+        summary: { uid, flowUrl: url, browserOpened: Boolean(opened), monthlyPrice: QIWE_MONTHLY_PRICE },
+        data: { uid, flowUrl: url, browserOpened: Boolean(opened) },
+        nextActions: ['引导用户在流程页完成开通与扫码', '用 qiwe_login_check 轮询登录状态']
+      });
+    } catch (error) {
+      // 流程页服务启动失败时回退到静态二维码流程
+    }
+  }
+  if (input.skipSubscriptionCheck !== true) {
+    try {
+      const statusResult = await callFmodeWecomGateway({
+        gatewayPath: '/subscribe/status',
+        httpMethod: 'GET',
+        token,
+        apiBase
+      });
+      const sub = statusResult.data || {};
+      if (!sub.subscribed) {
+        return subscriptionRequiredResult({ apiBase, token, uid, detail: '尚未开通包月订阅' });
+      }
+    } catch (error) {
+      if (error && (error.kind === 'billing' || error.httpStatus === 402)) {
+        return subscriptionRequiredResult({ apiBase, token, uid, detail: redactSecret(error.message) });
+      }
+      if (error && error.kind === 'auth') return gatewayErrorResult(error, 'loginStart', '生成登录二维码失败');
+      // 订阅状态接口不可用时不阻断登录,由 /login/start 自身订阅校验兑底
+    }
+  }
   try {
   try {
     const result = await callFmodeWecomGateway({
     const result = await callFmodeWecomGateway({
       gatewayPath: '/login/start',
       gatewayPath: '/login/start',
@@ -219,6 +293,9 @@ async function qiweLoginStart(input = {}) {
       nextActions: ['确认用户看到二维码并扫码', '调用 qiwe_login_check 轮询登录状态']
       nextActions: ['确认用户看到二维码并扫码', '调用 qiwe_login_check 轮询登录状态']
     });
     });
   } catch (error) {
   } catch (error) {
+    if (error && (error.kind === 'billing' || error.httpStatus === 402)) {
+      return subscriptionRequiredResult({ apiBase, token, uid, detail: redactSecret(error.message) });
+    }
     return gatewayErrorResult(error, 'loginStart', '生成登录二维码失败');
     return gatewayErrorResult(error, 'loginStart', '生成登录二维码失败');
   }
   }
 }
 }

+ 1 - 1
claude-code/claude-code-qiwe-assistant/package.json

@@ -4,7 +4,7 @@
   "description": "企业微信技能包:保留 Fmode 网关转发的企业微信接口,并增加按需下载的官方 CLI 会议与文档能力",
   "description": "企业微信技能包:保留 Fmode 网关转发的企业微信接口,并增加按需下载的官方 CLI 会议与文档能力",
   "main": "mcp/src/server.js",
   "main": "mcp/src/server.js",
   "scripts": {
   "scripts": {
-    "check": "node --check mcp/src/server.js && node --check mcp/src/core/api-catalog.js && node --check mcp/src/core/credentials.js && node --check mcp/src/core/result-envelope.js && node --check mcp/src/core/output-paths.js && node --check mcp/src/core/wecom-cli-runtime.js && node --check mcp/src/providers/fmode-wecom-gateway.js && node --check mcp/src/providers/wecom-official-cli.js && node --check mcp/src/tools/qiwe-api-catalog-run.js && node --check mcp/src/tools/qiwe-login-run.js && node --check mcp/src/tools/qiwe-subscription-run.js && node --check mcp/src/tools/wecom-official-cli-run.js && node --check bin/qiwe-official-cli.js && node --check scripts/install-wecom-cli.js && node --check scripts/smoke-test.js && node --check scripts/wecom-cli-smoke-test.js && node --check scripts/validate-output-standard.js",
+    "check": "node --check mcp/src/server.js && node --check mcp/src/core/api-catalog.js && node --check mcp/src/core/credentials.js && node --check mcp/src/core/result-envelope.js && node --check mcp/src/core/output-paths.js && node --check mcp/src/core/subscribe-page.js && node --check mcp/src/core/login-flow-server.js && node --check mcp/src/core/wecom-cli-runtime.js && node --check mcp/src/providers/fmode-wecom-gateway.js && node --check mcp/src/providers/wecom-official-cli.js && node --check mcp/src/tools/qiwe-api-catalog-run.js && node --check mcp/src/tools/qiwe-login-run.js && node --check mcp/src/tools/qiwe-subscription-run.js && node --check mcp/src/tools/wecom-official-cli-run.js && node --check bin/qiwe-official-cli.js && node --check scripts/install-wecom-cli.js && node --check scripts/smoke-test.js && node --check scripts/wecom-cli-smoke-test.js && node --check scripts/validate-output-standard.js",
     "start": "node mcp/src/server.js",
     "start": "node mcp/src/server.js",
     "smoke": "node scripts/smoke-test.js && node scripts/wecom-cli-smoke-test.js",
     "smoke": "node scripts/smoke-test.js && node scripts/wecom-cli-smoke-test.js",
     "outputs:validate": "node scripts/validate-output-standard.js",
     "outputs:validate": "node scripts/validate-output-standard.js",