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:
2026-09-16 23:09:14 -07:00
parent f52b3fe9e4
commit 95a7dcb93b
12 changed files with 510 additions and 83 deletions
+98 -2
View File
@@ -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;