ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

系统最终章:从功能完成到可交付的权限、幂等与部署验证指南

系统最终章:从功能完成到可交付的权限、幂等与部署验证指南 海兰德铁道学院是一个轨道交通岗位技能培训信息系统的代号。项目推进到最终章团队最常遇到的状态不是功能没写而是功能写了一大堆真正换一台机器、换一个账号、按一条完整业务流走一遍立刻暴露出权限漏洞、数据不一致、部署环境不干净、演示脚本临场卡壳。最终章真正要回答的问题不是“还能加什么功能”而是“当前这一版能不能安全地交给使用者”。这里以“实训报名与成绩发布”作为核心业务链路从数据模型、工程保护、验证路径、部署与文档几个角度说明最终章应该检查什么、补什么、怎么验证并给出一条可复现的排错链路。适合正在做实训项目收尾、毕业设计提交前自查或者负责一个小型内部系统上线前验收的同学参考。1. 最终章的核心目标从“功能写完”变为“可以交付”1.1 “能跑”和“能交付”之间差了哪些工作开发阶段的“能跑”通常是在本机环境下验证了一次成功操作。最终章要面对的是验收环境可能是另一台电脑、另一个数据库、另一个人来操作。要把这一次成功操作扩展成一组用例并补上失败路径才算真正可交付。可以交付的系统至少满足这些条件在一个干净环境里按业务顺序执行完核心流程不会中断。未登录、无权限、重复提交、参数错误等异常操作有明确反馈。关键日志能定位到某一次请求的执行过程。部署文档和SQL脚本可以独立复现。数据不会出现重复、孤儿记录、状态错乱。很多项目在“能跑”阶段被贴上“完成”标签实际上缺少的是边界处理、权限校验、并发保护和部署验证。这几项在功能开发阶段容易被忽略在最终章却会决定验收是否通过。“能跑”和“能交付”的差异可以这样对比维度能跑能交付功能核心接口通核心、边界、异常路径都覆盖数据本地有数据唯一约束、状态机、外键关系正确权限登录能进未登录和无权限操作均被拦截依赖本机可启动新环境按文档可复现运维日志有输出日志可定位、配置可外置、可备份回滚文档代码里有注释README、部署、接口、数据字典完整1.2 用收尾清单圈定工作边界最终章最容易犯的错误是继续加需求。每加一个功能就会带来新的接口、新的数据、新的回归风险。建议先冻结需求按收尾清单逐项核对而不是让开发人员继续在代码里“顺手补一个功能”。一份可复用的收尾清单可以这样定义数据模型是否满足业务约束唯一索引、外键关系、状态字段。核心业务链路是否完整查询、报名、确认、发布成绩、查看结果。后端接口是否都做了登录和权限校验。写操作是否具备幂等或并发保护。异常分支是否有提示、日志和状态码。部署过程是否能在干净环境里复现。演示数据是否干净是否存在脏数据和重复数据。文档是否和当前代码一致。注意收尾清单不是上线后才贴到会议室里的建议在进入最终章第一天就建立每处理完一项就记录验证结果。这样验收时可以把记录直接作为项目状态说明。2. 从数据模型开始核对实训报名与成绩的状态机2.1 报名和成绩表先设计成这样一个轨道交通技能培训系统至少要覆盖三类数据实训课程、学生报名、成绩发布。下面给出一个最小例子后续代码都围绕它展开。CREATE TABLE train_course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, capacity INT NOT NULL DEFAULT 30, status VARCHAR(20) NOT NULL DEFAULT DRAFT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE train_enrollment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, student_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_course_student (course_id, student_id) ); CREATE TABLE train_score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, enrollment_id BIGINT NOT NULL, score DECIMAL(5,2), published_by BIGINT, published_at DATETIME, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_enrollment (enrollment_id) );这里的核心是train_enrollment表。唯一索引uk_course_student保证同一个学生对同一门课程只能出现一次报名主记录即使前端因为网络问题把请求发了两遍数据库也会挡住重复数据。train_score表通过enrollment_id唯一索引保证一份报名记录只对应一条成绩。version字段暂时还不做逻辑处理它给后续的乐观锁使用。如果最终章发现成绩或报名状态可能被多人同时修改这个字段会成为必要的保护手段。2.2 状态不是字符串是业务规则很多项目直接用字符串存状态比如status等于ENROLLING、CONFIRMED、CANCELLED、FINISHED。但问题在于任意位置都可以直接执行update train_enrollment set statusCONFIRMED状态流转就失去了约束。推荐使用 Java 枚举定义状态public enum EnrollmentStatus { ENROLLING, // 报名中 CONFIRMED, // 已确认 CANCELLED, // 已取消 FINISHED // 已完成 }然后在 Service 层统一处理状态流转不允许其他代码直接修改状态字段。一个示例方法如下public Enrollment confirmEnrollment(Long enrollmentId) { Enrollment e enrollmentMapper.selectById(enrollmentId); if (e null) { throw new BusinessException(报名记录不存在); } if (e.getStatus() EnrollmentStatus.CANCELLED) { throw new BusinessException(已取消的记录不能确认); } if (e.getStatus() EnrollmentStatus.CONFIRMED) { return e; } e.setStatus(EnrollmentStatus.CONFIRMED); enrollmentMapper.updateById(e); return e; }这段代码做了三件事先判断记录是否存在再判断当前状态是否允许流转最后做幂等返回。已经确认过的记录再确认一次会直接返回而不会重复写库。2.3 最终章要清理的几种脏数据一套系统跑了一段时间后数据库里可能出现以下脏数据报名记录对应的课程被删除形成孤儿记录。成绩记录对应的报名不存在。课程名额已满但课程状态还是报名中。同一学生同一课程出现多条报名记录说明唯一索引没有正确建立。可以用如下 SQL 排查-- 查询没有对应课程报名的孤儿报名记录 SELECT te.id, te.course_id FROM train_enrollment te LEFT JOIN train_course tc ON te.course_id tc.id WHERE tc.id IS NULL; -- 查询没有对应报名记录的成绩 SELECT ts.id FROM train_score ts LEFT JOIN train_enrollment te ON ts.enrollment_id te.id WHERE te.id IS NULL; -- 查询已经满员但状态仍然是报名中的课程 SELECT tc.id, tc.course_name, tc.capacity FROM train_course tc WHERE tc.status ENROLLING AND (SELECT COUNT(*) FROM train_enrollment te WHERE te.course_id tc.id AND te.status CANCELLED) tc.capacity;这三条 SQL 应该作为最终章数据检查的固定脚本保留下来。修复的方式不是看到异常就手动删数据而是先确认业务规则再写一次性修复脚本并记录下来。3. 把最容易漏掉的工程保护补上3.1 后端权限校验不能只靠前端按钮前端隐藏按钮只影响界面展示。一个熟悉接口调用的人完全可以直接绕过页面向后台发送请求。如果后端接口没有权限校验就会出现“学生没有教师权限但直接调用接口发布了成绩”的情况。Spring Boot 项目里最简单的做法是使用拦截器统一校验登录态和角色。一个示例实现如下public class RoleInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod)) { return true; } LoginUser user (LoginUser) request.getAttribute(loginUser); if (user null) { throw new UnauthorizedException(请先登录); } RequireRole requireRole ((HandlerMethod) handler).getMethodAnnotation(RequireRole.class); if (requireRole ! null !user.getRoles().contains(requireRole.value())) { throw new ForbiddenException(无权限执行该操作); } return true; } }这段代码会拦截所有接口请求。先判断当前处理对象是不是普通方法再判断登录用户是否存在最后检查方法上的RequireRole注解。最终章一定要逐个接口确认是否所有写接口都配置了登录认证。注册拦截器时记得把登录接口放在放行列表里否则会出现“登录接口本身无法访问”的问题。3.2 报名接口用唯一索引兜住重复请求学生报名时如果连续点击两次“报名”或者因为网络重试验证请求被发送了两次业务层可能插入两条报名记录。即使数据库有唯一索引也需要在代码里捕获异常并返回业务提示而不是让用户看到一条数据库异常。典型实现如下Transactional public void enroll(Long courseId, Long studentId) { try { TrainCourse course courseMapper.selectById(courseId); if (course null || !ENROLLING.equals(course.getStatus())) { throw new BusinessException(该课程当前不可报名); } Enrollment e new Enrollment(); e.setCourseId(courseId); e.setStudentId(studentId); e.setStatus(EnrollmentStatus.ENROLLING); enrollmentMapper.insert(e); } catch (DuplicateKeyException ex) { throw new BusinessException(你已经报名过该课程); } }这里的顺序是先做业务校验再插入数据遇到唯一索引冲突时返回友好提示。Transactional保证报名记录和后续其他写入操作的一致性。注意唯一索引是最后防线不是唯一的并发控制。如果还需要防止报名人数超过课程容量常见思路是在事务里对课程记录加锁或使用数据库条件更新更新名额。在最终章阶段至少要先保证不会产生重复报名。3.3 成绩发布乐观锁防止覆盖教师 A 和教师 B 同时打开同一份成绩记录都看到 85 分。A 改成 90 分并保存B 改成 70 分并保存最后数据库里留存的是 70 分A 的修改被静默覆盖。这时需要在train_score的version字段上做乐观锁。int updated scoreMapper.update( null, new LambdaUpdateWrapperTrainScore() .eq(TrainScore::getId, scoreId) .eq(TrainScore::getVersion, oldVersion) .set(TrainScore::getScore, newScore) .set(TrainScore::getVersion, oldVersion 1) ); if (updated 0) { throw new BusinessException(成绩已被其他人修改请刷新后重试); }update的where条件中带上了version oldVersion。只有当前版本号还等于操作者查询到的版本号时更新才会成功。如果期间有人改过updated等于 0业务层会提示用户刷新后重试而不是直接覆盖。成绩发布是典型的并发写场景最终章一定要检查是否补了乐观锁。没有版本号的成绩表在多人同时操作时一定会有数据覆盖问题。3.4 高频查询要不要上缓存课程列表这类高频查询可以加缓存减少数据库压力。但最终章不要为了“显得技术完整”就随意引入 Redis。需要先评估数据量、查询频率和变更频率。如果只是几千条课程记录访问量也不高直接用本地缓存即可。一个常见的做法是使用 Caffeinespring: cache: type: caffeine然后在查询方法上增加缓存注解Cacheable(cacheNames courseList, key #status) public ListTrainCourse listCourses(String status) { return courseMapper.selectList( new LambdaQueryWrapperTrainCourse() .eq(TrainCourse::getStatus, status)); }加了缓存之后必须同步考虑失效时机。比如课程状态从报名中变成已开始时要执行CacheEvict清理对应缓存否则用户会一直看到旧状态。最终章最容易踩的坑就是“缓存加了数据更新后用户看不到新内容”。4. 用固定的验证脚本证明核心链路可用4.1 把点击页面的动作变成脚本手工点击页面做验收容易漏步骤也容易每次点出不同结果。最终章建议准备一组可以重复执行的接口脚本按业务顺序验证。下面是一个 bash 示例假设后端已经启动在本地 8080 端口# 1 登录获取 token TOKEN$(curl -s -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:student01,password:123456} | jq -r .data.token) # 2 查询可报名的课程 curl -s http://localhost:8080/api/courses?statusENROLLING \ -H Authorization: Bearer $TOKEN # 3 报名课程 curl -s -X POST http://localhost:8080/api/enrollments \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d {courseId:1} # 4 重复报名观察是否被幂等拦截 curl -s -X POST http://localhost:8080/api/enrollments \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d {courseId:1}这个脚本可以保存到项目的scripts/acceptance.sh目录中。以后每次改了代码都可以重新跑一遍对比输出结果。脚本比手工点击更稳定也更容易作为验收记录归档。4.2 预期结果和异常输出要提前定义最终章要给关键接口定义明确的预期结果不能只验证“请求没有报 500”。下面是一张可以贴在验收记录里的表场景请求预期 HTTP 状态预期业务码登录成功POST /api/auth/login200SUCCESS未登录查询课程GET /api/courses401UNAUTHORIZED重复报名POST /api/enrollments409DUPLICATE_ENROLL对未开放课程报名POST /api/enrollments400COURSE_NOT_ENROLLING成绩并发冲突PUT /api/scores/1409VERSION_CONFLICT错误响应该包含业务码、提示信息和 traceId方便日志定位{ code: DUPLICATE_ENROLL, message: 你已经报名过该课程, traceId: a3f9e0c1-9d2b-4e6f-8f2c-1b3d6a9e5c01 }如果接口在报错时只返回一个笼统的500最终章就需要给统一异常处理补上这些信息。否则演示时用户看到白屏或Internal Server Error连问题都说不清楚。4.3 日志要能按 traceId 串联排查问题时最怕日志里只有零散打印缺少请求标识。建议在拦截器或过滤器里生成 traceId 放进日志框架的 MDC在统一异常处理里记录完整异常信息。一个最小日志配置logging: level: root: INFO com.highland.training: DEBUG从现象到根因推荐按下面顺序排查请求内容是否正确URL、请求头、JSON 字段。配置文件是否生效环境变量、profile 是否选择正确。依赖版本是否匹配JDK、Spring Boot、MySQL 驱动。权限是否缺失登录态、角色、注解是否配置正确。环境是否正常端口、数据库连接、Redis 连接。数据是否有问题唯一约束、状态值、脏数据。日志是否记录关键路径先找 traceId再看 SQL再看异常栈。不要一开始就怀疑框架有问题。大多数最终章排错最后定位到的都是常见配置或数据问题。5. 学习环境与生产环境必须分开看待5.1 学习环境用一条命令起依赖开发验证阶段可以用 Docker Compose 快速创建 MySQL 和 Redis 依赖。一个最小配置如下services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: highland_train ports: - 3306:3306 redis: image: redis:7 ports: - 6379:6379启动命令docker compose up -d mvn spring-boot:run这是学习环境的快速方案。数据库密码不要直接作为生产配置使用Docker 里的数据容器一旦删除数据也会消失所以只能用于开发调试和功能验证。5.2 生产环境最少要补哪些配置生产环境需要把配置从代码仓库里剥离出来尤其是数据库口令。推荐通过环境变量注入并暴露健康检查接口spring: datasource: url: jdbc:mysql://数据库地址:3306/highland_train username: platform_user password: ${DB_PASSWORD} redis: host: ${REDIS_HOST} port: ${REDIS_PORT} cache: type: redis management: endpoints: web: exposure: include: health,info这样的配置意味着数据库口令不写死在仓库里而是在部署时通过环境变量传入。health接口暴露后监控系统可以定时检查服务状态。注意生产环境的数据库账户要遵循最小权限原则应用账户只需要业务库的增删改查权限不应当使用 root 账户。备份、回滚、监控这些能力必须在最终章完成而不是等上线出问题再补。5.3 上线前备份和回滚至少准备这些上线前至少准备三样东西数据库备份、上一版本的部署产物、数据库变更脚本。这样一旦发布出问题可以恢复数据并切回旧版本。备份命令示例mysqldump -u platform_user -p highland_train highland_train_backup_$(date %Y%m%d_%H%M%S).sql回滚顺序建议是恢复数据库备份或反向执行变更脚本。切换回上一版 jar 包或镜像。重启服务。检查健康检查接口和关键业务接口。这里要注意数据库变更脚本必须是可回滚的。如果某个 SQL 是删除字段那么回滚脚本必须能把字段加回来。最终章如果不维护这类脚本回滚就只能靠人工处理风险很高。6. 文档和演示才是最终章容易被看见的部分6.1 最小文档集项目文档不需要写得像产品手册但至少要覆盖使用和排障所需的信息。建议在docs目录下维护以下文件docs/ README.md deploy.md api.md >
返回列表