This commit is contained in:
2026-07-20 20:55:09 +08:00 Unverified
parent 52497957f3
commit c31602593a
13 changed files with 453 additions and 31 deletions
+12 -1
View File
@@ -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;
+24 -19
View File
@@ -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) {
+14 -6
View File
@@ -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;
}