学校成绩管理系统设计从数据库架构到权限管控的完整方案含多场景应用实例

先聊聊为什么这个系统这么难做

说实话,设计一个学校成绩管理系统听起来简单——不就是存成绩嘛。但真正做起来你会发现,这里面的门道比你想的深得多。

我见过太多项目,一开始就匆匆忙忙建了几张表,写几个CRUD接口,觉得万事大吉。结果开学没多久就炸了锅:家长登录不上去,班主任查不到自己的学生,教务处导出成绩慢了半分钟被投诉,管理员账号被盗导致成绩被改……这些坑,每个都踩过去,才算真正理解了这个系统的设计逻辑。

今天我要跟你聊的,不是教科书上的理论,而是一个真正能上线、能扛住实际业务压力的完整方案。我们从数据库架构一路聊到权限管控,中间穿插真实场景的落地细节。如果你正在设计或重构这样一个系统,这篇文章能帮你少走很多弯路。


数据库架构:别急着建表,先想清楚数据关系

核心数据模型设计

一个成熟的成绩管理系统,核心数据表大概长这样。先别急着复制粘贴,理解每个字段的设计意图更重要。

-- 学生表
CREATE TABLE students (
    student_id VARCHAR(20) PRIMARY KEY COMMENT '学号,全局唯一',
    name VARCHAR(50) NOT NULL COMMENT '学生姓名',
    gender ENUM('M','F') NOT NULL COMMENT '性别',
    birth_date DATE COMMENT '出生日期',
    student_type VARCHAR(20) COMMENT '学生类型:本科/研究生/博士生',
    enrollment_year INT NOT NULL COMMENT '入学年份',
    graduate_year INT COMMENT '预计毕业年份',
    status ENUM('active','probation','graduated','expelled','suspended') DEFAULT 'active' COMMENT '学籍状态',
    class_id INT COMMENT '班级ID,关联classes表',
    major_id INT COMMENT '专业ID,关联majors表',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_status (status),
    INDEX idx_enrollment_year (enrollment_year),
    INDEX idx_major_class (major_id, class_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生基本信息表';

-- 教师表
CREATE TABLE teachers (
    teacher_id VARCHAR(20) PRIMARY KEY COMMENT '工号',
    name VARCHAR(50) NOT NULL,
    gender ENUM('M','F') NOT NULL,
    department_id INT COMMENT '所属院系ID',
    title VARCHAR(30) COMMENT '职称:教授/副教授/讲师',
    email VARCHAR(100) UNIQUE NOT NULL COMMENT '工作邮箱',
    phone VARCHAR(20) COMMENT '联系电话',
    status ENUM('active','sabbatical','resigned','retired') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_department (department_id),
    INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教师基本信息表';

-- 班级表
CREATE TABLE classes (
    class_id INT AUTO_INCREMENT PRIMARY KEY,
    class_name VARCHAR(50) NOT NULL COMMENT '班级名称,如:计算机2101',
    class_code VARCHAR(20) UNIQUE NOT NULL COMMENT '班级代码,系统内部使用',
    grade INT NOT NULL COMMENT '年级,如:2021级',
    major_id INT NOT NULL COMMENT '专业ID',
    advisor_id VARCHAR(20) COMMENT '班主任工号',
    total_students INT DEFAULT 0 COMMENT '学生总数',
    status ENUM('active','closed') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (advisor_id) REFERENCES teachers(teacher_id) ON DELETE SET NULL,
    FOREIGN KEY (major_id) REFERENCES majors(major_id),
    INDEX idx_grade (grade),
    INDEX idx_major (major_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级信息表';

-- 专业表
CREATE TABLE majors (
    major_id INT AUTO_INCREMENT PRIMARY KEY,
    major_code VARCHAR(20) UNIQUE NOT NULL COMMENT '专业代码,教育部标准代码',
    major_name VARCHAR(100) NOT NULL COMMENT '专业名称',
    college_id INT NOT NULL COMMENT '所属学院ID',
    degree_type ENUM('bachelor','master','doctor') NOT NULL COMMENT '学位类型',
    duration_years INT DEFAULT 4 COMMENT '学制年限',
    description TEXT COMMENT '专业描述',
    status ENUM('active','suspended') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (college_id) REFERENCES colleges(college_id),
    INDEX idx_college (college_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='专业信息表';

-- 学院表
CREATE TABLE colleges (
    college_id INT AUTO_INCREMENT PRIMARY KEY,
    college_code VARCHAR(20) UNIQUE NOT NULL,
    college_name VARCHAR(100) NOT NULL,
    dean_id VARCHAR(20) COMMENT '院长工号',
    phone VARCHAR(20),
    email VARCHAR(100),
    building VARCHAR(50) COMMENT '所在楼栋',
    floor VARCHAR(10) COMMENT '所在楼层',
    status ENUM('active','suspended') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (dean_id) REFERENCES teachers(teacher_id),
    INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学院信息表';

-- 课程表
CREATE TABLE courses (
    course_id INT AUTO_INCREMENT PRIMARY KEY,
    course_code VARCHAR(20) UNIQUE NOT NULL COMMENT '课程编号',
    course_name VARCHAR(100) NOT NULL COMMENT '课程名称',
    course_type ENUM('compulsory','elective','general_education','physical_education') NOT NULL COMMENT '课程类型',
    credit DECIMAL(3,1) NOT NULL COMMENT '学分',
    semester_hours INT COMMENT '学时',
    exam_type ENUM('final_exam','coursework','project','comprehensive','pass_fail') DEFAULT 'final_exam' COMMENT '考核方式',
    college_id INT NOT NULL COMMENT '开课学院',
    department_id INT COMMENT '所属院系',
    required_majors JSON COMMENT '要求修读的专业列表(JSON格式)',
    description TEXT COMMENT '课程说明',
    status ENUM('active','inactive') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (college_id) REFERENCES colleges(college_id),
    INDEX idx_type (course_type),
    INDEX idx_college (college_id),
    INDEX idx_code (course_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程基本信息表';

-- 开课表(某个学期某门课的具体开设班级)
CREATE TABLE course_offerings (
    offering_id INT AUTO_INCREMENT PRIMARY KEY,
    course_id INT NOT NULL COMMENT '关联课程',
    academic_year INT NOT NULL COMMENT '学年,如:2023',
    semester TINYINT NOT NULL COMMENT '学期:1春季/2秋季/3暑期',
    section VARCHAR(10) NOT NULL COMMENT '班级编号,如:01、02',
    teacher_id VARCHAR(20) NOT NULL COMMENT '任课教师',
    max_students INT NOT NULL COMMENT '最大容量',
    enrolled_students INT DEFAULT 0 COMMENT '已选课人数',
    exam_date DATETIME COMMENT '考试时间',
    exam_location VARCHAR(100) COMMENT '考试地点',
    status ENUM('scheduled','active','completed','cancelled') DEFAULT 'scheduled',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (course_id) REFERENCES courses(course_id),
    FOREIGN KEY (teacher_id) REFERENCES teachers(teacher_id),
    UNIQUE KEY uk_offering (course_id, academic_year, semester, section),
    INDEX idx_year_semester (academic_year, semester)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程开课信息表';

-- 学生选课表
CREATE TABLE student_course_enrollments (
    enrollment_id INT AUTO_INCREMENT PRIMARY KEY,
    student_id VARCHAR(20) NOT NULL,
    offering_id INT NOT NULL,
    enrollment_status ENUM('enrolled','dropped','audit') DEFAULT 'enrolled' COMMENT '选课状态',
    enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    dropped_at DATETIME COMMENT '退课时间',
    FOREIGN KEY (student_id) REFERENCES students(student_id),
    FOREIGN KEY (offering_id) REFERENCES course_offerings(offering_id),
    UNIQUE KEY uk_enrollment (student_id, offering_id),
    INDEX idx_offering (offering_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生选课记录表';

-- 成绩表(核心表,也是权限管控的重点)
CREATE TABLE grades (
    grade_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    student_id VARCHAR(20) NOT NULL,
    offering_id INT NOT NULL,
    score DECIMAL(5,2) COMMENT '原始分数(百分制)',
    letter_grade CHAR(2) COMMENT '等级制成绩,如:A、B+',
    gpa_point DECIMAL(3,2) COMMENT '绩点',
    grade_status ENUM('pending','submitted','published','archived') DEFAULT 'pending' COMMENT '成绩状态',
    submitted_by VARCHAR(20) COMMENT '提交成绩的教师ID',
    submitted_at TIMESTAMP NULL COMMENT '提交时间',
    published_at TIMESTAMP NULL COMMENT '公布时间',
    remarks TEXT COMMENT '备注(如:缓考、补考、违纪)',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (student_id) REFERENCES students(student_id),
    FOREIGN KEY (offering_id) REFERENCES course_offerings(offering_id),
    UNIQUE KEY uk_grade (student_id, offering_id),
    INDEX idx_offering (offering_id),
    INDEX idx_student (student_id),
    INDEX idx_status (grade_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生成绩表';

-- 成绩变更记录日志表(审计追踪,非常重要!)
CREATE TABLE grade_change_logs (
    log_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    grade_id BIGINT NOT NULL,
    student_id VARCHAR(20) NOT NULL,
    offering_id INT NOT NULL,
    operator_id VARCHAR(20) NOT NULL COMMENT '操作人ID',
    operator_role VARCHAR(30) NOT NULL COMMENT '操作人角色',
    action ENUM('create','update','delete','publish','unpublish','approve','reject') NOT NULL,
    old_score DECIMAL(5,2) COMMENT '变更前分数',
    new_score DECIMAL(5,2) COMMENT '变更后分数',
    old_status VARCHAR(20),
    new_status VARCHAR(20),
    reason TEXT COMMENT '变更原因',
    ip_address VARCHAR(45) COMMENT '操作IP',
    user_agent VARCHAR(255) COMMENT '操作设备信息',
    timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (grade_id) REFERENCES grades(grade_id),
    INDEX idx_operator (operator_id),
    INDEX idx_timestamp (timestamp),
    INDEX idx_student (student_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩变更日志表';

-- 用户表(统一身份认证)
CREATE TABLE users (
    user_id VARCHAR(20) PRIMARY KEY COMMENT '与teacher_id或student_id关联',
    username VARCHAR(50) UNIQUE NOT NULL COMMENT '登录用户名',
    password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt加密后的密码',
    user_type ENUM('student','teacher','administrator','system') NOT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    last_login TIMESTAMP NULL,
    login_attempts INT DEFAULT 0 COMMENT '连续登录失败次数',
    locked_until TIMESTAMP NULL COMMENT '账户锁定截止时间',
    password_updated_at TIMESTAMP NULL,
    mfa_enabled BOOLEAN DEFAULT FALSE COMMENT '是否启用双因素认证',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_username (username),
    INDEX idx_user_type (user_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户认证表';

-- 权限角色表
CREATE TABLE roles (
    role_id VARCHAR(30) PRIMARY KEY COMMENT '角色标识',
    role_name VARCHAR(50) NOT NULL COMMENT '角色名称',
    role_level INT NOT NULL COMMENT '角色层级(数字越大权限越高)',
    description TEXT COMMENT '角色描述',
    is_system_role BOOLEAN DEFAULT FALSE COMMENT '是否系统内置角色',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_level (role_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限角色表';

-- 权限规则表
CREATE TABLE permissions (
    permission_id VARCHAR(50) PRIMARY KEY COMMENT '权限标识,如:grade:view_own',
    resource VARCHAR(50) NOT NULL COMMENT '资源类型:student/grade/course/class/report',
    action VARCHAR(20) NOT NULL COMMENT '操作:view/create/update/delete/export/publish',
    scope ENUM('self','department','college','school','global') NOT NULL COMMENT '权限范围',
    description VARCHAR(200) COMMENT '权限描述',
    is_active BOOLEAN DEFAULT TRUE,
    UNIQUE KEY uk_permission (resource, action, scope)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限规则表';

-- 用户角色关联表
CREATE TABLE user_roles (
    user_id VARCHAR(20) NOT NULL,
    role_id VARCHAR(30) NOT NULL,
    assigned_by VARCHAR(20) COMMENT '分配者',
    assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NULL COMMENT '角色过期时间',
    PRIMARY KEY (user_id, role_id),
    FOREIGN KEY (user_id) REFERENCES users(user_id),
    FOREIGN KEY (role_id) REFERENCES roles(role_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';

-- 角色权限关联表
CREATE TABLE role_permissions (
    role_id VARCHAR(30) NOT NULL,
    permission_id VARCHAR(50) NOT NULL,
    PRIMARY KEY (role_id, permission_id),
    FOREIGN KEY (role_id) REFERENCES roles(role_id),
    FOREIGN KEY (permission_id) REFERENCES permissions(permission_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';

关键设计思路解析

上面这些表,看着多,但每一条都有存在的必要。我来拆解几个最容易踩坑的设计点。

为什么成绩表要单独设计,而不是直接放在选课表里?

这是一个常见的设计误区。有些团队图省事,直接在 student_course_enrollments 表里加一个 score 字段。但这样做会带来严重问题:

  1. 成绩可能有多个版本(初评、复核、最终),一张表存不下
  2. 成绩一旦公布,修改需要审计追踪,直接改原表会丢失历史记录
  3. 成绩状态流转(待提交→已提交→已公布)需要独立管理

所以把成绩拆出来单独建表,既能支持完整的状态机,又能通过 grade_change_logs 表记录每一次变更——这在后续权限审计时非常关键。

为什么要有 grade_change_logs 这张表?

这是整个系统最容易被人忽略、但实际上最重要的设计。想象一个场景:系统里某个学生的数学成绩从85分变成了92分,三个月后家长投诉说”我孩子明明考了85分,为什么成绩单上变成92分了”。如果没有变更日志,你根本查不清楚谁在什么时候改的、为什么改。

这张表的设计有几个要点:

  • operator_role 字段记录了操作者的角色,这样即使账号被盗,也能快速定位是哪个角色权限出了问题
  • reason 字段强制要求填写变更原因,避免随意修改
  • ip_address 和 user_agent 用于安全审计,发现异常操作
  • 所有对成绩表的写操作,都必须通过这个表留痕

权限管控体系:不只是”谁能看什么”

权限设计是成绩管理系统的灵魂。一个设计不好的权限体系,要么导致数据泄露,要么把正常业务拖得寸步难行。

RBAC+ABAC 混合权限模型

传统的RBAC(基于角色的访问控制)在成绩管理系统里够用,但不够用。为什么?因为同一个角色在不同场景下的权限边界不一样。

举个例子:两个都是”教师”角色,但张老师只能看自己教的课的成绩,李老师作为教学秘书,可以看得意所有成绩。这就是RBAC解决不了的问题。

我们采用 RBAC + ABAC(基于属性的访问控制) 的混合模型:

权限判定 = RBAC角色权限 ∩ ABAC属性条件

具体实现如下:

# 权限判定核心逻辑(Python示例)

from datetime import datetime
from dataclasses import dataclass
from typing import Optional, Set
import hashlib
import hmac

@dataclass
class PermissionContext:
    """权限判定上下文"""
    user_id: str
    user_type: str
    roles: Set[str]
    attributes: dict  # 用户属性:department, college, class等
    target_resource: str  # 目标资源
    target_action: str  # 目标操作
    target_attributes: dict  # 资源属性:teacher_id, class_id等
    request_ip: str
    request_time: datetime

class PermissionEngine:
    """权限引擎"""
    
    def __init__(self, db_connection):
        self.db = db_connection
    
    def check_permission(self, context: PermissionContext) -> bool:
        """
        检查用户是否具有指定权限
        判定流程:超级管理员 → 角色权限 → 属性权限 → 拒绝
        """
        # 1. 超级管理员直接放行
        if 'super_admin' in context.roles:
            return True
        
        # 2. 系统内置角色的基础权限
        if self._check_role_permissions(context):
            return True
        
        # 3. 基于属性的动态权限判定
        if self._check_attribute_permissions(context):
            return True
        
        # 4. 默认拒绝
        return False
    
    def _check_role_permissions(self, context: PermissionContext) -> bool:
        """基于角色的权限检查"""
        role_permissions = self._get_role_permissions(context.roles)
        
        for perm in role_permissions:
            if self._match_permission(perm, context):
                return True
        return False
    
    def _check_attribute_permissions(self, context: PermissionContext) -> bool:
        """基于属性的权限检查(ABAC)"""
        # 属性匹配规则
        rules = self._get_attribute_rules(context.target_resource, context.target_action)
        
        for rule in rules:
            if self._evaluate_rule(rule, context):
                return True
        return False
    
    def _evaluate_rule(self, rule: dict, context: PermissionContext) -> bool:
        """评估单条属性规则"""
        # 规则示例:
        # {
        #   "resource": "grade",
        #   "action": "view",
        #   "condition": {
        #     "teacher_id": "user.department_id"  # 教师只能看自己院系的
        #   }
        # }
        
        conditions = rule.get("conditions", {})
        for attr_key, attr_expr in conditions.items():
            if not self._evaluate_attribute(attr_key, attr_expr, context):
                return False
        return True
    
    def _evaluate_attribute(self, attr_key: str, attr_expr: str, context: PermissionContext) -> bool:
        """
        评估属性表达式
        支持:精确匹配、IN操作符、范围比较
        """
        target_value = context.target_attributes.get(attr_key)
        
        if attr_expr.startswith("user."):
            # 引用用户属性
            user_attr = attr_expr.replace("user.", "")
            user_value = context.attributes.get(user_attr)
            return target_value == user_value
        
        if attr_expr.startswith("IN("):
            # IN操作符
            values = attr_expr[3:-1].split(",")
            return target_value in values
        
        if attr_expr.startswith(">=") or attr_expr.startswith("<="):
            # 范围比较
            op = ">=" if ">=" in attr_expr else "<="
            threshold = int(attr_expr.replace(">=", "").replace("<=", ""))
            if op == ">=":
                return target_value >= threshold
            return target_value <= threshold
        
        return target_value == attr_expr

权限场景化配置

权限不是静态配置的,需要针对不同场景动态调整。以下是几个典型场景的权限配置:

# 权限配置示例(实际项目中用数据库存储)

permission_scenarios:
  # 场景1:班主任查看本班学生成绩
  scenario_teacher_class_grade:
    conditions:
      user_type: "teacher"
      role_includes: ["teacher"]
    resource: "grade"
    actions: ["view", "export"]
    attributes:
      student.class_id: "user.advisor_class_id"  # 班主任只能看自己班的
    audit_required: true
    reason_required: true  # 查看后必须填写查看理由
    
  # 场景2:教学秘书查看全院成绩
  scenario_academic_secretary:
    conditions:
      user_type: "teacher"
      role_includes: ["academic_secretary"]
    resource: "grade"
    actions: ["view", "export", "publish"]
    attributes:
      course.college_id: "user.department_id"  # 只能看自己院系的
    audit_required: true
    
  # 场景3:学生查看自己的成绩
  scenario_student_own_grade:
    conditions:
      user_type: "student"
    resource: "grade"
    actions: ["view"]
    attributes:
      grade.student_id: "user_id"  # 只能看自己的
    audit_required: false
    
  # 场景4:教务处长查看全校成绩
  scenario_registrar:
    conditions:
      user_type: "administrator"
      role_includes: ["registrar"]
    resource: "grade"
    actions: ["view", "export", "analyze"]
    attributes:
      scope: "global"
    audit_required: true
    rate_limit: 100  # 每分钟最多导出100条
    
  # 场景5:教师录入/修改成绩
  scenario_teacher_submit_grade:
    conditions:
      user_type: "teacher"
      role_includes: ["teacher"]
    resource: "grade"
    actions: ["create", "update"]
    attributes:
      grade.offering_id: "user.teacher_id IN course_offering.teachers"  # 只能改自己教的课
    audit_required: true
    reason_required: true
    require_approval: true  # 修改已公布成绩需要审批

权限边界的具体实现

权限系统最怕的就是”看起来合理,但实际有漏洞”。我来列举几个容易出问题的边界情况,以及对应的处理方案:

边界情况1:学生可以查到自己其他年级的成绩吗?

不应该。学生只能查看当前在读年级及以后的成绩。历史成绩(已毕业的年级)需要单独授权才能查看。

-- 查询学生可见成绩范围的SQL
SELECT g.*
FROM grades g
JOIN course_offerings co ON g.offering_id = co.offering_id
WHERE g.student_id = ?
  AND g.grade_status IN ('published', 'archived')
  AND co.academic_year <= (
    SELECT enrollment_year + 4 
    FROM students 
    WHERE student_id = ?
  )
  AND co.academic_year >= (
    SELECT enrollment_year 
    FROM students 
    WHERE student_id = ?
  )
ORDER BY co.academic_year DESC, co.semester DESC;

边界情况2:教师可以批量导入成绩吗?如何防止误操作?

允许批量导入,但必须走审核流程:

import hashlib
import logging
from datetime import datetime
from dataclasses import dataclass
from typing import List, Dict, Any
import json

logger = logging.getLogger(__name__)

@dataclass
class GradeImportRequest:
    """成绩导入请求"""
    offering_id: int
    teacher_id: str
    grades: List[Dict[str, Any]]  # [{"student_id": "...", "score": 85.5}, ...]
    import_mode: str  # "replace"覆盖 / "append"追加 / "merge"合并
    reason: str  # 导入原因
    batch_id: str  # 批次ID,用于关联多条记录
    
class GradeImportValidator:
    """成绩导入验证器"""
    
    def __init__(self, db, cache):
        self.db = db
        self.cache = cache
    
    def validate(self, request: GradeImportRequest) -> Dict[str, Any]:
        """
        验证导入请求
        返回: {"valid": bool, "errors": [...], "warnings": [...]}
        """
        result = {"valid": True, "errors": [], "warnings": []}
        
        # 1. 验证教师权限
        if not self._check_teacher_permission(request.offering_id, request.teacher_id):
            result["valid"] = False
            result["errors"].append("教师无权修改此课程成绩")
            return result
        
        # 2. 验证课程状态
        offering = self._get_offering(request.offering_id)
        if offering["status"] != "completed":
            result["warnings"].append(f"课程{offering['course_code']}尚未结束,导入可能影响正在进行的成绩录入")
        
        # 3. 验证成绩数据格式
        for i, grade_data in enumerate(request.grades):
            if "student_id" not in grade_data or "score" not in grade_data:
                result["errors"].append(f"第{i+1}条记录缺少必要字段")
                continue
            
            score = grade_data.get("score")
            if score is not None:
                if not isinstance(score, (int, float)):
                    result["errors"].append(f"第{i+1}条记录分数格式错误")
                elif score < 0 or score > 100:
                    result["errors"].append(f"第{i+1}条记录分数超出范围(0-100)")
        
        # 4. 检查是否重复导入
        existing_count = self._count_existing_grades(request.offering_id)
        if existing_count > 0 and request.import_mode == "replace":
            result["warnings"].append(f"该课程已有{existing_count}条成绩记录,选择覆盖将清空现有数据")
        
        # 5. 检查成绩分布合理性
        scores = [g["score"] for g in request.grades if g.get("score") is not None]
        if len(scores) >= 10:
            avg_score = sum(scores) / len(scores)
            if avg_score < 40:
                result["warnings"].append(f"平均分数{avg_score:.1f}分过低,请确认是否录入错误")
            elif avg_score > 95:
                result["warnings"].append(f"平均分数{avg_score:.1f}分过高,请确认是否录入错误")
        
        # 6. 计算数据指纹(用于去重和审计)
        data_str = json.dumps(request.grades, sort_keys=True)
        request.batch_id = hashlib.sha256(f"{request.offering_id}:{data_str}:{datetime.now().isoformat()}".encode()).hexdigest()[:16]
        
        return result
    
    def _check_teacher_permission(self, offering_id: int, teacher_id: str) -> bool:
        """检查教师是否有权限修改指定开课的成绩"""
        sql = """
        SELECT COUNT(*) as cnt 
        FROM course_offerings co 
        JOIN teachers t ON co.teacher_id = t.teacher_id
        WHERE co.offering_id = %s AND t.teacher_id = %s
        """
        result = self.db.query(sql, (offering_id, teacher_id))
        return result[0]["cnt"] > 0
    
    def _count_existing_grades(self, offering_id: int) -> int:
        """统计已有成绩数量"""
        sql = "SELECT COUNT(*) as cnt FROM grades WHERE offering_id = %s"
        result = self.db.query(sql, (offering_id,))
        return result[0]["cnt"]

边界情况3:成绩公布后还能修改吗?怎么改?

可以修改,但必须走严格的审批流程:

-- 成绩修改审批流程
CREATE TABLE grade_change_requests (
    request_id VARCHAR(36) PRIMARY KEY COMMENT 'UUID',
    grade_id BIGINT NOT NULL,
    applicant_id VARCHAR(20) NOT NULL COMMENT '申请人',
    applicant_role VARCHAR(30) NOT NULL COMMENT '申请人角色',
    current_score DECIMAL(5,2) NOT NULL COMMENT '当前分数',
    requested_score DECIMAL(5,2) NOT NULL COMMENT '申请修改为',
    reason TEXT NOT NULL COMMENT '修改原因',
    supporting_docs TEXT COMMENT '支撑材料(文件路径JSON)',
    status ENUM('pending','approved','rejected','revoked') DEFAULT 'pending',
    approver_id VARCHAR(20) COMMENT '审批人',
    approval_comment TEXT COMMENT '审批意见',
    approved_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (grade_id) REFERENCES grades(grade_id),
    INDEX idx_status (status),
    INDEX idx_applicant (applicant_id),
    INDEX idx_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩修改申请单';

这个设计的关键是:成绩修改不能直接操作数据库,必须通过申请单走审批流程。审批通过后,系统自动执行修改并记录到变更日志。这样既保证了灵活性,又确保了安全性。


多场景应用实例:系统是怎么真正跑起来的

空谈架构没意思,我们来看几个真实的业务场景,看看这套设计是怎么在现实中运转的。

场景一:学期初——成绩录入流程

背景:期末考试结束,各位任课老师需要录入学生成绩。

系统流程:

  1. 成绩录入窗口开启 教务系统自动在成绩录入截止日前30天发送通知给相关教师。此时成绩表处于”待录入”状态,教师可以提交成绩。
# 成绩录入通知服务
class GradeEntryNotificationService:
    
    def send_entry_reminder(self):
        """发送成绩录入提醒"""
        cutoff_date = self._get_entry_cutoff_date()
        reminder_date = cutoff_date - timedelta(days=30)
        
        if datetime.now().date() == reminder_date:
            # 获取需要录入成绩的教师列表
            teachers = self.db.query("""
                SELECT DISTINCT t.teacher_id, t.name, t.email
                FROM course_offerings co
                JOIN teachers t ON co.teacher_id = t.teacher_id
                JOIN grades g ON g.offering_id = co.offering_id
                WHERE co.academic_year = %s
                  AND co.semester = %s
                  AND g.grade_status = 'pending'
                  AND t.status = 'active'
            """, (self.current_year, self.current_semester))
            
            for teacher in teachers:
                self._send_email(
                    to=teacher["email"],
                    subject=f"【提醒】{self.current_year}学年第{self.current_semester}学期成绩录入",
                    body=self._generate_reminder_body(teacher["teacher_id"])
                )
    
    def _generate_reminder_body(self, teacher_id: str) -> str:
        """生成提醒邮件正文"""
        pending_grades = self.db.query("""
            SELECT c.course_code, c.course_name, co.section
            FROM grades g
            JOIN course_offerings co ON g.offering_id = co.offering_id
            JOIN courses c ON co.course_id = c.course_id
            WHERE co.teacher_id = %s AND g.grade_status = 'pending'
        """, (teacher_id,))
        
        if not pending_grades:
            return "您暂无待录入的成绩。"
        
        body = "您有以下课程的成绩尚未录入:\n\n"
        for idx, grade in enumerate(pending_grades, 1):
            body += f"{idx}. {grade['course_code']} {grade['course_name']} (第{grade['section']}班)\n"
        
        body += f"\n成绩录入截止时间:{self._get_entry_cutoff_date()} 18:00"
        body += "\n请登录系统完成录入。"
        return body
  1. 教师录入成绩 教师登录系统后,看到待录入课程列表。可以逐个录入,也可以批量导入Excel。
# 教师端成绩录入API
@app.route("/api/grades/import", methods=["POST"])
@require_login
@require_role(["teacher", "academic_secretary"])
def import_grades():
    """批量导入成绩"""
    teacher = current_user
    
    # 解析请求
    data = request.get_json()
    offering_id = data.get("offering_id")
    grades_data = data.get("grades", [])
    import_mode = data.get("import_mode", "replace")
    reason = data.get("reason", "")
    
    # 验证
    validator = GradeImportValidator(db, cache)
    request = GradeImportRequest(
        offering_id=offering_id,
        teacher_id=teacher.teacher_id,
        grades=grades_data,
        import_mode=import_mode,
        reason=reason,
        batch_id=""
    )
    
    validation_result = validator.validate(request)
    
    if not validation_result["valid"]:
        return jsonify({
            "success": False,
            "errors": validation_result["errors"],
            "warnings": validation_result["warnings"]
        }), 400
    
    # 执行导入
    try:
        import_result = grade_import_service.execute(request)
        
        # 记录审计日志
        audit_log_service.log_grade_change(
            action="import",
            operator_id=teacher.teacher_id,
            operator_role=teacher.user_type,
            offering_id=offering_id,
            record_count=len(grades_data),
            reason=reason,
            batch_id=request.batch_id,
            ip_address=request.remote_addr
        )
        
        return jsonify({
            "success": True,
            "imported": import_result["imported"],
            "failed": import_result["failed"],
            "batch_id": request.batch_id,
            "warnings": validation_result["warnings"]
        })
    
    except Exception as e:
        logger.error(f"成绩导入失败: {e}")
        return jsonify({"success": False, "error": "导入失败,请稍后重试"}), 500
  1. 成绩提交与审核 教师提交成绩后,成绩状态变为”已提交待审核”。如果是异常数据(如全班平均分低于60或高于90),系统会自动触发审核提醒。
-- 异常成绩检测触发器
DELIMITER $$

CREATE TRIGGER trg_check_abnormal_grades
AFTER INSERT ON grades
FOR EACH ROW
BEGIN
    DECLARE avg_score DECIMAL(5,2);
    DECLARE std_dev DECIMAL(5,2);
    DECLARE grade_count INT;
    
    -- 计算该课程成绩的统计信息
    SELECT AVG(score), STDDEV(score), COUNT(*)
    INTO avg_score, std_dev, grade_count
    FROM grades
    WHERE offering_id = NEW.offering_id
      AND score IS NOT NULL
      AND grade_status != 'pending';
    
    -- 异常检测:平均分过低或过高
    IF avg_score < 50 AND grade_count >= 10 THEN
        INSERT INTO grade_alerts (
            offering_id, alert_type, alert_level, alert_message,
            detected_value, threshold, created_at
        ) VALUES (
            NEW.offering_id, 'low_average', 'high',
            CONCAT('课程平均分过低:', avg_score, '分(低于50分阈值)'),
            avg_score, 50, NOW()
        );
    END IF;
    
    IF avg_score > 90 AND grade_count >= 10 THEN
        INSERT INTO grade_alerts (
            offering_id, alert_type, alert_level, alert_message,
            detected_value, threshold, created_at
        ) VALUES (
            NEW.offering_id, 'high_average', 'medium',
            CONCAT('课程平均分过高:', avg_score, '分(高于90分阈值)'),
            avg_score, 90, NOW()
        );
    END IF;
    
    -- 异常检测:标准差过小(可能抄袭)
    IF std_dev < 3 AND grade_count >= 10 AND avg_score BETWEEN 60 AND 90 THEN
        INSERT INTO grade_alerts (
            offering_id, alert_type, alert_level, alert_message,
            detected_value, threshold, created_at
        ) VALUES (
            NEW.offering_id, 'low_variance', 'high',
            CONCAT('成绩分布异常:标准差过小(', ROUND(std_dev, 2), '),可能存在雷同卷'),
            std_dev, 3, NOW()
        );
    END IF;
END$$

DELIMITER ;

场景二:学期中——成绩查询与导出

背景:期中考试后,学生想查期中成绩,家长想查自己孩子的成绩,教务处要导出成绩用于学籍预警。

不同角色的查询体验:

# 成绩查询服务
class GradeQueryService:
    
    def get_student_grades(self, student_id: str, requester_id: str, requester_role: str) -> Dict:
        """
        获取学生成绩列表
        根据请求者角色返回不同粒度的数据
        """
        # 权限检查
        if not self._check_access_permission(student_id, requester_id, requester_role):
            raise PermissionError("无权查看此学生成绩")
        
        # 查询成绩(根据角色决定返回字段)
        if requester_role in ["student", "parent"]:
            # 学生/家长只能看到自己的/孩子的成绩,且看不到敏感信息
            sql = """
                SELECT 
                    c.course_code,
                    c.course_name,
                    c.credit,
                    g.score,
                    g.letter_grade,
                    g.gpa_point,
                    g.grade_status,
                    g.published_at
                FROM grades g
                JOIN course_offerings co ON g.offering_id = co.offering_id
                JOIN courses c ON co.course_id = c.course_id
                WHERE g.student_id = %s
                  AND g.grade_status = 'published'
                ORDER BY co.academic_year DESC, co.semester DESC, c.course_code
            """
            grades = self.db.query(sql, (student_id,))
            
            # 计算绩点汇总(只显示给本人)
            summary = self._calculate_gpa_summary(grades, student_id)
            
        elif requester_role in ["teacher", "academic_secretary"]:
            # 教师/教学秘书可以看到更多详情
            sql = """
                SELECT 
                    s.student_id,
                    s.name AS student_name,
                    s.class_id,
                    c.course_code,
                    c.course_name,
                    c.credit,
                    g.score,
                    g.letter_grade,
                    g.gpa_point,
                    g.grade_status,
                    g.submitted_at,
                    g.published_at
                FROM grades g
                JOIN students s ON g.student_id = s.student_id
                JOIN course_offerings co ON g.offering_id = co.offering_id
                JOIN courses c ON co.course_id = c.course_id
                WHERE g.student_id = %s
                  AND g.grade_status IN ('published', 'archived')
                ORDER BY co.academic_year DESC, co.semester DESC, c.course_code
            """
            grades = self.db.query(sql, (student_id,))
            
            # 附加班级信息
            class_info = self._get_class_info(student_id)
            
        elif requester_role in ["registrar", "administrator"]:
            # 教务/管理员可以看到最详细的信息
            sql = """
                SELECT 
                    s.student_id,
                    s.name AS student_name,
                    s.gender,
                    s.enrollment_year,
                    m.major_name,
                    cl.class_name,
                    c.course_code,
                    c.course_name,
                    c.course_type,
                    c.credit,
                    g.score,
                    g.letter_grade,
                    g.gpa_point,
                    g.grade_status,
                    g.submitted_by,
                    g.submitted_at,
                    g.published_at,
                    g.remarks
                FROM grades g
                JOIN students s ON g.student_id = s.student_id
                JOIN majors m ON s.major_id = m.major_id
                JOIN classes cl ON s.class_id = cl.class_id
                JOIN course_offerings co ON g.offering_id = co.offering_id
                JOIN courses c ON co.course_id = c.course_id
                WHERE g.student_id = %s
                  AND g.grade_status IN ('published', 'archived')
                ORDER BY co.academic_year DESC, co.semester DESC, c.course_code
            """
            grades = self.db.query(sql, (student_id,))
            
            # 附加完整档案信息
            full_profile = self._get_full_profile(student_id)
        
        # 记录查询日志(所有角色都需要)
        self._log_grade_query(
            query_by=requester_id,
            query_role=requester_role,
            target_student_id=student_id,
            record_count=len(grades) if grades else 0
        )
        
        return {
            "student_id": student_id,
            "grades": grades,
            "summary": summary if requester_role in ["student", "parent"] else None,
            "class_info": class_info if requester_role in ["teacher", "academic_secretary"] else None,
            "full_profile": full_profile if requester_role in ["registrar", "administrator"] else None
        }
    
    def _check_access_permission(self, target_student_id: str, requester_id: str, requester_role: str) -> bool:
        """检查是否有权限查看指定学生成绩"""
        requester = self._get_user(requester_id)
        
        # 自己看自己
        if requester_role == "student" and requester.student_id == target_student_id:
            return True
        
        # 家长看孩子
        if requester_role == "parent":
            relationship = self.db.query(
                "SELECT COUNT(*) as cnt FROM family_relations WHERE parent_id = %s AND child_id = %s",
                (requester_id, target_student_id)
            )
            return relationship[0]["cnt"] > 0
        
        # 教师看自己班级的学生
        if requester_role == "teacher":
            classes = self.db.query(
                "SELECT class_id FROM classes WHERE advisor_id = %s",
                (requester_id,)
            )
            class_ids = [c["class_id"] for c in classes]
            if class_ids:
                result = self.db.query(
                    "SELECT COUNT(*) as cnt FROM students WHERE class_id IN (%s) AND student_id = %s",
                    (",".join(map(str, class_ids)), target_student_id)
                )
                return result[0]["cnt"] > 0
        
        # 教学秘书看本院系学生
        if requester_role == "academic_secretary":
            result = self.db.query("""
                SELECT COUNT(*) as cnt FROM students s
                JOIN majors m ON s.major_id = m.major_id
                WHERE m.college_id = %s AND s.student_id = %s
            """, (requester.department_id, target_student_id))
            return result[0]["cnt"] > 0
        
        # 管理员看全校
        return requester_role in ["registrar", "administrator", "super_admin"]

家长查询场景的具体实现:

家长登录系统后,绑定自己的孩子,可以查看孩子的成绩单。这里需要特别注意隐私保护:

# 家长端成绩查询
@app.route("/api/parent/grades/<student_id>", methods=["GET"])
@require_login
@require_role(["parent"])
def parent_view_grades(student_id: str):
    """家长查看孩子成绩"""
    parent = current_user
    
    # 1. 验证亲子关系
    relationship = db.query(
        "SELECT * FROM family_relations WHERE parent_id = %s AND child_id = %s AND status = 'active'",
        (parent.user_id, student_id)
    )
    if not relationship:
        return jsonify({"success": False, "error": "未找到此学生的绑定关系"}), 403
    
    # 2. 获取成绩(过滤敏感信息)
    query_service = GradeQueryService(db)
    result = query_service.get_student_grades(
        student_id=student_id,
        requester_id=parent.user_id,
        requester_role="parent"
    )
    
    # 3. 脱敏处理:移除教师信息、备注等敏感字段
    sanitized_grades = []
    for grade in result["grades"]:
        sanitized_grades.append({
            "course_code": grade["course_code"],
            "course_name": grade["course_name"],
            "credit": grade["credit"],
            "score": grade["score"],
            "letter_grade": grade["letter_grade"],
            "gpa_point": grade["gpa_point"],
            "published_at": grade["published_at"]
            # 不返回 submitted_by、remarks等字段
        })
    
    # 4. 记录查询日志
    audit_log.log(
        user_id=parent.user_id,
        action="view_grade",
        resource="grade",
        target_id=student_id,
        parent_id=parent.user_id,
        ip_address=request.remote_addr
    )
    
    return jsonify({
        "success": True,
        "student_name": result.get("student_name"),
        "grades": sanitized_grades,
        "gpa_summary": result.get("gpa_summary")
    })

场景三:学期末——成绩统计与分析

背景:期末考试结束后,教务处需要统计各门课程的成绩分布,生成成绩分析报告,用于教学评估和质量监控。

成绩统计分析服务:

import numpy as np
from scipy import stats
from dataclasses import dataclass
from typing import List, Dict, Any
import json

@dataclass
class GradeStatistics:
    """成绩统计结果"""
    course_code: str
    course_name: str
    offering_id: int
    total_students: int
    submitted_count: int
    average: float
    median: float
    std_dev: float
    min_score: float
    max_score: float
    pass_rate: float  # 及格率
    excellent_rate: float  # 优秀率(90分以上)
    score_distribution: Dict[str, int]  # 分数段分布
    grade_curve: str  # 成绩曲线类型
    anomaly_flags: List[str]  # 异常标记

class GradeStatisticsService:
    """成绩统计分析服务"""
    
    def calculate_course_statistics(self, offering_id: int) -> GradeStatistics:
        """计算单门课程的成绩统计"""
        # 获取成绩数据
        scores = self.db.query("""
            SELECT score, gpa_point
            FROM grades
            WHERE offering_id = %s AND score IS NOT NULL AND grade_status = 'published'
        """, (offering_id,))
        
        if not scores:
            return None
        
        score_values = [s["score"] for s in scores]
        total = len(score_values)
        
        # 基础统计
        avg = sum(score_values) / total
        sorted_scores = sorted(score_values)
        median = sorted_scores[total // 2] if total % 2 == 1 else (sorted_scores[total // 2 - 1] + sorted_scores[total // 2]) / 2
        std = np.std(score_values, ddof=1) if total > 1 else 0
        
        # 分布统计
        min_score = min(score_values)
        max_score = max(score_values)
        pass_rate = sum(1 for s in score_values if s >= 60) / total
        excellent_rate = sum(1 for s in score_values if s >= 90) / total
        
        # 分数段分布
        bins = {
            "0-59": sum(1 for s in score_values if s < 60),
            "60-69": sum(1 for s in score_values if 60 <= s < 70),
            "70-79": sum(1 for s in score_values if 70 <= s < 80),
            "80-89": sum(1 for s in score_values if 80 <= s < 90),
            "90-100": sum(1 for s in score_values if s >= 90)
        }
        
        # 成绩曲线类型判断
        grade_curve = self._classify_curve(score_values)
        
        # 异常标记
        anomaly_flags = []
        if avg < 50:
            anomaly_flags.append("平均成绩过低")
        if avg > 90:
            anomaly_flags.append("平均成绩过高")
        if std < 5 and total >= 20:
            anomaly_flags.append("成绩分布异常集中")
        if max_score - min_score < 10 and total >= 20:
            anomaly_flags.append("成绩区分度不足")
        
        # 获取课程信息
        course_info = self.db.query("""
            SELECT c.course_code, c.course_name
            FROM courses c
            JOIN course_offerings co ON c.course_id = co.course_id
            WHERE co.offering_id = %s
        """, (offering_id,))
        
        return GradeStatistics(
            course_code=course_info[0]["course_code"],
            course_name=course_info[0]["course_name"],
            offering_id=offering_id,
            total_students=total,
            submitted_count=total,
            average=round(avg, 2),
            median=round(median, 2),
            std_dev=round(std, 2),
            min_score=min_score,
            max_score=max_score,
            pass_rate=round(pass_rate * 100, 1),
            excellent_rate=round(excellent_rate * 100, 1),
            score_distribution=bins,
            grade_curve=grade_curve,
            anomaly_flags=anomaly_flags
        )
    
    def _classify_curve(self, scores: List[float]) -> str:
        """判断成绩曲线类型"""
        if len(scores) < 10:
            return "insufficient_data"
        
        # 使用偏度和峰度判断
        skewness = stats.skew(scores)
        kurtosis = stats.kurtosis(scores)
        
        if skewness < -0.5:
            return "left_skewed"  # 左偏(高分多)
        elif skewness > 0.5:
            return "right_skewed"  # 右偏(低分多)
        elif abs(skewness) <= 0.5 and abs(kurtosis) <= 1:
            return "normal"  # 正态分布
        else:
            return "abnormal"  # 异常分布
    
    def generate_department_report(self, college_id: int, academic_year: int, semester: int) -> Dict:
        """生成院系成绩报告"""
        # 获取该院系所有课程的成绩统计
        offerings = self.db.query("""
            SELECT co.offering_id, c.course_code, c.course_name, c.credit,
                   c.course_type, t.teacher_id, t.name AS teacher_name
            FROM course_offerings co
            JOIN courses c ON co.course_id = c.course_id
            JOIN teachers t ON co.teacher_id = t.teacher_id
            WHERE co.academic_year = %s AND co.semester = %s
              AND c.college_id = %s
              AND co.status = 'completed'
        """, (academic_year, semester, college_id))
        
        reports = []
        for offering in offerings:
            stats = self.calculate_course_statistics(offering["offering_id"])
            if stats:
                reports.append({
                    **stats.__dict__,
                    "teacher_id": offering["teacher_id"],
                    "teacher_name": offering["teacher_name"],
                    "credit": offering["credit"],
                    "course_type": offering["course_type"]
                })
        
        # 汇总统计
        if reports:
            avg_avg = np.mean([r["average"] for r in reports])
            avg_pass = np.mean([r["pass_rate"] for r in reports])
            avg_excellent = np.mean([r["excellent_rate"] for r in reports])
            anomaly_count = sum(1 for r in reports if r["anomaly_flags"])
            
            summary = {
                "total_courses": len(reports),
                "average_score": round(avg_avg, 2),
                "average_pass_rate": round(avg_pass, 1),
                "average_excellent_rate": round(avg_excellent, 1),
                "courses_with_anomalies": anomaly_count,
                "anomaly_details": [r for r in reports if r["anomaly_flags"]]
            }
        else:
            summary = {"total_courses": 0}
        
        return {
            "college_id": college_id,
            "academic_year": academic_year,
            "semester": semester,
            "report_date": datetime.now().isoformat(),
            "summary": summary,
            "courses": reports
        }

场景四:突发事件——成绩泄露应急响应

背景:某天早上,教务处接到举报,有学生在论坛上晒出了某门课的全班成绩截图,怀疑系统被攻击导致数据泄露。

应急响应流程:

class SecurityIncidentResponse:
    """安全事件应急响应"""
    
    def __init__(self, db, audit_service, notification_service):
        self.db = db
        self.audit = audit_service
        self.notification = notification_service
    
    def handle_data_leak_report(self, reported_course_code: str, reported_date: datetime):
        """处理成绩泄露举报"""
        # 1. 立即冻结相关数据访问
        leak_response = self._freeze_related_data(reported_course_code)
        
        # 2. 追溯泄露源头
        leak_trace = self._trace_leak_source(reported_course_code, reported_date)
        
        # 3. 评估影响范围
        impact_assessment = self._assess_impact(leak_trace)
        
        # 4. 通知相关人员
        self._notify_stakeholders(leak_trace, impact_assessment)
        
        # 5. 生成事件报告
        report = self._generate_incident_report(leak_trace, impact_assessment)
        
        return {
            "incident_id": report["incident_id"],
            "severity": report["severity"],
            "root_cause": report["root_cause"],
            "affected_students": report["affected_count"],
            "leak_source": report["leak_source"],
            "recommended_actions": report["recommended_actions"]
        }
    
    def _trace_leak_source(self, course_code: str, reported_date: datetime) -> Dict:
        """追溯泄露源头"""
        # 查找泄露时间点前后的所有访问记录
        leak_window_start = reported_date - timedelta(hours=2)
        leak_window_end = reported_date + timedelta(hours=1)
        
        # 查询成绩表的访问日志
        access_logs = self.audit.query_access_logs(
            resource_type="grade",
            resource_code=course_code,
            start_time=leak_window_start,
            end_time=leak_window_end,
            order_by="timestamp DESC",
            limit=100
        )
        
        # 分析异常访问模式
        suspicious_users = set()
        abnormal_patterns = []
        
        for log in access_logs:
            # 检查是否超出正常权限范围
            if not self._is_access_within_permission(log):
                suspicious_users.add(log["user_id"])
                abnormal_patterns.append({
                    "user_id": log["user_id"],
                    "action": log["action"],
                    "timestamp": log["timestamp"],
                    "ip": log["ip_address"],
                    "reason": "超出权限范围访问"
                })
            
            # 检查批量下载行为
            if log["action"] == "export" and log["record_count"] > 50:
                suspicious_users.add(log["user_id"])
                abnormal_patterns.append({
                    "user_id": log["user_id"],
                    "action": log["action"],
                    "record_count": log["record_count"],
                    "timestamp": log["timestamp"],
                    "reason": "批量导出数据"
                })
        
        # 检查是否有SQL注入或越权访问的痕迹
        sql_logs = self.audit.query_sql_logs(
            start_time=leak_window_start,
            end_time=leak_window_end,
            suspicious_only=True
        )
        
        return {
            "course_code": course_code,
            "suspicious_users": list(suspicious_users),
            "abnormal_patterns": abnormal_patterns,
            "sql_anomalies": sql_logs,
            "total_access_records": len(access_logs)
        }
    
    def _is_access_within_permission(self, log_entry: Dict) -> bool:
        """检查访问是否在权限范围内"""
        user_id = log_entry["user_id"]
        action = log_entry["action"]
        target_id = log_entry.get("target_id")
        
        context = PermissionContext(
            user_id=user_id,
            user_type=log_entry["user_type"],
            roles=log_entry["roles"],
            attributes=log_entry.get("attributes", {}),
            target_resource="grade",
            target_action=action,
            target_attributes={"student_id": target_id} if target_id else {},
            request_ip=log_entry.get("ip_address", ""),
            request_time=log_entry["timestamp"]
        )
        
        engine = PermissionEngine(self.db)
        return engine.check_permission(context)
    
    def _assess_impact(self, leak_trace: Dict) -> Dict:
        """评估泄露影响"""
        # 统计受影响的学生数量
        affected_students = set()
        for pattern in leak_trace["abnormal_patterns"]:
            if "student_id" in pattern:
                affected_students.add(pattern["student_id"])
        
        # 评估泄露数据的敏感程度
        sensitive_data_exposed = []
        for user_id in leak_trace["suspicious_users"]:
            user_access = self.audit.query_user_access_history(
                user_id=user_id,
                start_time=leak_trace["timestamp"] - timedelta(days=7)
            )
            for access in user_access:
                if access["action"] == "export" and access["record_count"] > 20:
                    sensitive_data_exposed.append({
                        "user_id": user_id,
                        "records_exported": access["record_count"],
                        "export_time": access["timestamp"]
                    })
        
        return {
            "estimated_affected_students": len(affected_students),
            "sensitive_data_exposed": sensitive_data_exposed,
            "leak_confidence": self._calculate_leak_confidence(leak_trace)
        }
    
    def _calculate_leak_confidence(self, leak_trace: Dict) -> float:
        """计算泄露确认置信度"""
        if not leak_trace["abnormal_patterns"]:
            return 0.0
        
        # 基于多个证据源计算置信度
        evidence_scores = []
        
        # 证据1:异常访问模式数量
        evidence_scores.append(min(len(leak_trace["abnormal_patterns"]) / 5, 1.0) * 0.3)
        
        # 证据2:SQL注入痕迹
        if leak_trace["sql_anomalies"]:
            evidence_scores.append(0.4)
        
        # 证据3:批量导出行为
        bulk_exports = [p for p in leak_trace["abnormal_patterns"] if p.get("action") == "export"]
        if bulk_exports:
            evidence_scores.append(min(len(bulk_exports) / 3, 1.0) * 0.3)
        
        return sum(evidence_scores)

系统架构:从单机到生产级部署

聊完了数据层和权限层,最后看看整个系统是怎么组织起来的。

技术选型

前端:React + TypeScript + Ant Design
后端:Python 3.11 + FastAPI
数据库:PostgreSQL 15(主库)+ Redis 7(缓存)
消息队列:RabbitMQ(异步任务)
对象存储:MinIO(成绩文件存储)
搜索引擎:Elasticsearch(成绩检索)

核心API设计

# API路由设计
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import List, Optional
import uuid

router = APIRouter(prefix="/api/v1", tags=["成绩管理"])

# ===== 成绩录入 =====

class GradeImportRequest(BaseModel):
    offering_id: int
    grades: List[GradeItem]
    import_mode: str = Field(default="replace", pattern="^(replace|append|merge)$")
    reason: str = Field(..., min_length=10, max_length=500)

class GradeItem(BaseModel):
    student_id: str
    score: Optional[float] = Field(None, ge=0, le=100)
    letter_grade: Optional[str] = Field(None, pattern="^[A-F][+-]?$")
    gpa_point: Optional[float] = Field(None, ge=0, le=4.0)
    remarks: Optional[str] = None

@router.post("/grades/import")
async def import_grades(
    request: GradeImportRequest,
    current_user: User = Depends(get_current_user),
    service: GradeImportService = Depends(get_grade_import_service)
):
    """批量导入成绩"""
    result = await service.import_grades(request, current_user)
    return result

@router.post("/grades/{grade_id}/submit")
async def submit_grade(
    grade_id: int,
    current_user: User = Depends(get_current_user),
    service: GradeSubmitService = Depends(get_grade_submit_service)
):
    """提交成绩(正式提交,不可再修改)"""
    result = await service.submit(grade_id, current_user)
    return result

# ===== 成绩查询 =====

class GradeQueryFilter(BaseModel):
    student_id: Optional[str] = None
    class_id: Optional[int] = None
    course_code: Optional[str] = None
    academic_year: Optional[int] = None
    semester: Optional[int] = None
    grade_status: Optional[str] = None
    min_score: Optional[float] = None
    max_score: Optional[float] = None
    page: int = Field(default=1, ge=1)
    page_size: int = Field(default=20, le=100)

@router.get("/grades")
async def query_grades(
    filters: GradeQueryFilter = Depends(),
    current_user: User = Depends(get_current_user),
    service: GradeQueryService = Depends(get_grade_query_service)
):
    """查询成绩(带权限过滤)"""
    result = await service.query(filters, current_user)
    return result

@router.get("/grades/export")
async def export_grades(
    filters: GradeQueryFilter = Depends(),
    format: str = Field(default="xlsx", pattern="^(xlsx|csv|pdf)$"),
    current_user: User = Depends(get_current_user),
    service: GradeExportService = Depends(get_grade_export_service)
):
    """导出成绩"""
    # 检查导出权限
    if not await service.check_export_permission(filters, current_user):
        raise HTTPException(status_code=403, detail="无权导出此范围的成绩数据")
    
    # 异步导出
    export_task = await service.create_export_task(filters, format, current_user)
    return {"task_id": export_task.task_id, "status": "processing"}

# ===== 成绩审批 =====

class GradeChangeRequest(BaseModel):
    grade_id: int
    new_score: float = Field(..., ge=0, le=100)
    reason: str = Field(..., min_length=20, max_length=1000)
    supporting_docs: List[str] = Field(default_factory=list)

@router.post("/grades/change-request")
async def create_change_request(
    request: GradeChangeRequest,
    current_user: User = Depends(get_current_user),
    service: GradeChangeService = Depends(get_grade_change_service)
):
    """发起成绩修改申请"""
    result = await service.create_request(request, current_user)
    return result

@router.post("/grades/change-request/{request_id}/approve")
async def approve_change_request(
    request_id: str,
    comment: Optional[str] = None,
    current_user: User = Depends(get_current_user),
    service: GradeChangeService = Depends(get_grade_change_service)
):
    """审批成绩修改申请"""
    result = await service.approve(request_id, current_user, comment)
    return result

@router.post("/grades/change-request/{request_id}/reject")
async def reject_change_request(
    request_id: str,
    comment: str = Field(..., min_length=10),
    current_user: User = Depends(get_current_user),
    service: GradeChangeService = Depends(get_grade_change_service)
):
    """驳回成绩修改申请"""
    result = await service.reject(request_id, current_user, comment)
    return result

# ===== 统计报表 =====

@router.get("/reports/grade-distribution")
async def get_grade_distribution(
    offering_id: int,
    current_user: User = Depends(get_current_user),
    service: GradeReportService = Depends(get_grade_report_service)
):
    """获取成绩分布统计"""
    result = await service.get_distribution(offering_id, current_user)
    return result

@router.get("/reports/department-summary")
async def get_department_summary(
    academic_year: int,
    semester: int,
    current_user: User = Depends(get_current_user),
    service: GradeReportService = Depends(get_grade_report_service)
):
    """获取院系成绩汇总"""
    result = await service.get_department_summary(academic_year, semester, current_user)
    return result

性能优化策略

成绩系统在高并发场景下(如成绩公布日)会面临巨大压力。以下是一些关键的优化策略:

# 缓存策略
class GradeCacheManager:
    """成绩缓存管理器"""
    
    def __init__(self, redis_client):
        self.redis = redis_client
        self.ttl_default = 300  # 默认5分钟
        self.ttl_heavy_query = 600  # 重查询10分钟
    
    def get_grade_summary(self, student_id: str) -> Optional[Dict]:
        """获取学生成绩汇总(缓存)"""
        cache_key = f"grade:summary:{student_id}"
        
        # 尝试从缓存读取
        cached = self.redis.get(cache_key)
        if cached:
            return json.loads(cached)
        
        # 缓存未命中,查询数据库
        summary = self._query_summary_from_db(student_id)
        
        if summary:
            # 写入缓存
            self.redis.setex(
                cache_key,
                self.ttl_heavy_query,
                json.dumps(summary)
            )
        
        return summary
    
    def invalidate_grade_cache(self, student_id: str = None, offering_id: int = None):
        """清除成绩缓存"""
        if student_id and offering_id:
            # 清除特定学生的特定课程缓存
            self.redis.delete(f"grade:summary:{student_id}")
            self.redis.delete(f"grade:detail:{student_id}:{offering_id}")
        elif student_id:
            # 清除学生所有缓存
            keys = self.redis.keys(f"grade:summary:{student_id}*")
            if keys:
                self.redis.delete(*keys)
        elif offering_id:
            # 清除课程相关缓存
            keys = self.redis.keys(f"grade:detail:*:{offering_id}")
            if keys:
                self.redis.delete(*keys)
    
    def _query_summary_from_db(self, student_id: str) -> Optional[Dict]:
        """从数据库查询成绩汇总"""
        sql = """
        SELECT 
            COUNT(*) as total_courses,
            AVG(score) as avg_score,
            AVG(gpa_point) as avg_gpa,
            SUM(CASE WHEN score >= 90 THEN credit ELSE 0 END) / SUM(credit) * 4 as weighted_gpa
        FROM grades g
        JOIN course_offerings co ON g.offering_id = co.offering_id
        JOIN courses c ON co.course_id = c.course_id
        WHERE g.student_id = %s 
          AND g.grade_status = 'published'
          AND c.status = 'active'
        """
        result = self.db.query(sql, (student_id,))
        return result[0] if result else None
# 数据库读写分离
class GradeDatabaseManager:
    """成绩数据库管理器(读写分离)"""
    
    def __init__(self, primary_db, replica_db):
        self.primary = primary_db  # 写库
        self.replica = replica_db   # 读库
    
    def execute_write(self, sql: str, params: tuple = None):
        """执行写操作"""
        return self.primary.execute(sql, params)
    
    def execute_read(self, sql: str, params: tuple = None):
        """执行读操作(走从库)"""
        return self.replica.execute(sql, params)
    
    def execute_with_fallback(self, write_sql: str, read_sql: str, params: tuple = None):
        """先尝试读库,失败则回退到写库(适用于复杂查询)"""
        try:
            result = self.replica.execute(read_sql, params)
            if result:
                return result
        except Exception:
            pass  # 从库不可用,回退到主库
        
        return self.primary.execute(write_sql, params)

一些容易被忽视的设计细节

最后,聊几个在实际项目中踩过坑的细节。这些东西文档里很少写,但真的很重要。

1. 成绩数据的版本控制

成绩一旦被公布,就可能被家长截图、被学生打印出来用于申请学校。所以成绩数据需要有版本概念:

-- 成绩版本表
CREATE TABLE grade_versions (
    version_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    grade_id BIGINT NOT NULL,
    version_number INT NOT NULL COMMENT '版本号',
    score DECIMAL(5,2) NOT NULL,
    letter_grade CHAR(2),
    gpa_point DECIMAL(3,2),
    grade_status VARCHAR(20) NOT NULL,
    changed_by VARCHAR(20) NOT NULL,
    changed_at TIMESTAMP NOT NULL,
    change_reason TEXT NOT NULL,
    FOREIGN KEY (grade_id) REFERENCES grades(grade_id),
    UNIQUE KEY uk_version (grade_id, version_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩版本历史表';

每次成绩变更,不仅记录到变更日志,还要创建一个新版本。这样将来追溯历史成绩时有据可查。

2. 成绩的保密期设计

不是所有成绩都应该立即公开。有些考试(如期末考试)的成绩可能需要保密一段时间,用于教学评估:

class GradeReleasePolicy:
    """成绩发布策略"""
    
    RELEASE_RULES = {
        "exam_final": {
            "release_delay_days": 14,  # 期末考试延迟14天发布
            "approval_required": True,  # 需要教务处审批
            "allowed_viewers": ["student", "parent", "teacher", "registrar"]
        },
        "exam_midterm": {
            "release_delay_days": 3,  # 期中考试成绩3天后发布
            "approval_required": False,
            "allowed_viewers": ["student", "teacher"]
        },
        "assignment": {
            "release_delay_days": 0,  # 平时作业当天发布
            "approval_required": False,
            "allowed_viewers": ["student", "teacher"]
        },
        "project": {
            "release_delay_days": 7,
            "approval_required": True,
            "allowed_viewers": ["student", "parent", "teacher", "registrar"]
        }
    }
    
    def check_grade_release(self, grade: Dict) -> Dict:
        """检查成绩是否可以发布"""
        course_type = grade.get("course_type", "exam_final")
        rule = self.RELEASE_RULES.get(course_type, self.RELEASE_RULES["exam_final"])
        
        submitted_at = grade.get("submitted_at")
        now = datetime.now()
        
        # 计算是否可以发布
        delay = timedelta(days=rule["release_delay_days"])
        eligible_at = submitted_at + delay
        
        can_release = now >= eligible_at
        
        # 检查是否需要审批
        needs_approval = rule["approval_required"] and not grade.get("approved")
        
        return {
            "can_release": can_release and not needs_approval,
            "eligible_at": eligible_at.isoformat(),
            "needs_approval": needs_approval,
            "delay_days": rule["release_delay_days"]
        }

3. 成绩数据的脱敏处理

对外输出成绩数据时,需要脱敏。比如导出给家长的成绩单,不应该包含班级排名、教师评价等敏感信息:

class GradeDataMasker:
    """成绩数据脱敏器"""
    
    MASK_RULES = {
        "public": {
            "fields_to_hide": ["class_rank", "department_rank", "teacher_remarks", "submission_ip"],
            "score_format": "letter_only"  # 只显示等级,不显示分数
        },
        "internal": {
            "fields_to_hide": ["submission_ip", "operator_ip"],
            "score_format": "full"  # 显示完整分数
        },
        "confidential": {
            "fields_to_hide": [],
            "score_format": "full"
        }
    }
    
    def mask_grade_data(self, grade_data: Dict, release_level: str = "public") -> Dict:
        """对成绩数据进行脱敏"""
        rule = self.MASK_RULES.get(release_level, self.MASK_RULES["public"])
        
        masked = grade_data.copy()
        
        # 隐藏指定字段
        for field in rule["fields_to_hide"]:
            masked.pop(field, None)
        
        # 处理分数格式
        if rule["score_format"] == "letter_only" and "score" in masked:
            masked["score_display"] = masked.get("letter_grade", "N/A")
            masked.pop("score", None)
        
        return masked

写在最后

设计一个学校成绩管理系统,看似是在建一张表、写几个接口,实际上是在构建一套完整的数据治理体系。从数据库架构到权限管控,从日常业务场景到突发事件应急,每一个环节都需要反复推敲。

我在这篇文章里分享的,都是我经历过或见证过的真实案例。那些”踩过的坑”,大部分都转化成了这里的设计决策。如果你正在做类似的项目,希望这些内容能帮你少一些弯路。

当然,每个学校的业务需求都不一样,这套方案也需要根据实际情况进行调整。但核心思路是一致的:数据安全是底线,权限管控是手段,业务流畅是目标。三者平衡好了,系统才能真正用起来。