mirror of
https://github.com/wangwangit/SubsTracker.git
synced 2026-06-01 05:19:41 +08:00
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:
110
src/app.js
Normal file
110
src/app.js
Normal file
@@ -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;
|
||||
63
src/index.js
63
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:',
|
||||
|
||||
103
tests/api/routes-compat.test.js
Normal file
103
tests/api/routes-compat.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user