1. 项目概述与设计思路1.1 这个系统到底解决了什么问题做过图书编目、文学资料整理的朋友应该都有同感:作家的档案信息往往散落在不同渠道,有的是出版社内部的 Excel 表格,有的是资料室的手写卡片,有的只在某个官方报道里提了一嘴。真到了要写年度报告、做专题研究、向读者推荐书单的时候,光找数据就能耗掉小半天,而且极易遗漏重要获奖信息。我做的这套《当代获奖作家信息管理系统》,核心目标就是把这类资料管理场景搬到一个统一平台上。系统围绕“作家档案”这一个主数据展开,延伸出获奖记录、代表作品、基础检索、数据统计几个模块,最终形成一套前后端分离、可登录使用的完整管理工具。从功能上看,它覆盖了这几条核心业务线:作家信息管理:录入作家姓名、笔名、性别、出生年月、籍贯、创作流派、个人简介等内容,支持新增、编辑、删除、批量操作。获奖记录管理:一个作家可以关联多个文学奖项,记录获奖年份、获奖作品、是否为代表奖项,形成一条清晰的获奖时间线。作品信息管理:维护代表作的基本元数据,包括作品名、体裁、出版年份、出版社、内容摘要,并支持标记重点代表作。检索与统计:按流派、籍贯、获奖级别等条件组合筛选;用图表展示获奖分布、作品年代分布,辅助数据分析和选题策划。系统与安全:提供登录认证、用户角色区分,普通用户与管理员权限不同,接口层有统一的鉴权处理。这套系统不是单纯堆 CRUD,它的数据关系在同类“信息管理系统”里算是教育意义很强的:作家和获奖之间是多对多关系,作品和作家是多对一关系,还涉及级联保存和关联查询。把这些关系理清楚,整个项目的骨架就立住了。1.2 技术选型的前因后果技术栈选定为 SpringBoot + Vue3 + MyBatis + MySQL,是一个很务实的选择,各层都有充分的理由。后端用 SpringBoot 的出发点是生态成熟、配置简洁。对于一个业务复杂度不算太高、但需要快速落地的管理系统来说,内嵌 Tomcat、Starter 依赖、自动装配这几点能让开发效率明显提升。SpringBoot 2.7 在 JDK 8 上跑得非常稳,适合绝大多数团队现有环境,不需要为了尝鲜引入过高的升级成本。持久层选 MyBatis 而不选 JPA,在我看来是这类后台管理项目的正确判断。管理系统的 SQL 场景里,“多表关联 + 动态筛选 + 统计报表”占了很大比例,MyBatis 的 XML 映射文件能把每一段 SQL 都写得明明白白,条件筛选用动态 SQL 攒出来,统计 SQL 可以直接手写并优化执行计划。相比之下,JPA 在复杂查询上的自由度不如 MyBatis,一旦遇到需要微调的 SQL,反而要绕开框架自己做映射,有点拧巴。前端选 Vue3 + Element Plus,理由也很直白。Vue3 的组合式 API 让逻辑复用变得更干净,表格页、表单页、统计页这些典型管理页面写起来非常顺手。Element Plus 的表格、表单、分页、弹窗等组件开箱即用,UI 风格统一,能省下大量基础组件开发时间。Vite 做构建工具,本地启动速度快,热更新体验比老一代 Webpack 好一个数量级。数据库用 MySQL 8.x,一方面是它原生支持窗口函数,统计类 SQL 写起来灵活;另一方面,字符集、排序规则、JSON 字段支持都比较成熟,对中文内容的管理也友好。我更倾向在数据库里用 utf8mb4,真正能兼容完整 Unicode 字符集,防止某些生僻字出现乱码。前后端分离这套架构,在这类系统里的价值是“边界的清晰”。前端只关心数据展示和交互,后端只暴露 RESTful API,两者通过约定的 JSON 数据结构通信。这样前后端可以由不同成员并行开发,后续要做移动端接口复用也方便。2. 数据库设计:一张表一张表敲定2.1 五张核心表的结构与字段规范数据库表设计是这类系统的地基。设计原则很简单:不追求过度范式化,但也不能把信息全部压在一张表里。我最终确定的表结构一共五张,每一张承担一个清晰的职责域。t_user 用户表字段名类型说明idbigint主键,自增usernamevarchar(50)登录账号,唯一passwordvarchar(100)BCrypt 加密后的密文real_namevarchar(50)真实姓名/后台用户名rolevarchar(20)角色标识,admin/usercreate_timedatetime创建时间这里有一个很容易被新手忽略的点:password 字段长度不要只留 32 位。BCrypt 每次加密生成的字符串大约在 60 个字符,如果建表时按传统 MD5 的 32 位长度设计,加密数据根本存不进去。头部用户表权限等级字段我保留为 varchar,方便后续扩展“运营管理员”“只读用户”等角色。t_author 作家表字段名类型说明idbigint主键namevarchar(50)姓名pen_namevarchar(50)笔名gendertinyint性别,0未知 1男 2女birth_datedate出生日期birth_placevarchar(100)籍贯genrevarchar(50)创作流派/方向profiletext个人简介avatar_urlvarchar(255)照片/头像地址statustinyint数据状态,1启用 0停用create_timedatetime创建时间update_timedatetime更新时间这个表中 profile 字段用 text 而不 varchar,是因为作家简介往往几百上千字,varchar 长度再长也有硬上限,text 类型更合理。update_time 建议建表时设置ON UPDATE CURRENT_TIMESTAMP,这样每次数据变动就不用手动维护更新时间。t_work 作品表字段名类型说明idbigint主键author_idbigint所属作家 IDtitlevarchar(100)作品名categoryvarchar(30)体裁,如小说/散文/诗歌publish_yearint出版年份publishervarchar(100)出版社summarytext作品简介is_representativetinyint是否代表作,1是create_timedatetime创建时间作品和作家的对应关系是多对一,也就是一位作家名下有多部作品,所以用 author_id 做外键。这个字段上必须建索引,否则后面按作家查作品列表会全表扫描。t_award 奖项表字段名类型说明idbigint主键award_namevarchar(100)奖项名称organizervarchar(100)主办方leveltinyint奖项级别,如国家级/行业级/地方级descriptiontext奖项简介奖项单独拆一张表,而不是直接写死在关联表里,是为了避免大量的重复文案。比如同一位作家可能在不同年份获得同一奖项,如果每次记录都把奖项名称写进关联表,数据和表结构就是冗余的,后续改一个奖项名称还要改多条记录。拆开后关联表只保留 ID,数据一致性由外键关系保证。t_author_award 作家获奖关联表字段名类型说明idbigint主键author_idbigint作家 IDaward_idbigint奖项 IDaward_yearint获奖年份winning_workvarchar(100)获奖作品is_representativetinyint是否核心奖项作家和奖项的多对多关系必须用中间表承接,这是关系型数据库设计的标准做法。award_year 和 winning_work 这两个字段放在关联表里而不是奖项表里,是因为它们本质上描述的是“某作家在某一年凭借某作品获得了某奖”,属于关联记录自己的业务属性。很多初学者容易把获奖年份放进奖项表,这么设计会导致同一个人获得同一个奖项时产生多条奖项记录,逻辑上完全说不通。这五张表的关系可以用一句话概括:作家一对多作品,作家多对多奖项,查询统计时通过外键和中间表进行关联聚合。项目里我还建议给每张表都补一个主键id bigint auto_increment,统一使用 unsigned 也可以,保持习惯一致即可。2.2 索引设计与查询优化思路索引设计看似不起眼,但在数据量上来之后会直接决定查询体验。核心索引规划如下:t_user.username 建唯一索引,登录时走点查,避免全表扫。t_author.name 和 t_author.pen_name 各建普通索引,支撑按名字模糊搜索。t_work.author_id 建普通索引,这是高频外键查询路径。t_author_award 建联合索引(author_id, award_id),支撑反查某作家的全部奖项。t_author_award.award_year 建普通索引或加入联合索引,用于年份维度的统计排序。在分页查询场景里,我习惯用LIMIT #{}参数化分页而不是手动拼接LIMIT 0,1000这种写法。配合 MyBatis 的 PageHelper 插件,只需要在查询前调用一行PageHelper.startPage(pageNum, pageSize),后续的 select 语句就会自动带上合适的 SQL 分页。这里必须提一个暗坑:PageHelper 的startPage只对紧随其后的第一条查询生效,如果你在前面多写了一个其他的查询语句,分页参数就错位了。另外,如果以后数据量到几十万条量级,like 查询要尽量避免前缀通配的写法,比如%keyword%会导致索引失效,改成keyword%的范围查询才能有效利用索引。3. 后端实现:SpringBoot + MyBatis 的分层实战3.1 项目结构与统一返回规范后端项目按标准的三层结构划分,我采用如下包结构:com.example.author ├── common # 通用返回体、常量、异常定义 ├── config # 配置类(跨域、拦截器注册、Jackson 配置) ├── controller # 接口层 ├── dto # 入参/出参对象 ├── entity # 数据库实体对象 ├── interceptor # JWT 认证拦截器 ├── mapper # MyBatis Mapper 接口 ├── service # 业务层 ├── service.impl # 业务实现 └── utils # JWT、密码加密等工具统一返回数据结构对前后端联调非常重要。我定义了一个Result类,字段只有三个:code、msg、data。code 为 200 表示成功,401 表示未认证,500 表示服务异常。这样前端 Axios 的拦截器里只需要判断 code 是否为 200,就可以在全局统一处理错误提示。@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMsg("success"); r.setData(data); return r; } public static <T> Result<T> fail(Integer code, String msg) { Result<T> r = new Result<>(); r.setCode(code); r.setMsg(msg); return r; } }注意这里没有把业务异常堆栈直接返回给前端,而是通过全局异常处理器@RestControllerAdvice统一捕获,转换成友好提示。这样既不泄露内部细节,也便于前端统一弹出错误信息。3.2 登录认证:JWT 无状态会话后台管理系统不能完全裸奔,至少要有一个可用的认证机制。这个项目里我选择了 JWT + 拦截器的方式实现登录状态管理。用户登录后,后端校验用户名密码,验证通过后签发一个有效期 2 小时的 token。前端把 token 存到本地存储,之后每个请求在请求拦截器里自动带上Authorization: Bearer <token>。后端写一个AuthInterceptor,在preHandle中解析 token,校验签名和有效期,把用户信息塞入请求上下文。对于/api/auth/login接口和白名单路径直接放行,其余请求统一拦截。密码存储一定要用 BCrypt,不能明文入库。Spring Security 里的BCryptPasswordEncoder可以单独摘出来用,不需要引入一整套 Security 框架。注册用户时加密存储,登录时调用matches方法比对。@Component public class JwtUtil { // 这里只是示例,密钥建议配置在配置中心或环境变量里 private final String secret = "your-secret-key-change-in-production"; private final long expire = 2 * 60 * 60 * 1000L; // 2小时 public String generateToken(Long userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }一个容易被忽略的细节:JWT 密钥不能硬编码在代码里,这个示例代码只是为了演示流程,生产环境应该写到环境变量或配置中心,否则任何人拿到源码都可以伪造 token。3.3 作家信息 CRUD 与动态 SQL 落地后端核心业务是作家信息管理,我选择了在 Mapper XML 里写动态 SQL,而不是用注解方式。原因在于,搜搜条件多,乐观情况下可以有多种组合,动态标签if/where/set能把条件拼接这件事做得非常直观。以作家列表查询为例,需要支持按名字、笔名、流派、籍贯组合筛选:<select id="selectByCondition" resultType="com.example.author.entity.Author"> SELECT id, name, pen_name, gender, birth_date, birth_place, genre, status, create_time, update_time FROM t_author <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR pen_name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="genre != null and genre != ''"> AND genre = #{genre} </if> <if test="birthPlace != null and birthPlace != ''"> AND birth_place = #{birthPlace} </if> </where> ORDER BY create_time DESC </select>这里有一个细节:birth_place字段我只做等值匹配,因为如果做后模糊查询,一个字段匹配出来的结果集可能非常大且语义不明确。用户使用该系统查籍贯,更多情况下是在筛选项里选择具体的省/市,等值匹配更合理。更新语句与新增语句的设计也有讲究。新增时用主键回填拿到自增 id,是因为后面需要马上用它保存获奖关联数据和作品数据:<insert id="insertAuthor" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_author (...) VALUES (...) </insert>更新操作要注意不能把已有的字段覆盖成 null。比如编辑页面如果只提交了部分字段,前端传来的实体可能某些字段为空。这时用<set>标签配合 if 判断,只有非空字段才进入 SET 子句。4. 前端实现:Vue3 + Element Plus 构建三块核心页面4.1 工程初始化与路由设计前端基于 Vite + Vue3 构建。初始化之后,我习惯先做脚手架整理,把 axios 实例封装、路由守卫、菜单容器提前准备到位,避免后面散乱补丁。路由结构分为三层:登录页是独立路由,其余页面挂在Layout布局组件下,子路由包括作家管理、获奖数据、统计分析。const routes = [ { path: '/login', component: Login }, { path: '/', component: Layout, redirect: '/authors', children: [ { path: 'authors', component: AuthorList, meta: { title: '作家管理' } }, { path: 'awards', component: AwardList, meta: { title: '获奖数据' } }, { path: 'stats', component: StatsPage, meta: { title: '统计分析' } } ] } ]导航守卫里判断本地存储是否有 token,没有 token 一律跳登录页。这个判断逻辑虽然简单,但在后台管理项目中属于必备基础。token 过期时后端会返回 401,Axios 响应拦截里要清除本地登录态并跳转登录页。4.2 列表页的组装逻辑作家列表页是这个系统最核心的页面,承担了高频的检索、分页、查看详情、编辑入口等任务。页面拆成三个区域:上方筛选区:姓名/笔名关键字、流派选择器、状态开关。中间表格区:使用 el-table 展示数据。列包括姓名、笔名、性别、籍贯、流派、状态、操作。状态列用 el-tag 展示“启用/停用”标签,操作列放“编辑”“详情”“删除”三个按钮。下方分页区:使用 el-pagination,配置 current-page、page-size、total 绑定到响应式变量。Axios 请求参数设计列表请求接口设计为 GET/api/authors/page,参数是 pageNum、pageSize、keyword、genre、status。表格数据刷新时,把筛选表单的数据和分页数据合并为一个请求参数对象。const queryParams = reactive({ pageNum: 1, pageSize: 10, keyword: '', genre: '', status: '' }) async function loadAuthorList() { const res = await request.get('/api/authors/page', { params: queryParams }) const { list, total } = res.data tableData.value = list totalCount.value = total }有很多新手会在请求前手动拼接 URL 字符串,用 axios 的 params 对象其实更规范,它会自动处理编码,还能避免在 query 里出现空白字符串等脏数据。Axios 拦截器里去掉 + code 校验之外的自动序列化;在请求对象上设置只传列表要展示的字段,减少网络传输。4.3 表单页与级联保存作家编辑页面是这个小系统的“重头戏”,因为它要同时处理三部分数据:作家基本信息、获奖记录列表、代表作列表。界面我设计成上下分区的表单:基本信息区:姓名、笔名、性别、出生日期、籍贯、流派、简介、状态。获奖记录区:一个动态表格,每行包含奖项选择器、获奖年份、获奖作品输入框、是否核心奖项开关。可以添加行、删除行。作品信息区:一个子表格,填写作品名、体裁、出版年份、出版社、摘要、是否代表作,同样支持动态增删。这个表单提交时,需要一次性把作家主数据 + 获奖记录列表 + 作品列表全部提交给后端。后端用一个saveAuthorFull接口接收,内部开启事务:先保存作家主表,然后循环插入获奖关联记录和作品记录。前端只发一次请求,后端保证数据一致性,这比前端逐个提交更可靠。function handleSubmit() { const formData = { ...authorForm, awards: awardList.value, works: workList.value } request.post('/api/authors/saveFull', formData).then(() => { ElMessage.success('保存成功') }) }关联数据的增删改,其实还有个更省心的策略:前端在提交时把删除的关联记录 ID 也传过去,后端统一做差量更新。不过为了简化,这个项目里我采用了“整单覆盖”的思路——编辑提交时,后端先删除该作家原有的获奖关联和作品记录,再重新插入前端传来的新数据。这种方案胜在简单直接,出错率低,唯一的前提是要做好事务控制,避免删了没插引发脏数据。4.4 统计页的数据可视化统计分析页面我用 ECharts 做了两张图。第一张是“奖项分布柱状图”,横轴是不同奖项,纵轴是获奖作家数量,通过后端一个统计查询直接返回分组计数。后端 SQL 只需要一句:SELECT a.award_name, COUNT(ab.author_id) AS author_count FROM t_author_award ab JOIN t_award a ON ab.award_id = a.id GROUP BY a.award_name ORDER BY author_count DESC第二张是“作品出版年代折线图”,按作品出版年份分组统计数量,用来观察某位作家或整个作家群体的创作活跃期变化。统计图表的精髓是最小化前端数据加工,能后端聚合完成的就别传到前端再做 map reduce。ECharts 的实例在 Vue3 里需要注意销毁逻辑。组件 onUnmounted 时机调用chart.dispose(),否则页面切换后图表实例没有释放,会造成内存占用影响性能。如果多个页面共用一个图表容器,还要在 resize 时调用chart.resize()。5. 前后端联调与部署实战5.1 开发期的跨域处理前后端分离项目最常遇到的就是跨域问题。开发环境我推荐两种方式搭配使用。前端 Vite 里配置 proxy,把/api开头的请求转发到后端 8080:server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样浏览器看到的请求始终保持同源,不会触发 CORS 预检。后端同时保留一个 CORS 配置类,相当于双保险——如果直接通过 IP 访问,或者在部署阶段前端静态资源与后端服务不在同一域名下,后端也能正确响应跨域请求。这里有个操作提示:跨域配置是“前后端约定”的一部分,建议把允许的路径、方法、请求头统一确定下来,不要一边配一边扩展,联调时出现跨域问题最难排查的原因就是两边配置不一致。5.2 打包部署:Jar + Nginx后端打包比较简单,Maven 执行package生成 jar,在生产服务器或者容器环境直接java -jar运行。如果需要在不同环境切换配置,SpringBoot 的 profile 机制可以分开管理 application-dev.yml 和 application-prod.yml,部署时用--spring.profiles.active=prod指定。前端构建后生成dist静态目录,部署在 Nginx 下。最关键的配置是 location 规则:server { listen 80; server_name your-domain; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }try_files $uri $uri/ /index.html这一行解决 Vue Router 的 history 模式刷新 404 问题。很多新手部署完页面,首次访问正常,一刷新就 404,就是因为少了这个回退到 index.html 的配置。5.3 日期格式化与分页数据的暗坑时间字段是前后端联调中最容易出怪问题的部分。后端LocalDateTime默认序列化格式是 ISO 格式,前端展示不友好。我在项目里统一配置了 Jackson 格式:@Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); builder.serializers(new LocalDateTimeSerializer( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateDeserializer( DateTimeFormatter.ofPattern("yyyy-MM-dd"))); }; }分页数据还有一个经典坑:直接用 PageHelper 查出的Page对象序列化会带出很多无用属性,前端拿到不干净的数据结构。更好的做法是转成PageInfo,或者手动组装一个 PageResult:public class PageResult<T> { private Long total; // 总条数 private List<T> list; // 当前页数据 }这也是我在统一返回体里单独封装分页结果的原因,联调时前端拿到的结构永远只有 list 和 total,简单明确。6. 常见问题排查与优化经验6.1 联调期踩过的典型问题速查问题现象可能原因解决办法前端请求一直 403token 未带上或已过期检查 axios 请求拦截器是否正确设置 Authorization中文数据存入数据库变乱码JDBC URL 未指定编码在 url 中加characterEncoding=utf8mb4&useUnicode=true分页数据总条数异常PageHelper.startPage 前执行了其他查询确保 startPage 紧贴目标 select 语句日期显示是一串数字Jackson 未配置 LocalDateTime 格式在全局配置中统一序列化格式列表页刷新 404Nginx 未配置 try_files服务端回退到 index.html删除作家时提示外键约束关联表有残留数据删除作家时同步删除获奖关联和作品记录每个问题都不是什么高深技术难点,但它们非常消磨时间。我的经验是:遇到问题第一反应不要急着改代码,先抓请求的完整链路。打开浏览器开发者工具,先看请求到底发出去没有,再看后端日志报什么错,最后才定位代码逻辑。6.2 性能与体验优化心得后端查询方面,列表接口返回的字段不包含profile这种大文本,查看详情时才单独查询完整字段。这种做法避免了列表页每次传输大量简介文本,显著降低了接口响应体积。从体验上看,列表加载速度会快不少。前端请求方面,所有搜索条件和分页变化共享同一个加载函数,并且在切换筛选条件时把 pageNum 重置为 1,避免用户在第 10 页点搜索后看到的却是空列表。接口设计方面,新增、编辑共用同一套保存逻辑,前端通过 id 是否存在判断调用 POST 还是 PUT。这一类经验在更复杂的系统中依然适用:接口数量不贪多,职责保持单一,前后端约定清晰,联调效率才有保障。还有一个实用的扩展方向:数据导出。很多管理用户最后都会提一句话,“能不能把这份名单导成 Excel”。你可以在后端集成 EasyExcel,把作家列表或获奖记录输出为 xlsx 文件,前端下载接口返回的二进制流保存到本地。这个功能工作量不大但是相当加分,建议作为二期优先开发项。一套可复现的完整配置参考写到这里,我把自己创建项目时的核心配置贴一份在下面,方便照着复现。pom.xml 核心依赖<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>application.yml 关键配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/author_db?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your-password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true pagehelper: helper-dialect: mysql reasonable: truemap-underscore-to-camel-case这个配置非常关键,没有它,数据库的下划线命名不会自动映射到实体的驼峰属性,MyBatis 查询结果就会拿不到值,这是新手最容易漏的配置之一。个人复盘整个项目,我最深的体会是:这类管理系统真正难的不是某个技术点的深度,而是各个模块之间的一致性。数据库字段规范和实体属性要保持一致,后端接口返回的数据结构和前端 UI 需求要保持一致,JWT 的过期时间和前端跳转时机要保持一致。只要把约定和管理做好,这个项目从开发到上线会非常顺,把它拿去做课程设计、面试项目或者内部工具,都是很适合的选择。
1. 项目概述与设计思路1.1 这个系统到底解决了什么问题做过图书编目、文学资料整理的朋友应该都有同感:作家的档案信息往往散落在不同渠道,有的是出版社内部的 Excel 表格,有的是资料室的手写卡片,有的只在某个官方报道里提了一嘴。真到了要写年度报告、做专题研究、向读者推荐书单的时候,光找数据就能耗掉小半天,而且极易遗漏重要获奖信息。我做的这套《当代获奖作家信息管理系统》,核心目标就是把这类资料管理场景搬到一个统一平台上。系统围绕“作家档案”这一个主数据展开,延伸出获奖记录、代表作品、基础检索、数据统计几个模块,最终形成一套前后端分离、可登录使用的完整管理工具。从功能上看,它覆盖了这几条核心业务线:作家信息管理:录入作家姓名、笔名、性别、出生年月、籍贯、创作流派、个人简介等内容,支持新增、编辑、删除、批量操作。获奖记录管理:一个作家可以关联多个文学奖项,记录获奖年份、获奖作品、是否为代表奖项,形成一条清晰的获奖时间线。作品信息管理:维护代表作的基本元数据,包括作品名、体裁、出版年份、出版社、内容摘要,并支持标记重点代表作。检索与统计:按流派、籍贯、获奖级别等条件组合筛选;用图表展示获奖分布、作品年代分布,辅助数据分析和选题策划。系统与安全:提供登录认证、用户角色区分,普通用户与管理员权限不同,接口层有统一的鉴权处理。这套系统不是单纯堆 CRUD,它的数据关系在同类“信息管理系统”里算是教育意义很强的:作家和获奖之间是多对多关系,作品和作家是多对一关系,还涉及级联保存和关联查询。把这些关系理清楚,整个项目的骨架就立住了。1.2 技术选型的前因后果技术栈选定为 SpringBoot + Vue3 + MyBatis + MySQL,是一个很务实的选择,各层都有充分的理由。后端用 SpringBoot 的出发点是生态成熟、配置简洁。对于一个业务复杂度不算太高、但需要快速落地的管理系统来说,内嵌 Tomcat、Starter 依赖、自动装配这几点能让开发效率明显提升。SpringBoot 2.7 在 JDK 8 上跑得非常稳,适合绝大多数团队现有环境,不需要为了尝鲜引入过高的升级成本。持久层选 MyBatis 而不选 JPA,在我看来是这类后台管理项目的正确判断。管理系统的 SQL 场景里,“多表关联 + 动态筛选 + 统计报表”占了很大比例,MyBatis 的 XML 映射文件能把每一段 SQL 都写得明明白白,条件筛选用动态 SQL 攒出来,统计 SQL 可以直接手写并优化执行计划。相比之下,JPA 在复杂查询上的自由度不如 MyBatis,一旦遇到需要微调的 SQL,反而要绕开框架自己做映射,有点拧巴。前端选 Vue3 + Element Plus,理由也很直白。Vue3 的组合式 API 让逻辑复用变得更干净,表格页、表单页、统计页这些典型管理页面写起来非常顺手。Element Plus 的表格、表单、分页、弹窗等组件开箱即用,UI 风格统一,能省下大量基础组件开发时间。Vite 做构建工具,本地启动速度快,热更新体验比老一代 Webpack 好一个数量级。数据库用 MySQL 8.x,一方面是它原生支持窗口函数,统计类 SQL 写起来灵活;另一方面,字符集、排序规则、JSON 字段支持都比较成熟,对中文内容的管理也友好。我更倾向在数据库里用 utf8mb4,真正能兼容完整 Unicode 字符集,防止某些生僻字出现乱码。前后端分离这套架构,在这类系统里的价值是“边界的清晰”。前端只关心数据展示和交互,后端只暴露 RESTful API,两者通过约定的 JSON 数据结构通信。这样前后端可以由不同成员并行开发,后续要做移动端接口复用也方便。2. 数据库设计:一张表一张表敲定2.1 五张核心表的结构与字段规范数据库表设计是这类系统的地基。设计原则很简单:不追求过度范式化,但也不能把信息全部压在一张表里。我最终确定的表结构一共五张,每一张承担一个清晰的职责域。t_user 用户表字段名类型说明idbigint主键,自增usernamevarchar(50)登录账号,唯一passwordvarchar(100)BCrypt 加密后的密文real_namevarchar(50)真实姓名/后台用户名rolevarchar(20)角色标识,admin/usercreate_timedatetime创建时间这里有一个很容易被新手忽略的点:password 字段长度不要只留 32 位。BCrypt 每次加密生成的字符串大约在 60 个字符,如果建表时按传统 MD5 的 32 位长度设计,加密数据根本存不进去。头部用户表权限等级字段我保留为 varchar,方便后续扩展“运营管理员”“只读用户”等角色。t_author 作家表字段名类型说明idbigint主键namevarchar(50)姓名pen_namevarchar(50)笔名gendertinyint性别,0未知 1男 2女birth_datedate出生日期birth_placevarchar(100)籍贯genrevarchar(50)创作流派/方向profiletext个人简介avatar_urlvarchar(255)照片/头像地址statustinyint数据状态,1启用 0停用create_timedatetime创建时间update_timedatetime更新时间这个表中 profile 字段用 text 而不 varchar,是因为作家简介往往几百上千字,varchar 长度再长也有硬上限,text 类型更合理。update_time 建议建表时设置ON UPDATE CURRENT_TIMESTAMP,这样每次数据变动就不用手动维护更新时间。t_work 作品表字段名类型说明idbigint主键author_idbigint所属作家 IDtitlevarchar(100)作品名categoryvarchar(30)体裁,如小说/散文/诗歌publish_yearint出版年份publishervarchar(100)出版社summarytext作品简介is_representativetinyint是否代表作,1是create_timedatetime创建时间作品和作家的对应关系是多对一,也就是一位作家名下有多部作品,所以用 author_id 做外键。这个字段上必须建索引,否则后面按作家查作品列表会全表扫描。t_award 奖项表字段名类型说明idbigint主键award_namevarchar(100)奖项名称organizervarchar(100)主办方leveltinyint奖项级别,如国家级/行业级/地方级descriptiontext奖项简介奖项单独拆一张表,而不是直接写死在关联表里,是为了避免大量的重复文案。比如同一位作家可能在不同年份获得同一奖项,如果每次记录都把奖项名称写进关联表,数据和表结构就是冗余的,后续改一个奖项名称还要改多条记录。拆开后关联表只保留 ID,数据一致性由外键关系保证。t_author_award 作家获奖关联表字段名类型说明idbigint主键author_idbigint作家 IDaward_idbigint奖项 IDaward_yearint获奖年份winning_workvarchar(100)获奖作品is_representativetinyint是否核心奖项作家和奖项的多对多关系必须用中间表承接,这是关系型数据库设计的标准做法。award_year 和 winning_work 这两个字段放在关联表里而不是奖项表里,是因为它们本质上描述的是“某作家在某一年凭借某作品获得了某奖”,属于关联记录自己的业务属性。很多初学者容易把获奖年份放进奖项表,这么设计会导致同一个人获得同一个奖项时产生多条奖项记录,逻辑上完全说不通。这五张表的关系可以用一句话概括:作家一对多作品,作家多对多奖项,查询统计时通过外键和中间表进行关联聚合。项目里我还建议给每张表都补一个主键id bigint auto_increment,统一使用 unsigned 也可以,保持习惯一致即可。2.2 索引设计与查询优化思路索引设计看似不起眼,但在数据量上来之后会直接决定查询体验。核心索引规划如下:t_user.username 建唯一索引,登录时走点查,避免全表扫。t_author.name 和 t_author.pen_name 各建普通索引,支撑按名字模糊搜索。t_work.author_id 建普通索引,这是高频外键查询路径。t_author_award 建联合索引(author_id, award_id),支撑反查某作家的全部奖项。t_author_award.award_year 建普通索引或加入联合索引,用于年份维度的统计排序。在分页查询场景里,我习惯用LIMIT #{}参数化分页而不是手动拼接LIMIT 0,1000这种写法。配合 MyBatis 的 PageHelper 插件,只需要在查询前调用一行PageHelper.startPage(pageNum, pageSize),后续的 select 语句就会自动带上合适的 SQL 分页。这里必须提一个暗坑:PageHelper 的startPage只对紧随其后的第一条查询生效,如果你在前面多写了一个其他的查询语句,分页参数就错位了。另外,如果以后数据量到几十万条量级,like 查询要尽量避免前缀通配的写法,比如%keyword%会导致索引失效,改成keyword%的范围查询才能有效利用索引。3. 后端实现:SpringBoot + MyBatis 的分层实战3.1 项目结构与统一返回规范后端项目按标准的三层结构划分,我采用如下包结构:com.example.author ├── common # 通用返回体、常量、异常定义 ├── config # 配置类(跨域、拦截器注册、Jackson 配置) ├── controller # 接口层 ├── dto # 入参/出参对象 ├── entity # 数据库实体对象 ├── interceptor # JWT 认证拦截器 ├── mapper # MyBatis Mapper 接口 ├── service # 业务层 ├── service.impl # 业务实现 └── utils # JWT、密码加密等工具统一返回数据结构对前后端联调非常重要。我定义了一个Result类,字段只有三个:code、msg、data。code 为 200 表示成功,401 表示未认证,500 表示服务异常。这样前端 Axios 的拦截器里只需要判断 code 是否为 200,就可以在全局统一处理错误提示。@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMsg("success"); r.setData(data); return r; } public static <T> Result<T> fail(Integer code, String msg) { Result<T> r = new Result<>(); r.setCode(code); r.setMsg(msg); return r; } }注意这里没有把业务异常堆栈直接返回给前端,而是通过全局异常处理器@RestControllerAdvice统一捕获,转换成友好提示。这样既不泄露内部细节,也便于前端统一弹出错误信息。3.2 登录认证:JWT 无状态会话后台管理系统不能完全裸奔,至少要有一个可用的认证机制。这个项目里我选择了 JWT + 拦截器的方式实现登录状态管理。用户登录后,后端校验用户名密码,验证通过后签发一个有效期 2 小时的 token。前端把 token 存到本地存储,之后每个请求在请求拦截器里自动带上Authorization: Bearer <token>。后端写一个AuthInterceptor,在preHandle中解析 token,校验签名和有效期,把用户信息塞入请求上下文。对于/api/auth/login接口和白名单路径直接放行,其余请求统一拦截。密码存储一定要用 BCrypt,不能明文入库。Spring Security 里的BCryptPasswordEncoder可以单独摘出来用,不需要引入一整套 Security 框架。注册用户时加密存储,登录时调用matches方法比对。@Component public class JwtUtil { // 这里只是示例,密钥建议配置在配置中心或环境变量里 private final String secret = "your-secret-key-change-in-production"; private final long expire = 2 * 60 * 60 * 1000L; // 2小时 public String generateToken(Long userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }一个容易被忽略的细节:JWT 密钥不能硬编码在代码里,这个示例代码只是为了演示流程,生产环境应该写到环境变量或配置中心,否则任何人拿到源码都可以伪造 token。3.3 作家信息 CRUD 与动态 SQL 落地后端核心业务是作家信息管理,我选择了在 Mapper XML 里写动态 SQL,而不是用注解方式。原因在于,搜搜条件多,乐观情况下可以有多种组合,动态标签if/where/set能把条件拼接这件事做得非常直观。以作家列表查询为例,需要支持按名字、笔名、流派、籍贯组合筛选:<select id="selectByCondition" resultType="com.example.author.entity.Author"> SELECT id, name, pen_name, gender, birth_date, birth_place, genre, status, create_time, update_time FROM t_author <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR pen_name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="genre != null and genre != ''"> AND genre = #{genre} </if> <if test="birthPlace != null and birthPlace != ''"> AND birth_place = #{birthPlace} </if> </where> ORDER BY create_time DESC </select>这里有一个细节:birth_place字段我只做等值匹配,因为如果做后模糊查询,一个字段匹配出来的结果集可能非常大且语义不明确。用户使用该系统查籍贯,更多情况下是在筛选项里选择具体的省/市,等值匹配更合理。更新语句与新增语句的设计也有讲究。新增时用主键回填拿到自增 id,是因为后面需要马上用它保存获奖关联数据和作品数据:<insert id="insertAuthor" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_author (...) VALUES (...) </insert>更新操作要注意不能把已有的字段覆盖成 null。比如编辑页面如果只提交了部分字段,前端传来的实体可能某些字段为空。这时用<set>标签配合 if 判断,只有非空字段才进入 SET 子句。4. 前端实现:Vue3 + Element Plus 构建三块核心页面4.1 工程初始化与路由设计前端基于 Vite + Vue3 构建。初始化之后,我习惯先做脚手架整理,把 axios 实例封装、路由守卫、菜单容器提前准备到位,避免后面散乱补丁。路由结构分为三层:登录页是独立路由,其余页面挂在Layout布局组件下,子路由包括作家管理、获奖数据、统计分析。const routes = [ { path: '/login', component: Login }, { path: '/', component: Layout, redirect: '/authors', children: [ { path: 'authors', component: AuthorList, meta: { title: '作家管理' } }, { path: 'awards', component: AwardList, meta: { title: '获奖数据' } }, { path: 'stats', component: StatsPage, meta: { title: '统计分析' } } ] } ]导航守卫里判断本地存储是否有 token,没有 token 一律跳登录页。这个判断逻辑虽然简单,但在后台管理项目中属于必备基础。token 过期时后端会返回 401,Axios 响应拦截里要清除本地登录态并跳转登录页。4.2 列表页的组装逻辑作家列表页是这个系统最核心的页面,承担了高频的检索、分页、查看详情、编辑入口等任务。页面拆成三个区域:上方筛选区:姓名/笔名关键字、流派选择器、状态开关。中间表格区:使用 el-table 展示数据。列包括姓名、笔名、性别、籍贯、流派、状态、操作。状态列用 el-tag 展示“启用/停用”标签,操作列放“编辑”“详情”“删除”三个按钮。下方分页区:使用 el-pagination,配置 current-page、page-size、total 绑定到响应式变量。Axios 请求参数设计列表请求接口设计为 GET/api/authors/page,参数是 pageNum、pageSize、keyword、genre、status。表格数据刷新时,把筛选表单的数据和分页数据合并为一个请求参数对象。const queryParams = reactive({ pageNum: 1, pageSize: 10, keyword: '', genre: '', status: '' }) async function loadAuthorList() { const res = await request.get('/api/authors/page', { params: queryParams }) const { list, total } = res.data tableData.value = list totalCount.value = total }有很多新手会在请求前手动拼接 URL 字符串,用 axios 的 params 对象其实更规范,它会自动处理编码,还能避免在 query 里出现空白字符串等脏数据。Axios 拦截器里去掉 + code 校验之外的自动序列化;在请求对象上设置只传列表要展示的字段,减少网络传输。4.3 表单页与级联保存作家编辑页面是这个小系统的“重头戏”,因为它要同时处理三部分数据:作家基本信息、获奖记录列表、代表作列表。界面我设计成上下分区的表单:基本信息区:姓名、笔名、性别、出生日期、籍贯、流派、简介、状态。获奖记录区:一个动态表格,每行包含奖项选择器、获奖年份、获奖作品输入框、是否核心奖项开关。可以添加行、删除行。作品信息区:一个子表格,填写作品名、体裁、出版年份、出版社、摘要、是否代表作,同样支持动态增删。这个表单提交时,需要一次性把作家主数据 + 获奖记录列表 + 作品列表全部提交给后端。后端用一个saveAuthorFull接口接收,内部开启事务:先保存作家主表,然后循环插入获奖关联记录和作品记录。前端只发一次请求,后端保证数据一致性,这比前端逐个提交更可靠。function handleSubmit() { const formData = { ...authorForm, awards: awardList.value, works: workList.value } request.post('/api/authors/saveFull', formData).then(() => { ElMessage.success('保存成功') }) }关联数据的增删改,其实还有个更省心的策略:前端在提交时把删除的关联记录 ID 也传过去,后端统一做差量更新。不过为了简化,这个项目里我采用了“整单覆盖”的思路——编辑提交时,后端先删除该作家原有的获奖关联和作品记录,再重新插入前端传来的新数据。这种方案胜在简单直接,出错率低,唯一的前提是要做好事务控制,避免删了没插引发脏数据。4.4 统计页的数据可视化统计分析页面我用 ECharts 做了两张图。第一张是“奖项分布柱状图”,横轴是不同奖项,纵轴是获奖作家数量,通过后端一个统计查询直接返回分组计数。后端 SQL 只需要一句:SELECT a.award_name, COUNT(ab.author_id) AS author_count FROM t_author_award ab JOIN t_award a ON ab.award_id = a.id GROUP BY a.award_name ORDER BY author_count DESC第二张是“作品出版年代折线图”,按作品出版年份分组统计数量,用来观察某位作家或整个作家群体的创作活跃期变化。统计图表的精髓是最小化前端数据加工,能后端聚合完成的就别传到前端再做 map reduce。ECharts 的实例在 Vue3 里需要注意销毁逻辑。组件 onUnmounted 时机调用chart.dispose(),否则页面切换后图表实例没有释放,会造成内存占用影响性能。如果多个页面共用一个图表容器,还要在 resize 时调用chart.resize()。5. 前后端联调与部署实战5.1 开发期的跨域处理前后端分离项目最常遇到的就是跨域问题。开发环境我推荐两种方式搭配使用。前端 Vite 里配置 proxy,把/api开头的请求转发到后端 8080:server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样浏览器看到的请求始终保持同源,不会触发 CORS 预检。后端同时保留一个 CORS 配置类,相当于双保险——如果直接通过 IP 访问,或者在部署阶段前端静态资源与后端服务不在同一域名下,后端也能正确响应跨域请求。这里有个操作提示:跨域配置是“前后端约定”的一部分,建议把允许的路径、方法、请求头统一确定下来,不要一边配一边扩展,联调时出现跨域问题最难排查的原因就是两边配置不一致。5.2 打包部署:Jar + Nginx后端打包比较简单,Maven 执行package生成 jar,在生产服务器或者容器环境直接java -jar运行。如果需要在不同环境切换配置,SpringBoot 的 profile 机制可以分开管理 application-dev.yml 和 application-prod.yml,部署时用--spring.profiles.active=prod指定。前端构建后生成dist静态目录,部署在 Nginx 下。最关键的配置是 location 规则:server { listen 80; server_name your-domain; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }try_files $uri $uri/ /index.html这一行解决 Vue Router 的 history 模式刷新 404 问题。很多新手部署完页面,首次访问正常,一刷新就 404,就是因为少了这个回退到 index.html 的配置。5.3 日期格式化与分页数据的暗坑时间字段是前后端联调中最容易出怪问题的部分。后端LocalDateTime默认序列化格式是 ISO 格式,前端展示不友好。我在项目里统一配置了 Jackson 格式:@Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); builder.serializers(new LocalDateTimeSerializer( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateDeserializer( DateTimeFormatter.ofPattern("yyyy-MM-dd"))); }; }分页数据还有一个经典坑:直接用 PageHelper 查出的Page对象序列化会带出很多无用属性,前端拿到不干净的数据结构。更好的做法是转成PageInfo,或者手动组装一个 PageResult:public class PageResult<T> { private Long total; // 总条数 private List<T> list; // 当前页数据 }这也是我在统一返回体里单独封装分页结果的原因,联调时前端拿到的结构永远只有 list 和 total,简单明确。6. 常见问题排查与优化经验6.1 联调期踩过的典型问题速查问题现象可能原因解决办法前端请求一直 403token 未带上或已过期检查 axios 请求拦截器是否正确设置 Authorization中文数据存入数据库变乱码JDBC URL 未指定编码在 url 中加characterEncoding=utf8mb4&useUnicode=true分页数据总条数异常PageHelper.startPage 前执行了其他查询确保 startPage 紧贴目标 select 语句日期显示是一串数字Jackson 未配置 LocalDateTime 格式在全局配置中统一序列化格式列表页刷新 404Nginx 未配置 try_files服务端回退到 index.html删除作家时提示外键约束关联表有残留数据删除作家时同步删除获奖关联和作品记录每个问题都不是什么高深技术难点,但它们非常消磨时间。我的经验是:遇到问题第一反应不要急着改代码,先抓请求的完整链路。打开浏览器开发者工具,先看请求到底发出去没有,再看后端日志报什么错,最后才定位代码逻辑。6.2 性能与体验优化心得后端查询方面,列表接口返回的字段不包含profile这种大文本,查看详情时才单独查询完整字段。这种做法避免了列表页每次传输大量简介文本,显著降低了接口响应体积。从体验上看,列表加载速度会快不少。前端请求方面,所有搜索条件和分页变化共享同一个加载函数,并且在切换筛选条件时把 pageNum 重置为 1,避免用户在第 10 页点搜索后看到的却是空列表。接口设计方面,新增、编辑共用同一套保存逻辑,前端通过 id 是否存在判断调用 POST 还是 PUT。这一类经验在更复杂的系统中依然适用:接口数量不贪多,职责保持单一,前后端约定清晰,联调效率才有保障。还有一个实用的扩展方向:数据导出。很多管理用户最后都会提一句话,“能不能把这份名单导成 Excel”。你可以在后端集成 EasyExcel,把作家列表或获奖记录输出为 xlsx 文件,前端下载接口返回的二进制流保存到本地。这个功能工作量不大但是相当加分,建议作为二期优先开发项。一套可复现的完整配置参考写到这里,我把自己创建项目时的核心配置贴一份在下面,方便照着复现。pom.xml 核心依赖<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>application.yml 关键配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/author_db?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your-password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true pagehelper: helper-dialect: mysql reasonable: truemap-underscore-to-camel-case这个配置非常关键,没有它,数据库的下划线命名不会自动映射到实体的驼峰属性,MyBatis 查询结果就会拿不到值,这是新手最容易漏的配置之一。个人复盘整个项目,我最深的体会是:这类管理系统真正难的不是某个技术点的深度,而是各个模块之间的一致性。数据库字段规范和实体属性要保持一致,后端接口返回的数据结构和前端 UI 需求要保持一致,JWT 的过期时间和前端跳转时机要保持一致。只要把约定和管理做好,这个项目从开发到上线会非常顺,把它拿去做课程设计、面试项目或者内部工具,都是很适合的选择。