feat: 添加多站点支持,引入site_id字段

- 在Comment表中添加site_id字段,用于区分不同站点的评论
- 更新前后端API以支持site_id参数传递
- 修改自动迁移脚本,添加site_id字段迁移逻辑
- 更新前端组件配置,支持设置siteId参数
This commit is contained in:
anghunk
2026-02-09 13:56:35 +08:00
parent 5948949400
commit 32603d281a
8 changed files with 86 additions and 27 deletions

View File

@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS Comment (
likes INTEGER NOT NULL DEFAULT 0, likes INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 1, priority INTEGER NOT NULL DEFAULT 1,
status TEXT DEFAULT 'approved', status TEXT DEFAULT 'approved',
site_id TEXT NOT NULL DEFAULT '',
-- 建立自引用外键约束(父子评论关系) -- 建立自引用外键约束(父子评论关系)
FOREIGN KEY (parent_id) REFERENCES Comment (id) ON DELETE SET NULL FOREIGN KEY (parent_id) REFERENCES Comment (id) ON DELETE SET NULL
); );
@@ -24,3 +25,4 @@ CREATE TABLE IF NOT EXISTS Comment (
-- 可选:为常用查询字段创建索引以提高性能 -- 可选:为常用查询字段创建索引以提高性能
CREATE INDEX IF NOT EXISTS idx_post_slug ON Comment(post_slug); CREATE INDEX IF NOT EXISTS idx_post_slug ON Comment(post_slug);
CREATE INDEX IF NOT EXISTS idx_status ON Comment(status); CREATE INDEX IF NOT EXISTS idx_status ON Comment(status);
CREATE INDEX IF NOT EXISTS idx_site_id ON Comment(site_id);

View File

@@ -50,29 +50,50 @@ function run() {
const result = JSON.parse(output); const result = JSON.parse(output);
const count = result[0]?.results?.[0]?.count; const count = result[0]?.results?.[0]?.count;
if (count > 0) { if (count === 0) {
return; const sql = `
ALTER TABLE Comment ADD COLUMN post_url TEXT;
UPDATE Comment SET post_url = post_slug;
UPDATE Comment
SET post_slug = SUBSTR(
REPLACE(REPLACE(post_slug, 'https://', ''), 'http://', ''),
INSTR(REPLACE(REPLACE(post_slug, 'https://', ''), 'http://', ''), '/')
)
WHERE post_slug LIKE 'http%' AND INSTR(REPLACE(REPLACE(post_slug, 'https://', ''), 'http://', ''), '/') > 0;
`;
const flatSql = sql.replace(/\s+/g, ' ').trim();
const migrateCmd = `npx wrangler d1 execute ${dbName} --command "${flatSql}" --remote --yes`;
execSync(migrateCmd, { stdio: 'inherit' });
console.log('[Auto-Migrate] Migration applied for Comment.post_url.');
} }
} catch (e) { } catch (e) {
return; console.error('[Auto-Migrate] Failed post_url migration:', e.message);
} }
const sql = ` try {
ALTER TABLE Comment ADD COLUMN post_url TEXT; const checkCmd = `npx wrangler d1 execute ${dbName} --command "SELECT count(*) as count FROM pragma_table_info('Comment') WHERE name='site_id'" --remote --json`;
UPDATE Comment SET post_url = post_slug; const output = execSync(checkCmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
UPDATE Comment
SET post_slug = SUBSTR( const result = JSON.parse(output);
REPLACE(REPLACE(post_slug, 'https://', ''), 'http://', ''), const count = result[0]?.results?.[0]?.count;
INSTR(REPLACE(REPLACE(post_slug, 'https://', ''), 'http://', ''), '/')
) if (count === 0) {
WHERE post_slug LIKE 'http%' AND INSTR(REPLACE(REPLACE(post_slug, 'https://', ''), 'http://', ''), '/') > 0; const sql = `
`; ALTER TABLE Comment ADD COLUMN site_id TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_site_id ON Comment(site_id);
const flatSql = sql.replace(/\s+/g, ' ').trim(); `;
const migrateCmd = `npx wrangler d1 execute ${dbName} --command "${flatSql}" --remote --yes`;
const flatSql = sql.replace(/\s+/g, ' ').trim();
execSync(migrateCmd, { stdio: 'inherit' }); const migrateCmd = `npx wrangler d1 execute ${dbName} --command "${flatSql}" --remote --yes`;
console.log('[Auto-Migrate] Migration applied for Comment.post_url.');
execSync(migrateCmd, { stdio: 'inherit' });
console.log('[Auto-Migrate] Migration applied for Comment.site_id.');
}
} catch (e) {
console.error('[Auto-Migrate] Failed site_id migration:', e.message);
}
} catch (error) { } catch (error) {
console.error('[Auto-Migrate] Failed:', error.message); console.error('[Auto-Migrate] Failed:', error.message);

View File

@@ -12,6 +12,8 @@ export const listComments = async (c: Context<{ Bindings: Bindings }>) => {
const rawDomain = c.req.query('domain') || ''; const rawDomain = c.req.query('domain') || '';
const domain = rawDomain.trim(); const domain = rawDomain.trim();
const rawSiteId = c.req.query('site_id') || '';
const siteId = rawSiteId.trim();
let whereSql = ''; let whereSql = '';
const params: (string | number)[] = []; const params: (string | number)[] = [];
@@ -21,6 +23,15 @@ export const listComments = async (c: Context<{ Bindings: Bindings }>) => {
params.push(pattern, pattern); params.push(pattern, pattern);
} }
if (siteId) {
if (whereSql) {
whereSql += ' AND site_id = ?';
} else {
whereSql = 'WHERE site_id = ?';
}
params.push(siteId);
}
const totalCount = await c.env.CWD_DB.prepare( const totalCount = await c.env.CWD_DB.prepare(
`SELECT COUNT(*) as count FROM Comment ${whereSql}` `SELECT COUNT(*) as count FROM Comment ${whereSql}`
) )
@@ -67,7 +78,8 @@ export const listComments = async (c: Context<{ Bindings: Bindings }>) => {
: 0, : 0,
ua: row.ua, ua: row.ua,
avatar: await getCravatar(row.email, row.name, avatarPrefix || undefined), avatar: await getCravatar(row.email, row.name, avatarPrefix || undefined),
isAdmin: adminEmail && row.email === adminEmail isAdmin: adminEmail && row.email === adminEmail,
siteId: row.site_id
})) }))
); );

View File

@@ -9,6 +9,7 @@ export const getComments = async (c: Context<{ Bindings: Bindings }>) => {
const limit = Math.min(parseInt(c.req.query('limit') || '20'), 50) const limit = Math.min(parseInt(c.req.query('limit') || '20'), 50)
const nested = c.req.query('nested') !== 'false' const nested = c.req.query('nested') !== 'false'
const avatar_prefix = c.req.query('avatar_prefix') const avatar_prefix = c.req.query('avatar_prefix')
const siteId = c.req.query('site_id')
const offset = (page - 1) * limit const offset = (page - 1) * limit
if (!postSlug) return c.json({ message: "post_slug is required" }, 400) if (!postSlug) return c.json({ message: "post_slug is required" }, 400)
@@ -54,15 +55,23 @@ export const getComments = async (c: Context<{ Bindings: Bindings }>) => {
whereParts.length > 0 whereParts.length > 0
? `status = "approved" AND (${whereParts.join(' OR ')})` ? `status = "approved" AND (${whereParts.join(' OR ')})`
: 'status = "approved"' : 'status = "approved"'
let finalWhereClause = whereClause
const bindParams: unknown[] = [...equalSlugs, ...likePatterns]
if (siteId) {
finalWhereClause += ' AND site_id = ?'
bindParams.push(siteId)
}
const query = ` const query = `
SELECT id, name, email, url, content_text as contentText, SELECT id, name, email, url, content_text as contentText,
content_html as contentHtml, created, parent_id as parentId, content_html as contentHtml, created, parent_id as parentId,
post_slug as postSlug, post_url as postUrl, priority, COALESCE(likes, 0) as likes post_slug as postSlug, post_url as postUrl, priority, COALESCE(likes, 0) as likes
FROM Comment FROM Comment
WHERE ${whereClause} WHERE ${finalWhereClause}
ORDER BY priority DESC, created DESC ORDER BY priority DESC, created DESC
` `
const bindParams: unknown[] = [...equalSlugs, ...likePatterns]
const [commentsResult, adminEmailRows] = await Promise.all([ const [commentsResult, adminEmailRows] = await Promise.all([
c.env.CWD_DB.prepare(query).bind(...bindParams).all(), c.env.CWD_DB.prepare(query).bind(...bindParams).all(),

View File

@@ -24,6 +24,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
return c.json({ message: '无效的请求体' }, 400); return c.json({ message: '无效的请求体' }, 400);
} }
const { post_slug, content: rawContent, name: rawName, email, url, post_title, post_url, adminToken } = data; const { post_slug, content: rawContent, name: rawName, email, url, post_title, post_url, adminToken } = data;
const site_id = data.site_id ? String(data.site_id).trim() : "";
const parentId = (data as any).parent_id ?? (data as any).parentId ?? null; const parentId = (data as any).parent_id ?? (data as any).parentId ?? null;
if (!post_slug || typeof post_slug !== 'string') { if (!post_slug || typeof post_slug !== 'string') {
return c.json({ message: 'post_slug 必填' }, 400); return c.json({ message: 'post_slug 必填' }, 400);
@@ -161,8 +162,8 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
INSERT INTO Comment ( INSERT INTO Comment (
created, post_slug, post_url, name, email, url, ip_address, created, post_slug, post_url, name, email, url, ip_address,
os, browser, device, ua, content_text, content_html, os, browser, device, ua, content_text, content_html,
parent_id, status parent_id, status, site_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).bind( `).bind(
Date.now(), Date.now(),
post_slug, post_slug,
@@ -178,7 +179,8 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => {
contentText, contentText,
contentHtml, contentHtml,
parentId || null, parentId || null,
defaultStatus defaultStatus,
site_id
).run(); ).run();
if (!result.success) throw new Error("Database insert failed"); if (!result.success) throw new Error("Database insert failed");

View File

@@ -35,6 +35,7 @@ onMounted(async () => {
const comments = new window.CWDComments({ const comments = new window.CWDComments({
el: commentsRoot.value, el: commentsRoot.value,
apiBaseUrl, apiBaseUrl,
siteId: 'cwd-doc',
theme: getTheme(), theme: getTheme(),
}); });

View File

@@ -29,6 +29,9 @@ export class CWDComments {
*/ */
constructor(config) { constructor(config) {
this.config = { ...config }; this.config = { ...config };
if (config.siteId) {
this.config.siteId = config.siteId;
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
this.config.postSlug = window.location.pathname; this.config.postSlug = window.location.pathname;
} }
@@ -571,6 +574,9 @@ export class CWDComments {
const prevConfig = { ...this.config }; const prevConfig = { ...this.config };
Object.assign(this.config, newConfig); Object.assign(this.config, newConfig);
if (newConfig.siteId !== undefined) {
this.config.siteId = newConfig.siteId;
}
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
this.config.postSlug = window.location.pathname; this.config.postSlug = window.location.pathname;
} }
@@ -589,7 +595,8 @@ export class CWDComments {
const shouldReload = const shouldReload =
this.config.apiBaseUrl !== prevConfig.apiBaseUrl || this.config.apiBaseUrl !== prevConfig.apiBaseUrl ||
this.config.pageSize !== prevConfig.pageSize || this.config.pageSize !== prevConfig.pageSize ||
this.config.postSlug !== prevConfig.postSlug; this.config.postSlug !== prevConfig.postSlug ||
this.config.siteId !== prevConfig.siteId;
if (shouldReload) { if (shouldReload) {
const api = createApiClient(this.config); const api = createApiClient(this.config);

View File

@@ -50,6 +50,10 @@ export function createApiClient(config) {
params.set('avatar_prefix', config.avatarPrefix); params.set('avatar_prefix', config.avatarPrefix);
} }
if (config.siteId) {
params.set('site_id', config.siteId);
}
const response = await fetch(`${baseUrl}/api/comments?${params}`); const response = await fetch(`${baseUrl}/api/comments?${params}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`获取评论失败:${response.status} ${response.statusText}`); throw new Error(`获取评论失败:${response.status} ${response.statusText}`);
@@ -82,7 +86,8 @@ export function createApiClient(config) {
url: data.url || undefined, url: data.url || undefined,
content: data.content, content: data.content,
parent_id: data.parentId, parent_id: data.parentId,
adminToken: data.adminToken adminToken: data.adminToken,
site_id: config.siteId
}), }),
}); });