0. 今天学什么、和 Day 1 的区别
| 维度 | Day 1(查询) | Day 2(保存) |
|---|---|---|
| HTTP 方法 | GET |
POST |
| 前端传参 | params(拼在 URL 上) |
data(JSON Body) |
| Java 接参 | @RequestParam |
@RequestBody |
| MyBatis | <select> |
<insert> / <update> |
| 事务 | 一般不需要 | @Transactional 必须有 |
| 权限 | plan:query |
hasRole('sys') |
| 返回 | PageInfo<Plan>(分页列表) |
OperationInfo(成功/失败消息) |
1. 全链路总览(8 步,带真实路径)
用户点击「确认保存」
↓
① Plan.vue → savePlan() 组装 planInfo
↓
② manage.ts → savePlanApi(data) POST /esapi/plan/save
↓
③ ajax.ts → axios({ method:'post', data }) 带 Authorization
↓
④ PlanController.savePlan(@RequestBody Plan)
↓
⑤ PlanServiceImpl.save(@Transactional)
↓
⑥ PlanDao.insert / PlanDao.update
↓
⑦ PlanMap.xml 执行 SQL
↓
⑧ PostgreSQL 表 es_plan(+ 可能 es_plan_executor)
↓
返回 OperationInfo → 前端 cbSuccess 弹消息、关弹窗、刷新列表
2. 前端第一层:Plan.vue 怎么触发保存
2.1 入口页面与权限
Plan.vue 里有两个保存相关入口:
- 列表页弹窗「新增任务」 :只有
isSys(系统管理员)能直接在这里保存 - 普通创建人 :点「新增任务」会跳
/planDetail,走更完整的编辑页(Day 3 再讲)
弹窗里的表单字段:
96:140:D:\mes\service-front\src\views\sr\Plan.vue
<ESDialogVue v-model="planShow" title="实施计划" @close="reset">
<el-form ref="formRef" :model="planInfo" label-width="100px" size="medium" label-position="left">
<el-form-item label="任务标题" prop="title" :rules="rules.required">
...
<el-form-item label="任务时间" prop="daterange" :rules="rules.required">
...
<el-form-item label="耗费人力" prop="cost" :rules="[rules.required, rules.number]">
...
<el-form-item label="所属公司" prop="companyId" :rules="rules.required">
...
<el-form-item v-show="planInfo.companyId" label="执行者" prop="executorId" :rules="rules.required">
...
<el-form-item label="阶段交付物" prop="deliver" :rules="rules.required">
...
<div class="es-btn" @click="onSubmit(savePlan)">确认保存</div>
前端校验在这里做(Element Plus 表单 rules),后端还会再校验一层。
2.2 planInfo 是什么
161:166:D:\mes\service-front\src\views\sr\Plan.vue
const state = reactive({
planShow: false,
total: 0,
plans: Array<Plan>(),
planInfo: {} as Plan
})
点击保存时,planInfo 会被整个对象 POST 出去,大致长这样(新建时无 id):
json
{
"title": "某客户巡检支持",
"startDate": "2026-06-01",
"endDate": "2026-06-30",
"companyId": 4,
"deliver": "巡检报告",
"cost": 5,
"executorId": 123
}
重要细节(前后端字段不完全对齐):
| 前端字段 | 后端 Plan.java 有没有 |
说明 |
|---|---|---|
title |
✅ 有 | 会入库 |
startDate / endDate |
✅ 有 | 会入库 |
companyId |
✅ 有 | 会入库 |
deliver |
✅ 有 | 会入库 |
cost |
❌ 没有 | 前端展示用,save 接口不会写库 |
executorId |
❌ 没有(是 executorIds / executorList) |
弹窗路径不会自动插执行人 |
id |
✅ 有 | 有 id = 更新,无 id = 新建 |
所以:Plan.vue 弹窗走的是简化保存 ------主要写主表字段;执行人要在 PlanDetail.vue 里通过 /plan/save/executor 单独维护(Day 3)。
2.3 点击「确认保存」的执行顺序
215:221:D:\mes\service-front\src\views\sr\Plan.vue
const savePlan = async () => {
if (!isSys.value) return
let { data } = await savePlanApi(state.planInfo)
cbSuccess(data, () => {
state.planShow = false
fetchData()
})
}
配合 useAdmq 的 onSubmit:
onSubmit(savePlan)
→ formRef 前端校验通过
→ savePlan()
→ savePlanApi(state.planInfo)
→ cbSuccess:success=true 时关弹窗 + 重新拉列表
cbSuccess 逻辑:
28:35:D:\mes\service-front\src\utils\util.ts
export const cbSuccess = (data: OperateRes, cb?: any, isMessage = true) => {
if (data.success && cb) cb()
if (isMessage)
ElMessage({ message: data.operateMessage, type: data.success ? 'success' : 'error' })
}
和 Day 1 列表返回 { total, list } 不同,写操作返回的是:
json
{
"success": true,
"operateMessage": "操作成功",
"operateCallBackObj": null
}
3. 前端第二层:API 定义 + ajax 封装
3.1 savePlanApi
318:323:D:\mes\service-front\src\apis\manage.ts
export const savePlanApi = (data = {}): Res<OperateRes> =>
ajax({
url: 'plan/save',
method: 'post',
data: data
})
对比 Day 1 的 getPlanListApi:
| 列表查询 | 保存 | |
|---|---|---|
method |
默认 get |
post |
| 参数位置 | params: {...} |
data: {...} |
| URL | /esapi/plan/list?pageNum=1&... |
/esapi/plan/save(参数在 Body) |
3.2 ajax.ts 实际发出的请求
46:55:D:\mes\service-front\src\utils\ajax.ts
export function ajax(options: any): any {
const config = {
baseURL: options.proxy || '/esapi/',
url: options.url,
method: options.method || 'get',
params: options.params || {},
data: options.data || {},
...
}
实际 HTTP 请求等价于:
POST http://localhost:8084/esapi/plan/save
Headers:
Authorization: <token>
Content-Type: application/json
Body:
{ "title": "...", "startDate": "...", ... }
记忆口诀:
- axios
params→ Java@RequestParam→ URL 问号后面 - axios
data→ Java@RequestBody→ 请求体 JSON
4. Controller 层:逐行拆解 savePlan
77:86:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\workbench\controller\PlanController.java
@ApiOperation("保存实施计划")
@PostMapping("/save")
@PreAuthorize("hasRole('sys')")
@ApiImplicitParams({@ApiImplicitParam(name = "Authorization", value = "token", required = true, dataType = "string", paramType = "header")})
public OperationInfo<Object> savePlan(@RequestBody @Validated Plan plan) {
if (Objects.isNull(plan.getCompanyId())) {
plan.setCompanyId(4);
}
return planService.save(plan);
}
| 注解/代码 | 作用 |
|---|---|
@PostMapping("/save") |
只接受 POST;类上有 @RequestMapping("/plan"),完整路径 /plan/save |
@PreAuthorize("hasRole('sys')") |
Spring Security:必须是 sys 角色,否则 403 |
@RequestBody Plan plan |
把 JSON Body 反序列化成 Plan 对象(Jackson) |
@Validated Plan plan |
触发 Plan.java 上的校验注解(如 @Length) |
companyId == null → 4 |
兜底默认值(业务规则) |
return planService.save(plan) |
Controller 不写业务,只转发给 Service |
返回 OperationInfo<Object> |
统一操作结果包装,不是直接返回 Plan 实体 |
和 Day 1 findPlanList 对比:
| 列表 | 保存 | |
|---|---|---|
| 映射 | @GetMapping("/list") |
@PostMapping("/save") |
| 权限 | hasAuthority('plan:query') |
hasRole('sys') |
| 参数 | 很多 @RequestParam |
一个 @RequestBody |
| 返回 | PageInfo<Plan> |
OperationInfo<Object> |
5. Model 层:Plan.java 与 JSON 的对应
Spring 收到 JSON 后,按字段名自动赋值(驼峰一致即可):
17:52:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\workbench\model\Plan.java
@Data
public class Plan {
private Integer id;
private Integer createdBy;
private Integer companyId;
@Length(max = 128, message = "{title}{lengthMax}")
private String title;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", ...)
private Date startDate;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", ...)
private Date endDate;
@Length(max = 300, message = "{deliver}{lengthMax}")
private String deliver;
...
private List<PlanExecutor> executorList;
要点:
@Data(Lombok):自动生成 getter/setter,Jackson 靠 getter/setter 读写字段@JsonFormat:前端传"2026-06-01"也能转成Date@Length:配合@Validated,title 超 128 字符会在进 Service 前被拦截id是分支关键 :null→ 新建;有值 → 更新
6. Service 层:save() 完整业务逻辑
172:281:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\workbench\service\Impl\PlanServiceImpl.java
@Override
@Transactional
public OperationInfo<Object> save(Plan plan) {
Integer userId = UserUtils.getCurrentUserId();
Plan dbPlan = null;
if (plan.getId() != null) {
// ===== 更新分支 =====
dbPlan = planDao.findById(plan.getId());
if (dbPlan == null) {
return OperationInfo.failure();
}
// ... externalId 业务处理 ...
if (plan.getCheckType() != null) {
PlanType.getTypeByCode(plan.getCheckType());
}
plan.setStatus(null); // 故意置 null,防止前端改状态
plan.setEndRealTime(null);
planDao.update(plan);
List<PlanExecutor> executorList = plan.getExecutorList();
if (CollectionUtils.isNotEmpty(executorList)) {
updateExecutorsSmartly(plan, executorList);
}
} else {
// ===== 新建分支 =====
// ... externalId 业务处理 ...
if (plan.getCheckType() == null) {
plan.setCheckType(PlanType.OTHER.getCode());
}
if (plan.getStartDate() == null || plan.getEndDate() == null || StringUtils.isBlank(plan.getTitle())) {
throw new EmcsCustomException();
}
if (plan.getIgnoreWeekend() == null) plan.setIgnoreWeekend(false);
plan.setCreatedBy(createdBy); // 默认当前登录人
planDao.insert(plan);
List<PlanExecutor> executorList = plan.getExecutorList();
if (CollectionUtils.isNotEmpty(executorList)) {
planExecutorService.batchInsertPlanExecutor(plan, executorList);
planExecutorService.refreshExecutorIds(plan.getId());
}
}
return OperationInfo.success();
}
6.1 新建分支(id == null)逐步说明
| 步骤 | 代码 | 含义 |
|---|---|---|
| 1 | UserUtils.getCurrentUserId() |
取当前登录用户 ID |
| 2 | checkType == null → OTHER |
默认计划类型 |
| 3 | 校验 title/startDate/endDate | 缺任何一个 → 抛 EmcsCustomException → 事务回滚 |
| 4 | plan.setCreatedBy(userId) |
创建人 = 当前用户 |
| 5 | planDao.insert(plan) |
插入主表,MyBatis 回填 id |
| 6 | 若有 executorList |
批量插执行人 + 刷新主表 executor_ids 字段 |
6.2 更新分支(id != null)逐步说明
| 步骤 | 代码 | 含义 |
|---|---|---|
| 1 | planDao.findById |
先查库,不存在 → OperationInfo.failure() |
| 2 | plan.setStatus(null) |
关键技巧 :置 null 后,MyBatis 动态 SQL 的 <if test="status != null"> 不生效,不会误改状态 |
| 3 | planDao.update(plan) |
只更新前端传了值的字段 |
| 4 | 若有 executorList |
智能 diff 更新执行人(避免重复发邮件) |
6.3 @Transactional 在这里具体保护什么
新建且带执行人时,一次 save 可能执行:
INSERT es_plan
→ INSERT es_plan_executor(多条)
→ UPDATE es_plan SET executor_ids = ...
→ (batchInsert 里还可能发站内信/邮件)
任一步抛异常 → 前面所有 SQL 全部回滚,不会出现「计划有了、执行人没有」。
7. Dao 层:Java 接口 ↔ XML 的绑定
43:50:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\workbench\dao\PlanDao.java
/**
* 新增计划,默认新建状态
*/
void insert(Plan plan);
/**
* 更新计划
*/
void update(Plan plan);
MyBatis 规则:接口方法名 = XML 里的 id,namespace = 接口全限定名。
PlanDao.insert(plan) → PlanMap.xml 里 <insert id="insert">
PlanDao.update(plan) → PlanMap.xml 里 <update id="update">
8. MyBatis XML:INSERT 详解
160:195:D:\mes\enmo_support\src\main\resources\mybatis\workbench\PlanMap.xml
<insert id="insert" parameterType="com.enmo.enmo_support.workbench.model.Plan">
insert into es_plan(id, created_by, company_id, start_date, end_date, title, deliver, ...)
values (
nextval('seq_es_plan'),
#{createdBy},
#{companyId},
...
)
<selectKey order="AFTER" keyProperty="id" resultType="Integer">
SELECT currval('seq_es_plan')
</selectKey>
</insert>
逐块理解:
| 部分 | 含义 |
|---|---|
nextval('seq_es_plan') |
PostgreSQL 序列生成主键 ID |
#{createdBy} |
占位符,取 plan.getCreatedBy() |
#{executorIds,typeHandler=SetTypeHandler} |
Java Set<Integer> → 数据库可存储格式 |
<selectKey order="AFTER"> |
INSERT 之后 执行,把新 ID 写回 plan.id |
所以 Service 里 planDao.insert(plan) 执行完后,plan.getId() 已经有值了,后续插执行人要用这个 ID。
9. MyBatis XML:UPDATE 动态 SQL 详解
272:296:D:\mes\enmo_support\src\main\resources\mybatis\workbench\PlanMap.xml
<update id="update" parameterType="com.enmo.enmo_support.workbench.model.Plan">
update es_plan
<set>
<if test="companyId != null">company_id = #{companyId},</if>
<if test="title != null">title = #{title},</if>
<if test="startDate != null">start_date = #{startDate},</if>
...
</set>
where id = #{id}
</update>
动态 SQL 的意义:只更新「非 null 字段」
举例:前端只改了 title,Body 里只有 { "id": 100, "title": "新标题" }
生成的 SQL 近似:
sql
UPDATE es_plan SET title = '新标题' WHERE id = 100
status 不会被改------因为 Service 里 plan.setStatus(null),<if test="status != null"> 为 false。
对比 Day 1 的 <select>:
| SELECT | UPDATE | |
|---|---|---|
| 标签 | <select id="findList"> |
<update id="update"> |
| 结果 | resultMap 映射到对象 |
无 resultMap,返回影响行数 |
| 动态部分 | <where> + <if> 拼条件 |
<set> + <if> 拼 SET 子句 |
10. 执行人子流程(完整编辑路径)
若 Body 里带了 executorList(PlanDetail.vue 会带),新建时还会走:
74:81:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\workbench\service\Impl\PlanExecutorServiceImpl.java
public void batchInsertPlanExecutor(Plan plan, List<PlanExecutor> executorList) {
for (PlanExecutor planExecutor : executorList) {
planExecutor.setPlanId(plan.getId());
if (Objects.isNull(planExecutor.getExecutorId())){
throw new EmcsCustomException();
}
}
planDao.batchInsertPlanExecutor(executorList);
// 发站内信 + 邮件 ...
}
然后 refreshExecutorIds 把执行人 ID 汇总写回主表:
312:318:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\workbench\service\Impl\PlanExecutorServiceImpl.java
public void refreshExecutorIds(Integer planId) {
List<PlanExecutor> executorList = planDao.findExecutorListByPlanId(planId);
Set<Integer> executorIds = executorList.stream().map(PlanExecutor::getExecutorId).collect(Collectors.toSet());
Plan plan = new Plan();
plan.setId(planId);
plan.setExecutorIds(executorIds);
planDao.update(plan);
}
涉及两张表:
es_plan ← 主表(title、日期、deliver...)
es_plan_executor ← 执行人明细(每人一行:executor_id、task_time、remarks...)
主表还有一个冗余字段 executor_ids(Set),由 refreshExecutorIds 维护,方便列表查询时不用 JOIN。
11. 返回体 OperationInfo 结构
13:19:D:\mes\enmo_support\src\main\java\com\enmo\enmo_support\common\result\OperationInfo.java
public class OperationInfo<M> {
private String operateCode;
private String operateMessage;
private String operateCallBackUrl;
private Boolean success;
private M operateCallBackObj;
常用工厂方法:
| 方法 | 场景 |
|---|---|
OperationInfo.success() |
save 成功 |
OperationInfo.failure() |
更新时 id 不存在 |
throw new EmcsCustomException() |
校验失败,全局异常处理转 failure |