知识点思维导图
29 个知识节点
Java(30) - 实战 —— 从零开发一个完整接口(字典管理)
读完后,你应能完成以下任务:
- 绘制“Java(30) - 实战 —— 从零开发一个完整接口(字典管理) / 需求分析(写代码前先想清楚)”的关键对象与数据流,解释“后端也一样,先把"接口清单"列出来。”,并用源码位置、日志或 Trace 标注证据。
- 为“Java(30) - 实战 —— 从零开发一个完整接口(字典管理) / 设计表结构(数据库是地基)”设计正常与异常输入,验证“【前端类比】这相当于你在 TS 里定义一个 interface DictItem,只不过它落在 MySQL 里,是"持久化的类型定义"。”,输出首个偏差位置与回归测试结果。
- 实现“Java(30) - 实战 —— 从零开发一个完整接口(字典管理) / demo 的分层架构(先看清全貌)”的最小代码或配置,检验“记住这个分工,后面每写一层你都知道"它该干嘛、不该干嘛"。”,输出命令、结果与 Diff,并说明不适用边界。
前 29 课讲的都是"零件",这一课我们把它们拼成一台能跑的车:手把手从需求到联调,完整开发一个"字典管理"接口。看完你就能独立交付一个 demo 风格的接口了。
前面我们学了环境、语法、HTTP 生命周期(见第 4 课)、类与对象、集合、异常、注解。这些知识单独看都懂,但真到工作里,你要面对的是"产品给了个需求,你怎么从空白文件夹一路写到前端能调通"。这一课就完整走一遍。
我们要做的需求很常见:字典管理。所谓"字典",就是系统里那些可枚举的配置项,比如"车辆类型:1=货车 2=挂车 3=厢式车""维保状态:1=待审 2=审核中 3=已完成"。前端下拉框、状态标签全靠它。
一、需求分析(写代码前先想清楚)
【前端类比】这一步就像你接到一个页面需求,先不急着写 <template>,而是先问:要展示什么字段?要调几个接口?数据结构长啥样?后端也一样,先把"接口清单"列出来。
字典管理最基本的四件事,就是经典的 CRUD(增删改查):
| 操作 | 前端动作 | 接口 | HTTP 方法 |
|---|---|---|---|
| 查列表 | 进页面拉取字典项 | /dict/list |
GET |
| 查详情 | 点某一项看详情 | /dict/getById |
GET |
| 新增 | 填表单点保存 | /dict/add |
POST |
| 修改 | 编辑后保存 | /dict/update |
POST |
| 删除 | 点删除按钮 | /dict/delete |
POST |
数据长什么样?一条字典项至少需要这些字段:
字典编码 dictCode —— 属于哪个分组,如 "vehicle_type"(车辆类型)
字典键 dictKey —— 值,如 "1"
字典名 dictName —— 显示文本,如 "货车"
排序 sort —— 下拉框里的顺序
状态 status —— 1启用 0禁用
需求清楚了,开始设计表。
二、设计表结构(数据库是地基)
【前端类比】这相当于你在 TS 里定义一个 interface DictItem,只不过它落在 MySQL 里,是"持久化的类型定义"。
-- 字典项表:存储系统所有可枚举配置
CREATE TABLE `sys_dict` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键id',
`dict_code` VARCHAR(64) NOT NULL COMMENT '字典编码(分组), 如 vehicle_type',
`dict_key` VARCHAR(64) NOT NULL COMMENT '字典键(值), 如 1',
`dict_name` VARCHAR(128) NOT NULL COMMENT '字典名(显示文本), 如 货车',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序号, 越小越靠前',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1启用 0禁用',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_dict_code` (`dict_code`) -- 按编码查列表很频繁, 建索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字典项表';
两个关键决策,解释下 WHY:
dict_code建索引:前端最常用的查询是"给我vehicle_type这一组的所有项",等于WHERE dict_code = ?。这种高频条件查询不建索引会全表扫描,数据量大了会慢。- 字段命名用下划线
dict_code:MySQL 习惯下划线,Java 习惯驼峰dictCode。两者的映射 MyBatis-Plus 会自动帮我们做(下面会讲)。
【数据库列名 vs Java 字段名对照】
| MySQL(下划线) | Java(驼峰) | 说明 |
|---|---|---|
dict_code |
dictCode |
自动映射 |
create_time |
createTime |
自动映射 |
id |
id |
一致 |
三、demo 的分层架构(先看清全貌)
在 demo 里一个模块(如 demo-basic)通常拆成三个 maven 子模块,对应你前端项目里的"分层目录":
demo-basic-common ← 放 In(入参) / Out(出参) DTO ≈ 前端的 types/ 接口类型定义
demo-basic-service ← 放 entity / Mapper / ServiceImpl ≈ 前端的 api/ + store/ 数据层
demo-basic-biz ← 放 Controller ≈ 前端的 router + 页面入口
一个请求进来的完整链路(第 4 课讲过的五站,这里聚焦后端三层):
前端 axios
│ POST /dict/add { dictCode, dictKey, dictName }
▼
┌──────────────────────────────────────────────┐
│ Controller 收 In, 调 Service, 包成 R 返回 │ demo-basic-biz
│ DictController.add(DictAddIn in) │
└───────────────────┬──────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Service 业务逻辑(校验/组装/事务) │ demo-basic-service
│ DictServiceImpl.add(...) │
└───────────────────┬──────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Mapper 只管和 MySQL 打交道 │ demo-basic-service
│ DictMapper extends BaseMapper<Dict> │
└───────────────────┬──────────────────────────┘
▼
MySQL sys_dict 表
记住这个分工,后面每写一层你都知道"它该干嘛、不该干嘛"。下面从最底层往上写。
四、写 Entity(数据库表的 Java 镜像)
Entity 就是数据库一行记录的 Java 对象。参考 demo 示例的 Organization 实体(demo-basic-service/.../entity/Organization.java),它长这样:
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("organization") // 对应哪张表
public class Organization extends Model<Organization> {
@TableId
private Integer id;
private Integer groupId;
private String organizationName;
// ...
}
照葫芦画瓢,我们的 Dict 实体:
package com.example.platform.basic.service.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 字典项表实体: 一个对象 = sys_dict 表里的一行记录
*/
@Data // Lombok: 自动生成 getter/setter/toString(见第8课)
@EqualsAndHashCode(callSuper = true)
@TableName("sys_dict") // 告诉 MyBatis-Plus 这个类映射到 sys_dict 表
public class Dict extends Model<Dict> {
/** 主键id */
@TableId // 标记主键, MyBatis-Plus 据此做更新/删除定位
private Integer id;
/** 字典编码(分组), 如 vehicle_type */
private String dictCode; // 注意: 库里是 dict_code, 这里驼峰, 自动映射
/** 字典键(值), 如 1 */
private String dictKey;
/** 字典名(显示文本), 如 货车 */
private String dictName;
/** 排序号, 越小越靠前 */
private Integer sort;
/** 状态 1启用 0禁用 */
private Integer status;
/** 创建时间 */
private LocalDateTime createTime;
/** 更新时间 */
private LocalDateTime updateTime;
}
【前端类比】Entity 约等于你 TS 里贴着数据库的 interface DictRow。@TableName 像是告诉 ORM "这个类型对应哪张表",@Data 帮你省掉手写 getter/setter(JS 里属性天生可读写,Java 需要这个)。
| 注解 | 作用 | 前端类比 |
|---|---|---|
@TableName("sys_dict") |
类 ↔ 表 映射 | ORM 的 @Entity({ table }) |
@TableId |
标记主键字段 | 主键约定 |
@Data |
生成 getter/setter | TS 属性默认可读写 |
五、写 Mapper(数据访问层,最省力的一层)
demo 用 MyBatis-Plus,它的精髓是:让 Mapper 接口继承 BaseMapper<T>,CRUD 的 SQL 它全自动给你生成,你一行 SQL 都不用写。看 demo 示例的 OrganizationMapper:
public interface OrganizationMapper extends BaseMapper<Organization> {
// 只有"非标准"的复杂查询才需要自己声明方法
IPage<Organization> getOrganizationPage(Page page, @Param("organization") Organization organization);
}
我们的 DictMapper 更简单,CRUD 全靠继承:
package com.example.platform.basic.service.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.platform.basic.service.entity.Dict;
/**
* 字典 Mapper: 继承 BaseMapper 即白拿一整套 CRUD 方法
* 不需要写任何 SQL, MyBatis-Plus 运行时自动生成
*/
public interface DictMapper extends BaseMapper<Dict> {
// 暂时不需要自定义方法, 标准 CRUD 已够用
}
继承 BaseMapper<Dict> 后,你白拿了这些方法(无需实现):
| 方法 | 作用 | 等价 SQL |
|---|---|---|
insert(dict) |
插入一行 | INSERT INTO sys_dict ... |
selectById(id) |
按主键查 | SELECT * WHERE id=? |
selectList(wrapper) |
条件查列表 | SELECT * WHERE ... |
updateById(dict) |
按主键改 | UPDATE ... WHERE id=? |
deleteById(id) |
按主键删 | DELETE WHERE id=? |
【前端类比】这就像你用 Prisma:定义好 model,prisma.dict.findMany() / create() / update() 全都白送,不用手写 SQL。BaseMapper<Dict> 里的泛型 <Dict>(见第 6 课)就是告诉它"我操作的是 Dict 这张表"。
⚠️ 别忘了:要让 Spring 扫到 Mapper,demo 项目入口类上通常有
@MapperScan("com.example.platform.basic.service.mapper")。这是项目级配置,已经配好了,新建 Mapper 放对包就行。
六、定义 In / Out(接口的"出入境"类型)
【前端类比】这就是你和后端约定的 请求体类型 和 响应体类型。前端写 interface AddDictReq 和 interface DictResp,后端写 In 和 Out。两边对齐,联调才顺。
为什么不直接用 Entity 收发?因为 Entity 是给数据库看的,In/Out 是给前端看的,二者职责不同。比如新增时前端不该传 id(自增)、不该传 createTime(后端生成)。用专门的 In 能精确控制"前端能传什么"。
参考 demo 示例的 OrganizationIn(demo-basic-common/.../in/OrganizationIn.java)风格:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrganizationIn {
private List<Integer> ids;
private Integer groupId;
}
我们定义新增用的入参 DictAddIn:
package com.example.platform.basic.common.in;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 新增字典项入参: 只暴露前端"应该"填的字段
*/
@Data
public class DictAddIn {
/** 字典编码(分组), 不能为空 */
@NotBlank(message = "字典编码不能为空") // 参数校验(见第7课异常), 为空直接拦下
private String dictCode;
/** 字典键(值), 不能为空 */
@NotBlank(message = "字典键不能为空")
private String dictKey;
/** 字典名(显示文本), 不能为空 */
@NotBlank(message = "字典名不能为空")
private String dictName;
/** 排序号, 可不传, 默认 0 */
private Integer sort;
}
出参 DictOut(参考 demo OrganizationOut 风格,扁平的展示对象):
package com.example.platform.basic.common.out;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 字典项出参: 返回给前端展示的字段
*/
@Data
public class DictOut {
private Integer id; // 主键, 前端编辑/删除时回传
private String dictCode; // 字典编码
private String dictKey; // 字典键
private String dictName; // 字典名
private Integer sort; // 排序
private Integer status; // 状态 1启用 0禁用
private LocalDateTime createTime; // 创建时间
}
| In(入参) | Out(出参) | Entity | |
|---|---|---|---|
| 给谁看 | 前端→后端 | 后端→前端 | 后端↔数据库 |
| 前端类比 | RequestBody 类型 |
Response 类型 |
DB row 类型 |
| 含 id 吗 | 新增不含/修改含 | 含 | 含 |
七、写 Service(业务逻辑的大脑)
Service 是真正"干活"的地方:参数校验、业务规则、对象转换、调 Mapper 落库。Controller 不写业务,Mapper 不写业务,业务全在这。
先定义接口(demo 习惯接口 + 实现分离):
package com.example.platform.basic.service.service;
import com.example.platform.basic.common.in.DictAddIn;
import com.example.platform.basic.common.out.DictOut;
import java.util.List;
/**
* 字典业务接口
*/
public interface DictService {
/** 新增字典项, 返回新记录id */
Integer add(DictAddIn in);
/** 按字典编码查列表 */
List<DictOut> listByCode(String dictCode);
}
再写实现类:
package com.example.platform.basic.service.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.platform.basic.common.in.DictAddIn;
import com.example.platform.basic.common.out.DictOut;
import com.example.platform.basic.service.entity.Dict;
import com.example.platform.basic.service.mapper.DictMapper;
import com.example.platform.basic.service.service.DictService;
import com.example.platform.common.core.exception.BusinessException;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 字典业务实现
*/
@Service // 标记为 Spring Bean(见第5课), 才能被 @Autowired 注入
public class DictServiceImpl implements DictService {
@Autowired // 注入 Mapper, Spring 自动给我们 new 好实例
private DictMapper dictMapper;
/**
* 新增字典项
* @param in 前端传来的新增参数
* @return 新记录的主键id
*/
@Override
public Integer add(DictAddIn in) {
// 业务校验: 同一编码下, dictKey 不允许重复(否则前端下拉框会出现两个相同值)
LambdaQueryWrapper<Dict> dupQuery = new LambdaQueryWrapper<Dict>()
.eq(Dict::getDictCode, in.getDictCode())
.eq(Dict::getDictKey, in.getDictKey());
// 注意: demo-basic 用的 MyBatis-Plus 3.1.0, selectCount 返回 Integer
// (高版本 3.4+ 才改成返回 Long, 跟着项目实际版本走)
Integer count = dictMapper.selectCount(dupQuery); // 查有没有同编码同键的记录
if (count != null && count > 0) {
// 抛业务异常(见第7课), 全局异常处理器会包成统一错误响应给前端
throw new BusinessException("该字典编码下已存在相同的字典键");
}
// In → Entity 转换: 拷贝同名属性, 省去逐个 setXxx
Dict dict = new Dict();
BeanUtils.copyProperties(in, dict); // dictCode/dictKey/dictName/sort 自动拷过去
dict.setStatus(1); // 新增默认启用
dict.setSort(in.getSort() == null ? 0 : in.getSort()); // 排序兜底为0
dict.setCreateTime(LocalDateTime.now()); // 创建时间后端生成, 不信任前端传值
dictMapper.insert(dict); // 落库, 回填自增id 到 dict.id
return dict.getId();
}
/**
* 按字典编码查列表
* @param dictCode 字典编码, 如 vehicle_type
* @return 该编码下的所有字典项(已转为出参)
*/
@Override
public List<DictOut> listByCode(String dictCode) {
// 构造查询条件: dict_code = ? AND status = 1, 按 sort 升序
LambdaQueryWrapper<Dict> query = new LambdaQueryWrapper<Dict>()
.eq(Dict::getDictCode, dictCode) // 只查这一组
.eq(Dict::getStatus, 1) // 只返回启用的
.orderByAsc(Dict::getSort); // 按排序号升序, 保证下拉框顺序稳定
List<Dict> list = dictMapper.selectList(query);
// Entity → Out 转换: 用 Stream(见第6课) 把每个 Dict 映射成 DictOut
return list.stream().map(dict -> {
DictOut out = new DictOut();
BeanUtils.copyProperties(dict, out); // 同名属性自动拷贝
return out;
}).collect(Collectors.toList());
}
}
几个值得记住的点:
LambdaQueryWrapper:MyBatis-Plus 的条件构造器,.eq(Dict::getDictCode, code)等价于 SQLWHERE dict_code = ?。用方法引用Dict::getDictCode而非字符串"dict_code",编译期就能查错(字段改名会编译报错),比手写字符串安全。BeanUtils.copyProperties(源, 目标):把同名属性批量拷贝。【前端类比】约等于Object.assign(target, source)或{ ...source }的浅拷贝,省去一行行set。- 校验失败抛
BusinessException(第 7 课):不要return null或返回错误码字符串,抛异常 + 全局处理器才是 demo 的统一姿势。
【前端类比】Service 就是你 Vue/React 里那个不碰 UI、只处理数据和规则的 store action 或 service.ts 函数:校验、调 API、转数据格式,最后把干净的结果交出去。
八、写 Controller(接口的门面)
Controller 是最薄的一层,只做三件事:收参数 → 调 Service → 包成 R 返回。绝不写业务逻辑。
直接对照 demo 示例的 OrganizationController(demo-basic-biz/.../controller/OrganizationController.java):
@RestController
@RequestMapping("/organization")
public class OrganizationController extends BaseController {
@Autowired
private OrganizationService organizationService;
@GetMapping("/getById")
public R<OrganizationOut> getOrganization(@RequestParam("id") int id) {
return R.ok(organizationService.getOrganization(id)); // 调 service, 包成 R 返回
}
}
我们的 DictController 一模一样的套路:
package com.example.platform.basic.biz.controller;
import com.example.platform.basic.common.in.DictAddIn;
import com.example.platform.basic.common.out.DictOut;
import com.example.platform.basic.service.service.DictService;
import com.example.platform.common.core.entity.R;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
/**
* 字典管理接口
*/
@RestController // = @Controller + @ResponseBody, 返回值自动转JSON(见第8课)
@RequestMapping("/dict") // 这个类下所有接口都以 /dict 开头
public class DictController {
@Autowired // 注入业务层
private DictService dictService;
/**
* 新增字典项
* @param in 请求体(JSON), @Valid 触发 In 里的 @NotBlank 等校验
* @return R 包裹的新记录id
*/
@PostMapping("/add") // POST, 入参从请求体取
public R<Integer> add(@Valid @RequestBody DictAddIn in) { // @RequestBody: JSON→对象(见第8课)
return R.ok(dictService.add(in));
}
/**
* 按字典编码查列表
* @param dictCode URL 上的查询参数, 如 ?dictCode=vehicle_type
* @return R 包裹的字典项列表
*/
@GetMapping("/list") // GET, 入参从 URL 取
public R<List<DictOut>> list(@RequestParam("dictCode") String dictCode) { // @RequestParam: 取URL参数
return R.ok(dictService.listByCode(dictCode));
}
}
约定提醒:demo 的 biz 层 Controller 通常都
extends BaseController(如上面的OrganizationController),父类封装了取登录用户、分页参数等通用能力。这个示例为聚焦主线省略了它,真实开发时按团队规范补上extends BaseController。
@RequestParam vs @RequestBody 是前端对接时最容易踩的坑,重点记住(第 8 课讲过):
| 注解 | 取值来源 | 前端怎么传 | 适用 |
|---|---|---|---|
@RequestParam |
URL 查询串 ?dictCode=x |
params: { dictCode } |
GET 查询 |
@RequestBody |
请求体 JSON | data: { ... } |
POST 提交对象 |
【前端类比】@RequestParam 对应 axios 的 params,@RequestBody 对应 axios 的 data。传错了后端就收到 null,这是联调时 80% "参数收不到" 问题的根因。
关于返回值 R<T>:这是 demo 的统一响应包装(第 4 课见过),R.ok(data) 生成的 JSON 长这样:
{ "code": 0, "message": "success", "data": { ... } }
⚠️ 注意字段名:demo 示例的
R<T>(demo-common-core/.../entity/R.java)用的是code(错误码,成功为 0)、message(提示文案)、data(业务数据),不是很多教程里常见的code/msg/data。对接前看准字段名,这是联调踩坑高发区。
前端永远先看 code(等于 0 才是成功)再取 data,结构稳定,全项目一致。
九、前端对接联调
后端写完,回到你熟悉的主场。前端调用这两个接口:
import request from '@/utils/request' // 项目封装好的 axios 实例
// 1. 查字典列表 —— GET, 参数放 params(对应 @RequestParam)
export function fetchDictList(dictCode: string) {
return request({
url: '/dict/list',
method: 'get',
params: { dictCode }, // 拼到 URL: /dict/list?dictCode=vehicle_type
})
}
// 2. 新增字典项 —— POST, 参数放 data(对应 @RequestBody)
export function addDict(payload: {
dictCode: string
dictKey: string
dictName: string
sort?: number
}) {
return request({
url: '/dict/add',
method: 'post',
data: payload, // 作为 JSON 请求体发出
})
}
页面里用(以 React 为例):
// 进页面拉取"车辆类型"字典, 渲染下拉框
const { data } = await fetchDictList('vehicle_type')
if (data.code === 0) { // 先判 code(0=成功), 这是 R<T> 的约定
setOptions(data.data) // data.data 就是 List<DictOut>
}
// 新增一项
const data = await addDict({ dictCode: 'vehicle_type', dictKey: '4', dictName: '冷藏车' })
if (data.data.code === 0) {
message.success('新增成功, id=' + data.data.data)
}
【联调自查清单】出问题时按这个顺序排查:
后端收不到参数?
├─ GET 接口 → 检查前端是否用了 params (而不是 data)
├─ POST 接口 → 检查前端是否用了 data (而不是 params)
└─ 字段名拼错? dictCode 写成 dictcode? (大小写敏感)
HTTP 200 但 code != 0 ?(demo 业务异常不靠 HTTP 状态码, 全走 code)
├─ code=400 → 校验失败, @NotBlank 字段没传或传了空串, 看 message 提示哪个字段
└─ code=1 → BusinessException, 如"字典键重复", message 就是抛出的提示文案
HTTP 500 ?
└─ 未被业务捕获的异常(如 NPE/SQL报错), 看后端日志栈追根因
关键认知:demo 用
@RestControllerAdvice全局异常处理器把BusinessException、校验失败统一包成 HTTP 200 +code(1 或 400) 返回,所以前端不能只看 HTTP 状态码,必须看code。只有真正未捕获的系统异常才落到 HTTP 500。
完整链路验证(用 curl 或 Apifox 模拟前端):
# 新增一条
curl -X POST http://localhost:8080/dict/add \
-H "Content-Type: application/json" \
-d '{"dictCode":"vehicle_type","dictKey":"1","dictName":"货车"}'
# 返回 {"code":0,"message":"success","data":1}
# 查列表
curl "http://localhost:8080/dict/list?dictCode=vehicle_type"
# 返回 {"code":0,"message":"success","data":[{"id":1,"dictKey":"1","dictName":"货车",...}]}
十、回顾全链路(一张图串起来)
┌─ demo-basic-common ─────────────────────────────┐
│ DictAddIn (入参) DictOut (出参) │
└──────────────┬──────────────────▲──────────────┘
│ │
┌─ demo-basic-biz ──────────────────────────────────┐
│ DictController │
│ @PostMapping add(@RequestBody DictAddIn) │
│ @GetMapping list(@RequestParam dictCode) │
└──────────────┬──────────────────▲────────────────┘
│ 调 service │ 返回 R<T>
┌─ demo-basic-service ──────────────────────────────┐
│ DictServiceImpl (校验/转换/业务) │
│ BeanUtils.copyProperties In↔Entity↔Out │
│ │ ▲ │
│ DictMapper extends BaseMapper<Dict> (CRUD自动) │
│ │ ▲ │
│ Dict (Entity) @TableName("sys_dict") │
└───────────┴──────────────────┴───────────────────┘
│ ▲
▼ │
MySQL sys_dict 表
你会发现,写一个接口就是从下往上盖楼:表 → Entity → Mapper → In/Out → Service → Controller → 前端联调。每一层职责单一,互不越界。这套分层一旦形成肌肉记忆,再复杂的接口你也能照这个骨架往里填。
十一、本课小结
- 开发顺序:需求分析 → 设计表 → Entity → Mapper → In/Out → Service → Controller → 前端联调,从下往上盖楼
- 分层职责:Controller 只收发不写业务;Service 是业务大脑(校验/转换/事务);Mapper 只管和库打交道(继承
BaseMapper白拿 CRUD) - 三种对象别混:Entity 对数据库、In 对前端请求、Out 对前端响应,靠
BeanUtils.copyProperties互转 - MyBatis-Plus 省力:
extends BaseMapper<Dict>+LambdaQueryWrapper,常见 CRUD 不用写一行 SQL,方法引用Dict::getDictCode比字符串更安全 - 联调最大坑:
@RequestParam(对 axiosparams) vs@RequestBody(对 axiosdata) 别传错;前端先判R的code(等于 0 才成功) 再取data - 统一响应
R<T>:demo 示例字段是code(0=成功) /message/data,不是code/msg/data;业务异常走全局处理器返回 HTTP 200 +code,前端认code不认 HTTP 状态码 - 参考的 demo 匿名化示例代码:
OrganizationController(Controller 模式)、Organization(Entity)、OrganizationMapper(BaseMapper 继承)、OrganizationIn/OrganizationOut(In/Out DTO 风格)、R(统一响应)
下一课预告:第 31 课 单元测试入门。接口写完怎么证明它是对的?我们会用 JUnit + Mockito 给这个 DictService 写测试,类比你前端的 Jest/Vitest,让你交付的代码更有底气。
十二、总结
- 设计表结构(数据库是地基):【前端类比】这相当于你在 TS 里定义一个 interface DictItem,只不过它落在 MySQL 里,是"持久化的类型定义"。
- 写 Entity(数据库表的 Java 镜像):Entity 就是数据库一行记录的 Java 对象。
- 写 Mapper(数据访问层,最省力的一层):demo 用 MyBatis-Plus,它的精髓是:让 Mapper 接口继承 BaseMapper ,CRUD 的 SQL 它全自动给你生成,你一行 SQL 都不用写。
- 定义 In / Out(接口的"出入境"类型):【前端类比】这就是你和后端约定的 请求体类型 和 响应体类型。
- 写 Service(业务逻辑的大脑):Service 是真正"干活"的地方:参数校验、业务规则、对象转换、调 Mapper 落库。
- 写 Controller(接口的门面):Controller 是最薄的一层,只做三件事:收参数 → 调 Service → 包成 R 返回。
学完自测
选择所有正确答案;提交后逐项核对判断依据。