backend-dev-best-practices.md 25 KB

后端开发最佳实践

1. 数据库表位置规则

1.1 数据库说明

本项目使用两个数据库:

  • TugboatCommon:系统公共库,存放所有机构共享的数据
  • LiandaTugboatMIS:分支机构的业务库,存放各自机构的业务数据

1.2 表位置规则

根据FlinkDataSync项目的配置,以下表位于TugboatCommon库:

  • Sys_User(用户表)
  • Sys_Role(角色表)
  • Sys_Menu(菜单表)
  • Sys_UserRole(用户角色关联表)
  • Sys_Department(部门表)
  • Sys_Title(职务表)
  • Sys_Dictionary(字典表)
  • Sys_DictionaryItem(字典元素表)
  • Sys_Parameter(参数表)
  • Sys_SerialNumber(序号表)
  • Sys_log(日志表)
  • Sys_Attachment(附件表)
  • Sys_Announcement(公告)
  • Sys_Announcement_Sys_Role(公告发布角色)
  • Sys_Announcement_Sys_User(公告发布用户)
  • 以及其他以Sys_开头的系统表

业务表位于LiandaTugboatMIS库:

  • Bus_*(业务相关表)
  • Disp_*(调度相关表)
  • Sal_*(薪酬相关表)
  • Man_*(机务相关表)
  • Tug_*(拖轮相关表)
  • Pro_*(项目相关表)
  • Fin_*(财务相关表)
  • Fabric_*(布品相关表)

1.3 读写分离规则

只有tugboatcommon和liandatugboatmis这两个库同时存在的表才需要读写分离:

  • 写入到tugboatcommon库
  • 从liandatugboatmis库读取数据
  • 其他表都在各自的数据库里读写即可

1.4 数据源配置

@Service
@DataSource(value = RoutingDataSourceConfig.DataSourceType.COMMON)
public class UserService {
    // 使用TugboatCommon库
}

@Service
@DataSource(value = RoutingDataSourceConfig.DataSourceType.MIS)
public class BusinessService {
    // 使用LiandaTugboatMIS库
}

2. 公共字段处理规范

2.1 公共字段定义

以下字段为公共字段,所有表如果存在这些字段,都需要在新增和修改操作中处理:

  • RecordStatus:记录状态(1=启用,0=禁用)
  • CreateUserID:创建人ID
  • CreateTime:创建时间
  • ModifyUserID:修改人ID
  • ModifyTime:修改时间

2.2 新增操作公共字段处理

@Transactional
public UserDTO createUser(UserCreateDTO userCreateDTO) {
    User user = new User();
    user.setId(UUID.randomUUID().toString());
    user.setLoginId(userCreateDTO.getLoginId());
    user.setName(userCreateDTO.getName());
    
    // 设置公共字段
    String currentUserId = getCurrentUserId();
    user.setRecordStatus(1); // 新增默认RecordStatus为1(启用)
    user.setCreateUserID(currentUserId); // 创建人为当前登录人
    user.setCreateTime(new Date()); // 创建时间为当前时间
    user.setModifyUserID(currentUserId); // 修改人为当前登录人
    user.setModifyTime(new Date()); // 修改时间为当前时间
    
    user = userRepository.save(user);
    return UserDTO.fromEntity(user);
}

/**
 * 获取当前登录用户ID
 */
private String getCurrentUserId() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (authentication != null && authentication.getPrincipal() instanceof UserDetails) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        return userDetails.getUsername();
    }
    return "admin"; // 默认返回admin
}

2.3 修改操作公共字段处理

@Transactional
public UserDTO updateUser(String userId, UserUpdateDTO userUpdateDTO) {
    User user = userRepository.findById(userId).orElseThrow(() -> 
        new RuntimeException("用户不存在"));
    
    user.setLoginId(userUpdateDTO.getLoginId());
    user.setName(userUpdateDTO.getName());
    
    // 更新公共字段
    String currentUserId = getCurrentUserId();
    user.setModifyUserID(currentUserId); // 修改人为当前登录人
    user.setModifyTime(new Date()); // 修改时间为当前时间
    
    user = userRepository.save(user);
    return UserDTO.fromEntity(user);
}

2.4 前端表单默认值

const addUserForm = reactive({
  loginId: '',
  name: '',
  recordStatus: 1 // 默认设置为1(启用)
})

3. 数据库表名和字段名规则

3.1 表名规则

  • 严格按照数据库结构文档(database_structure.md)中的表名
  • 表名使用PascalCase命名,如:Sys_User、Bus_CustomerCompany
  • 实体类的@Table注解必须与数据库表名完全一致
  • 注意:数据库文档中的表名可能使用下划线分隔,如Sys_User

3.2 字段名规则

  • 严格按照数据库结构文档中的字段名
  • 字段名使用PascalCase命名,如:UserID、LoginID、Name
  • 实体类的@Column注解必须与数据库字段名完全一致
  • 注意:数据库文档中的字段名可能使用下划线分隔,如UserID

3.3 实体类示例

@Entity
@Table(name = "Sys_User")
public class User {
    @Id
    @Column(name = "UserID")
    private String id;
    
    @Column(name = "LoginID")
    private String loginId;
    
    @Column(name = "Name")
    private String name;
    
    @Column(name = "Password")
    private String password;
    
    @Column(name = "RecordStatus")
    private Integer recordStatus;
    
    @Column(name = "CreateUserID")
    private String createUserID;
    
    @Column(name = "CreateTime")
    private Date createTime;
    
    @Column(name = "ModifyUserID")
    private String modifyUserID;
    
    @Column(name = "ModifyTime")
    private Date modifyTime;
}

4. 字典数据查询规范

4.1 字典查询规则

获取Sys_DictionaryItem数据时,必须同时传递DictionaryCode和Value值:

// 错误:只传递Value值
SysDictionaryItem item = sysDictionaryItemRepository.findByValue(1);

// 正确:同时传递DictionaryCode和Value
SysDictionaryItem item = sysDictionaryItemRepository.findByDictionaryCodeAndValue("RecordStatus", 1);

4.2 字典Repository示例

@Repository
public interface SysDictionaryItemRepository extends JpaRepository<SysDictionaryItem, String> {
    @Query(value = "SELECT * FROM Sys_DictionaryItem WHERE DictionaryCode = :dictionaryCode AND Value = :value", nativeQuery = true)
    SysDictionaryItem findByDictionaryCodeAndValue(@Param("dictionaryCode") String dictionaryCode, @Param("value") Integer value);
    
    @Query(value = "SELECT * FROM Sys_DictionaryItem WHERE DictionaryCode = :dictionaryCode ORDER BY OrderNo", nativeQuery = true)
    List<SysDictionaryItem> findByDictionaryCode(@Param("dictionaryCode") String dictionaryCode);
}

5. 重复校验规范

5.1 新增操作重复校验

@Transactional
public UserDTO createUser(UserCreateDTO userCreateDTO) {
    // 重复校验:检查LoginID是否重复
    if (userRepository.findByLoginID(userCreateDTO.getLoginId()) != null) {
        throw new RuntimeException("账号已存在,请使用其他账号");
    }
    
    // 重复校验:检查Name是否重复
    if (userRepository.findByName(userCreateDTO.getName()) != null) {
        throw new RuntimeException("用户名已存在,请使用其他用户名");
    }
    
    // 创建用户...
}

5.2 修改操作重复校验

@Transactional
public UserDTO updateUser(String userId, UserUpdateDTO userUpdateDTO) {
    User user = userRepository.findById(userId).orElseThrow(() -> 
        new RuntimeException("用户不存在"));
    
    // 重复校验:检查LoginID是否与其他用户重复
    User existingUser = userRepository.findByLoginID(userUpdateDTO.getLoginId());
    if (existingUser != null && !existingUser.getId().equals(userId)) {
        throw new RuntimeException("账号已存在,请使用其他账号");
    }
    
    // 重复校验:检查Name是否与其他用户重复
    existingUser = userRepository.findByName(userUpdateDTO.getName());
    if (existingUser != null && !existingUser.getId().equals(userId)) {
        throw new RuntimeException("用户名已存在,请使用其他用户名");
    }
    
    // 更新用户...
}

6. 密码处理规范

6.1 密码加密

使用MD5加密密码:

private String md5(String password) {
    try {
        MessageDigest md = MessageDigest.getInstance("MD5");
        byte[] digest = md.digest(password.getBytes("UTF-8"));
        StringBuilder sb = new StringBuilder();
        for (byte b : digest) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    } catch (Exception e) {
        throw new RuntimeException("密码加密失败", e);
    }
}

6.2 默认密码

新增用户时,默认密码为"TIMS123456"的MD5值:

user.setPassword(md5("TIMS123456"));

7. 删除操作规范

7.1 删除确认

前端必须显示删除确认对话框:

const deleteUser = async (id) => {
  try {
    await ElMessageBox.confirm(
      '确定要删除该用户吗?删除后用户及其角色关联将被永久删除,不可恢复。',
      '删除确认',
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }
    )
    
    await request.delete(`/user/delete/${id}`)
    ElMessage.success('删除成功')
    getUsers()
  } catch (error) {
    if (error !== 'cancel') {
      console.error('删除用户失败:', error)
      ElMessage.error('删除用户失败')
    }
  }
}

7.2 级联删除

删除主表数据时,必须先删除关联表数据:

@Transactional
public void deleteUsers(List<String> userIds) {
    for (String userId : userIds) {
        // 先删除关联表数据
        sysUserRoleRepository.deleteByUserId(userId);
        // 再删除主表数据
        userRepository.deleteById(userId);
    }
}

8. SQL查询规范

8.1 使用JdbcTemplate处理动态SQL

对于需要动态拼接SQL的查询,使用JdbcTemplate:

@Autowired
@Qualifier("writeJdbcTemplate")
private JdbcTemplate writeJdbcTemplate;

public List<User> queryUsers(UserQuery query) {
    StringBuilder sql = new StringBuilder("SELECT * FROM Sys_User WHERE 1=1");
    List<Object> params = new ArrayList<>();
    
    if (query.getLoginId() != null && !query.getLoginId().isEmpty()) {
        sql.append(" AND LoginID LIKE ?");
        params.add("%" + query.getLoginId() + "%");
    }
    
    if (query.getName() != null && !query.getName().isEmpty()) {
        sql.append(" AND Name LIKE ?");
        params.add("%" + query.getName() + "%");
    }
    
    return writeJdbcTemplate.query(sql.toString(), params.toArray(), new UserRowMapper());
}

8.2 使用CONCAT函数处理字符串拼接

// 错误:使用+号拼接字符串
@Query(value = "SELECT * FROM Sys_User WHERE LoginID LIKE '%' + :loginID + '%'", nativeQuery = true)

// 正确:使用CONCAT函数
@Query(value = "SELECT * FROM Sys_User WHERE LoginID LIKE CONCAT('%', :loginID, '%')", nativeQuery = true)
User findByLoginIDLike(@Param("loginID") String loginID);

8.3 排序处理

使用Pageable处理排序,不要在SQL中写死ORDER BY:

public Page<User> queryUsers(UserQuery query, Pageable pageable) {
    Sort sort = pageable.getSort();
    if (query.getSortBy() != null && query.getSortOrder() != null) {
        sort = Sort.by(
            "asc".equalsIgnoreCase(query.getSortOrder()) ? Sort.Direction.ASC : Sort.Direction.DESC,
            query.getSortBy()
        );
    }
    return userRepository.findAll(pageable.withSort(sort));
}

9. 错误处理规范

9.1 业务异常

使用RuntimeException抛出业务异常:

if (userRepository.findByLoginID(userCreateDTO.getLoginId()) != null) {
    throw new RuntimeException("账号已存在,请使用其他账号");
}

9.2 前端错误处理

try {
  await request.post('/user/create', data)
  ElMessage.success('操作成功')
} catch (error) {
  console.error('操作失败:', error)
  ElMessage.error(error.response?.data?.message || '操作失败,请稍后重试')
}

10. 事务管理规范

10.1 使用@Transactional注解

所有涉及多个数据库操作的方法都必须添加@Transactional注解:

@Transactional
public UserDTO createUser(UserCreateDTO userCreateDTO) {
    // 创建用户
    User user = userRepository.save(user);
    
    // 创建用户角色关联
    SysUserRole userRole = new SysUserRole();
    userRole.setUserId(user.getId());
    userRole.setRoleId(userCreateDTO.getRoleId());
    sysUserRoleRepository.save(userRole);
    
    return UserDTO.fromEntity(user);
}

10.2 只读事务

查询方法可以使用@Transactional(readOnly = true):

@Transactional(readOnly = true)
public Page<User> queryUsers(UserQuery query, Pageable pageable) {
    return userRepository.findAll(pageable);
}

11. 分页查询规范

11.1 使用Spring Data JPA分页

public Page<User> queryUsers(UserQuery query, Pageable pageable) {
    return userRepository.findAll(pageable);
}

11.2 前端分页参数

const getUsers = async () => {
  const response = await request.get('/user/list', {
    params: {
      page: currentPage.value,
      pageSize: pageSize.value,
      sortBy: sortMap.value.sortBy,
      sortOrder: sortMap.value.sortOrder
    }
  })
  userList.value = response.data.content
  total.value = response.data.totalElements
}

12. 日志记录规范

12.1 使用SLF4J记录日志

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Service
public class UserService {
    private static final Logger logger = LoggerFactory.getLogger(UserService.class);
    
    public UserDTO createUser(UserCreateDTO userCreateDTO) {
        logger.info("开始创建用户,账号:{}", userCreateDTO.getLoginId());
        try {
            // 创建用户逻辑
            logger.info("用户创建成功,ID:{}", user.getId());
            return UserDTO.fromEntity(user);
        } catch (Exception e) {
            logger.error("用户创建失败", e);
            throw new RuntimeException("用户创建失败", e);
        }
    }
}

13. 注意事项

  1. 不要随意修改数据库结构:数据库结构以database_structure.md文档为准
  2. 不要添加不存在的字段:如果数据库文档没有描述某个字段,就是没有这个字段
  3. 注意表名和字段名的大小写:严格按照数据库文档的大小写
  4. 使用正确的数据源:根据表的位置选择正确的数据源
  5. 处理公共字段:所有新增和修改操作都必须处理公共字段
  6. 添加重复校验:新增和修改操作都必须进行重复校验
  7. 使用事务管理:涉及多个数据库操作的方法必须使用@Transactional
  8. 记录日志:重要操作必须记录日志
  9. 处理异常:使用RuntimeException抛出业务异常,前端统一处理错误提示
  10. 使用MD5加密密码:密码必须使用MD5加密存储

14. 前端认证错误处理规范

14.1 401和403错误处理

前端必须正确处理401(未授权)和403(禁止访问)错误,自动清除token并重定向到登录页面:

request.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (error.response) {
      switch (error.response.status) {
        case 401:
          console.error('未授权,请重新登录')
          localStorage.removeItem('token')
          localStorage.removeItem('userInfo')
          window.location.href = '/login'
          break
        case 403:
          console.error('没有权限访问,请重新登录')
          localStorage.removeItem('token')
          localStorage.removeItem('userInfo')
          window.location.href = '/login'
          break
        case 404:
          console.error('请求的资源不存在')
          break
        case 500:
          console.error('服务器错误')
          break
        default:
          console.error('请求失败:', error.response.status)
      }
    } else if (error.request) {
      console.error('网络错误,请检查网络连接')
    } else {
      console.error('请求配置错误:', error.message)
    }
    return Promise.reject(error)
  }
)

14.2 请求拦截器

每次请求都必须在请求头中添加Bearer token:

request.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers['Authorization'] = 'Bearer ' + token
    }
    return config
  },
  error => {
    console.error('请求错误:', error)
    return Promise.reject(error)
  }
)

15. 客户名称显示规范

15.1 客户名称显示逻辑

在调度列表查询和导出时,客户名称的显示规则如下:

  • 如果同时存在代理公司和船公司,则显示为:代理公司名 + 空格 + 船公司名
  • 如果只有代理公司,则只显示代理公司名
  • 如果只有船公司,则只显示船公司名
  • 如果两者都不存在,则显示为空

15.2 后端实现示例

public String queryCustomerName(String agencyId, String shipownerId) {
    StringBuilder sql = new StringBuilder("SELECT ");
    
    List<String> names = new ArrayList<>();
    boolean hasAgency = agencyId != null && !agencyId.isEmpty();
    boolean hasShipowner = shipownerId != null && !shipownerId.isEmpty();
    
    if (hasAgency) {
        sql.append("(SELECT ccb.BusinessCode FROM Bus_CustomerCompanyBusiness ccb WHERE ccb.CustomerCompanyBusinessID = ?) AS AgencyName");
        names.add("AgencyName");
    }
    
    if (hasShipowner) {
        if (hasAgency) {
            sql.append(", ");
        }
        sql.append("(SELECT ccb.BusinessCode FROM Bus_CustomerCompanyBusiness ccb WHERE ccb.CustomerCompanyBusinessID = ?) AS ShipownerName");
        names.add("ShipownerName");
    }
    
    if (names.isEmpty()) {
        return "";
    }
    
    try {
        List<Object> params = new ArrayList<>();
        if (hasAgency) {
            params.add(agencyId);
        }
        if (hasShipowner) {
            params.add(shipownerId);
        }
        
        return jdbcTemplate.queryForObject(sql.toString(), (rs, rowNum) -> {
            StringBuilder result = new StringBuilder();
            for (String name : names) {
                String value = rs.getString(name);
                if (value != null && !value.isEmpty()) {
                    if (result.length() > 0) {
                        result.append(" ");
                    }
                    result.append(value);
                }
            }
            return result.toString();
        }, params.toArray());
    } catch (Exception e) {
        return "";
    }
}

16. 引航计划PortID填充规范

16.1 PortID填充逻辑

引航计划导入后复制到调度表时,PortID的填充逻辑如下:

第一步:通过Disp_BerthageDictionary的Keyword完全匹配泊位名称

  • 如果匹配成功,读取BerthageID
  • 通过Disp_Berthage表查找对应的记录,获取PortID
  • 检查Disp_Port表中该PortID的PortType,如果是1(码头)且还未匹配到PortID,则使用该PortID

第二步:如果第一步无法匹配,用Disp_PortDictionary的PortKeyword模糊匹配泊位名称

  • 如果匹配成功,港口名称为Disp_PortDictionary.Name
  • 泊位名称为除港口名称外的剩余部分

第三步:如果第二步没有找到,检查是否为"数字+#号"格式

  • 如果是,数字+#号的部分是泊位名称,其余部分是港口名称
  • 匹配成功的港口都是码头(PortType=1)

第四步:如果还没找到,说明是锚地

  • 港口名称=泊位名称=导入的泊位名称
  • PortType值为2

第五步:用找到的港口名称、泊位名称查找对应的记录

  • 如果数据库中没有记录,则新增记录(Disp_Port、Disp_Berthage、Disp_BerthageSetting)
  • Disp_BerthageSetting的IsInner字段默认为1

16.2 实现示例

@DataSource(value = RoutingDataSourceConfig.DataSourceType.COMMON)
@Transactional
public BerthageInfo findOrCreateBerthageInfo(String berthName) {
    String portId = null;
    String berthageId = null;
    String portName = null;
    String actualBerthName = null;

    // 第一步:通过Disp_BerthageDictionary的Keyword完全匹配
    // ...实现第一步逻辑...

    // 第二步:用Disp_PortDictionary的PortKeyword模糊匹配
    // ...实现第二步逻辑...

    // 第三步:检查是否为"数字+#号"格式
    if (portId == null) {
        Pattern pattern = Pattern.compile("^(.+?)\\s*(\\d+#)$");
        Matcher matcher = pattern.matcher(berthName);
        if (matcher.matches()) {
            portName = matcher.group(1).trim();
            actualBerthName = matcher.group(2);

            // 查找对应的Port和Berthage记录
            List<DispPort> ports = dispPortRepository.findByName(portName);
            if (!ports.isEmpty()) {
                DispPort port = ports.get(0);
                if (port.getPortType() != null && port.getPortType() == 1) {
                    // 确保是码头
                    portId = port.getPortId();
                    List<DispBerthage> berthages = dispBerthageRepository.findByNameAndPortId(actualBerthName, portId);
                    if (!berthages.isEmpty()) {
                        berthageId = berthages.get(0).getBerthageId();
                    }
                }
            }
        }
    }

    // 第四步:锚地处理
    // ...实现第四步逻辑...

    // 第五步:新增记录
    // ...实现第五步逻辑...

    return new BerthageInfo(berthageId, portId);
}

17. CommonDataService使用规范

17.1 为什么需要CommonDataService

@DataSource注解在同一个Service类的内部方法调用时不会生效,因为Spring AOP只对代理对象的方法调用有效。当在同一个Service类内部调用方法时,不会经过代理,因此@DataSource注解不会生效。

17.2 解决方案

创建专门的CommonDataService来处理跨切面的数据操作,确保@DataSource注解生效:

@Service
public class CommonDataService {
    
    /**
     * 查找或创建泊位信息
     * @param berthName 泊位名称
     * @return 泊位信息
     */
    @DataSource(value = RoutingDataSourceConfig.DataSourceType.COMMON)
    @Transactional
    public BerthageInfo findOrCreateBerthageInfo(String berthName) {
        // 实现逻辑...
    }
    
    /**
     * 查找或创建港口信息
     * @param portName 港口名称
     * @param portType 港口类型
     * @return 港口信息
     */
    @DataSource(value = RoutingDataSourceConfig.DataSourceType.COMMON)
    @Transactional
    public PortInfo findOrCreatePortInfo(String portName, Integer portType) {
        // 实现逻辑...
    }
}

17.3 调用示例

@Service
public class PilotPlanService {
    
    @Autowired
    private CommonDataService commonDataService;
    
    private void syncToDispatcherTable(DispPilotPlan pilotPlan, PilotPlanImportDTO importDTO) {
        // 调用CommonDataService的方法,@DataSource注解会生效
        CommonDataService.BerthageInfo fromBerthageInfo = commonDataService.findOrCreateBerthageInfo(importDTO.getFromBerthage());
        CommonDataService.BerthageInfo toBerthageInfo = commonDataService.findOrCreateBerthageInfo(importDTO.getToBerthage());
        
        // 使用返回的BerthageID和PortID...
    }
}

18. 引航计划复制到调度表规范

18.1 字段复制规则

引航计划导入后复制到调度表时,必须复制以下字段:

  • WorkTime:从PlanTime复制
  • PortID:根据FromBerthageID和ToBerthageID计算得出
  • FromBerthageID:直接复制
  • ToBerthageID:直接复制
  • ShipID:直接复制
  • Deep:直接复制
  • AgencyID:直接复制
  • PilotTypeID:从PilotType复制
  • PilotID:从MainPilotID复制(主引)
  • Remark:直接复制(备注)
  • StartTime:根据PlanTime和PilotType计算得出
  • EndTime:根据PlanTime和PilotType计算得出

18.2 实现示例

private void syncToDispatcherTable(DispPilotPlan pilotPlan, PilotPlanImportDTO importDTO, 
                                   CommonDataService.BerthageInfo fromBerthageInfo, 
                                   CommonDataService.BerthageInfo toBerthageInfo) {
    DispDispatcher dispatcher = dispDispatcherRepository.findByPilotPlanId(pilotPlan.getPilotPlanId());
    
    Date currentTime = new Date();
    String currentUserId = getCurrentUserId();
    
    if (dispatcher == null) {
        dispatcher = new DispDispatcher();
        dispatcher.setDispatcherId(UUID.randomUUID().toString().replace("-", ""));
        dispatcher.setPilotPlanId(pilotPlan.getPilotPlanId());
        dispatcher.setRecordStatus(1);
        dispatcher.setCreateTime(currentTime);
        dispatcher.setCreateUserId(currentUserId);
    }
    
    // 复制字段
    dispatcher.setWorkTime(pilotPlan.getPlanTime());
    dispatcher.setPortID(toBerthageInfo.getPortId());
    dispatcher.setFromBerthageId(pilotPlan.getFromBerthageId());
    dispatcher.setToBerthageId(pilotPlan.getToBerthageId());
    dispatcher.setShipId(pilotPlan.getShipId());
    dispatcher.setDeep(pilotPlan.getDeep());
    dispatcher.setAgencyId(pilotPlan.getAgencyId());
    dispatcher.setPilotTypeId(pilotPlan.getPilotType());
    dispatcher.setPilotId(pilotPlan.getMainPilotId()); // 复制主引
    dispatcher.setRemark(pilotPlan.getRemark()); // 复制备注
    
    // 计算StartTime和EndTime
    if (pilotPlan.getPilotType() != null) {
        // 根据PilotType计算开始和结束时间
        // ...实现计算逻辑...
    }
    
    dispatcher.setModifyTime(currentTime);
    dispatcher.setModifyUserId(currentUserId);
    
    dispDispatcherRepository.save(dispatcher);
}