淘车湾项目实战二_养修预约模块分析与实现

O泡李华 7

淘车湾项目实战(二):养修预约模块分析与实现

本章位置:第三阶段 Java 企业项目 + AI 助手
对应课程:淘车湾项目实战——养修预约模块分析实现
前置知识:淘车湾项目实战(一)、若依脚手架、SpringBoot、MyBatis、Redis、事务、RBAC、Vue3、Element Plus
后续衔接:服务单项与套餐、服务工单、结算单、Activiti 审批
学习目标:完整实现一个企业级养修预约模块,掌握客户与车辆联动、预约创建、编号生成、状态机、确认、取消、到店、转工单、事务、防重复提交、门店数据权限、列表查询、详情查询、Vue3 页面以及常见错误排查。


一、本章最终要做出什么

本章完成后,系统应支持:

客户管理
车辆管理
养修预约

其中预约模块至少具备:

新增预约

编辑预约

查看详情

分页查询

按门店查询

按客户查询

按车牌查询

按时间范围查询

按状态查询

确认预约

取消预约

客户到店

转服务工单

导出预约

权限控制

门店数据权限

二、预约模块不是普通 CRUD

如果只是普通 CRUD:

新增
修改
删除
查询

那么最多只能算:

数据库练习

真实预约业务必须有:

状态

动作

权限

事务

业务校验

三、预约业务主流程

创建预约
↓
待确认
↓
门店确认
↓
已确认
↓
客户到店
↓
已到店
↓
转服务工单
↓
已转工单
↓
后续维修流程

旁路:

待确认
↓
取消

已确认
↓
取消

四、预约状态

推荐:

0 待确认

1 已确认

2 已到店

3 已转工单

4 已完成

5 已取消

五、为什么不用中文直接存数据库

错误:

status = "待确认"

问题:

展示文字可能修改

数据库逻辑难统一

多语言困难

代码比较容易写错

推荐:

数据库保存 Code

页面通过字典显示 Label

六、预约状态枚举

public enum AppointmentStatus {

    PENDING_CONFIRM(
        "0",
        "待确认"
    ),

    CONFIRMED(
        "1",
        "已确认"
    ),

    ARRIVED(
        "2",
        "已到店"
    ),

    CONVERTED(
        "3",
        "已转工单"
    ),

    COMPLETED(
        "4",
        "已完成"
    ),

    CANCELLED(
        "5",
        "已取消"
    );

    private final String code;

    private final String description;

    AppointmentStatus(
            String code,
            String description
    ) {
        this.code = code;
        this.description = description;
    }

    public String getCode() {
        return code;
    }

    public String getDescription() {
        return description;
    }
}

七、为什么 Enum 和 Dict 同时存在

Enum:

后端业务规则

Dict:

前端显示

例如:

AppointmentStatus.CONFIRMED

用于 Java:

合法状态判断

字典:

car_appointment_status

用于前端:

已确认

八、预约表回顾

CREATE TABLE car_appointment (
    appointment_id BIGINT NOT NULL AUTO_INCREMENT,
    appointment_no VARCHAR(40) NOT NULL,
    customer_id BIGINT NOT NULL,
    vehicle_id BIGINT NOT NULL,
    dept_id BIGINT NOT NULL,
    appointment_time DATETIME NOT NULL,
    appointment_type VARCHAR(20),
    description VARCHAR(1000),
    arrival_mileage INT,
    advisor_user_id BIGINT,
    status CHAR(1) NOT NULL DEFAULT '0',
    cancel_reason VARCHAR(500),
    remark VARCHAR(500),
    create_by VARCHAR(64),
    create_time DATETIME,
    update_by VARCHAR(64),
    update_time DATETIME,
    PRIMARY KEY (appointment_id),
    UNIQUE KEY uk_appointment_no (
        appointment_no
    ),
    KEY idx_appointment_customer (
        customer_id
    ),
    KEY idx_appointment_vehicle (
        vehicle_id
    ),
    KEY idx_appointment_dept_time (
        dept_id,
        appointment_time
    ),
    KEY idx_appointment_status (
        status
    )
);

九、为什么 appointment_no UNIQUE

业务编号:

页面展示

搜索

打印

后续引用

必须:

唯一

十、为什么 customer_id 和 vehicle_id 都要存

理论上:

vehicle
已经能找到 customer

为什么 appointment 还存:

customer_id

因为:

查询方便

业务关系更明确

后续客户车辆归属变化时
历史预约仍然可定位客户

十一、但要保证 customer_id 与 vehicle_id 匹配

创建预约时必须检查:

vehicle.customerId
==
appointment.customerId

否则可能出现:

张三预约
却选了李四的车

十二、预约创建 DTO

不要直接让前端提交完整:

CarAppointment Entity

推荐:

public class AppointmentCreateDTO {

    @NotNull(
        message = "客户不能为空"
    )
    private Long customerId;

    @NotNull(
        message = "车辆不能为空"
    )
    private Long vehicleId;

    @NotNull(
        message = "门店不能为空"
    )
    private Long deptId;

    @NotNull(
        message = "预约时间不能为空"
    )
    private LocalDateTime appointmentTime;

    @NotBlank(
        message = "预约类型不能为空"
    )
    private String appointmentType;

    @Size(
        max = 1000,
        message = "预约描述不能超过1000字"
    )
    private String description;

    private String remark;
}

十三、为什么不允许前端传这些字段

不要:

appointmentNo

status

advisorUserId

createBy

createTime

原因:

应该由后端生成或决定

十四、预约修改 DTO

public class AppointmentUpdateDTO {

    @NotNull
    private Long appointmentId;

    @NotNull
    private LocalDateTime appointmentTime;

    @NotBlank
    private String appointmentType;

    @Size(max = 1000)
    private String description;

    private String remark;
}

十五、为什么修改 DTO 不一定允许改客户和车辆

预约创建后:

客户和车辆属于核心关联

第一版可以规定:

不允许直接修改

如果选错:

取消旧预约
重新创建

更安全。


十六、什么时候可以编辑预约

推荐:

待确认

允许:

编辑预约时间
预约类型
描述
备注

已确认:

原则上不允许普通编辑

十七、为什么

一旦门店确认:

已经进入实际安排

继续任意修改:

容易造成门店计划混乱

十八、预约查询 DTO

public class AppointmentQueryDTO {

    private String appointmentNo;

    private String customerName;

    private String phone;

    private String plateNo;

    private Long deptId;

    private String appointmentType;

    private String status;

    private LocalDateTime beginTime;

    private LocalDateTime endTime;
}

十九、查询结果 VO

public class AppointmentListVO {

    private Long appointmentId;

    private String appointmentNo;

    private String customerName;

    private String phone;

    private String plateNo;

    private String vehicleName;

    private String deptName;

    private LocalDateTime appointmentTime;

    private String appointmentType;

    private String status;

    private String advisorName;

    private LocalDateTime createTime;
}

二十、为什么列表 VO 不直接返回 Entity

因为页面需要:

customerName

phone

plateNo

deptName

advisorName

这些都:

不是 appointment 表单字段

二十一、详情 VO

public class AppointmentDetailVO {

    private Long appointmentId;

    private String appointmentNo;

    private Long customerId;

    private String customerName;

    private String phone;

    private Long vehicleId;

    private String plateNo;

    private String brand;

    private String series;

    private String model;

    private Long deptId;

    private String deptName;

    private LocalDateTime appointmentTime;

    private String appointmentType;

    private String description;

    private Integer arrivalMileage;

    private String status;

    private String cancelReason;

    private Long advisorUserId;

    private String advisorName;

    private String remark;
}

二十二、Controller 路径设计

建议:

/car/appointment

二十三、列表

GET /car/appointment/list

二十四、详情

GET /car/appointment/{id}

二十五、新增

POST /car/appointment

二十六、修改

PUT /car/appointment

二十七、确认

POST /car/appointment/{id}/confirm

二十八、取消

POST /car/appointment/{id}/cancel

二十九、到店

POST /car/appointment/{id}/arrive

三十、转工单

POST /car/appointment/{id}/convert

三十一、为什么不统一成 changeStatus

错误:

PUT /appointment/status

Body:

{
    "id": 1,
    "status": "3"
}

前端可以:

从待确认直接改成已转工单

业务规则:

完全被绕过

三十二、正确业务动作

confirm()

cancel()

arrive()

convertToWorkOrder()

每个动作:

有自己的前置条件

三十三、预约 Controller

@RestController
@RequestMapping(
    "/car/appointment"
)
public class CarAppointmentController
        extends BaseController {

    private final ICarAppointmentService
            appointmentService;

    public CarAppointmentController(
            ICarAppointmentService appointmentService
    ) {
        this.appointmentService =
                appointmentService;
    }
}

三十四、列表接口

@PreAuthorize(
    "@ss.hasPermi('car:appointment:list')"
)
@GetMapping("/list")
public TableDataInfo list(
        AppointmentQueryDTO query
) {

    startPage();

    List<AppointmentListVO> list =
            appointmentService
                    .selectAppointmentList(
                            query
                    );

    return getDataTable(
            list
    );
}

三十五、详情接口

@PreAuthorize(
    "@ss.hasPermi('car:appointment:query')"
)
@GetMapping("/{id}")
public AjaxResult getInfo(
        @PathVariable Long id
) {

    return success(
        appointmentService
            .getAppointmentDetail(
                id
            )
    );
}

三十六、新增接口

@PreAuthorize(
    "@ss.hasPermi('car:appointment:add')"
)
@Log(
    title = "养修预约",
    businessType =
        BusinessType.INSERT
)
@RepeatSubmit(
    interval = 3000
)
@PostMapping
public AjaxResult add(
        @Valid
        @RequestBody
        AppointmentCreateDTO dto
) {

    Long id =
            appointmentService
                .createAppointment(
                    dto
                );

    return success(
            id
    );
}

三十七、为什么 create 返回 id

前端新增成功后可能:

跳详情页

或:

继续操作

返回 ID:

更方便

三十八、修改接口

@PreAuthorize(
    "@ss.hasPermi('car:appointment:edit')"
)
@Log(
    title = "养修预约",
    businessType =
        BusinessType.UPDATE
)
@PutMapping
public AjaxResult edit(
        @Valid
        @RequestBody
        AppointmentUpdateDTO dto
) {

    appointmentService
        .updateAppointment(
            dto
        );

    return success();
}

三十九、确认接口

@PreAuthorize(
    "@ss.hasPermi('car:appointment:confirm')"
)
@Log(
    title = "养修预约",
    businessType =
        BusinessType.UPDATE
)
@RepeatSubmit(
    interval = 3000
)
@PostMapping(
    "/{id}/confirm"
)
public AjaxResult confirm(
        @PathVariable Long id
) {

    appointmentService
        .confirmAppointment(
            id
        );

    return success();
}

四十、取消 DTO

public class AppointmentCancelDTO {

    @NotBlank(
        message = "取消原因不能为空"
    )
    @Size(
        max = 500
    )
    private String reason;
}

四十一、取消接口

@PreAuthorize(
    "@ss.hasPermi('car:appointment:cancel')"
)
@Log(
    title = "养修预约",
    businessType =
        BusinessType.UPDATE
)
@PostMapping(
    "/{id}/cancel"
)
public AjaxResult cancel(
        @PathVariable Long id,
        @Valid
        @RequestBody
        AppointmentCancelDTO dto
) {

    appointmentService
        .cancelAppointment(
            id,
            dto.getReason()
        );

    return success();
}

四十二、到店 DTO

public class AppointmentArriveDTO {

    @NotNull(
        message = "到店里程不能为空"
    )
    @Min(
        value = 0,
        message = "里程不能小于0"
    )
    private Integer mileage;
}

四十三、到店接口

@PreAuthorize(
    "@ss.hasPermi('car:appointment:arrive')"
)
@Log(
    title = "养修预约",
    businessType =
        BusinessType.UPDATE
)
@PostMapping(
    "/{id}/arrive"
)
public AjaxResult arrive(
        @PathVariable Long id,
        @Valid
        @RequestBody
        AppointmentArriveDTO dto
) {

    appointmentService
        .markArrived(
            id,
            dto.getMileage()
        );

    return success();
}

四十四、转工单接口

@PreAuthorize(
    "@ss.hasPermi('car:appointment:convert')"
)
@Log(
    title = "养修预约",
    businessType =
        BusinessType.UPDATE
)
@RepeatSubmit(
    interval = 3000
)
@PostMapping(
    "/{id}/convert"
)
public AjaxResult convert(
        @PathVariable Long id
) {

    Long workOrderId =
            appointmentService
                .convertToWorkOrder(
                    id
                );

    return success(
            workOrderId
    );
}

四十五、Service 接口

public interface ICarAppointmentService {

    List<AppointmentListVO>
        selectAppointmentList(
            AppointmentQueryDTO query
        );

    AppointmentDetailVO
        getAppointmentDetail(
            Long id
        );

    Long createAppointment(
        AppointmentCreateDTO dto
    );

    void updateAppointment(
        AppointmentUpdateDTO dto
    );

    void confirmAppointment(
        Long id
    );

    void cancelAppointment(
        Long id,
        String reason
    );

    void markArrived(
        Long id,
        Integer mileage
    );

    Long convertToWorkOrder(
        Long id
    );
}

四十六、创建预约完整业务流程

校验客户
↓
校验车辆
↓
校验车辆属于客户
↓
校验门店
↓
校验预约时间
↓
生成预约编号
↓
设置默认状态
↓
设置当前服务顾问
↓
保存
↓
返回预约 ID

四十七、客户校验

CarCustomer customer =
        customerMapper
            .selectById(
                dto.getCustomerId()
            );

if (
    customer == null
) {
    throw new ServiceException(
        "客户不存在"
    );
}

四十八、车辆校验

CarVehicle vehicle =
        vehicleMapper
            .selectById(
                dto.getVehicleId()
            );

if (
    vehicle == null
) {
    throw new ServiceException(
        "车辆不存在"
    );
}

四十九、校验车辆属于客户

if (
    !Objects.equals(
        vehicle.getCustomerId(),
        dto.getCustomerId()
    )
) {
    throw new ServiceException(
        "所选车辆不属于当前客户"
    );
}

五十、校验客户状态

如果客户:

已停用

则:

不能创建新预约

五十一、校验车辆状态

车辆:

停用 / 报废

也不能:

继续预约

五十二、校验门店

如果门店使用:

sys_dept

需要:

部门存在
+
状态正常

五十三、预约时间不能在过去

if (
    dto.getAppointmentTime()
        .isBefore(
            LocalDateTime.now()
        )
) {
    throw new ServiceException(
        "预约时间不能早于当前时间"
    );
}

五十四、为什么不建议精确到“必须晚 30 分钟”

是否需要:

至少提前 30 分钟

属于:

业务规则

没有需求:

不要擅自增加

五十五、生成预约编号

简单方案:

AP
+
yyyyMMdd
+
4 位流水

例如:

AP202609100001

五十六、Redis 流水号

Key:

seq:appointment:20260910

五十七、Java 示例

private String generateAppointmentNo() {

    String date =
            LocalDate.now()
                .format(
                    DateTimeFormatter
                        .BASIC_ISO_DATE
                );

    String key =
            "seq:appointment:"
            + date;

    Long seq =
            stringRedisTemplate
                .opsForValue()
                .increment(
                    key
                );

    if (
        seq != null
        &&
        seq == 1L
    ) {

        stringRedisTemplate
            .expire(
                key,
                Duration.ofDays(
                    2
                )
            );
    }

    return "AP"
            + date
            + String.format(
                "%04d",
                seq
            );
}

五十八、为什么流水 Key 过期

每天:

新 Key

旧流水:

后面不再需要

设置 TTL:

避免无限增长

五十九、Redis 挂了怎么办

学习项目可以:

抛异常

或者:

降级 UUID

但生产业务编号:

需要明确一致性方案

六十、数据库 UNIQUE 仍是兜底

即使 Redis:

理论上不会重复

appointment_no:

仍要 UNIQUE

六十一、创建预约 Service

@Transactional(
    rollbackFor = Exception.class
)
@Override
public Long createAppointment(
        AppointmentCreateDTO dto
) {

    CarCustomer customer =
            customerMapper
                .selectById(
                    dto.getCustomerId()
                );

    if (
        customer == null
    ) {
        throw new ServiceException(
            "客户不存在"
        );
    }

    CarVehicle vehicle =
            vehicleMapper
                .selectById(
                    dto.getVehicleId()
                );

    if (
        vehicle == null
    ) {
        throw new ServiceException(
            "车辆不存在"
        );
    }

    if (
        !Objects.equals(
            customer.getCustomerId(),
            vehicle.getCustomerId()
        )
    ) {
        throw new ServiceException(
            "车辆与客户不匹配"
        );
    }

    CarAppointment appointment =
            new CarAppointment();

    appointment.setAppointmentNo(
        generateAppointmentNo()
    );

    appointment.setCustomerId(
        dto.getCustomerId()
    );

    appointment.setVehicleId(
        dto.getVehicleId()
    );

    appointment.setDeptId(
        dto.getDeptId()
    );

    appointment.setAppointmentTime(
        dto.getAppointmentTime()
    );

    appointment.setAppointmentType(
        dto.getAppointmentType()
    );

    appointment.setDescription(
        dto.getDescription()
    );

    appointment.setAdvisorUserId(
        SecurityUtils.getUserId()
    );

    appointment.setStatus(
        AppointmentStatus
            .PENDING_CONFIRM
            .getCode()
    );

    appointment.setRemark(
        dto.getRemark()
    );

    appointmentMapper
        .insertAppointment(
            appointment
        );

    return appointment
            .getAppointmentId();
}

六十二、为什么 rollbackFor = Exception.class

若依很多业务异常:

可能是 RuntimeException

默认会回滚。

显式写:

rollbackFor = Exception.class

主要是:

让业务意图更明确

六十三、修改预约 Service

流程:

查预约
↓
只能待确认
↓
修改允许字段
↓
Update

六十四、实现

@Transactional(
    rollbackFor = Exception.class
)
@Override
public void updateAppointment(
        AppointmentUpdateDTO dto
) {

    CarAppointment appointment =
            appointmentMapper
                .selectById(
                    dto.getAppointmentId()
                );

    if (
        appointment == null
    ) {
        throw new ServiceException(
            "预约不存在"
        );
    }

    if (
        !AppointmentStatus
            .PENDING_CONFIRM
            .getCode()
            .equals(
                appointment.getStatus()
            )
    ) {
        throw new ServiceException(
            "当前预约状态不允许修改"
        );
    }

    appointment.setAppointmentTime(
        dto.getAppointmentTime()
    );

    appointment.setAppointmentType(
        dto.getAppointmentType()
    );

    appointment.setDescription(
        dto.getDescription()
    );

    appointment.setRemark(
        dto.getRemark()
    );

    appointmentMapper
        .updateAppointment(
            appointment
        );
}

六十五、确认预约规则

允许:

待确认
→ 已确认

不允许:

已确认
→ 再确认

已取消
→ 已确认

已转工单
→ 已确认

六十六、确认 Service

@Transactional(
    rollbackFor = Exception.class
)
@Override
public void confirmAppointment(
        Long id
) {

    CarAppointment appointment =
            getRequired(
                id
            );

    requireStatus(
        appointment,
        AppointmentStatus
            .PENDING_CONFIRM
    );

    int rows =
            appointmentMapper
                .updateStatus(
                    id,
                    AppointmentStatus
                        .PENDING_CONFIRM
                        .getCode(),
                    AppointmentStatus
                        .CONFIRMED
                        .getCode()
                );

    if (
        rows == 0
    ) {
        throw new ServiceException(
            "预约状态已变化,请刷新后重试"
        );
    }
}

六十七、为什么 updateStatus 要带 oldStatus

不要:

UPDATE car_appointment
SET status = '1'
WHERE appointment_id = ?

更推荐:

UPDATE car_appointment
SET status = '1'
WHERE appointment_id = ?
  AND status = '0'

六十八、这样解决什么

两个请求同时确认:

A 查询 status=0

B 查询 status=0

A 更新:

0 → 1
成功

B 更新:

WHERE status=0

已经匹配不到:

rows=0

六十九、这叫乐观状态更新

不一定需要:

version 字段

直接利用:

旧状态作为条件

也能实现简单并发控制。


七十、Mapper

int updateStatus(
    @Param("id")
    Long id,

    @Param("oldStatus")
    String oldStatus,

    @Param("newStatus")
    String newStatus
);

七十一、XML

<update id="updateStatus">
    UPDATE car_appointment

    SET
        status = #{newStatus},
        update_time = NOW()

    WHERE
        appointment_id = #{id}
        AND status = #{oldStatus}
</update>

七十二、取消预约规则

允许:

待确认
已确认

取消。


七十三、不允许

已到店

已转工单

已完成

普通取消。


七十四、取消 Service

@Transactional(
    rollbackFor = Exception.class
)
@Override
public void cancelAppointment(
        Long id,
        String reason
) {

    CarAppointment appointment =
            getRequired(
                id
            );

    String status =
            appointment.getStatus();

    boolean allowed =
            AppointmentStatus
                .PENDING_CONFIRM
                .getCode()
                .equals(
                    status
                )
            ||
            AppointmentStatus
                .CONFIRMED
                .getCode()
                .equals(
                    status
                );

    if (
        !allowed
    ) {
        throw new ServiceException(
            "当前预约状态不允许取消"
        );
    }

    int rows =
            appointmentMapper
                .cancelAppointment(
                    id,
                    status,
                    reason
                );

    if (
        rows == 0
    ) {
        throw new ServiceException(
            "预约状态已变化,请刷新后重试"
        );
    }
}

七十五、取消 SQL

<update id="cancelAppointment">
    UPDATE car_appointment

    SET
        status = '5',
        cancel_reason = #{reason},
        update_time = NOW()

    WHERE
        appointment_id = #{id}
        AND status = #{oldStatus}
</update>

七十六、为什么 reason 必填

客户以后问:

为什么预约取消了?

系统必须:

能追溯

七十七、到店规则

推荐:

已确认
→ 已到店

七十八、是否允许待确认直接到店

现实:

可能客户突然提前到

但课程第一版:

不允许

流程更清晰。


七十九、到店要记录什么

arrival_mileage

必要时:

arrival_time

八十、是否增加 arrival_time 字段

推荐:

ALTER TABLE car_appointment
ADD arrival_time DATETIME NULL;

因为:

预约时间
≠
真实到店时间

八十一、到店 Service

@Transactional(
    rollbackFor = Exception.class
)
@Override
public void markArrived(
        Long id,
        Integer mileage
) {

    CarAppointment appointment =
            getRequired(
                id
            );

    requireStatus(
        appointment,
        AppointmentStatus
            .CONFIRMED
    );

    int rows =
            appointmentMapper
                .markArrived(
                    id,
                    AppointmentStatus
                        .CONFIRMED
                        .getCode(),
                    AppointmentStatus
                        .ARRIVED
                        .getCode(),
                    mileage
                );

    if (
        rows == 0
    ) {
        throw new ServiceException(
            "预约状态已变化,请刷新后重试"
        );
    }

    vehicleMapper
        .updateMileageIfGreater(
            appointment.getVehicleId(),
            mileage
        );
}

八十二、为什么同步车辆最新里程

车辆表:

mileage

保存:

最新已知里程

到店时有新的:

arrival_mileage

可以更新。


八十三、但不能让新里程小于旧里程

例如车辆当前:

50000 km

前端传:

30000

不应该:

覆盖

八十四、Mapper

UPDATE car_vehicle

SET mileage = #{mileage}

WHERE vehicle_id = #{vehicleId}
  AND (
      mileage IS NULL
      OR mileage < #{mileage}
  )

八十五、转服务工单是本章最重要事务

业务:

Appointment
↓
WorkOrder

一次动作需要:

创建工单

+
更新预约状态

八十六、为什么必须事务

如果:

工单 insert 成功

然后:

预约 status 更新失败

预约仍:

已到店

用户再次点击:

转工单

可能:

重复生成工单

八十七、工单表建议增加 UNIQUE

ALTER TABLE car_work_order
ADD UNIQUE KEY uk_work_order_appointment (
    appointment_id
);

前提:

一条预约只能转一张工单

八十八、事务 + 状态 + UNIQUE

三层:

事务
保证一次用例内部一致

状态旧值条件
防并发重复状态转换

UNIQUE
数据库最终兜底

八十九、WorkOrder 初始状态

预约转工单后:

工单 = 待派工

例如:

0

九十、工单编号

WO202609100001

和预约编号:

类似生成

九十一、转工单 Service

@Transactional(
    rollbackFor = Exception.class
)
@Override
public Long convertToWorkOrder(
        Long appointmentId
) {

    CarAppointment appointment =
            getRequired(
                appointmentId
            );

    requireStatus(
        appointment,
        AppointmentStatus
            .ARRIVED
    );

    CarWorkOrder existed =
            workOrderMapper
                .selectByAppointmentId(
                    appointmentId
                );

    if (
        existed != null
    ) {
        throw new ServiceException(
            "当前预约已生成服务工单"
        );
    }

    CarWorkOrder workOrder =
            new CarWorkOrder();

    workOrder.setWorkOrderNo(
        workOrderNoGenerator
            .next()
    );

    workOrder.setAppointmentId(
        appointmentId
    );

    workOrder.setCustomerId(
        appointment.getCustomerId()
    );

    workOrder.setVehicleId(
        appointment.getVehicleId()
    );

    workOrder.setDeptId(
        appointment.getDeptId()
    );

    workOrder.setAdvisorUserId(
        appointment.getAdvisorUserId()
    );

    workOrder.setFaultDescription(
        appointment.getDescription()
    );

    workOrder.setStatus(
        WorkOrderStatus
            .WAIT_ASSIGN
            .getCode()
    );

    workOrderMapper
        .insertWorkOrder(
            workOrder
        );

    int rows =
            appointmentMapper
                .updateStatus(
                    appointmentId,
                    AppointmentStatus
                        .ARRIVED
                        .getCode(),
                    AppointmentStatus
                        .CONVERTED
                        .getCode()
                );

    if (
        rows == 0
    ) {
        throw new ServiceException(
            "预约状态已变化,请刷新后重试"
        );
    }

    return workOrder
            .getWorkOrderId();
}

九十二、这里 existed 查询是不是已经足够

不够。

两个线程:

同时 select

都可能:

查不到

然后:

同时 insert

九十三、所以 UNIQUE 才是最终兜底

数据库:

只允许一条成功

九十四、DuplicateKeyException 怎么处理

可以统一:

捕获后抛 ServiceException

例如:

该预约已生成工单,请勿重复操作

九十五、不要把数据库异常直接返回前端

错误:

Duplicate entry '1' for key ...

用户:

看不懂

同时:

泄露数据库实现

九十六、数据权限:预约列表

预约是:

门店业务数据

非常适合:

@DataScope

九十七、Service

@DataScope(
    deptAlias = "a"
)
@Override
public List<AppointmentListVO>
    selectAppointmentList(
        AppointmentQueryDTO query
    ) {

    return appointmentMapper
        .selectAppointmentList(
            query
        );
}

九十八、Mapper SQL

<select
    id="selectAppointmentList"
    resultType="...AppointmentListVO"
>

    SELECT
        a.appointment_id,
        a.appointment_no,
        c.customer_name,
        c.phone,
        v.plate_no,
        CONCAT(
            COALESCE(v.brand, ''),
            ' ',
            COALESCE(v.series, '')
        ) AS vehicle_name,
        d.dept_name,
        a.appointment_time,
        a.appointment_type,
        a.status,
        u.nick_name AS advisor_name,
        a.create_time

    FROM car_appointment a

    LEFT JOIN car_customer c
        ON c.customer_id =
           a.customer_id

    LEFT JOIN car_vehicle v
        ON v.vehicle_id =
           a.vehicle_id

    LEFT JOIN sys_dept d
        ON d.dept_id =
           a.dept_id

    LEFT JOIN sys_user u
        ON u.user_id =
           a.advisor_user_id

    WHERE 1 = 1

    <if test="appointmentNo != null
              and appointmentNo != ''">
        AND a.appointment_no
            LIKE CONCAT(
                '%',
                #{appointmentNo},
                '%'
            )
    </if>

    <if test="customerName != null
              and customerName != ''">
        AND c.customer_name
            LIKE CONCAT(
                '%',
                #{customerName},
                '%'
            )
    </if>

    <if test="phone != null
              and phone != ''">
        AND c.phone =
            #{phone}
    </if>

    <if test="plateNo != null
              and plateNo != ''">
        AND v.plate_no
            LIKE CONCAT(
                '%',
                #{plateNo},
                '%'
            )
    </if>

    <if test="deptId != null">
        AND a.dept_id =
            #{deptId}
    </if>

    <if test="status != null
              and status != ''">
        AND a.status =
            #{status}
    </if>

    <if test="beginTime != null">
        AND a.appointment_time
            <![CDATA[ >= ]]>
            #{beginTime}
    </if>

    <if test="endTime != null">
        AND a.appointment_time
            <![CDATA[ <= ]]>
            #{endTime}
    </if>

    ${params.dataScope}

    ORDER BY
        a.appointment_time DESC

</select>

九十九、注意 QueryDTO 是否有 params

若依经典 @DataScope:

通常要求查询对象

能接收:

params.dataScope

一百、两种做法

做法一:

QueryDTO extends BaseEntity

简单。


一百零一、做法二

Service:

把 dataScope 放入一个专门 Query Entity

更严格。

课程第一版:

可以 extends BaseEntity

一百零二、详情接口也必须考虑数据越权

如果:

普通门店用户

直接:

GET /appointment/999

这个 ID 属于别的门店。

如果 Service:

selectById(999)

就泄露了。


一百零三、正确详情查询

可以:

带当前数据范围查询

而不是:

纯主键 select

一百零四、思路

selectAppointmentDetailById

SQL:

WHERE appointment_id = #{id}
${params.dataScope}

一百零五、或者 Service 二次校验

查询预约
↓
检查 appointment.deptId
是否属于当前用户可访问部门

一百零六、课程推荐

为了复用若依:

详情也尽量走 DataScope 查询

一百零七、写操作也要考虑归属

例如 A 门店用户:

是否能 confirm B 门店预约?

不能。


一百零八、所以 confirm 不能只:

查 ID
+
看 status

还要:

检查 DataScope / 门店归属

一百零九、简单实现方式

写操作前:

selectAccessibleAppointment(id)

这个查询:

带 DataScope

找不到:

统一提示“预约不存在或无权限”

一百一十、为什么提示“不存在或无权限”

避免:

让攻击者通过错误信息
判断某个 ID 是否真实存在

一百一十一、客户选择组件

新增预约时:

先选客户

一百一十二、选择客户后

加载:

该客户车辆

一百一十三、API

GET /car/vehicle/customer/{customerId}

一百一十四、返回

[
    {
        "vehicleId": 1,
        "plateNo": "辽A12345",
        "brand": "大众",
        "series": "迈腾"
    }
]

一百一十五、为什么不要一次加载所有车辆

数据量大:

没必要

应该:

按客户查询

一百一十六、客户下拉数据多怎么办

不要:

一次拉全客户

推荐:

远程搜索

一百一十七、客户搜索 API

GET /car/customer/options?keyword=张

一百一十八、限制

例如:

最多返回 20 条

一百一十九、为什么

Select:

不是数据库全量导出工具

一百二十、Vue 预约 API

import request from '@/utils/request'

export function listAppointment(
    query
) {
    return request({
        url: '/car/appointment/list',
        method: 'get',
        params: query
    })
}

export function getAppointment(
    id
) {
    return request({
        url:
            '/car/appointment/'
            + id,
        method: 'get'
    })
}

export function addAppointment(
    data
) {
    return request({
        url:
            '/car/appointment',
        method: 'post',
        data
    })
}

export function updateAppointment(
    data
) {
    return request({
        url:
            '/car/appointment',
        method: 'put',
        data
    })
}

一百二十一、业务动作 API

export function confirmAppointment(
    id
) {
    return request({
        url:
            `/car/appointment/${id}/confirm`,
        method: 'post'
    })
}

export function cancelAppointment(
    id,
    data
) {
    return request({
        url:
            `/car/appointment/${id}/cancel`,
        method: 'post',
        data
    })
}

export function arriveAppointment(
    id,
    data
) {
    return request({
        url:
            `/car/appointment/${id}/arrive`,
        method: 'post',
        data
    })
}

export function convertAppointment(
    id
) {
    return request({
        url:
            `/car/appointment/${id}/convert`,
        method: 'post'
    })
}

一百二十二、页面状态字典

const {
    car_appointment_status,
    car_appointment_type
} =
    proxy.useDict(
        'car_appointment_status',
        'car_appointment_type'
    )

一百二十三、查询表单

字段:

预约编号

客户姓名

手机号

车牌号

门店

预约状态

预约时间范围

一百二十四、Element Plus 表格

主要列:

预约编号

客户

手机号

车辆

门店

预约时间

预约类型

状态

服务顾问

操作

一百二十五、状态显示

<dict-tag
    :options="
        car_appointment_status
    "
    :value="
        row.status
    "
/>

一百二十六、不要手写状态颜色

如果:

若依字典已经配置

让:

dict-tag

统一显示。


一百二十七、操作按钮条件

确认:

status == 0

一百二十八、Vue 示例

<el-button
    v-if="
        row.status === '0'
    "
    v-hasPermi="[
        'car:appointment:confirm'
    ]"
    link
    type="primary"
    @click="
        handleConfirm(row)
    "
>
    确认
</el-button>

一百二十九、取消

待确认
已确认

显示。


一百三十、到店

已确认

显示。


一百三十一、转工单

已到店

显示。


一百三十二、已转工单

可以显示:

查看工单

一百三十三、为什么按钮状态判断 + 权限判断都要有

状态:

当前业务是否允许

权限:

当前用户是否允许

一百三十四、确认对话框

function handleConfirm(
    row
) {

    proxy.$modal
        .confirm(
            `确认预约 ${row.appointmentNo} 吗?`
        )
        .then(
            () =>
                confirmAppointment(
                    row.appointmentId
                )
        )
        .then(
            () => {
                proxy.$modal
                    .msgSuccess(
                        '确认成功'
                    )

                getList()
            }
        )
}

一百三十五、为什么重要动作需要确认框

防止:

误点

尤其:

取消

转工单

结算

一百三十六、取消 Dialog

需要:

reason textarea

一百三十七、取消前端校验

reason 必填

一百三十八、但后端也必须校验

前端校验:

可以绕过

一百三十九、到店 Dialog

输入:

当前里程

一百四十、里程规则

前端:

>= 0

后端:

>= 0

并检查:

不能明显小于车辆现有里程

一百四十一、预约新增 Form

字段:

客户

车辆

门店

预约时间

预约类型

问题描述

备注

一百四十二、客户车辆联动

客户改变:

function handleCustomerChange(
    customerId
) {

    form.value.vehicleId = undefined

    vehicleOptions.value = []

    if (
        !customerId
    ) {
        return
    }

    listVehicleByCustomer(
        customerId
    ).then(
        response => {
            vehicleOptions.value =
                response.data
        }
    )
}

一百四十三、为什么客户变更要清 vehicleId

否则:

前客户的 vehicleId

还残留。

页面看起来:

选了新客户

提交时:

却还是旧车辆

一百四十四、这是一类常见联动 Bug

所有联动:

父值变化
↓
清子值
↓
重新加载子选项

一百四十五、预约时间组件

<el-date-picker
    v-model="
        form.appointmentTime
    "
    type="datetime"
    placeholder="请选择预约时间"
/>

一百四十六、时间格式

前端和后端必须统一:

ISO
或
yyyy-MM-dd HH:mm:ss

一百四十七、SpringBoot 时间解析

如果 DTO:

LocalDateTime

通常:

Jackson

根据配置解析。


一百四十八、不要前端传时间戳和字符串混用

一个项目:

统一一种时间协议

一百四十九、预约列表日期范围

若依查询常见:

params.beginTime
params.endTime

也可以:

beginTime/endTime 字段

一百五十、项目内统一即可

不要:

一个模块 params
一个模块字段
另一个模块数组

一百五十一、分页查询

Controller:

startPage()

一百五十二、为什么 Mapper 不自己写 LIMIT

若依:

PageHelper

统一处理。


一百五十三、排序

默认:

appointment_time DESC

更符合:

最近预约优先

一百五十四、能不能前端自由传 orderBy

若依:

有排序字段安全处理

但业务项目不要:

把任意前端字符串直接拼 ORDER BY

一百五十五、SQL 注入风险

错误:

ORDER BY ${orderBy}

如果 orderBy:

完全来自用户

危险。


一百五十六、使用白名单

只允许:

appointment_time

create_time

appointment_no

一百五十七、查询索引

预约最常查:

dept_id + appointment_time

所以:

KEY idx_appointment_dept_time (
    dept_id,
    appointment_time
)

一百五十八、状态

KEY idx_appointment_status (
    status
)

一百五十九、status 选择性可能低

例如:

90% 都是已完成

单列索引:

收益可能有限

一百六十、为什么仍可能保留

课程项目:

数据量不大

后期:

看执行计划

再优化。


一百六十一、组合索引思路

常见:

WHERE dept_id = ?
AND status = ?
ORDER BY appointment_time DESC

可以考虑:

(dept_id, status, appointment_time)

一百六十二、是否立即建

不必。

先:

有真实查询数据

再:

EXPLAIN

一百六十三、预约导出

权限:

car:appointment:export

一百六十四、导出字段

推荐:

预约编号

客户

手机号

车牌

门店

预约时间

预约类型

状态

服务顾问

一百六十五、不要导出

内部主键

内部 userId

敏感字段

一百六十六、预约 ExportVO

public class AppointmentExportVO {

    @Excel(
        name = "预约编号"
    )
    private String appointmentNo;

    @Excel(
        name = "客户姓名"
    )
    private String customerName;

    @Excel(
        name = "手机号"
    )
    private String phone;

    @Excel(
        name = "车牌号"
    )
    private String plateNo;

    @Excel(
        name = "门店"
    )
    private String deptName;

    @Excel(
        name = "预约时间",
        dateFormat =
            "yyyy-MM-dd HH:mm:ss"
    )
    private Date appointmentTime;

    @Excel(
        name = "预约状态",
        dictType =
            "car_appointment_status"
    )
    private String status;
}

一百六十七、为什么使用 ExportVO

比:

直接 CarAppointment

更安全。


一百六十八、导出也必须 DataScope

A 门店:

不能导出 B 门店

一百六十九、预约删除功能要不要有

建议:

不提供物理删除

一百七十、为什么

预约属于:

业务历史

应该:

取消

而不是:

删除

一百七十一、代码生成器默认有 remove

生成后:

可以移除

这是:

代码生成后二次改造

的典型例子。


一百七十二、菜单权限也不要保留 remove

如果业务不允许删除:

删除按钮权限

也不要生成。


一百七十三、客户 CRUD 和预约差别

客户:

普通 CRUD 居多

预约:

状态机业务

一百七十四、不要拿客户 CRUD 模板硬套预约

否则:

所有状态通过 edit

业务会失控。


一百七十五、客户详情选择

预约表单中的客户 Select:

远程搜索

一百七十六、车辆联动

customerId
↓
vehicle options

一百七十七、门店 Select

从:

sys_dept

获取。


一百七十八、服务顾问

第一版可以:

创建人 = advisorUserId

一百七十九、以后如果前台代预约

可以:

单独选择服务顾问

但当前:

不增加复杂度

一百八十、预约重复校验

是否允许同一车辆:

同一天多个预约?

需要业务定义。


一百八十一、课程第一版

建议:

允许

因为:

不同时间段
可能多个业务

一百八十二、不要擅自 UNIQUE(vehicle_id, date)

会误伤:

真实业务

一百八十三、可以做冲突提示

例如同车辆:

预约时间前后 30 分钟

已有预约:

提示

但这属于:

增强规则

当前不是必须。


一百八十四、取消后的预约编号

不要:

重复利用

业务编号:

一旦生成就永久唯一

一百八十五、转工单后预约怎么查看

预约详情:

显示 workOrderId / workOrderNo

一百八十六、是否把 work_order_id 存 appointment 表

不一定。

因为:

work_order
已经有 appointment_id

可以:

反查

一百八十七、为什么不双向都存

双向存:

增加一致性维护

如果一边错:

关系混乱

一百八十八、推荐单向 FK 关系字段

car_work_order.appointment_id

足够。


一百八十九、Appointment Detail 查询工单

LEFT JOIN car_work_order wo
ON wo.appointment_id =
   a.appointment_id

获取:

work_order_no

一百九十、预约完成 status=4 什么时候用

如果:

预约流程最终跟工单完成联动

可以在:

工单完成

后设置:

预约 = 已完成

一百九十一、为什么不是转工单就完成

转工单:

只是进入实际服务

还没:

施工完成

所以:

已转工单

更准确。


一百九十二、工单完成后更新预约

后续课程:

WorkOrder Finish

事务中可以:

更新预约状态

一百九十三、跨模块状态同步

要由:

业务 Service

统一控制。

不要:

数据库触发器

第一版没必要。


一百九十四、事务边界

确认预约:

一个业务事务

一百九十五、到店

更新预约
+
更新车辆里程

一个事务。


一百九十六、转工单

创建工单
+
更新预约

一个事务。


一百九十七、不要把前端请求拆成两次

错误:

POST /work-order
↓
成功

PUT /appointment/status
↓
失败

前端很难:

保持数据库一致

一百九十八、一个业务动作一个 API

例如:

convertToWorkOrder

由后端:

一次事务完成

一百九十九、防重复提交

@RepeatSubmit:

可以防快速双击

二百、但需要数据库兜底

再次强调:

@RepeatSubmit
不是幂等最终保证

二百零一、状态更新 SQL

WHERE old_status

也很重要。


二百零二、三层并发安全

HTTP 层
@RepeatSubmit

业务层
状态校验 + 事务

数据库层
UNIQUE + 条件 UPDATE

二百零三、这是本章最重要工程思维之一

一个问题:

不要只用一个机制解决

二百零四、预约权限设计

car:appointment:list

car:appointment:query

car:appointment:add

car:appointment:edit

car:appointment:confirm

car:appointment:cancel

car:appointment:arrive

car:appointment:convert

car:appointment:export

二百零五、为什么没有 remove

因为:

不允许物理删除

二百零六、角色建议

服务顾问:

list
query
add
edit
confirm
cancel
arrive
convert
export

二百零七、技师

预约模块:

通常只 query/list

后面主要:

工单

二百零八、财务

预约:

通常只读

二百零九、门店管理员

预约全部业务权限

但数据:

只限本门店/下级

二百一十、管理员

功能:

全部

数据:

全部

二百一十一、数据权限测试

A 店:

dept_id = 101

B 店:

dept_id = 102

二百一十二、A 店账号登录

列表:

不能看到 B 店预约

二百一十三、直接请求 B 店 ID

详情:

也不能查看

二百一十四、直接 confirm B 店 ID

应该:

403 或业务无权限

二百一十五、这是完整数据权限

不只是:

列表隐藏

二百一十六、常见 Bug 1:列表有数据,详情 404

可能:

详情 SQL DataScope alias 写错

二百一十七、常见 Bug 2:管理员也查不到

可能:

DataScope 角色范围逻辑

或:

deptAlias

错误。


二百一十八、常见 Bug 3:普通用户能看全部

检查:

Service 有没有 @DataScope

Mapper 有没有 ${params.dataScope}

二百一十九、常见 Bug 4:创建预约 500

检查:

customerId

vehicleId

deptId

appointment_time

appointmentNo UNIQUE

二百二十、常见 Bug 5:Redis 编号重复

检查:

是不是多个环境用了不同 Redis

seq Key 是否正确

是否有手工重置

数据库:

UNIQUE

必须保留。


二百二十一、常见 Bug 6:客户切换后车辆还是旧的

前端:

没清 form.vehicleId

二百二十二、常见 Bug 7:取消后还能确认

后端:

确认接口只 update id
没带 oldStatus

二百二十三、常见 Bug 8:双击转工单生成两张

检查:

@RepeatSubmit

状态条件更新

appointment_id UNIQUE

二百二十四、常见 Bug 9:转工单成功但预约没变

说明:

事务没有正确生效

检查:

@Transactional

是否同类自调用

是否异常被 catch

是否 Service Bean 调用

二百二十五、常见 Bug 10:工单 insert 后异常没回滚

检查:

异常是不是被 catch 后吞掉

二百二十六、错误写法

try {
    ...
} catch (
    Exception e
) {
    log.error(
        "失败",
        e
    );
}

然后:

方法正常返回

Spring:

认为事务成功

二百二十七、正确

catch (
    Exception e
) {
    log.error(
        "失败",
        e
    );

    throw e;
}

或:

不 catch

交给:

GlobalExceptionHandler

二百二十八、常见 Bug 11:预约日期乱码/解析错误

检查:

前端 DatePicker value-format

后端 LocalDateTime

Jackson 时间格式

二百二十九、常见 Bug 12:状态下拉不显示文字

检查:

car_appointment_status

sys_dict_type

sys_dict_data

useDict

字典缓存

二百三十、常见 Bug 13:按钮不显示

检查:

sys_menu perms

角色菜单

/getInfo permissions

v-hasPermi

二百三十一、常见 Bug 14:按钮显示但接口 403

检查:

前端 permissions 旧缓存

Controller @PreAuthorize

权限码拼写

二百三十二、常见 Bug 15:导出全门店数据

说明:

Export 查询绕过 @DataScope

二百三十三、正确

导出调用:

和列表同一个带 DataScope 的 Service

二百三十四、日志

关键动作:

新增预约

修改预约

确认

取消

到店

转工单

导出

二百三十五、查询要不要日志

一般:

不需要 @Log

避免:

日志过多

二百三十六、敏感字段

客户:

phone

如果记录 Request:

注意日志敏感数据

二百三十七、预约描述

可能有:

用户输入

避免:

原样用 v-html

防 XSS。


二百三十八、前端只使用普通文本

<span>
    {{ row.description }}
</span>

Vue:

会转义文本

二百三十九、不要随便

<div
    v-html="
        row.description
    "
/>

二百四十、预约编号搜索

LIKE %keyword%:

索引利用有限

如果业务:

完整编号查询

更适合:

=

二百四十一、页面可以区分

预约编号:

精确搜索

客户姓名:

模糊搜索

二百四十二、手机号

建议:

精确

二百四十三、车牌号

可:

模糊

但大型数据:

需考虑索引策略

二百四十四、预约列表默认条件

可以:

默认查询最近 30 天

但这属于:

产品需求

课程第一版:

不强制

二百四十五、预约首页统计卡片要不要做

当前课程:

不是必须

先完成:

主业务

二百四十六、为什么不要第一版做花哨统计

项目实战重点:

业务正确

不是:

图表数量

二百四十七、测试用例 1

创建客户:

张三

二百四十八、测试用例 2

为张三创建:

辽A12345

二百四十九、测试用例 3

创建预约。

预期:

status=0

appointmentNo 自动生成

advisorUserId = 当前用户

二百五十、测试用例 4

预约选:

李四车辆

预期:

拒绝

二百五十一、测试用例 5

预约时间:

昨天

预期:

拒绝

二百五十二、测试用例 6

待确认:

修改成功

二百五十三、测试用例 7

确认后:

再次编辑

预期:

拒绝

二百五十四、测试用例 8

确认:

0 → 1

二百五十五、测试用例 9

再次确认:

拒绝

二百五十六、测试用例 10

已确认:

取消成功

二百五十七、测试用例 11

已取消:

再次确认失败

二百五十八、测试用例 12

正常流程:

0
↓
1
↓
2
↓
3

二百五十九、测试用例 13

到店里程:

50000

车辆最新里程:

同步更新

二百六十、测试用例 14

到店里程:

小于车辆当前里程

预期:

拒绝或不回写

二百六十一、测试用例 15

快速双击:

转工单

预期:

只有一张工单

二百六十二、测试用例 16

A 店用户:

查询 B 店

预期:

无数据

二百六十三、测试用例 17

A 店用户:

直接查 B 店 ID

预期:

拒绝

二百六十四、测试用例 18

无 confirm 权限:

Postman 调 confirm

预期:

403

二百六十五、测试用例 19

有按钮无后端权限:

前端可能显示

但接口:

403

验证:

后端安全

二百六十六、测试用例 20

导出:

A 店用户

只能:

导出 A 店

二百六十七、Git 提交建议

客户车辆准备:

git commit -m "feat: prepare customer and vehicle selection"

二百六十八、预约 CRUD:

git commit -m "feat: add maintenance appointment CRUD"

二百六十九、预约状态流:

git commit -m "feat: add appointment lifecycle actions"

二百七十、转工单:

git commit -m "feat: convert appointment to work order"

二百七十一、数据权限:

git commit -m "feat: apply store data scope to appointments"

二百七十二、为什么项目实战要拆 Commit

你后面:

服务单项
套餐
结算
Activiti

会继续叠加。

如果某一步坏了:

Git 能准确回退

二百七十三、Cursor 提示词 1:分析预约模块

当前项目基于官方 RuoYi-Vue springboot3 + RuoYi-Vue3。

请只分析,不修改。

请阅读当前:
car_customer
car_vehicle
car_appointment
相关 Domain、Mapper、Service、Controller、Vue 文件。

请检查预约业务是否具备:
1. 客户车辆归属校验
2. 预约时间校验
3. 状态机
4. @PreAuthorize
5. @DataScope
6. @RepeatSubmit
7. 条件状态更新
8. appointment_no 唯一
9. 转工单事务
10. appointment_id 工单唯一约束

必须引用真实文件路径。

二百七十四、Cursor 提示词 2:只实现预约状态动作

请基于当前真实若依项目,
只修改养修预约模块。

新增业务动作:
confirmAppointment
cancelAppointment
markArrived
convertToWorkOrder

要求:
1. 不允许前端直接修改 status
2. 每个动作检查当前合法状态
3. 使用条件 UPDATE oldStatus → newStatus
4. 转工单必须 @Transactional
5. 一条预约只能生成一张工单
6. 复用若依 ServiceException
7. 复用 @PreAuthorize、@Log、@RepeatSubmit
8. 不修改 SecurityConfig 和 TokenService

二百七十五、Cursor 提示词 3:前端页面

请基于当前 RuoYi-Vue3 现有页面风格,
优化 car/appointment/index.vue。

要求:
1. 使用现有 request 封装
2. 使用 useDict
3. 使用 v-hasPermi
4. 客户改变后清空并重新加载车辆
5. 不允许直接 changeStatus
6. 状态按钮:
   待确认 → 确认/取消
   已确认 → 到店/取消
   已到店 → 转工单
7. 取消必须填写原因
8. 到店必须填写里程
9. 不修改全局 UI 框架

二百七十六、Cursor 提示词 4:安全审查

请审查养修预约模块的越权和并发问题。

重点检查:
1. list 是否 @DataScope
2. detail 是否能访问其他门店 ID
3. confirm/cancel/arrive/convert 是否检查数据归属
4. 是否只靠前端按钮权限
5. 是否允许前端传 status
6. convert 是否可能重复建工单
7. appointment_id 是否有数据库唯一约束
8. 是否存在事务失效
9. 是否有敏感日志
10. 是否存在 ${} 用户可控 SQL

二百七十七、IDEA 阅读快捷方式

找 Service:

Ctrl + N

二百七十八、找 XML

Ctrl + Shift + N

二百七十九、全局找权限

Ctrl + Shift + F

搜索:

car:appointment:confirm

应该找到:

sys_menu SQL

Controller

Vue

二百八十、找调用

Alt + F7

查看:

convertToWorkOrder

有没有:

多个入口

二百八十一、打断点

重点:

createAppointment

confirmAppointment

cancelAppointment

markArrived

convertToWorkOrder

二百八十二、调试转工单

观察:

appointment.status

insertWorkOrder

updateStatus rows

transaction

二百八十三、数据库检查

SELECT *
FROM car_appointment
ORDER BY appointment_id DESC;

二百八十四、查看工单

SELECT *
FROM car_work_order
WHERE appointment_id = ?;

二百八十五、确认只有一条

SELECT
    appointment_id,
    COUNT(*)
FROM car_work_order
WHERE appointment_id IS NOT NULL
GROUP BY appointment_id
HAVING COUNT(*) > 1;

应该:

0 行

二百八十六、预约状态统计

SELECT
    status,
    COUNT(*)
FROM car_appointment
GROUP BY status;

二百八十七、慢查询后期优化

如果列表慢:

先 EXPLAIN

不要:

先乱加 Redis

二百八十八、预约适合缓存吗

列表:

变化频繁
条件复杂

一般:

不优先缓存

二百八十九、可以缓存什么

预约类型字典

门店列表

服务项目

二百九十、为什么不缓存预约列表

因为:

状态经常变化

查询条件多

数据权限复杂

缓存一致性成本:

高

二百九十一、项目优化原则

不是:

Redis 越多越高级

而是:

热点明确才缓存

二百九十二、面试题 1:预约为什么不是普通 CRUD

答:

预约具有明确生命周期,
例如待确认、已确认、已到店、已转工单、已取消。

不同状态允许的操作不同,
并且涉及权限、业务校验、事务和并发控制。

因此应该通过明确的业务动作接口和 Service 状态机处理,
而不是单纯开放任意 status 修改。

二百九十三、面试题 2:为什么创建预约要校验车辆属于客户

答:

appointment 同时保存 customer_id 和 vehicle_id。

如果只校验两条记录各自存在,
攻击者可以提交一个客户 ID
和另一个客户的车辆 ID。

因此后端必须确认 vehicle.customer_id
与当前 customer_id 一致。

二百九十四、面试题 3:为什么状态 UPDATE 带 oldStatus

答:

如果两个并发请求都读取到同一个旧状态,
只有第一个成功把 oldStatus 更新为 newStatus。

第二个请求执行 WHERE status=oldStatus 时已经匹配不到数据,
可以检测到状态已经变化。

这种条件更新可以用于简单的乐观并发控制。

二百九十五、面试题 4:为什么转工单必须事务

答:

转工单同时包含创建 car_work_order
和更新 car_appointment 状态。

如果只完成其中一步,
系统会出现工单和预约状态不一致。

因此两个操作应该在同一个 Spring 事务中完成。

二百九十六、面试题 5:为什么还需要 appointment_id UNIQUE

答:

事务只能保证一次请求内部的一致性,
不能自动阻止两个并发请求同时尝试创建工单。

即使应用层先查询是否存在,
两个线程仍可能同时查到不存在。

数据库唯一约束可以作为最终防线,
保证一条预约最多只有一张工单。

二百九十七、面试题 6:@RepeatSubmit 能防止重复工单吗

答:

它可以减少用户快速双击导致的重复 HTTP 请求,
但不是完整幂等保证。

真正还需要:
状态条件更新、
事务、
数据库唯一约束。

二百九十八、面试题 7:为什么列表和详情都要数据权限

答:

如果只给列表加 @DataScope,
攻击者仍可以直接猜测 appointmentId
访问详情接口。

因此详情和写操作也必须校验业务数据归属,
避免水平越权。

二百九十九、面试题 8:为什么预约表保存 dept_id

答:

预约属于具体门店业务。

保存 dept_id 后,
可以直接复用若依 @DataScope
按门店或部门范围查询,
也方便统计和索引设计。

三百、面试题 9:为什么不用 changeStatus 通用接口

答:

不同状态动作具有不同业务语义和前置条件。

确认预约、取消预约、客户到店、转工单
分别需要不同权限、校验和副作用。

如果只提供通用 changeStatus,
前端就可能绕过合法状态流转。

三百零一、面试题 10:为什么客户车辆联动要在后端再次校验

答:

前端下拉联动只能改善用户体验,
不能作为安全保证。

用户可以绕过 Vue 页面直接构造 HTTP 请求,
所以后端必须重新检查车辆归属。

三百零二、面试题 11:预约列表为什么不优先 Redis 缓存

答:

预约列表状态变化频繁,
查询条件多,
并且带门店数据权限。

缓存 Key 组合和失效逻辑会非常复杂,
一致性成本较高。

因此应先通过合理索引和分页优化 MySQL,
明确出现性能热点后再考虑缓存。

三百零三、面试题 12:为什么业务编号由后端生成

答:

业务编号必须满足全局规则和唯一性。

如果由前端生成,
不同客户端无法可靠协调,
也容易被篡改或伪造。

因此通常由服务端生成,
再通过数据库唯一约束兜底。

三百零四、预约模块知识树

Appointment
│
├─ Data
│  ├─ car_customer
│  ├─ car_vehicle
│  ├─ car_appointment
│  └─ car_work_order
│
├─ Create
│  ├─ Customer Check
│  ├─ Vehicle Check
│  ├─ Relation Check
│  ├─ Time Check
│  ├─ Number Generator
│  └─ Default Status
│
├─ Status
│  ├─ Pending
│  ├─ Confirmed
│  ├─ Arrived
│  ├─ Converted
│  ├─ Completed
│  └─ Cancelled
│
├─ Action
│  ├─ Confirm
│  ├─ Cancel
│  ├─ Arrive
│  └─ Convert
│
├─ Security
│  ├─ @PreAuthorize
│  ├─ @DataScope
│  ├─ Horizontal Authorization
│  └─ v-hasPermi
│
├─ Consistency
│  ├─ @Transactional
│  ├─ oldStatus Update
│  ├─ UNIQUE
│  └─ @RepeatSubmit
│
└─ Vue
   ├─ Query
   ├─ Customer Select
   ├─ Vehicle Cascade
   ├─ DictTag
   ├─ Dialog
   └─ Action Buttons

三百零五、完整预约主链路

Create
↓
PENDING_CONFIRM
↓ confirm
CONFIRMED
↓ arrive
ARRIVED
↓ convert
CONVERTED
↓
WorkOrder

取消:

PENDING_CONFIRM
↓ cancel
CANCELLED

或:

CONFIRMED
↓ cancel
CANCELLED

三百零六、完整安全链路

Vue Button
↓
v-hasPermi
↓
HTTP
↓
@PreAuthorize
↓
Service
↓
@DataScope / Ownership
↓
State Check
↓
Database

三百零七、完整并发防护链路

Double Click
↓
@RepeatSubmit
↓
Service Transaction
↓
Check Current Status
↓
UPDATE ... WHERE old_status
↓
UNIQUE Constraint
↓
Commit

三百零八、本章最终验收

你应该能独立完成:

1. 创建预约

2. 查询预约

3. 查看预约详情

4. 修改待确认预约

5. 确认预约

6. 取消预约

7. 客户到店

8. 更新到店里程

9. 转服务工单

10. 防止重复工单

11. 门店数据权限

12. 前端客户车辆联动

13. 状态字典

14. 按钮权限

15. Excel 导出

16. 401 / 403 / 越权测试

17. 并发重复测试

三百零九、本章最重要的工程原则

1. 预约不是普通 CRUD

2. 状态不能让前端任意修改

3. 前端联动不能替代后端校验

4. 权限不能只做按钮隐藏

5. 列表有 DataScope,详情和写操作也要防越权

6. 转工单必须事务

7. @RepeatSubmit 不是幂等最终保证

8. 状态条件 UPDATE 可以防并发重复

9. 数据库 UNIQUE 是最终兜底之一

10. 复杂列表先优化 SQL,不要先乱加 Redis

三百一十、下一篇

按照课程表,下一篇进入:

《淘车湾项目实战(三):服务单项与套餐模块分析及实现》

下一章会重点学习:

服务项目 CRUD

服务分类

价格 BigDecimal

项目启停

服务套餐

套餐与服务项目多对多

car_service_package_item

套餐明细维护

批量保存关联表

事务

删除校验

价格快照思想

Vue3 套餐编辑器

动态表格

服务项目选择 Dialog

权限

字典

若依代码生成后的二次改造