前后端分离的项目我做过不少,但每次接到“XX信息管理系统”这类需求时,依然不能掉以轻心。这次要聊的获奖作家信息管理系统,乍一看只是常规的增删改查,实际落地时却把全栈项目里最容易翻车的环节都走了一遍:SpringBoot 的接口设计、Vue 的页面组件划分、MyBatis 的数据库映射、MySQL 的表结构规划,还有最后从打包到部署的一整套流程。整套系统从前到后没有用任何奇技淫巧,全部基于主流稳定方案,正适合刚开始接触 JavaWeb 全栈开发的同学,或者需要快速交付一个信息管理后台、但又不想从零踩坑的团队参考。我会把这个项目的完整实现过程、关键源码片段和部署顺序拆开讲清楚,包括我实测过程中遇到的坑和对应解法,尽量让这篇内容能直接当作一份可执行的开发手册使用。
1. 业务建模先行:获奖作家信息管理系统的字段与表关系设计
信息管理系统的第一步往往不是写代码,而是把“要管什么”想清楚。很多新手上来就开建表,结果做到一半发现“作家”“奖项”“作品”这三个概念纠缠不清,后面改起来非常痛苦。我这次用的方法是先把业务流程画成一条直线:录入一位作家,给这位作家关联对应的获奖记录,再关联代表作品,最终在前端页面按列表和详情两种视角展示。
1.1 核心实体拆分:作家、奖项、作品三者如何解耦
当时在实体划分上,我最先犹豫的是“奖项”到底要不要单独成表。后来参考了几个同类管理系统的设计,也结合了实际维护体验,最终确认:奖项必须独立成表,不能直接把“获得某年某奖”塞在作家里当作一个普通字段。原因很直接——同一奖项可能授予多位作家,如果只在作家表里存一列字符串,后续要做“按奖项筛选获奖作家”的需求就会变成噩梦。于是我把核心数据拆成四张表:
- 作家表(writer):存放基本信息,如姓名、性别、生日、籍贯、个人简介、头像地址等;
- 奖项表(award):存放奖项名称、设立年份、颁奖单位等固定信息;
- 获奖关系表(writer_award):作家的获奖记录,包含获奖年份、届次、具体获奖作品名;
- 作品表(work):作家代表作品,与作家表一对多关联。
这里的关键设计思路是“关系表”的灵活替换:一位作家可以拿多个奖项,一个奖项也可以颁给多位作家,只有通过中间表才能保证这种多对多关系不会产生数据冗余。如果只给作家表加一列“获奖情况”,后续做数据统计、按奖项维度导出名单时,改动成本会大到你不想碰。
1.2 MySQL 建表脚本与字段设计中的细节取舍
用 MySQL 建表时,字符集我统一选择 utf8mb4,而不是 utf8。原因是后者在存储生僻字或特殊符号时可能报错,尤其是作家姓名、作品名里很容易出现不规范字符,utf8mb4 可以兼容完整的 Unicode。主键方面,我采用自增 id 作为业务主键,没有用 UUID,理由是这个系统数据量不大,自增主键查询效率高,且在分页排序场景下更自然。
一个容易忽略的细节是“逻辑删除”。我没有直接用 DELETE 语句物理删除记录,而是在表里加了 deleted 字段,默认值是 0。好处是误删数据可以恢复,而且对前端展示、数据统计都更安全。缺点是每次查询都要记得带上 deleted = 0 条件,我在 Mapper 层统一做了处理,避免漏掉。
另外一个我在实际项目中踩过的坑是字段命名风格。后端 Java 习惯使用驼峰命名,比如 birthDate,但 MySQL 里通常使用下划线风格 birth_date。如果两边不一致,MyBatis 又不是所有版本默认开启驼峰映射,就会导致查询结果全是 null。这个问题我后面在后端章节会重点说,建表时统一用下划线,实体类里用驼峰,工程习惯上更清晰。
下面是简化后的核心建表语句,保留关键字段,去掉一些与示例无关的索引细节:
sql复制CREATE TABLE `writer` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL COMMENT '作家姓名',
`gender` tinyint DEFAULT 0 COMMENT '0保密 1男 2女',
`birth_date` date DEFAULT NULL,
`native_place` varchar(128) DEFAULT NULL,
`profile` text COMMENT '个人简介',
`photo_url` varchar(255) DEFAULT NULL,
`deleted` tinyint NOT NULL DEFAULT 0,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='作家基本信息表';
CREATE TABLE `award` (
`id` bigint NOT NULL AUTO_INCREMENT,
`award_name` varchar(128) NOT NULL,
`founded_year` int DEFAULT NULL,
`organizer` varchar(128) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='奖项表';
CREATE TABLE `writer_award` (
`id` bigint NOT NULL AUTO_INCREMENT,
`writer_id` bigint NOT NULL,
`award_id` bigint NOT NULL,
`award_year` int DEFAULT NULL COMMENT '获奖年份',
`award_work` varchar(255) DEFAULT NULL COMMENT '获奖作品',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='作家获奖关联表';
时间字段 create_time 和 update_time 我建议用数据库默认值维护,这样应用层完全不用关心写入时间,也避免不同服务器时间不一致的问题。除非业务要求记录“创建时由人工指定时间”,否则默认值方案是性价比最高的选择。
1.3 索引与查询需求的对应关系
很多人在小数据集的项目里会忽略索引,但查询慢的问题一旦出现,改起来反而更麻烦。我在 writer_award 表上专门建了一个组合索引,包含 writer_id 和 award_id,因为所有涉及“某作家获得过哪些奖”的查询都走这两个字段。单纯给 writer_id 建单列索引也可以,但带上 award_id 后,查询获奖详情时能够减少回表次数。
设计表结构时还有一个原则:能拆的表尽量拆,但不要拆成过度范式化的碎片。比如这段示例里面,作品表和作者的关系其实也可以并行拆成“作家-作品”独立表,但实际使用中作品必然归属且仅归属一位作者,我最终选择在作品表直接用 writer_id 外键指向作家表,而不是再造一张作者与作品的中间表,避免为了理论规范增加无谓的 join 复杂度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SpringBoot + MyBatis 后端实现:从项目骨架到多条件检索接口
后端部分我选择 SpringBoot 作为主框架,理由很朴素:起步依赖完善,内嵌 Tomcat,打包后一个 jar 就能跑,配合 MyBatis 操作数据库足够灵活。MyBatis 相比 JPA 的优势在于 SQL 完全可控,遇到复杂多表查询时手写 SQL 更直观,对刚接触企业级开发的读者来说也更容易理解到底发生了什么。这套组合虽然没有 JPA 那么“自动”,但调试和维护成本低,对信息管理系统这类场景非常合适。
2.1 项目初始化与关键依赖
初始化项目时,我通过 Maven 管理依赖。需要注意 SpringBoot 版本与 JDK 版本的对应关系,我这里用的是 2.7.x + JDK 8,稳定性好,网上资料也多。如果你本机已经是 JDK 17,也可以选择 SpringBoot 3.x,但要注意部分老版本 MyBatis 对应启动器存在兼容问题,建议查阅官方适配表后再选版本。
核心依赖如下:
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.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>
这里我选 PageHelper 做分页插件,原因是在 MyBatis 框架下,分页 SQL 如果全靠手写,每个查询方法都要维护 limit 参数,代码重复度太高。PageHelper 提供了一个基于拦截器的分页能力,调用 PageHelper.startPage 后,后面紧跟的第一条查询会自动带上分页条件,并返回总数等分页元数据,对大多数管理系统来说完全够用。
2.2 application.yml 中的关键配置与驼峰映射
配置文件里最容易坑人的就是 MyBatis 驼峰映射开关。我的实体类用驼峰命名,数据库字段用下划线命名,必须在配置里加上:
yaml复制mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
没有这个开关,接口返回的对象里,birthDate 这个属性会因为无法和 birth_date 列自动对应而变成 null。这个错误在联网查资料时也很容易遗漏,因为很多教学项目字段命名恰好一致,根本不会暴露问题。我在第一次写完查询接口时,前端明明拿到了行数,却每行都是空对象,排查了好一阵才发现是这行配置缺失。
另外,在配置数据源时,我习惯把连接参数单独拎出来,方便不同环境切换:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/writer_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 你的密码
driver-class-name: com.mysql.cj.jdbc.Driver
serverTimezone=Asia/Shanghai 必须带上,否则数据库连接时会因为时区问题直接报错。这类问题不算难,但第一次部署到服务器时特别容易漏。
2.3 分层结构与统一返回体设计
后端代码我分成四层:controller、service、mapper、entity。很多初学者会怀疑“这么简单的系统有必要分层吗”,我的看法是:就算只有增删改查,分层也能帮你把接口逻辑、业务逻辑、SQL逻辑分开,后期扩展权限、加日志、加缓存时,只需要在对应层调整,而不至于去改一坨堆在一起的代码。
另外,前后端分离项目里,接口返回格式不统一是比报错更难受的问题。我在 common 包里定义了一个简单的 Result 类:
java复制public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.code = 200;
result.message = "success";
result.data = data;
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.code = 500;
result.message = message;
return result;
}
}
前端拿到响应后,不需要猜测 code 的含义,只要看到 200 就代表业务成功,非 200 直接弹 message。这种约定统一以后,联调效率能提升一个档次。如果你想把错误处理再细化,也可以配合全局异常处理器,把参数校验失败、数据不存在等场景映射成不同的错误码,但刚开始做管理系统,保持简单更重要。
2.4 多条件检索接口与动态 SQL 实现
列表查询是最核心的接口。这个系统需要支持按作家姓名模糊搜索、按性别筛选、按获奖年份区间筛选,同时还要分页。如果为每种组合写一个独立方法,代码会膨胀到不可维护。我的做法是使用 MyBatis 的动态 SQL,通过 <if> 标签拼条件,一个 mapper 方法覆盖所有组合。
Mapper 接口方法定义如下:
java复制List<WriterVO> selectWriterPage(@Param("name") String name,
@Param("gender") Integer gender,
@Param("startYear") Integer startYear,
@Param("endYear") Integer endYear);
对应的 XML 片段是:
xml复制<select id="selectWriterPage" resultType="com.example.writer.entity.WriterVO">
SELECT w.*, wa.award_year
FROM writer w
LEFT JOIN writer_award wa ON w.id = wa.writer_id
<where>
w.deleted = 0
<if test="name != null and name != ''">
AND w.name LIKE CONCAT('%', #{name}, '%')
</if>
<if test="gender != null">
AND w.gender = #{gender}
</if>
<if test="startYear != null">
AND wa.award_year >= #{startYear}
</if>
<if test="endYear != null">
AND wa.award_year <= #{endYear}
</if>
</where>
GROUP BY w.id
ORDER BY w.id DESC
</select>
这段 SQL 有几个细节值得说明。一是用 <where> 标签包裹条件,MyBatis 会自动去掉第一个多余的 AND;二是模糊查询用 CONCAT('%', #{name}, '%') 而不是在 Java 层拼好字符串再传入,这样能有效避免 SQL 注入问题;三是 > 和 < 是 XML 中对大于号和小于号的转义写法,直接写 > 会导致 XML 解析错误。GROUP BY 在这里是为了让一位作家关联多个奖项时,主表数据不会重复,我配合聚合函数和子查询做了去重。
2.5 事务与更新接口的处理规范
写入接口我用到了 @Transactional 注解。比如新增一条获奖记录,要同时向 writer_award 表插入数据,并更新作家的获奖次数统计字段,这两步必须在一个事务里,否则中间失败就会造成数据不一致。Spring 的 @Transactional 默认只在 RuntimeException 时回滚,如果业务抛的是受检异常,记得在注解里指定 rollbackFor,否则事务形同虚设。这个细节在教科书里经常被轻轻带过,实际项目里却能引起不小的数据事故。
3. Vue 前端实现:组件拆分、路由规划与列表页复用
后端接口就绪以后,前端部分我使用 Vue 2 + Element UI 这套成熟组合。选择 Vue 2 不是因为它最新,而是因为相关文档和踩坑记录最全,对要参考源码的人来说学习成本最低。如果你准备新起项目,直接上 Vue 3 + Element Plus 也没问题,组件化思路完全一样,只是部分 API 写法有差别。下面讲的核心思路,换到 Vue 3 一样成立。
3.1 项目结构与路由设计
前端工程使用 Vue CLI 创建,目录结构按功能模块划分,而不是按文件类型生硬堆叠。我的划分方式是:
views/writer/下放作家管理相关页面(列表页、新增页、编辑页、详情页);views/award/下放奖项管理相关页面;api/目录统一封装所有后端接口请求;router/维护前端路由表;components/放多个页面会复用的公共组件。
路由规划时我做了两级结构:登录后进入主布局,主布局内有侧边栏菜单,菜单项对应各个业务模块。需要注意,Vue Router 的 history 模式在部署到 Nginx 后,如果只配置了根路径的 try_files,刷新子路径页面时会报 404。这个坑我放到部署章节具体说。
3.2 列表页组件化的三种抽象
获奖作家管理系统的核心页面就是列表页。这个页面我从头到尾拆成了三块:搜索条件栏、数据表格、分页条。每块都可以独立复用,不仅是这个项目内部复用,后续如果要做“奖项管理”“作品管理”,基本就是换个字段名复制这套结构。
表格列的定义我抽成了一个配置数组,放在页面的 data 里:
js复制tableColumns: [
{ prop: 'name', label: '姓名', width: 120 },
{ prop: 'gender', label: '性别', width: 80, formatter: (row) => row.gender === 1 ? '男' : (row.gender === 2 ? '女' : '保密') },
{ prop: 'birthDate', label: '出生日期', width: 120 },
{ prop: 'nativePlace', label: '籍贯', width: 150 },
{ prop: 'awardYearText', label: '获奖年份', width: 160 },
{ prop: 'profile', label: '简介', minWidth: 200, showOverflowTooltip: true }
]
这种配置驱动表格的方式,比在模板里写死一长串 <el-table-column> 更清晰。加字段、删字段、调顺序,都只改数组,不碰模板。如果你用 Vue 3,也可以用 v-for 配合动态列的方案,但一定要注意 key 的绑定,避免列顺序变化时出现渲染错位。
搜索结果的处理我封装了一个通用方法:
js复制async function fetchList(params) {
const res = await getWriterList(params)
if (res.code === 200) {
tableData.value = res.data.list
total.value = res.data.total
}
}
分页变化和搜索按钮点击都复用这一个方法,只是传入的 query 参数不同。这个方法里最在意的点是:切换页码时要保留搜索条件,不能让用户一翻页就回到第一页且丢失筛选内容。所以我用一个响应式的 searchForm 对象保存搜索条件,每次请求都把 searchForm 和 pageNum、pageSize 一起传。
3.3 表单页与编辑页的复用逻辑
新增和编辑是两个不同路由,但表单内容一致。我直接把表单抽成了一个子组件,通过 props 传入初始值,编辑时在 mounted 阶段根据路由参数拉取详情再回填,保存时统一走同一个提交接口,根据是否有 id 决定是新增还是更新。这样表单的校验规则、输入项配置只需要维护一份,减掉了大量重复代码。
表单校验这里有一个经验:出生日期字段用日期选择器时,组件返回的值格式是字符串,但后端接口希望接收的是 yyyy-MM-dd 格式,一定要在提交前做一次格式化。我见过不少项目因为时区差异,日期被后端解析后比预期少了 8 小时。在前后端联调阶段,时间字段的统一是最容易出幺蛾子的地方,后面我会单独说。
3.4 Axios 封装与登录状态处理
前端所有接口请求,我都封装在一个 request.js 文件里,基于 axios 实例统一配置 baseURL、超时时间和响应拦截器。拦截器里做两件关键事:一是判断后端返回的 code,非 200 时统一弹出错误提示;二是携带 token 到请求头。这个系统的权限要求不高,登录后后端签发一个简单的 token 存到 localStorage,每次请求自动带上。如果你接的是更严格的权限体系,可能还要在拦截器里处理 token 过期后的自动跳转登录页。
Axios 封装的模板如下:
js复制const service = axios.create({
baseURL: process.env.VUE_APP_BASE_URL,
timeout: 10000
})
service.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = 'Bearer ' + token
}
return config
})
service.interceptors.response.use(response => {
const res = response.data
if (res.code !== 200) {
Message.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res
})
开发环境下,我没有用完整的 baseURL 指向后端地址,而是让代理层去转发。理由是这样的:浏览器直接请求后端接口会遇到跨域限制,虽然后端也能配 CORS 解决,但生产环境对 API 地址一般也是通过 Nginx 反向代理统一入口,所以在开发阶段就使用 proxy 代理,能够让前后端联调环境与生产环境保持一致。说白了,前端只管请求同一个域名下的 /api 前缀,具体请求转发到哪个后端端口,由代理配置决定。
4. 前后端联调与接口约定:CORS、时间格式与分页数据结构的统一
前后端分离项目里,最磨人的不是单独写前端或后端,而是两个团队把接口对接起来的联调阶段。这一章从我实际的踩坑经历出发,把最容易引发争论的几个点讲透。
4.1 跨域问题的两种解法:开发代理与生产反向代理
开发模式下,我在 Vue CLI 的配置文件里加了如下代理:
js复制devServer: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: { '^/api': '' }
}
}
}
这样可以避免前端开发服务器和 SpringBoot 服务之间直接跨域。要注意的是 pathRewrite,如果后端接口本来就没有 /api 前缀,而前端约定统一带 /api 访问,这里必须把前缀重写掉,否则请求会因为路径对不上返回 404。
生产环境里,我用 Nginx 反向代理同一套体系的 /api 路径,效果是前端与后端对外表现为同一个域名,从根本上规避了浏览器跨域问题,也就不用在后端额外配置 permissive 的 CORS。这类信息管理系统通常不对外开放 API,内部部署时一致性远大于 CORS 的灵活性,所以我也不建议在生产环境给 SpringBoot 开启全局 CORS,安全风险大于便利。
4.2 时间字段格式化:LocalDateTime 与 JSON 序列化
后端实体类里我用了 Java 8 的 LocalDateTime 类型,但 SpringBoot 默认的 Jackson 序列化会把这种类型输出成数组格式,前端拿到的是 [2025, 1, 10, 15, 30, 0] 这样一串奇怪的数组,和预期的 2025-01-10 15:30:00 差了十万八千里。我最初的解决方式是在实体字段上加注解:
java复制@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
这个方法写起来快,但每个时间字段都要加一遍。更好的方案是全局配置 Jackson 的序列化规则,覆盖 LocalDateTime 和 LocalDate 的默认输出格式:
java复制@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder -> {
builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
builder.serializers(new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
};
}
}
全局配置的好处是一劳永逸,新加的字段只要类型正确,序列化格式就正确。前端也只需要依赖一种约定:页面上看到的时间要么是日期,要么是日期时间,不存在格式不统一造成的展示错位。
4.3 分页返回结构的前后端约定
信息管理系统的列表接口几乎都要分页。我和前端约定的分页返回结构是:
json复制{
"code": 200,
"message": "success",
"data": {
"list": [],
"total": 128,
"pageNum": 1,
"pageSize": 10
}
}
前端只需要取 data.list 渲染表格,用 data.total 控制分页总条数。PageHelper 天然能给出 total 和 list,Controller 里做一次组装就行:
java复制PageHelper.startPage(pageNum, pageSize);
List<WriterVO> list = writerMapper.selectWriterPage(name, gender, startYear, endYear);
PageInfo<WriterVO> pageInfo = new PageInfo<>(list);
使用 PageHelper 时有几个细节需要留心:startPage 必须在紧接着执行 Mapper 查询的方法前调用,中间不能插其他数据库操作;返回的 List 已经是被 PageHelper 代理过的分页结果,所以直接用 PageInfo 取总数即可。一个项目中,如果某条查询逻辑里有多条 SQL,别把 startPage 放在所有查询最前面,它会拦截后续第一条查询,造成错误分页。
4.4 联调时遇到的数据显示异常排查清单
联调阶段我整理过一份自己的排查清单,很多显示异常其实就出在几个固定的点上,照着查通常能快速定位。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 表格有行数但全部空白 | MyBatis 驼峰映射未开启 | 检查 mybatis configuration 配置 |
| 数字字段全是 0 | 字段类型不匹配,Long/Integer 映射异常 | 检查实体类类型与数据库列类型 |
| 日期少 8 小时 | 时区未设置一致 | 数据源 URL 加 serverTimezone |
| 编辑页打开后数据不显示 | 前端详情接口没在 mounted 调用或回填逻辑漏了 | 检查组件生命周期与赋值时机 |
| 搜索条件无效 | 请求参数命名与后端 @RequestParam 不一致 | 统一参数名,用注解或 query 对象 |
这张表不算全面,但覆盖了信息管理系统最常见的五类问题。特别是第一类驼峰映射问题,我几乎每次帮别人排查都能遇到,所以宁可在这里再多提一次。
5. 服务器部署全流程:Maven 打包、Nginx 转发与进程守护
开发环境跑通不代表项目交付,真正的考验在部署。部署的流程不算复杂,但每一步都有关键细节。我以前部署别的项目时曾因漏掉其中一环,导致整整一个下午都在查为什么页面白屏。下面我把从本地打包到服务器上线的完整过程拆开来写。
5.1 Maven 打包:jar 包与前端静态资源的生成
后端项目在根目录执行:
bash复制mvn clean package -DskipTests
执行完成后,target 目录下会生成一个可执行 jar 包。如果不加 -DskipTests,单元测试失败会导致打包中断,所以我一般建议先跳过测试,等正式发布流程需要时再补。
前端项目构建:
bash复制npm run build
Vue CLI 会把所有资源打包到 dist 目录。如果前端使用了路由 history 模式,并且直接以静态文件方式部署,必须配合 Nginx 的 try_files 配置,否则点击页面跳转没问题,但手动刷新或分享链接时会直接在服务器层面报 404。
5.2 Nginx 配置静态页面与反向代理
我在这台服务器上用 Nginx 同时承担两个任务:托管前端静态文件和反向代理后端接口。前端文件放在 /opt/writer-web 目录,后端 jar 运行在 8080 端口,Nginx 配置如下:
nginx复制server {
listen 80;
server_name your-domain.com;
root /opt/writer-web;
index index.html;
location / {
try_files $uri $uri/ /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;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
第二段 location 这里有一个非常常见的配置陷阱:proxy_pass http://127.0.0.1:8080; 后面没有斜杠,意味着请求会带上原始 URI 原样转发。也就是说,前端请求 /api/writer/list,后端收到的是 /api/writer/list;如果我把 proxy_pass 写成 http://127.0.0.1:8080/,后端收到的请求就会变成 /writer/list,丢掉了 /api 前缀。到底需不需要带斜杠,取决于后端接口本身有没有 /api 前缀。我在这个项目中统一让后端接口不带全局前缀,而是用 Nginx 的 rewrite 或直接保留路径,保持一致即可。
try_files 是刷新不白屏的关键,它会在请求路径找不到对应文件时,回退到 index.html,由前端路由接管后续解析。漏了这行,部署完以后首页能打开,路由跳转也正常,但只要一按 F5 刷新就会白屏,这是前端 history 模式下最经典的部署问题。
5.3 systemd 配置进程守护与常见运维命令
后端 jar 包不能只靠 java -jar 放后台跑,我用 systemd 管理服务,进程崩溃后能自动拉起,服务器重启后也会自动启动。服务文件路径是 /etc/systemd/system/writer.service,内容如下:
ini复制[Unit]
Description=Writer Management System
After=network.target
[Service]
ExecStart=/usr/bin/java -jar /opt/writer-app/writer-system.jar --spring.profiles.active=prod
Restart=on-failure
User=root
SuccessExitStatus=143
[Install]
WantedBy=multi-user.target
这里注意 SuccessExitStatus=143,这个 143 是 systemd 停止服务时发送 SIGTERM 信号后的退出码,如果漏了这行,每次执行 systemctl stop writer 时服务状态都会被标记为 failed,虽然实际影响不大,但看着很不专业。部署完成后,常用命令是:
bash复制systemctl daemon-reload
systemctl start writer
systemctl enable writer
systemctl status writer
journalctl -u writer -f
改完 jar 包需要重启服务时,先执行 systemctl restart writer。日志排查一律走 journalctl,这条命令能实时看到 SpringBoot 的控制台输出,几乎等价于开发环境 IDE 里的日志窗口。
5.4 部署现场遇到的三类典型故障
第一类是数据库连接失败。服务器上的 MySQL 密码包含特殊字符时,写在 application.yml 里必须转义,否则 YAML 解析会把密码切段。我建议部署时把数据库连接参数放到独立的 application-prod.yml 配置文件里,并使用环境变量注入密码,不让敏感信息写死在代码仓库。
第二类是端口冲突。服务器上已经有其他进程占用 8080,SpringBoot 启动会直接报端口被占用。用 netstat -tlnp | grep 8080 查看占用进程,要么换端口,要么杀掉进程。为了避免这种事,我一般约定生产环境一个应用一套端口范围,并记录在运维文档里。
第三类是静态资源更新不生效。前端构建后重新上传 dist 目录,但浏览器缓存还在加载旧版本。Nginx 里可以配置静态资源缓存策略,或者在 index.html 里引用带 hash 的资源版本。我的经验是:交付前端代码时,明确告诉使用方“dist 整个目录替换”,不要只覆盖部分文件,否则容易出现摸着新版壳、壳里套旧逻辑的尴尬状态。
6. 从 Demo 到可用系统:功能扩展与通用化改造建议
很多人做完一个管理系统就停在“能跑”的阶段,但真正交付时,客户或团队往往会提出几个必须迈过去的坎:数据要能导入导出、权限要能区分管理员和普通用户、字段要允许后台自定义。这一章讲几个我认为最有价值的扩展方向,也分享一下我在这个项目基础上做过的取舍。
6.1 导入导出的低成本实现
最初的系统只有手工录入功能,但做信息管理的人最常问的一句话是“有没有 Excel 导入”。为了解决这个问题,我在后端引入了 EasyExcel 依赖,专门用于作家数据的批量导入和导出。实现思路不复杂:导入时读取 Excel 的每一行,解析成 Writer 实体后批量插入;导出时把列表查询结果写入 Excel 响应流。真正要关注的是模板校验:导出的模板里字段名要与实体类的表头注解对应,导入前先做格式校验,把错误行号返回给前端提示,而不是直接把整个 Excel 处理失败。
EasyExcel 相比传统 POI 的明显优势是内存占用低,大数据量导入时不容易 OOM。如果你的系统并发量不大,甚至可以用 CSV 简单实现,但考虑到中文编码、列对齐等问题,我还是选择了 EasyExcel。
6.2 权限体系的轻量设计
如果这个系统要有登录后的权限区分,一个轻量方案是:用户表里增加角色字段,后端在 Controller 层用拦截器校验角色身份。管理员可以新增、编辑、删除,普通用户只能查看列表和详情。SpringBoot 里的 HandlerInterceptor 配合注解就能实现,不需要引入 Spring Security 这种重型框架。但如果你后续要支持细粒度权限、菜单按钮级控制,Spring Security 才是长期更稳妥的方案。
在信息管理系统这类内部工具里,我的经验是权限一开始别做太重,先满足“登录可见、角色隔离”这层需求,随着业务方提新需求再迭代。一上来就做复杂权限模型,只会拖慢交付进度,最后可能一半功能用不上。
6.3 通用化的思路:把“作家”改成任意对象
这个项目表面上是作家信息管理,实际上整套架构可以很容易改造成人员管理、图书管理、设备管理等等。核心诀窍是保持业务代码和通用组件分离:列表页的搜索框、表格、分页是通用的,只需要替换表格列配置和请求接口;详情页的表单是通用的,只需要替换字段配置。我写这套系统时,特意没有把大量业务字段硬编码在通用组件里,为的就是后续复制项目时能快速改造成新系统。
改造时最花时间的部分,通常是把底表中的业务字段替换成新业务的字段,而不是前端界面本身。如果需求频繁变化,还可以考虑借助元数据驱动的思路,把字段定义搬到数据库,页面通过接口动态渲染。但这种方案复杂度明显上升,只有在字段确实经常变、业务方又提不出稳定需求时才建议采用。
6.4 维护期的技术债务与迁移路径
这个项目用的是 MyBatis XML 手写 SQL,优点是灵活可控,缺点是 XML 文件多了以后查找和重构比较费劲。维护期我会在 mapper 接口里写清楚每个方法的业务意图,XML 里保持统一的 SQL 风格,尽量让新人接手时也敢改。
如果未来要把这套系统迁移成 Spring Boot 3 + Vue 3 的版本,整体分层不用动,主要工作量集中在依赖版本升级和前端 API 写法调整上,数据库表结构基本可以复用。我对这类系统的建议是:核心价值在数据结构设计和业务流梳理,技术栈只是手段,别过度执着于追求所谓最新框架,稳定可靠才是长期维护的第一位。
最后分享一个自己在部署和维护这套系统时的体会:前后端分离项目真正的分水岭,不是代码里用了多少热门框架,而是你能否把每一步环节的前因后果讲清楚。记录数据库设计、接口约定、部署步骤这些“看不见的工程资产”,比啃下某个新特性更有价值。这个项目从建表到上线,我最大的收获不是把功能做出来了,而是把过程中踩过的每个坑都沉淀成了自己的一套检查清单,下个项目再遇到类似场景,能少走很多弯路。
