ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot员工管理实战:从CRUD到文件上传的完整后端实现

Spring Boot员工管理实战:从CRUD到文件上传的完整后端实现 员工管理这个模块我在JavaWeb后端实战系列里已经写了整整两篇学习笔记这一篇是第三篇。前面两篇搞定了环境配置、登录鉴权、分页查询剩下新增、编辑、删除、文件上传这四件事才是员工管理真正日常的功能。如果你也是跟着黑马JavaWeb那套课程一路走过来的应该能感受到员工管理这个案例把后端开发里最常见的一整套CRUD操作全都串起来了前端传JSON、后端接收参数、Service处理业务、Mapper操作MySQL。这篇笔记就把我实际敲代码过程中踩过的坑、想明白的原理、以及最终的代码写法全部记录下来给后面做同类功能的人一个参考。1. 员工管理第三篇先给这轮实战画个范围1.1 前两篇做了什么这一篇又做了什么前两篇主要解决的是“员工列表怎么展示”的问题。第一遍笔记大概记录了如何在IDEA里跑通Spring Boot项目连上MySQL配置好MyBatis和数据库连接池第二篇重点写了分页查询、登录校验、拦截器以及前后端分离开发时跨域请求的处理方式。这些都属于员工管理模块的“读”操作。到了第三篇重心就完全转移到“写”操作上了。员工管理后台最常用的功能其实不是看列表而是以下几个动作新增员工录入职信息上传头像给一个初始密码编辑员工把手机号、岗位、入职日期这些信息改掉单个删除比如员工离职了要把数据移除批量删除比如清理一批测试数据或者部门整体调整时批量处理。这些操作在页面上看起来就是几个按钮的事但后端要处理的问题不少。比如新增员工时密码要不要加密、创建时间谁来维护、部门ID传过来是字符串还是整数、编辑页面回显时字段能不能对得上、批量删除的SQL怎么写才不报错。这篇笔记就是把这些问题一一拆开说清楚。1.2 本篇涉及的接口清单在动手写代码之前我习惯先列一张接口清单理清路径、请求方式、参数和返回结果。这样写Controller的时候思路会清晰很多不至于写着写着就东一块西一块。功能请求方式路径主要参数说明新增员工POST/api/empEmpDTOJSON返回新增后的员工信息修改员工PUT/api/emp/{id}EmpDTOJSON按ID更新员工资料单个删除DELETE/api/emp/{id}路径参数id逻辑删除或物理删除批量删除DELETE/api/emp/batch?ids1,2,3请求参数ids一次删除多条记录上传头像POST/api/uploadMultipartFile返回图片访问URL员工回显GET/api/emp/{id}路径参数id修改时回填表单数据注意我并没有把“查询列表”写到这张表里因为那个功能在第二篇已经处理过了。第三篇如果再把分页查询写一遍内容就会重复。实际项目开发里我们也是围绕一个页面操作去列接口而不是把所有接口一股脑堆出来。1.3 开发环境和项目骨架我的开发环境是这样的JDK 8Spring Boot 2.7.xMyBatis 2.xMySQL 8.0前端用的是Vue2加Element UI整个项目是典型的前后端分离结构。IDEA里通过Maven引入依赖启动类直接跑Application即可。项目目录大致是这样的结构com.example.emp ├── controller │ ├── EmpController.java │ └── UploadController.java ├── service │ ├── EmpService.java │ └── impl │ └── EmpServiceImpl.java ├── mapper │ ├── EmpMapper.java │ └── EmpMapper.xml ├── pojo │ ├── Emp.java │ ├── EmpDTO.java │ └── Result.java └── config └── WebConfig.java这里的配置有一个细节值得说MyBatis的驼峰映射我是在application.yml里打开的这样数据库字段是下划线命名、Java属性是驼峰命名时可以自动匹配省掉写resultMap的麻烦。配置就一行mybatis: configuration: map-underscore-to-camel-case: true这个配置对员工管理这种字段比较多的表尤其管用后面写实体类和Mapper的时候会明显感觉到省了很多事。2. 员工表的设计与实体映射命名规范能省一半事2.1 建表语句与字段设计的思路员工表算是业务系统里最有代表性的表之一。字段不算特别多但类型很全字符串、整数、日期、外键关联、状态标记都有。我实际用的建表语句是这样CREATE TABLE emp ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 员工ID, username VARCHAR(20) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(64) NOT NULL COMMENT 密码, name VARCHAR(10) NOT NULL COMMENT 姓名, gender TINYINT NOT NULL COMMENT 性别, 1男 2女, phone VARCHAR(11) DEFAULT NULL COMMENT 手机号, job TINYINT DEFAULT NULL COMMENT 职位, 1班主任 2讲师 3学工主管 4咨询师, salary DECIMAL(10,2) DEFAULT NULL COMMENT 薪资, image VARCHAR(500) DEFAULT NULL COMMENT 头像路径, entry_date DATE DEFAULT NULL COMMENT 入职日期, dept_id INT DEFAULT NULL COMMENT 关联部门ID, create_time DATETIME DEFAULT NULL COMMENT 创建时间, update_time DATETIME DEFAULT NULL COMMENT 更新时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT员工表;这里有两个设计上的点要说一下。第一点是gender和job这类“看起来像业务字典”的字段我直接用TINYINT存数字而不是存中文。原因很简单数字在前端展示时可以映射成对应的标签1对男2对女存中文会导致后续需求变化时比如“男”改成“男性”需要写SQL去批量更新数据非常被动。第二点是password字段的长度我留了64。如果你只存明文密码20个字符完全够用。但实际开发中密码都会做加密存储MD5加密结果是固定32位SHA-256是64位BCrypt的结果更长。所以密码字段留64是比较稳妥的做法避免后面改了加密方式还得改表结构。2.2 Emp实体类的写法与自动驼峰映射实体类我用Lombok简化了getter和setter整体长这样Data public class Emp { private Integer id; private String username; private String password; private String name; private Integer gender; private String phone; private Integer job; private BigDecimal salary; private String image; private LocalDate entryDate; private Integer deptId; private LocalDateTime createTime; private LocalDateTime updateTime; }注意几个细节。第一entry_date对应的Java属性是entryDate这是靠驼峰映射自动转的。如果没有打开map-underscore-to-camel-caseMyBatis查询出来的结果里entryDate就永远是null而且不会报任何错排查起来很阴间。第二日期类型的处理。entry_date在表里是DATE只精确到天所以实体类用LocalDatecreate_time和update_time是精确到时分秒的用LocalDateTime。如果都用Date类型也不是不行但后面涉及日期格式化时就要自己处理时区问题不如LocalDate和LocalDateTime省心。第三password这个字段在查询列表、回显给前端时通常是不需要返回的。我们不会把员工的密码hash也发给前端。常见处理方式是在实体上加JsonIgnore注解JsonIgnore private String password;这样Jackson在做JSON序列化时会自动跳过这个字段。前端调用查询接口时返回的JSON里就不会出现密码相关内容。2.3 时间字段的交给数据库还是交给Java这是一个在员工管理里反复遇到的问题create_time和update_time的新增、更新操作应该由谁来完成第一种做法是在Java代码里手动设置emp.setCreateTime(LocalDateTime.now()); emp.setUpdateTime(LocalDateTime.now());第二种做法是利用MySQL的DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP让数据库自动维护create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP我实际项目中用的是第二种也就是数据库维护时间。原因很实际不管后端谁写接口只要执行INSERT或UPDATE语句时间字段都会自动更新不需要每个Service方法里都记得set一遍。它省掉的是重复代码避免的是“新增员工的时候忘了填create_time结果列表排序全乱了”这种低级错误。但要注意如果项目里的SQL是批量导入或者直接走工具执行的ON UPDATE CURRENT_TIMESTAMP在批量更新多条记录时所有记录的update_time都会变成同一个这可能不是我们想要的效果。如果业务上需要精确到每一条记录各自的更新时间那还是老老实实在代码里为每一条数据单独设置时间。3. Controller层接口设计RESTful路径和统一返回结构3.1 为什么定义Result统一返回类前后端分离的项目里接口返回值必须有一个统一的格式。前端拿到这个格式后只需要判断code是否为1或200就能决定是渲染数据还是弹出错误提示不用每个接口单独写一套处理逻辑。我当时是从黑马课程里学到的Result类自己又改了一版支持泛型传数据Data public class ResultT { private Integer code; // 1表示成功, 0表示失败 private String msg; // 提示信息 private T data; // 数据 public static T ResultT success() { return new Result(1, success, null); } public static T ResultT success(T data) { return new Result(1, success, data); } public static T ResultT error(String msg) { return new Result(0, msg, null); } }这个类本身很简单但它带来一个额外的好处所有Controller方法的返回类型都可以统一写成Resultxxx前端对接成本很低。不管是查询员工列表、新增员工还是上传头像返回的JSON外层结构都是一样的。3.2 新增、修改、删除的接口定义员工管理第三篇的Controller代码我把它控制在最短的形态方便理解RestController RequestMapping(/api/emp) public class EmpController { Autowired private EmpService empService; PostMapping public ResultEmp add(RequestBody EmpDTO empDTO) { Emp emp empService.addEmp(empDTO); return Result.success(emp); } PutMapping(/{id}) public ResultEmp update(PathVariable Integer id, RequestBody EmpDTO empDTO) { Emp emp empService.updateEmp(id, empDTO); return Result.success(emp); } DeleteMapping(/{id}) public ResultVoid deleteById(PathVariable Integer id) { empService.deleteById(id); return Result.success(); } DeleteMapping(/batch) public ResultVoid deleteByIds(RequestParam ListInteger ids) { empService.deleteByIds(ids); return Result.success(); } GetMapping(/{id}) public ResultEmp getById(PathVariable Integer id) { return Result.success(empService.getById(id)); } }这里有几个容易搞混的细节。新增用PostMapping不带路径请求URL是/api/emp修改用PutMapping(/{id})直接PUT到具体资源上删除用DeleteMapping。这套RESTful风格一开始不太习惯尤其是习惯了所有接口都用GetMapping加save/update/delete这种动词命名的人。但用熟了以后前端同学看一眼方法名就能猜到请求方式团队沟通成本明显降低。3.3 PathVariable和RequestParam的使用边界在实际联调中我最开始分不清PathVariable和RequestParam导致接口报405或者“参数不匹配”。后来总结出一条经验参数是URL路径的一部分比如/api/emp/5里的5用PathVariable参数跟在问号后面比如/api/emp/batch?ids1,2,3里的ids用RequestParam前端提交的数据是JSON放进请求体用RequestBody接收并且要配一个DTO类。这三种接收方式在同一个Controller里经常混着出现好在Spring Boot对它们的区分很严格。如果PathVariable和RequestParam用混了最常见的报错就是“Required request parameter ‘xxx’ is not present”排查时先检查注解是不是写对了。另外RequestParam默认要求参数必传如果想允许可不传要手写required false。4. Service层才是重头戏新增和编辑的业务规则4.1 新增员工默认密码、创建时间、校验逻辑Controller层只是薄薄的一层真正的逻辑都在Service里。新增员工看起来就是往表里插一条数据但实际要处理三个问题默认密码、密码加密、校验用户名重复。我的ServiceImpl写法是这样Override public Emp addEmp(EmpDTO empDTO) { if (empService.findByUsername(empDTO.getUsername()) ! null) { throw new BusinessException(用户名已存在); } Emp emp new Emp(); BeanUtils.copyProperties(empDTO, emp); // 默认密码为123456MD5加密后存储 emp.setPassword(DigestUtils.md5DigestAsHex(123456.getBytes())); // create_time和update_time由数据库自动维护 empMapper.insert(emp); return emp; }DigestUtils.md5DigestAsHex是Spring自带的工具类不用额外引入依赖。直接在代码里写死123456是挺粗暴的做法但员工管理系统的真实场景通常就是新员工的初始密码由管理员统一设置员工第一次登录后再要求改密。所以这里的“默认密码”其实是一个业务约定不是偷懒。为什么我提到校验用户名重复因为emp表里username字段我建了唯一索引所以即使Service层不校验数据库也会抛DuplicateKeyException。但问题在于异常抛到前端前端拿到的JSON格式是Spring Boot默认的错误页不是我们约定的Result结构。所以更稳妥的做法是Service层提前查一次查到就抛自定义业务异常再配合全局异常处理器返回统一格式的Result.error(...)。4.2 编辑员工回显和保存为什么必须查全量编辑员工接口对应的是前端“点击编辑按钮弹窗回显数据修改后提交保存”的流程。最容易被忽略的一点是保存时必须使用更新后的全量字段去执行UPDATE而不是只更新前端传过来的字段。比如前端只传了name和phone而后端UPDATE语句是UPDATE emp SET name?, phone? WHERE id?那这条记录的其他字段会保留原值好像没有问题。可如果哪天产品说“编辑员工时职级变了薪资不变但薪资应该保留”而前端因为页面没有渲染薪资输入框就没传salary字段后端如果用的是全量更新的写法salary就会被更新成NULL。所以我的做法是编辑保存时前端弹窗会回显该员工的全部基本信息提交时也提交全量字段。DTO里所有字段都允许为空Service里再做一次BeanUtils.copyProperties把非空字段拷贝到Emp对象中。回显接口getById也要注意这个接口返回的Emp包含了password字段的hash值。虽然我在实体类上加了JsonIgnore前端回显数据里看不到密码但这不属于加密只是隐藏。真正的密码修改要单独走“修改密码”接口接收旧密码和新密码校验通过后再更新。4.3 DTO与实体分离避免把参数直接扔给MyBatis在很小的项目里有人会直接把前端传来的JSON参数交给实体类Emp去接收Controller形参写成Emp emp。这确实能跑但有个隐患前端如果多传了一个你实体类里没有的字段Spring Boot默认会忽略掉不会报错如果前端传了一个id字段并且这个id不是它该改的那条数据麻烦就来了。我在这系列笔记里养成的习惯是定义一个EmpDTO字段和Emp实体几乎一样但它是专门用来接收前端请求参数的。Data public class EmpDTO { private String username; private String password; private String name; private Integer gender; private String phone; private Integer job; private BigDecimal salary; private String image; private LocalDate entryDate; private Integer deptId; }接收参数用DTO操作数据库用实体类。Service层里通过BeanUtils.copyProperties(dto, emp)做转换代码没有多多少但字段边界清晰了前端永远碰不到createTime、updateTime这种后端维护字段。这个习惯在项目变大后收益很明显。5. 删除操作没你想的那么简单单删、批量删与事务5.1 单个删除的SQL和接口返回删除员工的第一反应是DELETE FROM emp WHERE id ?。接口层级上Controller已经写好了deleteByIdService的实现也很简单Override public void deleteById(Integer id) { empMapper.deleteById(id); }这里有个细节删除之后前端要刷新列表所以接口返回值最好是一个固定的Result.success()不要返回被删除的Emp对象。返回空成功比返回数据更符合业务直觉。另外删除操作要判断影响行数。如果传入一个不存在的idMyBatis的deleteById不会报错只是影响行数为0。业务上管理员点击删除时员工已经不存在了直接返回成功也能接受。但如果后续有人用脚本批量调用这个接口删除不存在的数据也返回成功就会造成“我明明删了怎么数据还在”的困惑。稳妥做法是Service里检查affectedRows 0时抛异常提示“员工不存在或已删除”。5.2 批量删除的foreach写法批量删除的SQL有多种写法。最直观的是用foreach拼出IN (1,2,3)int deleteByIds(ListInteger ids);XML里这样写delete iddeleteByIds DELETE FROM emp WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /delete前端调接口时传的参数是ids1,2,3Controller用RequestParam ListInteger ids接收Spring会自行把逗号分隔的字符串解析成List。这里有一个非常容易踩的坑如果前端一次传几百上千个IDIN后面的列表太大MySQL的SQL语句长度可能超限也会影响查询性能。实际业务上员工批量删除一般不会超过几十条前端通常也会限制最大勾选数量所以这个方案够用了。5.3 删除员工时的数据关联和事务边界员工表不是孤立的它通过dept_id关联部门表。如果业务上存在“员工属于某个部门部门不能随意删除”的约定后端删除员工的时候不需要动部门表。但反过来如果员工的头像图片是存在服务器本地磁盘的那么删除员工时图片要不要一并删除我实际处理时选择了“先删数据库记录再删图片文件”。因为如果先删文件、后删数据库数据库删除失败的话页面上会显示数据还在但图片已经没了相当于数据损坏。反过来如果数据库删除成功、图片文件删除失败顶多服务器上多一个没人引用的孤儿文件定期清理就行。如果这里涉及多张表一起操作比如删除员工的同时还要删除员工的考勤记录、绩效记录那Service方法上必须加Transactional。Spring Boot里开启事务就是加一个注解的事但要注意事务默认只在RuntimeException时回滚如果代码里手动catch了异常而没有重新抛出事务就不会生效。这点在批量删除场景里尤其容易踩因为批量删除经常要遍历调用单删逻辑一旦其中一条因为外键约束失败前面的删除可能已经提交了。6. 头像上传MultipartFile、UUID重命名和静态资源映射6.1 文件上传的后端处理流程员工管理里通常会给员工设置头像。前端是一个上传控件选完图片后直接调用后台上传接口拿到图片URL后再把URL作为image字段和其他员工信息一起提交。上传接口的Controller是这样PostMapping(/api/upload) public ResultString upload(MultipartFile image) throws IOException { String originalFilename image.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String newFileName UUID.randomUUID().toString() ext; image.transferTo(new File(D:/upload/ newFileName)); return Result.success(/images/ newFileName); }这里有两个关键点。第一文件名必须用UUID重命名。用户上传的图片名可能是“张三.jpg”“头像(最终版).png”如果按原名保存一方面可能文件名重复会互相覆盖另一方面文件名里带中文和括号放到URL里很可能会被转义或者报错。UUID原后缀名的方式能避免几乎所有文件名冲突。第二图片要保存到项目外部目录而不是项目内部的static/images。我之前犯过把上传目录放在项目resources下的错结果每次用IDEA重启项目上传的图片就不见了。因为Spring Boot项目重启时会重新编译resources目录。所以后来统一改成保存到D:/upload或者Linux部署环境下的/data/upload图片和代码分离。6.2 存储路径选择和URL拼接的坑图片保存到本地磁盘后前端要能通过URL访问还需要配置静态资源映射。Spring Boot的配置方法是Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/images/**) .addResourceLocations(file:D:/upload/); } }这样配置后请求http://localhost:8080/images/xxx.jpg时会去D:/upload/xxx.jpg找文件。很多人在这个环节会拼错地址尤其是addResourceLocations必须以file:开头并且路径结尾要有斜杠。如果漏了file:Spring会把它当成classpath路径去解析然后报404。另外前端拿到的应该是完整的可访问URL还是只拿一个相对路径我的做法是后端只返回相对路径/images/xxx.jpg前端在展示时自己拼上域名或IP前缀。这样做的原因是开发环境是localhost:8080部署环境可能是https://emp.example.com域名前缀本来就不同如果后端写死绝对地址换环境就得改代码。相对路径在前后端分离项目里最灵活。6.3 上传文件大小限制与格式校验Spring Boot默认限制单次请求文件大小是1MB如果员工上传一张手机拍的高清照片大概率直接报MaxUploadSizeExceededException。需要修改配置文件spring: servlet: multipart: max-file-size: 5MB max-request-size: 10MB除了大小限制还要校验文件类型。不能只靠前端做图片格式过滤因为接口是可以被人用Postman直接调的。后端校验我习惯这样做if (image null || image.isEmpty()) { return Result.error(上传文件不能为空); } String originalFilename image.getOriginalFilename(); if (!originalFilename.matches(.*\\.(jpg|jpeg|png|gif)$)) { return Result.error(仅支持jpg/png/gif格式图片); }这里用了正则做后缀匹配。正规项目还会去校验文件的MIME类型或者实际读取文件头但员工管理这种内部系统的头像上传后缀校验已经能挡住99%的误操作。7. 前后端联调中反复踩的5个坑7.1 JSON字段名不匹配导致的400/500新增员工时前端提交的JSON如果是{ entry_date: 2024-03-01 }而后端DTO字段是entryDateSpring的Jackson默认是“严格匹配模式”找不到entry_date字段时会忽略它或者直接报错。最终结果就是员工的入职日期保存不了甚至整个接口400。解决办法有两个。要么前端严格按照后端DTO字段名来传要么在后端配置Jackson将下划线自动转驼峰spring: jackson: property-naming-strategy: SNAKE_CASE实际项目里大多数前端团队都习惯于后端DTO字段是什么名字就传什么名字而不是让后端做名字转换。所以我的建议是和后端同学约定好接口字段名以接口文档为准。接口文档里写了entryDate前端就传entryDate不要在联调时临时改字段名。7.2 日期格式反序列化失败前端传入职日期时最常见的是2024-03-01这种字符串后端DTO字段如果是LocalDateSpring Boot默认可以解析这种格式。但如果是LocalDateTime字段比如员工上下班打卡时间前端传的是2024-03-01 12:00:00Spring Boot默认的解析格式并不包含空格而是ISO的2024-03-01T12:00:00于是会直接报DateTimeParseException。解决方式是在DTO的日期字段上加上JsonFormat注解JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime createTime;我把员工表里的create_time设计成数据库自动维护所以新增和编辑接口基本不涉及这个坑。只有需要接收前端传时间的接口比如录入员工入职时间才必须显式声明格式。踩过一次以后我现在写DTO凡是出现日期类型第一时间先问自己前端会传什么格式后端要返回什么格式7.3 RequestParam必填导致的400批量删除的接口路径是DELETE /api/emp/batch?ids1,2,3。如果前端调用时ids参数为空比如勾选了0条数据还点了批量删除按钮RequestParam ListInteger ids会直接401或400提示参数缺失。更好的做法是后端给一个默认空值并做业务校验DeleteMapping(/batch) public ResultVoid deleteByIds(RequestParam(required false) ListInteger ids) { if (ids null || ids.isEmpty()) { return Result.error(请至少选择一条数据); } empService.deleteByIds(ids); return Result.success(); }这个“默认必填的参数”坑其实反映了前后端约定的一个原则接口尽量通过required false加服务端二次校验不要寄希望于“前端一定会传对”。真正健壮的后端接口应该能容忍前端的异常调用并返回友好的提示信息。7.4 跨域配置和按钮重复提交前后端分离项目里前端跑在http://localhost:5173后端跑在http://localhost:8080浏览器会阻止跨域请求。黑马课程里在配置类里加了CORS映射Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*); }这里有两个容易忽略的点。第一allowedOrigins写的是具体域名不能用*。如果允许所有来源跨域等于任何一个恶意页面都能调用你后端的接口这在员工管理系统里是绝对不能接受的。第二按钮重复提交问题。新增员工时用户快速点两次“保存”按钮后端会收到两次POST请求结果就是员工表里出现两条相同用户名的数据。即使Service层做了用户名查重依然存在“两次请求几乎同时到达第一次查重还没完成第二次查重也通过了”的并发窗口。这个问题的彻底解决办法是后端做接口幂等性处理比如用Redis记录userId:emp:add这个Key第一次请求设置Key第二次请求发现Key已存在就拒绝。代码写起来不算复杂但对员工管理这个体量的系统可能有点重。更实际的做法是前端在提交按钮的loading状态没结束前禁掉按钮双保险再加一个用户名唯一索引兜底。唯一索引是最可靠的兜底数据库层面能保证不可能插入重复用户名。7.5 前端显示的时间格式问题数据库里的entry_date是Date类型后端返回给前端的JSON经过Jackson序列化后默认格式是带T的ISO格式比如2024-03-01T00:00:00。前端直接渲染到表格里用户看到的是“2024-03-01T00:00:00”这显然不是想展示的“2024-03-01”。处理方式有两种。第一种是后端在字段上加JsonFormat(pattern yyyy-MM-dd)控制序列化格式第二种是前端在渲染层用dayjs或moment来做格式化。我自己的习惯是后端把格式统一处理到位因为后端最清楚每个字段的语义而且前端渲染层不应该为每个字段都写格式化工具。如果你跟我一样是边学JavaWeb边做实战这个员工管理模块做到这里其实已经覆盖了后端开发日常中相当核心的一部分单表CRUD、参数接收、接口设计、文件上传、异常处理、前后端联调。把第三篇里这几个接口完整跑通一遍比在教程里看十遍代码都管用。真正动手写的时候你会发现那些注解的参数要不要写、路径变量和请求参数的区别、日期格式怎么统一每一个小细节都能把你卡住半天。这些笔记里写下的坑都是我实际踩过以后才总结出来的后面如果再做类似的功能顺着这套思路下来会顺手很多。
返回列表