2 Commits
20 changed files with 786 additions and 81 deletions
+5 -1
View File
@@ -19,5 +19,9 @@ PUBLIC_SITE_HERO_HIGHLIGHT=贯穿每一次考试。
PUBLIC_SITE_HERO_DESCRIPTION=使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。
PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示
# 可选 Redis 缓存;容器外的 Redis 不应填写 127.0.0.1。
# 可选 Redis;容器外的 Redis 不应填写 127.0.0.1。
# 普通接口缓存使用 DB 0,认证状态会自动使用独立 DB 1。
# REDIS_URL=redis://redis-host:6379/0
# REDIS_SESSION_DB=1
# 也可让认证状态使用另一台 Redis(此时 URL 中可指定自己的逻辑 DB)。
# REDIS_SESSION_URL=rediss://session-redis-host:6379/1
+11 -1
View File
@@ -5,12 +5,18 @@ SQLITE_PATH=./data/exam.sqlite
HOST=127.0.0.1
PORT=4173
# 可选 Redis 公开接口缓存;未设置 REDIS_URL 时自动禁用并直接读取数据库
# 可选 Redis。普通接口缓存使用 DB 0;配置 REDIS_URL 后,登录状态默认自动使用独立的 DB 1
# 未设置任何 Redis 地址时,接口缓存和认证状态均使用本机内存模式。
# 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
# REDIS_SESSION_DB=1
# REDIS_SESSION_PREFIX=exam-information:auth
# AUTH_SESSION_TTL_SECONDS=28800
# 如需让认证状态使用另一台 Redis,可设置独立地址;URL 中可直接指定逻辑 DB。
# REDIS_SESSION_URL=rediss://session-redis.example.com:6379/1
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
INITIAL_ADMIN_USERNAME=admin
@@ -48,3 +54,7 @@ PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示
# 也可以用单个连接地址替代全部 MYSQL_* 连接参数:
# DATABASE_URL=mysql://exam_app:password@127.0.0.1:3306/exam_information
# 全量数据库状态快照的最长复用时间(毫秒)。应用内写入会立即失效;MySQL 外部直写最长延迟此时间可见。
DATABASE_STATE_CACHE_TTL_MS=30000
# Redis 未配置或暂不可用时,本机响应缓存的最大条目数。
LOCAL_CACHE_MAX_ENTRIES=200
+12 -4
View File
@@ -73,6 +73,7 @@
- 服务端角色权限校验
- SQLite / MySQL 8.4 双数据库持久化
- 可选 Redis 公开接口缓存,支持写后版本失效、热点请求合并和故障回源
- Redis 登录状态存储,和普通接口缓存使用不同逻辑数据库,支持多实例共享会话
- 规范关系模型、外键、唯一约束和业务索引
- 业务写入与审计日志使用原子事务提交
- 关键管理操作审计日志
@@ -130,7 +131,7 @@ docker compose logs --follow app
docker build --tag hengzhun-exam-system:local .
```
镜像默认监听 `0.0.0.0:4173`,以非 root 用户运行,并通过 `/api/public/home` 执行健康检查。需要连接 MySQL 或 Redis 时,用运行环境变量覆盖 `DATABASE_CLIENT``DATABASE_URL`/`MYSQL_*``REDIS_URL`;此时 SQLite 数据卷可以移除。
镜像默认监听 `0.0.0.0:4173`,以非 root 用户运行,并通过 `/api/public/home` 执行健康检查。需要连接 MySQL 或 Redis 时,用运行环境变量覆盖 `DATABASE_CLIENT``DATABASE_URL`/`MYSQL_*``REDIS_URL`认证状态默认使用同一 Redis 服务的独立 DB 1,也可以通过 `REDIS_SESSION_DB``REDIS_SESSION_URL` 单独配置。此时 SQLite 数据卷可以移除。
#### Gitea Actions 自动发布到 Docker Hub 与 Gitea 软件包
@@ -231,17 +232,23 @@ npm start
### Redis 缓存(可选)
配置 `REDIS_URL` 后,应用会缓存公开首页、已发布公告详情和每名考生的已发布成绩查询。公开数据默认 TTL 为 60 秒,成绩默认 TTL 为 24 小时;成绩录入/发布、批量导入、考试归档和成绩复议会自动使成绩缓存失效,超级管理员也可以在成绩管理中心手动刷新全部成绩缓存。Redis 在启动或运行期间不可用时,接口会自动回源数据库,不影响登录、报名和管理功能
配置 `REDIS_URL` 后,应用会缓存公开首页、已发布公告详情和每名考生的已发布成绩查询。公开数据默认 TTL 为 60 秒,成绩默认 TTL 为 24 小时;成绩录入/发布、批量导入、考试归档和成绩复议会自动使成绩缓存失效,超级管理员也可以在成绩管理中心手动刷新全部成绩缓存。Redis 未配置或暂时不可用时,应用会自动切换到有界本机缓存(默认最多 200 条),避免热点接口反复回源;写入后的失效语义保持不变
同一个 `REDIS_URL` 还会启用 Redis 认证状态存储:登录 Session、TOTP 登录挑战和 TOTP 绑定临时状态全部写入 Redis。普通接口缓存按 `REDIS_URL` 使用 DB 0 时,认证状态默认自动选择独立 DB 1;可通过 `REDIS_SESSION_DB` 修改逻辑 DB,或通过 `REDIS_SESSION_URL` 指向另一台 Redis。应用会拒绝让认证状态与普通缓存使用同一 Redis 端点的同一逻辑 DB。认证 Redis 配置后连接失败会阻止服务启动,避免多实例之间悄悄退回本机状态而出现随机掉线。完全未配置 Redis 时,认证状态仍使用本机内存,服务重启会要求重新登录。
数据库关系表会按写入版本复用进程内只读快照,避免每个请求重复扫描并转换全部业务表。应用自身写入会立即使快照失效;SQLite 还通过 `PRAGMA data_version` 检测其他连接的提交,MySQL 外部直写默认最多延迟 30 秒可见。可通过 `DATABASE_STATE_CACHE_TTL_MS` 调整 MySQL 快照时间,通过 `LOCAL_CACHE_MAX_ENTRIES` 控制本机接口缓存上限。
```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'
$env:REDIS_SESSION_DB = '1'
$env:AUTH_SESSION_TTL_SECONDS = '28800'
npm start
```
生产环境可使用 `redis://` 或启用 TLS 的 `rediss://` 连接地址,并通过 `REDIS_CONNECT_TIMEOUT_MS` 调整启动连接超时。
生产环境可使用 `redis://` 或启用 TLS 的 `rediss://` 连接地址,并通过 `REDIS_CONNECT_TIMEOUT_MS` 调整启动连接超时。若 Redis Cluster 不支持非 0 逻辑 DB,请用 `REDIS_SESSION_URL` 为认证状态配置独立 Redis 实例。
### 导入服务器 MySQL 测试数据
@@ -339,7 +346,8 @@ src/data/base.mjs 空业务库与系统基础配置
src/data/seed.mjs 手动测试数据生成器
scripts/import-test-data.mjs 独立测试数据导入脚本
src/http/responses.mjs JSON、文件与请求体处理
src/security/session.mjs Cookie 会话与当前用户
src/security/auth-state.mjs Redis / 本机会话与 TOTP 临时状态
src/security/session.mjs Cookie 解析与当前用户
src/security/authorization.mjs 管理层级、权限和数据范围
src/routes/public.routes.mjs 公开 API
src/routes/auth.routes.mjs 登录、注册与改密 API
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"start": "node server.mjs",
"test": "node tests/client-auth.test.mjs && node tests/cache.test.mjs && node tests/document-verification.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs",
"test": "node tests/client-auth.test.mjs && node tests/api-dedup.test.mjs && node tests/cache.test.mjs && node tests/auth-state.test.mjs && node tests/state-cache.test.mjs && node tests/document-verification.test.mjs && node tests/admission.test.mjs && node tests/seed.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",
+12 -4
View File
@@ -13,6 +13,7 @@ import { createAuthRoutes } from './src/routes/auth.routes.mjs';
import { createPublicRoutes } from './src/routes/public.routes.mjs';
import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs';
import { createSessionManager } from './src/security/session.mjs';
import { createAuthStateStore } from './src/security/auth-state.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';
@@ -41,7 +42,6 @@ const publicSiteConfig = Object.freeze({
heroDescription: process.env.PUBLIC_SITE_HERO_DESCRIPTION || '使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。',
footerNotice: process.env.PUBLIC_SITE_FOOTER_NOTICE || ''
});
const sessions = new Map();
const staticFiles = new Set([
'/index.html',
'/styles.css',
@@ -105,6 +105,13 @@ const initializeDatabase = () => createBaseDatabase({
});
const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
const cache = await createRedisCache();
let authState;
try {
authState = await createAuthStateStore();
} catch (error) {
await Promise.allSettled([persistentDatabase.close(), cache.close()]);
throw error;
}
const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateFeatureScores', 'updateExam', 'archiveExam']);
const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => {
const namespaces = ['public'];
@@ -121,7 +128,7 @@ const documentNumberDb = await readDb();
const missingNoticeNumbers = admissionRecords(documentNumberDb, 'placement').filter(item => item.status === 'final' && !item.payload?.noticeNumber);
if (missingNoticeNumbers.length) await database.saveAdmissionRecords(assignAdmissionNoticeNumbers(documentNumberDb, missingNoticeNumbers));
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ authState, readDb, sendError });
const requirePermission = createPermissionGuard(sendError);
function adminsForStep(db, adminLevel, profile) {
@@ -966,7 +973,7 @@ const routeContext = {
randomBytes,
uid,
nowIso,
sessions,
authState,
buildWorkbook,
buildCenterMaterialsWorkbook,
hasExcelResource,
@@ -1020,12 +1027,13 @@ server.listen(port, host, () => {
console.log(`衡准考试信息管理系统:http://${host}:${port}`);
console.log(`数据库:${database.client}${database.location}`);
console.log(`Redis 缓存:${cache.status === 'ready' ? '已连接' : cache.status === 'disabled' ? '未配置' : '不可用,已回源数据库'}`);
console.log(`登录状态:${authState.status === 'ready' ? `Redis DB ${authState.database}` : '本机内存(Redis 未配置)'}`);
});
async function shutdown(signal) {
console.log(`收到 ${signal},正在关闭服务...`);
server.close(async () => {
await Promise.allSettled([database.close(), cache.close()]);
await Promise.allSettled([database.close(), cache.close(), authState.close()]);
process.exit(0);
});
}
+55 -10
View File
@@ -5,26 +5,69 @@ function positiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
}
function disabledCache(status = 'disabled') {
function disabledCache(status = 'disabled', { ttlSeconds = 60, maxEntries = 200 } = {}) {
const values = new Map();
const pending = new Map();
const generations = new Map();
const cacheKey = (namespace, key) => `${namespace}:${key}`;
const generation = namespace => generations.get(namespace) || 0;
const prune = () => {
const now = Date.now();
for (const [key, entry] of values) if (entry.expiresAt <= now) values.delete(key);
while (values.size > maxEntries) values.delete(values.keys().next().value);
};
return {
enabled: false,
status,
async remember(_namespace, _key, loader) {
return loader();
async remember(namespace, key, loader, options = {}) {
const fullKey = cacheKey(namespace, key);
const cached = values.get(fullKey);
if (cached && cached.expiresAt > Date.now()) return cached.value;
if (cached) values.delete(fullKey);
const startedGeneration = generation(namespace);
const active = pending.get(fullKey);
if (active?.generation === startedGeneration) return active.promise;
const loading = Promise.resolve(loader()).then(value => {
if (generation(namespace) === startedGeneration) {
const lifetime = positiveInteger(options.ttlSeconds, ttlSeconds, 86400);
values.set(fullKey, { value, expiresAt: Date.now() + lifetime * 1000 });
prune();
}
return value;
}).finally(() => {
if (pending.get(fullKey)?.promise === loading) pending.delete(fullKey);
});
pending.set(fullKey, { generation: startedGeneration, promise: loading });
return loading;
},
async invalidate() { return false; },
async close() {}
async invalidate(namespace) {
generations.set(namespace, generation(namespace) + 1);
const prefix = `${namespace}:`;
for (const key of values.keys()) if (key.startsWith(prefix)) values.delete(key);
// Preserve the public meaning of this return value: no Redis namespace
// was refreshed, even though the local fallback was invalidated.
return false;
},
async close() {
values.clear();
pending.clear();
}
};
}
export async function createRedisCache({ env = process.env, logger = console, clientFactory = createClient } = {}) {
const url = String(env.REDIS_URL || '').trim();
if (!url) return disabledCache();
const defaultTtlSeconds = positiveInteger(env.REDIS_CACHE_TTL_SECONDS, 60, 86400);
const localMaxEntries = positiveInteger(env.LOCAL_CACHE_MAX_ENTRIES, 200, 5000);
if (!url) return disabledCache('disabled', { ttlSeconds: defaultTtlSeconds, maxEntries: localMaxEntries });
const fallback = disabledCache('unavailable', { ttlSeconds: defaultTtlSeconds, maxEntries: localMaxEntries });
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;
@@ -54,7 +97,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
} catch (error) {
warn(error);
if (client.isOpen) client.destroy();
return disabledCache('unavailable');
return fallback;
}
const versionKey = namespace => `${prefix}:namespace:${namespace}`;
@@ -75,7 +118,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
return client.isReady ? 'ready' : 'unavailable';
},
async remember(namespace, key, loader, { ttlSeconds = defaultTtlSeconds } = {}) {
if (!client.isReady) return loader();
if (!client.isReady) return fallback.remember(namespace, key, loader, { ttlSeconds });
try {
const version = await namespaceVersion(namespace);
const cacheKey = `${prefix}:${namespace}:${version}:${key}`;
@@ -99,10 +142,11 @@ export async function createRedisCache({ env = process.env, logger = console, cl
return loading;
} catch (error) {
warn(error);
return loader();
return fallback.remember(namespace, key, loader, { ttlSeconds });
}
},
async invalidate(namespace) {
await fallback.invalidate(namespace);
if (!client.isReady) return false;
try {
await client.incr(versionKey(namespace));
@@ -113,6 +157,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
}
},
async close() {
await fallback.close();
if (client.isOpen) await client.quit();
}
};
+18 -1
View File
@@ -1,4 +1,6 @@
export async function api(path, options = {}) {
const pendingReads = new Map();
async function request(path, options) {
const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData;
const response = await fetch(path, {
credentials: 'same-origin',
@@ -15,3 +17,18 @@ export async function api(path, options = {}) {
}
return data;
}
export function api(path, options = {}) {
const method = String(options.method || 'GET').toUpperCase();
if (method !== 'GET' || options.body != null || options.signal) return request(path, options);
// A quick double click or repeated render must not download and parse the
// same large JSON response more than once while the first request is active.
const key = String(path);
if (pendingReads.has(key)) return pendingReads.get(key);
const loading = request(path, options).finally(() => {
if (pendingReads.get(key) === loading) pendingReads.delete(key);
});
pendingReads.set(key, loading);
return loading;
}
+8 -2
View File
@@ -1,5 +1,7 @@
import { synchronizeMysqlPartitions } from './partition-storage.mjs';
import { createStateCache } from './state-cache.mjs';
export function createMysqlAdapter(context) {
const {
mkdir,
@@ -408,7 +410,9 @@ export function createMysqlAdapter(context) {
partitionConnection.release();
}
let stateCache;
const transaction = async operations => {
stateCache?.invalidate();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
@@ -419,10 +423,11 @@ export function createMysqlAdapter(context) {
await connection.rollback();
throw error;
} finally {
stateCache?.invalidate();
connection.release();
}
};
const read = async () => {
const loadState = async () => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
@@ -436,10 +441,11 @@ export function createMysqlAdapter(context) {
connection.release();
}
};
stateCache = createStateCache({ load: loadState });
return createRepository({
client: 'mysql',
location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`,
read,
read: stateCache.read,
transaction,
close: async () => pool.end()
});
+21 -3
View File
@@ -1,4 +1,5 @@
import { synchronizeSqlitePartitions } from './partition-storage.mjs';
import { createStateCache } from './state-cache.mjs';
export function createSqliteAdapter(context) {
const {
@@ -19,8 +20,14 @@ export function createSqliteAdapter(context) {
await mkdir(dirname(path), { recursive: true });
const connection = new DatabaseSync(path, { timeout: 5000 });
connection.exec('PRAGMA journal_mode = WAL;');
connection.exec('PRAGMA synchronous = NORMAL;');
connection.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -65536;
PRAGMA mmap_size = 268435456;
PRAGMA wal_autocheckpoint = 1000;
`);
const tableExists = name => Boolean(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
const ensureColumns = (table, columns) => {
if (!tableExists(table)) return;
@@ -484,7 +491,16 @@ export function createSqliteAdapter(context) {
synchronizeSqlitePartitions(connection);
const dataVersion = connection.prepare('PRAGMA data_version');
const stateCache = createStateCache({
load: () => stateFromRows(readSqliteRows(connection)),
version: () => Number(dataVersion.get().data_version)
});
const transaction = async operations => {
// Call this before the first possible await so request-local mutations of
// the previous snapshot can never be observed by another request.
stateCache.invalidate();
connection.exec('BEGIN IMMEDIATE');
try {
for (const item of operations) connection.prepare(item.sql).run(...item.params);
@@ -493,12 +509,14 @@ export function createSqliteAdapter(context) {
} catch (error) {
connection.exec('ROLLBACK');
throw error;
} finally {
stateCache.invalidate();
}
};
return createRepository({
client: 'sqlite',
location: path,
read: async () => stateFromRows(readSqliteRows(connection)),
read: stateCache.read,
transaction,
close: async () => connection.close()
});
+55
View File
@@ -0,0 +1,55 @@
function cacheDuration(value, fallback = 30000) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? Math.min(Math.trunc(parsed), 3600000) : fallback;
}
/**
* Keeps the materialized application state in process instead of rebuilding it
* from every relational table for every HTTP request. Writes explicitly
* invalidate the snapshot; an optional version reader also detects changes made
* by another database connection.
*/
export function createStateCache({ load, version, maxAgeMs = process.env.DATABASE_STATE_CACHE_TTL_MS }) {
// A database-native version token is stronger than a timer, so SQLite can
// keep the snapshot indefinitely while still observing external commits.
const ttlMs = version ? 0 : cacheDuration(maxAgeMs);
let snapshot = null;
let snapshotVersion;
let loadedAt = 0;
let generation = 0;
let pending = null;
function invalidate() {
generation += 1;
snapshot = null;
snapshotVersion = undefined;
loadedAt = 0;
pending = null;
}
async function read() {
const currentVersion = version ? await version() : undefined;
const freshByAge = !ttlMs || Date.now() - loadedAt < ttlMs;
if (snapshot && freshByAge && (!version || currentVersion === snapshotVersion)) return snapshot;
if (pending && (!version || pending.version === currentVersion)) return pending.promise;
const startedGeneration = generation;
const loading = Promise.resolve().then(load).then(state => {
if (generation === startedGeneration) {
snapshot = state;
snapshotVersion = currentVersion;
loadedAt = Date.now();
}
return state;
});
pending = { version: currentVersion, promise: loading };
try {
return await loading;
} finally {
if (pending?.promise === loading) pending = null;
}
}
return { read, invalidate };
}
+7 -7
View File
@@ -58,7 +58,7 @@ export function createAdminRoutes(context) {
randomBytes,
uid,
nowIso,
sessions,
authState,
buildWorkbook,
buildCenterMaterialsWorkbook,
hasExcelResource,
@@ -506,7 +506,7 @@ export function createAdminRoutes(context) {
target.active = body.active == null ? target.active : Boolean(body.active);
target.displayName = cleanText(body.displayName || target.displayName, 80);
await database.updateAdmin(target, false, logAction(db, user, target.active ? '启用招生学校账号' : '停用招生学校账号', `${target.displayName} · ${target.username}`));
if (!target.active) for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
if (!target.active) await authState.deleteUserSessions(target.id);
return sendJson(response, 200, { ok: true, account: { ...safeUser(target), active: target.active } });
}
const admissionAccountResetMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)\/reset-password$/);
@@ -518,7 +518,7 @@ export function createAdminRoutes(context) {
target.passwordHash = hashPassword(temporaryPassword);
target.active = true;
await database.updateAdmin(target, true, logAction(db, user, '重置招生学校账号密码', `${target.displayName} · ${target.username}`));
for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
await authState.deleteUserSessions(target.id);
return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
}
const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/);
@@ -850,7 +850,7 @@ export function createAdminRoutes(context) {
Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass?.id || target.classId || null, active: body.active == null ? target.active : Boolean(body.active) });
if (password) target.passwordHash = hashPassword(password);
await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护管理员账户', `${target.displayName} · ${adminLevelNames[target.adminLevel]} · ${target.active ? '启用' : '停用'}`));
if (!target.active || password) for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
if (!target.active || password) await authState.deleteUserSessions(target.id);
return sendJson(response, 200, { ok: true, admin: safeUser(target) });
}
const adminPasswordResetMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)\/reset-password$/);
@@ -863,7 +863,7 @@ export function createAdminRoutes(context) {
target.passwordHash = hashPassword(temporaryPassword);
target.active = true;
await database.updateAdmin(target, true, logAction(db, user, '重置管理员密码', `${target.displayName} · ${target.username}`));
for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
await authState.deleteUserSessions(target.id);
return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
}
if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') {
@@ -1261,7 +1261,7 @@ export function createAdminRoutes(context) {
await database.updateCandidateArchives(targets, logAction(db, user, archived ? '批量归档考生账户' : '批量恢复考生账户', `${scopeLabel} · ${targets.length} 个账户`));
if (archived && targets.length) {
const targetUserIds = new Set(targets.map(item => item.id));
for (const [token, session] of sessions) if (targetUserIds.has(session.userId)) sessions.delete(token);
await authState.deleteUsersSessions(targetUserIds);
}
return sendJson(response, 200, { ok: true, archived, count: targets.length, scopeLabel });
}
@@ -1276,7 +1276,7 @@ export function createAdminRoutes(context) {
target.passwordHash = hashPassword(temporaryPassword);
target.mustChangePassword = true;
await database.changePassword(target, logAction(db, user, '重置考生密码', `${target.candidateNumber} · ${profile.name}`));
for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
await authState.deleteUserSessions(target.id);
return sendJson(response, 200, { ok: true, candidateNumber: target.candidateNumber, temporaryPassword });
}
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
+21 -36
View File
@@ -56,7 +56,7 @@ export function createAuthRoutes(context) {
randomBytes,
uid,
nowIso,
sessions,
authState,
buildWorkbook,
hasExcelResource,
parseWorkbook,
@@ -64,21 +64,11 @@ export function createAuthRoutes(context) {
permissionsByLevel
} = context;
const loginChallenges = new Map();
const totpSetups = new Map();
const challengeLifetime = 5 * 60 * 1000;
function pruneTemporaryState() {
const now = Date.now();
for (const [key, value] of loginChallenges) if (value.expiresAt < now) loginChallenges.delete(key);
for (const [key, value] of totpSetups) if (value.expiresAt < now) totpSetups.delete(key);
}
function issueSession(user) {
async function issueSession(user) {
const token = randomBytes(32).toString('hex');
sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
await authState.createSession(token, user.id);
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return { token, cookie: `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=28800` };
return { token, cookie: `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=${authState.sessionTtlSeconds}` };
}
function sessionToken(request) {
@@ -127,7 +117,6 @@ export function createAuthRoutes(context) {
return sendJson(response, 201, { ok: true, registrationNumber: generated.number, message: '报名号已生成,请使用该号码登录并补全个人信息' });
}
if (request.method === 'POST' && pathname === '/api/auth/login') {
pruneTemporaryState();
const body = await readJson(request);
const db = await readDb();
const account = cleanText(body.username, 120).toLowerCase();
@@ -135,40 +124,38 @@ export function createAuthRoutes(context) {
if (!user || user.active === false || user.archivedAt || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
if (user.totpEnabled) {
const challenge = randomBytes(32).toString('base64url');
loginChallenges.set(challenge, { userId: user.id, expiresAt: Date.now() + challengeLifetime, attempts: 0 });
await authState.createLoginChallenge(challenge, user.id);
return sendJson(response, 200, { ok: true, requiresTotp: true, challenge });
}
const session = issueSession(user);
const session = await issueSession(user);
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': session.cookie });
}
if (request.method === 'POST' && pathname === '/api/auth/login/totp') {
pruneTemporaryState();
const body = await readJson(request);
const challengeKey = String(body.challenge || '');
const challenge = loginChallenges.get(challengeKey);
if (!challenge || challenge.expiresAt < Date.now() || challenge.attempts >= 5) {
loginChallenges.delete(challengeKey);
const challenge = await authState.getLoginChallenge(challengeKey);
if (!challenge || challenge.attempts >= 5) {
await authState.deleteLoginChallenge(challengeKey);
return sendError(response, 401, '验证请求已过期,请重新输入账号和密码');
}
const db = await readDb();
const user = db.users.find(item => item.id === challenge.userId);
if (!user || !user.totpEnabled || user.active === false || user.archivedAt) {
loginChallenges.delete(challengeKey);
await authState.deleteLoginChallenge(challengeKey);
return sendError(response, 401, '验证请求已失效,请重新登录');
}
let verified = null;
try { verified = verifySecondFactor(user, body.code); } catch {}
if (!verified) {
challenge.attempts += 1;
if (challenge.attempts >= 5) loginChallenges.delete(challengeKey);
return sendError(response, 401, challenge.attempts >= 5 ? '验证失败次数过多,请重新登录' : '验证码或恢复码不正确');
const failure = await authState.recordLoginChallengeFailure(challengeKey, 5);
return sendError(response, 401, failure?.exhausted ? '验证失败次数过多,请重新登录' : failure ? '验证码或恢复码不正确' : '验证请求已过期,请重新输入账号和密码');
}
if (verified.type === 'totp') user.totpLastUsedStep = verified.step;
else user.totpRecoveryCodes = verified.recoveryCodes;
const log = verified.type === 'recovery' ? logAction(db, user, '使用 TOTP 恢复码登录', user.username) : null;
await database.updateTotpSecurity(user, log);
loginChallenges.delete(challengeKey);
const session = issueSession(user);
await authState.deleteLoginChallenge(challengeKey);
const session = await issueSession(user);
return sendJson(response, 200, { ok: true, user: safeUser(user), usedRecoveryCode: verified.type === 'recovery' }, { 'Set-Cookie': session.cookie });
}
if (request.method === 'POST' && pathname === '/api/auth/change-password') {
@@ -197,7 +184,6 @@ export function createAuthRoutes(context) {
});
}
if (request.method === 'POST' && pathname === '/api/auth/totp/setup') {
pruneTemporaryState();
const user = await requireUser(request, response);
if (!user) return true;
if (user.mustChangePassword) return sendError(response, 400, '请先修改初始密码,再启用二次验证');
@@ -209,18 +195,17 @@ export function createAuthRoutes(context) {
const secret = createTotpSecret();
const uri = buildOtpAuthUri({ secret, account: user.candidateNumber || user.username, issuer });
const token = sessionToken(request);
totpSetups.set(token, { userId: user.id, secret, expiresAt: Date.now() + 10 * 60 * 1000 });
await authState.createTotpSetup(token, user.id, secret);
const qrCode = await QRCode.toDataURL(uri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
return sendJson(response, 200, { ok: true, secret, uri, qrCode, expiresIn: 600 });
}
if (request.method === 'POST' && pathname === '/api/auth/totp/enable') {
pruneTemporaryState();
const user = await requireUser(request, response);
if (!user) return true;
const token = sessionToken(request);
const setup = totpSetups.get(token);
if (!setup || setup.userId !== user.id || setup.expiresAt < Date.now()) {
totpSetups.delete(token);
const setup = await authState.getTotpSetup(token);
if (!setup || setup.userId !== user.id) {
await authState.deleteTotpSetup(token);
return sendError(response, 400, '绑定信息已过期,请重新开始');
}
const body = await readJson(request);
@@ -234,7 +219,7 @@ export function createAuthRoutes(context) {
const db = await readDb();
const log = logAction(db, user, '启用 TOTP 二次验证', user.username);
await database.updateTotpSecurity(user, log);
totpSetups.delete(token);
await authState.deleteTotpSetup(token);
return sendJson(response, 200, { ok: true, recoveryCodes, user: safeUser(user) });
}
if (request.method === 'POST' && pathname === '/api/auth/totp/recovery-codes') {
@@ -270,12 +255,12 @@ export function createAuthRoutes(context) {
const db = await readDb();
const log = logAction(db, user, '关闭 TOTP 二次验证', user.username);
await database.updateTotpSecurity(user, log);
totpSetups.delete(sessionToken(request));
await authState.deleteTotpSetup(sessionToken(request));
return sendJson(response, 200, { ok: true, user: safeUser(user) });
}
if (request.method === 'POST' && pathname === '/api/auth/logout') {
const token = parseCookies(request).hz_session;
if (token) sessions.delete(token);
if (token) await authState.deleteSession(token);
return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' });
}
return false;
-1
View File
@@ -55,7 +55,6 @@ export function createCandidateRoutes(context) {
verifyPassword,
uid,
nowIso,
sessions,
buildWorkbook,
hasExcelResource,
parseWorkbook,
-1
View File
@@ -47,7 +47,6 @@ export function createPublicRoutes(context) {
verifyPassword,
uid,
nowIso,
sessions,
buildWorkbook,
hasExcelResource,
parseWorkbook,
+259
View File
@@ -0,0 +1,259 @@
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 nonNegativeInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 ? Math.min(parsed, maximum) : fallback;
}
function redisDatabase(url) {
try {
const pathname = new URL(url).pathname.replace(/^\//, '');
const parsed = Number(pathname || 0);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
} catch {
return 0;
}
}
function redisEndpoint(url) {
try {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.hostname}:${parsed.port || '6379'}`;
} catch {
return '';
}
}
function memoryAuthState({ sessionTtlSeconds, loginChallengeTtlSeconds, totpSetupTtlSeconds }) {
const sessions = new Map();
const loginChallenges = new Map();
const totpSetups = new Map();
function liveEntry(map, key) {
const entry = map.get(key);
if (!entry || entry.expiresAt <= Date.now()) {
map.delete(key);
return null;
}
return entry;
}
return {
status: 'disabled',
backend: 'memory',
database: null,
sessionTtlSeconds,
async createSession(token, userId) {
sessions.set(token, { userId, expiresAt: Date.now() + sessionTtlSeconds * 1000 });
},
async getSession(token) {
const entry = liveEntry(sessions, token);
return entry ? { userId: entry.userId } : null;
},
async deleteSession(token) {
return sessions.delete(token);
},
async deleteUserSessions(userId) {
let deleted = 0;
for (const [token, session] of sessions) {
if (session.userId === userId) {
sessions.delete(token);
deleted += 1;
}
}
return deleted;
},
async deleteUsersSessions(userIds) {
const targets = userIds instanceof Set ? userIds : new Set(userIds);
let deleted = 0;
for (const [token, session] of sessions) {
if (targets.has(session.userId)) {
sessions.delete(token);
deleted += 1;
}
}
return deleted;
},
async createLoginChallenge(key, userId) {
loginChallenges.set(key, { userId, attempts: 0, expiresAt: Date.now() + loginChallengeTtlSeconds * 1000 });
},
async getLoginChallenge(key) {
const entry = liveEntry(loginChallenges, key);
return entry ? { userId: entry.userId, attempts: entry.attempts } : null;
},
async recordLoginChallengeFailure(key, maximumAttempts) {
const entry = liveEntry(loginChallenges, key);
if (!entry) return null;
entry.attempts += 1;
const exhausted = entry.attempts >= maximumAttempts;
if (exhausted) loginChallenges.delete(key);
return { attempts: entry.attempts, exhausted };
},
async deleteLoginChallenge(key) {
return loginChallenges.delete(key);
},
async createTotpSetup(token, userId, secret) {
totpSetups.set(token, { userId, secret, expiresAt: Date.now() + totpSetupTtlSeconds * 1000 });
},
async getTotpSetup(token) {
const entry = liveEntry(totpSetups, token);
return entry ? { userId: entry.userId, secret: entry.secret } : null;
},
async deleteTotpSetup(token) {
return totpSetups.delete(token);
},
async close() {
sessions.clear();
loginChallenges.clear();
totpSetups.clear();
}
};
}
const recordFailureScript = `
if redis.call('EXISTS', KEYS[1]) == 0 then
return -1
end
local attempts = redis.call('HINCRBY', KEYS[1], 'attempts', 1)
if attempts >= tonumber(ARGV[1]) then
redis.call('DEL', KEYS[1])
end
return attempts
`;
export async function createAuthStateStore({ env = process.env, logger = console, clientFactory = createClient } = {}) {
const cacheUrl = String(env.REDIS_URL || '').trim();
const explicitSessionUrl = String(env.REDIS_SESSION_URL || '').trim();
const sessionUrl = explicitSessionUrl || cacheUrl;
const sessionTtlSeconds = positiveInteger(env.AUTH_SESSION_TTL_SECONDS, 8 * 60 * 60, 30 * 24 * 60 * 60);
const loginChallengeTtlSeconds = positiveInteger(env.AUTH_LOGIN_CHALLENGE_TTL_SECONDS, 5 * 60, 60 * 60);
const totpSetupTtlSeconds = positiveInteger(env.AUTH_TOTP_SETUP_TTL_SECONDS, 10 * 60, 60 * 60);
const lifetimes = { sessionTtlSeconds, loginChallengeTtlSeconds, totpSetupTtlSeconds };
if (!sessionUrl) return memoryAuthState(lifetimes);
const cacheDatabase = redisDatabase(cacheUrl);
const configuredDatabase = String(env.REDIS_SESSION_DB || '').trim();
const sessionDatabase = configuredDatabase
? nonNegativeInteger(configuredDatabase, cacheDatabase === 0 ? 1 : 0, 1024)
: explicitSessionUrl
? redisDatabase(explicitSessionUrl)
: cacheDatabase === 0 ? 1 : 0;
if (cacheUrl && redisEndpoint(cacheUrl) === redisEndpoint(sessionUrl) && cacheDatabase === sessionDatabase) {
throw new Error('Redis 认证状态必须使用与普通缓存不同的逻辑数据库;请配置 REDIS_SESSION_DB 或 REDIS_SESSION_URL');
}
const prefix = String(env.REDIS_SESSION_PREFIX || 'exam-information:auth')
.trim()
.replace(/[^a-zA-Z0-9:_-]/g, '-') || 'exam-information:auth';
const connectTimeout = positiveInteger(env.REDIS_CONNECT_TIMEOUT_MS, 1500, 30000);
const client = clientFactory({
url: sessionUrl,
database: sessionDatabase,
socket: { connectTimeout }
});
client.on('error', error => logger.error(`Redis 认证状态存储错误:${error?.message || error}`));
try {
await client.connect();
} catch (error) {
if (client.isOpen) client.destroy();
throw new Error(`Redis 认证状态存储连接失败:${error?.message || error}`, { cause: error });
}
const sessionKey = token => `${prefix}:session:${token}`;
const userSessionsKey = userId => `${prefix}:user-sessions:${userId}`;
const loginChallengeKey = key => `${prefix}:login-challenge:${key}`;
const totpSetupKey = token => `${prefix}:totp-setup:${token}`;
async function deleteUserSessions(userId) {
const indexKey = userSessionsKey(userId);
const tokens = await client.sMembers(indexKey);
if (!tokens.length) {
await client.del(indexKey);
return 0;
}
const transaction = client.multi();
for (const token of tokens) transaction.del(sessionKey(token));
transaction.del(indexKey);
await transaction.exec();
return tokens.length;
}
return {
status: 'ready',
backend: 'redis',
database: sessionDatabase,
sessionTtlSeconds,
async createSession(token, userId) {
const indexKey = userSessionsKey(userId);
const transaction = client.multi();
transaction.set(sessionKey(token), userId, { EX: sessionTtlSeconds });
transaction.sAdd(indexKey, token);
transaction.expire(indexKey, sessionTtlSeconds);
await transaction.exec();
},
async getSession(token) {
const userId = await client.get(sessionKey(token));
return userId ? { userId } : null;
},
async deleteSession(token) {
const key = sessionKey(token);
const userId = await client.get(key);
const transaction = client.multi();
transaction.del(key);
if (userId) transaction.sRem(userSessionsKey(userId), token);
await transaction.exec();
return Boolean(userId);
},
deleteUserSessions,
async deleteUsersSessions(userIds) {
const counts = await Promise.all([...userIds].map(deleteUserSessions));
return counts.reduce((sum, count) => sum + count, 0);
},
async createLoginChallenge(key, userId) {
const redisKey = loginChallengeKey(key);
const transaction = client.multi();
transaction.hSet(redisKey, { userId, attempts: '0' });
transaction.expire(redisKey, loginChallengeTtlSeconds);
await transaction.exec();
},
async getLoginChallenge(key) {
const entry = await client.hGetAll(loginChallengeKey(key));
return entry.userId ? { userId: entry.userId, attempts: Number(entry.attempts || 0) } : null;
},
async recordLoginChallengeFailure(key, maximumAttempts) {
const attempts = Number(await client.eval(recordFailureScript, {
keys: [loginChallengeKey(key)],
arguments: [String(maximumAttempts)]
}));
return attempts < 0 ? null : { attempts, exhausted: attempts >= maximumAttempts };
},
async deleteLoginChallenge(key) {
return Boolean(await client.del(loginChallengeKey(key)));
},
async createTotpSetup(token, userId, secret) {
const key = totpSetupKey(token);
const transaction = client.multi();
transaction.hSet(key, { userId, secret });
transaction.expire(key, totpSetupTtlSeconds);
await transaction.exec();
},
async getTotpSetup(token) {
const entry = await client.hGetAll(totpSetupKey(token));
return entry.userId && entry.secret ? { userId: entry.userId, secret: entry.secret } : null;
},
async deleteTotpSetup(token) {
return Boolean(await client.del(totpSetupKey(token)));
},
async close() {
if (client.isOpen) await client.quit();
}
};
}
+3 -6
View File
@@ -1,4 +1,4 @@
export function createSessionManager({ sessions, readDb, sendError }) {
export function createSessionManager({ authState, readDb, sendError }) {
function normalizeUser(user) {
if (user?.role === 'admin' && !user.adminLevel) return { ...user, adminLevel: 'super' };
return user;
@@ -13,11 +13,8 @@ export function createSessionManager({ sessions, readDb, sendError }) {
async function currentUser(request) {
const token = parseCookies(request).hz_session;
const session = token && sessions.get(token);
if (!session || session.expiresAt < Date.now()) {
if (token) sessions.delete(token);
return null;
}
const session = token ? await authState.getSession(token) : null;
if (!session) return null;
const db = await readDb();
request.authDb = db;
const user = db.users.find(item => item.id === session.userId) || null;
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { api } from '../src/client/api.mjs';
const originalFetch = globalThis.fetch;
let calls = 0;
let release;
globalThis.fetch = async () => {
calls += 1;
await new Promise(resolve => { release = resolve; });
return new Response(JSON.stringify({ ok: true, calls }), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
try {
const first = api('/api/large-ledger');
const second = api('/api/large-ledger');
await Promise.resolve();
assert.equal(calls, 1, '并发的相同 GET 请求应只发送一次');
release();
assert.deepEqual(await first, { ok: true, calls: 1 });
assert.deepEqual(await second, { ok: true, calls: 1 });
} finally {
globalThis.fetch = originalFetch;
}
console.log('API read deduplication tests passed');
+209
View File
@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { createAuthStateStore } from '../src/security/auth-state.mjs';
class FakeRedisClient extends EventEmitter {
constructor({ connectError = null } = {}) {
super();
this.connectError = connectError;
this.isOpen = false;
this.values = new Map();
this.sets = new Map();
this.hashes = new Map();
}
async connect() {
if (this.connectError) throw this.connectError;
this.isOpen = true;
}
async get(key) {
return this.values.has(key) ? this.values.get(key) : null;
}
async set(key, value) {
this.values.set(key, String(value));
return 'OK';
}
async del(key) {
const deleted = Number(this.values.delete(key)) + Number(this.sets.delete(key)) + Number(this.hashes.delete(key));
return deleted ? 1 : 0;
}
async sAdd(key, value) {
if (!this.sets.has(key)) this.sets.set(key, new Set());
const before = this.sets.get(key).size;
this.sets.get(key).add(value);
return this.sets.get(key).size - before;
}
async sRem(key, value) {
return Number(this.sets.get(key)?.delete(value) || false);
}
async sMembers(key) {
return [...(this.sets.get(key) || [])];
}
async expire() {
return 1;
}
async hSet(key, entries) {
if (!this.hashes.has(key)) this.hashes.set(key, new Map());
for (const [field, value] of Object.entries(entries)) this.hashes.get(key).set(field, String(value));
return Object.keys(entries).length;
}
async hGetAll(key) {
return Object.fromEntries(this.hashes.get(key) || []);
}
async eval(_script, { keys, arguments: scriptArguments }) {
const hash = this.hashes.get(keys[0]);
if (!hash) return -1;
const attempts = Number(hash.get('attempts') || 0) + 1;
hash.set('attempts', String(attempts));
if (attempts >= Number(scriptArguments[0])) this.hashes.delete(keys[0]);
return attempts;
}
multi() {
const operations = [];
const transaction = {};
for (const method of ['set', 'del', 'sAdd', 'sRem', 'expire', 'hSet']) {
transaction[method] = (...args) => {
operations.push(() => this[method](...args));
return transaction;
};
}
transaction.exec = async () => Promise.all(operations.map(operation => operation()));
return transaction;
}
async quit() {
this.isOpen = false;
}
destroy() {
this.isOpen = false;
}
}
const silentLogger = { error() {} };
{
const state = await createAuthStateStore({ env: {} });
assert.equal(state.status, 'disabled');
assert.equal(state.backend, 'memory');
await state.createSession('session-1', 'user-1');
assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' });
await state.createLoginChallenge('challenge-1', 'user-1');
assert.deepEqual(await state.getLoginChallenge('challenge-1'), { userId: 'user-1', attempts: 0 });
await state.createTotpSetup('session-1', 'user-1', 'SECRET');
assert.deepEqual(await state.getTotpSetup('session-1'), { userId: 'user-1', secret: 'SECRET' });
assert.equal(await state.deleteUserSessions('user-1'), 1);
assert.equal(await state.getSession('session-1'), null);
await state.close();
}
{
const client = new FakeRedisClient();
let clientOptions;
const state = await createAuthStateStore({
env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_PREFIX: 'test:auth' },
logger: silentLogger,
clientFactory(options) {
clientOptions = options;
return client;
}
});
assert.equal(state.status, 'ready');
assert.equal(state.backend, 'redis');
assert.equal(state.database, 1, '普通缓存使用 DB 0 时,认证状态应自动使用 DB 1');
assert.equal(clientOptions.database, 1);
assert.equal(clientOptions.url, 'redis://cache.example:6379/0');
await state.createSession('session-1', 'user-1');
await state.createSession('session-2', 'user-1');
assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' });
assert.equal(await state.deleteUserSessions('user-1'), 2);
assert.equal(await state.getSession('session-1'), null);
assert.equal(await state.getSession('session-2'), null);
await state.createLoginChallenge('challenge-1', 'user-1');
for (let attempts = 1; attempts <= 4; attempts += 1) {
assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts, exhausted: false });
}
assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts: 5, exhausted: true });
assert.equal(await state.getLoginChallenge('challenge-1'), null);
await state.createTotpSetup('session-3', 'user-1', 'SECRET');
assert.deepEqual(await state.getTotpSetup('session-3'), { userId: 'user-1', secret: 'SECRET' });
await state.deleteTotpSetup('session-3');
assert.equal(await state.getTotpSetup('session-3'), null);
await state.close();
assert.equal(client.isOpen, false);
}
{
const client = new FakeRedisClient();
let options;
const state = await createAuthStateStore({
env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_URL: 'rediss://sessions.example:6380/4' },
logger: silentLogger,
clientFactory(clientOptions) {
options = clientOptions;
return client;
}
});
assert.equal(options.url, 'rediss://sessions.example:6380/4');
assert.equal(options.database, 4);
await state.close();
}
{
const client = new FakeRedisClient();
let options;
const state = await createAuthStateStore({
env: { REDIS_URL: 'redis://cache.example:6379/1', REDIS_SESSION_DB: '0' },
logger: silentLogger,
clientFactory(clientOptions) {
options = clientOptions;
return client;
}
});
assert.equal(options.database, 0, '应允许显式选择 DB 0,只要普通缓存使用不同的 DB');
await state.close();
}
await assert.rejects(
createAuthStateStore({
env: { REDIS_URL: 'redis://same.example:6379/0', REDIS_SESSION_URL: 'redis://same.example:6379/0' },
logger: silentLogger,
clientFactory: () => new FakeRedisClient()
}),
/必须使用与普通缓存不同的逻辑数据库/
);
await assert.rejects(
createAuthStateStore({
env: { REDIS_URL: 'redis://cache-user@same.example:6379/0', REDIS_SESSION_URL: 'redis://session-user@same.example:6379/0' },
logger: silentLogger,
clientFactory: () => new FakeRedisClient()
}),
/必须使用与普通缓存不同的逻辑数据库/
);
await assert.rejects(
createAuthStateStore({
env: { REDIS_URL: 'redis://unavailable.example:6379/0' },
logger: silentLogger,
clientFactory: () => new FakeRedisClient({ connectError: new Error('connection refused') })
}),
/认证状态存储连接失败/
);
console.log('✓ 认证状态本机回退、独立 Redis DB、会话失效与 TOTP 临时状态');
+23 -3
View File
@@ -83,16 +83,35 @@ const silentLogger = { warn() {} };
await cache.invalidate('results');
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 3 });
assert.equal(resultLoads, 3, '后台手动刷新后应重新生成成绩缓存');
client.isReady = false;
let fallbackLoads = 0;
assert.equal(await cache.remember('public', 'runtime-fallback', async () => ++fallbackLoads), 1);
assert.equal(await cache.remember('public', 'runtime-fallback', async () => ++fallbackLoads), 1);
assert.equal(fallbackLoads, 1, 'Redis 运行中断开后,相同热点读取应由本机缓存合并');
client.isReady = true;
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(await cache.remember('public', 'home', async () => ++loads), 1);
assert.equal(await cache.remember('public', 'home', async () => ++loads), 1);
assert.equal(loads, 1, '未配置 Redis 时应使用有界本机缓存,避免重复回源');
await cache.invalidate('public');
assert.equal(await cache.remember('public', 'home', async () => ++loads), 2);
assert.equal(loads, 2, '本机缓存应在数据库写入后立即失效');
assert.equal(cache.status, 'disabled');
let releaseOld;
const oldLoad = cache.remember('public', 'race', () => new Promise(resolve => { releaseOld = () => resolve('old'); }));
await cache.invalidate('public');
const newLoad = cache.remember('public', 'race', async () => 'new');
releaseOld();
assert.equal(await oldLoad, 'old');
assert.equal(await newLoad, 'new', '失效后不得等待失效前仍在运行的加载');
assert.equal(await cache.remember('public', 'race', async () => 'unexpected'), 'new', '旧加载完成后不得覆盖新缓存');
}
{
@@ -104,6 +123,7 @@ const silentLogger = { warn() {} };
});
assert.equal(cache.status, 'unavailable');
assert.equal(await cache.remember('public', 'home', async () => 'database'), 'database');
assert.equal(await cache.remember('public', 'home', async () => 'unexpected'), 'database', 'Redis 故障时本机缓存应继续承接重复读取');
}
console.log('Redis 缓存测试通过');
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { createStateCache } from '../src/database/state-cache.mjs';
let loads = 0;
let release;
const gate = new Promise(resolve => { release = resolve; });
const coalesced = createStateCache({
maxAgeMs: 30000,
async load() {
loads += 1;
await gate;
return { load: loads };
}
});
const firstPending = coalesced.read();
const secondPending = coalesced.read();
release();
const [first, second] = await Promise.all([firstPending, secondPending]);
assert.equal(loads, 1, '并发冷读取应合并为一次全量加载');
assert.strictEqual(first, second, '并发读取应共享同一份快照');
assert.strictEqual(await coalesced.read(), first, '有效期内应直接复用内存快照');
coalesced.invalidate();
const afterInvalidation = await coalesced.read();
assert.equal(loads, 2, '应用写入失效后应重新加载');
assert.notStrictEqual(afterInvalidation, first, '失效后不得继续返回旧快照');
let version = 1;
let versionLoads = 0;
const versioned = createStateCache({
version: () => version,
load: () => ({ load: ++versionLoads })
});
const versionOne = await versioned.read();
assert.strictEqual(await versioned.read(), versionOne, '数据库版本未变化时应复用快照');
version += 1;
const versionTwo = await versioned.read();
assert.equal(versionLoads, 2, '外部数据库版本变化后应重新加载');
assert.notStrictEqual(versionTwo, versionOne, '外部写入后不得返回旧快照');
console.log('State cache tests passed');