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 密钥 (确保当前登录信息有效)
-
-
当前密钥:
-
+
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"