REDis
This commit is contained in:
@@ -5,6 +5,13 @@ SQLITE_PATH=./data/exam.sqlite
|
|||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
PORT=4173
|
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_USERNAME=admin
|
||||||
INITIAL_ADMIN_PASSWORD=Admin123!
|
INITIAL_ADMIN_PASSWORD=Admin123!
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
- HttpOnly、SameSite 登录 Cookie
|
- HttpOnly、SameSite 登录 Cookie
|
||||||
- 服务端角色权限校验
|
- 服务端角色权限校验
|
||||||
- SQLite / MySQL 8.4 双数据库持久化
|
- SQLite / MySQL 8.4 双数据库持久化
|
||||||
|
- 可选 Redis 公开接口缓存,支持写后版本失效、热点请求合并和故障回源
|
||||||
- 规范关系模型、外键、唯一约束和业务索引
|
- 规范关系模型、外键、唯一约束和业务索引
|
||||||
- 业务写入与审计日志使用原子事务提交
|
- 业务写入与审计日志使用原子事务提交
|
||||||
- 关键管理操作审计日志
|
- 关键管理操作审计日志
|
||||||
@@ -146,6 +147,20 @@ npm start
|
|||||||
|
|
||||||
也可以只设置标准连接地址 `DATABASE_URL=mysql://user:password@host:3306/database`。完整模板见 `.env.example`;将模板复制为 `.env` 后取消 MySQL 配置项的注释并填写实际连接信息即可。生产部署仍建议由部署平台注入环境变量,避免在服务器文件中保存密码。
|
也可以只设置标准连接地址 `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 测试数据
|
### 导入服务器 MySQL 测试数据
|
||||||
|
|
||||||
先停止正在运行的应用进程,确认服务器 `.env` 中已经设置 `DATABASE_CLIENT=mysql` 及完整 MySQL 连接参数,然后执行:
|
先停止正在运行的应用进程,确认服务器 `.env` 中已经设置 `DATABASE_CLIENT=mysql` 及完整 MySQL 连接参数,然后执行:
|
||||||
|
|||||||
@@ -223,6 +223,11 @@ document.addEventListener('click', async event => {
|
|||||||
state.resultImportPreview = null;
|
state.resultImportPreview = null;
|
||||||
return renderRoute();
|
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') {
|
if (action === 'generate-admit') {
|
||||||
const registration = state.pageData.registrations.find(item => item.id === target.dataset.id);
|
const registration = state.pageData.registrations.find(item => item.id === target.dataset.id);
|
||||||
if (registration.admitCard) return openAdmitPreview(registration);
|
if (registration.admitCard) return openAdmitPreview(registration);
|
||||||
|
|||||||
Generated
+98
@@ -11,6 +11,7 @@
|
|||||||
"ckeditor5": "^48.3.1",
|
"ckeditor5": "^48.3.1",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"mysql2": "^3.14.2",
|
"mysql2": "^3.14.2",
|
||||||
|
"redis": "^5.12.1",
|
||||||
"sanitize-html": "^2.17.6"
|
"sanitize-html": "^2.17.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -900,6 +901,78 @@
|
|||||||
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/color-convert": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.4.tgz",
|
"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"
|
"@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": {
|
"node_modules/color-convert": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.0.tgz",
|
||||||
@@ -3341,6 +3423,22 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/rehype-dom-parse": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/rehype-dom-parse/-/rehype-dom-parse-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/rehype-dom-parse/-/rehype-dom-parse-5.0.2.tgz",
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"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",
|
"reset-db": "node scripts/reset-dev-database.mjs",
|
||||||
"seed-test-data": "node scripts/import-test-data.mjs",
|
"seed-test-data": "node scripts/import-test-data.mjs",
|
||||||
"seed-test-data:sqlite": "node scripts/import-test-data.mjs --sqlite",
|
"seed-test-data:sqlite": "node scripts/import-test-data.mjs --sqlite",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"ckeditor5": "^48.3.1",
|
"ckeditor5": "^48.3.1",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"mysql2": "^3.14.2",
|
"mysql2": "^3.14.2",
|
||||||
|
"redis": "^5.12.1",
|
||||||
"sanitize-html": "^2.17.6"
|
"sanitize-html": "^2.17.6"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
+18
-2
@@ -15,6 +15,7 @@ import { createSessionManager } from './src/security/session.mjs';
|
|||||||
import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
|
import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
|
||||||
import { createBaseDatabase } from './src/data/base.mjs';
|
import { createBaseDatabase } from './src/data/base.mjs';
|
||||||
import { resolveRegion } from './src/data/region-service.mjs';
|
import { resolveRegion } from './src/data/region-service.mjs';
|
||||||
|
import { createRedisCache, withCacheInvalidation } from './src/cache/redis-cache.mjs';
|
||||||
|
|
||||||
const root = resolve(process.cwd());
|
const root = resolve(process.cwd());
|
||||||
const envPath = join(root, '.env');
|
const envPath = join(root, '.env');
|
||||||
@@ -93,7 +94,19 @@ const initializeDatabase = () => createBaseDatabase({
|
|||||||
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
|
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 readDb = () => database.read();
|
||||||
|
|
||||||
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
|
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
|
||||||
@@ -825,6 +838,8 @@ function admitCardsHtml(db, registrations, title) {
|
|||||||
|
|
||||||
const routeContext = {
|
const routeContext = {
|
||||||
database,
|
database,
|
||||||
|
cache,
|
||||||
|
resultsCacheTtlSeconds: process.env.REDIS_RESULTS_CACHE_TTL_SECONDS || 86400,
|
||||||
readDb,
|
readDb,
|
||||||
publicSiteConfig,
|
publicSiteConfig,
|
||||||
sendJson,
|
sendJson,
|
||||||
@@ -927,12 +942,13 @@ const server = createServer(async (request, response) => {
|
|||||||
server.listen(port, host, () => {
|
server.listen(port, host, () => {
|
||||||
console.log(`衡准考试信息管理系统:http://${host}:${port}`);
|
console.log(`衡准考试信息管理系统:http://${host}:${port}`);
|
||||||
console.log(`数据库:${database.client}(${database.location})`);
|
console.log(`数据库:${database.client}(${database.location})`);
|
||||||
|
console.log(`Redis 缓存:${cache.status === 'ready' ? '已连接' : cache.status === 'disabled' ? '未配置' : '不可用,已回源数据库'}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function shutdown(signal) {
|
async function shutdown(signal) {
|
||||||
console.log(`收到 ${signal},正在关闭服务...`);
|
console.log(`收到 ${signal},正在关闭服务...`);
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
await database.close();
|
await Promise.allSettled([database.close(), cache.close()]);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+136
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -155,7 +155,10 @@ export function createAdminViews(context) {
|
|||||||
const currentExams = data.exams.filter(exam => !exam.archivedAt);
|
const currentExams = data.exams.filter(exam => !exam.archivedAt);
|
||||||
const archivedExams = data.exams.filter(exam => exam.archivedAt);
|
const archivedExams = data.exams.filter(exam => exam.archivedAt);
|
||||||
const examStrip = `<section class="result-exam-strip">${currentExams.map(examButton).join('')}</section>${archivedExams.length ? `<details class="result-archive-switcher" ${activeExam?.archivedAt ? 'open' : ''}><summary>历史归档考试 <span>${archivedExams.length} 场 · 成绩永久锁定</span></summary><section class="result-exam-strip archived">${archivedExams.map(examButton).join('')}</section></details>` : ''}`;
|
const examStrip = `<section class="result-exam-strip">${currentExams.map(examButton).join('')}</section>${archivedExams.length ? `<details class="result-archive-switcher" ${activeExam?.archivedAt ? 'open' : ''}><summary>历史归档考试 <span>${archivedExams.length} 场 · 成绩永久锁定</span></summary><section class="result-exam-strip archived">${archivedExams.map(examButton).join('')}</section></details>` : ''}`;
|
||||||
const toolbar = `<div class="excel-toolbar result-excel-toolbar"><span><strong>${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}</strong><small>${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '模板导入会先暂存预览,确认后才原子写入数据库'}</small></span><div>${activeExam?.archivedAt ? '' : '<button class="row-action" data-action="excel-download" data-resource="results" data-template="1">下载模板</button>'}<button class="row-action" data-action="excel-download" data-resource="results" data-exam-id="${h(activeExam?.id || '')}">导出本场成绩</button>${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '<button class="row-action primary" data-action="excel-import" data-resource="results">选择 Excel 预览</button><input type="file" accept=".xlsx" hidden data-excel-file="results">' : ''}</div></div>`;
|
const cacheButton = state.user.adminLevel === 'super'
|
||||||
|
? `<button class="row-action" data-action="refresh-results-cache" ${data.resultCache?.enabled ? '' : 'disabled'} title="${data.resultCache?.enabled ? '让所有考生成绩查询在下次访问时重新生成 Redis 缓存' : 'Redis 缓存当前未连接'}">${data.resultCache?.enabled ? '刷新成绩 Redis 缓存' : 'Redis 缓存未启用'}</button>`
|
||||||
|
: '';
|
||||||
|
const toolbar = `<div class="excel-toolbar result-excel-toolbar"><span><strong>${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}</strong><small>${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '模板导入会先暂存预览,确认后才原子写入数据库'}</small></span><div>${cacheButton}${activeExam?.archivedAt ? '' : '<button class="row-action" data-action="excel-download" data-resource="results" data-template="1">下载模板</button>'}<button class="row-action" data-action="excel-download" data-resource="results" data-exam-id="${h(activeExam?.id || '')}">导出本场成绩</button>${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '<button class="row-action primary" data-action="excel-import" data-resource="results">选择 Excel 预览</button><input type="file" accept=".xlsx" hidden data-excel-file="results">' : ''}</div></div>`;
|
||||||
const metrics = `<section class="result-metric-grid"><article><small>报名考生</small><strong>${activeExam?.registrationCount ?? 0}</strong><span>本场已通过报名</span></article><article><small>录入进度</small><strong>${activeExam?.scored ?? 0}<em> / ${activeExam?.enrolledSubjects ?? 0}</em></strong><span>剩余 ${activeExam?.missing ?? 0} 科次</span></article><article><small>已发布</small><strong>${activeExam?.published ?? 0}</strong><span>草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条</span></article><article><small>成绩已出齐</small><strong>${activeExam?.complete ?? 0}</strong><span>人</span></article><article><small>整场合格率</small><strong>${passRate == null ? '—' : `${passRate}%`}</strong><span>按本场排名或所设规则判定</span></article><article><small>成绩复议</small><strong>${examAppeals.length}</strong><span>当前考试累计</span></article></section>`;
|
const metrics = `<section class="result-metric-grid"><article><small>报名考生</small><strong>${activeExam?.registrationCount ?? 0}</strong><span>本场已通过报名</span></article><article><small>录入进度</small><strong>${activeExam?.scored ?? 0}<em> / ${activeExam?.enrolledSubjects ?? 0}</em></strong><span>剩余 ${activeExam?.missing ?? 0} 科次</span></article><article><small>已发布</small><strong>${activeExam?.published ?? 0}</strong><span>草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条</span></article><article><small>成绩已出齐</small><strong>${activeExam?.complete ?? 0}</strong><span>人</span></article><article><small>整场合格率</small><strong>${passRate == null ? '—' : `${passRate}%`}</strong><span>按本场排名或所设规则判定</span></article><article><small>成绩复议</small><strong>${examAppeals.length}</strong><span>当前考试累计</span></article></section>`;
|
||||||
const ledger = `<section class="panel data-panel result-ledger"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultTable" placeholder="搜索报名号、姓名、学校或科目"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="resultTable" data-status="all">全部</button><button data-action="status-filter" data-target="resultTable" data-status="published">已发布</button><button data-action="status-filter" data-target="resultTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="resultTable" data-status="qualified">单科达线</button><button data-action="status-filter" data-target="resultTable" data-status="unqualified">单科未达线</button></div></div><div class="table-scroll"><table id="resultTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>排名 / 等级</th><th>单科及格规则</th><th>达线</th><th>发布</th><th>更新时间</th></tr></thead><tbody>${examResults.map(result => `<tr data-status="${result.published ? 'published' : 'draft'} ${result.qualified === true ? 'qualified' : result.qualified === false ? 'unqualified' : ''}"><td><div class="person-cell"><span>${h((result.candidateName || '?').slice(0,1))}</span><div><strong>${h(result.candidateName)}</strong><small class="mono">${h(result.candidateNumber)}</small><small>${h(result.schoolName)} · ${h(result.className)}</small></div></div></td><td><strong>${h(result.subjectName)}</strong><small>${h(result.examCode)}</small></td><td><strong class="result-score">${h(result.score)}<em> / ${h(result.fullScore)}</em></strong></td><td><strong>第 ${h(result.rank)} / ${h(result.cohortSize)} 名</strong><small>${h(result.grade)} · 前 ${h(result.rankPercent)}%</small></td><td><strong>${h(result.passText)}</strong></td><td>${result.qualified == null ? '<span class="result-neutral">不判定</span>' : result.qualified ? '<span class="result-qualified">达线</span>' : '<span class="result-unqualified">未达线</span>'}</td><td>${badge(result.published ? 'published' : 'draft')}</td><td>${formatDate(result.updatedAt || result.publishedAt, true)}</td></tr>`).join('') || '<tr><td colspan="8" class="empty-state">本场考试还没有成绩记录</td></tr>'}</tbody></table></div></section>`;
|
const ledger = `<section class="panel data-panel result-ledger"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="resultTable" placeholder="搜索报名号、姓名、学校或科目"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="resultTable" data-status="all">全部</button><button data-action="status-filter" data-target="resultTable" data-status="published">已发布</button><button data-action="status-filter" data-target="resultTable" data-status="draft">草稿</button><button data-action="status-filter" data-target="resultTable" data-status="qualified">单科达线</button><button data-action="status-filter" data-target="resultTable" data-status="unqualified">单科未达线</button></div></div><div class="table-scroll"><table id="resultTable"><thead><tr><th>考生</th><th>考试 / 科目</th><th>成绩</th><th>排名 / 等级</th><th>单科及格规则</th><th>达线</th><th>发布</th><th>更新时间</th></tr></thead><tbody>${examResults.map(result => `<tr data-status="${result.published ? 'published' : 'draft'} ${result.qualified === true ? 'qualified' : result.qualified === false ? 'unqualified' : ''}"><td><div class="person-cell"><span>${h((result.candidateName || '?').slice(0,1))}</span><div><strong>${h(result.candidateName)}</strong><small class="mono">${h(result.candidateNumber)}</small><small>${h(result.schoolName)} · ${h(result.className)}</small></div></div></td><td><strong>${h(result.subjectName)}</strong><small>${h(result.examCode)}</small></td><td><strong class="result-score">${h(result.score)}<em> / ${h(result.fullScore)}</em></strong></td><td><strong>第 ${h(result.rank)} / ${h(result.cohortSize)} 名</strong><small>${h(result.grade)} · 前 ${h(result.rankPercent)}%</small></td><td><strong>${h(result.passText)}</strong></td><td>${result.qualified == null ? '<span class="result-neutral">不判定</span>' : result.qualified ? '<span class="result-qualified">达线</span>' : '<span class="result-unqualified">未达线</span>'}</td><td>${badge(result.published ? 'published' : 'draft')}</td><td>${formatDate(result.updatedAt || result.publishedAt, true)}</td></tr>`).join('') || '<tr><td colspan="8" class="empty-state">本场考试还没有成绩记录</td></tr>'}</tbody></table></div></section>`;
|
||||||
const archiveLock = activeExam?.archivedAt ? `<section class="exam-lock-banner"><span>${icons.check}</span><div><strong>本场考试已归档</strong><small>${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。</small></div></section>` : '';
|
const archiveLock = activeExam?.archivedAt ? `<section class="exam-lock-banner"><span>${icons.check}</span><div><strong>本场考试已归档</strong><small>${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。</small></div></section>` : '';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../secu
|
|||||||
export function createAdminRoutes(context) {
|
export function createAdminRoutes(context) {
|
||||||
const {
|
const {
|
||||||
database,
|
database,
|
||||||
|
cache,
|
||||||
readDb,
|
readDb,
|
||||||
sendJson,
|
sendJson,
|
||||||
sendError,
|
sendError,
|
||||||
@@ -1040,6 +1041,16 @@ export function createAdminRoutes(context) {
|
|||||||
await database.updateNotice(notice, log);
|
await database.updateNotice(notice, log);
|
||||||
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
|
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
|
||||||
}
|
}
|
||||||
|
if (request.method === 'POST' && pathname === '/api/admin/results/cache/refresh') {
|
||||||
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
const refreshed = await cache.invalidate('results');
|
||||||
|
return sendJson(response, 200, {
|
||||||
|
ok: true,
|
||||||
|
refreshed,
|
||||||
|
cacheStatus: cache.status,
|
||||||
|
message: refreshed ? '成绩 Redis 缓存已刷新,后续查询将重新生成缓存' : 'Redis 缓存当前未连接或刷新失败,成绩查询继续直接读取数据库'
|
||||||
|
});
|
||||||
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
||||||
if (!requirePermission(user, response, 'results.read')) return true;
|
if (!requirePermission(user, response, 'results.read')) return true;
|
||||||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||||
@@ -1088,7 +1099,7 @@ export function createAdminRoutes(context) {
|
|||||||
const account = db.users.find(accountItem => accountItem.id === item.userId);
|
const account = db.users.find(accountItem => accountItem.id === item.userId);
|
||||||
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: db.classes.find(classItem => classItem.id === profile?.classId)?.name || profile?.grade || '' };
|
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: db.classes.find(classItem => classItem.id === profile?.classId)?.name || profile?.grade || '' };
|
||||||
}) : [];
|
}) : [];
|
||||||
return sendJson(response, 200, { ok: true, results, appeals, registrations, exams });
|
return sendJson(response, 200, { ok: true, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } });
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && pathname === '/api/admin/results/import') {
|
if (request.method === 'POST' && pathname === '/api/admin/results/import') {
|
||||||
if (!requirePermission(user, response, '*')) return true;
|
if (!requirePermission(user, response, '*')) return true;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { noticeForClient } from '../security/notice-content.mjs';
|
|||||||
export function createCandidateRoutes(context) {
|
export function createCandidateRoutes(context) {
|
||||||
const {
|
const {
|
||||||
database,
|
database,
|
||||||
|
cache,
|
||||||
|
resultsCacheTtlSeconds,
|
||||||
readDb,
|
readDb,
|
||||||
sendJson,
|
sendJson,
|
||||||
sendError,
|
sendError,
|
||||||
@@ -126,6 +128,7 @@ export function createCandidateRoutes(context) {
|
|||||||
return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' });
|
return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' });
|
||||||
}
|
}
|
||||||
if (request.method === 'GET' && pathname === '/api/candidate/results') {
|
if (request.method === 'GET' && pathname === '/api/candidate/results') {
|
||||||
|
const payload = await cache.remember('results', `candidate:${encodeURIComponent(user.id)}`, async () => {
|
||||||
const registrations = db.registrations.filter(item => item.userId === user.id);
|
const registrations = db.registrations.filter(item => item.userId === user.id);
|
||||||
const results = db.results.filter(item => item.published && registrations.some(reg => reg.id === item.registrationId)).map(result => {
|
const results = db.results.filter(item => item.published && registrations.some(reg => reg.id === item.registrationId)).map(result => {
|
||||||
const registration = registrations.find(reg => reg.id === result.registrationId);
|
const registration = registrations.find(reg => reg.id === result.registrationId);
|
||||||
@@ -144,7 +147,9 @@ export function createCandidateRoutes(context) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects);
|
const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects);
|
||||||
return sendJson(response, 200, { ok: true, results, summaries });
|
return { ok: true, results, summaries };
|
||||||
|
}, { ttlSeconds: resultsCacheTtlSeconds });
|
||||||
|
return sendJson(response, 200, payload);
|
||||||
}
|
}
|
||||||
const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/);
|
const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/);
|
||||||
if (request.method === 'POST' && scoreAppealMatch) {
|
if (request.method === 'POST' && scoreAppealMatch) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { noticeForClient } from '../security/notice-content.mjs';
|
|||||||
export function createPublicRoutes(context) {
|
export function createPublicRoutes(context) {
|
||||||
const {
|
const {
|
||||||
database,
|
database,
|
||||||
|
cache,
|
||||||
readDb,
|
readDb,
|
||||||
publicSiteConfig,
|
publicSiteConfig,
|
||||||
sendJson,
|
sendJson,
|
||||||
@@ -51,16 +52,23 @@ export function createPublicRoutes(context) {
|
|||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
async function handlePublic(pathname, response) {
|
async function handlePublic(pathname, response) {
|
||||||
const db = await readDb();
|
|
||||||
if (pathname === '/api/public/home') {
|
if (pathname === '/api/public/home') {
|
||||||
|
const payload = await cache.remember('public', 'home', async () => {
|
||||||
|
const db = await readDb();
|
||||||
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient);
|
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient);
|
||||||
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
||||||
return sendJson(response, 200, { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } });
|
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
|
||||||
|
});
|
||||||
|
return sendJson(response, 200, payload);
|
||||||
}
|
}
|
||||||
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
|
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
|
||||||
if (noticeMatch) {
|
if (noticeMatch) {
|
||||||
const notice = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
|
const notice = await cache.remember('public', `notice:${encodeURIComponent(noticeMatch[1])}`, async () => {
|
||||||
return notice ? sendJson(response, 200, { ok: true, notice: noticeForClient(notice) }) : sendError(response, 404, '通知不存在或尚未发布');
|
const db = await readDb();
|
||||||
|
const found = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
|
||||||
|
return found ? noticeForClient(found) : null;
|
||||||
|
});
|
||||||
|
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { createRedisCache, withCacheInvalidation } from '../src/cache/redis-cache.mjs';
|
||||||
|
|
||||||
|
class FakeRedisClient extends EventEmitter {
|
||||||
|
constructor({ connectError = null } = {}) {
|
||||||
|
super();
|
||||||
|
this.connectError = connectError;
|
||||||
|
this.isOpen = false;
|
||||||
|
this.isReady = false;
|
||||||
|
this.values = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (this.connectError) throw this.connectError;
|
||||||
|
this.isOpen = true;
|
||||||
|
this.isReady = true;
|
||||||
|
this.emit('ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key) {
|
||||||
|
return this.values.has(key) ? this.values.get(key) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key, value, options = {}) {
|
||||||
|
if (options.NX && this.values.has(key)) return null;
|
||||||
|
this.values.set(key, value);
|
||||||
|
return 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
async incr(key) {
|
||||||
|
const next = Number(this.values.get(key) || 0) + 1;
|
||||||
|
this.values.set(key, String(next));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async quit() {
|
||||||
|
this.isReady = false;
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.isReady = false;
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const silentLogger = { warn() {} };
|
||||||
|
|
||||||
|
{
|
||||||
|
const client = new FakeRedisClient();
|
||||||
|
const cache = await createRedisCache({
|
||||||
|
env: { REDIS_URL: 'redis://test', REDIS_CACHE_PREFIX: 'test', REDIS_CACHE_TTL_SECONDS: '30' },
|
||||||
|
logger: silentLogger,
|
||||||
|
clientFactory: () => client
|
||||||
|
});
|
||||||
|
let loads = 0;
|
||||||
|
const load = async () => ({ version: ++loads });
|
||||||
|
|
||||||
|
assert.deepEqual(await cache.remember('public', 'home', load), { version: 1 });
|
||||||
|
assert.deepEqual(await cache.remember('public', 'home', load), { version: 1 });
|
||||||
|
assert.equal(loads, 1, '相同缓存键应只读取一次数据源');
|
||||||
|
|
||||||
|
await cache.invalidate('public');
|
||||||
|
assert.deepEqual(await cache.remember('public', 'home', load), { version: 2 });
|
||||||
|
assert.equal(loads, 2, '命名空间失效后应重新读取数据源');
|
||||||
|
|
||||||
|
let resultLoads = 0;
|
||||||
|
const loadResults = async () => ({ version: ++resultLoads });
|
||||||
|
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 1 });
|
||||||
|
|
||||||
|
const database = withCacheInvalidation({
|
||||||
|
client: 'test',
|
||||||
|
async read() { return {}; },
|
||||||
|
async save() { return 'saved'; },
|
||||||
|
async close() {}
|
||||||
|
}, cache, method => method === 'save' ? ['public', 'results'] : ['public']);
|
||||||
|
assert.equal(await database.save(), 'saved');
|
||||||
|
assert.deepEqual(await cache.remember('public', 'home', load), { version: 3 });
|
||||||
|
assert.equal(loads, 3, '数据库写入后应让公开缓存失效');
|
||||||
|
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 2 });
|
||||||
|
assert.equal(resultLoads, 2, '成绩相关写入后应让成绩缓存失效');
|
||||||
|
await cache.invalidate('results');
|
||||||
|
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 3 });
|
||||||
|
assert.equal(resultLoads, 3, '后台手动刷新后应重新生成成绩缓存');
|
||||||
|
await cache.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const cache = await createRedisCache({ env: {}, logger: silentLogger });
|
||||||
|
let loads = 0;
|
||||||
|
await cache.remember('public', 'home', async () => ++loads);
|
||||||
|
await cache.remember('public', 'home', async () => ++loads);
|
||||||
|
assert.equal(loads, 2, '未配置 Redis 时应始终回源');
|
||||||
|
assert.equal(cache.status, 'disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const client = new FakeRedisClient({ connectError: new Error('connection refused') });
|
||||||
|
const cache = await createRedisCache({
|
||||||
|
env: { REDIS_URL: 'redis://unavailable' },
|
||||||
|
logger: silentLogger,
|
||||||
|
clientFactory: () => client
|
||||||
|
});
|
||||||
|
assert.equal(cache.status, 'unavailable');
|
||||||
|
assert.equal(await cache.remember('public', 'home', async () => 'database'), 'database');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Redis 缓存测试通过');
|
||||||
@@ -89,7 +89,7 @@ await seededTestDatabase.close();
|
|||||||
|
|
||||||
const server = spawn(process.execPath, ['server.mjs'], {
|
const server = spawn(process.execPath, ['server.mjs'], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', PORT: String(port), SQLITE_PATH: testDb, PUBLIC_SITE_NAME: '环境变量考试中心', PUBLIC_SITE_CODE: 'ENV-TEST', PUBLIC_SITE_PHONE: '0518-1234 5678', PUBLIC_SITE_ADDRESS: '测试地址 1 号', PUBLIC_SITE_EMAIL: 'service@example.test', PUBLIC_SITE_HERO_TITLE: '一次配置,', PUBLIC_SITE_HERO_HIGHLIGHT: '统一首页文案。', PUBLIC_SITE_FOOTER_NOTICE: '环境变量页脚提示' },
|
env: { ...process.env, NODE_ENV: 'test', DATABASE_CLIENT: 'sqlite', REDIS_URL: '', PORT: String(port), SQLITE_PATH: testDb, PUBLIC_SITE_NAME: '环境变量考试中心', PUBLIC_SITE_CODE: 'ENV-TEST', PUBLIC_SITE_PHONE: '0518-1234 5678', PUBLIC_SITE_ADDRESS: '测试地址 1 号', PUBLIC_SITE_EMAIL: 'service@example.test', PUBLIC_SITE_HERO_TITLE: '一次配置,', PUBLIC_SITE_HERO_HIGHLIGHT: '统一首页文案。', PUBLIC_SITE_FOOTER_NOTICE: '环境变量页脚提示' },
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,6 +158,7 @@ try {
|
|||||||
assert.match(appSource, /data-modal-backdrop/, '弹窗应仅在点击背景层本身时关闭');
|
assert.match(appSource, /data-modal-backdrop/, '弹窗应仅在点击背景层本身时关闭');
|
||||||
assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');
|
assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');
|
||||||
assert.match(appSource, /data-action="archive-exam"/, '超级管理员界面应提供不可逆考试归档入口');
|
assert.match(appSource, /data-action="archive-exam"/, '超级管理员界面应提供不可逆考试归档入口');
|
||||||
|
assert.match(appSource, /data-action="refresh-results-cache"/, '成绩管理中心应提供 Redis 成绩缓存刷新入口');
|
||||||
assert.match(appSource, /归档不可撤销/, '归档前应明确提示成绩将永久锁定');
|
assert.match(appSource, /归档不可撤销/, '归档前应明确提示成绩将永久锁定');
|
||||||
|
|
||||||
const { DatabaseSync } = await import('node:sqlite');
|
const { DatabaseSync } = await import('node:sqlite');
|
||||||
@@ -724,6 +725,12 @@ try {
|
|||||||
assert.equal(publishResult.response.status, 200);
|
assert.equal(publishResult.response.status, 200);
|
||||||
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分');
|
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分');
|
||||||
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200);
|
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200);
|
||||||
|
const adminResults = await admin.request('/api/admin/results');
|
||||||
|
assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用');
|
||||||
|
assert.equal((await schoolAdmin.request('/api/admin/results/cache/refresh', { method: 'POST' })).response.status, 403, '仅超级管理员可以手动刷新成绩缓存');
|
||||||
|
const cacheRefresh = await admin.request('/api/admin/results/cache/refresh', { method: 'POST' });
|
||||||
|
assert.equal(cacheRefresh.response.status, 200, '超级管理员应能调用成绩缓存刷新接口');
|
||||||
|
assert.equal(cacheRefresh.data.refreshed, false, 'Redis 未配置时刷新接口应安全降级为数据库直读');
|
||||||
const results = await candidate.request('/api/candidate/results');
|
const results = await candidate.request('/api/candidate/results');
|
||||||
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
|
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
|
||||||
const resultSummary = results.data.summaries.find(item => item.examId === exam.id);
|
const resultSummary = results.data.summaries.find(item => item.examId === exam.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user