Files
blog/blog-admin/src/lib/cook.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

257 lines
9.9 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 {
CommentRow,
CookedComment,
CookedNotify,
CookedPage,
CookedSite,
CookedUser,
Env,
PageRow,
SiteRow,
UserRow,
} from '../types';
import { findPagesByKeys, findUsersByIds, siteFirstUrl } from './db';
import { renderMarkdown } from './md';
import { md5Lower } from './md5';
import { formatDateCN } from './util';
// ============================================================================
// 评论等级(九品十八阶)——与博客主题 Ying 的 RANK_TABLE 保持一致。
// 主题原来用 localStorage 自己累计(换设备/清缓存就归零、别人看到的也不准),
// 现在改成后端按全站真实评论数算好,直接塞进官方 badge_name/badge_color
// 字段——官方前端原生渲染白字彩底徽章,跨设备全局一致。
// 只对「没有自定义徽章」的用户生效;博主等保留原徽章不覆盖。
// ============================================================================
interface Rank {
min: number;
title: string;
short: string;
/** 徽章文字色(与主题原版配色一致) */
color: string;
/** 徽章半透明底色(与主题原版配色一致) */
bg: string;
}
const RANK_TABLE: Rank[] = [
{ min: 2000, title: '正一品·太师', short: '太师', color: '#c5881b', bg: 'rgba(197,136,27,0.16)' },
{ min: 1400, title: '从一品·太尉', short: '太尉', color: '#cc942a', bg: 'rgba(204,148,42,0.13)' },
{ min: 1050, title: '正二品·参知政事', short: '参知政事', color: '#d46d33', bg: 'rgba(212,109,51,0.16)' },
{ min: 800, title: '从二品·节度使', short: '节度使', color: '#d87940', bg: 'rgba(216,121,64,0.13)' },
{ min: 600, title: '正三品·御史中丞', short: '御史中丞', color: '#cf4a58', bg: 'rgba(207,74,88,0.16)' },
{ min: 480, title: '从三品·秘书监', short: '秘书监', color: '#d45d68', bg: 'rgba(212,93,104,0.13)' },
{ min: 380, title: '正四品·谏议大夫', short: '谏议大夫', color: '#b05385', bg: 'rgba(176,83,133,0.16)' },
{ min: 300, title: '从四品·侍读学士', short: '侍读学士', color: '#b8638f', bg: 'rgba(184,99,143,0.13)' },
{ min: 240, title: '正五品·给事中', short: '给事中', color: '#9570d6', bg: 'rgba(149,112,214,0.16)' },
{ min: 190, title: '从五品·知州', short: '知州', color: '#a07bda', bg: 'rgba(160,123,218,0.13)' },
{ min: 150, title: '正六品·侍御史', short: '侍御史', color: '#5f7ace', bg: 'rgba(95,122,206,0.16)' },
{ min: 115, title: '从六品·通判', short: '通判', color: '#6b85d0', bg: 'rgba(107,133,208,0.13)' },
{ min: 85, title: '正七品·知县', short: '知县', color: '#2a9a84', bg: 'rgba(42,154,132,0.16)' },
{ min: 60, title: '从七品·殿中侍御史', short: '殿中侍御史', color: '#34a390', bg: 'rgba(52,163,144,0.13)' },
{ min: 40, title: '正八品·大理评事', short: '大理评事', color: '#628530', bg: 'rgba(98,133,48,0.16)' },
{ min: 25, title: '从八品·录事参军', short: '录事参军', color: '#6e913d', bg: 'rgba(110,145,61,0.13)' },
{ min: 15, title: '正九品·主簿', short: '主簿', color: '#8a7050', bg: 'rgba(138,112,80,0.16)' },
{ min: 5, title: '从九品·司户参军', short: '司户参军', color: '#917a5c', bg: 'rgba(145,122,92,0.13)' },
{ min: 0, title: '庶民', short: '庶民', color: '#8a8e98', bg: 'rgba(138,142,152,0.10)' },
];
function getRank(count: number): Rank {
for (const r of RANK_TABLE) {
if (count >= r.min) return r;
}
return RANK_TABLE[RANK_TABLE.length - 1];
}
// Worker isolate 级缓存:评论数变化慢,同一实例的后续请求零 D1 消耗。
// (D1 免费额度按 rows_read 计费,GROUP BY 全表扫很贵,必须挡在缓存后面)
const RANK_CACHE_TTL = 10 * 60 * 1000;
const rankCache = ((globalThis as Record<string, unknown>).__atkRankCache ??= new Map<
number,
{ n: number; t: number }
>()) as Map<number, { n: number; t: number }>;
async function getCommentCounts(env: Env, userIds: number[]): Promise<Map<number, number>> {
const out = new Map<number, number>();
const missing: number[] = [];
const now = Date.now();
for (const id of userIds) {
const hit = rankCache.get(id);
if (hit && now - hit.t < RANK_CACHE_TTL) out.set(id, hit.n);
else missing.push(id);
}
if (missing.length) {
const rows = (
await env.DB.prepare(
`SELECT user_id, COUNT(*) AS n FROM comments
WHERE deleted_at = 0 AND user_id IN (${missing.map(() => '?').join(',')})
GROUP BY user_id`,
)
.bind(...missing)
.all<{ user_id: number; n: number }>()
.catch(() => ({ results: [] as { user_id: number; n: number }[] }))
).results;
for (const r of rows ?? []) {
out.set(r.user_id, r.n);
rankCache.set(r.user_id, { n: r.n, t: now });
}
// 查不到的(0 条评论)也缓存,避免反复查
for (const id of missing) {
if (!out.has(id)) {
out.set(id, 0);
rankCache.set(id, { n: 0, t: now });
}
}
}
return out;
}
/** page_key 是相对路径时,拼到站点地址后面(与 Artalk GetPageAccessibleURL 一致) */
export function pageAccessibleUrl(pageKey: string, siteUrl: string): string {
if (/^https?:\/\//i.test(pageKey)) return pageKey;
if (!siteUrl) return pageKey;
try {
return new URL(pageKey, siteUrl.endsWith('/') ? siteUrl : `${siteUrl}/`).toString();
} catch {
return pageKey;
}
}
export function cookUser(u: UserRow): CookedUser {
return {
id: u.id,
name: u.name,
email: u.email,
link: u.link || '',
badge_name: u.badge_name || '',
badge_color: u.badge_color || '',
title_name: u.title_name || '',
is_admin: !!u.is_admin,
receive_email: !!u.receive_email,
};
}
export function cookSite(s: SiteRow): CookedSite {
const urls = (s.urls || '')
.split(',')
.map((x) => x.trim())
.filter(Boolean);
return { id: s.id, name: s.name, urls, urls_raw: s.urls || '', first_url: urls[0] || '' };
}
export function cookPage(p: PageRow, siteUrl = ''): CookedPage {
return {
id: p.id,
admin_only: !!p.admin_only,
key: p.key,
url: p.accessible_url || pageAccessibleUrl(p.key, siteUrl),
title: p.title || '',
site_name: p.site_name,
vote_up: p.vote_up || 0,
vote_down: p.vote_down || 0,
pv: p.pv || 0,
date: formatDateCN(p.created_at),
};
}
export function cookNotify(n: {
id: number;
user_id: number;
comment_id: number;
is_read: number;
is_emailed: number;
}, readLink = ''): CookedNotify {
return {
id: n.id,
user_id: n.user_id,
comment_id: n.comment_id,
is_read: !!n.is_read,
is_emailed: !!n.is_emailed,
read_link: readLink,
};
}
/**
* 批量把评论行转成 API 结构。
* 一次性把用到的 user / page 捞出来,避免 N+1(Worker 里每次 D1 查询都是成本)。
*
* ★ includeMarked 默认 false:content_marked 是「把 content 再渲染一遍成 HTML」,
* 体积几乎翻倍,而且 **Artalk 2.8.x 客户端根本不消费它**(它自带 marked,在浏览器里
* 渲染 content;只有官方后台 sidebar 会用到)。实测 20 条评论 36KB,
* 去掉后能砍掉一大半,跨境加载明显更快。
* → 只有管理员请求(后台)才带上这个字段。
*/
export async function cookComments(
env: Env,
rows: CommentRow[],
opts: { ipRegion?: boolean; visibility?: Map<number, boolean>; includeMarked?: boolean } = {},
): Promise<CookedComment[]> {
if (!rows.length) return [];
const users = await findUsersByIds(
env,
rows.map((r) => r.user_id),
);
// 评论等级:批量取本批用户的真实评论数(带 isolate 缓存)
const distinctUserIds = [...new Set(rows.map((r) => r.user_id))];
const commentCounts = await getCommentCounts(env, distinctUserIds);
const bySite = new Map<string, string[]>();
for (const r of rows) {
const arr = bySite.get(r.site_name) ?? [];
arr.push(r.page_key);
bySite.set(r.site_name, arr);
}
const pageMap = new Map<string, PageRow>();
const siteUrlMap = new Map<string, string>();
for (const [siteName, keys] of bySite) {
for (const [k, v] of await findPagesByKeys(env, keys, siteName)) {
pageMap.set(`${siteName}\u0000${k}`, v);
}
siteUrlMap.set(siteName, await siteFirstUrl(env, siteName));
}
return rows.map((c) => {
const user = users.get(c.user_id);
const siteUrl = siteUrlMap.get(c.site_name) || '';
const page = pageMap.get(`${c.site_name}\u0000${c.page_key}`);
const cook: CookedComment = {
id: c.id,
content: c.content,
content_marked: opts.includeMarked ? renderMarkdown(c.content) : '',
user_id: c.user_id,
nick: user?.name ?? '',
email_encrypted: md5Lower(user?.email ?? ''),
link: user?.link ?? '',
ua: c.ua || '',
date: formatDateCN(c.created_at),
is_collapsed: !!c.is_collapsed,
is_pending: !!c.is_pending,
is_pinned: !!c.is_pinned,
is_allow_reply: !c.is_collapsed && !c.is_pending,
is_verified: user?.is_admin ? true : !!c.is_verified,
rid: c.rid,
badge_name: user?.badge_name ?? '',
badge_color: user?.badge_color ?? '',
title_name: user?.title_name || '',
visible: opts.visibility?.get(c.id) ?? true,
vote_up: c.vote_up || 0,
vote_down: c.vote_down || 0,
page_key: c.page_key,
page_url: page ? cookPage(page, siteUrl).url : pageAccessibleUrl(c.page_key, siteUrl),
site_name: c.site_name,
};
if (opts.ipRegion && c.ip_region) cook.ip_region = c.ip_region;
// 官职徽标独立字段:不占用官方 badge 槽(博主的「博主」徽章独一无二,
// 官职接在它后面由主题渲染),配色沿用主题原版(彩字 + 半透明底)
const rank = getRank(commentCounts.get(c.user_id) ?? 0);
const myCount = commentCounts.get(c.user_id) ?? 0;
cook.rank_name = rank.short;
cook.rank_title = rank.title;
cook.rank_count = myCount;
cook.rank_color = rank.color;
cook.rank_bg = rank.bg;
return cook;
});
}