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).__atkRankCache ??= new Map< number, { n: number; t: number } >()) as Map; async function getCommentCounts(env: Env, userIds: number[]): Promise> { const out = new Map(); 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; includeMarked?: boolean } = {}, ): Promise { 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(); 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(); const siteUrlMap = new Map(); 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; }); }