From 42aa7fa1223386a6a9862a7b7ab716741f0b71c6 Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 00:40:54 +0800 Subject: [PATCH 01/14] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20Vercel=20?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=20+=20RESTful=20=E6=94=B9=E9=80=A0=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-09-27-vercel-restful-migration-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-27-vercel-restful-migration-design.md diff --git a/docs/superpowers/specs/2026-09-27-vercel-restful-migration-design.md b/docs/superpowers/specs/2026-09-27-vercel-restful-migration-design.md new file mode 100644 index 0000000..5612075 --- /dev/null +++ b/docs/superpowers/specs/2026-09-27-vercel-restful-migration-design.md @@ -0,0 +1,129 @@ +# Vercel 部署 + RESTful 改造设计 + +- 日期:2026-09-27 +- 状态:待用户 review +- 分支:`feat/proanimer` + +## 背景与目标 + +Meting-API 当前是 Bun 常驻进程服务(原生 `Bun.serve`),接口为查询串风格 +`GET /api?server=&type=&id=&auth=`。本次改造要达成两个目标: + +1. **方便 Vercel 部署**:把请求处理逻辑抽成框架无关的核心,使项目既能以 + Bun 常驻进程运行(本地 `bun run dev` / Docker),也能作为 Vercel + Serverless 函数部署。 +2. **RESTful 风格**:新增基于路径的 RESTful 接口作为主接口,同时保留旧查询串 + 接口向后兼容。 + +## 已确认的关键决策 + +1. **API 形态**:RESTful 为主 + 保留旧接口。demo 页与 Meting.js 继续使用旧 + 查询串接口,无需改动。 +2. **部署目标**:双适配 —— 共享核心逻辑,同时提供 Bun 入口与 Vercel 函数 + 入口。不采用纯 Vercel 重写,也不重新引入 Hono。 + +## 目标架构 + +``` +src/ + app.js # 新增:createApp() → (Request) => Response,组合 CORS+logger+error+router + router.js # 新增:路由解析(RESTful 路径 + legacy 查询串) + index.js # 保留:Bun 入口(Bun.serve + HTTPS) + service/api.js # 改造:抽出纯函数 resolve({server,type,id}),路由与业务解耦 + service/demo.js # 保留 + middleware/logger.js # 保留(Web 标准 API) + middleware/errors.js # 保留 + utils/cookie.js # 条件化:文件读取+fs.watch 仅在非 Vercel 环境启用 + utils/http-exception.js# 保留 + utils/lyric.js # 保留 +api/ + [...path].js # 新增:Vercel catch-all 函数 +vercel.json # 新增:路由 rewrite 配置 +``` + +### 数据流 + +``` +请求(Request, Web 标准) + → CORS → logger 中间件 → error 中间件 → router + → RESTful 路径解析 或 legacy 查询串解析 → {server, type, id, token} + → service/api.resolve(server, type, id) → 鉴权 → 缓存 → 调 @meting/core → URL 转换 + → 组装 Response +``` + +核心 `resolve` 函数对 Bun 与 Vercel 两端完全一致,仅入口适配器不同。 + +## RESTful 路由设计(新主接口) + +``` +GET /api/:server/search?keywords=周杰伦 # 搜索(关键词走 keywords 查询参数) +GET /api/:server/song/:id +GET /api/:server/album/:id +GET /api/:server/artist/:id +GET /api/:server/playlist/:id +GET /api/:server/lrc/:id # 需鉴权 +GET /api/:server/url/:id # 需鉴权 +GET /api/:server/pic/:id # 需鉴权 +``` + +设计要点: + +- `server` 白名单:`netease/tencent/kugou/baidu/kuwo` +- `search` 的关键词从 `id` 参数改名为 `keywords`(路径语义更清晰) +- `lrc/url/pic` 保持扁平 `/api/:server/url/:id`,**不做** `/song/:id/url` + 嵌套 —— 因为这三类资源使用的是 `x.lyric_id / x.url_id / x.pic_id`, + 与歌曲 `id` 不同,嵌套在语义上是错的 +- 鉴权仍走 `?token=` / `?auth=` 查询参数,HMAC-SHA1 公式不变: + `HMAC-SHA1(METING_TOKEN, "${server}${type}${id}")` +- 返回格式、状态码、`x-error-message` / `x-cache` 响应头行为不变 + +### legacy 接口(向后兼容,原样保留) + +``` +GET /api?server=&type=&id=&auth= +``` + +demo 页与 Meting.js 依赖此格式,保持不变。 + +## Vercel 适配 + +- **运行时**:Node.js Serverless Functions(默认),**不用** Edge Runtime + (`node:crypto` 与 `@meting/core` 依赖 Node API)。 +- **入口**:单个 `api/[...path].js` catch-all,复用同一份 `handle(request)`。 +- **路由**:`vercel.json` 将 `/api/*` 与 `/demo` 转发到该函数。 +- **环境变量**:`METING_TOKEN`、`METING_URL`(未设置时默认 + `https://${VERCEL_URL}`)、`METING_COOKIE_*`。 + +> 实现细节:Vercel Node.js 函数确切的 handler 签名(Web 标准 +> `Request → Response`,还是 `@vercel/node` 的 `(req, res)`)需在写代码前 +> 对照 Vercel 当前文档核实,据此决定 `api/[...path].js` 适配垫片的写法。 +> 不影响整体设计。 + +## Vercel 不兼容处置 + +| 现状问题 | 处置 | +|---------|------| +| 内存 LRU(冷启动/多实例间不共享) | 保留(对 Bun 有意义;Vercel 每个热实例内也有效,无害);另给 `url`/`pic`/列表响应加 `Cache-Control` 头,让 Vercel CDN 边缘缓存 | +| `fs.watch` + `cookie/` 文件读取 | 条件化:仅在非 Vercel 环境启用文件读取与监听;Vercel 上只走 `METING_COOKIE_*` 环境变量 | +| `pino-pretty` transport(fork 子进程) | 现有代码已是「非生产才用 pretty」,Vercel 生产自动走纯 JSON stdout,基本无需改 | + +## 其他 + +- CORS 抽进共享 handler,两端统一 +- HTTPS / HTTP 端口仅 Bun 路径保留;Vercel 由平台托管 TLS +- `HTTP_PREFIX`:Bun 继续支持;Vercel 用 `vercel.json` 处理 +- README 同步更新(现有 README 已过期,仍写着 Hono / yarn) + +## 测试 + +项目目前无测试套件。本次改造: + +1. 本地 `bun run dev` 冒烟验证:RESTful 路由、legacy 路由、demo 页 +2. 为 `auth`、路由解析、`lyric.js` 增加轻量单测(`bun test`) + +## 范围外(Out of scope) + +- 不重新引入框架(Hono / Express 等) +- 不做 Edge Runtime 版本 +- 不改变 @meting/core 的调用方式与上游数据格式 +- 不新增平台或 type From a66c563011f6c67ab8f41aa145f37c5c222faaf3 Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 00:46:11 +0800 Subject: [PATCH 02/14] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20Vercel=20+?= =?UTF-8?q?=20RESTful=20=E6=94=B9=E9=80=A0=E5=AE=9E=E7=8E=B0=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-09-27-vercel-restful-migration.md | 749 ++++++++++++++++++ 1 file changed, 749 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-vercel-restful-migration.md diff --git a/docs/superpowers/plans/2026-09-27-vercel-restful-migration.md b/docs/superpowers/plans/2026-09-27-vercel-restful-migration.md new file mode 100644 index 0000000..062a2a9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-vercel-restful-migration.md @@ -0,0 +1,749 @@ +# Vercel 部署 + RESTful 改造实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 Meting-API 从「Bun 常驻进程 + 查询串接口」改造为「框架无关核心 + Bun/Vercel 双适配 + RESTful 主接口(保留 legacy 向后兼容)」。 + +**Architecture:** 抽取 Web 标准 `Request → Response` 的共享 handler(`src/app.js`),组合 CORS + logger + error + router;`src/index.js`(Bun)和 `api/*.js`(Vercel)分别作为薄适配器调用它。路由层 `src/router.js` 同时解析 RESTful 路径与 legacy 查询串,统一收敛到 `resolve({server,type,id,token})`。 + +**Tech Stack:** Bun(本地)、Vercel Node.js Serverless Functions、`@meting/core`、`lru-cache`、`pino`、`bun:test`。 + +**Spec:** `docs/superpowers/specs/2026-09-27-vercel-restful-migration-design.md` + +## Global Constraints + +- `server` 白名单:`netease/tencent/kugou/baidu/kuwo` +- `type` 白名单:`song/album/search/artist/playlist/lrc/url/pic` +- HMAC-SHA1 公式:`HMAC-SHA1(METING_TOKEN, "${server}${type}${id}")`,token 默认 `'token'` +- 返回格式、状态码、`x-error-message`/`x-cache` 头行为不变 +- **列表响应中的 `url`/`pic`/`lrc` 回调 URL 保持 legacy 格式**(`/api?server=&type=&id=&auth=`),不得改成 RESTful,否则破坏 Meting.js +- Vercel 用 Node.js Serverless Runtime(非 Edge) +- 双适配:Bun 本地 + Vercel,共享核心逻辑 + +--- + +### Task 1: 抽取 `auth` 为可测纯函数并加测试 + +**Files:** +- Create: `src/service/auth.js` +- Modify: `src/service/api.js:139-141`(删除底部 `auth` 定义,改为 import) +- Test: `test/auth.test.js` +- Modify: `package.json`(加 `test` script) + +**Interfaces:** +- Produces: `auth(server, type, id, secret = config.meting.token) -> string`(HMAC-SHA1 hex,40 字符)。`secret` 可选,默认取 `config.meting.token`,供测试传固定值。 + +- [ ] **Step 1: 写失败测试** + +```js +// test/auth.test.js +import { test, expect } from 'bun:test' +import { auth } from '../src/service/auth.js' + +test('auth 生成确定性的 HMAC-SHA1 token', () => { + expect(auth('netease', 'url', '123', 'token')).toBe('463829e428bd90210d4bfe4f3d57bf8aace86736') + expect(auth('netease', 'lrc', '123', 'token')).toBe('85c08d777d3d1a42c0cc455c6cd4b2f41ebb0a0a') + expect(auth('tencent', 'pic', 'abc', 'token')).toBe('19e7755ed6d85e32892b1d5b02804a51f14dbc22') +}) + +test('auth 对不同输入产生不同结果', () => { + expect(auth('netease', 'url', '123', 'token')).not.toBe(auth('netease', 'url', '124', 'token')) + expect(auth('netease', 'url', '123', 'token')).not.toBe(auth('tencent', 'url', '123', 'token')) +}) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `bun test test/auth.test.js` +Expected: FAIL,报 `Cannot find module '../src/service/auth.js'` + +- [ ] **Step 3: 创建 `src/service/auth.js`** + +```js +import { createHmac } from 'node:crypto' +import config from '../config.js' + +/** + * 生成敏感接口(lrc/url/pic)的 HMAC-SHA1 鉴权 token + * @param {string} server 平台名 + * @param {string} type 操作类型 + * @param {string} id 资源 ID + * @param {string} [secret] 签名密钥,默认取配置 METING_TOKEN + * @returns {string} hex token + */ +export function auth (server, type, id, secret = config.meting.token) { + return createHmac('sha1', secret).update(`${server}${type}${id}`).digest('hex') +} +``` + +- [ ] **Step 4: 修改 `src/service/api.js` 引用 auth** + +在 `api.js` 顶部 import 区加入 `import { auth } from './auth.js'`,删除第 2 行的 `import { createHmac } from 'node:crypto'`(不再使用),并删除文件底部第 139-141 行的 `auth` 定义(第 131-136 行对 `auth(...)` 的调用保持不动)。 + +- [ ] **Step 5: 加 `test` script 到 package.json** + +```json +"scripts": { + "start": "bun run src/index.js", + "dev": "bun --watch run src/index.js", + "test": "bun test", + "lint": "oxlint ." +} +``` + +- [ ] **Step 6: 运行测试确认通过** + +Run: `bun test test/auth.test.js` +Expected: PASS(2 个测试) + +- [ ] **Step 7: 提交** + +```bash +git add src/service/auth.js src/service/api.js test/auth.test.js package.json +git commit -m "refactor: 抽取 auth 为可测纯函数并加单测" +``` + +--- + +### Task 2: 新增 `parseRoute` 纯路由解析器并加测试 + +**Files:** +- Create: `src/router.js`(本任务只加 `parseRoute`,`route()` 在 Task 4 加) +- Test: `test/router.test.js` + +**Interfaces:** +- Produces: `parseRoute(pathname, prefix = config.http.prefix) -> { kind, server?, type?, id? }`,`kind ∈ { legacy, search, resource, demo, notfound }`。 + +- [ ] **Step 1: 写失败测试** + +```js +// test/router.test.js +import { test, expect } from 'bun:test' +import { parseRoute } from '../src/router.js' + +test('parseRoute 识别 legacy /api', () => { + expect(parseRoute('/api', '')).toEqual({ kind: 'legacy' }) + expect(parseRoute('/api/', '')).toEqual({ kind: 'legacy' }) +}) + +test('parseRoute 识别 RESTful 资源路径', () => { + expect(parseRoute('/api/netease/song/123', '')).toEqual({ kind: 'resource', server: 'netease', type: 'song', id: '123' }) + expect(parseRoute('/api/tencent/url/abc/', '')).toEqual({ kind: 'resource', server: 'tencent', type: 'url', id: 'abc' }) +}) + +test('parseRoute 识别 search 路径', () => { + expect(parseRoute('/api/netease/search', '')).toEqual({ kind: 'search', server: 'netease' }) +}) + +test('parseRoute 识别 demo', () => { + expect(parseRoute('/demo', '')).toEqual({ kind: 'demo' }) + expect(parseRoute('/api/demo', '')).toEqual({ kind: 'demo' }) +}) + +test('parseRoute 前缀与非法路径', () => { + expect(parseRoute('/myprefix/api/netease/song/123', '/myprefix')).toEqual({ kind: 'resource', server: 'netease', type: 'song', id: '123' }) + expect(parseRoute('/api/netease', '')).toEqual({ kind: 'notfound' }) + expect(parseRoute('/api/netease/song', '')).toEqual({ kind: 'notfound' }) + expect(parseRoute('/api/netease/song/123/extra', '')).toEqual({ kind: 'notfound' }) + expect(parseRoute('/other', '')).toEqual({ kind: 'notfound' }) +}) +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `bun test test/router.test.js` +Expected: FAIL,报 `Cannot find module '../src/router.js'` + +- [ ] **Step 3: 创建 `src/router.js`(仅 parseRoute)** + +```js +import config from '../config.js' + +/** + * 纯函数:把请求路径解析为路由描述,供 router 与测试复用。 + * 归一化顺序:去掉 prefix → 去掉末尾斜杠 → 匹配。 + */ +export function parseRoute (pathname, prefix = config.http.prefix) { + let p = pathname + if (prefix && p.startsWith(prefix)) { + p = p.slice(prefix.length) || '/' + } + if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1) + + if (p === '/demo') return { kind: 'demo' } + if (p === '/api') return { kind: 'legacy' } + + if (p.startsWith('/api/')) { + const segs = p.slice('/api/'.length).split('/').filter(Boolean) + if (segs.length === 1 && segs[0] === 'demo') return { kind: 'demo' } + if (segs.length === 2 && segs[1] === 'search') return { kind: 'search', server: segs[0] } + if (segs.length === 3) return { kind: 'resource', server: segs[0], type: segs[1], id: segs[2] } + } + + return { kind: 'notfound' } +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `bun test test/router.test.js` +Expected: PASS(5 个测试) + +- [ ] **Step 5: 提交** + +```bash +git add src/router.js test/router.test.js +git commit -m "feat: 新增 parseRoute 纯路由解析器并加单测" +``` + +--- + +### Task 3: 为 `lyric.js` 加单测 + +**Files:** +- Test: `test/lyric.test.js` + +**Interfaces:** +- Consumes: `format(lyric, tlyric) -> string`(已存在,`src/utils/lyric.js`) + +- [ ] **Step 1: 写测试** + +```js +// test/lyric.test.js +import { test, expect } from 'bun:test' +import { format } from '../src/utils/lyric.js' + +test('format 合并原文与翻译', () => { + const lyric = '[00:00.000]第一句\n[00:05.000]第二句' + const tlyric = '[00:00.000]First\n[00:05.000]Second' + expect(format(lyric, tlyric)).toBe('[00:00.000]第一句 (First)\n[00:05.000]第二句 (Second)') +}) + +test('format 无翻译时原样返回原文', () => { + const lyric = '[00:00.000]第一句\n[00:05.000]第二句' + expect(format(lyric, '')).toBe(lyric) +}) +``` + +- [ ] **Step 2: 运行测试确认通过** + +Run: `bun test test/lyric.test.js` +Expected: PASS(2 个测试) + +- [ ] **Step 3: 提交** + +```bash +git add test/lyric.test.js +git commit -m "test: 为 lyric.js 加单测" +``` + +--- + +### Task 4: 核心重构 —— Bun 适配到共享 handler + +**Files:** +- Modify: `src/service/api.js`(默认导出 `(request, ctx)` → 具名 `resolve(request, ctx, params)`;加 Cache-Control) +- Modify: `src/router.js`(补上 `route(request, ctx)`) +- Create: `src/app.js` +- Modify: `src/index.js`(改用 `createApp()`) + +**Interfaces:** +- Consumes: `auth`(Task 1)、`parseRoute`(Task 2) +- Produces: `resolve(request, ctx, { server, type, id, token }) -> Response`;`route(request, ctx) -> Response`;`createApp() -> async (request) => Response` + +- [ ] **Step 1: 重写 `src/service/api.js`** + +将文件整体替换为以下内容(参数解析移到 router,`resolve` 只接收已解析的 `{server,type,id,token}`;列表回调 URL 保持 legacy 格式;各响应加 `Cache-Control`): + +```js +import Meting from '@meting/core' +import { HTTPException } from '../utils/http-exception.js' +import config from '../config.js' +import { format as lyricFormat } from '../utils/lyric.js' +import { readCookieFile, isAllowedHost } from '../utils/cookie.js' +import { auth } from './auth.js' +import { LRUCache } from 'lru-cache' + +const cache = new LRUCache({ + max: 1000, + ttl: 1000 * 30 +}) + +const METING_METHODS = { + search: 'search', + song: 'song', + album: 'album', + artist: 'artist', + playlist: 'playlist', + lrc: 'lyric', + url: 'url', + pic: 'pic' +} + +export async function resolve (request, ctx, { server, type, id, token }) { + // 1. 校验参数 + if (!['netease', 'tencent', 'kugou', 'baidu', 'kuwo'].includes(server)) { + throw new HTTPException(400, { message: 'server 参数不合法' }) + } + if (!['song', 'album', 'search', 'artist', 'playlist', 'lrc', 'url', 'pic'].includes(type)) { + throw new HTTPException(400, { message: 'type 参数不合法' }) + } + + // 2. 鉴权 + if (['lrc', 'url', 'pic'].includes(type)) { + if (auth(server, type, id) !== token) { + throw new HTTPException(401, { message: '鉴权失败,非法调用' }) + } + } + + // 3. 调用 API(缓存) + const cacheKey = `${server}/${type}/${id}` + let data = cache.get(cacheKey) + if (data === undefined) { + ctx.responseHeaders.set('x-cache', 'miss') + const meting = new Meting(server) + meting.format(true) + + const referrer = request.headers.get('referer') + if (isAllowedHost(referrer)) { + const cookie = await readCookieFile(server) + if (cookie) { + meting.cookie(cookie) + } + } + + const method = METING_METHODS[type] + let response + try { + response = await meting[method](id) + } catch { + throw new HTTPException(500, { message: '上游 API 调用失败' }) + } + try { + data = JSON.parse(response) + } catch { + throw new HTTPException(500, { message: '上游 API 返回格式异常' }) + } + cache.set(cacheKey, data, { + ttl: type === 'url' ? 1000 * 60 * 10 : 1000 * 60 * 60 + }) + } + + // 4. 组装结果 + if (type === 'url') { + let url = data.url + if (!url) { + return new Response(null, { status: 404 }) + } + if (server === 'netease') { + url = url + .replace('://m7c.', '://m7.') + .replace('://m8c.', '://m8.') + .replace('http://', 'https://') + if (url.includes('vuutv=')) { + const tempUrl = new URL(url) + tempUrl.search = '' + url = tempUrl.toString() + } + } + if (server === 'tencent') { + url = url + .replace('http://', 'https://') + .replace('://ws.stream.qqmusic.qq.com', '://dl.stream.qqmusic.qq.com') + } + if (server === 'baidu') { + url = url + .replace('http://zhangmenshiting.qianqian.com', 'https://gss3.baidu.com/y0s1hSulBw92lNKgpU_Z2jR7b2w6buu') + } + return new Response(null, { + status: 302, + headers: { location: url, 'cache-control': 'public, max-age=600' } + }) + } + + if (type === 'pic') { + const url = data.url + if (!url) { + return new Response(null, { status: 404 }) + } + return new Response(null, { + status: 302, + headers: { location: url, 'cache-control': 'public, max-age=600' } + }) + } + + if (type === 'lrc') { + return new Response(lyricFormat(data.lyric, data.tlyric || ''), { + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'public, max-age=3600' + } + }) + } + + return Response.json(data.map(x => { + return { + title: x.name, + author: x.artist.join(' / '), + url: `${config.meting.url}/api?server=${server}&type=url&id=${x.url_id}&auth=${auth(server, 'url', x.url_id)}`, + pic: `${config.meting.url}/api?server=${server}&type=pic&id=${x.pic_id}&auth=${auth(server, 'pic', x.pic_id)}`, + lrc: `${config.meting.url}/api?server=${server}&type=lrc&id=${x.lyric_id}&auth=${auth(server, 'lrc', x.lyric_id)}` + } + }), { + headers: { 'cache-control': 'public, max-age=300, stale-while-revalidate=3600' } + }) +} +``` + +- [ ] **Step 2: 在 `src/router.js` 补上 `route()`** + +在 `parseRoute` 之后追加: + +```js +import { resolve } from './service/api.js' +import demoService from './service/demo.js' + +export async function route (request, ctx) { + const url = new URL(request.url) + const parsed = parseRoute(url.pathname) + + switch (parsed.kind) { + case 'legacy': { + const q = url.searchParams + return resolve(request, ctx, { + server: q.get('server') || 'netease', + type: q.get('type') || 'search', + id: q.get('id') || 'hello', + token: q.get('token') || q.get('auth') || 'token' + }) + } + case 'search': + return resolve(request, ctx, { + server: parsed.server, + type: 'search', + id: url.searchParams.get('keywords') || 'hello', + token: url.searchParams.get('token') || url.searchParams.get('auth') || 'token' + }) + case 'resource': + return resolve(request, ctx, { + server: parsed.server, + type: parsed.type, + id: parsed.id, + token: url.searchParams.get('token') || url.searchParams.get('auth') || 'token' + }) + case 'demo': + return demoService(request) + default: + return new Response('Not Found', { status: 404 }) + } +} +``` + +注意:`router.js` 顶部 import 区当前只有 `import config from '../config.js'`,需保持并在其下追加上述两个 import(ES Module 的 import 必须放文件顶部,合并为一份 import 块)。 + +- [ ] **Step 3: 创建 `src/app.js`** + +```js +import { withRequestLogger } from './middleware/logger.js' +import { withErrorHandler } from './middleware/errors.js' +import { route } from './router.js' + +const CORS_HEADERS = { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, HEAD, OPTIONS', + 'access-control-allow-headers': 'Content-Type', + 'access-control-max-age': '86400' +} + +function addCorsHeaders (response) { + const headers = new Headers(response.headers) + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value) + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers + }) +} + +export function createApp () { + const handler = withRequestLogger(withErrorHandler(route)) + return async (request) => { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: CORS_HEADERS }) + } + return addCorsHeaders(await handler(request)) + } +} +``` + +- [ ] **Step 4: 重写 `src/index.js`(Bun 适配器)** + +```js +import { readFileSync } from 'node:fs' +import { logger } from './middleware/logger.js' +import { createApp } from './app.js' +import config from './config.js' + +const app = createApp() + +Bun.serve({ + port: config.http.port, + fetch: app +}) + +logger.info({ port: config.http.port }, 'HTTP server started') + +if (config.https.enabled) { + if (!config.https.keyPath || !config.https.certPath) { + logger.error('HTTPS_ENABLED is true but SSL_KEY_PATH or SSL_CERT_PATH is not configured') + process.exit(1) + } + + let key + let cert + + try { + key = readFileSync(config.https.keyPath) + cert = readFileSync(config.https.certPath) + } catch (error) { + logger.error({ error: error.message }, 'Failed to read SSL certificate files') + process.exit(1) + } + + Bun.serve({ + port: config.https.port, + tls: { key, cert }, + fetch: app + }) + + logger.info({ port: config.https.port }, 'HTTPS server started') +} else { + logger.info('HTTPS server is disabled') +} +``` + +- [ ] **Step 5: 冒烟验证 Bun 服务** + +在终端 1 启动:`bun run dev` +在终端 2 验证(注意替换 `` 为实际值): + +```bash +# legacy 仍可用 +curl -s "http://localhost:80/api?server=netease&type=search&id=周杰伦" | head -c 200 +# RESTful 资源 +curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:80/api/netease/search?keywords=周杰伦" +# RESTful 带鉴权(先算 token,或直接测 401 分支) +curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:80/api/netease/url/xxx" +# demo 页 +curl -s "http://localhost:80/demo" | head -c 100 +``` + +Expected: +- legacy search 返回 JSON 数组(200) +- RESTful search 返回 200 +- RESTful url 不带正确 token 返回 401(或带 token 返回 302) +- demo 返回 HTML + +- [ ] **Step 6: 运行全部测试确认无回归** + +Run: `bun test` +Expected: 全部 PASS(9 个测试) + +- [ ] **Step 7: 提交** + +```bash +git add src/service/api.js src/router.js src/app.js src/index.js +git commit -m "refactor: 抽取共享 handler,Bun 适配器改用 createApp,新增 RESTful 路由" +``` + +--- + +### Task 5: Vercel 适配层 + +**Files:** +- Create: `api/index.js` +- Create: `api/[...path].js` +- Create: `vercel.json` +- Modify: `src/config.js`(`meting.url` 支持 VERCEL_URL 默认) +- Modify: `src/utils/cookie.js`(serverless 环境下跳过文件监听/读取) + +**Interfaces:** +- Consumes: `createApp`(Task 4) +- Produces: Vercel 函数入口导出 `GET`/`OPTIONS`(Web 标准签名) + +- [ ] **Step 1: 创建 `api/index.js`** + +```js +import { createApp } from '../src/app.js' + +const app = createApp() + +export const GET = app +export const OPTIONS = app +``` + +- [ ] **Step 2: 创建 `api/[...path].js`** + +```js +import { createApp } from '../src/app.js' + +const app = createApp() + +export const GET = app +export const OPTIONS = app +``` + +- [ ] **Step 3: 创建 `vercel.json`** + +```json +{ + "rewrites": [ + { "source": "/demo", "destination": "/api/demo" } + ] +} +``` + +- [ ] **Step 4: 修改 `src/config.js`** + +将 `meting.url` 一行改为: + +```js +url: process.env.METING_URL + || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : ''), +``` + +- [ ] **Step 5: 修改 `src/utils/cookie.js`** + +在顶部 `const COOKIE_TTL = ...` 之后加一行: + +```js +const isServerless = process.env.VERCEL === '1' +``` + +把文件底部的监听启动块: + +```js +if (!watcher) { + startWatcher().catch(() => {}) +} +``` + +改为: + +```js +if (!isServerless && !watcher) { + startWatcher().catch(() => {}) +} +``` + +并在 `readCookieFile` 内、环境变量判断 `if (envCookie) {...}` 之后、文件读取之前,插入 serverless 短路: + +```js +if (isServerless) { + cookieCache.set(server, { value: '', timestamp: now }) + return '' +} +``` + +- [ ] **Step 6: 本地语法/打包冒烟(无需登录)** + +Run: `bun run lint` +Expected: 0 errors(如 oxlint 对 `api/*.js` 的 `process.env.VERCEL` 无意见) + +- [ ] **Step 7: 提交** + +```bash +git add api/index.js "api/[...path].js" vercel.json src/config.js src/utils/cookie.js +git commit -m "feat: 新增 Vercel serverless 适配层" +``` + +> 说明:Vercel 上真正的运行时验证需 `vercel dev` 或部署(Vercel CLI + 登录)。本任务交付后由用户自行 `vercel` 部署,或后续单独验证。 + +--- + +### Task 6: 文档更新 + +**Files:** +- Modify: `README.md` +- Modify: `CLAUDE.md` + +**Interfaces:** 无(纯文档) + +- [ ] **Step 1: 更新 `README.md`** + +具体改动: +1. 顶部「基于 Hono.js」改为「基于原生 Web 标准 Request/Response,可同时部署为 Bun 常驻进程或 Vercel Serverless 函数」;特性列表加「☁️ Vercel 一键部署」。 +2. 把 `yarn install` / `yarn dev` / `yarn start` 改为 `bun install` / `bun run dev` / `bun run start`。 +3. 在「API 接口文档」章节新增「RESTful 接口」小节,列出: + ``` + GET /api/:server/search?keywords=xxx + GET /api/:server/song/:id + GET /api/:server/album/:id + GET /api/:server/artist/:id + GET /api/:server/playlist/:id + GET /api/:server/lrc/:id (需 token) + GET /api/:server/url/:id (需 token) + GET /api/:server/pic/:id (需 token) + ``` + 并说明 legacy `/api?server=&type=&id=` 仍可用。 +4. 新增「Vercel 部署」小节:说明 `api/` 目录自动识别为 Serverless Functions,需设置环境变量 `METING_TOKEN`(可选 `METING_URL`,未设时自动取 Vercel 域名)。 +5. 技术栈一节把「运行时 Node.js 22+ / Hono / hash.js」改为「Bun + Vercel Node.js Runtime / 原生 fetch API / node:crypto」。 +6. 「开发」一节把「ESLint Standard / yarn lint」改为「oxlint / bun run lint」。 + +- [ ] **Step 2: 更新 `CLAUDE.md`** + +具体改动: +1. 「核心架构」请求处理链图更新为 `Bun.serve / Vercel 函数 → CORS → logger → error → router → service`。 +2. 「文件职责」表新增 `src/app.js`(共享 handler)、`src/router.js`(路由解析)、`src/service/auth.js`(鉴权)、`api/*.js`(Vercel 入口),并更新 `src/index.js` 职责为「Bun 适配器」。 +3. 「认证机制」补充 auth 已抽到 `src/service/auth.js`。 +4. 「环境变量」表新增 `VERCEL_URL`(Vercel 自动注入)说明,并注明 `METING_URL` 未设时回退到它。 +5. 「常用命令」补 `bun test`。 + +- [ ] **Step 3: 提交** + +```bash +git add README.md CLAUDE.md +git commit -m "docs: 更新 README 与 CLAUDE.md 反映双适配与 RESTful 接口" +``` + +--- + +### Task 7: 最终验证 + +**Files:** 无新增(仅验证) + +- [ ] **Step 1: 代码检查** + +Run: `bun run lint` +Expected: 0 errors + +- [ ] **Step 2: 全量测试** + +Run: `bun test` +Expected: 全部 PASS(9 个测试) + +- [ ] **Step 3: 完整冒烟(重启 Bun)** + +Run: `bun run dev` 后依次: +```bash +curl -s "http://localhost:80/api?server=netease&type=search&id=周杰伦" | head -c 200 +curl -s "http://localhost:80/api/netease/search?keywords=周杰伦" | head -c 200 +curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:80/api/netease/url/xxx" +curl -s "http://localhost:80/demo" | head -c 100 +``` +Expected:legacy 与 RESTful 均返回 200 JSON;url 无 token 返回 401;demo 返回 HTML。 + +- [ ] **Step 4: 检查 git 状态与提交遗漏** + +Run: `git status` +Expected: 无未提交改动(或仅剩用户预期的文件) + +--- + +## 执行备注 + +- 全部任务提交后可选择 `superpowers:subagent-driven-development`(逐任务派发子代理 + 审查)或 `superpowers:executing-plans`(本会话内批量执行 + 检查点)。 +- Vercel 运行时部署验证(Vercel CLI 登录、`vercel dev`、正式部署)需要用户账号,不在本计划内自动完成;Task 5 已把该验证标记为「交付后由用户执行」。 +- 若 Vercel 安装依赖时对 `bun.lock` 有异议,可在 `vercel.json` 加 `"installCommand": "npm install"` 或提交 `package-lock.json` 解决(本计划默认不处理)。 From a8e8606566e2d1e10d0d4443ccbbbc99254d6528 Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 00:49:55 +0800 Subject: [PATCH 03/14] =?UTF-8?q?refactor:=20=E6=8A=BD=E5=8F=96=20auth=20?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E6=B5=8B=E7=BA=AF=E5=87=BD=E6=95=B0=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/service/api.js | 6 +----- src/service/auth.js | 14 ++++++++++++++ test/auth.test.js | 13 +++++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 src/service/auth.js create mode 100644 test/auth.test.js diff --git a/package.json b/package.json index 58c1c33..e746e81 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "start": "bun run src/index.js", "dev": "bun --watch run src/index.js", + "test": "bun test", "lint": "oxlint ." }, "dependencies": { diff --git a/src/service/api.js b/src/service/api.js index 3c82d7e..1ad705c 100644 --- a/src/service/api.js +++ b/src/service/api.js @@ -1,7 +1,7 @@ import Meting from '@meting/core' -import { createHmac } from 'node:crypto' import { HTTPException } from '../utils/http-exception.js' import config from '../config.js' +import { auth } from './auth.js' import { format as lyricFormat } from '../utils/lyric.js' import { readCookieFile, isAllowedHost } from '../utils/cookie.js' import { LRUCache } from 'lru-cache' @@ -135,7 +135,3 @@ export default async (request, ctx) => { } })) } - -const auth = (server, type, id) => { - return createHmac('sha1', config.meting.token).update(`${server}${type}${id}`).digest('hex') -} diff --git a/src/service/auth.js b/src/service/auth.js new file mode 100644 index 0000000..4467cfa --- /dev/null +++ b/src/service/auth.js @@ -0,0 +1,14 @@ +import { createHmac } from 'node:crypto' +import config from '../config.js' + +/** + * 生成敏感接口(lrc/url/pic)的 HMAC-SHA1 鉴权 token + * @param {string} server 平台名 + * @param {string} type 操作类型 + * @param {string} id 资源 ID + * @param {string} [secret] 签名密钥,默认取配置 METING_TOKEN + * @returns {string} hex token + */ +export function auth (server, type, id, secret = config.meting.token) { + return createHmac('sha1', secret).update(`${server}${type}${id}`).digest('hex') +} diff --git a/test/auth.test.js b/test/auth.test.js new file mode 100644 index 0000000..86fea1e --- /dev/null +++ b/test/auth.test.js @@ -0,0 +1,13 @@ +import { test, expect } from 'bun:test' +import { auth } from '../src/service/auth.js' + +test('auth 生成确定性的 HMAC-SHA1 token', () => { + expect(auth('netease', 'url', '123', 'token')).toBe('463829e428bd90210d4bfe4f3d57bf8aace86736') + expect(auth('netease', 'lrc', '123', 'token')).toBe('85c08d777d3d1a42c0cc455c6cd4b2f41ebb0a0a') + expect(auth('tencent', 'pic', 'abc', 'token')).toBe('19e7755ed6d85e32892b1d5b02804a51f14dbc22') +}) + +test('auth 对不同输入产生不同结果', () => { + expect(auth('netease', 'url', '123', 'token')).not.toBe(auth('netease', 'url', '124', 'token')) + expect(auth('netease', 'url', '123', 'token')).not.toBe(auth('tencent', 'url', '123', 'token')) +}) From d148db54521cd633b5e6df6846301df0699cec6d Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 00:52:46 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20parseRoute?= =?UTF-8?q?=20=E7=BA=AF=E8=B7=AF=E7=94=B1=E8=A7=A3=E6=9E=90=E5=99=A8?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/router.js | 25 +++++++++++++++++++++++++ test/router.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/router.js create mode 100644 test/router.test.js diff --git a/src/router.js b/src/router.js new file mode 100644 index 0000000..01d8280 --- /dev/null +++ b/src/router.js @@ -0,0 +1,25 @@ +import config from './config.js' + +/** + * 纯函数:把请求路径解析为路由描述,供 router 与测试复用。 + * 归一化顺序:去掉 prefix → 去掉末尾斜杠 → 匹配。 + */ +export function parseRoute (pathname, prefix = config.http.prefix) { + let p = pathname + if (prefix && p.startsWith(prefix)) { + p = p.slice(prefix.length) || '/' + } + if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1) + + if (p === '/demo') return { kind: 'demo' } + if (p === '/api') return { kind: 'legacy' } + + if (p.startsWith('/api/')) { + const segs = p.slice('/api/'.length).split('/').filter(Boolean) + if (segs.length === 1 && segs[0] === 'demo') return { kind: 'demo' } + if (segs.length === 2 && segs[1] === 'search') return { kind: 'search', server: segs[0] } + if (segs.length === 3) return { kind: 'resource', server: segs[0], type: segs[1], id: segs[2] } + } + + return { kind: 'notfound' } +} diff --git a/test/router.test.js b/test/router.test.js new file mode 100644 index 0000000..7f0b8b6 --- /dev/null +++ b/test/router.test.js @@ -0,0 +1,29 @@ +import { test, expect } from 'bun:test' +import { parseRoute } from '../src/router.js' + +test('parseRoute 识别 legacy /api', () => { + expect(parseRoute('/api', '')).toEqual({ kind: 'legacy' }) + expect(parseRoute('/api/', '')).toEqual({ kind: 'legacy' }) +}) + +test('parseRoute 识别 RESTful 资源路径', () => { + expect(parseRoute('/api/netease/song/123', '')).toEqual({ kind: 'resource', server: 'netease', type: 'song', id: '123' }) + expect(parseRoute('/api/tencent/url/abc/', '')).toEqual({ kind: 'resource', server: 'tencent', type: 'url', id: 'abc' }) +}) + +test('parseRoute 识别 search 路径', () => { + expect(parseRoute('/api/netease/search', '')).toEqual({ kind: 'search', server: 'netease' }) +}) + +test('parseRoute 识别 demo', () => { + expect(parseRoute('/demo', '')).toEqual({ kind: 'demo' }) + expect(parseRoute('/api/demo', '')).toEqual({ kind: 'demo' }) +}) + +test('parseRoute 前缀与非法路径', () => { + expect(parseRoute('/myprefix/api/netease/song/123', '/myprefix')).toEqual({ kind: 'resource', server: 'netease', type: 'song', id: '123' }) + expect(parseRoute('/api/netease', '')).toEqual({ kind: 'notfound' }) + expect(parseRoute('/api/netease/song', '')).toEqual({ kind: 'notfound' }) + expect(parseRoute('/api/netease/song/123/extra', '')).toEqual({ kind: 'notfound' }) + expect(parseRoute('/other', '')).toEqual({ kind: 'notfound' }) +}) From 106c42bfca7bd1e6aef8b25494635e59e7a72a53 Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 00:54:29 +0800 Subject: [PATCH 05/14] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E4=B8=AD=20router.js=20=E7=9A=84=20import=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=BA=20./config.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-09-27-vercel-restful-migration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-09-27-vercel-restful-migration.md b/docs/superpowers/plans/2026-09-27-vercel-restful-migration.md index 062a2a9..833f6d3 100644 --- a/docs/superpowers/plans/2026-09-27-vercel-restful-migration.md +++ b/docs/superpowers/plans/2026-09-27-vercel-restful-migration.md @@ -157,7 +157,7 @@ Expected: FAIL,报 `Cannot find module '../src/router.js'` - [ ] **Step 3: 创建 `src/router.js`(仅 parseRoute)** ```js -import config from '../config.js' +import config from './config.js' /** * 纯函数:把请求路径解析为路由描述,供 router 与测试复用。 @@ -439,7 +439,7 @@ export async function route (request, ctx) { } ``` -注意:`router.js` 顶部 import 区当前只有 `import config from '../config.js'`,需保持并在其下追加上述两个 import(ES Module 的 import 必须放文件顶部,合并为一份 import 块)。 +注意:`router.js` 顶部 import 区当前只有 `import config from './config.js'`,需保持并在其下追加上述两个 import(ES Module 的 import 必须放文件顶部,合并为一份 import 块)。 - [ ] **Step 3: 创建 `src/app.js`** From 78b14dc8f05b11337f3f9a158a71fc3e638d3061 Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 00:57:01 +0800 Subject: [PATCH 06/14] =?UTF-8?q?test:=20=E4=B8=BA=20lyric.js=20=E5=8A=A0?= =?UTF-8?q?=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/lyric.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 test/lyric.test.js diff --git a/test/lyric.test.js b/test/lyric.test.js new file mode 100644 index 0000000..a1afe44 --- /dev/null +++ b/test/lyric.test.js @@ -0,0 +1,13 @@ +import { test, expect } from 'bun:test' +import { format } from '../src/utils/lyric.js' + +test('format 合并原文与翻译', () => { + const lyric = '[00:00.000]第一句\n[00:05.000]第二句' + const tlyric = '[00:00.000]First\n[00:05.000]Second' + expect(format(lyric, tlyric)).toBe('[00:00.000]第一句 (First)\n[00:05.000]第二句 (Second)') +}) + +test('format 无翻译时原样返回原文', () => { + const lyric = '[00:00.000]第一句\n[00:05.000]第二句' + expect(format(lyric, '')).toBe(lyric) +}) From 43ceba72a30208369378eebf0f3e39aab6fcbded Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 01:00:21 +0800 Subject: [PATCH 07/14] =?UTF-8?q?refactor:=20=E6=8A=BD=E5=8F=96=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=20handler,Bun=20=E9=80=82=E9=85=8D=E5=99=A8=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20createApp,=E6=96=B0=E5=A2=9E=20RESTful=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.js | 32 ++++++++++++++++++++++++ src/index.js | 61 ++++------------------------------------------ src/router.js | 37 ++++++++++++++++++++++++++++ src/service/api.js | 44 ++++++++++++++++----------------- 4 files changed, 96 insertions(+), 78 deletions(-) create mode 100644 src/app.js diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..1115521 --- /dev/null +++ b/src/app.js @@ -0,0 +1,32 @@ +import { withRequestLogger } from './middleware/logger.js' +import { withErrorHandler } from './middleware/errors.js' +import { route } from './router.js' + +const CORS_HEADERS = { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, HEAD, OPTIONS', + 'access-control-allow-headers': 'Content-Type', + 'access-control-max-age': '86400' +} + +function addCorsHeaders (response) { + const headers = new Headers(response.headers) + for (const [key, value] of Object.entries(CORS_HEADERS)) { + headers.set(key, value) + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers + }) +} + +export function createApp () { + const handler = withRequestLogger(withErrorHandler(route)) + return async (request) => { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: CORS_HEADERS }) + } + return addCorsHeaders(await handler(request)) + } +} diff --git a/src/index.js b/src/index.js index a82b8f5..5559f1d 100644 --- a/src/index.js +++ b/src/index.js @@ -1,62 +1,17 @@ import { readFileSync } from 'node:fs' -import { withRequestLogger, logger } from './middleware/logger.js' -import { withErrorHandler } from './middleware/errors.js' -import apiService from './service/api.js' -import demoService from './service/demo.js' +import { logger } from './middleware/logger.js' +import { createApp } from './app.js' import config from './config.js' -const CORS_HEADERS = { - 'access-control-allow-origin': '*', - 'access-control-allow-methods': 'GET, HEAD, OPTIONS', - 'access-control-allow-headers': 'Content-Type', - 'access-control-max-age': '86400' -} +const app = createApp() -function addCorsHeaders (response) { - const headers = new Headers(response.headers) - for (const [key, value] of Object.entries(CORS_HEADERS)) { - headers.set(key, value) - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers - }) -} - -// 路由调度 -async function router (request, ctx) { - const url = new URL(request.url) - const pathname = url.pathname - - if (request.method === 'GET' && pathname === `${config.http.prefix}/api`) { - return apiService(request, ctx) - } - if (request.method === 'GET' && pathname === `${config.http.prefix}/demo`) { - return demoService(request) - } - - return new Response('Not Found', { status: 404 }) -} - -// 组合中间件: logger -> error handler -> router -const handler = withRequestLogger(withErrorHandler(router)) - -// HTTP 服务器 Bun.serve({ port: config.http.port, - async fetch (request) { - if (request.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: CORS_HEADERS }) - } - const response = await handler(request) - return addCorsHeaders(response) - } + fetch: app }) logger.info({ port: config.http.port }, 'HTTP server started') -// HTTPS 服务器 if (config.https.enabled) { if (!config.https.keyPath || !config.https.certPath) { logger.error('HTTPS_ENABLED is true but SSL_KEY_PATH or SSL_CERT_PATH is not configured') @@ -77,13 +32,7 @@ if (config.https.enabled) { Bun.serve({ port: config.https.port, tls: { key, cert }, - async fetch (request) { - if (request.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: CORS_HEADERS }) - } - const response = await handler(request) - return addCorsHeaders(response) - } + fetch: app }) logger.info({ port: config.https.port }, 'HTTPS server started') diff --git a/src/router.js b/src/router.js index 01d8280..bb10e9f 100644 --- a/src/router.js +++ b/src/router.js @@ -1,4 +1,6 @@ import config from './config.js' +import { resolve } from './service/api.js' +import demoService from './service/demo.js' /** * 纯函数:把请求路径解析为路由描述,供 router 与测试复用。 @@ -23,3 +25,38 @@ export function parseRoute (pathname, prefix = config.http.prefix) { return { kind: 'notfound' } } + +export async function route (request, ctx) { + const url = new URL(request.url) + const parsed = parseRoute(url.pathname) + + switch (parsed.kind) { + case 'legacy': { + const q = url.searchParams + return resolve(request, ctx, { + server: q.get('server') || 'netease', + type: q.get('type') || 'search', + id: q.get('id') || 'hello', + token: q.get('token') || q.get('auth') || 'token' + }) + } + case 'search': + return resolve(request, ctx, { + server: parsed.server, + type: 'search', + id: url.searchParams.get('keywords') || 'hello', + token: url.searchParams.get('token') || url.searchParams.get('auth') || 'token' + }) + case 'resource': + return resolve(request, ctx, { + server: parsed.server, + type: parsed.type, + id: parsed.id, + token: url.searchParams.get('token') || url.searchParams.get('auth') || 'token' + }) + case 'demo': + return demoService(request) + default: + return new Response('Not Found', { status: 404 }) + } +} diff --git a/src/service/api.js b/src/service/api.js index 1ad705c..094f846 100644 --- a/src/service/api.js +++ b/src/service/api.js @@ -1,15 +1,16 @@ import Meting from '@meting/core' import { HTTPException } from '../utils/http-exception.js' import config from '../config.js' -import { auth } from './auth.js' import { format as lyricFormat } from '../utils/lyric.js' import { readCookieFile, isAllowedHost } from '../utils/cookie.js' +import { auth } from './auth.js' import { LRUCache } from 'lru-cache' const cache = new LRUCache({ max: 1000, ttl: 1000 * 30 }) + const METING_METHODS = { search: 'search', song: 'song', @@ -21,16 +22,8 @@ const METING_METHODS = { pic: 'pic' } -export default async (request, ctx) => { - // 1. 初始化参数 - const url = new URL(request.url) - const query = Object.fromEntries(url.searchParams) - const server = query.server || 'netease' - const type = query.type || 'search' - const id = query.id || 'hello' - const token = query.token || query.auth || 'token' - - // 2. 校验参数 +export async function resolve (request, ctx, { server, type, id, token }) { + // 1. 校验参数 if (!['netease', 'tencent', 'kugou', 'baidu', 'kuwo'].includes(server)) { throw new HTTPException(400, { message: 'server 参数不合法' }) } @@ -38,14 +31,14 @@ export default async (request, ctx) => { throw new HTTPException(400, { message: 'type 参数不合法' }) } - // 3. 鉴权 + // 2. 鉴权 if (['lrc', 'url', 'pic'].includes(type)) { if (auth(server, type, id) !== token) { throw new HTTPException(401, { message: '鉴权失败,非法调用' }) } } - // 4. 调用 API + // 3. 调用 API(缓存) const cacheKey = `${server}/${type}/${id}` let data = cache.get(cacheKey) if (data === undefined) { @@ -53,7 +46,6 @@ export default async (request, ctx) => { const meting = new Meting(server) meting.format(true) - // 检查 referrer 并配置 cookie const referrer = request.headers.get('referer') if (isAllowedHost(referrer)) { const cookie = await readCookieFile(server) @@ -79,14 +71,12 @@ export default async (request, ctx) => { }) } - // 5. 组装结果 + // 4. 组装结果 if (type === 'url') { let url = data.url - // 空结果返回 404 if (!url) { return new Response(null, { status: 404 }) } - // 链接转换 if (server === 'netease') { url = url .replace('://m7c.', '://m7.') @@ -107,21 +97,29 @@ export default async (request, ctx) => { url = url .replace('http://zhangmenshiting.qianqian.com', 'https://gss3.baidu.com/y0s1hSulBw92lNKgpU_Z2jR7b2w6buu') } - return new Response(null, { status: 302, headers: { location: url } }) + return new Response(null, { + status: 302, + headers: { location: url, 'cache-control': 'public, max-age=600' } + }) } if (type === 'pic') { const url = data.url - // 空结果返回 404 if (!url) { return new Response(null, { status: 404 }) } - return new Response(null, { status: 302, headers: { location: url } }) + return new Response(null, { + status: 302, + headers: { location: url, 'cache-control': 'public, max-age=600' } + }) } if (type === 'lrc') { return new Response(lyricFormat(data.lyric, data.tlyric || ''), { - headers: { 'content-type': 'text/plain; charset=utf-8' } + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'cache-control': 'public, max-age=3600' + } }) } @@ -133,5 +131,7 @@ export default async (request, ctx) => { pic: `${config.meting.url}/api?server=${server}&type=pic&id=${x.pic_id}&auth=${auth(server, 'pic', x.pic_id)}`, lrc: `${config.meting.url}/api?server=${server}&type=lrc&id=${x.lyric_id}&auth=${auth(server, 'lrc', x.lyric_id)}` } - })) + }), { + headers: { 'cache-control': 'public, max-age=300, stale-while-revalidate=3600' } + }) } From 0af5520c0d1f14d3c20bcc720ebd8bea76833155 Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 01:03:43 +0800 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Vercel=20ser?= =?UTF-8?q?verless=20=E9=80=82=E9=85=8D=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/[...path].js | 6 ++++++ api/index.js | 6 ++++++ src/config.js | 3 ++- src/utils/cookie.js | 8 +++++++- vercel.json | 5 +++++ 5 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 api/[...path].js create mode 100644 api/index.js create mode 100644 vercel.json diff --git a/api/[...path].js b/api/[...path].js new file mode 100644 index 0000000..c252bf0 --- /dev/null +++ b/api/[...path].js @@ -0,0 +1,6 @@ +import { createApp } from '../src/app.js' + +const app = createApp() + +export const GET = app +export const OPTIONS = app diff --git a/api/index.js b/api/index.js new file mode 100644 index 0000000..c252bf0 --- /dev/null +++ b/api/index.js @@ -0,0 +1,6 @@ +import { createApp } from '../src/app.js' + +const app = createApp() + +export const GET = app +export const OPTIONS = app diff --git a/src/config.js b/src/config.js index 4f11d9d..25d5bfe 100644 --- a/src/config.js +++ b/src/config.js @@ -20,7 +20,8 @@ export default { certPath: process.env.SSL_CERT_PATH || '' }, meting: { - url: process.env.METING_URL || '', + url: process.env.METING_URL + || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : ''), token: process.env.METING_TOKEN || 'token', cookie: { allowHosts: process.env.METING_COOKIE_ALLOW_HOSTS diff --git a/src/utils/cookie.js b/src/utils/cookie.js index 57ce4a3..da61ff1 100644 --- a/src/utils/cookie.js +++ b/src/utils/cookie.js @@ -6,6 +6,7 @@ import config from '../config.js' // Cookie 缓存 const cookieCache = new Map() const COOKIE_TTL = 1000 * 60 * 5 // 5分钟缓存过期 +const isServerless = process.env.VERCEL === '1' // 启动文件监听 const cookieDir = resolve(process.cwd(), 'cookie') @@ -26,7 +27,7 @@ async function startWatcher () { } // 启动监听(仅启动一次) -if (!watcher) { +if (!isServerless && !watcher) { startWatcher().catch(() => {}) } @@ -57,6 +58,11 @@ export async function readCookieFile (server) { return value } + if (isServerless) { + cookieCache.set(server, { value: '', timestamp: now }) + return '' + } + // 从文件读取 try { const cookiePath = resolve(process.cwd(), 'cookie', server) diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..3699221 --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + { "source": "/demo", "destination": "/api/demo" } + ] +} From b1ae2ed76b84bd7d91ebc5f6af74f796826b025d Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 01:07:51 +0800 Subject: [PATCH 09/14] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20README=20?= =?UTF-8?q?=E4=B8=8E=20CLAUDE.md=20=E5=8F=8D=E6=98=A0=E5=8F=8C=E9=80=82?= =?UTF-8?q?=E9=85=8D=E4=B8=8E=20RESTful=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 32 ++++++++++++++++++++----------- README.md | 57 +++++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 24c11e5..f60216c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,17 +18,20 @@ bun run start # 代码检查(oxlint) bun run lint +# 单元测试 +bun test + # Docker 构建与运行 docker build -t meting-api . docker run -p 80:80 -e METING_URL=https://example.com -e METING_TOKEN=secret meting-api ``` -项目没有测试套件。 +单元测试位于 `test/` 目录(auth/lyric/router),使用 Bun 内置测试运行器。 ## 技术栈 -- **运行时**: Bun (ES Module) -- **HTTP 服务**: 原生 Bun.serve API(非框架) +- **运行时**: Bun (ES Module);Vercel 部署时为 Node.js Serverless Runtime(非 Edge) +- **HTTP 服务**: 原生 Bun.serve API(非框架),共享 handler 基于 Web 标准 `Request`/`Response` - **核心库**: @meting/core ^1.6.0(音乐 API 封装) - **缓存**: lru-cache ^11.x - **日志**: pino(JSON 格式)+ pino-pretty(开发环境) @@ -39,14 +42,15 @@ docker run -p 80:80 -e METING_URL=https://example.com -e METING_TOKEN=secret met ### 请求处理链 -入口 `src/index.js` 使用 Bun.serve 启动 HTTP/HTTPS 服务器。中间件按函数组合模式串联: +入口 `src/index.js` 使用 Bun.serve 启动 HTTP/HTTPS 服务器(Vercel 部署时由 `api/` 下的函数作为入口)。中间件按函数组合模式串联: ``` -Bun.serve → CORS 处理 → logger 中间件 → error 中间件 → router → service +Bun.serve / Vercel 函数 → CORS 处理 → logger 中间件 → error 中间件 → router → service ``` -路由分派是手动 if-else(非框架路由器): -- `GET {prefix}/api` → `src/service/api.js`(核心 API) +路由解析在 `src/router.js`(纯函数 parseRoute + route handler,非框架路由器),支持两种形式: +- 传统 `GET {prefix}/api?server=&type=&id=` → `src/service/api.js`(核心 API) +- RESTful `GET {prefix}/api/:server/search` / `GET {prefix}/api/:server/:type/:id` → 同上 - `GET {prefix}/demo` → `src/service/demo.js`(演示播放器页面) - 其他 → 404 @@ -54,10 +58,15 @@ Bun.serve → CORS 处理 → logger 中间件 → error 中间件 → router | 文件 | 职责 | |------|------| -| `src/index.js` | 应用入口,Bun.serve 启动,CORS 处理,路由分派,中间件组合 | +| `src/index.js` | Bun 适配器:Bun.serve 启动 HTTP/HTTPS 服务器,复用共享 handler | +| `src/app.js` | 共享 handler:`createApp()` 组合 CORS + logger + error + router,兼容 Bun 与 Vercel | +| `src/router.js` | 路由解析:纯函数 `parseRoute`(路径→路由描述)与 `route` handler,支持传统与 RESTful 两种形式 | | `src/config.js` | 环境变量解析为结构化配置对象 | -| `src/service/api.js` | 核心业务:参数校验→鉴权→缓存→调用上游API→URL转换→响应组装 | +| `src/service/api.js` | 核心业务:参数校验→鉴权→缓存→调用上游API→URL转换→响应组装(导出 `resolve`) | +| `src/service/auth.js` | 鉴权:生成敏感接口(lrc/url/pic)的 HMAC-SHA1 token | | `src/service/demo.js` | 返回嵌入 APlayer + Meting.js 的 HTML 演示页 | +| `api/index.js` | Vercel 入口(根路由) | +| `api/[...path].js` | Vercel 入口(catch-all 路由,兜底 RESTful 路径) | | `src/middleware/logger.js` | 请求日志:生成 requestId,记录响应时间和状态码 | | `src/middleware/errors.js` | 统一异常捕获,通过 `x-error-message` 响应头传递错误信息 | | `src/utils/cookie.js` | Cookie 读取(环境变量优先,文件次之),5分钟缓存,referrer 白名单校验 | @@ -68,7 +77,7 @@ Bun.serve → CORS 处理 → logger 中间件 → error 中间件 → router 敏感操作(lrc、url、pic)使用 HMAC-SHA1 token 认证: - token 计算: `HMAC-SHA1(METING_TOKEN, "${server}${type}${id}")` -- auth 函数在 `src/service/api.js:139` +- auth 函数已抽取到 `src/service/auth.js`(由 `src/service/api.js` 引入调用) - 认证参数通过查询字符串 `token` 或 `auth` 传递 ### 缓存策略 @@ -103,7 +112,8 @@ Cookie 支持两种来源(优先级从高到低): | `HTTPS_PORT` | HTTPS 端口 | `443` | | `SSL_KEY_PATH` | HTTPS 私钥路径 | - | | `SSL_CERT_PATH` | HTTPS 证书路径 | - | -| `METING_URL` | 公网访问地址(用于生成回调 URL) | - | +| `METING_URL` | 公网访问地址(用于生成回调 URL) | -(未设置时回退到 `https://${VERCEL_URL}`) | +| `VERCEL_URL` | Vercel 自动注入的域名 | -(仅 Vercel 环境存在) | | `METING_TOKEN` | HMAC 签名密钥 | `token` | | `METING_COOKIE_ALLOW_HOSTS` | Cookie referrer 白名单(逗号分隔) | `` (不限制) | | `METING_COOKIE_{SERVER}` | 各平台 Cookie(NETEASE/TENCENT/KUGOU/BAIDU/KUWO) | - | diff --git a/README.md b/README.md index c3b1e20..7fa9e57 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # Meting-API -基于 Hono.js 的多平台音乐 API 代理服务,封装 [@meting/core](https://www.npmjs.com/package/@meting/core) 提供的统一音乐 API。 +基于原生 Web 标准 `Request`/`Response` 的多平台音乐 API 代理服务,可同时部署为 Bun 常驻进程或 Vercel Serverless 函数,封装 [@meting/core](https://www.npmjs.com/package/@meting/core) 提供的统一音乐 API。 ## 特性 - 🎵 支持多个音乐平台:网易云、QQ音乐、酷狗、百度、酷我 -- 🚀 基于 Hono.js 高性能框架 +- 🚀 基于原生 Web 标准 Request/Response,零框架依赖 +- ☁️ Vercel 一键部署 - 💾 内置 LRU 缓存机制,减少上游 API 调用 - 🔐 HMAC-SHA1 令牌鉴权,保护敏感接口 - 🐳 Docker 部署支持 @@ -34,17 +35,17 @@ ```bash # 安装依赖 -yarn install +bun install # 配置环境变量(可选) cp .env.example .env # 编辑 .env 文件配置参数 # 开发模式(热重载) -yarn dev +bun run dev # 生产模式 -yarn start +bun run start ``` ### Docker 部署 @@ -77,6 +78,15 @@ services: restart: unless-stopped ``` +### Vercel 部署 + +项目内置 Vercel Serverless 适配层,`api/` 目录会被自动识别为 Serverless Functions(Node.js Runtime,非 Edge)。 + +1. 将仓库导入 Vercel(或使用 CLI `vercel deploy`) +2. 配置环境变量: + - `METING_TOKEN`(建议必填):HMAC 签名密钥 + - `METING_URL`(可选):公网访问地址,未设置时自动回退到 `https://${VERCEL_URL}`(`VERCEL_URL` 由 Vercel 自动注入,无需手动配置) + ## HTTPS 配置 ### 开发环境 @@ -98,7 +108,7 @@ openssl req -x509 -nodes -days 365 \ HTTPS_ENABLED=true \ SSL_KEY_PATH=certs/local.key \ SSL_CERT_PATH=certs/local.crt \ -yarn start +bun run start ``` ### 生产环境 @@ -163,6 +173,23 @@ docker run -d \ GET /api ``` +### RESTful 接口 + +除了下述传统接口,项目还提供 RESTful 风格路由: + +``` +GET /api/:server/search?keywords=xxx +GET /api/:server/song/:id +GET /api/:server/album/:id +GET /api/:server/artist/:id +GET /api/:server/playlist/:id +GET /api/:server/lrc/:id (需 token) +GET /api/:server/url/:id (需 token) +GET /api/:server/pic/:id (需 token) +``` + +其中 `:server` 取值为 `netease`/`tencent`/`kugou`/`baidu`/`kuwo`。传统接口 `GET /api?server=&type=&id=` 仍然可用(演示页与 Meting.js 依赖它)。 + ### 请求参数 | 参数 | 类型 | 必填 | 说明 | @@ -334,20 +361,26 @@ API 返回标准 HTTP 状态码: ### 代码规范 -项目使用 ESLint Standard 规范: +项目使用 oxlint 进行代码检查: + +```bash +bun run lint +``` + +运行单元测试: ```bash -yarn lint +bun test ``` ### 技术栈 -- **运行时**: Node.js 22+ (ES Module) -- **框架**: [Hono](https://hono.dev/) 4.x -- **核心库**: [@meting/core](https://www.npmjs.com/package/@meting/core) 1.5+ +- **运行时**: Bun(本地常驻进程)+ Vercel Node.js Runtime(Serverless,非 Edge) +- **HTTP 服务**: 原生 fetch API(Web 标准 `Request`/`Response`,无框架) +- **核心库**: [@meting/core](https://www.npmjs.com/package/@meting/core) 1.6+ - **缓存**: lru-cache 11.x - **日志**: pino (JSON 格式) -- **加密**: hash.js (HMAC-SHA1) +- **加密**: Node.js 内置 `node:crypto` (HMAC-SHA1) ## 许可证 From e75334d2a5e666d103a9e63a53d4559fd2ee5616 Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 01:19:23 +0800 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=20resolve=20?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95=E5=B9=B6=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=20GET=20=E6=96=B9=E6=B3=95=E9=97=A8=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.js | 3 +++ src/service/api.js | 20 +++++++++++--------- test/api.test.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 test/api.test.js diff --git a/src/app.js b/src/app.js index 1115521..b7ee3bb 100644 --- a/src/app.js +++ b/src/app.js @@ -27,6 +27,9 @@ export function createApp () { if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: CORS_HEADERS }) } + if (request.method !== 'GET') { + return addCorsHeaders(new Response('Not Found', { status: 404 })) + } return addCorsHeaders(await handler(request)) } } diff --git a/src/service/api.js b/src/service/api.js index 094f846..98a51ae 100644 --- a/src/service/api.js +++ b/src/service/api.js @@ -22,6 +22,16 @@ const METING_METHODS = { pic: 'pic' } +export function buildItem (server, x) { + return { + title: x.name, + author: x.artist.join(' / '), + url: `${config.meting.url}/api?server=${server}&type=url&id=${x.url_id}&auth=${auth(server, 'url', x.url_id)}`, + pic: `${config.meting.url}/api?server=${server}&type=pic&id=${x.pic_id}&auth=${auth(server, 'pic', x.pic_id)}`, + lrc: `${config.meting.url}/api?server=${server}&type=lrc&id=${x.lyric_id}&auth=${auth(server, 'lrc', x.lyric_id)}` + } +} + export async function resolve (request, ctx, { server, type, id, token }) { // 1. 校验参数 if (!['netease', 'tencent', 'kugou', 'baidu', 'kuwo'].includes(server)) { @@ -123,15 +133,7 @@ export async function resolve (request, ctx, { server, type, id, token }) { }) } - return Response.json(data.map(x => { - return { - title: x.name, - author: x.artist.join(' / '), - url: `${config.meting.url}/api?server=${server}&type=url&id=${x.url_id}&auth=${auth(server, 'url', x.url_id)}`, - pic: `${config.meting.url}/api?server=${server}&type=pic&id=${x.pic_id}&auth=${auth(server, 'pic', x.pic_id)}`, - lrc: `${config.meting.url}/api?server=${server}&type=lrc&id=${x.lyric_id}&auth=${auth(server, 'lrc', x.lyric_id)}` - } - }), { + return Response.json(data.map(x => buildItem(server, x)), { headers: { 'cache-control': 'public, max-age=300, stale-while-revalidate=3600' } }) } diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..2bd09b4 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,44 @@ +import { test, expect } from 'bun:test' +import { resolve, buildItem } from '../src/service/api.js' +import { createApp } from '../src/app.js' + +test('resolve 非法 server 返回 400', async () => { + let status = 0 + try { + await resolve(new Request('http://x'), { responseHeaders: new Headers() }, { server: 'invalid', type: 'song', id: '1', token: 'token' }) + } catch (e) { status = e.status } + expect(status).toBe(400) +}) + +test('resolve 非法 type 返回 400', async () => { + let status = 0 + try { + await resolve(new Request('http://x'), { responseHeaders: new Headers() }, { server: 'netease', type: 'invalid', id: '1', token: 'token' }) + } catch (e) { status = e.status } + expect(status).toBe(400) +}) + +test('resolve 鉴权失败返回 401', async () => { + let status = 0 + try { + await resolve(new Request('http://x'), { responseHeaders: new Headers() }, { server: 'netease', type: 'url', id: '123', token: 'definitely-wrong' }) + } catch (e) { status = e.status } + expect(status).toBe(401) +}) + +test('buildItem 生成 legacy 格式回调 URL', () => { + const item = buildItem('netease', { name: '歌名', artist: ['甲', '乙'], url_id: '111', pic_id: '222', lyric_id: '333' }) + expect(item.title).toBe('歌名') + expect(item.author).toBe('甲 / 乙') + expect(item.url).toContain('/api?server=netease&type=url&id=111&auth=') + expect(item.pic).toContain('/api?server=netease&type=pic&id=222&auth=') + expect(item.lrc).toContain('/api?server=netease&type=lrc&id=333&auth=') + expect(item.url).not.toContain('/api/netease/url/') + expect(item.pic).not.toContain('/api/netease/pic/') + expect(item.lrc).not.toContain('/api/netease/lrc/') +}) + +test('createApp 非 GET 方法返回 404', async () => { + const res = await createApp()(new Request('http://x/api', { method: 'POST' })) + expect(res.status).toBe(404) +}) From c25e444e2916dbe9947902e345db8d4a03f4835e Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sun, 27 Sep 2026 01:24:49 +0800 Subject: [PATCH 11/14] =?UTF-8?q?docs:=20README=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E6=94=B9=E9=80=A0=E8=AF=B4=E6=98=8E=E4=B8=8E=20Vercel=20?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E9=A1=BB=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7fa9e57..0c93eff 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,17 @@ - 🐳 Docker 部署支持 - 📝 结构化 JSON 日志输出 +## 改造说明 + +本项目由单进程 Bun 服务改造为「Bun 常驻进程 + Vercel Serverless」双适配架构: + +- 请求处理逻辑抽为框架无关的 `createApp()`(`src/app.js`),Bun 与 Vercel 共用同一 handler +- 新增 RESTful 路径路由(`src/router.js`),传统查询串接口 `/api?server=&type=&id=` 完整保留 +- 鉴权逻辑抽为纯函数 `src/service/auth.js`(HMAC-SHA1) +- 新增 Vercel 入口 `api/index.js`、`api/[...path].js` 与 `vercel.json` +- 列表 / 歌词 / 资源响应附带 `Cache-Control`,便于 CDN 边缘缓存 +- 新增单元测试(`bun test`):鉴权、路由解析、歌词合并、核心契约 + ## 支持的平台 | 平台 | server 参数 | 说明 | @@ -84,9 +95,17 @@ services: 1. 将仓库导入 Vercel(或使用 CLI `vercel deploy`) 2. 配置环境变量: - - `METING_TOKEN`(建议必填):HMAC 签名密钥 + - `METING_TOKEN`(**必填**):HMAC 签名密钥。默认值为 `token`,公开部署务必改掉,否则任何人都能算出 token - `METING_URL`(可选):公网访问地址,未设置时自动回退到 `https://${VERCEL_URL}`(`VERCEL_URL` 由 Vercel 自动注入,无需手动配置) +**部署须知:** + +- **依赖安装**:仓库只提交了 `bun.lock`,Vercel 默认用 `npm install`(依赖 `^` 范围可能与本地 Bun 版本不一致)。建议二选一: + - 在 `vercel.json` 添加 `"installCommand": "bun install"`(若启用 Bun) + - 或运行一次 `npm install --package-lock-only` 提交 `package-lock.json` +- **Cookie**:Serverless 环境只支持环境变量 `METING_COOKIE_{SERVER}`,`cookie/` 目录文件方式不生效 +- **缓存**:LRU 缓存每实例独立、冷启动后重建;列表 / url / pic / lrc 响应已附带 `Cache-Control`,可借助 Vercel CDN 边缘缓存减少上游调用 + ## HTTPS 配置 ### 开发环境 From 64b3b2f86b0cdebb89e4d709bcb6898a18059d6d Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sat, 3 Oct 2026 22:01:29 +0800 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=E5=86=85=E7=BD=AE=E7=BD=91?= =?UTF-8?q?=E6=98=93=E4=BA=91=E5=85=9C=E5=BA=95=20Cookie,MUSIC=5FU=20?= =?UTF-8?q?=E4=BB=8E=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E4=BB=A5=E8=A7=A3=E6=9E=90=20VIP=20=E6=AD=8C=E6=9B=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 8 +++++--- README.md | 20 +++++++++++++++++--- src/utils/cookie.js | 18 ++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f60216c..dadeb47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,9 +96,10 @@ LRU 缓存(lru-cache),最多 1000 条,默认 TTL 30 秒: ### Cookie 管理 -Cookie 支持两种来源(优先级从高到低): -1. 环境变量 `METING_COOKIE_{SERVER}`(如 `METING_COOKIE_NETEASE`) -2. 文件系统 `./cookie/{server}` +Cookie 支持三种来源(优先级从高到低): +1. 环境变量 `METING_COOKIE_{SERVER}`(如 `METING_COOKIE_NETEASE`,完整 Cookie) +2. 环境变量 `MUSIC_U` + 内置客户端指纹(`DEFAULT_COOKIES.netease`,MUSIC_U 从环境变量读取) +3. 文件系统 `./cookie/{server}` 通过 `METING_COOKIE_ALLOW_HOSTS` 限制哪些 referrer 来源可使用 Cookie。 @@ -117,6 +118,7 @@ Cookie 支持两种来源(优先级从高到低): | `METING_TOKEN` | HMAC 签名密钥 | `token` | | `METING_COOKIE_ALLOW_HOSTS` | Cookie referrer 白名单(逗号分隔) | `` (不限制) | | `METING_COOKIE_{SERVER}` | 各平台 Cookie(NETEASE/TENCENT/KUGOU/BAIDU/KUWO) | - | +| `MUSIC_U` | 网易云登录凭证(浏览器 Cookie 中的 MUSIC_U 值,与内置客户端指纹拼成兜底 Cookie) | - | ## 开发注意事项 diff --git a/README.md b/README.md index 0c93eff..77abdbc 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ docker run -d \ | `METING_TOKEN` | HMAC 签名密钥 | `token` | | `METING_COOKIE_ALLOW_HOSTS` | 允许使用 cookie 的 referrer 域名白名单(逗号分隔) | `` (空,不限制) | | `METING_COOKIE_NETEASE` | 网易云音乐 Cookie | - | +| `MUSIC_U` | 网易云登录凭证(浏览器 Cookie 中的 MUSIC_U 值,与内置客户端指纹拼成兜底 Cookie) | - | | `METING_COOKIE_TENCENT` | QQ音乐 Cookie | - | | `METING_COOKIE_KUGOU` | 酷狗音乐 Cookie | - | | `METING_COOKIE_BAIDU` | 百度音乐 Cookie | - | @@ -309,7 +310,7 @@ const token = generateToken('netease', 'url', '123456'); ## Cookie 配置 -部分音乐平台的 API 需要登录态才能访问完整数据。可以通过以下两种方式配置 Cookie: +部分音乐平台的 API 需要登录态才能访问完整数据。可以通过以下方式配置 Cookie: ### 方式一:环境变量(推荐) @@ -339,10 +340,23 @@ cookie/ 每个文件存储对应平台的 Cookie 字符串。 +### 方式三:内置客户端指纹 + MUSIC_U 环境变量 + +项目在 `src/utils/cookie.js` 内置了网易云的客户端指纹(`os`/`osver`/`appver`/`channel` 等公开信息),而登录凭证 `MUSIC_U` 从环境变量读取,二者自动拼成完整 Cookie: + +```js +const DEFAULT_COOKIES = { + netease: 'os=pc; ...; MUSIC_U={MUSIC_U}; __remember_me=true' +} +``` + +使用时只需设置环境变量 `MUSIC_U=你的网易云登录凭证`(浏览器 Cookie 里的 `MUSIC_U` 值),即可解析 VIP 歌曲,无需把密钥写进代码。 + ### Cookie 优先级 -1. 优先从环境变量读取(`METING_COOKIE_NETEASE` 等) -2. 环境变量不存在时从文件读取(`cookie/netease` 等) +1. 环境变量 `METING_COOKIE_NETEASE`(完整 Cookie,最高优先) +2. 环境变量 `MUSIC_U` + 内置客户端指纹(未配 `METING_COOKIE_NETEASE` 时生效) +3. 文件 `cookie/netease`(兜底,仅本地/非 Serverless 环境) ### Cookie 缓存 diff --git a/src/utils/cookie.js b/src/utils/cookie.js index da61ff1..42b7104 100644 --- a/src/utils/cookie.js +++ b/src/utils/cookie.js @@ -8,6 +8,13 @@ const cookieCache = new Map() const COOKIE_TTL = 1000 * 60 * 5 // 5分钟缓存过期 const isServerless = process.env.VERCEL === '1' +// 内置兜底 Cookie 模板:当未配置 METING_COOKIE_{SERVER} 时使用。 +// 客户端指纹(os/osver/appver/channel)为公开信息,保留在代码里; +// 登录凭证 MUSIC_U 从环境变量读取,避免把密钥写死在代码里。 +const DEFAULT_COOKIES = { + netease: 'os=pc; osver=Microsoft-Windows-10-Professional-build-10586-64bit; appver=2.0.3.131777; channel=netease; MUSIC_U={MUSIC_U}; __remember_me=true' +} + // 启动文件监听 const cookieDir = resolve(process.cwd(), 'cookie') let watcher = null @@ -58,6 +65,17 @@ export async function readCookieFile (server) { return value } + // 未配置 METING_COOKIE_{SERVER} 时,用内置客户端指纹 + 环境变量 MUSIC_U 拼出兜底 Cookie + const template = DEFAULT_COOKIES[server] + if (template) { + const musicU = process.env.MUSIC_U + if (musicU) { + const value = template.replace('{MUSIC_U}', musicU.trim()) + cookieCache.set(server, { value, timestamp: now }) + return value + } + } + if (isServerless) { cookieCache.set(server, { value: '', timestamp: now }) return '' From 22bd290974136a515baa95ae59c44f6e72582122 Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sat, 3 Oct 2026 22:26:52 +0800 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20=E5=85=9C=E5=BA=95=20Cookie=20?= =?UTF-8?q?=E6=8C=87=E7=BA=B9=E6=94=B9=E4=B8=BA=20Android=20=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E7=A7=BB=E5=8A=A8=E7=AB=AF=20UA,=E6=B6=88=E9=99=A4=20?= =?UTF-8?q?-460=20=E9=A3=8E=E9=99=A9=E5=AF=BC=E8=87=B4=E7=9A=84=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/utils/cookie.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 77abdbc..1cba75b 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ cookie/ ```js const DEFAULT_COOKIES = { - netease: 'os=pc; ...; MUSIC_U={MUSIC_U}; __remember_me=true' + netease: 'os=android; ...; MUSIC_U={MUSIC_U}; __remember_me=true' } ``` diff --git a/src/utils/cookie.js b/src/utils/cookie.js index 42b7104..0c89e20 100644 --- a/src/utils/cookie.js +++ b/src/utils/cookie.js @@ -11,8 +11,10 @@ const isServerless = process.env.VERCEL === '1' // 内置兜底 Cookie 模板:当未配置 METING_COOKIE_{SERVER} 时使用。 // 客户端指纹(os/osver/appver/channel)为公开信息,保留在代码里; // 登录凭证 MUSIC_U 从环境变量读取,避免把密钥写死在代码里。 +// 注意:指纹必须与 @meting/core 内建的移动端 User-Agent(NeteaseMusic/8.7.01 Android)保持一致, +// 否则网易云会返回 -460「检测到网络环境存在风险」,导致所有请求上游调用失败(500)。 const DEFAULT_COOKIES = { - netease: 'os=pc; osver=Microsoft-Windows-10-Professional-build-10586-64bit; appver=2.0.3.131777; channel=netease; MUSIC_U={MUSIC_U}; __remember_me=true' + netease: 'os=android; osver=android; appver=8.7.01; channel=netease; MUSIC_U={MUSIC_U}; __remember_me=true' } // 启动文件监听 From 29c2584a7fe1a8b3087bf4ec7267779c38797bf5 Mon Sep 17 00:00:00 2001 From: proanimer <1322767102@qq.com> Date: Sat, 3 Oct 2026 22:32:18 +0800 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20Cookie=20=E6=94=B9=E7=94=A8=20PC?= =?UTF-8?q?=20=E7=8E=B0=E4=BB=A3=20appver=20=E6=B6=88=E9=99=A4=20-460,?= =?UTF-8?q?=E5=B9=B6=E5=85=9C=E5=BA=95=E4=B8=8A=E6=B8=B8=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E9=81=BF=E5=85=8D=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- src/service/api.js | 27 +++++++++++++++++---------- src/utils/cookie.js | 8 ++++---- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 1cba75b..3e9cbe2 100644 --- a/README.md +++ b/README.md @@ -342,11 +342,11 @@ cookie/ ### 方式三:内置客户端指纹 + MUSIC_U 环境变量 -项目在 `src/utils/cookie.js` 内置了网易云的客户端指纹(`os`/`osver`/`appver`/`channel` 等公开信息),而登录凭证 `MUSIC_U` 从环境变量读取,二者自动拼成完整 Cookie: +项目在 `src/utils/cookie.js` 内置了网易云的客户端指纹(`os`/`appver`/`channel` 等公开信息),而登录凭证 `MUSIC_U` 从环境变量读取,二者自动拼成完整 Cookie: ```js const DEFAULT_COOKIES = { - netease: 'os=android; ...; MUSIC_U={MUSIC_U}; __remember_me=true' + netease: 'os=pc; ...; MUSIC_U={MUSIC_U}; __remember_me=true' } ``` diff --git a/src/service/api.js b/src/service/api.js index 98a51ae..b8cc9eb 100644 --- a/src/service/api.js +++ b/src/service/api.js @@ -65,20 +65,27 @@ export async function resolve (request, ctx, { server, type, id, token }) { } const method = METING_METHODS[type] - let response + let degraded = false try { - response = await meting[method](id) + data = JSON.parse(await meting[method](id)) } catch { - throw new HTTPException(500, { message: '上游 API 调用失败' }) + // 只有网络层故障(meting.error 非空:超时/DNS/连接失败)才视为真正的上游故障 + if (meting.error) { + throw new HTTPException(500, { message: '上游 API 调用失败' }) + } + // 上游返回了非预期格式(如 -460 风控、资源下架 404、字段缺失), + // 按空结果降级处理,避免把上游异常误报成本服务 500。 + degraded = true + data = type === 'lrc' + ? { lyric: '', tlyric: '' } + : (type === 'url' || type === 'pic') ? { url: '' } : [] } - try { - data = JSON.parse(response) - } catch { - throw new HTTPException(500, { message: '上游 API 返回格式异常' }) + // 降级结果不写缓存,避免短暂的风控/上游异常被缓存成「永久空结果」 + if (!degraded) { + cache.set(cacheKey, data, { + ttl: type === 'url' ? 1000 * 60 * 10 : 1000 * 60 * 60 + }) } - cache.set(cacheKey, data, { - ttl: type === 'url' ? 1000 * 60 * 10 : 1000 * 60 * 60 - }) } // 4. 组装结果 diff --git a/src/utils/cookie.js b/src/utils/cookie.js index 0c89e20..61ba43f 100644 --- a/src/utils/cookie.js +++ b/src/utils/cookie.js @@ -9,12 +9,12 @@ const COOKIE_TTL = 1000 * 60 * 5 // 5分钟缓存过期 const isServerless = process.env.VERCEL === '1' // 内置兜底 Cookie 模板:当未配置 METING_COOKIE_{SERVER} 时使用。 -// 客户端指纹(os/osver/appver/channel)为公开信息,保留在代码里; +// 客户端指纹(os/appver/channel)为公开信息,保留在代码里; // 登录凭证 MUSIC_U 从环境变量读取,避免把密钥写死在代码里。 -// 注意:指纹必须与 @meting/core 内建的移动端 User-Agent(NeteaseMusic/8.7.01 Android)保持一致, -// 否则网易云会返回 -460「检测到网络环境存在风险」,导致所有请求上游调用失败(500)。 +// 注意:os 用 pc(与浏览器登录产生的 MUSIC_U 同源);appver 必须是网易云当前仍在用的较新版本, +// 若用旧版 appver(如历史 meting.js 的 2.0.3.131777)会被网易云风控拒绝,返回 -460 导致 500。 const DEFAULT_COOKIES = { - netease: 'os=android; osver=android; appver=8.7.01; channel=netease; MUSIC_U={MUSIC_U}; __remember_me=true' + netease: 'os=pc; appver=8.9.70; channel=netease; MUSIC_U={MUSIC_U}; __remember_me=true' } // 启动文件监听