Day 2 :POST `/plan/save` 保存链路 + MyBatis 写操作

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()
  })
}

配合 useAdmqonSubmit

复制代码
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;

要点:

  1. @Data(Lombok):自动生成 getter/setter,Jackson 靠 getter/setter 读写字段
  2. @JsonFormat :前端传 "2026-06-01" 也能转成 Date
  3. @Length :配合 @Validated,title 超 128 字符会在进 Service 前被拦截
  4. 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 里带了 executorListPlanDetail.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

相关推荐
cheems95275 小时前
[开发日记]Spring Boot + MyBatis-Plus 抽奖系统排障实录:从 JWT 被拦截到雪花 ID 失控,我是怎样一步步修通登录与人员列表的
spring boot·后端·mybatis
我登哥MVP5 小时前
Spring Boot 从“会用”到“精通”:Rest风格原理
java·spring boot·后端·spring·maven·intellij-idea·mybatis
我是唐青枫21 小时前
Java MyBatis-Flex 实战指南:从 BaseMapper 到 QueryWrapper 的轻量 ORM 用法
java·开发语言·mybatis
程序猿乐锅1 天前
【苍穹外卖|Day01】项目初识:从多模块结构到 OpenAPI 接口文档踩坑
java·spring·maven·mybatis
linweidong1 天前
Java 后端开发面试 50 个高频易混淆知识点详解
java·spring boot·spring·spring cloud·面试·mybatis·spring事务
Mahir082 天前
MyBatis 延迟加载深度解密:从使用方式到底层动态代理原理全解
java·后端·面试·mybatis
Bat U2 天前
JavaEE|SpringBoot快速入门
spring boot·java-ee·mybatis
唐青枫2 天前
Java MyBatis-Flex 实战指南:从 BaseMapper 到 QueryWrapper 的轻量 ORM 用法
java·mybatis