Files
blog/blog-admin/src/index.ts
T
zqlit a74b3c7127
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped
归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
2026-10-04 08:45:40 +08:00

185 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Env } from './types';
import { Router } from './router';
import { corsHeaders } from './lib/util';
import { friendlyError } from './lib/errors';
import { gcCaptcha, gcRateLimits } from './lib/db';
import { checkProxyHealth, notifyProxyDown } from './lib/rss/proxy-health';
import { RssApi } from './rss-api';
import * as C from './routes/comments';
import * as P from './routes/public';
import * as U from './routes/user';
import * as A from './routes/admin';
import * as H from './routes/human';
const router = new Router();
// ---------------------------------------------------------------- 公共 / 配置
router.all('/conf', P.getConf);
router.get('/version', P.getVersion);
router.get('/conf/domain', P.getConfDomain);
router.get('/conf/auth/providers', P.getAuthProviders);
router.get('/healthz', P.healthz);
router.post('/setup', P.setup);
// ---------------------------------------------------------------- 验证码
router.get('/captcha', P.getCaptcha);
router.get('/captcha/status', P.getCaptchaStatus);
router.post('/captcha/verify', P.verifyCaptcha);
// ------------------------------------------------- 人机验证(自研「一键验证」)
router.get('/human/challenge', H.humanChallenge);
router.post('/human/verify', H.humanVerify);
router.get('/human/status', H.humanStatus);
// ---------------------------------------------------------------- 评论
router.get('/comments', C.listComments);
router.post('/comments', C.createComment);
router.get('/comments/:id', C.getComment);
router.put('/comments/:id', C.updateComment);
router.del('/comments/:id', C.deleteComment);
// ---------------------------------------------------------------- 投票 / 浏览量
router.get('/votes/:target_name/:target_id', P.voteGet);
router.post('/votes/:target_name/:target_id/:choice', P.voteCreate);
router.post('/votes/sync', P.voteSync);
router.post('/pages/pv', P.pagePV);
// ---------------------------------------------------------------- 用户 / 登录
router.get('/user', U.userInfo);
router.post('/user', U.userUpdate);
router.get('/user/status', U.userStatus);
router.post('/user/access_token', U.userAccessToken);
router.post('/auth/email/login', U.authEmailLogin);
router.post('/auth/email/send', U.authEmailSend);
router.post('/auth/email/register', U.authEmailRegister);
router.get('/auth/merge', U.authMergeCheck);
router.post('/auth/merge', U.authMergeApply);
router.post('/sso/exchange', U.ssoExchange);
// ---------------------------------------------------------------- 后台:设置
router.get('/settings', A.settingGet);
router.put('/settings', A.settingApply);
router.get('/settings/template/:locale', A.settingTemplate);
// ---------------------------------------------------------------- 后台:站点
router.get('/sites', A.siteList);
router.post('/sites', A.siteCreate);
router.put('/sites/:id', A.siteUpdate);
router.del('/sites/:id', A.siteDelete);
// ---------------------------------------------------------------- 后台:页面
router.get('/pages', A.pageList);
router.post('/pages/fetch', A.pageFetch);
router.get('/pages/fetch/status', A.pageFetchStatus);
router.post('/pages/:id/fetch', A.pageFetch);
router.put('/pages/:id', A.pageUpdate);
router.del('/pages/:id', A.pageDelete);
// ---------------------------------------------------------------- 后台:用户
router.post('/users', A.userCreate);
router.get('/users/:type', A.userList);
router.put('/users/:id', A.userUpdateAdmin);
router.del('/users/:id', A.userDeleteAdmin);
// ---------------------------------------------------------------- 后台:消息 / 统计 / 缓存
router.get('/notifies', A.notifyList);
router.post('/notifies/read', A.notifyReadAll);
router.post('/notifies/:comment_id/:notify_key', A.notifyReadOne);
router.get('/stats/:type', A.stat);
router.post('/cache/flush', A.cacheFlush);
router.post('/cache/warm_up', A.cacheWarmUp);
// ---------------------------------------------------------------- 后台:上传 / 迁移
router.post('/upload', A.upload);
router.get('/transfer/export', A.transferExport);
router.post('/transfer/import', A.transferImport);
router.post('/transfer/upload', A.transferUpload);
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
try {
// R2 里的图片:/file/** 或 /api/v2/file/**
if (req.method === 'GET' && /^(\/api\/v2)?\/file\//.test(url.pathname)) {
return A.serveFile({
env,
req,
url,
params: {},
user: null,
});
}
// RSS 订阅模块(rss-robot 合并进来):/api/*(Artalk 用 /api/v2/* 不会撞)
// + /feed 订阅源管理页 + 微信验证文件
const p = url.pathname;
if (
(p.startsWith('/api/') && !p.startsWith('/api/v2')) ||
p === '/feed' ||
p.startsWith('/MP_verify_')
) {
return RssApi.fetch(req, env, ctx);
}
return router.dispatch(req, env, ctx);
} catch (e) {
// 兜底:任何漏网的异常也必须带 CORS 头,否则浏览器把它当"网络故障",
// 读者只能看到 "TypeError: Failed to fetch",根本不知道发生了什么。
const err = friendlyError(e);
console.error('[artalk-cf] unhandled:', err.code, err.detail);
let cors = new Headers();
try {
cors = corsHeaders(req, env);
} catch {
cors.set('Access-Control-Allow-Origin', '*');
}
cors.set('Content-Type', 'application/json; charset=utf-8');
cors.set('Cache-Control', 'no-store');
return new Response(
JSON.stringify({ msg: err.msg, code: err.code, detail: err.detail }),
{ status: err.status, headers: cors },
);
}
},
// 定时任务:0 * * * * = RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源)
// 17 3 * * * = 评论 GC(限流/验证码/healthz 缓存)
async scheduled(event: ScheduledController, env: Env): Promise<void> {
if (event.cron === '0 * * * *') {
await RssApi.scheduled(event, env, undefined as unknown as ExecutionContext);
return;
}
try {
await gcRateLimits(env);
await gcCaptcha(env);
// 顺带刷新 healthz 用的评论数缓存:COUNT(*) 是全表扫描,
// 放在 healthz 里每次请求都烧几千行 rows_read,挪到这里一天只烧一次
const n = await env.DB.prepare('SELECT COUNT(*) AS n FROM comments WHERE deleted_at = 0')
.first<{ n: number }>();
if (n?.n != null) {
await env.DB.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES ('healthz_comments', ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.bind(JSON.stringify(n.n), Date.now())
.run();
}
} catch (e) {
console.error('[artalk-cf] scheduled gc failed:', e instanceof Error ? e.message : e);
}
// 国内代理体检(每天一次):挂掉就给站长发提醒邮件。
// 刻意放在服务器端 —— 站长电脑关着也能收到,不依赖本地定时任务。
try {
const health = await checkProxyHealth(env);
if (!health.ok) {
const r = await notifyProxyDown(env, health);
console.log('[artalk-cf] proxy down, notify:', JSON.stringify(r));
}
} catch (e) {
console.error('[artalk-cf] proxy health check failed:', e instanceof Error ? e.message : e);
}
},
};