feat(admin): add ephemeral staging DB, read-only posts overview, and dynamic search
- Implement ephemeral staging mode (APP_ENV=staging) to isolate test/dev DB writes in /tmp - Add read-only posts management dashboard under /guanli/posts with live links and comment counters - Add hot re-indexing endpoint (/guanli/reindex) to refresh Markdown without restarting server - Replace static recommended search config with dynamic frequency-based trending searches - Remove early scaffold mockup file (dropbox/zh.md) and clean legacy mock test queries - Add native Makefile with dev, dev-persistent, test, lint, and docker targets - Update test suite and documentation for development workflows
This commit is contained in:
+98
-2
@@ -1,11 +1,13 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
class DatabaseService {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.dbPath = null;
|
||||
this.isEphemeral = false;
|
||||
}
|
||||
|
||||
init(contentDir) {
|
||||
@@ -14,8 +16,43 @@ class DatabaseService {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
this.dbPath = path.join(dataDir, 'site.db');
|
||||
this.db = new DatabaseSync(this.dbPath);
|
||||
const isStaging = process.env.NODE_ENV === 'test' || process.env.APP_ENV === 'staging' || process.env.DB_EPHEMERAL === 'true';
|
||||
const prodDbPath = path.join(dataDir, 'site.db');
|
||||
|
||||
if (isStaging) {
|
||||
this.isEphemeral = true;
|
||||
const ephemeralName = `site.staging-${process.pid}-${Date.now()}.db`;
|
||||
this.dbPath = path.join(os.tmpdir(), ephemeralName);
|
||||
|
||||
// Copy existing production DB if present so staging has realistic base state
|
||||
if (fs.existsSync(prodDbPath)) {
|
||||
try {
|
||||
fs.copyFileSync(prodDbPath, this.dbPath);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Automatically clean up ephemeral DB on process exit
|
||||
const cleanup = () => {
|
||||
try {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
if (fs.existsSync(this.dbPath)) {
|
||||
fs.unlinkSync(this.dbPath);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
process.once('exit', cleanup);
|
||||
process.once('SIGINT', () => { cleanup(); process.exit(0); });
|
||||
process.once('SIGTERM', () => { cleanup(); process.exit(0); });
|
||||
|
||||
this.db = new DatabaseSync(this.dbPath);
|
||||
console.log(`[DB] Ephemeral Staging SQLite database initialized at ${this.dbPath}`);
|
||||
} else {
|
||||
this.dbPath = prodDbPath;
|
||||
this.db = new DatabaseSync(this.dbPath);
|
||||
}
|
||||
|
||||
// Initialize unified tables
|
||||
this.db.exec(`
|
||||
@@ -204,6 +241,65 @@ class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate genuine popular searches by frequency where result_count > 0
|
||||
getTopSearchQueries(limit = 6) {
|
||||
if (!this.db) return [];
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT query, COUNT(*) as frequency
|
||||
FROM search_logs
|
||||
WHERE result_count > 0 AND length(trim(query)) >= 2
|
||||
GROUP BY lower(trim(query))
|
||||
ORDER BY frequency DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(limit).map(r => ({
|
||||
label: r.query,
|
||||
query: r.query,
|
||||
frequency: r.frequency
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[DB] Error fetching top search queries:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Get total & pending comment counts per post slug
|
||||
getPostCommentStats() {
|
||||
if (!this.db) return new Map();
|
||||
try {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT post_slug,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN approved = 0 THEN 1 ELSE 0 END) as pending
|
||||
FROM comments
|
||||
GROUP BY post_slug
|
||||
`);
|
||||
const rows = stmt.all();
|
||||
const map = new Map();
|
||||
for (const row of rows) {
|
||||
map.set(row.post_slug, {
|
||||
total: row.total || 0,
|
||||
pending: row.pending || 0
|
||||
});
|
||||
}
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.error('[DB] Error fetching post comment stats:', err);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
cleanTestSearchData() {
|
||||
if (!this.db) return;
|
||||
try {
|
||||
this.db.exec(`
|
||||
DELETE FROM search_logs WHERE query LIKE '%container image%' OR query LIKE '%tag:k8s "container image"%';
|
||||
DELETE FROM search_recommendations WHERE query LIKE '%container image%';
|
||||
`);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// --- Comments & WordPress-Grade Moderation Workflow ---
|
||||
isAuthorWhitelisted(email) {
|
||||
if (!this.db || !email) return false;
|
||||
|
||||
Reference in New Issue
Block a user