refactor(api): 引入 Hono 应用作为路由外壳

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.
This commit is contained in:
wangwangit
2026-05-24 18:14:07 +08:00
parent 0bb7e7670d
commit a10c212989
4 changed files with 232 additions and 51 deletions

110
src/app.js Normal file
View File

@@ -0,0 +1,110 @@
// @ts-check
/**
* Hono 应用装配v3
*
* 设计目标:
* - 用 Hono 替换 v2 的手写 if/else 路由分发
* - 引入中间件:迁移检查 / 日志 / 认证 / 错误处理
* - 保持 path / method / response shape 与 v2 严格 1:1 兼容
* 现有前端代码无需改动即可继续工作
*
* 落地策略:
* - 现阶段Task 7Hono 充当"外壳路由器",把请求转发给现有 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;

View File

@@ -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:',

View File

@@ -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('<!DOCTYPE html');
});
it('GET /admin 未登录跳回 / (302)', async () => {
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('<!DOCTYPE html');
});
});

View File

@@ -12,15 +12,20 @@
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
// 让 vite 把 .html 当作文本字符串 import与 wrangler 生产环境的 text loader 行为一致)
assetsInclude: ['**/*.html'],
test: {
include: ['tests/**/*.test.js'],
poolOptions: {
workers: {
// 测试环境最小 worker 配置:仅声明 KV 绑定供 repo 单测用
miniflare: {
compatibilityDate: '2024-09-23',
compatibilityFlags: ['nodejs_compat'],
kvNamespaces: ['SUBSCRIPTIONS_KV']
},
// 让生产环境用的 .html 文本 import 在测试中也能工作
wrangler: {
configPath: './wrangler.toml'
}
}
}