From a10c212989d9687d514b8d75ce02fd1bf8244406 Mon Sep 17 00:00:00 2001 From: wangwangit Date: Sun, 24 May 2026 18:14:07 +0800 Subject: [PATCH] =?UTF-8?q?refactor(api):=20=E5=BC=95=E5=85=A5=20Hono=20?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E4=BD=9C=E4=B8=BA=E8=B7=AF=E7=94=B1=E5=A4=96?= =?UTF-8?q?=E5=A3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/app.js 新建 Hono 应用: - 全局中间件:ensureMigrations(首次访问透明迁移)、onError 兜底 - 路由:GET /、GET /admin、ALL /admin/*、ALL /api/*、/debug、兜底 - path/method/response shape 与 v2 严格 1:1 兼容(前端零修改) src/index.js 简化为 { fetch: app.fetch, scheduled } 策略说明: - Hono 现阶段当"路由外壳"用,请求转发给现有 handler - 后续 Task 可逐 handler 改成 Hono 原生写法(c.json/c.req.json 等) - 这样既拿到中间件 / 错误兜底好处,又不破坏既有行为 vitest.config.js 增加 assetsInclude: ['**/*.html'] 让 vite 在测试中能解析 src/views/*.html 文本 import(与生产 wrangler text loader 行为一致) 测试 tests/api/routes-compat.test.js 8 条覆盖: - GET / 未登录 → 登录页 HTML - GET /admin 未登录 → 重定向 - GET /api/subscriptions 未登录 → 401 标准错误体 - POST /api/login 错误 body / 正确凭据 - GET /debug 未登录 → 401 - GET /api/未知 → 404 标准错误体 - 兜底路径 → 登录页 总计 158 条测试全绿;wrangler dry-run 476 KiB / gzip 101 KiB。 Refs Task 7 of refactor/v3-product-grade plan. --- src/app.js | 110 ++++++++++++++++++++++++++++++++ src/index.js | 63 ++++-------------- tests/api/routes-compat.test.js | 103 ++++++++++++++++++++++++++++++ vitest.config.js | 7 +- 4 files changed, 232 insertions(+), 51 deletions(-) create mode 100644 src/app.js create mode 100644 tests/api/routes-compat.test.js diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..d0297f1 --- /dev/null +++ b/src/app.js @@ -0,0 +1,110 @@ +// @ts-check +/** + * Hono 应用装配(v3) + * + * 设计目标: + * - 用 Hono 替换 v2 的手写 if/else 路由分发 + * - 引入中间件:迁移检查 / 日志 / 认证 / 错误处理 + * - 保持 path / method / response shape 与 v2 严格 1:1 兼容 + * 现有前端代码无需改动即可继续工作 + * + * 落地策略: + * - 现阶段(Task 7):Hono 充当"外壳路由器",把请求转发给现有 handler + * 后续 Task 可逐个把 handler 改成 Hono 原生写法,但当前优先保证不破坏。 + * + * 维护人:v3 重构 (2026-05) + */ + +import { Hono } from 'hono'; + +import { handleApiRequest } from './api/router.js'; +import { handleAdminRequest, handleLoginPage } from './api/admin.js'; +import { handleDebug } from './api/debug.js'; +import { getUserFromRequest } from './api/handlers/auth.js'; +import { ensureMigrations } from './data/migrate.js'; + +/** + * @typedef {{ SUBSCRIPTIONS_KV: KVNamespace }} Bindings + */ + +/** @type {Hono<{ Bindings: Bindings }>} */ +const app = new Hono(); + +// ───────────────────────────────────────────────────────────── +// 全局中间件:迁移检查(首次访问透明触发) +// ───────────────────────────────────────────────────────────── +app.use('*', async (c, next) => { + try { + await ensureMigrations(c.env); + } catch (err) { + console.error('[app] 迁移失败,回退继续处理请求:', err); + } + await next(); +}); + +// ───────────────────────────────────────────────────────────── +// 全局错误兜底 +// ───────────────────────────────────────────────────────────── +app.onError((err, c) => { + console.error('[app] 未捕获异常:', err && err.stack ? err.stack : err); + // 与 v2 错误格式保持一致 + return c.json( + { + success: false, + message: err && err.message ? err.message : '服务异常', + code: 'internal_error' + }, + 500 + ); +}); + +// ───────────────────────────────────────────────────────────── +// 路由:根路径 +// 已登录跳 /admin;未登录返回登录页 +// ───────────────────────────────────────────────────────────── +app.get('/', async (c) => { + const { user } = await getUserFromRequest(c.req.raw, c.env); + if (user) return c.redirect('/admin'); + return handleLoginPage(); +}); + +// ───────────────────────────────────────────────────────────── +// 路由:/debug(必须登录) +// ───────────────────────────────────────────────────────────── +app.all('/debug', async (c) => { + const { user } = await getUserFromRequest(c.req.raw, c.env); + if (!user) { + return new Response('未授权访问', { + status: 401, + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }); + } + return handleDebug(c.req.raw, c.env); +}); + +// ───────────────────────────────────────────────────────────── +// 路由:/api/*(认证由 handler 内部处理,与 v2 一致) +// ───────────────────────────────────────────────────────────── +app.all('/api/*', async (c) => { + return handleApiRequest(c.req.raw, c.env); +}); + +// ───────────────────────────────────────────────────────────── +// 路由:/admin/* +// ───────────────────────────────────────────────────────────── +app.all('/admin/*', async (c) => { + return handleAdminRequest(c.req.raw, c.env); +}); + +app.get('/admin', async (c) => { + return handleAdminRequest(c.req.raw, c.env); +}); + +// ───────────────────────────────────────────────────────────── +// 兜底:其他路径返回登录页(保留 v2 行为) +// ───────────────────────────────────────────────────────────── +app.all('*', async () => { + return handleLoginPage(); +}); + +export default app; diff --git a/src/index.js b/src/index.js index fe49942..4beed79 100644 --- a/src/index.js +++ b/src/index.js @@ -2,70 +2,33 @@ /** * Worker 入口(v3) * - * - fetch handler:处理 HTTP 请求;首先确保 KV 数据已迁移到 v3 schema - * - scheduled handler:每小时触发一次到期检查(cron 0 * * * * UTC) - * - * v3 起 schema 迁移由 src/data/migrate.js 自动完成(首次访问透明触发,幂等可重跑)。 - * - * 后续 Task 7 会把 fetch handler 整体迁到 Hono 应用,本文件届时大幅简化。 + * fetch handler 委托给 Hono 应用(src/app.js)。 + * scheduled handler 触发定时任务执行。 * * 维护人:v3 重构 (2026-05) */ -import { handleApiRequest } from './api/router.js'; -import { handleAdminRequest, handleLoginPage } from './api/admin.js'; -import { handleDebug } from './api/debug.js'; -import { checkExpiringSubscriptions } from './services/scheduler.js'; -import { getUserFromRequest } from './api/handlers/auth.js'; +import app from './app.js'; import { ensureMigrations } from './data/migrate.js'; +import { checkExpiringSubscriptions } from './services/scheduler.js'; export default { - async fetch(request, env, ctx) { - // 透明迁移:v3 schema 不到位时先迁移再处理请求 - try { - await ensureMigrations(env); - } catch (err) { - console.error('[index] 迁移失败,回退继续处理请求(用户会看到旧数据):', err); - } - - const url = new URL(request.url); - - if (url.pathname === '/') { - const { user } = await getUserFromRequest(request, env); - if (user) { - return new Response('', { - status: 302, - headers: { Location: '/admin' } - }); - } - return handleLoginPage(); - } else if (url.pathname === '/debug') { - // 调试页必须登录后才能访问,避免泄露系统信息 - const { user } = await getUserFromRequest(request, env); - if (!user) { - return new Response('未授权访问', { - status: 401, - headers: { 'Content-Type': 'text/plain; charset=utf-8' } - }); - } - return handleDebug(request, env); - } else if (url.pathname.startsWith('/api')) { - return handleApiRequest(request, env); - } else if (url.pathname.startsWith('/admin')) { - return handleAdminRequest(request, env); - } else { - return handleLoginPage(); - } - }, + fetch: app.fetch, + /** + * 每小时由 Cron 触发一次。 + * + * @param {ScheduledEvent} event + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @param {ExecutionContext} ctx + */ async scheduled(event, env, ctx) { - // Cron 触发也要确保迁移完成(首次部署后用户可能还没访问过页面) + void ctx; try { await ensureMigrations(env); } catch (err) { console.error('[index] scheduled 迁移失败:', err); } - console.log( '[Workers] 定时任务触发', 'cron:', diff --git a/tests/api/routes-compat.test.js b/tests/api/routes-compat.test.js new file mode 100644 index 0000000..996352e --- /dev/null +++ b/tests/api/routes-compat.test.js @@ -0,0 +1,103 @@ +// @ts-check +/** + * API 路由兼容性 smoke 测试 + * + * 验证 Hono 应用对 v2 路由表的 1:1 兼容: + * - 未授权时返回 401 + 同样的错误结构 + * - 公开端点(登录页、登录接口)行为一致 + * - 已知 4xx/5xx 错误格式与 v2 一致 + */ +import { describe, it, expect, beforeEach } from 'vitest'; +// @ts-ignore +import { env } from 'cloudflare:test'; +import app from '../../src/app.js'; + +async function clearKv() { + const list = await env.SUBSCRIPTIONS_KV.list(); + await Promise.all(list.keys.map((k) => env.SUBSCRIPTIONS_KV.delete(k.name))); +} + +beforeEach(clearKv); + +describe('Hono app 路由兼容', () => { + it('GET / 未登录返回登录页 HTML', async () => { + const res = await app.request('/', {}, env); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain(' { + const res = await app.request('/admin', {}, env); + expect([302, 200].includes(res.status)).toBe(true); + }); + + it('GET /api/subscriptions 未登录返回 401 + 标准错误体', async () => { + const res = await app.request('/api/subscriptions', {}, env); + expect(res.status).toBe(401); + const json = await res.json(); + expect(json.success).toBe(false); + expect(typeof json.message).toBe('string'); + }); + + it('POST /api/login 缺少 body → 400/401', async () => { + const res = await app.request('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not json' + }, env); + expect([400, 401].includes(res.status)).toBe(true); + const json = await res.json(); + expect(json.success).toBe(false); + }); + + it('POST /api/login 正确凭据 → success + Set-Cookie', async () => { + // 配置默认管理员 + await env.SUBSCRIPTIONS_KV.put( + 'config', + JSON.stringify({ ADMIN_USERNAME: 'admin', ADMIN_PASSWORD: 'password', JWT_SECRET: 'k' }) + ); + const res = await app.request('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'password' }) + }, env); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.success).toBe(true); + expect(res.headers.get('Set-Cookie') || '').toContain('token='); + }); + + it('GET /debug 未登录返回 401', async () => { + const res = await app.request('/debug', {}, env); + expect(res.status).toBe(401); + }); + + it('GET /api/未知端点 → 404 + 标准错误体', async () => { + // 先登录拿 cookie + await env.SUBSCRIPTIONS_KV.put( + 'config', + JSON.stringify({ ADMIN_USERNAME: 'admin', ADMIN_PASSWORD: 'password', JWT_SECRET: 'k' }) + ); + const loginRes = await app.request('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'password' }) + }, env); + const cookie = loginRes.headers.get('Set-Cookie')?.split(';')[0] || ''; + + const res = await app.request('/api/non-existent-route', { + headers: { Cookie: cookie } + }, env); + expect(res.status).toBe(404); + const json = await res.json(); + expect(json.success).toBe(false); + }); + + it('未匹配路径走兜底 → 返回登录页', async () => { + const res = await app.request('/random/path/xyz', {}, env); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('