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:
@@ -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
@@ -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
@@ -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