
1. 需求梳理传统反诈宣传的短板与平台切入点1.1 人到了、章盖了、内容没过脑是最大的问题我接手这个项目之前先到合作的那所高校保卫处蹲了半天。他们给我翻出去年一整年的防诈骗宣传台账横幅、海报、班会PPT、宿舍楼门厅的易拉宝、每学期固定两场专题讲座……东西确实做了不少但问起实际效果几位老师的表情都很微妙。原话是讲座通知发下去能到一半人算不错到了的也是后排刷手机。你现场问一句冒充客服退款和冒充公检法怎么分辨能答利索的不超过四分之一。这个现象在高校里其实非常普遍。电信网络诈骗的剧本迭代速度极快今天这套话术还没在全院通报下个月骗子已经换了新的由头。传统线下宣传天然有两个短板一是触达靠人传人通知发到班群里往下沉两三层之后就衰减得厉害二是效果不可量化你到底覆盖了多少人、每个人学进去多少台账上完全没有数据支撑。说白了宣传做没做有记录但做得好不好、学生们到底有没有真正建立防骗意识没人能回答。所以校方的需求从一开始就很明确不要一个简单的内容展示网站要一个能把学习—练习—测试—预警—追踪完整闭环的在线平台。每个学生注册后能看到最新案例做完答题能立刻看到薄弱环节学院这边能实时看到每个人的学习进度和答题得分。宣传不再是发出去就完事而是变成一件可以管理、可以考核、可以持续运营的事。1.2 平台需要解决的核心场景当时我们坐在一起理需求花了整整一个下午。最终把平台要解决的场景收敛成了五个自主学习场景学生按诈骗类型分类浏览案例库阅读最新反诈文章没有时间限制碎片时间也能学。在线答题场景每个案例和知识点对应一组选择题答完即时出分并显示解析答错的题目自动进入错题本。预警通知场景发生新型诈骗手法或校园周边出现高发警情时平台向全体在校学生推送预警通知并要求确认已读。管理统计场景辅导员和保卫处老师能按学院、班级维度查看学习率、答题通过率、预警签收率不再靠手工汇总。反馈举报场景学生遇到疑似诈骗电话、短信或网络链接时可以在线提交举报或反馈后台收到后进行核实与跟进。这五个场景看起来不复杂但每个场景背后都牵涉到用户体系、内容分类、答题判分、消息触达、数据统计等一系列基础能力。如果一开始没有把场景理清楚后面实现的时候非常容易做成一个大杂烩。1.3 用户角色与业务流程设计系统角色最终定为三种权限边界用一张角色表加一套权限拦截器来控制角色主要功能典型使用者学生注册登录、浏览案例、在线答题、查看积分、签收预警、提交反馈全体在校生宣传管理员案例库维护、题目维护、文章发布、预警推送、查看本学院统计数据辅导员、保卫处干事系统管理员用户管理、角色分配、全站数据统计、敏感内容审核信息中心或项目维护人员业务流程上学生端的核心链路是注册登录 → 进入案例库选择分类学习 → 参加答题 / 每日一练 → 查看得分与解析 → 积累学习积分管理端的核心链路是发布诈骗案例 → 关联题目 → 定时推送预警 → 查看学生学习和答题统计数据。两条链路看似平行但最终都汇合到统计报表这一层这也是为什么我在数据库设计阶段特别重视答题记录和访问日志这两张明细表。2. Spring Boot技术选型与项目整体架构设计2.1 技术栈选型的取舍过程这套系统的定位是校园内部使用并发量不大但业务逻辑涉及用户、内容、权限、消息、统计多个领域而且后续可能会被学生用来做课程设计或毕业设计二次开发。基于这个前提技术选型其实没什么悬念后端框架Spring Boot 2.7.x。生态成熟资料多内嵌Tomcat打包即运行学生接手也容易上手。持久层框架MyBatis-Plus 3.5.x。单表CRUD基本不需要写XML条件构造器做查询很方便内置分页插件可以省掉大量重复代码。数据库MySQL 8.0。稳定、普及学校服务器上基本都有现成环境。缓存Redis。主要用于登录token的存储和每日一练的题目缓存如果部署环境没有Redis也可以降级到本地Map但线上建议还是用Redis。前端方案服务端渲染模板 Bootstrap。考虑到团队里没有专业前端没有引入Vue这类重前端框架用的是Thymeleaf模板加一套开源的AdminLTE后台模板简单直接页面效果也还过得去。认证方案JWT 拦截器。无状态方便后续做小程序或移动端接口复用。其实也考虑过Spring Security加OAuth2那套但对于这样一个内部平台来说太重了。JWT配合拦截器完全够用代码量少逻辑也更好理解。2.2 项目分层与包结构项目采用经典的四层结构包名用的是通用业务命名com.uni.antifraud ├── controller // 接口层 │ ├── admin // 管理端接口 │ └── student // 学生端接口 ├── service // 业务逻辑层 │ └── impl ├── mapper // 数据访问层 ├── entity // 实体类 ├── common // 通用类 │ ├── result // 统一返回结构 │ ├── exception // 全局异常 │ ├── interceptor // JWT拦截器 │ └── util // 工具类JWT工具、日期工具等 ├── config // 配置类 └── timedtask // 定时任务控制层只做参数接收和结果封装业务逻辑全部收敛到Service层。一开始如果没有这个约束很容易出现Controller里几百行SQL的情况后面维护起来会痛不欲生。2.3 统一返回结构与全局异常处理所有接口的返回值统一封装为一个Result对象包含状态码、消息和数据三个字段public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }全局异常处理用RestControllerAdvice统一拦截业务异常、参数校验异常和兜底异常避免每个接口里都写一堆try-catch。这个习惯非常重要因为答题提交、积分计算这类接口一旦出错前端拿到的必须是清晰可读的错误提示而不是一段堆栈信息。3. 数据库设计从人找内容到内容找人3.1 核心表的划分思路数据库设计是整个项目的地基。我画ER图的时候没有拘泥于特别复杂的范式而是围绕业务场景设计最终落成了八张主要表。下面这张表是核心表设计时的角色划分表名用途关键字段sys_user用户表id, username, password, real_name, college, major, grade, credit, role, statusfraud_case诈骗案例表id, case_type, title, content, fraud_method, loss_amount, prevention_tips, publish_time, statusquestion题目表id, case_id, question_type, content, options, answer, analysis, difficultyanswer_record答题记录表id, user_id, question_id, is_correct, user_answer, create_timearticle宣传文章表id, title, cover_url, content, tags, publisher_id, publish_time, read_countwarning_notice预警通知表id, title, content, notice_type, push_time, deadlinenotice_read通知签收表id, notice_id, user_id, read_time, read_statusfeedback反馈举报表id, user_id, content, contact_info, handle_status, handle_time用户名、手机号、邮箱这些字段都加了唯一索引反诈骗平台本身涉及安全问题用户数据这块不能含糊。所有密码字段一律存BCrypt加密后的密文明文密码是绝对红线。3.2 案例库和题库的关系设计案例和题目的关系是一对多一条典型案例可以挂多道测试题题型包括单选、多选和判断。题目表里专门设计了case_id这个外键字段目的很明确学生看完案例之后可以直接跳转到关联题目进行测试形成阅读—理解—检验的学习闭环。case_type字段用的是字典值例如刷单返利冒充客服冒充公检法网络交友诱导注销校园贷款裸聊敲诈等每个分类都有对应的图标和配色前端展示的时候更直观。这个设计参考了校园里高频发案类型的数据分布分类太粗会让学生没有代入感太细则维护成本高。我们最终定了十个常用分类后台可以随时增删。3.3 答题记录与积分流水分开存储答题记录表保存每次答题的明细积分流水单独建表这两个设计要分开讲一下。答题明细记录的是谁在哪道题上答对答错用于生成错题本和个人强弱项分析而积分流水记录的是学习行为产生了多少分用于年级排名和激励体系。如果把两个逻辑塞到同一张表里统计时各种别扭。积分规则我设计成比较简单的三档浏览一篇案例加2分每答对一题加3分每天完成全部每日一练额外加5分积分明细在credit_log表里记录。学生端首页会显示个人积分和学院排名前面百分之二十的学生有反诈先锋标签。事实证明这个设计起到了非常大的激励作用。3.4 预警通知为什么需要单独一张签收表预警通知表存的是通知内容本身而谁看过、什么时候看的放在notice_read表里两张表一对多关联。为什么不直接在通知表里加一个已读字段因为一条通知要发给全校几千名学生如果你在学生那边维护一个我的通知已读标志要么给每个学生复制一条通知记录要么就只能在关联表里记录已读状态。复制记录那方案在几千人规模下勉强能用但到了一万人就会产生大量垃圾数据而且撤回通知时也麻烦。所以正解一定是主表和签收表分开统计未签收人数时对notice_read表做count聚合就行了。这里我特别注意了聚合查询的效率在notice_read表的notice_id和user_id上建了联合索引按学院、班级过滤时再关联sys_user表实测几万条数据下查询响应时间在毫秒级完全够用。4. 几个核心功能的实现逻辑与代码细节4.1 JWT认证与登录拦截器登录流程并不复杂但有几个细节值得留意。用户提交用户名和密码后先通过BCrypt校验密码校验通过后用JWT工具类生成一个token返回给前端。前端在后续请求的请求头里带上这个token后端通过拦截器统一验签。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、注册等公开接口 if (request.getRequestURI().contains(/api/auth/)) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); try { String userId JwtUtil.parseToken(token); request.setAttribute(userId, userId); return true; } catch (JwtException e) { response.setStatus(401); response.getWriter().write(登录状态已过期); return false; } } response.setStatus(401); response.getWriter().write(请先登录); return false; } }这里踩过一个不大不小的坑拦截器放行登录接口时我用的是contains(/api/auth/)结果把/api/auth/queryUserInfo这类不该放行的接口也放行了。后来改成用路径前缀精确匹配并且维护一张白名单列表才彻底解决。安全相关的代码宁可多写几行不要图省事。4.2 案例库的分类检索与分页查询案例库是学生使用频率最高的模块列表页需要支持按诈骗类型筛选、按关键字搜索、按发布时间排序同时还要分页。用MyBatis-Plus的条件构造器实现非常直接public PageFraudCase queryCaseList(int pageNum, int pageSize, String caseType, String keyword) { PageFraudCase page new Page(pageNum, pageSize); LambdaQueryWrapperFraudCase wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(caseType) !全部.equals(caseType)) { wrapper.eq(FraudCase::getCaseType, caseType); } if (StringUtils.hasText(keyword)) { wrapper.and(w - w.like(FraudCase::getTitle, keyword) .or().like(FraudCase::getContent, keyword)); } wrapper.orderByDesc(FraudCase::getPublishTime); return fraudCaseMapper.selectPage(page, wrapper); }页面上每张案例卡片显示标题、分类标签、发布时间和简要摘要点击进入详情页以后才展开完整内容。列表接口做了简单的浏览量统计detail页面加载时对fraud_case表的read_count字段加一。这个字段在管理端统计本月高热度案例的时候很有用能看到哪些诈骗类型学生关注得最多。4.3 答题判分与积分计算的事务控制答题提交算是整个系统里对数据一致性要求最高的一个接口。学生提交一份答卷后台要做四件事逐题判分、保存答题明细、计算本次得分、更新用户积分和答题统计。这四步任何一个失败都会导致数据错乱所以必须放到同一个事务里。Transactional(rollbackFor Exception.class) public SubmitResult submitAnswer(AnswerSubmitDTO dto) { // 1. 逐题判分 int score 0; int correctCount 0; for (AnswerItem item : dto.getAnswers()) { Question question questionMapper.selectById(item.getQuestionId()); boolean correct question.getAnswer().equals(item.getUserAnswer()); // 2. 保存答题记录 AnswerRecord record new AnswerRecord(); record.setUserId(dto.getUserId()); record.setQuestionId(question.getId()); record.setIsCorrect(correct ? 1 : 0); record.setUserAnswer(item.getUserAnswer()); answerRecordMapper.insert(record); if (correct) { score question.getScore(); correctCount; } } // 3. 更新积分 userService.addCredit(dto.getUserId(), score, 在线答题); // 4. 更新统计 UserStat stat userStatMapper.selectByUserId(dto.getUserId()); stat.setTotalCount(stat.getTotalCount() dto.getAnswers().size()); stat.setCorrectCount(stat.getCorrectCount() correctCount); userStatMapper.updateById(stat); ... }答题接口用Transactional把这个过程包起来非常关键。有一次测试时我在判分之后手动抛了一个异常发现答题明细已经被写进去了而积分没有更新后来加上rollbackFor才正常。这个细节特别值得提醒做类似平台的同学事务要么全成功要么全回滚别把看起来没问题当成真的没问题。4.4 预警推送的定时任务设计预警通知我分了两种推送方式一种是管理员手动创建后立即推送另一种是每天定时扫描诈骗案例表把近24小时内新增的高危案例自动生成一份今日反诈速报推送给所有学生。定时任务用的是Spring自带的Scheduled注解Scheduled(cron 0 30 8 * * ?) public void dailyAntiFraudDigest() { // 查询近24小时新增、且风险等级为高的案例 ListFraudCase hotCases fraudCaseMapper.selectList(...); if (CollectionUtils.isEmpty(hotCases)) { return; } WarningNotice notice new WarningNotice(); notice.setTitle(今日反诈速报 hotCases.size() 个新案例请注意); // 拼装内容并插入通知表 warningNoticeMapper.insert(notice); // 生成待签收记录select all user ids and batch insert ... }这里最容易出问题的点在于批量生成签收记录。如果直接for循环挨个insert一万个学生就是一万次数据库操作非常浪费时间。我当时改成先查出全部userId列表再用MyBatis-Plus的批量插入方法分段处理性能才有明显改善。定时任务在分布式部署时还要考虑加分布式锁不然多个实例同时执行会重复推送这个我在本地测试阶段没遇到后来想加上时项目已经上线暂时通过配置单实例部署规避了。5. 开发过程中踩过的坑与完整的排查链路5.1 MyBatis-Plus分页插件失效问题排查第一周写列表页的时候就遇到一个特别诡异的问题分页查询返回的total值永远为0但records里确实有数据。刚开始我以为是自己参数传错了检查了很久没有发现异常。后来打开SQL日志发现打印出来的SQL语句根本没有limit关键字这时候才意识到是分页插件没有生效。排查链路是这样的先确认MyBatis-Plus版本和Spring Boot版本是否兼容发现没问题再检查是否配置了分页插件拦截器发现我这里遗漏了配置类。MyBatis-Plus新版需要显式添加MybatisPlusInterceptor把PaginationInnerInterceptor注册进去才行Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }配置加上之后分页就正常了。这个坑很多第一次用MyBatis-Plus的人都会踩关键是遇到问题不要瞎猜先把SQL日志打开看一眼实际执行的SQL是什么问题基本就暴露了。5.2 JWT过期时间与拦截器放行策略的冲突平台上线试用第二周有学生反馈明明在登录状态过一会儿就自动退出。排查之后发现两个原因叠加。一是JWT的过期时间我最初只设置了30分钟学生在案例详情页停留时间稍长回来再点下一页就失效了。二是前端在请求401时没有做静默刷新的逻辑而是直接跳回登录页。我把过期时间调整为2小时同时增加了一个过期时间小于30分钟时自动续期的处理逻辑拦截器解析token时发现剩余有效期不足半个钟头就在响应头里返回一个新的token前端发现这个新token就自动替换本地的旧token。这样学生长阅读场景下体验会好很多。不过也要提醒一点延长token有效期等于扩大安全窗口校园内部平台问题不大如果是公网部署的高价值系统建议还是配合Refresh Token机制或者缩短有效期并增加自动续期别把过期时间无脑拉到一天以上。5.3 管理端跨域联调与Cookie携带问题管理端页面最初是用一个独立的前端工程在8080端口开发调试后端跑在9090端口结果所有带登录态的请求全部失败。打开浏览器控制台看请求是发出去了但响应被浏览器拦截原因是跨域。解决方式是在后端加一个跨域配置类Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:8080); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }这里有一个非常容易忽略的细节设置了allowCredentials(true)之后allowedOrigins不能使用*通配符必须显式指定域名。我一开始写的是*结果前端报错Cannot allow credentials to wildcard origin折腾了半天才明白。如果后面对接的小程序不在这个域名列表里记得要把微信小程序的合法域名加进去。5.4 批量插入性能优化的一次实测对比预警通知生成签收记录时最初用逐条插入一万条数据耗时接近30秒数据库CPU直接飙到80%以上。后来我换成了MyBatis-Plus提供的insertBatchSomeColumn方法需要自行注入自定义方法或者直接用JDBC的rewriteBatchedStatementstrue参数配合批量插入实测一万条数据耗时降到2秒左右。这个优化对日常单条插入没有感知但面对全校规模的消息推送时差别是秒级和分钟级的差距非常影响管理端的操作体验。6. 本地运行与云服务器部署的完整流程6.1 环境准备清单部署环境其实不复杂前提是基础组件先准备好组件版本建议说明JDK1.8或11Spring Boot 2.7.x兼容性最好MySQL8.0需要utf8mb4字符集Redis6.x如无强制要求可暂时去掉依赖Nginx1.20负责静态资源与反向代理Maven3.6用于打包服务器配置方面2核4G的云主机对这个规模已经完全够用学生数量在一万人以内都不会有压力。如果要部署到校园内网环境数据库和Redis直接装在同一台服务器上也行不做额外的高可用设计也能稳定跑。6.2 数据库初始化和配置文件项目提供了一份完整的初始化SQL脚本建库建表加基础字典数据一次性完成。关键配置在application-prod.yml里要注意三处数据库连接地址、Redis地址、JWT密钥。JWT密钥一定不要用默认值建议用至少32位的随机字符串否则token很容易被伪造。6.3 打包部署与Nginx配置后端打包很简单在项目根目录执行mvn clean package -DskipTests生成的可执行jar包放到服务器指定目录后用nohup启动nohup java -Xms512m -Xmx1024m -jar antifraud-platform.jar --spring.profiles.activeprod app.log 21 前端静态页面统一拷贝到Nginx的html目录下Nginx配置反向代理把/api路径转发到后端的9090端口server { listen 80; server_name yourdomain.edu.cn; location / { root /data/antifraud-web; index index.html; } location /api/ { proxy_pass http://127.0.0.1:9090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }上线之前不要忘记做几件安全方面的事修改MySQL的root密码、创建独立的业务账号、关闭服务器上不需要的端口、给管理端接口加IP白名单限制。这些措施不用花多少时间但能挡掉相当一部分自动扫描攻击。我见过很多学生项目把数据库密码明文写在配置文件里传到公网仓库这种问题在这个项目里从一开始就规避了。7. 试运行三个月的真实反馈与后续优化方向平台在某高校试运行了三个月整体数据和一线反馈给了我几个挺意外的结论。先说好的方面学生注册率接近九成人均在线学习时长稳步上升各学院预警通知签收率都达到95%以上答题通过率从第一周的62%提升到第八周的88%。保卫处的老师说至少今年新生入学宣传时手里终于有了一份实实在在的数据而不是一堆已组织已开展的空洞汇报。但暴露的问题也同样明显。第一学生对于积分激励的热情衰减很快前两周盯着排名冲积分到第四周学完固定内容之后活跃度就明显下降纯靠积分体系无法维持长期留存。第二管理端后台虽然功能齐全但辅导员日常工作繁忙很少有人主动登录去发内容内容更新速度跟不上诈骗手法的迭代节奏。第三最受欢迎的其实是每日一练这个轻量功能很多学生把它当成一种反诈知识闯关游戏来玩这让我意识到平台的核心价值并不在于塞入更多长篇大论而在于用轻量、高频的方式不断强化学生的防骗直觉。所以在后续迭代里我打算做三件事把每日一练升级成对战模式学生之间可以随机匹配PK答题用排名和连胜场次制造竞争感增加一个模拟电话场景的短视频模块用剧本式的内容还原真实的诈骗通话流程传播效果比纯文字案例好很多管理端这边做一个小程序配套让辅导员在手机上就能完成案例发布和预警推送降低使用门槛。还有一个方向是打通学校的统一身份认证学生用学号直接登录省掉注册这一步新用户转化率还能再上一个台阶。技术方面下一步我会把答题模块的缓存策略优化一下热门题目用Redis缓存减少数据库无效查询统计报表的生成改成定时任务预计算而不是每次打开页面都实时聚合全量明细数据引入简单的操作日志记录谁在什么时间改过哪条案例、哪道题目后台留痕方便出问题时回溯。这些都是试运行阶段真实遇到的需求也是任何一个从能用走向好用的项目都绕不开的工作。这个项目让我最大的体会是把反诈宣传做成一个低门槛、高频次、有反馈的在线服务远比堆砌几篇长篇大论更有现实意义。如果你也在做类似的校园信息化项目建议先把数据闭环跑通再逐步加激励和运营功能别一上来就想着把所有模块一次做完。