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
+51
View File
@@ -102,6 +102,57 @@ router.get('/guanli/logout', (req, res) => {
const dbService = require('../services/db');
// 0. Posts Read-Only Overview Dashboard
router.get('/guanli/posts', requireAdminAuth, (req, res) => {
const q = (req.query.q || '').trim().toLowerCase();
const activeCategory = (req.query.category || '').trim();
const allPosts = contentService.getAllPosts();
let filteredPosts = allPosts;
if (activeCategory) {
filteredPosts = filteredPosts.filter(p => (p.category || '').toLowerCase() === activeCategory.toLowerCase());
}
if (q) {
filteredPosts = filteredPosts.filter(p =>
(p.title || '').toLowerCase().includes(q) ||
(p.slug || '').toLowerCase().includes(q) ||
(p.tags || []).some(t => t.toLowerCase().includes(q))
);
}
const taxonomy = contentService.getTaxonomy();
const commentStats = dbService.getPostCommentStats();
const stats = dbService.getAdminStats();
res.render('pages/admin-posts', {
title: 'Posts Overview - Site Administration',
posts: allPosts,
filteredPosts,
categories: taxonomy.categories,
activeCategory,
q: req.query.q || '',
commentStats,
stats
});
});
// Hot Content Re-index & Cache Flush endpoint
router.post('/guanli/reindex', requireAdminAuth, (req, res) => {
try {
const { CONTENT_DIR } = require('../config');
contentService.init(CONTENT_DIR);
pageCache.clear();
const totalPosts = contentService.getAllPosts().length;
const totalPages = contentService.pagesBySlug.size;
res.json({ success: true, totalPosts, totalPages });
} catch (err) {
console.error('[Admin] Hot re-index failed:', err);
res.status(500).json({ success: false, error: err.message });
}
});
// 1. Comment Moderation Dashboard
router.get('/guanli/comments', requireAdminAuth, (req, res) => {
const status = req.query.status || 'all';
+5 -13
View File
@@ -5,7 +5,6 @@ const contentService = require('../services/content');
const { loadSiteConfig } = require('../config');
function getRecommendedSearches() {
const config = loadSiteConfig();
const taxonomy = contentService.getTaxonomy();
const dbService = require('../services/db');
@@ -29,26 +28,19 @@ function getRecommendedSearches() {
});
}
// 3. Add top trending searches from SQLite database
const trendingDb = dbService.getTopSearchRecommendations(4);
// 3. Add genuine trending searches by frequency from search_logs (result_count > 0)
const trendingDb = dbService.getTopSearchQueries(6);
const customPills = [];
for (const item of trendingDb) {
if (!dynamicPills.some(p => p.query === item.query)) {
if (!dynamicPills.some(p => p.query.toLowerCase() === item.query.toLowerCase())) {
customPills.push({
label: item.label,
query: item.query
query: item.query,
frequency: item.frequency
});
}
}
// 4. Add any static curated pills from config
const staticConfig = (config.search && config.search.recommended) || [];
for (const item of staticConfig) {
if (!dynamicPills.some(p => p.query === item.query) && !customPills.some(p => p.query === item.query)) {
customPills.push(item);
}
}
return {
dynamicPills,
customPills
+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;