From c31602593ab4d2d426071cdbd655be9379b9535c Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 20 Jul 2026 20:55:09 +0800 Subject: [PATCH] REDis --- .env.example | 7 ++ README.md | 15 ++++ app.js | 5 ++ package-lock.json | 98 +++++++++++++++++++++++ package.json | 4 +- server.mjs | 20 ++++- src/cache/redis-cache.mjs | 136 ++++++++++++++++++++++++++++++++ src/client/admin-views.mjs | 5 +- src/routes/admin.routes.mjs | 13 ++- src/routes/candidate.routes.mjs | 43 +++++----- src/routes/public.routes.mjs | 20 +++-- tests/cache.test.mjs | 109 +++++++++++++++++++++++++ tests/system.test.mjs | 9 ++- 13 files changed, 453 insertions(+), 31 deletions(-) create mode 100644 src/cache/redis-cache.mjs create mode 100644 tests/cache.test.mjs 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 = `
${currentExams.map(examButton).join('')}
${archivedExams.length ? `
历史归档考试 ${archivedExams.length} 场 · 成绩永久锁定
${archivedExams.map(examButton).join('')}
` : ''}`; - const toolbar = `
${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '模板导入会先暂存预览,确认后才原子写入数据库'}
${activeExam?.archivedAt ? '' : ''}${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '' : ''}
`; + const cacheButton = state.user.adminLevel === 'super' + ? `` + : ''; + const toolbar = `
${activeExam?.archivedAt ? '归档成绩只读区' : '成绩 Excel 工作区'}${activeExam?.archivedAt ? '本场成绩已永久锁定,仅保留导出与查阅能力' : '模板导入会先暂存预览,确认后才原子写入数据库'}
${cacheButton}${activeExam?.archivedAt ? '' : ''}${state.user.adminLevel === 'super' && !activeExam?.archivedAt ? '' : ''}
`; const metrics = `
报名考生${activeExam?.registrationCount ?? 0}本场已通过报名
录入进度${activeExam?.scored ?? 0} / ${activeExam?.enrolledSubjects ?? 0}剩余 ${activeExam?.missing ?? 0} 科次
已发布${activeExam?.published ?? 0}草稿 ${Math.max(0, (activeExam?.scored || 0) - (activeExam?.published || 0))} 条
成绩已出齐${activeExam?.complete ?? 0}
整场合格率${passRate == null ? '—' : `${passRate}%`}按本场排名或所设规则判定
成绩复议${examAppeals.length}当前考试累计
`; const ledger = `
${examResults.map(result => ``).join('') || ''}
考生考试 / 科目成绩排名 / 等级单科及格规则达线发布更新时间
${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)}
本场考试还没有成绩记录
`; const archiveLock = activeExam?.archivedAt ? `
${icons.check}
本场考试已归档${formatDate(activeExam.archivedAt, true)} 起,手工录入、Excel 导入和成绩复议改分均已永久关闭。
` : ''; diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index a9ac7a3..6fbefc7 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -4,6 +4,7 @@ import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../secu export function createAdminRoutes(context) { const { database, + cache, readDb, sendJson, sendError, @@ -1040,6 +1041,16 @@ export function createAdminRoutes(context) { await database.updateNotice(notice, log); 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 (!requirePermission(user, response, 'results.read')) return true; 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); 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 (!requirePermission(user, response, '*')) return true; diff --git a/src/routes/candidate.routes.mjs b/src/routes/candidate.routes.mjs index 8adb8ed..895c9e1 100644 --- a/src/routes/candidate.routes.mjs +++ b/src/routes/candidate.routes.mjs @@ -3,6 +3,8 @@ import { noticeForClient } from '../security/notice-content.mjs'; export function createCandidateRoutes(context) { const { database, + cache, + resultsCacheTtlSeconds, readDb, sendJson, sendError, @@ -126,25 +128,28 @@ export function createCandidateRoutes(context) { return sendJson(response, 201, { ok: true, registration: examRegistrationView(db, registration), message: '考试报名已提交' }); } if (request.method === 'GET' && pathname === '/api/candidate/results') { - 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 registration = registrations.find(reg => reg.id === result.registrationId); - const exam = db.exams.find(item => item.id === registration.examId); - const subject = exam.subjects.find(item => item.id === result.subjectId); - const appealInstance = db.workflowInstances.find(item => item.businessType === 'score_appeal' && item.businessId === result.id); - const appeal = appealInstance ? workflowView(db, appealInstance) : null; - const rank = resultRankInfo(db, result); - const pass = subjectPassEvaluation(db, result, subject); - return { - ...result, ...rank, grade: rank.grade, examId: exam.id, examName: exam.name, examCode: exam.code, examStart: exam.examStart, archivedAt: exam.archivedAt || null, - subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150, - passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore, - passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject), qualified: pass.qualified, - appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null - }; - }); - const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects); - return sendJson(response, 200, { ok: true, results, summaries }); + const payload = await cache.remember('results', `candidate:${encodeURIComponent(user.id)}`, async () => { + 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 registration = registrations.find(reg => reg.id === result.registrationId); + const exam = db.exams.find(item => item.id === registration.examId); + const subject = exam.subjects.find(item => item.id === result.subjectId); + const appealInstance = db.workflowInstances.find(item => item.businessType === 'score_appeal' && item.businessId === result.id); + const appeal = appealInstance ? workflowView(db, appealInstance) : null; + const rank = resultRankInfo(db, result); + const pass = subjectPassEvaluation(db, result, subject); + return { + ...result, ...rank, grade: rank.grade, examId: exam.id, examName: exam.name, examCode: exam.code, examStart: exam.examStart, archivedAt: exam.archivedAt || null, + subjectName: subject?.name || result.subjectId, fullScore: subject?.fullScore || 150, + passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore, + passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject), qualified: pass.qualified, + appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null + }; + }); + const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects); + return { ok: true, results, summaries }; + }, { ttlSeconds: resultsCacheTtlSeconds }); + return sendJson(response, 200, payload); } const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/); if (request.method === 'POST' && scoreAppealMatch) { diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs index 1e3429e..72062cb 100644 --- a/src/routes/public.routes.mjs +++ b/src/routes/public.routes.mjs @@ -3,6 +3,7 @@ import { noticeForClient } from '../security/notice-content.mjs'; export function createPublicRoutes(context) { const { database, + cache, readDb, publicSiteConfig, sendJson, @@ -51,16 +52,23 @@ export function createPublicRoutes(context) { } = context; async function handlePublic(pathname, response) { - const db = await readDb(); if (pathname === '/api/public/home') { - 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 })); - 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 } }); + 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 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 { 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\/([^/]+)$/); if (noticeMatch) { - const notice = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published'); - return notice ? sendJson(response, 200, { ok: true, notice: noticeForClient(notice) }) : sendError(response, 404, '通知不存在或尚未发布'); + const notice = await cache.remember('public', `notice:${encodeURIComponent(noticeMatch[1])}`, async () => { + 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; } diff --git a/tests/cache.test.mjs b/tests/cache.test.mjs new file mode 100644 index 0000000..595020d --- /dev/null +++ b/tests/cache.test.mjs @@ -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 缓存测试通过'); diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 55e5c25..266c209 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -89,7 +89,7 @@ await seededTestDatabase.close(); const server = spawn(process.execPath, ['server.mjs'], { 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'] }); @@ -158,6 +158,7 @@ try { assert.match(appSource, /data-modal-backdrop/, '弹窗应仅在点击背景层本身时关闭'); assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口'); assert.match(appSource, /data-action="archive-exam"/, '超级管理员界面应提供不可逆考试归档入口'); + assert.match(appSource, /data-action="refresh-results-cache"/, '成绩管理中心应提供 Redis 成绩缓存刷新入口'); assert.match(appSource, /归档不可撤销/, '归档前应明确提示成绩将永久锁定'); const { DatabaseSync } = await import('node:sqlite'); @@ -724,6 +725,12 @@ try { 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[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'); assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询'); const resultSummary = results.data.summaries.find(item => item.examId === exam.id);