归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
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
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
This commit is contained in:
1 parent
855a001046
commit
a74b3c7127
98 files changed
+15657
No files matched your search
@@ -0,0 +1,256 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user