256 lines
9.9 KiB
TypeScript
256 lines
9.9 KiB
TypeScript
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;
|
|||
|
|
});
|
|||
|
|
}
|