Spring Boot + Vue构建互动剧情网站:分支引擎与审计日志实战

Spring Boot + Vue构建互动剧情网站:分支引擎与审计日志实战
如果你平时喜欢写同人设定又正好是搞后端或全栈开发的那这篇文章应该能给你不少灵感。我们不讲小说剧情而是把“穿越、枷锁、舰长、高层窥视”这组设定翻译成一套可以落地的互动剧情网站系统玩家从任意节点进入故事、在关键选项处分叉剧情、通过能力解锁隐藏内容、管理员在后台维护剧情树同时所有敏感操作都会留下审计记录。整篇文章围绕 Spring Boot Vue 的完整前后端实现来展开内容包括数据建模、剧情分支引擎、存档恢复、权限控制、审计日志、常见问题排查和工程建议适合有一定 Java 基础、想完成一个全栈实战项目的读者。1. 项目背景与需求拆解1.1 同人互动剧情站要解决什么问题传统的同人小说阅读方式是线性的作者写好一章读者按顺序往下读。这种模式的优点是创作门槛低但缺点是读者参与感弱尤其是“如果主角当时做了另一个选择故事会怎样”这类想法很难在静态文本里得到满足。互动剧情站解决的就是这个问题。它把一个小说的核心剧情拆成节点和选项读者在关键节点做出选择系统根据选择跳到不同的后续节点。这样一部同人作品可以演化出多条剧情线读者也可以反复重玩去体验不同分支。技术上的核心难点有三个剧情数据怎么建模才能表达“分支、跳转、结局”这些关系。读者读到一半退出后怎么恢复上次的进度。同一部作品里有隐藏剧情、能力解锁等特殊内容权限控制怎么做。如果把这三个问题解决掉一个互动剧情网站的核心骨架就出来了。1.2 从小说设定到功能模块我们拿标题里的设定来对需求做一次映射这样理解起来更直观。小说设定对应技术模块具体功能穿越到崩坏世界存档与时间线恢复读者进入故事后生成进度随时可继续阅读掌握神秘力量“枷锁”能力解锁与条件校验满足解锁条件后才能看到隐藏剧情或特殊结局被任命为舰长后台管理与角色权限有“舰长”角色的用户可以在后台维护剧情节点发现更高层次的窥视操作审计与监控日志后台敏感操作全部记录操作人、时间、内容这样一拆原本偏文学设定的内容就变成了很具体的功能需求。1.3 技术选型思路这个项目选择 Spring Boot 作为后端基础框架原因很直接Spring Boot 的自动配置能大幅减少开发配置。Spring Data JPA 可以快速完成对象与数据库表的映射。Spring Security 和 AOP 能很好地支撑权限校验与日志审计。前端用 Vue 3 Element Plus适合做阅读器和后台管理这类界面交互比较多的小型系统。数据库使用 MySQL。2. 环境准备与项目初始化2.1 环境依赖说明本文示例使用以下环境版本需要根据你的实际情况调整JDK 8 或 JDK 11Maven 3.6 以上MySQL 5.7 或 8.0Node.js 16 以上IDEA 或 VSCode如果你的本机环境版本不一致也不用担心重点了解配置思路即可。2.2 创建 Spring Boot 工程推荐直接通过 Spring Initializr 生成工程也可以使用 IDEA 自带的 Spring Initializr。需要勾选的依赖包括Spring WebSpring Data JPAMySQL DriverLombokSpring Security可选用于权限控制AOP生成后工程的目录结构如下story-platform ├── pom.xml └── src └── main ├── java │ └── com │ └── example │ └── story │ ├── StoryApplication.java │ ├── controller │ ├── service │ ├── repository │ ├── entity │ ├── config │ └── aspect └── resources └── application.yml2.3 基础配置文件在src/main/resources/application.yml中配置数据源和 JPA 基础信息server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/story_platform?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true open-in-view: false这里有几个容易忽略的配置项需要解释一下。serverTimezoneAsia/Shanghai是为了避免 MySQL 驱动在连接时报时区错误如果你的数据库时区设置不同需要改成对应时区。ddl-auto: update表示 Hibernate 会根据实体类自动更新表结构开发环境很方便但生产环境通常建议改成validate或直接关掉避免 Hibernate 自动改表造成风险。open-in-view: false是为了避免 Controller 层持有数据库连接会话减少连接占用但在使用懒加载时需要格外注意序列化问题后面会提到。3. 数据库设计与核心概念3.1 剧情节点模型互动剧情的数据模型是整个系统最核心的部分。这里使用经典的“邻接表”思路每个剧情节点记录自己的内容以及可以跳转到哪些后续节点。story_node表结构CREATE TABLE story_node ( id BIGINT PRIMARY KEY AUTO_INCREMENT, node_code VARCHAR(64) NOT NULL COMMENT 节点编码例如 N001, title VARCHAR(128) NOT NULL COMMENT 节点标题, content TEXT NOT NULL COMMENT 节点正文, is_end TINYINT DEFAULT 0 COMMENT 是否结局节点, lock_key VARCHAR(64) DEFAULT NULL COMMENT 解锁条件对应的能力标识, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_node_code (node_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;story_choice表记录某个节点下的可选分支CREATE TABLE story_choice ( id BIGINT PRIMARY KEY AUTO_INCREMENT, node_id BIGINT NOT NULL COMMENT 当前节点ID, choice_text VARCHAR(255) NOT NULL COMMENT 选项文案, next_node_id BIGINT NOT NULL COMMENT 跳转到的节点ID, required_key VARCHAR(64) DEFAULT NULL COMMENT 选项需要的能力标识, sort_order INT DEFAULT 0 COMMENT 排序, KEY idx_node_id (node_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的设计使用的是“节点 选项”的结构。一个剧情节点有多个选项每个选项跳转到下一个节点于是自然形成了一棵剧情树。lock_key用来标记隐藏剧情。比如某个节点要求读者拥有“枷锁”能力才能进入那么在这个节点上可以设置lock_key SHACKLE。3.2 用户进度与能力解锁模型读者在阅读时系统需要保存进度。这里字段不能只存当前节点还要记录历史路径以便后续做剧情回看和统计。设计user_story_progress表CREATE TABLE user_story_progress ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户ID, story_id BIGINT NOT NULL COMMENT 作品ID, current_node_id BIGINT NOT NULL COMMENT 当前节点ID, path_data TEXT COMMENT JSON格式的历史路径, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_story (user_id, story_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;path_data字段用于保存读者一路选择过来的节点路径形式可以是[N001,N003,N007]方便后续做剧情统计或者回退操作。能力解锁用独立的user_item表来保存CREATE TABLE user_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, item_key VARCHAR(64) NOT NULL COMMENT 能力标识例如 SHACKLE, unlocked TINYINT DEFAULT 0 COMMENT 是否已解锁, unlock_time DATETIME DEFAULT NULL, UNIQUE KEY uk_user_item (user_id, item_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这样做的好处是能力系统可以通用。以后不管是“获得道具”“达到等级”还是“通关某条线”都可以抽象成item_key来判断。3.3 用户、角色与审计日志模型后台管理员并不是直接给每个用户加一个“管理员”布尔值而是使用标准的 RBAC 权限模型用户关联角色角色关联权限。虽然实际项目会单独建sys_permission表但本文简化处理先建核心三张表CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL, password VARCHAR(128) NOT NULL COMMENT BCrypt加密后的密码, nickname VARCHAR(64), enabled TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(32) NOT NULL COMMENT 角色编码如 CAPTAIN, role_name VARCHAR(32) NOT NULL, UNIQUE KEY uk_role_code (role_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE sys_user_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, UNIQUE KEY uk_user_role (user_id, role_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;审计日志表记录后台敏感操作对应标题里的“高层窥视”CREATE TABLE audit_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 操作人ID, username VARCHAR(64) NOT NULL, action VARCHAR(64) NOT NULL COMMENT 操作动作, target_type VARCHAR(32) NOT NULL COMMENT 操作对象类型, target_id BIGINT DEFAULT NULL COMMENT 操作对象ID, detail TEXT COMMENT 操作详情, ip VARCHAR(64) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;审计日志的核心原则是“只增不改”。一旦写入就不允许修改和删除这样才能保证出现问题时有据可查。4. 核心功能实战4.1 实体与数据访问层先创建剧情节点的实体类。路径为src/main/java/com/example/story/entity/StoryNode.javapackage com.example.story.entity; import lombok.Data; import javax.persistence.*; import java.time.LocalDateTime; Data Entity Table(name story_node) public class StoryNode { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name node_code, nullable false, length 64) private String nodeCode; Column(nullable false, length 128) private String title; Column(nullable false, columnDefinition TEXT) private String content; Column(name is_end, nullable false) private Integer isEnd; Column(name lock_key, length 64) private String lockKey; Column(name create_time) private LocalDateTime createTime; Column(name update_time) private LocalDateTime updateTime; }再创建选项实体类StoryChoice.javapackage com.example.story.entity; import lombok.Data; import javax.persistence.*; Data Entity Table(name story_choice) public class StoryChoice { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name node_id, nullable false) private Long nodeId; Column(name choice_text, nullable false, length 255) private String choiceText; Column(name next_node_id, nullable false) private Long nextNodeId; Column(name required_key, length 64) private String requiredKey; Column(name sort_order) private Integer sortOrder; }数据访问层使用 Spring Data JPA不需要写 SQL只需要定义接口。路径为src/main/java/com/example/story/repository/StoryNodeRepository.javapackage com.example.story.repository; import com.example.story.entity.StoryNode; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface StoryNodeRepository extends JpaRepositoryStoryNode, Long { OptionalStoryNode findByNodeCode(String nodeCode); }StoryChoiceRepository.javapackage com.example.story.repository; import com.example.story.entity.StoryChoice; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; public interface StoryChoiceRepository extends JpaRepositoryStoryChoice, Long { ListStoryChoice findByNodeIdOrderBySortOrderAsc(Long nodeId); }4.2 剧情分支引擎剧情分支引擎是整个系统的核心服务负责根据当前节点返回可选选项并在用户选择后推进剧情。路径为src/main/java/com/example/story/service/StoryEngineService.javapackage com.example.story.service; import com.example.story.entity.StoryChoice; import com.example.story.entity.StoryNode; import com.example.story.entity.UserItem; import com.example.story.repository.StoryChoiceRepository; import com.example.story.repository.StoryNodeRepository; import com.example.story.repository.UserItemRepository; import org.springframework.stereotype.Service; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; Service public class StoryEngineService { private final StoryNodeRepository nodeRepository; private final StoryChoiceRepository choiceRepository; private final UserItemRepository userItemRepository; public StoryEngineService(StoryNodeRepository nodeRepository, StoryChoiceRepository choiceRepository, UserItemRepository userItemRepository) { this.nodeRepository nodeRepository; this.choiceRepository choiceRepository; this.userItemRepository userItemRepository; } /** * 根据当前节点 ID 获取用户可用的选项 * 如果选项有 required_key需要校验用户是否已解锁对应能力 */ public ListStoryChoice getAvailableChoices(Long currentNodeId, Long userId) { ListStoryChoice choices choiceRepository.findByNodeIdOrderBySortOrderAsc(currentNodeId); SetString unlockedKeys userItemRepository.findByUserIdAndUnlocked(userId, 1) .stream() .map(UserItem::getItemKey) .collect(Collectors.toSet()); return choices.stream() .filter(choice - { if (choice.getRequiredKey() null || choice.getRequiredKey().isEmpty()) { return true; } return unlockedKeys.contains(choice.getRequiredKey()); }) .collect(Collectors.toList()); } /** * 执行选项跳转返回下一个节点 * 如果下一个节点设置了 lock_key需要再次校验 */ public StoryNode moveToNextNode(Long currentNodeId, Long choiceId, Long userId) { StoryNode currentNode nodeRepository.findById(currentNodeId) .orElseThrow(() - new RuntimeException(当前节点不存在)); StoryChoice choice choiceRepository.findById(choiceId) .filter(c - Objects.equals(c.getNodeId(), currentNodeId)) .orElseThrow(() - new RuntimeException(选项非法)); StoryNode nextNode nodeRepository.findById(choice.getNextNodeId()) .orElseThrow(() - new RuntimeException(目标节点不存在)); if (nextNode.getLockKey() ! null !nextNode.getLockKey().isEmpty()) { boolean unlocked userItemRepository .findByUserIdAndItemKeyAndUnlocked(userId, nextNode.getLockKey(), 1) .isPresent(); if (!unlocked) { throw new RuntimeException(尚未解锁能力 nextNode.getLockKey()); } } return nextNode; } }这段代码有几个关键点getAvailableChoices会把用户已经解锁的能力标识取出来过滤掉未解锁的选项。moveToNextNode必须校验选项是否属于当前节点防止用户直接传任意choiceId跳关。下一个节点的lockKey也要校验因为可能存在通过其他接口直接跳转的情况。这种服务层的双重校验能保证即使前端被绕过后端也不会放行未解锁的剧情。4.3 存档与进度恢复读者退出后再回来需要从上次的节点继续阅读这就是“穿越”对应的存档能力。路径为src/main/java/com/example/story/service/ProgressService.javapackage com.example.story.service; import com.example.story.entity.UserStoryProgress; import com.example.story.repository.UserStoryProgressRepository; import org.springframework.stereotype.Service; Service public class ProgressService { private final UserStoryProgressRepository progressRepository; public ProgressService(UserStoryProgressRepository progressRepository) { this.progressRepository progressRepository; } public UserStoryProgress getOrCreateProgress(Long userId, Long storyId, Long startNodeId) { return progressRepository.findByUserIdAndStoryId(userId, storyId) .orElseGet(() - { UserStoryProgress progress new UserStoryProgress(); progress.setUserId(userId); progress.setStoryId(storyId); progress.setCurrentNodeId(startNodeId); progress.setPathData([]); return progressRepository.save(progress); }); } public void saveProgress(Long userId, Long storyId, Long currentNodeId, String pathData) { UserStoryProgress progress progressRepository.findByUserIdAndStoryId(userId, storyId) .orElseThrow(() - new RuntimeException(进度不存在)); progress.setCurrentNodeId(currentNodeId); progress.setPathData(pathData); progressRepository.save(progress); } }这里需要注意进度保存的频率不能太密。如果用户每点一个选项都写一次数据库在高并发场景下会带来不必要的压力。实际项目可以做成“防抖保存”前端在离开页面或切换章节时才调用保存接口或者由后端维护延迟写机制。4.4 基于 AOP 的操作审计审计日志的实现放在 Controller 层最合适因为可以拿到操作人、请求参数和操作对象。为了避免在每个方法里重复写日志代码可以使用 AOP 切面统一处理。先定义一个自定义注解路径为src/main/java/com/example/story/annotation/AuditLog.javapackage com.example.story.annotation; import java.lang.annotation.*; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) Documented public interface AuditLog { String action() default ; }再定义切面路径为src/main/java/com/example/story/aspect/AuditLogAspect.javapackage com.example.story.aspect; import com.example.story.annotation.AuditLog; import com.example.story.entity.AuditLogEntity; import com.example.story.repository.AuditLogRepository; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterReturning; import org.aspectj.lang.annotation.Aspect; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; import java.time.LocalDateTime; Aspect Component public class AuditLogAspect { private final AuditLogRepository auditLogRepository; private final HttpServletRequest request; public AuditLogAspect(AuditLogRepository auditLogRepository, HttpServletRequest request) { this.auditLogRepository auditLogRepository; this.request request; } AfterReturning(annotation(auditLog)) public void record(JoinPoint joinPoint, AuditLog auditLog) { try { AuditLogEntity log new AuditLogEntity(); log.setAction(auditLog.action()); log.setTargetType(joinPoint.getSignature().getDeclaringType().getSimpleName()); log.setDetail(joinPoint.getArgs().length 0 ? String.valueOf(joinPoint.getArgs()[0]) : null); log.setIp(getClientIp()); log.setCreateTime(LocalDateTime.now()); log.setUsername(getCurrentUsername()); auditLogRepository.save(log); } catch (Exception e) { // 日志记录失败不能影响业务主流程 } } private String getClientIp() { String ip request.getHeader(X-Forwarded-For); if (ip null || ip.isEmpty()) { ip request.getRemoteAddr(); } return ip; } private String getCurrentUsername() { Authentication auth SecurityContextHolder.getContext().getAuthentication(); if (auth ! null) { return auth.getName(); } return anonymous; } }审计切面的实现有两个重要细节使用try-catch包裹日志写入防止日志表异常导致业务接口报错。日志记录不能影响主流程所以异常直接吞掉即可但最好在日志里输出错误信息。4.5 管理后台接口与角色权限后台管理接口需要限制只有“舰长”角色可以操作。在 Spring Security 中可以通过方法注解来实现。先开启方法安全在启动类上添加EnableGlobalMethodSecurity。路径为src/main/java/com/example/story/StoryApplication.javapackage com.example.story; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; SpringBootApplication EnableGlobalMethodSecurity(prePostEnabled true) public class StoryApplication { public static void main(String[] args) { SpringApplication.run(StoryApplication.class, args); } }后台节点管理接口示例路径为src/main/java/com/example/story/controller/AdminNodeController.javapackage com.example.story.controller; import com.example.story.annotation.AuditLog; import com.example.story.entity.StoryNode; import com.example.story.repository.StoryNodeRepository; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/admin/node) public class AdminNodeController { private final StoryNodeRepository nodeRepository; public AdminNodeController(StoryNodeRepository nodeRepository) { this.nodeRepository nodeRepository; } PostMapping PreAuthorize(hasRole(CAPTAIN)) AuditLog(action CREATE_NODE) public StoryNode createNode(RequestBody StoryNode node) { node.setId(null); return nodeRepository.save(node); } PutMapping(/{id}) PreAuthorize(hasRole(CAPTAIN)) AuditLog(action UPDATE_NODE) public StoryNode updateNode(PathVariable Long id, RequestBody StoryNode node) { StoryNode target nodeRepository.findById(id) .orElseThrow(() - new RuntimeException(节点不存在)); target.setTitle(node.getTitle()); target.setContent(node.getContent()); target.setLockKey(node.getLockKey()); target.setIsEnd(node.getIsEnd()); return nodeRepository.save(target); } DeleteMapping(/{id}) PreAuthorize(hasRole(ADMIN)) AuditLog(action DELETE_NODE) public void deleteNode(PathVariable Long id) { nodeRepository.deleteById(id); } }这里的权限设计体现了最小权限原则普通运营人员舰长角色可以创建和修改节点。删除节点属于高风险操作只有 ADMIN 角色可以执行。每个操作都通过AuditLog记录方便后续追溯。4.6 阅读器前端示例后端接口准备好以后写一个简单的 Vue 3 阅读器组件。这里只演示核心逻辑。路径为src/main/frontend/src/components/StoryReader.vuetemplate div classstory-reader h2{{ currentNode.title }}/h2 div classcontent{{ currentNode.content }}/div div v-if!currentNode.isEnd classchoices button v-forchoice in choices :keychoice.id clickchoose(choice) {{ choice.choiceText }} /button /div div v-else classend-tip—— 结局 ——/div /div /template script setup import { ref, onMounted } from axios const currentNode ref({}) const choices ref([]) async function loadNode(nodeId) { const res await axios.get(/api/story/node/ nodeId) currentNode.value res.data const choiceRes await axios.get(/api/story/choices?nodeId nodeId) choices.value choiceRes.data } async function choose(choice) { const res await axios.post(/api/story/choose, { nodeId: currentNode.value.id, choiceId: choice.id }) await loadNode(res.data.id) } onMounted(() { loadNode(1) }) /script这款阅读器的交互逻辑并不复杂加载节点、展示选项、点击选项后提交后端、后端返回下一个节点、前端重新渲染。需要注意的是实际项目中前端展示的选项可能已经需要过滤隐藏项但真正的安全过滤必须放在后端。前端只是配合后端做展示不能依赖前端来控制权限。5. 常见问题与排查思路这类全栈项目在开发和部署过程中最容易遇到下面几个问题。问题现象常见原因解决思路启动时报时区错误MySQL 连接地址缺少 serverTimezone 参数在 JDBC URL 中指定serverTimezoneAsia/Shanghai接口返回节点时循环引用实体类双向关联JSON 序列化递归使用JsonIgnore或改为单向关联懒加载字段在 Controller 层报错open-in-view被关闭后会话已结束在 Service 层内完成关联数据查询或使用 DTO 转换用户未解锁隐藏剧情但接口仍返回隐藏选项后端没有校验required_key在 Service 层增加能力解锁校验审计日志为空AOP 切面未生效确认启动类已开启 AOP且切点表达式正确中文乱码数据库表字符集不是 utf8mb4建表时指定DEFAULT CHARSETutf8mb4这里单独说一个开发中很隐蔽的问题隐藏剧情的选项过滤。有的同学会在前端判断required_key然后只隐藏按钮但接口仍然会把所有选项返回。这种做法是不安全的因为用户完全可以自己抓包调用接口。正确的做法是后端在返回选项列表时就过滤掉未解锁的选项同时跳转接口也要再次校验。6. 最佳实践与工程建议6.1 剧情数据结构选择本文使用的是邻接表模型每一条记录只保存“下一节点”的关系。这种模型的优点是直观、容易理解适合剧情分支不算特别复杂的项目。如果剧情规模很大出现了大量跨章节跳转、条件分支、循环结构推荐引入 Workflow 引擎的思路把节点、边、条件判断拆成更通用的流程模型。但要注意通用工作流引擎通常比较重前期不要过度设计。6.2 权限模型要落地到数据层后台权限不能只做菜单隐藏必须落实到接口权限。Spring Security 的PreAuthorize可以在方法级别做拦截这是最直接有效的方案。实际项目建议再引入角色层级或数据权限范围。比如某些管理员只能维护指定作品的剧情这就需要“数据权限”而不仅仅是“功能权限”。6.3 审计日志的可靠性审计日志是“高层窥视”的技术映射设计时要记住一个原则稳定性和不可抵赖性。日志写入要异步化避免影响业务接口性能。日志表禁止提供修改和删除接口。关键操作要记录操作前的数据快照而不只是结果。定期对审计日志做归档备份。6.4 能力解锁系统的扩展设计“枷锁”在小说里是一种力量在系统里是能力标识。不要把能力判断写死到业务代码中而是统一走能力中心。比如可以设计一个AbilityService负责判断用户是否拥有某能力、能力从哪个任务获得、能力过期时间等。后面要加新能力时只需要新增item_key和对应的获取方式不用改剧情引擎代码。6.5 生产环境的性能与安全生产环境首先要把 JPA 的ddl-auto改为validate或none表结构变更通过 Flyway 这类迁移工具管理。其次剧情节点是典型的读多写少数据可以为高频节点增加 Redis 缓存。节点内容变更后主动删除对应的缓存 key 即可。最后所有用户输入的内容都要做转义和长度限制防止存储型 XSS 和超长文本攻击。富文本内容尤其要注意。7. 总结与学习路线从需求拆解到数据库建模再到 Spring Boot 后端、Vue 前端、审计日志和管理员权限这一整套代码实现下来你掌握的其实不只是一个“崩坏3同人互动剧情站”而是一个有着清晰分层结构的全栈内容平台。如果继续深入学习建议从这几个方向入手研究 Flowable 或 Camunda 这类工作流引擎理解通用流程建模的思路。学习 Redis 在热点数据缓存中的落地方式。深入了解 Spring Security 的过滤器链和 RBAC 扩展。尝试把 AI 生成剧情接入系统让系统根据读者选择动态生成后续节点。这些方向无论哪一个都能把本文的项目往更深、更实用的方向推进。你可以先跑通本文的示例再动手改一个属于自己的同人互动站真正把技术变成作品。

最新新闻

日新闻

周新闻

月新闻