先说个实在话:前后端分离的管理系统,是最容易做“看起来简单、做起来全是坑”的一类项目。流浪宠物管理系统就是一个典型——从表面看,无非是宠物信息的增删改查,再加一个领养申请流程;但真正把SpringBoot+Vue+MyBatis+MySQL这一整套串联起来,从前台展示到后台审批,从图片上传到权限控制,任何一个细节没处理好,都会在联调阶段跳出来捣乱。这套完整源码加部署流程,我前前后后改了三轮才跑利索,今天把核心设计、编码要点和部署过程整理出来,给准备做同类型Web系统的人当个参考。
做这种系统最怕的是什么?不是技术难,而是需求糊。没搞清“谁在用、怎么用、数据怎么流转”,代码写一半必然返工。所以我建议所有动手写代码的人,先跟着文章第一章把业务边界梳理清楚,再往下看后端和前端的具体实现,最后再碰部署。顺序对了,后面能少走一大半弯路。
1. 这个系统要解决什么问题:流浪宠物管理场景的业务梳理
1.1 从纸质台账到在线管理:救助场景的真实痛点
流浪宠物救助站的实际运营,和很多人想象的不太一样。我在做这个项目前特意和一位长期参与救助工作的志愿者聊过,发现大部分小型救助站点还在用纸质登记表记录宠物信息:捡到一只猫,手写一张卡片,贴在墙上;有人想领养,翻卡片、打电话、当面沟通,信息更新全靠嘴。遇到宠物被领养或者生病隔离,台账经常来不及改,外人看到的往往是过期信息。
这些痛点的本质,是信息不透明和流转低效。救助站需要一个对外展示的窗口,让普通人能浏览待领养宠物的情况;也需要一个内部管理入口,让工作人员快速登记、更新状态、审核领养申请。这正是流浪宠物管理系统最核心的定位:对外是展示大厅,对内是管理后台,中间用一条领养申请流程打通。
把这个定位想清楚了,系统的边界就自然划分出来了:
- 普通访客:浏览宠物列表、查看宠物详情、提交领养申请、查看公告。
- 管理员:维护宠物信息、上下架宠物、审核领养申请、发布公告、查看基础统计。
这两个角色对应的前端页面、后端接口、数据库权限都不一样,前后端分离架构在这里的优势就体现出来了——前端按角色拆页面,后端按资源拆接口,两边各自演进,互不拖累。
1.2 前台展示与后台管理如何分工:三类页面的组织方式
按照角色差异,我把前端页面分成三大类,每一类的开发思路完全不同:
第一类是访客可见的公开页面。宠物列表页要用卡片式布局展示照片和关键信息,详情页除了基本资料还要有领养理由填写表单;公告页则比较简单,列表加详情就行。这类页面的核心是视觉呈现和操作引导,要让人一眼看到宠物的可爱之处,愿意往下滑、点进去、填申请表。
第二类是登录后的用户中心页面。已登录用户可以查看自己的申请记录、申请状态和审核结果反馈。这个模块不用做得太复杂,但必须有,否则用户提交申请后完全不知道进度,体验会大打折扣。
第三类是管理员后台页面。宠物管理列表、申请审核列表、公告编辑、数据概览。后台页面的核心不是好看,而是高效——表格能分页筛选用就不搞花里胡哨的卡片,操作按钮要少而明确,审核结果必须有备注输入框。
三类页面的逻辑顺序是:先有数据展示,再有用户交互,最后才是后台管控。文章后续的数据库设计、接口定义都是围绕这条链路铺开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型背后的原因:SpringBoot+Vue+MyBatis+MySQL这套组合凭什么能打
2.1 后端框架:SpringBoot把配置时间挤出来做业务
这个系统选择SpringBoot作为后端基础,老实说不是因为它最炫酷,而是因为它最适合这类中小型管理系统的开发节奏。早期Spring MVC时代写一个Web项目,光是配置XML就要堆好几页:数据源配置、事务管理器、视图解析器、包扫描路径,任何一个环节写错都跑不起来。SpringBoot把这些问题用“约定优于配置”的方式解决了大半——引入起步依赖,写好application.yml,一个@SpringBootApplication启动类就能把项目跑起来。
对流浪宠物管理系统这种业务,SpringBoot另一层价值在于生态整合非常顺滑:集成MyBatis只需要引入mybatis-spring-boot-starter,集成文件上传只需要配一个MultipartFile参数,集成拦截器只需要实现HandlerInterceptor接口再注册到配置类里。代码结构清爽,新手也能看懂主干逻辑。
我用的版本是SpringBoot 2.7.x,搭配Java 8。有人会问为什么不上SpringBoot 3,原因很现实:3.x要求Java 17起步,而且很多依赖的兼容性调整会带来额外成本,对这类管理型系统收益不大。技术选型不是越新越好,而是够用、稳、资料多。
2.2 前端框架:Vue组件化让界面开发像拼积木
前端我用的是Vue 2.6加Element UI组件库。可能又会有人说Vue都出到3.x了为什么还用2,这里分享一个真实经验:如果项目需要快速交付、团队对Vue 2生态更熟悉,或者代码里有大量Element UI的中文资料可以参考,那Vue 2仍然是性价比极高的选择。但如果你是从零开始学,想兼顾长期维护,选Vue 3加Element Plus也没问题,核心思路是相通的。
Vue带来的最大改变是组件化。宠物卡片、状态标签、分页器、表单弹窗这些UI片段被封装成独立组件后,前台列表页和后台管理页可以复用同一套宠物卡片组件,只是数据来源不同。这比传统模板渲染那套写法省事太多——改一处组件样式,所有页面同步更新。
2.3 数据层:MyBatis和MySQL为什么比JPA更适合这套系统
数据层选MyBatis而不是JPA,这个选择我特意多说两句。JPA确实很方便,定义好实体类后,基本的CRUD都自动生成,但它强的地方也是它受限的地方——一旦查询条件复杂起来,比如宠物列表需要同时按分类、健康状况、状态、关键字筛选,用JPA写条件构造器,很容易写出绕来绕去的复杂代码,性能也不好预估。
MyBatis是半自动ORM框架,SQL由开发者自己掌控。表面上看多写了一些XML映射文件,但换来的是对每一条SQL的绝对掌控权。宠物筛选这种场景,用<where>加<if>动态标签就能写出清晰的条件拼装逻辑;在调用层传入一个包含多个可选条件的查询对象即可。MySQL 8则提供了更好的JSON支持和字符集处理,配合MyBatis的批处理和索引使用,完全支撑得起这类系统的数据访问需求。
用一句话总结技术栈的取舍:SpringBoot管装配,Vue管界面,MyBatis管SQL,MySQL管存储。每层都选最成熟、被验证最多的方案,整个系统的排错成本就会降到最低。
3. 数据库设计与后端编码:六张表背后的业务逻辑
3.1 六张核心表的结构规划
数据库设计是这类系统最不该省时间的一步。表结构没设计好,后面代码写得再漂亮也是空中楼阁。我梳理出了这六张表:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| tb_user | 用户表 | id、username、password、nickname、phone、role、create_time |
| tb_category | 宠物分类表 | id、name、remark |
| tb_pet | 宠物信息表 | id、name、category_id、gender、age、health_status、photo、description、status、create_time、update_time |
| tb_adoption | 领养申请表 | id、pet_id、user_id、reason、income_status、pet_experience、status、apply_time、audit_time、audit_note |
| tb_notice | 公告表 | id、title、content、create_time |
| tb_favorite | 收藏表 | id、pet_id、user_id、create_time |
其中tb_pet的status字段用整数表示:0是待领养,1是已领养,2是治疗中。这里有个设计细节值得注意——宠物状态和领养申请的审核状态不要混在一个字段里,否则会出现“宠物已领养但申请还在待审核”这种脏数据。宠物状态由管理员直接维护,申请审核状态只由审核流程更新,两个状态解耦,逻辑才不乱。
tb_adoption表的income_status和pet_experience也不是拍脑袋加的。领养审核不能只看“我很喜欢”一句话,审核人需要了解申请人的经济条件和养宠经验,这些字段就是为审核提供判断依据的。
3.2 后端分层与基础配置
后端项目结构我采用经典的四层分包:
code复制com.example.petadmin
├── controller # 接口层,接收前端请求
├── service # 业务层,处理逻辑
├── mapper # 数据访问层,MyBatis接口
├── entity # 实体类
├── config # 配置类
└── common # 统一返回结果、状态枚举
application.yml里有几个关键配置值得专门说明:
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/pet_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: 123456
servlet:
multipart:
max-file-size: 10MB
max-request-size: 20MB
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.petadmin.entity
configuration:
map-underscore-to-camel-case: true
serverTimezone=Asia/Shanghai这个参数特别重要。MySQL 8的默认时区是UTC,如果不显式指定,后端拿到的日期时间会差8小时,查出来的数据永远是“昨天”。map-underscore-to-camel-case: true则能把数据库的create_time自动映射到实体类的createTime属性,省掉一堆@Alias注解。
3.3 宠物条件筛选:动态SQL的正确写法
前台宠物列表页最常见的操作是条件筛选:按分类、按状态、按关键字搜索。这需求听着简单,但如果每条筛选条件拆一个接口,接口数量会爆炸。更好的做法是定义一个统一的查询DTO,然后用MyBatis的动态SQL处理可选条件。
核心的Mapper XML长这样:
xml复制<select id="findPetsByCondition" resultType="com.example.petadmin.entity.Pet">
SELECT p.*, c.name AS categoryName
FROM tb_pet p
LEFT JOIN tb_category c ON p.category_id = c.id
<where>
<if test="categoryId != null">
AND p.category_id = #{categoryId}
</if>
<if test="status != null">
AND p.status = #{status}
</if>
<if test="keyword != null and keyword != ''">
AND (p.name LIKE CONCAT('%', #{keyword}, '%')
OR p.description LIKE CONCAT('%', #{keyword}, '%'))
</if>
</where>
ORDER BY p.create_time DESC
</select>
这个写法的好处是:所有筛选走同一个方法,传入的条件对象里哪个字段有值就拼哪个条件,没有值就自动忽略。<where>标签会自动处理首条条件前的“AND”问题,不必担心SQL拼接出语法错误。
配套的查询对象大概长这样:
java复制public class PetQuery {
private Integer categoryId;
private Integer status;
private String keyword;
private Integer pageNum = 1;
private Integer pageSize = 10;
}
分页我直接用了PageHelper插件,底层原理是拦截器在SQL执行前自动拼接LIMIT语句,引入一个依赖就能用,非常省事。但有个注意点:PageHelper必须紧跟查询语句调用,中间不能穿插其他SQL操作,否则分页会失效。
3.4 领养申请状态流转:从0到2的审核逻辑
领养申请是整个系统最核心的业务流。状态我用整数字段表示:0待审核、1已通过、2已拒绝。流程非常明确:
- 用户在前台选择一只待领养的宠物,填写领养理由、经济情况、养宠经验,提交申请。
- 后台管理员看到待审核列表,查看申请详情和宠物状态,决定通过或拒绝。
- 通过申请后,宠物状态同步改为已领养;拒绝申请时,填写审核备注,宠物保持待领养状态。
这里最关键的逻辑在Service层。处理审核时不能只改tb_adoption的status,还必须联动宠物状态。我写了一段事务方法,确保两步操作要么都成功,要么都回滚:
java复制@Transactional
public void auditAdoption(Integer adoptionId, Integer auditStatus, String auditNote) {
Adoption adoption = adoptionMapper.selectById(adoptionId);
if (adoption == null) {
throw new RuntimeException("申请记录不存在");
}
Adoption updated = new Adoption();
updated.setId(adoptionId);
updated.setStatus(auditStatus);
updated.setAuditTime(new Date());
updated.setAuditNote(auditNote);
adoptionMapper.updateById(updated);
if (auditStatus == 1) {
Pet pet = new Pet();
pet.setId(adoption.getPetId());
pet.setStatus(1); // 已领养
petMapper.updateById(pet);
}
}
@Transactional必须加在public方法上,且不能在同类内部通过方法调用触发,否则事务失效。这个坑我后面详细说。
3.5 图片上传与静态资源映射
宠物没有照片,列表页就失去了灵魂,所以图片上传是硬需求。SpringBoot里处理文件上传很简单,Controller接收MultipartFile就行。真正容易踩坑的是后端的存储路径和访问路径映射。
我在配置类里写了一个资源映射:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String uploadPath = System.getProperty("user.dir") + "/upload/";
registry.addResourceHandler("/upload/**")
.addResourceLocations("file:" + uploadPath);
}
}
这样设计的效果是:前端上传照片后,后端把文件保存到项目根目录下的upload文件夹,并返回“/upload/xxx.jpg”这样的相对路径。当前端访问http://localhost:8080/upload/xxx.jpg时,SpringBoot会将请求转到实际的upload文件夹。这个路径在部署时尤其要注意——因为打包成jar后,项目根目录和你写代码时的目录可能不是同一个,后面的踩坑章节我会具体讲。
4. 前端编码实战:Vue工程化下的页面开发
4.1 路由规划与整体布局
前端的路由规划决定了用户怎么在系统里走动。我的路由结构分为两大块:
javascript复制const routes = [
// 访客页面
{ path: '/', component: Home, children: [
{ path: '', component: PetList },
{ path: 'pet/:id', component: PetDetail },
{ path: 'notice', component: NoticeList },
{ path: 'login', component: Login }
]},
// 后台管理页面
{ path: '/admin', component: AdminLayout, meta: { requiresAuth: true, role: 'admin' }, children: [
{ path: '', redirect: '/admin/dashboard' },
{ path: 'dashboard', component: Dashboard },
{ path: 'pets', component: AdminPetList },
{ path: 'adoptions', component: AdoptionAudit },
{ path: 'notices', component: NoticeAdmin }
]}
]
整体布局用的是经典后台式:顶部是导航栏,左侧栏是菜单,右侧是内容区。访客页面单独一套布局,管理后台一套布局,两者通过路由的children嵌套实现,互不干扰。
路由守卫也是必须的:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})
后端接口有拦截器做第一道校验,前端路由守卫做第二道校验。两层都拦一遍不是因为闲得慌,而是避免管理员页面在未登录状态下闪现后又被重定向,体验很差。
4.2 Axios请求封装与跨域处理
每个页面都直接写axios.get这种散装代码,项目维护起来会很痛苦。我把请求统一封装成一个模块,后端返回统一的数据结构{ code, message, data },前端在拦截器里统一处理错误:
javascript复制import axios from 'axios'
import { Message } from 'element-ui'
import router from '../router'
const service = axios.create({
baseURL: '/api',
timeout: 10000
})
service.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = token
}
return config
})
service.interceptors.response.use(
response => {
const res = response.data
if (res.code === 401) {
Message.error('登录状态已失效,请重新登录')
localStorage.removeItem('token')
router.push('/login')
return Promise.reject(new Error('unauthorized'))
}
return res
},
error => {
Message.error(error.message || '网络请求异常')
return Promise.reject(error)
}
)
export default service
开发环境的跨域问题是这一环节最容易卡住的点。前后端分离后,前端跑在8081端口,后端跑在8080端口,直接请求必然跨域。解决思路有两个:后端开启CORS配置,或者前端用代理转发。
我推荐前端代理方案,原因很实际——后端CORS配置在生产环境如果忘了关或者配错,等于给网络安全开了口子。而前端代理只作用于开发环境,生产环境用Nginx转发,干净利落。
Vue CLI项目的vue.config.js里配代理:
javascript复制module.exports = {
devServer: {
port: 8081,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: { '^/api': '' }
}
}
}
}
这样前端代码里的axios.post('/api/login', ...),在开发环境会自动转发到http://localhost:8080/login。前端感知不到“跨域”这件事,后端也不用开放CORS。
4.3 宠物列表页与详情页:组件拆分思路
列表页是实现难度最高也最显功底的部分。我把列表页拆成三个组件:SearchFilter(筛选条件栏)、PetCard(宠物卡片)、Pagination(分页条),页面通过组合它们完成整体功能。
PetCard组件接收一个pet对象和分类名称作为prop:
vue复制<template>
<div class="pet-card" @click="$router.push('/pet/' + pet.id)">
<el-image
:src="pet.photo"
fit="cover"
lazy
class="pet-photo"
/>
<div class="pet-info">
<h3>{{ pet.name }}</h3>
<span class="category">{{ pet.categoryName }}</span>
<div class="tags">
<el-tag v-if="pet.status === 0" type="success">待领养</el-tag>
<el-tag v-else-if="pet.status === 2" type="warning">治疗中</el-tag>
<el-tag v-else type="info">已领养</el-tag>
</div>
<p class="description">{{ pet.description | truncate }}</p>
</div>
</div>
</template>
注意el-image的lazy属性。如果一次性返回几十张宠物照片,浏览器可能直接卡死,懒加载能在图片进入视口时才请求资源,对列表页性能提升非常明显。
详情页则用el-descriptions组件来展示宠物的完整信息,下面接一个领养申请按钮。点击后弹出一个el-dialog对话框,里面是领养申请表。用户填写理由、经济情况、养宠经验后提交,提交成功就提示等待审核。
这里有个很容易忽略的产品细节:如果宠物状态已经是已领养或治疗中,申请按钮应该置灰或隐藏。前端要判断这个状态,后端接口同样要校验,处理申请时先查一下宠物当前状态,非待领养直接拒绝。前端的友好只是表象,后端的校验才是底线。
4.4 后台管理页:表格CRUD与状态改动
后台页面主要围绕Element UI的Table组件展开。宠物管理页用表格列出所有宠物,配上筛选栏和管理操作按钮。分类下拉选择、状态切换、照片预览、编辑弹窗、删除确认,这些交互写起来不复杂,但结构组织不好会显得很乱。
我的组织方式是:表格和表单分开成两个视图组件,共用同一个数据模型。列表负责展示,表单负责录入,两者通过父组件的状态联动。删除操作必须加确认弹窗,这个习惯很重要——后台操作者对数据的误删风险,比前台用户高得多。
领养审核页是后台最关键的页面。表格展示待审核申请,行内有一个“查看详情”按钮,点击展开右侧抽屉(el-drawer),里面显示申请人的填表内容和该宠物的信息,底部是两个审核按钮:通过、拒绝。拒绝时弹出输入框强制填写审核备注,审核通过则提示“宠物状态已更新为已领养”。
数据概览页则用四张图表卡片展示基础统计:宠物总数、待领养数、治疗中数、本月新增领养申请数。这些数据通过一个聚合查询接口返回,SQL用COUNT加GROUP BY就能搞定,不需要额外引入图表库的重量级方案。
5. 部署上线的完整链路:从打包到Nginx反向代理
5.1 后端打包与启动
本地开发跑得好好的,不代表服务器上能跑起来,部署是另一套完整的测试流程。后端这块,我用Maven打包成一个可执行的jar包,不再放到外部Tomcat里,因为SpringBoot内嵌了Tomcat,直接java -jar省心很多。
bash复制# 在项目根目录执行
mvn clean package -DskipTests
# 生成的jar包在 target/ 目录下
java -jar pet-admin-server.jar --server.port=8080
这个命令有几个需要注意的参数:-DskipTests跳过测试可以提升打包速度;如果服务器内存紧张,可以在启动命令里指定堆内存:
bash复制nohup java -Xms256m -Xmx512m -jar pet-admin-server.jar \
--spring.datasource.password=你的数据库密码 \
> app.log 2>&1 &
nohup和&配合后,关闭终端不会杀掉进程,日志写到app.log里方便排查问题。注意数据库密码如果包含&等特殊字符,要用单引号包住,否则会被Shell解析出错。
5.2 前端构建与Nginx配置
前端构建同样一句话:
bash复制npm run build
构建完成后会在dist目录生成一堆纯静态文件,这一堆文件扔给Nginx就行。Nginx配置是整个部署链路里最见功夫的部分,它需要同时干三件事:托管静态文件、转发API请求、映射上传目录。
一个典型的配置:
nginx复制server {
listen 80;
server_name your-domain.com;
# 前端静态资源
root /home/pet-app/dist;
index index.html;
# 解决Vue Router刷新404
location / {
try_files $uri $uri/ /index.html;
}
# 后端API代理
location /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 上传的图片资源
location /upload/ {
alias /home/pet-app/upload/;
}
}
这里解释三个配置的意义:
try_files $uri $uri/ /index.html:Vue Router如果是history模式,直接访问/pet/3这个地址时,服务端没有对应文件,不写这行会404。try_files把所有路由都回退到index.html,由前端路由接管页面。proxy_pass http://127.0.0.1:8080/;:注意结尾的斜杠。location /api/中带/api前缀代理到8080/时,Nginx会自动把/api前缀去掉,比如/api/pet/list实际会请求8080/pet/list。location /upload/:图片是从后端接口返回的相对路径/upload/xxx.jpg,Nginx不配置这个映射,图片就会404。
前端代码里的axios请求路径此时也要对应调整。如果开发时用的是/api前缀且代理转发时把前缀去掉,生产环境的请求路径依然是/api开头,Nginx那里再把前缀剥掉,两边的逻辑刚好对上,前端代码完全不用改。
5.3 数据初始化与项目验证
部署完成后,还差最后一步:初始化数据库。完整的部署教程一定包含这一步,不能依赖开发环境里那份数据。准备一个init.sql脚本,把建表语句和必要的初始数据(比如管理员账号、默认分类)都放进去,在MySQL里执行:
bash复制mysql -u root -p pet_system < /home/pet-app/init.sql
然后按这个顺序做上线前检查:
| 检查项 | 验证方式 | 预期结果 |
|---|---|---|
| 后端进程 | curl http://127.0.0.1:8080/api/pet/list |
返回JSON数据 |
| 前端页面 | 浏览器访问域名 | 展示宠物列表 |
| 图片资源 | 访问图片URL | 正常显示图片 |
| 领养流程 | 提交申请并登录后台审核 | 状态同步流转 |
| 权限控制 | 未登录访问后台页面 | 跳转登录页或返回401 |
这五个检查项全部通过,才说明整套系统算是真正跑通了。很多项目源码能够在本地跑起来,但部署到服务器就各种摆手,原因无非就是静态资源路径、代理配置、数据库初始化三个环节有一个没对。
6. 开发与部署中踩过的坑及排查记录
6.1 登录状态失效:拦截器与路由守卫的配合
这个坑在联调阶段反复出现。用户在登录页输入账号密码,接口返回登录成功,但跳转到后台页面后,马上又弹回登录页。排查链路是这样的:先看浏览器控制台,发现所有后台请求返回401;再开调试工具看请求头,发现Authorization字段根本不存在。
原因很简单:登录后把token存到了localStorage,但axios封装的请求拦截器读取的是另一个key,或者路由守卫在自己判断的时候没读token。比如登录时存的是token,拦截器读的是userToken,自然是徒劳。
排查这类问题的标准动作是:先把存和取的代码统一为同一个常量,然后打开F12的Application面板看localStorage里到底有没有值,最后看Network面板确认请求头是否携带。很多时候问题不在“存没存”,而在“读没读”。
另一层隐患是后端拦截器把/login、/register等公开接口白名单漏掉了,导致用户登录请求被拦截。写拦截器时一定要维护好白名单列表,用数组把公开路径列出来,用request.getRequestURI()去匹配。
6.2 图片路径404:从访问路径倒推到文件映射
图片上传后,数据库里存的是/upload/xxx.jpg,本地开发时访问一切正常,部署到服务器后访问图片却404。排查思路是倒推:浏览器请求的URL是什么,这个URL由谁来解析。
浏览器请求http://your-domain.com/upload/xxx.jpg,Nginx根据location /upload/配置转发或解析。如果Nginx配置里没有这个location块,请求会落到Vue Router的try_files规则上,被当作前端路由返回index.html,而不是图片文件,404是必然结果。
解决方式就是前面说的,在Nginx里配置location /upload/并指向后端存储的实际目录。如果请求从Nginx转发到后端,也可以在后端配置类里加资源映射,两条路二选一即可,不要两边同时配,否则会出现权限或路径冲突的怪问题。
6.3 中文乱码的两种出现位置与处理差异
中文乱码在Web系统里是老生常谈,但很多人不知道它的根源其实分两处。第一处是数据库连接层,数据库连接URL里要有characterEncoding=utf8,MySQL表的默认字符集也必须是utf8mb4,缺一不可。只配了连接URL但建表时用错了字符集,数据存进去依然是乱码。
第二处是响应输出层。后端返回JSON数据时,如果SpringBoot的spring.jackson默认配置下没有强制指定UTF-8,某些容器会对响应头做默认字符集补充,导致中文变问号。在Controller方法上标注@RequestMapping(produces = "application/json;charset=UTF-8"),或者在application.yml里配置:
yaml复制server:
servlet:
encoding:
charset: UTF-8
force: true
force: true的意思是强制所有响应都使用UTF-8编码,而不是只在未指定时生效。加上这个配置后,响应乱码的问题基本能杜绝。
6.4 SQL报错排查:多表联查时的字段重名误杀
写动态SQL时,多表联查很容易出现一个隐蔽的坑:连接的两张表都有同样的字段名,比如宠物表和申请单里都有status字段。如果SQL直接写WHERE status = 1,MySQL会报Column 'status' in where clause is ambiguous。
我第一次遇到的这类报错还不一样——MySQL没报错,但查出来的结果完全不对,因为MyBatis把两个status字段映射到了同一个实体属性上,后一个值覆盖了前一个。排查了很久才发现是联查字段名冲突。
解决办法是在SQL中给字段加表别名前缀:
sql复制SELECT p.*, a.status AS applyStatus
FROM tb_pet p
LEFT JOIN tb_adoption a ON p.id = a.pet_id
WHERE a.status = 0
同时,实体类里的属性名也要区分,比如Pet类里是status,而申请记录里是applyStatus,不能偷懒共用同一个字段名。这个教训在写任何联查SQL时都适用——凡是多表都有的字段,一律用别名明确区分。
另外提一句SQL日志的重要性。MyBatis在开发环境下一定要开启SQL日志输出,application.yml里配置logging.level.com.example.petadmin.mapper: debug,执行过程中MyBatis会打印完整的SQL语句和参数值。遇到任何查不出数据的问题,第一条线索一定是看这条日志,看传入的参数和拼出来的SQL是否和自己预期一致。我排查过的绝大多数数据层问题,都是靠这层日志锁定的。
最后说一点个人体会。这一类系统做完,最大的收获反而不是学会了多少新框架,而是建立了一套完整的全栈排查方法论:前端预览到异常,先定位请求发没发、参数对不对;后端看接口日志,再查SQL日志;数据层有问题,先重现需求再分析表和字段。任何异常都能在这条链路里找到原因,只不过经验不同,定位速度有快慢。
如果你准备复刻这套流浪宠物管理系统,建议先照着文章把数据库初始化好,再跑后端验证接口,最后跑前端联调。不要直接跳到最后一步去部署,线上的坑都是从本地一步步带过去的。过程中遇到任何新问题,记住一点:看日志,别瞎猜。日志不会说谎,排查过程的每一个判断都要有日志依据。
