REDis
This commit is contained in:
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 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 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 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>` : '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user