diff --git a/.env.example b/.env.example
index 28fc281..9cc1948 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,13 @@ SQLITE_PATH=./data/exam.sqlite
HOST=127.0.0.1
PORT=4173
+# 可选 Redis 公开接口缓存;未设置 REDIS_URL 时自动禁用并直接读取数据库。
+# REDIS_URL=redis://127.0.0.1:6379/0
+# REDIS_CACHE_PREFIX=exam-information
+# REDIS_CACHE_TTL_SECONDS=60
+# REDIS_RESULTS_CACHE_TTL_SECONDS=86400
+# REDIS_CONNECT_TIMEOUT_MS=1500
+
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=Admin123!
diff --git a/README.md b/README.md
index 0874d03..4d32450 100644
--- a/README.md
+++ b/README.md
@@ -68,6 +68,7 @@
- HttpOnly、SameSite 登录 Cookie
- 服务端角色权限校验
- SQLite / MySQL 8.4 双数据库持久化
+- 可选 Redis 公开接口缓存,支持写后版本失效、热点请求合并和故障回源
- 规范关系模型、外键、唯一约束和业务索引
- 业务写入与审计日志使用原子事务提交
- 关键管理操作审计日志
@@ -146,6 +147,20 @@ npm start
也可以只设置标准连接地址 `DATABASE_URL=mysql://user:password@host:3306/database`。完整模板见 `.env.example`;将模板复制为 `.env` 后取消 MySQL 配置项的注释并填写实际连接信息即可。生产部署仍建议由部署平台注入环境变量,避免在服务器文件中保存密码。
+### Redis 缓存(可选)
+
+配置 `REDIS_URL` 后,应用会缓存公开首页、已发布公告详情和每名考生的已发布成绩查询。公开数据默认 TTL 为 60 秒,成绩默认 TTL 为 24 小时;成绩录入/发布、批量导入、考试归档和成绩复议会自动使成绩缓存失效,超级管理员也可以在成绩管理中心手动刷新全部成绩缓存。Redis 在启动或运行期间不可用时,接口会自动回源数据库,不影响登录、报名和管理功能。
+
+```powershell
+$env:REDIS_URL = 'redis://127.0.0.1:6379/0'
+$env:REDIS_CACHE_PREFIX = 'exam-information'
+$env:REDIS_CACHE_TTL_SECONDS = '60'
+$env:REDIS_RESULTS_CACHE_TTL_SECONDS = '86400'
+npm start
+```
+
+生产环境可使用 `redis://` 或启用 TLS 的 `rediss://` 连接地址,并通过 `REDIS_CONNECT_TIMEOUT_MS` 调整启动连接超时。
+
### 导入服务器 MySQL 测试数据
先停止正在运行的应用进程,确认服务器 `.env` 中已经设置 `DATABASE_CLIENT=mysql` 及完整 MySQL 连接参数,然后执行:
diff --git a/app.js b/app.js
index f8f8382..0d4c3d9 100644
--- a/app.js
+++ b/app.js
@@ -223,6 +223,11 @@ document.addEventListener('click', async event => {
state.resultImportPreview = null;
return renderRoute();
}
+ if (action === 'refresh-results-cache') {
+ const result = await api('/api/admin/results/cache/refresh', { method: 'POST' });
+ toast(result.refreshed ? '成绩缓存已刷新' : '成绩缓存未刷新', result.message);
+ return renderRoute();
+ }
if (action === 'generate-admit') {
const registration = state.pageData.registrations.find(item => item.id === target.dataset.id);
if (registration.admitCard) return openAdmitPreview(registration);
diff --git a/package-lock.json b/package-lock.json
index 561379d..47c4665 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"ckeditor5": "^48.3.1",
"exceljs": "^4.4.0",
"mysql2": "^3.14.2",
+ "redis": "^5.12.1",
"sanitize-html": "^2.17.6"
},
"engines": {
@@ -900,6 +901,78 @@
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
+ "node_modules/@redis/bloom": {
+ "version": "5.12.1",
+ "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz",
+ "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18.19.0"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.12.1"
+ }
+ },
+ "node_modules/@redis/client": {
+ "version": "5.12.1",
+ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz",
+ "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==",
+ "license": "MIT",
+ "dependencies": {
+ "cluster-key-slot": "1.1.2"
+ },
+ "engines": {
+ "node": ">= 18.19.0"
+ },
+ "peerDependencies": {
+ "@node-rs/xxhash": "^1.1.0",
+ "@opentelemetry/api": ">=1 <2"
+ },
+ "peerDependenciesMeta": {
+ "@node-rs/xxhash": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@redis/json": {
+ "version": "5.12.1",
+ "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz",
+ "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18.19.0"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.12.1"
+ }
+ },
+ "node_modules/@redis/search": {
+ "version": "5.12.1",
+ "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz",
+ "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18.19.0"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.12.1"
+ }
+ },
+ "node_modules/@redis/time-series": {
+ "version": "5.12.1",
+ "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz",
+ "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18.19.0"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.12.1"
+ }
+ },
"node_modules/@types/color-convert": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.4.tgz",
@@ -1315,6 +1388,15 @@
"@ckeditor/ckeditor5-word-count": "48.3.1"
}
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/color-convert": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.0.tgz",
@@ -3341,6 +3423,22 @@
"node": ">=10"
}
},
+ "node_modules/redis": {
+ "version": "5.12.1",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz",
+ "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==",
+ "license": "MIT",
+ "dependencies": {
+ "@redis/bloom": "5.12.1",
+ "@redis/client": "5.12.1",
+ "@redis/json": "5.12.1",
+ "@redis/search": "5.12.1",
+ "@redis/time-series": "5.12.1"
+ },
+ "engines": {
+ "node": ">= 18.19.0"
+ }
+ },
"node_modules/rehype-dom-parse": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/rehype-dom-parse/-/rehype-dom-parse-5.0.2.tgz",
diff --git a/package.json b/package.json
index 4c854c3..21b061d 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"start": "node server.mjs",
- "test": "node tests/system.test.mjs",
+ "test": "node tests/cache.test.mjs && node tests/system.test.mjs",
+ "test:cache": "node tests/cache.test.mjs",
"reset-db": "node scripts/reset-dev-database.mjs",
"seed-test-data": "node scripts/import-test-data.mjs",
"seed-test-data:sqlite": "node scripts/import-test-data.mjs --sqlite",
@@ -18,6 +19,7 @@
"ckeditor5": "^48.3.1",
"exceljs": "^4.4.0",
"mysql2": "^3.14.2",
+ "redis": "^5.12.1",
"sanitize-html": "^2.17.6"
},
"overrides": {
diff --git a/server.mjs b/server.mjs
index a744577..fdbceee 100644
--- a/server.mjs
+++ b/server.mjs
@@ -15,6 +15,7 @@ import { createSessionManager } from './src/security/session.mjs';
import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
import { createBaseDatabase } from './src/data/base.mjs';
import { resolveRegion } from './src/data/region-service.mjs';
+import { createRedisCache, withCacheInvalidation } from './src/cache/redis-cache.mjs';
const root = resolve(process.cwd());
const envPath = join(root, '.env');
@@ -93,7 +94,19 @@ const initializeDatabase = () => createBaseDatabase({
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
}
});
-const database = await createDatabase({ root, seed: initializeDatabase });
+const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
+const cache = await createRedisCache();
+const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateExam', 'archiveExam']);
+const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => {
+ const namespaces = ['public'];
+ const instance = args[0];
+ if (resultCacheWriteMethods.has(method)
+ || (['createWorkflow', 'processWorkflow', 'transferWorkflow'].includes(method) && instance?.businessType === 'score_appeal')
+ || (method === 'saveWorkflow' && instance?.businessType === 'score_appeal')) {
+ namespaces.push('results');
+ }
+ return namespaces;
+});
const readDb = () => database.read();
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
@@ -825,6 +838,8 @@ function admitCardsHtml(db, registrations, title) {
const routeContext = {
database,
+ cache,
+ resultsCacheTtlSeconds: process.env.REDIS_RESULTS_CACHE_TTL_SECONDS || 86400,
readDb,
publicSiteConfig,
sendJson,
@@ -927,12 +942,13 @@ const server = createServer(async (request, response) => {
server.listen(port, host, () => {
console.log(`衡准考试信息管理系统:http://${host}:${port}`);
console.log(`数据库:${database.client}(${database.location})`);
+ console.log(`Redis 缓存:${cache.status === 'ready' ? '已连接' : cache.status === 'disabled' ? '未配置' : '不可用,已回源数据库'}`);
});
async function shutdown(signal) {
console.log(`收到 ${signal},正在关闭服务...`);
server.close(async () => {
- await database.close();
+ await Promise.allSettled([database.close(), cache.close()]);
process.exit(0);
});
}
diff --git a/src/cache/redis-cache.mjs b/src/cache/redis-cache.mjs
new file mode 100644
index 0000000..8629a9a
--- /dev/null
+++ b/src/cache/redis-cache.mjs
@@ -0,0 +1,136 @@
+import { createClient } from 'redis';
+
+function positiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
+}
+
+function disabledCache(status = 'disabled') {
+ return {
+ enabled: false,
+ status,
+ async remember(_namespace, _key, loader) {
+ return loader();
+ },
+ async invalidate() { return false; },
+ async close() {}
+ };
+}
+
+export async function createRedisCache({ env = process.env, logger = console, clientFactory = createClient } = {}) {
+ const url = String(env.REDIS_URL || '').trim();
+ if (!url) return disabledCache();
+
+ const prefix = String(env.REDIS_CACHE_PREFIX || 'exam-information')
+ .trim()
+ .replace(/[^a-zA-Z0-9:_-]/g, '-') || 'exam-information';
+ const defaultTtlSeconds = positiveInteger(env.REDIS_CACHE_TTL_SECONDS, 60, 86400);
+ const connectTimeout = positiveInteger(env.REDIS_CONNECT_TIMEOUT_MS, 1500, 30000);
+ const pending = new Map();
+ let warningReported = false;
+
+ const warn = error => {
+ if (warningReported) return;
+ warningReported = true;
+ logger.warn(`Redis 缓存暂不可用,已回源数据库:${error?.message || error}`);
+ };
+
+ const client = clientFactory({
+ url,
+ socket: {
+ connectTimeout,
+ reconnectStrategy(retries) {
+ return retries >= 3 ? false : Math.min(100 * 2 ** retries, 1000);
+ }
+ }
+ });
+ client.on('error', warn);
+ client.on('ready', () => {
+ warningReported = false;
+ });
+
+ try {
+ await client.connect();
+ } catch (error) {
+ warn(error);
+ if (client.isOpen) client.destroy();
+ return disabledCache('unavailable');
+ }
+
+ const versionKey = namespace => `${prefix}:namespace:${namespace}`;
+
+ async function namespaceVersion(namespace) {
+ const key = versionKey(namespace);
+ const current = await client.get(key);
+ if (current) return current;
+ await client.set(key, '1', { NX: true });
+ return (await client.get(key)) || '1';
+ }
+
+ return {
+ get enabled() {
+ return client.isReady;
+ },
+ get status() {
+ return client.isReady ? 'ready' : 'unavailable';
+ },
+ async remember(namespace, key, loader, { ttlSeconds = defaultTtlSeconds } = {}) {
+ if (!client.isReady) return loader();
+ try {
+ const version = await namespaceVersion(namespace);
+ const cacheKey = `${prefix}:${namespace}:${version}:${key}`;
+ const cached = await client.get(cacheKey);
+ if (cached !== null) return JSON.parse(cached);
+
+ if (pending.has(cacheKey)) return pending.get(cacheKey);
+ const loading = Promise.resolve(loader()).then(async value => {
+ if (client.isReady) {
+ try {
+ await client.set(cacheKey, JSON.stringify(value), {
+ EX: positiveInteger(ttlSeconds, defaultTtlSeconds, 86400)
+ });
+ } catch (error) {
+ warn(error);
+ }
+ }
+ return value;
+ }).finally(() => pending.delete(cacheKey));
+ pending.set(cacheKey, loading);
+ return loading;
+ } catch (error) {
+ warn(error);
+ return loader();
+ }
+ },
+ async invalidate(namespace) {
+ if (!client.isReady) return false;
+ try {
+ await client.incr(versionKey(namespace));
+ return true;
+ } catch (error) {
+ warn(error);
+ return false;
+ }
+ },
+ async close() {
+ if (client.isOpen) await client.quit();
+ }
+ };
+}
+
+export function withCacheInvalidation(database, cache, namespaces = ['public']) {
+ const resolveNamespaces = typeof namespaces === 'function' ? namespaces : () => namespaces;
+ return new Proxy(database, {
+ get(target, property, receiver) {
+ const value = Reflect.get(target, property, receiver);
+ if (typeof value !== 'function') return value;
+ if (property === 'read' || property === 'close') return value.bind(target);
+ return async (...args) => {
+ const result = await value.apply(target, args);
+ const affected = [...new Set(resolveNamespaces(property, args, result) || [])];
+ await Promise.all(affected.map(namespace => cache.invalidate(namespace)));
+ return result;
+ };
+ }
+ });
+}
diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs
index 772e6a8..f661ebc 100644
--- a/src/client/admin-views.mjs
+++ b/src/client/admin-views.mjs
@@ -155,7 +155,10 @@ export function createAdminViews(context) {
const currentExams = data.exams.filter(exam => !exam.archivedAt);
const archivedExams = data.exams.filter(exam => exam.archivedAt);
const examStrip = `历史归档考试 ${archivedExams.length} 场 · 成绩永久锁定
| 考生 | 考试 / 科目 | 成绩 | 排名 / 等级 | 单科及格规则 | 达线 | 发布 | 更新时间 |
|---|---|---|---|---|---|---|---|
${h((result.candidateName || '?').slice(0,1))} ${h(result.candidateName)}${h(result.candidateNumber)}${h(result.schoolName)} · ${h(result.className)} | ${h(result.subjectName)}${h(result.examCode)} | ${h(result.score)} / ${h(result.fullScore)} | 第 ${h(result.rank)} / ${h(result.cohortSize)} 名${h(result.grade)} · 前 ${h(result.rankPercent)}% | ${h(result.passText)} | ${result.qualified == null ? '不判定' : result.qualified ? '达线' : '未达线'} | ${badge(result.published ? 'published' : 'draft')} | ${formatDate(result.updatedAt || result.publishedAt, true)} |
| 本场考试还没有成绩记录 | |||||||