feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013

## P1 功能(20 项)
- 站内消息系统、家长仪表盘、学生考勤管理
- Excel 导入导出、用户批量导入、成绩导出
- 排课规则+自动排课+课表调整
- 成绩趋势+对比分析、密码安全策略、速率限制
- 数据变更日志、文件预览+存储策略、全文检索
- 依赖审计集成 CI、数据库定时备份、E2E 测试完善
- 通知偏好管理

## 基础设施修复
- src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求)
- .env: MySQL 端口从 13002 切换至 14013
- scripts/create-db.ts: 新增数据库初始化脚本

## 架构文档同步
- 004_architecture_impact_map.md 和 005_architecture_data.json
  完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

View File

@@ -0,0 +1,119 @@
/**
* Password security policy and account lockout helpers.
*
* These utilities are pure (no DB / I/O) so they can be safely used
* in both server and client contexts.
*/
export const PASSWORD_RULES = {
minLength: 8,
requireUppercase: true,
requireLowercase: true,
requireNumber: true,
requireSpecialChar: false,
maxLoginAttempts: 5,
lockoutDurationMinutes: 30,
} as const
export interface PasswordValidationResult {
valid: boolean
errors: string[]
}
/**
* Validate a password against the configured policy.
*/
export function validatePassword(password: string): PasswordValidationResult {
const errors: string[] = []
if (password.length < PASSWORD_RULES.minLength) {
errors.push(`Password must be at least ${PASSWORD_RULES.minLength} characters long`)
}
if (PASSWORD_RULES.requireUppercase && !/[A-Z]/.test(password)) {
errors.push("Password must contain at least one uppercase letter")
}
if (PASSWORD_RULES.requireLowercase && !/[a-z]/.test(password)) {
errors.push("Password must contain at least one lowercase letter")
}
if (PASSWORD_RULES.requireNumber && !/[0-9]/.test(password)) {
errors.push("Password must contain at least one number")
}
if (PASSWORD_RULES.requireSpecialChar && !/[^A-Za-z0-9]/.test(password)) {
errors.push("Password must contain at least one special character")
}
return { valid: errors.length === 0, errors }
}
export type PasswordStrength = "weak" | "medium" | "strong"
/**
* Compute a coarse password strength label based on length and character
* diversity. Useful for client-side strength indicators.
*/
export function getPasswordStrength(password: string): PasswordStrength {
if (password.length === 0) return "weak"
let score = 0
if (password.length >= 8) score++
if (password.length >= 12) score++
if (/[a-z]/.test(password)) score++
if (/[A-Z]/.test(password)) score++
if (/[0-9]/.test(password)) score++
if (/[^A-Za-z0-9]/.test(password)) score++
if (score <= 2) return "weak"
if (score <= 4) return "medium"
return "strong"
}
/**
* Determine whether an account should be considered locked given its
* failed-attempt count and the timestamp of the most recent failure.
*
* The lockout is lifted automatically once `lockoutDurationMinutes` have
* elapsed since `lastFailedAt`.
*/
export function isAccountLocked(
failedAttempts: number,
lastFailedAt: Date | null
): boolean {
if (failedAttempts < PASSWORD_RULES.maxLoginAttempts) return false
if (!lastFailedAt) return false
const lockoutMs = PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000
const elapsed = Date.now() - lastFailedAt.getTime()
return elapsed < lockoutMs
}
/**
* Compute the remaining lockout time in milliseconds (0 if unlocked).
*/
export function getRemainingLockoutMs(
failedAttempts: number,
lastFailedAt: Date | null
): number {
if (!isAccountLocked(failedAttempts, lastFailedAt)) return 0
if (!lastFailedAt) return 0
const lockoutMs = PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000
const elapsed = Date.now() - lastFailedAt.getTime()
return Math.max(0, lockoutMs - elapsed)
}
/**
* Human-readable summary of the password policy. Used by the UI to
* display requirements next to the password input.
*/
export const PASSWORD_REQUIREMENT_HINTS: string[] = [
`At least ${PASSWORD_RULES.minLength} characters`,
PASSWORD_RULES.requireUppercase ? "At least one uppercase letter (A-Z)" : null,
PASSWORD_RULES.requireLowercase ? "At least one lowercase letter (a-z)" : null,
PASSWORD_RULES.requireNumber ? "At least one number (0-9)" : null,
PASSWORD_RULES.requireSpecialChar
? "At least one special character (!@#$...)"
: null,
].filter((s): s is string => Boolean(s))