From 4e1893e180e20be81278646ea386dedb35376382 Mon Sep 17 00:00:00 2001 From: laoxu Date: Mon, 27 Apr 2026 16:36:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Cloudflare=20Pages=20=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20+=20=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 wrangler.toml,绑定 KV namespace(id: 22d46ff673c946f1a5f3b790818b5618) - nuxt.config.ts 加入 preset/binding 配置,确保 cloudflare_pages 预设和 KV 正确注入 - package.json 补充 deploy/preview 脚本(输出目录从 .output 修正为 dist) - 抽取 server/utils/set-cookie.ts:extractSetCookieValues 在 CF Workers 运行时兼容 getSetCookie() 不可靠的问题,加入正则回退路径 - server/services/api/mp-gateway.ts 统一使用 extractSetCookieValues,加入 [login] 诊断日志(native/extracted 计数) - server/services/api/auth-session.ts getCookieValueFromResponse 改用 extractSetCookieValues,消除另一处裸 getSetCookie() 调用 - bizlogin.post.ts 错误透传:不再吞掉上游 base_resp.err_msg,方便定位 KV 写入失败等具体原因 - 重构 server/services/ 目录(auth-session, mp-gateway, mp-service, session-core, mp-core) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 + AGENTS.md | 26 ++ components/api/Summary.vue | 24 +- nuxt.config.ts | 2 + package.json | 4 +- server/api/_debug.get.ts | 22 +- server/api/public/beta/authorinfo.get.ts | 22 +- server/api/public/v1/account.get.ts | 28 +- server/api/public/v1/accountbyurl.get.ts | 11 +- server/api/public/v1/article.get.ts | 45 +-- server/api/public/v1/authkey.get.ts | 23 +- server/api/web/login/bizlogin.post.ts | 40 ++- server/api/web/login/getqrcode.get.ts | 4 +- server/api/web/login/scan.get.ts | 4 +- server/api/web/login/session/[sid].post.ts | 2 +- server/api/web/misc/accountname.get.ts | 45 +-- server/api/web/misc/appmsgalbum.get.ts | 26 +- server/api/web/misc/comment.get.ts | 25 +- server/api/web/mp/appmsgpublish.get.ts | 32 +- server/api/web/mp/info.get.ts | 36 +-- server/api/web/mp/logout.get.ts | 13 +- server/api/web/mp/profile_ext_getmsg.get.ts | 22 +- server/api/web/mp/searchbiz.get.ts | 28 +- server/api/web/mp/searchbyurl.get.ts | 37 +-- server/kv/api-token.ts | 50 ++++ server/kv/cookie.ts | 12 +- server/services/api/auth-session.ts | 208 +++++++++++++ server/services/api/mp-core.ts | 83 ++++++ server/services/api/mp-gateway.ts | 139 +++++++++ server/services/api/mp-service.ts | 275 +++++++++++++++++ server/services/api/session-core.ts | 176 +++++++++++ server/types.d.ts | 6 - server/utils/CookieStore.ts | 308 -------------------- server/utils/proxy-request.ts | 161 ---------- server/utils/set-cookie.ts | 18 ++ test/api-core/auth-token-lifecycle.test.ts | 75 +++++ test/api-core/mp-core.test.ts | 69 +++++ test/api-core/session-core.test.ts | 37 +++ wrangler.toml | 7 + 39 files changed, 1328 insertions(+), 819 deletions(-) create mode 100644 AGENTS.md create mode 100644 server/kv/api-token.ts create mode 100644 server/services/api/auth-session.ts create mode 100644 server/services/api/mp-core.ts create mode 100644 server/services/api/mp-gateway.ts create mode 100644 server/services/api/mp-service.ts create mode 100644 server/services/api/session-core.ts delete mode 100644 server/utils/CookieStore.ts delete mode 100644 server/utils/proxy-request.ts create mode 100644 server/utils/set-cookie.ts create mode 100644 test/api-core/auth-token-lifecycle.test.ts create mode 100644 test/api-core/mp-core.test.ts create mode 100644 test/api-core/session-core.test.ts create mode 100644 wrangler.toml diff --git a/.gitignore b/.gitignore index 95b948cc..0ea87a65 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ samples/**/output # wrangler .wrangler +.omx/ +.omc/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..6ee8dd59 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This repository is a Nuxt 3 application with client pages in `pages/`, reusable UI in `components/`, and stateful client logic in `composables/` and `store/v2/`. Shared parsing and rendering code lives in `shared/utils/` and `utils/`. Server endpoints and helpers are under `server/api/`, `server/utils/`, and `server/kv/`. Static assets belong in `public/` and `assets/`. Sample WeChat HTML fixtures used for regression checks are stored in `samples/`, and ad hoc validation scripts live in `test/`. + +## Build, Test, and Development Commands +Use Node `>=22` and Yarn `1.22.22`. + +- `corepack enable && corepack prepare yarn@1.22.22 --activate && yarn`: install dependencies. +- `yarn dev`: start the local Nuxt development server. +- `yarn build`: produce a production build in `.output/`. +- `yarn preview`: build for the Cloudflare Pages target and run a local preview. +- `yarn format`: run Biome formatting and import organization. +- `yarn docker:build`: build the published container image. + +## Coding Style & Naming Conventions +Biome is the formatting source of truth; use `yarn format` before opening a PR. The codebase uses 2-space indentation, semicolons, single quotes in JS/TS, and a 120-column target. Keep Vue components in PascalCase such as `components/dashboard/SideBar.vue`, composables in `useX.ts` form, and route files lowercase such as `pages/dashboard/article.vue`. Place shared type declarations in `types/*.d.ts` or `server/types.d.ts`. Avoid editing generated vendor assets in `public/vendors/` unless you are intentionally updating a bundled dependency. + +## Testing Guidelines +There is currently no unified `yarn test` script. For parser, renderer, and export changes, add or update a focused script in `test/` and validate against the HTML fixtures in `samples/`. Follow the existing script naming style, for example `test/normalize_html.ts` and `test/render_html_from_cgi_data.ts`. In every PR, state the exact validation command or manual flow you ran. + +## Commit & Pull Request Guidelines +Recent history uses short version bumps plus concise `feat:` and `fix:` subjects. Prefer descriptive commit messages such as `fix: handle empty CGI payload in exporter` and keep each commit narrowly scoped. PRs should explain the user-visible change, link the relevant issue when available, note any config or deployment impact, and include screenshots for UI changes. Call out test coverage and known gaps explicitly. + +## Security & Configuration Tips +Do not commit live WeChat credentials, exported article data, or local OMX state. Runtime configuration is environment-driven; common variables include `NUXT_AGGRID_LICENSE`, `NUXT_SENTRY_*`, `NUXT_UMAMI_*`, and Nitro KV settings. diff --git a/components/api/Summary.vue b/components/api/Summary.vue index dfdf5e9a..c2da6442 100644 --- a/components/api/Summary.vue +++ b/components/api/Summary.vue @@ -8,16 +8,16 @@ import type { GetAuthKeyResult } from '~/types/types'; const toast = toastFactory(); const loading = ref(false); -const authKey = ref(''); -async function getAuthKey() { +const apiKey = ref(''); +async function getApiKey() { loading.value = true; try { await sleep(1000); const resp = await request(`/api/public/v1/authkey`); if (resp.code === 0) { - authKey.value = resp.data; + apiKey.value = resp.data; } else { - toast.error('获取密钥失败', resp.msg); + toast.error('获取 API 密钥失败', resp.msg); } } finally { loading.value = false; @@ -53,14 +53,14 @@ async function getAuthKey() {
  • -

    以下所有 API 如无特殊说明,均需要携带密钥进行调用。密钥可通过以下两种方式传输:

    -

    a. 通过自定义请求头 X-Auth-Key

    -

    b. 通过 name 为 auth-key 的 Cookie

    +

    以下所有 API 如无特殊说明,均需要携带密钥进行调用。推荐通过以下方式传输:

    +

    a. 通过自定义请求头 X-Auth-Key 传递 API 密钥

    +

    b. 已在本网站登录的浏览器环境可继续使用 name 为 auth-key 的 Cookie

  • 调用 API 的密钥与本网站的登录已集成在一起,也就是说,你在该网站扫码登录之后会自动刷新 API 密钥。调用 API 的密钥由当前登录态签发,你在该网站扫码登录并保持会话有效时,可以随时重新生成或查询当前 API 密钥。

  • @@ -70,12 +70,12 @@ async function getAuthKey() {

    - + 查询 API 密钥 (确保当前登录信息有效) -
    -

    当前密钥:

    - +
    +

    当前 API 密钥:

    +
    diff --git a/nuxt.config.ts b/nuxt.config.ts index 912a0fa3..2e3d3c26 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -35,6 +35,7 @@ export default defineNuxtConfig({ client: 'hidden', }, nitro: { + preset: process.env.NITRO_PRESET, minify: process.env.NODE_ENV === 'production', rollupConfig: { external: ['puppeteer'], @@ -43,6 +44,7 @@ export default defineNuxtConfig({ kv: { driver: process.env.NITRO_KV_DRIVER || 'memory', base: process.env.NITRO_KV_BASE, + binding: 'KV', }, }, }, diff --git a/package.json b/package.json index ab303042..755d3d09 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "debug": "nuxt dev --inspect", "dev": "nuxt dev", "build": "nuxt build", - "preview": "NITRO_PRESET=cloudflare_pages nuxt build && npx wrangler --cwd dist pages dev", + "preview": "NITRO_PRESET=cloudflare_pages NITRO_KV_DRIVER=cloudflare-kv-binding nuxt build && npx wrangler pages dev dist", + "deploy": "NITRO_PRESET=cloudflare_pages NITRO_KV_DRIVER=cloudflare-kv-binding nuxt build && wrangler pages deploy dist --project-name wechat-article-exporter --commit-dirty=true", + "test:api-core": "vite-node --script test/api-core/session-core.test.ts && vite-node --script test/api-core/mp-core.test.ts && vite-node --script test/api-core/auth-token-lifecycle.test.ts", "format": "biome check --write", "postinstall": "nuxt prepare", "docker:build": "docker build --build-arg VERSION=$npm_package_version -t ghcr.io/wechat-article/wechat-article-exporter:$npm_package_version .", diff --git a/server/api/_debug.get.ts b/server/api/_debug.get.ts index 058b7829..47f57823 100644 --- a/server/api/_debug.get.ts +++ b/server/api/_debug.get.ts @@ -1,14 +1,16 @@ -import { cookieStore } from '~/server/utils/CookieStore'; - -interface DebugQuery { - key: string; -} +import { getSessionCacheSnapshot } from '~/server/services/api/auth-session'; export default defineEventHandler(async event => { - const { key } = getQuery(event); - if (key && key === process.env.DEBUG_KEY) { - return cookieStore.toJSON(); - } else { - return 'not set debug key'; + if (process.env.NODE_ENV !== 'development') { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }); + } + + const debugKey = getRequestHeader(event, 'x-debug-key'); + if (!debugKey || debugKey !== process.env.DEBUG_KEY) { + throw createError({ statusCode: 403, statusMessage: 'Forbidden' }); } + + return { + activeSessions: Object.keys(getSessionCacheSnapshot()).length, + }; }); diff --git a/server/api/public/beta/authorinfo.get.ts b/server/api/public/beta/authorinfo.get.ts index bf8d268a..d04759ed 100644 --- a/server/api/public/beta/authorinfo.get.ts +++ b/server/api/public/beta/authorinfo.get.ts @@ -1,8 +1,4 @@ -/** - * 搜索公众号主体信息 - */ - -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { fetchAuthorInfoResponse } from '~/server/services/api/mp-service'; interface AuthorInfoQuery { fakeid: string; @@ -11,20 +7,8 @@ interface AuthorInfoQuery { export default defineEventHandler(async event => { const { fakeid } = getQuery(event); - const params: Record = { - wxtoken: '777', - biz: fakeid, - __biz: fakeid, - x5: 0, - f: 'json', - }; - - return proxyMpRequest({ - event: event, - method: 'GET', - endpoint: 'https://mp.weixin.qq.com/mp/authorinfo', - query: params, - parseJson: true, + return fetchAuthorInfoResponse(event, { + fakeid, }).catch(e => { return { base_resp: { diff --git a/server/api/public/v1/account.get.ts b/server/api/public/v1/account.get.ts index 6f405c38..168bc8c6 100644 --- a/server/api/public/v1/account.get.ts +++ b/server/api/public/v1/account.get.ts @@ -1,5 +1,5 @@ -import { getTokenFromStore } from '~/server/utils/CookieStore'; -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { getTokenFromEvent } from '~/server/services/api/auth-session'; +import { fetchSearchBizResponse } from '~/server/services/api/mp-service'; interface SearchBizQuery { begin?: number; @@ -8,7 +8,7 @@ interface SearchBizQuery { } export default defineEventHandler(async event => { - const token = await getTokenFromStore(event); + const token = await getTokenFromEvent(event); if (!token) { return { @@ -33,23 +33,11 @@ export default defineEventHandler(async event => { const begin: number = query.begin || 0; const size: number = query.size || 5; - const params: Record = { - action: 'search_biz', - begin: begin, - count: size, - query: keyword, - token: token, - lang: 'zh_CN', - f: 'json', - ajax: '1', - }; - - return proxyMpRequest({ - event: event, - method: 'GET', - endpoint: 'https://mp.weixin.qq.com/cgi-bin/searchbiz', - query: params, - parseJson: true, + return fetchSearchBizResponse(event, { + token, + keyword, + begin, + size, }).catch(e => { return { base_resp: { diff --git a/server/api/public/v1/accountbyurl.get.ts b/server/api/public/v1/accountbyurl.get.ts index ea8fdc0d..1f10eb59 100644 --- a/server/api/public/v1/accountbyurl.get.ts +++ b/server/api/public/v1/accountbyurl.get.ts @@ -1,4 +1,4 @@ -import { request } from '#shared/utils/request'; +import { searchAccountByArticleUrl } from '~/server/services/api/mp-service'; interface UrlQuery { url: string; @@ -7,10 +7,9 @@ interface UrlQuery { export default defineEventHandler(async event => { const { url } = getQuery(event); - return await request('/api/web/mp/searchbyurl?url=' + encodeURIComponent(url), { - headers: { - 'X-Auth-Key': getHeader(event, 'X-Auth-Key')!, - Cookie: getHeader(event, 'Cookie')!, - }, + return searchAccountByArticleUrl(event, { + url, + authErrorMessage: '认证信息无效', + searchErrorMessage: '搜索公众号接口失败,请稍后重试', }); }); diff --git a/server/api/public/v1/article.get.ts b/server/api/public/v1/article.get.ts index 36673048..a422401d 100644 --- a/server/api/public/v1/article.get.ts +++ b/server/api/public/v1/article.get.ts @@ -1,5 +1,6 @@ -import { getTokenFromStore } from '~/server/utils/CookieStore'; -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { getTokenFromEvent } from '~/server/services/api/auth-session'; +import { extractPublishedArticles } from '~/server/services/api/mp-core'; +import { fetchAppMsgPublishResponse } from '~/server/services/api/mp-service'; interface AppMsgPublishQuery { fakeid: string; @@ -9,7 +10,7 @@ interface AppMsgPublishQuery { } export default defineEventHandler(async event => { - const token = await getTokenFromStore(event); + const token = await getTokenFromEvent(event); if (!token) { return { @@ -34,30 +35,12 @@ export default defineEventHandler(async event => { const begin: number = query.begin || 0; const size: number = query.size || 5; - const isSearching = !!keyword; - - const params: Record = { - sub: isSearching ? 'search' : 'list', - search_field: isSearching ? '7' : 'null', - begin: begin, - count: size, - query: keyword, - fakeid: fakeid, - type: '101_1', - free_publish_type: 1, - sub_action: 'list_ex', - token: token, - lang: 'zh_CN', - f: 'json', - ajax: 1, - }; - - const resp = await proxyMpRequest({ - event: event, - method: 'GET', - endpoint: 'https://mp.weixin.qq.com/cgi-bin/appmsgpublish', - query: params, - parseJson: true, + const resp = await fetchAppMsgPublishResponse(event, { + token, + fakeid, + keyword, + begin, + size, }).catch(e => { return { base_resp: { @@ -68,13 +51,7 @@ export default defineEventHandler(async event => { }); if (resp.base_resp.ret === 0) { - const publish_page = JSON.parse(resp.publish_page); - const articles = publish_page.publish_list - .filter((item: any) => !!item.publish_info) - .flatMap((item: any) => { - const publish_info = JSON.parse(item.publish_info); - return publish_info.appmsgex; - }); + const articles = extractPublishedArticles(resp); return { base_resp: resp.base_resp, articles: articles, diff --git a/server/api/public/v1/authkey.get.ts b/server/api/public/v1/authkey.get.ts index 8a6fa8fd..89182473 100644 --- a/server/api/public/v1/authkey.get.ts +++ b/server/api/public/v1/authkey.get.ts @@ -1,16 +1,25 @@ -import { getMpCookie } from '~/server/kv/cookie'; -import { getAuthKeyFromRequest } from '~/server/utils/proxy-request'; +import { + getSessionByAuthKey, + issueApiTokenForAuthKey, + resolveAuthKeyFromEvent, +} from '~/server/services/api/auth-session'; export default defineEventHandler(async event => { - const authKey = getAuthKeyFromRequest(event); + const authKey = await resolveAuthKeyFromEvent(event); + const session = await getSessionByAuthKey(authKey); - // 这里进行服务器验证,确定请求中的 auth-key 是否还有效 - const cookie = await getMpCookie(authKey); + if (authKey && session) { + const apiToken = await issueApiTokenForAuthKey(authKey); + if (!apiToken) { + return { + code: -1, + msg: 'API token issue failed', + }; + } - if (authKey && cookie) { return { code: 0, - data: authKey, + data: apiToken, }; } else { return { diff --git a/server/api/web/login/bizlogin.post.ts b/server/api/web/login/bizlogin.post.ts index 065e96e0..40cce477 100644 --- a/server/api/web/login/bizlogin.post.ts +++ b/server/api/web/login/bizlogin.post.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs'; -import { request } from '#shared/utils/request'; -import { getCookieFromResponse, getCookiesFromRequest } from '~/server/utils/CookieStore'; -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { getCookiesFromRequest, getCookieValueFromResponse } from '~/server/services/api/auth-session'; +import { proxyMpRequest } from '~/server/services/api/mp-gateway'; +import { fetchMpHomeInfoByAuthKey } from '~/server/services/api/mp-service'; export default defineEventHandler(async event => { const cookie = getCookiesFromRequest(event); @@ -28,22 +28,38 @@ export default defineEventHandler(async event => { }, body: payload, cookie: cookie, - action: 'login', // 有这个标志就会把微信原始响应中的所有 set-cookie 存储在 CookieStore 中,并返回给客户端一个唯一的cookie: auth-key=xxx + action: 'login', // 该流程会写入登录态并返回 auth-key cookie }); - // 从响应中取出唯一的 set-cookie (即上一步 `action=login` 标志所设置的 auth-key=xxx) - const authKey = getCookieFromResponse('auth-key', response); + // 从登录响应里提取 auth-key cookie + const authKey = getCookieValueFromResponse('auth-key', response); if (!authKey) { + let detail = '登录失败,请稍后重试'; + try { + const loginResponse = await response.clone().json(); + const upstreamMessage = loginResponse?.base_resp?.err_msg; + if (typeof upstreamMessage === 'string' && upstreamMessage.trim()) { + detail = upstreamMessage; + } + } catch { + try { + const rawText = await response.clone().text(); + if (rawText.trim()) { + detail = rawText.trim(); + } + } catch { + // Keep the default fallback message. + } + } + return { - err: '登录失败,请稍后重试', + err: detail, }; } - const { nick_name, head_img } = await request(`/api/web/mp/info`, { - headers: { - Cookie: `auth-key=${authKey}`, - }, - }); + const info = await fetchMpHomeInfoByAuthKey(event, authKey); + const nick_name = info?.nick_name || ''; + const head_img = info?.head_img || ''; if (!nick_name) { return { err: '获取公众号昵称失败,请稍后重试', diff --git a/server/api/web/login/getqrcode.get.ts b/server/api/web/login/getqrcode.get.ts index 4bc96eba..1cf9da9d 100644 --- a/server/api/web/login/getqrcode.get.ts +++ b/server/api/web/login/getqrcode.get.ts @@ -1,5 +1,5 @@ -import { getCookiesFromRequest } from '~/server/utils/CookieStore'; -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { getCookiesFromRequest } from '~/server/services/api/auth-session'; +import { proxyMpRequest } from '~/server/services/api/mp-gateway'; export default defineEventHandler(async event => { const cookie = getCookiesFromRequest(event); diff --git a/server/api/web/login/scan.get.ts b/server/api/web/login/scan.get.ts index f29c7500..2f99b74f 100644 --- a/server/api/web/login/scan.get.ts +++ b/server/api/web/login/scan.get.ts @@ -1,5 +1,5 @@ -import { getCookiesFromRequest } from '~/server/utils/CookieStore'; -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { getCookiesFromRequest } from '~/server/services/api/auth-session'; +import { proxyMpRequest } from '~/server/services/api/mp-gateway'; export default defineEventHandler(async event => { const cookie = getCookiesFromRequest(event); diff --git a/server/api/web/login/session/[sid].post.ts b/server/api/web/login/session/[sid].post.ts index 2288077a..b91ba87b 100644 --- a/server/api/web/login/session/[sid].post.ts +++ b/server/api/web/login/session/[sid].post.ts @@ -1,4 +1,4 @@ -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { proxyMpRequest } from '~/server/services/api/mp-gateway'; export default defineEventHandler(async event => { const { sid } = event.context.params!; diff --git a/server/api/web/misc/accountname.get.ts b/server/api/web/misc/accountname.get.ts index 52fab403..ae7e2770 100644 --- a/server/api/web/misc/accountname.get.ts +++ b/server/api/web/misc/accountname.get.ts @@ -1,52 +1,13 @@ -import * as cheerio from 'cheerio'; -import { USER_AGENT } from '~/config'; - interface AccountNameQuery { url: string; } -// 允许服务端请求的微信域名白名单 -const ALLOWED_HOSTS = new Set(['mp.weixin.qq.com', 'weixin.qq.com']); - -/** - * 验证 URL 是否为允许的微信域名 - */ -function isAllowedUrl(rawUrl: string): boolean { - try { - const parsed = new URL(rawUrl); - // 只允许 https 协议 - if (parsed.protocol !== 'https:') { - return false; - } - return ALLOWED_HOSTS.has(parsed.hostname); - } catch { - return false; - } -} +import { resolveAccountNameFromArticleUrl } from '~/server/services/api/mp-service'; /** * 根据文章 url 获取公众号名称 */ export default defineEventHandler(async event => { - let { url } = getQuery(event); - url = decodeURIComponent(url); - - if (!isAllowedUrl(url)) { - throw createError({ - statusCode: 400, - statusMessage: '不允许的 URL:仅支持微信公众平台域名', - }); - } - - const rawHtml = await fetch(url, { - headers: { - Referer: 'https://mp.weixin.qq.com/', - Origin: 'https://mp.weixin.qq.com', - 'User-Agent': USER_AGENT, - }, - redirect: 'error', // 禁止跟随重定向,防止 SSRF 绕过 - }).then(res => res.text()); - - const $ = cheerio.load(rawHtml); - return $('.wx_follow_nickname:first').text().trim(); + const { url } = getQuery(event); + return resolveAccountNameFromArticleUrl(url); }); diff --git a/server/api/web/misc/appmsgalbum.get.ts b/server/api/web/misc/appmsgalbum.get.ts index 0b33cad4..d5769bf7 100644 --- a/server/api/web/misc/appmsgalbum.get.ts +++ b/server/api/web/misc/appmsgalbum.get.ts @@ -2,7 +2,7 @@ * 获取合集数据接口 */ -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { fetchAppMsgAlbumResponse } from '~/server/services/api/mp-service'; interface AppMsgAlbumQuery { fakeid: string; @@ -22,25 +22,13 @@ export default defineEventHandler(async event => { const begin_itemidx = query.begin_itemidx; const count: number = query.count || 20; - const params: Record = { - action: 'getalbum', - __biz: fakeid, - album_id: album_id, - begin_msgid: begin_msgid, - begin_itemidx: begin_itemidx, - count: count, + return fetchAppMsgAlbumResponse(event, { + fakeid, + album_id, is_reverse: isReverse, - f: 'json', - }; - - // misc 目录下的接口虽然也是用 proxyMpRequest 方法,但是其实不需要透传公众号的 cookie - // 只是为了方法复用 - return proxyMpRequest({ - event: event, - method: 'GET', - endpoint: 'https://mp.weixin.qq.com/mp/appmsgalbum', - query: params, - parseJson: true, + begin_msgid, + begin_itemidx, + count, }).catch(e => { return { base_resp: { diff --git a/server/api/web/misc/comment.get.ts b/server/api/web/misc/comment.get.ts index 46aacd03..6e9c7151 100644 --- a/server/api/web/misc/comment.get.ts +++ b/server/api/web/misc/comment.get.ts @@ -2,7 +2,7 @@ * 获取文章评论 */ -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { fetchCommentResponse } from '~/server/services/api/mp-service'; interface GetCommentQuery { __biz: string; @@ -15,23 +15,12 @@ interface GetCommentQuery { export default defineEventHandler(async event => { const { __biz, comment_id, uin, key, pass_ticket } = getQuery(event); - const params: Record = { - action: 'getcomment', - __biz: __biz, - comment_id: comment_id, - uin: uin, - key: key, - pass_ticket: pass_ticket, - limit: 1000, - f: 'json', - }; - - const resp: Response = await proxyMpRequest({ - event: event, - method: 'GET', - endpoint: 'https://mp.weixin.qq.com/mp/appmsg_comment', - query: params, - parseJson: false, + const resp: Response = await fetchCommentResponse(event, { + __biz, + comment_id, + uin, + key, + pass_ticket, }); return new Response(resp.body, { headers: { diff --git a/server/api/web/mp/appmsgpublish.get.ts b/server/api/web/mp/appmsgpublish.get.ts index 66d5d669..3c94b693 100644 --- a/server/api/web/mp/appmsgpublish.get.ts +++ b/server/api/web/mp/appmsgpublish.get.ts @@ -2,8 +2,8 @@ * 获取文章列表接口 */ -import { getTokenFromStore } from '~/server/utils/CookieStore'; -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { getTokenFromEvent } from '~/server/services/api/auth-session'; +import { fetchAppMsgPublishResponse } from '~/server/services/api/mp-service'; interface AppMsgPublishQuery { begin?: number; @@ -13,7 +13,7 @@ interface AppMsgPublishQuery { } export default defineEventHandler(async event => { - const token = await getTokenFromStore(event); + const token = await getTokenFromEvent(event); if (!token) { return { base_resp: { ret: -1, err_msg: '未登录或登录已过期,请重新扫码登录' } }; } @@ -26,28 +26,12 @@ export default defineEventHandler(async event => { const isSearching = !!keyword; - const params: Record = { - sub: isSearching ? 'search' : 'list', - search_field: isSearching ? '7' : 'null', - begin: begin, - count: size, - query: keyword, + return fetchAppMsgPublishResponse(event, { + token, fakeid: id, - type: '101_1', - free_publish_type: 1, - sub_action: 'list_ex', - token: token, - lang: 'zh_CN', - f: 'json', - ajax: 1, - }; - - return proxyMpRequest({ - event: event, - method: 'GET', - endpoint: 'https://mp.weixin.qq.com/cgi-bin/appmsgpublish', - query: params, - parseJson: true, + keyword, + begin, + size, }).catch(e => { console.error(e); return { diff --git a/server/api/web/mp/info.get.ts b/server/api/web/mp/info.get.ts index 0021539c..d03c44ed 100644 --- a/server/api/web/mp/info.get.ts +++ b/server/api/web/mp/info.get.ts @@ -5,42 +5,14 @@ * 这个接口用于后端登录成功之后调用,非客户端直接调用 */ -import { getTokenFromStore } from '~/server/utils/CookieStore'; -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { getTokenFromEvent } from '~/server/services/api/auth-session'; +import { fetchMpHomeInfo } from '~/server/services/api/mp-service'; export default defineEventHandler(async event => { - const token = await getTokenFromStore(event); + const token = await getTokenFromEvent(event); if (!token) { return { nick_name: '', head_img: '', error: '未登录或登录已过期,请重新扫码登录' }; } - const html: string = await proxyMpRequest({ - event: event, - method: 'GET', - endpoint: 'https://mp.weixin.qq.com/cgi-bin/home', - query: { - t: 'home/index', - token: token, - lang: 'zh_CN', - }, - }).then(resp => resp.text()); - - // 提取昵称 - let nick_name = ''; - const nicknameMatchResult = html.match(/wx\.cgiData\.nick_name\s*?=\s*?"(?[^"]+)"/); - if (nicknameMatchResult && nicknameMatchResult.groups && nicknameMatchResult.groups.nick_name) { - nick_name = nicknameMatchResult.groups.nick_name; - } - - // 提取头像 - let head_img = ''; - const headImgMatchResult = html.match(/wx\.cgiData\.head_img\s*?=\s*?"(?[^"]+)"/); - if (headImgMatchResult && headImgMatchResult.groups && headImgMatchResult.groups.head_img) { - head_img = headImgMatchResult.groups.head_img; - } - - return { - nick_name: nick_name, - head_img: head_img, - }; + return fetchMpHomeInfo(event, token); }); diff --git a/server/api/web/mp/logout.get.ts b/server/api/web/mp/logout.get.ts index f87056cc..ef669540 100644 --- a/server/api/web/mp/logout.get.ts +++ b/server/api/web/mp/logout.get.ts @@ -2,12 +2,11 @@ * 退出登录接口 */ -import { parseCookies } from 'h3'; -import { cookieStore, getTokenFromStore } from '~/server/utils/CookieStore'; -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { getTokenFromEvent, revokeSessionFromEvent } from '~/server/services/api/auth-session'; +import { proxyMpRequest } from '~/server/services/api/mp-gateway'; export default defineEventHandler(async event => { - const token = await getTokenFromStore(event); + const token = await getTokenFromEvent(event); if (!token) { return { statusCode: 401, statusText: '未登录或登录已过期,请重新扫码登录' }; } @@ -23,11 +22,7 @@ export default defineEventHandler(async event => { }, }); - // 登出后清理内存中的 cookie 缓存 - const authKey = getRequestHeader(event, 'X-Auth-Key') || parseCookies(event)['auth-key']; - if (authKey) { - cookieStore.removeCookie(authKey); - } + await revokeSessionFromEvent(event); return { statusCode: response.status, diff --git a/server/api/web/mp/profile_ext_getmsg.get.ts b/server/api/web/mp/profile_ext_getmsg.get.ts index 4af7c590..23e42df3 100644 --- a/server/api/web/mp/profile_ext_getmsg.get.ts +++ b/server/api/web/mp/profile_ext_getmsg.get.ts @@ -2,7 +2,7 @@ * 搜索公众号文章列表接口 */ -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { fetchProfileExtGetMsgResponse } from '~/server/services/api/mp-service'; interface SearchBizQuery { begin?: number; @@ -18,25 +18,13 @@ export default defineEventHandler(async event => { const begin: number = query.begin || 0; const size: number = query.size || 10; - const params: Record = { - action: 'getmsg', - __biz: query.id, - offset: begin, - count: size, + return fetchProfileExtGetMsgResponse(event, { + id: query.id, + begin, + size, uin: query.uin, key: query.key, pass_ticket: query.pass_ticket, - f: 'json', - is_ok: '1', - scene: '124', - }; - - return proxyMpRequest({ - event: event, - method: 'GET', - endpoint: 'https://mp.weixin.qq.com/mp/profile_ext', - query: params, - parseJson: true, }).catch(e => { return { base_resp: { diff --git a/server/api/web/mp/searchbiz.get.ts b/server/api/web/mp/searchbiz.get.ts index 40777e49..bb6bc164 100644 --- a/server/api/web/mp/searchbiz.get.ts +++ b/server/api/web/mp/searchbiz.get.ts @@ -2,8 +2,8 @@ * 搜索公众号接口 */ -import { getTokenFromStore } from '~/server/utils/CookieStore'; -import { proxyMpRequest } from '~/server/utils/proxy-request'; +import { getTokenFromEvent } from '~/server/services/api/auth-session'; +import { fetchSearchBizResponse } from '~/server/services/api/mp-service'; interface SearchBizQuery { begin?: number; @@ -12,7 +12,7 @@ interface SearchBizQuery { } export default defineEventHandler(async event => { - const token = await getTokenFromStore(event); + const token = await getTokenFromEvent(event); if (!token) { return { base_resp: { ret: -1, err_msg: '未登录或登录已过期,请重新扫码登录' } }; } @@ -22,23 +22,11 @@ export default defineEventHandler(async event => { const begin: number = query.begin || 0; const size: number = query.size || 5; - const params: Record = { - action: 'search_biz', - begin: begin, - count: size, - query: keyword, - token: token, - lang: 'zh_CN', - f: 'json', - ajax: '1', - }; - - return proxyMpRequest({ - event: event, - method: 'GET', - endpoint: 'https://mp.weixin.qq.com/cgi-bin/searchbiz', - query: params, - parseJson: true, + return fetchSearchBizResponse(event, { + token, + keyword, + begin, + size, }).catch(e => { return { base_resp: { diff --git a/server/api/web/mp/searchbyurl.get.ts b/server/api/web/mp/searchbyurl.get.ts index 51a8c63c..74b1a443 100644 --- a/server/api/web/mp/searchbyurl.get.ts +++ b/server/api/web/mp/searchbyurl.get.ts @@ -1,4 +1,4 @@ -import { request } from '#shared/utils/request'; +import { searchAccountByArticleUrl } from '~/server/services/api/mp-service'; interface UrlQuery { url: string; @@ -7,36 +7,9 @@ interface UrlQuery { export default defineEventHandler(async event => { let { url } = getQuery(event); - const name = await request('/api/web/misc/accountname?url=' + encodeURIComponent(url)); - if (!name) { - return { - base_resp: { - ret: -1, - err_msg: 'url解析公众号名称失败', - }, - }; - } - - const originalResp = await request(`/api/web/mp/searchbiz?keyword=${name}&size=20`, { - headers: { - 'X-Auth-Key': getHeader(event, 'X-Auth-Key')!, - Cookie: getHeader(event, 'Cookie')!, - }, + return searchAccountByArticleUrl(event, { + url, + authErrorMessage: '未登录或登录已过期,请重新扫码登录', + searchErrorMessage: '搜索公众号接口失败,请重试', }); - if (originalResp.base_resp.ret !== 0) { - return originalResp; - } - - let resp = JSON.parse(JSON.stringify(originalResp)); - resp.list = resp.list.filter((item: any) => item.nickname === name); - resp.total = resp.list.length; - - if (resp.list.length === 0) { - resp.base_resp.ret = -1; - resp.base_resp.err_msg = '根据解析的名称搜索公众号失败'; - resp.resolved_name = name; - resp.original_resp = originalResp; - } - - return resp; }); diff --git a/server/kv/api-token.ts b/server/kv/api-token.ts new file mode 100644 index 00000000..029ffdc2 --- /dev/null +++ b/server/kv/api-token.ts @@ -0,0 +1,50 @@ +export interface ApiTokenKVValue { + authKey: string; + expiresAt: number; +} + +export async function setApiToken(token: string, data: ApiTokenKVValue, ttlSeconds: number): Promise { + const kv = useStorage('kv'); + try { + await kv.set(`api-token:${token}`, data, { + expirationTtl: ttlSeconds, + }); + return true; + } catch (error) { + console.error('setApiToken failed:', error); + return false; + } +} + +export async function getApiToken(token: string): Promise { + const kv = useStorage('kv'); + return await kv.get(`api-token:${token}`); +} + +export async function deleteApiToken(token: string): Promise { + const kv = useStorage('kv'); + await kv.remove(`api-token:${token}`); +} + +export async function setApiTokenByAuthKey(authKey: string, token: string, ttlSeconds: number): Promise { + const kv = useStorage('kv'); + try { + await kv.set(`api-token-by-auth:${authKey}`, token, { + expirationTtl: ttlSeconds, + }); + return true; + } catch (error) { + console.error('setApiTokenByAuthKey failed:', error); + return false; + } +} + +export async function getApiTokenByAuthKey(authKey: string): Promise { + const kv = useStorage('kv'); + return await kv.get(`api-token-by-auth:${authKey}`); +} + +export async function deleteApiTokenByAuthKey(authKey: string): Promise { + const kv = useStorage('kv'); + await kv.remove(`api-token-by-auth:${authKey}`); +} diff --git a/server/kv/cookie.ts b/server/kv/cookie.ts index 0963daf4..02fc5374 100644 --- a/server/kv/cookie.ts +++ b/server/kv/cookie.ts @@ -1,11 +1,8 @@ -import { type CookieEntity } from '~/server/utils/CookieStore'; +import { type StoredSessionRecord } from '../services/api/session-core'; export type CookieKVKey = string; -export interface CookieKVValue { - token: string; - cookies: CookieEntity[]; -} +export type CookieKVValue = StoredSessionRecord; export async function setMpCookie(key: CookieKVKey, data: CookieKVValue): Promise { const kv = useStorage('kv'); @@ -25,3 +22,8 @@ export async function getMpCookie(key: CookieKVKey): Promise(`cookie:${key}`); } + +export async function deleteMpCookie(key: CookieKVKey): Promise { + const kv = useStorage('kv'); + await kv.remove(`cookie:${key}`); +} diff --git a/server/services/api/auth-session.ts b/server/services/api/auth-session.ts new file mode 100644 index 00000000..9f0d538a --- /dev/null +++ b/server/services/api/auth-session.ts @@ -0,0 +1,208 @@ +import { H3Event, parseCookies } from 'h3'; +import { extractSetCookieValues } from '../../utils/set-cookie'; + +import { + deleteApiToken, + deleteApiTokenByAuthKey, + getApiToken, + getApiTokenByAuthKey, + setApiToken, + setApiTokenByAuthKey, +} from '../../kv/api-token'; +import { deleteMpCookie, getMpCookie, setMpCookie } from '../../kv/cookie'; +import { + AccountSession, + AUTH_KEY_TTL_MS, + AUTH_KEY_TTL_SECONDS, + AuthSessionCache, + computeAuthKeyExpiresAt, +} from './session-core'; + +export const sessionCache = new AuthSessionCache(); + +async function resolveAuthKeyFromApiToken(token: string, now = Date.now()): Promise { + const record = await getApiToken(token); + if (!record) { + return null; + } + + if (record.expiresAt <= now) { + await deleteApiToken(token); + await deleteApiTokenByAuthKey(record.authKey); + return null; + } + + return record.authKey; +} + +export async function getSessionByAuthKey(authKey?: string | null, now = Date.now()): Promise { + if (!authKey) { + return null; + } + + const cachedSession = sessionCache.get(authKey, now); + if (cachedSession) { + return cachedSession; + } + + const storedSession = await getMpCookie(authKey); + if (!storedSession) { + return null; + } + + const session = AccountSession.fromStoredRecord(storedSession); + if (session.isExpiredAt(now)) { + await deleteMpCookie(authKey); + return null; + } + + sessionCache.set(authKey, session); + return session; +} + +export async function getResolvedSessionFromEvent(event: H3Event, now = Date.now()): Promise { + const headerCredential = getRequestHeader(event, 'X-Auth-Key'); + if (headerCredential) { + const directSession = await getSessionByAuthKey(headerCredential, now); + if (directSession) { + return directSession; + } + + const resolvedAuthKey = await resolveAuthKeyFromApiToken(headerCredential, now); + if (resolvedAuthKey) { + return getSessionByAuthKey(resolvedAuthKey, now); + } + } + + const cookies = parseCookies(event); + return getSessionByAuthKey(cookies['auth-key'], now); +} + +export async function persistSession( + authKey: string, + token: string, + cookies: string[], + now = Date.now() +): Promise { + const session = AccountSession.fromSetCookieStrings(token, cookies, computeAuthKeyExpiresAt(now)); + sessionCache.set(authKey, session); + + const success = await setMpCookie(authKey, session.toJSON()); + if (!success) { + sessionCache.delete(authKey); + return null; + } + + return session; +} + +export async function revokeSession(authKey?: string | null): Promise { + if (!authKey) { + return; + } + + const apiToken = await getApiTokenByAuthKey(authKey); + sessionCache.delete(authKey); + await deleteMpCookie(authKey); + await deleteApiTokenByAuthKey(authKey); + if (apiToken) { + await deleteApiToken(apiToken); + } +} + +export async function revokeSessionFromEvent(event: H3Event, now = Date.now()): Promise { + const authKey = await resolveAuthKeyFromEvent(event, now); + if (!authKey) { + return; + } + + await revokeSession(authKey); +} + +export function extractAuthKeyFromEvent(event: H3Event): string { + const headerAuthKey = getRequestHeader(event, 'X-Auth-Key'); + if (headerAuthKey) { + return headerAuthKey; + } + + const cookies = parseCookies(event); + return cookies['auth-key']; +} + +export async function resolveAuthKeyFromEvent(event: H3Event, now = Date.now()): Promise { + const headerCredential = getRequestHeader(event, 'X-Auth-Key'); + if (headerCredential) { + const directSession = await getSessionByAuthKey(headerCredential, now); + if (directSession) { + return headerCredential; + } + + return resolveAuthKeyFromApiToken(headerCredential, now); + } + + const cookies = parseCookies(event); + return cookies['auth-key'] || null; +} + +export async function getCookieHeaderFromEvent(event: H3Event, now = Date.now()): Promise { + const session = await getResolvedSessionFromEvent(event, now); + if (!session) { + return null; + } + + const cookieHeader = session.toCookieHeader(now); + return cookieHeader || null; +} + +export async function getTokenFromEvent(event: H3Event, now = Date.now()): Promise { + const session = await getResolvedSessionFromEvent(event, now); + return session?.token || null; +} + +export function getCookiesFromRequest(event: H3Event): string { + const cookies = parseCookies(event); + return ['uuid'] + .filter(key => !!cookies[key]) + .map(key => `${key}=${encodeURIComponent(cookies[key])}`) + .join(';'); +} + +export function getCookieValueFromResponse(name: string, response: Response): string | null { + const cookies = AccountSession.parse(extractSetCookieValues(response.headers)); + const targetCookie = cookies.find(cookie => cookie.name === name); + if (!targetCookie) { + return null; + } + + return targetCookie.value as string; +} + +export function getSessionCacheSnapshot() { + return sessionCache.toJSON(); +} + +export async function issueApiTokenForAuthKey(authKey: string, now = Date.now()): Promise { + const existingToken = await getApiTokenByAuthKey(authKey); + if (existingToken) { + const existingRecord = await getApiToken(existingToken); + if (existingRecord && existingRecord.expiresAt > now) { + return existingToken; + } + + await deleteApiToken(existingToken); + await deleteApiTokenByAuthKey(authKey); + } + + const token = crypto.randomUUID().replace(/-/g, ''); + const expiresAt = now + AUTH_KEY_TTL_MS; + const tokenWritten = await setApiToken(token, { authKey, expiresAt }, AUTH_KEY_TTL_SECONDS); + const reverseWritten = tokenWritten && (await setApiTokenByAuthKey(authKey, token, AUTH_KEY_TTL_SECONDS)); + + if (!tokenWritten || !reverseWritten) { + await deleteApiToken(token); + await deleteApiTokenByAuthKey(authKey); + return null; + } + + return token; +} diff --git a/server/services/api/mp-core.ts b/server/services/api/mp-core.ts new file mode 100644 index 00000000..d4d36956 --- /dev/null +++ b/server/services/api/mp-core.ts @@ -0,0 +1,83 @@ +export interface SearchBizParamsInput { + begin?: number; + keyword: string; + size?: number; + token: string; +} + +export interface AppMsgPublishParamsInput { + begin?: number; + fakeid: string; + keyword?: string; + size?: number; + token: string; +} + +interface PublishPageLike { + publish_list?: Array<{ publish_info?: string | null }>; +} + +interface PublishResponseLike { + publish_page: string; +} + +export function buildSearchBizParams(input: SearchBizParamsInput): Record { + return { + action: 'search_biz', + begin: input.begin || 0, + count: input.size || 5, + query: input.keyword, + token: input.token, + lang: 'zh_CN', + f: 'json', + ajax: '1', + }; +} + +export function buildAppMsgPublishParams(input: AppMsgPublishParamsInput): Record { + const keyword = input.keyword || ''; + const isSearching = keyword.length > 0; + + return { + sub: isSearching ? 'search' : 'list', + search_field: isSearching ? '7' : 'null', + begin: input.begin || 0, + count: input.size || 5, + query: keyword, + fakeid: input.fakeid, + type: '101_1', + free_publish_type: 1, + sub_action: 'list_ex', + token: input.token, + lang: 'zh_CN', + f: 'json', + ajax: 1, + }; +} + +export function extractPublishedArticles(response: PublishResponseLike): any[] { + const publishPage = JSON.parse(response.publish_page) as PublishPageLike; + const publishList = Array.isArray(publishPage.publish_list) ? publishPage.publish_list : []; + + return publishList + .filter(item => !!item.publish_info) + .flatMap(item => { + const publishInfo = JSON.parse(item.publish_info as string); + return publishInfo.appmsgex; + }); +} + +export function filterSearchBizResponseByNickname(originalResponse: any, nickname: string): any { + const response = JSON.parse(JSON.stringify(originalResponse)); + response.list = Array.isArray(response.list) ? response.list.filter((item: any) => item.nickname === nickname) : []; + response.total = response.list.length; + + if (response.list.length === 0) { + response.base_resp.ret = -1; + response.base_resp.err_msg = '根据解析的名称搜索公众号失败'; + response.resolved_name = nickname; + response.original_resp = originalResponse; + } + + return response; +} diff --git a/server/services/api/mp-gateway.ts b/server/services/api/mp-gateway.ts new file mode 100644 index 00000000..832732ca --- /dev/null +++ b/server/services/api/mp-gateway.ts @@ -0,0 +1,139 @@ +import type { RequestOptions } from '~/server/types'; +import { isDev, USER_AGENT } from '../../../config'; +import { logRequest, logResponse } from '../../utils/logger'; +import { extractSetCookieValues } from '../../utils/set-cookie'; +import { extractAuthKeyFromEvent, getCookieHeaderFromEvent, persistSession } from './auth-session'; + +function buildCookieHeader(name: string, value: string, expiresAt: number): string { + return `${name}=${value}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; Secure; HttpOnly; SameSite=Strict`; +} + +function buildExpiredCookieHeader(name: string): string { + return `${name}=EXPIRED; Path=/; Expires=${new Date(0).toUTCString()}; Secure; HttpOnly; SameSite=Strict`; +} + +function formatLoginFailureDetails(details: Record): string { + return Object.entries(details) + .map(([key, value]) => `${key}=${typeof value === 'string' ? value : JSON.stringify(value)}`) + .join('; '); +} + +export async function proxyMpRequest(options: RequestOptions) { + const headers = new Headers({ + Referer: 'https://mp.weixin.qq.com/', + Origin: 'https://mp.weixin.qq.com', + 'User-Agent': USER_AGENT, + 'Accept-Encoding': 'identity', + }); + + const cookie = options.cookie || (await getCookieHeaderFromEvent(options.event)); + if (cookie) { + headers.set('Cookie', cookie); + } + + const requestInit: RequestInit = { + method: options.method, + headers, + redirect: options.redirect || 'follow', + }; + + const endpoint = options.query + ? `${options.endpoint}?${new URLSearchParams(options.query as Record).toString()}` + : options.endpoint; + + if (options.method === 'POST' && options.body) { + requestInit.body = new URLSearchParams(options.body as Record).toString(); + } + + const request = new Request(endpoint, requestInit); + const requestId = crypto.randomUUID().replace(/-/g, ''); + + if (process.env.NUXT_DEBUG_MP_REQUEST && isDev) { + await logRequest(requestId, request.clone()); + } + + const mpResponse = await fetch(request); + + if (process.env.NUXT_DEBUG_MP_REQUEST && isDev) { + await logResponse(requestId, mpResponse.clone()); + } + + let setCookies: string[] = []; + + if (options.action === 'start_login') { + setCookies = extractSetCookieValues(mpResponse.headers).filter(cookieValue => cookieValue.startsWith('uuid=')); + } else if (options.action === 'login') { + try { + const authKey = crypto.randomUUID().replace(/-/g, ''); + const body = await mpResponse.clone().json(); + const redirectUrl = body?.redirect_url; + if (!redirectUrl || typeof redirectUrl !== 'string') { + throw new Error(`登录响应中未找到 redirect_url,响应内容: ${JSON.stringify(body)}`); + } + + const token = new URL(`http://localhost${redirectUrl}`).searchParams.get('token'); + if (!token) { + throw new Error(`redirect_url 中未找到 token 参数: ${redirectUrl}`); + } + + const upstreamSetCookies = extractSetCookieValues(mpResponse.headers); + const nativeCount = mpResponse.headers.getSetCookie?.().length ?? -1; + console.info( + `[login] wx.status=${mpResponse.status} native=${nativeCount} extracted=${upstreamSetCookies.length} runtime=${process.env.NITRO_PRESET || 'node-server'}`, + ); + if (upstreamSetCookies.length === 0) { + throw new Error( + formatLoginFailureDetails({ + reason: '上游登录响应未暴露 set-cookie', + runtime: process.env.NITRO_PRESET || 'node-server', + status: mpResponse.status, + redirectUrlFound: true, + hint: 'Cloudflare Pages/Workers 环境可能无法读取微信登录返回的 set-cookie,请优先使用 Node/Docker 部署验证', + }) + ); + } + + const session = await persistSession(authKey, token, upstreamSetCookies); + if (!session || !session.expiresAt) { + throw new Error( + formatLoginFailureDetails({ + reason: 'cookie 写入 KV 存储失败', + runtime: process.env.NITRO_PRESET || 'node-server', + status: mpResponse.status, + upstreamSetCookieCount: upstreamSetCookies.length, + }) + ); + } + + setCookies = [buildCookieHeader('auth-key', authKey, session.expiresAt), buildExpiredCookieHeader('uuid')]; + } catch (error) { + console.error('action(login) failed:', error); + return new Response(JSON.stringify({ base_resp: { ret: -1, err_msg: `登录处理失败: ${error}` } }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + } else if (options.action === 'switch_account') { + if (extractAuthKeyFromEvent(options.event)) { + setCookies = ['switch_account=1']; + } + } + + const responseHeaders = new Headers(mpResponse.headers); + responseHeaders.delete('set-cookie'); + setCookies.forEach(setCookie => { + responseHeaders.append('set-cookie', setCookie); + }); + + const finalResponse = new Response(mpResponse.body, { + status: mpResponse.status, + statusText: mpResponse.statusText, + headers: responseHeaders, + }); + + if (!options.parseJson) { + return finalResponse; + } + + return finalResponse.json(); +} diff --git a/server/services/api/mp-service.ts b/server/services/api/mp-service.ts new file mode 100644 index 00000000..bec4bb76 --- /dev/null +++ b/server/services/api/mp-service.ts @@ -0,0 +1,275 @@ +import * as cheerio from 'cheerio'; +import { H3Event } from 'h3'; + +import { USER_AGENT } from '../../../config'; +import { getSessionByAuthKey, getTokenFromEvent } from './auth-session'; +import { buildAppMsgPublishParams, buildSearchBizParams, filterSearchBizResponseByNickname } from './mp-core'; +import { proxyMpRequest } from './mp-gateway'; + +interface SearchBizInput { + begin?: number; + keyword: string; + size?: number; + token: string; +} + +interface AppMsgPublishInput { + begin?: number; + fakeid: string; + keyword?: string; + size?: number; + token: string; +} + +interface SearchAccountByUrlInput { + authErrorMessage: string; + searchErrorMessage: string; + url: string; +} + +interface AuthorInfoInput { + fakeid: string; +} + +interface ProfileExtGetMsgInput { + begin?: number; + id: string; + key: string; + pass_ticket: string; + size?: number; + uin: string; +} + +interface AppMsgAlbumInput { + album_id: string; + begin_itemidx?: string; + begin_msgid?: string; + count?: number; + fakeid: string; + is_reverse?: string; +} + +interface CommentInput { + __biz: string; + comment_id: string; + key: string; + pass_ticket: string; + uin: string; +} + +const ALLOWED_HOSTS = new Set(['mp.weixin.qq.com', 'weixin.qq.com']); + +function isAllowedUrl(rawUrl: string): boolean { + try { + const parsed = new URL(rawUrl); + if (parsed.protocol !== 'https:') { + return false; + } + + return ALLOWED_HOSTS.has(parsed.hostname); + } catch { + return false; + } +} + +export async function fetchSearchBizResponse(event: H3Event, input: SearchBizInput) { + return proxyMpRequest({ + event, + method: 'GET', + endpoint: 'https://mp.weixin.qq.com/cgi-bin/searchbiz', + query: buildSearchBizParams(input), + parseJson: true, + }); +} + +export async function fetchAppMsgPublishResponse(event: H3Event, input: AppMsgPublishInput) { + return proxyMpRequest({ + event, + method: 'GET', + endpoint: 'https://mp.weixin.qq.com/cgi-bin/appmsgpublish', + query: buildAppMsgPublishParams(input), + parseJson: true, + }); +} + +export async function fetchAuthorInfoResponse(event: H3Event, input: AuthorInfoInput) { + return proxyMpRequest({ + event, + method: 'GET', + endpoint: 'https://mp.weixin.qq.com/mp/authorinfo', + query: { + wxtoken: '777', + biz: input.fakeid, + __biz: input.fakeid, + x5: 0, + f: 'json', + }, + parseJson: true, + }); +} + +export async function fetchProfileExtGetMsgResponse(event: H3Event, input: ProfileExtGetMsgInput) { + return proxyMpRequest({ + event, + method: 'GET', + endpoint: 'https://mp.weixin.qq.com/mp/profile_ext', + query: { + action: 'getmsg', + __biz: input.id, + offset: input.begin || 0, + count: input.size || 10, + uin: input.uin, + key: input.key, + pass_ticket: input.pass_ticket, + f: 'json', + is_ok: '1', + scene: '124', + }, + parseJson: true, + }); +} + +export async function fetchAppMsgAlbumResponse(event: H3Event, input: AppMsgAlbumInput) { + return proxyMpRequest({ + event, + method: 'GET', + endpoint: 'https://mp.weixin.qq.com/mp/appmsgalbum', + query: { + action: 'getalbum', + __biz: input.fakeid, + album_id: input.album_id, + begin_msgid: input.begin_msgid, + begin_itemidx: input.begin_itemidx, + count: input.count || 20, + is_reverse: input.is_reverse || '0', + f: 'json', + }, + parseJson: true, + }); +} + +export async function fetchCommentResponse(event: H3Event, input: CommentInput) { + return proxyMpRequest({ + event, + method: 'GET', + endpoint: 'https://mp.weixin.qq.com/mp/appmsg_comment', + query: { + action: 'getcomment', + __biz: input.__biz, + comment_id: input.comment_id, + uin: input.uin, + key: input.key, + pass_ticket: input.pass_ticket, + limit: 1000, + f: 'json', + }, + parseJson: false, + }); +} + +export function extractMpHomeInfo(rawHtml: string) { + let nick_name = ''; + const nicknameMatchResult = rawHtml.match(/wx\.cgiData\.nick_name\s*?=\s*?"(?[^"]+)"/); + if (nicknameMatchResult?.groups?.nick_name) { + nick_name = nicknameMatchResult.groups.nick_name; + } + + let head_img = ''; + const headImgMatchResult = rawHtml.match(/wx\.cgiData\.head_img\s*?=\s*?"(?[^"]+)"/); + if (headImgMatchResult?.groups?.head_img) { + head_img = headImgMatchResult.groups.head_img; + } + + return { + nick_name, + head_img, + }; +} + +export async function fetchMpHomeInfo(event: H3Event, token: string, cookie?: string) { + const html: string = await proxyMpRequest({ + event, + method: 'GET', + endpoint: 'https://mp.weixin.qq.com/cgi-bin/home', + query: { + t: 'home/index', + token, + lang: 'zh_CN', + }, + cookie, + }).then(resp => resp.text()); + + return extractMpHomeInfo(html); +} + +export async function fetchMpHomeInfoByAuthKey(event: H3Event, authKey: string) { + const session = await getSessionByAuthKey(authKey); + if (!session) { + return null; + } + + return fetchMpHomeInfo(event, session.token, session.toCookieHeader()); +} + +export async function searchAccountByArticleUrl(event: H3Event, input: SearchAccountByUrlInput) { + const name = await resolveAccountNameFromArticleUrl(input.url); + if (!name) { + return { + base_resp: { + ret: -1, + err_msg: 'url解析公众号名称失败', + }, + }; + } + + const token = await getTokenFromEvent(event); + if (!token) { + return { + base_resp: { + ret: -1, + err_msg: input.authErrorMessage, + }, + }; + } + + const originalResp = await fetchSearchBizResponse(event, { + token, + keyword: name, + size: 20, + }).catch(() => { + return { + base_resp: { + ret: -1, + err_msg: input.searchErrorMessage, + }, + }; + }); + + if (originalResp.base_resp.ret !== 0) { + return originalResp; + } + + return filterSearchBizResponseByNickname(originalResp, name); +} + +export async function resolveAccountNameFromArticleUrl(rawUrl: string): Promise { + const url = decodeURIComponent(rawUrl); + if (!isAllowedUrl(url)) { + throw createError({ + statusCode: 400, + statusMessage: '不允许的 URL:仅支持微信公众平台域名', + }); + } + + const rawHtml = await fetch(url, { + headers: { + Referer: 'https://mp.weixin.qq.com/', + Origin: 'https://mp.weixin.qq.com', + 'User-Agent': USER_AGENT, + }, + redirect: 'error', + }).then(resp => resp.text()); + + const $ = cheerio.load(rawHtml); + return $('.wx_follow_nickname:first').text().trim(); +} diff --git a/server/services/api/session-core.ts b/server/services/api/session-core.ts new file mode 100644 index 00000000..6767c3cb --- /dev/null +++ b/server/services/api/session-core.ts @@ -0,0 +1,176 @@ +export const AUTH_KEY_TTL_MS = 4 * 24 * 60 * 60 * 1000; +export const AUTH_KEY_TTL_SECONDS = AUTH_KEY_TTL_MS / 1000; + +export type CookieEntity = Record; + +export interface StoredSessionRecord { + token: string; + cookies: CookieEntity[]; + expiresAt?: number; +} + +export function computeAuthKeyExpiresAt(now = Date.now()): number { + return now + AUTH_KEY_TTL_MS; +} + +export class AccountSession { + private readonly _token: string; + private readonly _cookies: CookieEntity[]; + private readonly _expiresAt?: number; + + constructor(token: string, cookies: CookieEntity[], expiresAt?: number) { + this._token = token; + this._cookies = cookies; + this._expiresAt = expiresAt; + } + + static fromSetCookieStrings(token: string, cookies: string[], expiresAt = computeAuthKeyExpiresAt()): AccountSession { + return new AccountSession(token, AccountSession.parse(cookies), expiresAt); + } + + static fromStoredRecord(record: StoredSessionRecord): AccountSession { + return new AccountSession(record.token, record.cookies, record.expiresAt); + } + + static parse(cookies: string[]): CookieEntity[] { + const cookieMap = new Map(); + + for (const cookie of cookies) { + const cookieObj: CookieEntity = {}; + const parts = cookie.split(';').map(part => part.trim()); + const [nameValue] = parts; + if (!nameValue) { + continue; + } + + const [name, ...valueParts] = nameValue.split('='); + const cookieName = name.trim(); + if (!cookieName) { + continue; + } + + cookieObj.name = cookieName; + cookieObj.value = valueParts.join('=').trim(); + + for (const part of parts.slice(1)) { + const [key, ...attributeParts] = part.split('='); + if (!key) { + continue; + } + + const attributeName = key.toLowerCase(); + const attributeValue = attributeParts.join('=').trim(); + cookieObj[attributeName] = attributeValue || true; + + if (attributeName === 'expires' && attributeValue) { + const timestamp = Date.parse(attributeValue); + if (!Number.isNaN(timestamp)) { + cookieObj.expires_timestamp = timestamp; + } + } + } + + cookieMap.set(cookieName, cookieObj); + } + + return Array.from(cookieMap.values()); + } + + public get token(): string { + return this._token; + } + + public get expiresAt(): number | undefined { + return this._expiresAt; + } + + public get(name: string): CookieEntity | undefined { + return this._cookies.find(cookie => cookie.name === name); + } + + public isExpiredAt(now = Date.now()): boolean { + return typeof this._expiresAt === 'number' && this._expiresAt <= now; + } + + public toCookieHeader(now = Date.now()): string { + if (this.isExpiredAt(now)) { + return ''; + } + + return this._cookies + .filter(cookie => { + if (!cookie.value || cookie.value === 'EXPIRED') { + return false; + } + + if (typeof cookie.expires_timestamp === 'number') { + return cookie.expires_timestamp > now; + } + + return true; + }) + .map(cookie => `${cookie.name}=${cookie.value}`) + .join('; '); + } + + public toJSON(): StoredSessionRecord { + return { + token: this._token, + cookies: this._cookies, + expiresAt: this._expiresAt, + }; + } +} + +export class AuthSessionCache { + private readonly store = new Map(); + private readonly maxSize: number; + + constructor(maxSize = 1000) { + this.maxSize = maxSize; + } + + get(authKey: string, now = Date.now()): AccountSession | null { + const session = this.store.get(authKey); + if (!session) { + return null; + } + + if (session.isExpiredAt(now)) { + this.store.delete(authKey); + return null; + } + + this.store.delete(authKey); + this.store.set(authKey, session); + return session; + } + + set(authKey: string, session: AccountSession): void { + this.store.delete(authKey); + this.evictIfNeeded(); + this.store.set(authKey, session); + } + + delete(authKey: string): void { + this.store.delete(authKey); + } + + toJSON(): Record { + const json: Record = {}; + for (const [authKey, session] of this.store) { + json[authKey] = session.toJSON(); + } + return json; + } + + private evictIfNeeded(): void { + while (this.store.size >= this.maxSize) { + const oldestKey = this.store.keys().next().value; + if (oldestKey === undefined) { + break; + } + this.store.delete(oldestKey); + } + } +} diff --git a/server/types.d.ts b/server/types.d.ts index 4c4558be..5e018530 100644 --- a/server/types.d.ts +++ b/server/types.d.ts @@ -12,11 +12,5 @@ export interface RequestOptions { cookie?: string; referer?: string; redirect?: RequestRedirect; - - /** - * start_login: 开始登录流程 (把微信原始响应中的 uuid 这个 set-cookie 传递给客户端,以便后续扫码登录用) - * login: 登录流程完成 (把微信原始响应中的所有 set-cookie 存储在 CookieStore 中,并返回给客户端一个唯一的cookie: auth-key=xxx) - * switch_account: 切换公众号 - */ action?: 'start_login' | 'login' | 'switch_account'; } diff --git a/server/utils/CookieStore.ts b/server/utils/CookieStore.ts deleted file mode 100644 index 995a61e9..00000000 --- a/server/utils/CookieStore.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { H3Event, parseCookies } from 'h3'; -import { CookieKVValue, getMpCookie, setMpCookie } from '~/server/kv/cookie'; - -// 表示一条 set-cookie 记录的解析结果 -export type CookieEntity = Record; - -// 公众号所有的 set-cookie 解析结果 -export class AccountCookie { - private readonly _token: string; - private _cookie: CookieEntity[]; - - /** - * @param token - * @param cookies response.headers.getSetCookie() 的结果,是一个字符串数组 - */ - constructor(token: string, cookies: string[]) { - this._token = token; - this._cookie = AccountCookie.parse(cookies); - } - - static create(token: string, cookies: CookieEntity[]): AccountCookie { - const value = new AccountCookie(token, []); - value._cookie = cookies; - return value; - } - - public toString(): string { - return this.stringify(this._cookie); - } - - public toJSON(): CookieKVValue { - return { - token: this._token, - cookies: this._cookie, - }; - } - - public get(name: string): CookieEntity | undefined { - return this._cookie.find(cookie => cookie.name === name); - } - - public get token() { - return this._token; - } - - // 根据 cookie 中的 expires 来确定是否已过期 - public get isExpired(): boolean { - // todo - return false; - } - - public static parse(cookies: string[]): CookieEntity[] { - // key 为 cookie 的 name - const cookieMap = new Map(); - - for (const cookie of cookies) { - const cookieObj: CookieEntity = {}; - // 分割 cookie 字符串为各个属性 - const parts = cookie.split(';').map(str => str.trim()); - - // 第一个部分是name=value - const [nameValue] = parts; - if (nameValue) { - const [name, ...valueParts] = nameValue.split('='); - const cookieName = name.trim(); - cookieObj.name = cookieName; - cookieObj.value = valueParts.join('=').trim(); // 处理值中可能包含的等号 - - // 处理其他属性(如Expires, Path, Domain等) - for (const part of parts.slice(1)) { - const [key, ...valueParts] = part.split('='); - const value = valueParts.join('=').trim(); // 处理值中可能包含的等号 - if (key) { - const keyLower = key.toLowerCase(); - cookieObj[keyLower] = value || 'true'; // 无值属性(如HttpOnly)设为true - - // 如果是expires字段,添加时间戳 - if (keyLower === 'expires' && value) { - try { - const timestamp = Date.parse(value); - if (!isNaN(timestamp)) { - cookieObj.expires_timestamp = timestamp; // 添加时间戳(毫秒) - } - } catch (e) { - // 如果日期解析失败,忽略时间戳字段 - } - } - } - } - - // Only add valid cookies to the map (overwrite if duplicate name) - if (cookieObj.name) { - cookieMap.set(cookieName, cookieObj); - } - } - } - - return Array.from(cookieMap.values()); - } - - private stringify(parsedCookie: CookieEntity[]): string { - return parsedCookie - .filter(cookie => cookie.value && cookie.value !== 'EXPIRED') - .map(cookie => `${cookie.name}=${cookie.value}`) - .join('; '); - } -} - -// 所有用户的 cookie 仓库 -class CookieStore { - // key 为 authKey, value 为 AccountCookie 实例 - // 使用 Map 的插入顺序特性实现 LRU 淘汰 - store: Map = new Map(); - - // 内存缓存最大条目数,防止无限增长 - private readonly maxSize: number = 1000; - - async getAccountCookie(authKey: string): Promise { - // 优先从本地内存取 - let cachedAccountCookie = this.store.get(authKey); - - if (cachedAccountCookie) { - // LRU: 访问时将条目移到末尾(最近使用) - this.store.delete(authKey); - this.store.set(authKey, cachedAccountCookie); - return cachedAccountCookie; - } - - // 如果内存没有,则从 kv 数据库取 - const cookieValue = await getMpCookie(authKey); - if (!cookieValue) { - return null; - } - - cachedAccountCookie = AccountCookie.create(cookieValue.token, cookieValue.cookies); - this.evictIfNeeded(); - this.store.set(authKey, cachedAccountCookie); - - return cachedAccountCookie; - } - - /** - * 检索用户的cookie - * @param authKey - * @return 适合作为请求头的Cookie字符串 - */ - async getCookie(authKey: string): Promise { - const accountCookie = await this.getAccountCookie(authKey); - if (!accountCookie) { - return null; - } - return accountCookie.toString(); - } - - /** - * 存储用户的cookie - * @param authKey - * @param token - * @param cookie 原始的 set-cookie 字符串数组 - */ - async setCookie(authKey: string, token: string, cookie: string[]): Promise { - const accountCookie = new AccountCookie(token, cookie); - // 如果已存在则先删除(保证 LRU 顺序正确) - this.store.delete(authKey); - this.evictIfNeeded(); - this.store.set(authKey, accountCookie); - return await setMpCookie(authKey, accountCookie.toJSON()); - } - - /** - * 移除用户的 cookie(用于登出等场景) - * @param authKey - */ - removeCookie(authKey: string): void { - this.store.delete(authKey); - } - - /** - * 当内存缓存达到上限时,淘汰最久未使用的条目 - */ - private evictIfNeeded(): void { - while (this.store.size >= this.maxSize) { - // Map 迭代器按插入顺序返回,第一个即为最久未使用 - const oldestKey = this.store.keys().next().value; - if (oldestKey !== undefined) { - this.store.delete(oldestKey); - } else { - break; - } - } - } - - /** - * 检索用户的 token - * @param authKey - */ - async getToken(authKey: string): Promise { - const accountCookie = await this.getAccountCookie(authKey); - if (!accountCookie) { - return null; - } - - return accountCookie.token; - } - - /** - * 转换为 json 格式,方便存储与传输 - * 返回一个对象,键为 uuid,值为解析后的 cookie 对象 - */ - toJSON(): Record { - const json: Record = {}; - for (const [authKey, accountCookie] of this.store) { - json[authKey] = accountCookie; - } - return json; - } -} - -export const cookieStore = new CookieStore(); - -/** - * 从 CookieStore 中获取 cookie 字符串 - * - * @description 根据请求中的 X-Auth-Key header 或者 auth-key cookie,从 CookieStore 中检索用户登录信息的 cookie,这些 cookie 会透传给微信 - * @param event - */ -export async function getCookieFromStore(event: H3Event): Promise { - let cookie: string | null = null; - - // 优先根据自定义的 X-Auth-Key 检索 - let authKey = getRequestHeader(event, 'X-Auth-Key'); - if (authKey) { - cookie = await cookieStore.getCookie(authKey); - if (cookie) { - return cookie; - } - } - - // 从 cookie 中的 token 检索 - const cookies = parseCookies(event); - authKey = cookies['auth-key']; - if (authKey) { - cookie = await cookieStore.getCookie(authKey); - if (cookie) { - return cookie; - } - } - - return null; -} - -/** - * 从 CookieStore 中获取公众号的 token - * - * @description 根据请求中的 X-Auth-Key header 或者 auth-key cookie,从 CookieStore 中检索用户登录时绑定的 token - * @param event - */ -export async function getTokenFromStore(event: H3Event): Promise { - let token: string | null = null; - - // 优先根据自定义的 X-Auth-Key 检索 - let authKey = getRequestHeader(event, 'X-Auth-Key'); - if (authKey) { - token = await cookieStore.getToken(authKey); - if (token) { - return token; - } - } - - // 从 cookie 中的 token 检索 - const cookies = parseCookies(event); - authKey = cookies['auth-key']; - if (authKey) { - token = await cookieStore.getToken(authKey); - if (token) { - return token; - } - } - - return null; -} - -/** - * 从请求中获取 cookie 字符串 - * - * @description 用于登录过程中 uuid cookie 透传给微信 - * @param event - */ -export function getCookiesFromRequest(event: H3Event): string { - const cookies = parseCookies(event); - return Object.keys(cookies) - .map(key => `${key}=${encodeURIComponent(cookies[key])}`) - .join(';'); -} - -/** - * 从 response 中获取指定的 set-cookie 的 value 部分 - * @param name cookie 名 - * @param response - */ -export function getCookieFromResponse(name: string, response: Response): string | null { - const cookies = AccountCookie.parse(response.headers.getSetCookie()); - const targetCookie = cookies.find(cookie => cookie.name === name); - if (targetCookie) { - return targetCookie.value as string; - } - return null; -} diff --git a/server/utils/proxy-request.ts b/server/utils/proxy-request.ts deleted file mode 100644 index 3d48ce56..00000000 --- a/server/utils/proxy-request.ts +++ /dev/null @@ -1,161 +0,0 @@ -import dayjs from 'dayjs'; -import { H3Event, parseCookies } from 'h3'; -import { v4 as uuidv4 } from 'uuid'; -import { isDev, USER_AGENT } from '~/config'; -import { RequestOptions } from '~/server/types'; -import { cookieStore, getCookieFromStore } from '~/server/utils/CookieStore'; -import { logRequest, logResponse } from '~/server/utils/logger'; - -/** - * 代理微信公众号请求 - * @description 备注:只有登录请求(`action=login`)中的 `set-cookie` 才会被写入到 CookieStore 中 - * @param options 请求参数 - */ -export async function proxyMpRequest(options: RequestOptions) { - const runtimeConfig = useRuntimeConfig(); - - const headers = new Headers({ - Referer: 'https://mp.weixin.qq.com/', - Origin: 'https://mp.weixin.qq.com', - 'User-Agent': USER_AGENT, - 'Accept-Encoding': 'identity', // 禁用压缩,避免出现response.clone() bug - }); - - // 优先读取参数中的 cookie,若无则从 CookieStore 中读取 - const cookie: string | null = options.cookie || (await getCookieFromStore(options.event)); - if (cookie) { - headers.set('Cookie', cookie); - } - - const requestInit: RequestInit = { - method: options.method, - headers: headers, - redirect: options.redirect || 'follow', - }; - - // 处理参数 - if (options.query) { - options.endpoint += '?' + new URLSearchParams(options.query as Record).toString(); - } - if (options.method === 'POST' && options.body) { - requestInit.body = new URLSearchParams(options.body as Record).toString(); - } - - // 构造请求 - const request = new Request(options.endpoint, requestInit); - - // 记录请求报文 - const requestId = uuidv4().replace(/-/g, ''); - if (process.env.NUXT_DEBUG_MP_REQUEST && isDev) { - await logRequest(requestId, request.clone()); - } - - // 转发请求 - const mpResponse = await fetch(request); - - // 记录响应报文 - if (process.env.NUXT_DEBUG_MP_REQUEST && isDev) { - await logResponse(requestId, mpResponse.clone()); - } - - let setCookies: string[] = []; - - // 处理登录请求的 uuid cookie - if (options.action === 'start_login') { - // 提取出 uuid 这个 cookie,并透传给客户端 - setCookies = mpResponse.headers.getSetCookie().filter(cookie => cookie.startsWith('uuid=')); - } - - // 处理登录成功请求的 cookie - // 只有登录请求才会将 Cookie 数据写入 CookieStore - // 返回给客户端的一个 auth-key 的 cookie - else if (options.action === 'login') { - // 提取出 token 和 cookies - try { - const authKey = crypto.randomUUID().replace(/-/g, ''); - - const body = await mpResponse.clone().json(); - const redirectUrl = body?.redirect_url; - if (!redirectUrl || typeof redirectUrl !== 'string') { - throw new Error(`登录响应中未找到 redirect_url,响应内容: ${JSON.stringify(body)}`); - } - - const token = new URL(`http://localhost${redirectUrl}`).searchParams.get('token'); - if (!token) { - throw new Error(`redirect_url 中未找到 token 参数: ${redirectUrl}`); - } - - console.log('token', token); - const success = await cookieStore.setCookie(authKey, token, mpResponse.headers.getSetCookie()); - if (!success) { - throw new Error('cookie 写入 KV 存储失败'); - } - console.log('cookie 写入成功'); - - setCookies = [ - `auth-key=${authKey}; Path=/; Expires=${dayjs().add(4, 'days').toString()}; Secure; HttpOnly`, - - // 登录成功后,删除浏览器的 uuid cookie - `uuid=EXPIRED; Path=/; Expires=${dayjs().subtract(1, 'days').toString()}; Secure; HttpOnly`, - ]; - } catch (error) { - console.error('action(login) failed:', error); - - // 登录失败时返回错误响应,而不是静默继续 - return new Response(JSON.stringify({ base_resp: { ret: -1, err_msg: `登录处理失败: ${error}` } }), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }); - } - } - - // 处理切换公众号的请求 - else if (options.action === 'switch_account') { - const authKey = getAuthKeyFromRequest(options.event); - if (authKey) { - setCookies = ['switch_account=1']; - } - } - - // 这里是否需要执行? - // 更新 CookieStore 中的 cookie - else { - // updateCookies(options.event, mpResponse.headers.getSetCookie()); - } - - // 构造返回给客户端的响应 - const responseHeaders = new Headers(mpResponse.headers); - responseHeaders.delete('set-cookie'); - setCookies.forEach(setCookie => { - responseHeaders.append('set-cookie', setCookie); - }); - - const finalResponse = new Response(mpResponse.body, { - status: mpResponse.status, - statusText: mpResponse.statusText, - headers: responseHeaders, - }); - - if (!options.parseJson) { - return finalResponse; - } else { - return finalResponse.json(); - } -} - -export function getAuthKeyFromRequest(event: H3Event): string { - let authKey = getRequestHeader(event, 'X-Auth-Key'); - if (!authKey) { - const cookies = parseCookies(event); - authKey = cookies['auth-key']; - } - - return authKey; -} - -// function updateCookies(event: H3Event, cookies: string[]): void { -// const authKey = getAuthKeyFromRequest(event); -// if (authKey) { -// cookieStore.updateCookie(authKey, cookies); -// } -// } diff --git a/server/utils/set-cookie.ts b/server/utils/set-cookie.ts new file mode 100644 index 00000000..c6bd5076 --- /dev/null +++ b/server/utils/set-cookie.ts @@ -0,0 +1,18 @@ +export function extractSetCookieValues(headers: Headers): string[] { + const native = headers.getSetCookie?.bind(headers); + if (native) { + const cookies = native(); + if (cookies.length > 0) return cookies; + } + + const raw = headers.get('set-cookie'); + if (!raw) return []; + + // Regex split that avoids breaking on comma-containing Expires dates like + // "Expires=Wed, 09 Jun 2027 10:18:14 GMT". The lookahead requires that the + // comma be followed by a key=value token (no spaces in key), which dates never satisfy. + return raw + .split(/,(?=\s*[^;,=\s]+=[^;,]+)/) + .map(c => c.trim()) + .filter(Boolean); +} diff --git a/test/api-core/auth-token-lifecycle.test.ts b/test/api-core/auth-token-lifecycle.test.ts new file mode 100644 index 00000000..3ecdb49c --- /dev/null +++ b/test/api-core/auth-token-lifecycle.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; + +type StorageMap = Map; + +function createStorageRegistry() { + const stores = new Map(); + + return (name: string) => { + let store = stores.get(name); + if (!store) { + store = new Map(); + stores.set(name, store); + } + + return { + async get(key: string): Promise { + return store!.has(key) ? (store!.get(key) as T) : null; + }, + async set(key: string, value: T): Promise { + store!.set(key, value); + }, + async remove(key: string): Promise { + store!.delete(key); + }, + }; + }; +} + +function createEvent(headers: Record) { + return { + node: { + req: { + headers, + }, + res: {}, + }, + } as any; +} + +async function run() { + (globalThis as any).useStorage = createStorageRegistry(); + (globalThis as any).getRequestHeader = (event: any, name: string) => { + return event?.node?.req?.headers?.[name.toLowerCase()]; + }; + + const { + getTokenFromEvent, + issueApiTokenForAuthKey, + persistSession, + resolveAuthKeyFromEvent, + revokeSessionFromEvent, + } = await import('../../server/services/api/auth-session'); + + const authKey = 'auth-key-1'; + await persistSession(authKey, 'wechat-token-1', ['sessionid=abc; Path=/', 'uuid=EXPIRED; Path=/'], 1000); + + const apiToken = await issueApiTokenForAuthKey(authKey, 1000); + assert.ok(apiToken); + + const tokenEvent = createEvent({ + 'x-auth-key': apiToken!, + }); + + assert.equal(await resolveAuthKeyFromEvent(tokenEvent, 1000), authKey); + assert.equal(await getTokenFromEvent(tokenEvent, 1000), 'wechat-token-1'); + + await revokeSessionFromEvent(tokenEvent, 1000); + + assert.equal(await resolveAuthKeyFromEvent(tokenEvent, 1000), null); + assert.equal(await getTokenFromEvent(tokenEvent, 1000), null); + + console.log('auth-token lifecycle regression checks passed'); +} + +run(); diff --git a/test/api-core/mp-core.test.ts b/test/api-core/mp-core.test.ts new file mode 100644 index 00000000..3d5f34b8 --- /dev/null +++ b/test/api-core/mp-core.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; + +import { + buildAppMsgPublishParams, + buildSearchBizParams, + extractPublishedArticles, + filterSearchBizResponseByNickname, +} from '../../server/services/api/mp-core'; + +function run() { + assert.deepEqual(buildSearchBizParams({ token: 't-1', keyword: 'rail', begin: 2, size: 8 }), { + action: 'search_biz', + begin: 2, + count: 8, + query: 'rail', + token: 't-1', + lang: 'zh_CN', + f: 'json', + ajax: '1', + }); + + assert.deepEqual(buildAppMsgPublishParams({ token: 't-1', fakeid: 'f-1', begin: 0, size: 5 }), { + sub: 'list', + search_field: 'null', + begin: 0, + count: 5, + query: '', + fakeid: 'f-1', + type: '101_1', + free_publish_type: 1, + sub_action: 'list_ex', + token: 't-1', + lang: 'zh_CN', + f: 'json', + ajax: 1, + }); + + assert.equal(buildAppMsgPublishParams({ token: 't-1', fakeid: 'f-1', keyword: 'agent' }).sub, 'search'); + assert.equal(buildAppMsgPublishParams({ token: 't-1', fakeid: 'f-1', keyword: 'agent' }).search_field, '7'); + + const articles = extractPublishedArticles({ + publish_page: JSON.stringify({ + publish_list: [ + { publish_info: JSON.stringify({ appmsgex: [{ title: 'A' }, { title: 'B' }] }) }, + { publish_info: '' }, + { publish_info: JSON.stringify({ appmsgex: [{ title: 'C' }] }) }, + ], + }), + }); + + assert.deepEqual(articles, [{ title: 'A' }, { title: 'B' }, { title: 'C' }]); + + const originalResponse = { + base_resp: { ret: 0, err_msg: 'ok' }, + total: 2, + list: [{ nickname: 'A' }, { nickname: 'B' }], + }; + + const response = filterSearchBizResponseByNickname(originalResponse, 'C'); + assert.equal(response.base_resp.ret, -1); + assert.equal(response.base_resp.err_msg, '根据解析的名称搜索公众号失败'); + assert.equal(response.total, 0); + assert.equal(response.resolved_name, 'C'); + assert.deepEqual(response.original_resp, originalResponse); + + console.log('mp-core regression checks passed'); +} + +run(); diff --git a/test/api-core/session-core.test.ts b/test/api-core/session-core.test.ts new file mode 100644 index 00000000..fa86f7e9 --- /dev/null +++ b/test/api-core/session-core.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; + +import { AccountSession, AuthSessionCache, computeAuthKeyExpiresAt } from '../../server/services/api/session-core'; + +function run() { + const session = AccountSession.fromSetCookieStrings('token-1', [ + 'foo=bar; Path=/; HttpOnly', + 'foo=baz; Path=/; Secure', + 'uuid=EXPIRED; Path=/', + ]); + + assert.equal(session.get('foo')?.value, 'baz'); + assert.equal(session.toCookieHeader().includes('foo=baz'), true); + assert.equal(session.toCookieHeader().includes('uuid=EXPIRED'), false); + + const expiringSession = AccountSession.fromSetCookieStrings('token-1', ['foo=bar; Path=/'], 1_000); + assert.equal(expiringSession.isExpiredAt(999), false); + assert.equal(expiringSession.isExpiredAt(1_000), true); + assert.equal(expiringSession.toCookieHeader(1_000), ''); + + const mixedExpirySession = AccountSession.fromSetCookieStrings('token-2', [ + 'foo=bar; Expires=Wed, 01 Jan 2025 00:00:00 GMT', + 'baz=qux; Expires=Wed, 01 Jan 2031 00:00:00 GMT', + ]); + assert.equal(mixedExpirySession.toCookieHeader(Date.parse('2026-01-01T00:00:00Z')), 'baz=qux'); + + assert.equal(computeAuthKeyExpiresAt(10), 10 + 4 * 24 * 60 * 60 * 1000); + + const cache = new AuthSessionCache(10); + cache.set('auth-1', AccountSession.fromSetCookieStrings('token-1', ['foo=bar; Path=/'], 100)); + assert.equal(cache.get('auth-1', 101), null); + assert.deepEqual(cache.toJSON(), {}); + + console.log('session-core regression checks passed'); +} + +run(); diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 00000000..a84d9e06 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,7 @@ +name = "wechat-article-exporter" +compatibility_date = "2025-10-30" +pages_build_output_dir = "dist" + +[[kv_namespaces]] +binding = "KV" +id = "22d46ff673c946f1a5f3b790818b5618"