# 后端开发最佳实践 ## 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 数据源配置 ```java @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 新增操作公共字段处理 ```java @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 修改操作公共字段处理 ```java @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 前端表单默认值 ```javascript 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 实体类示例 ```java @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值: ```java // 错误:只传递Value值 SysDictionaryItem item = sysDictionaryItemRepository.findByValue(1); // 正确:同时传递DictionaryCode和Value SysDictionaryItem item = sysDictionaryItemRepository.findByDictionaryCodeAndValue("RecordStatus", 1); ``` ### 4.2 字典Repository示例 ```java @Repository public interface SysDictionaryItemRepository extends JpaRepository { @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 findByDictionaryCode(@Param("dictionaryCode") String dictionaryCode); } ``` ## 5. 重复校验规范 ### 5.1 新增操作重复校验 ```java @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 修改操作重复校验 ```java @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加密密码: ```java 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值: ```java user.setPassword(md5("TIMS123456")); ``` ## 7. 删除操作规范 ### 7.1 删除确认 前端必须显示删除确认对话框: ```javascript 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 级联删除 删除主表数据时,必须先删除关联表数据: ```java @Transactional public void deleteUsers(List userIds) { for (String userId : userIds) { // 先删除关联表数据 sysUserRoleRepository.deleteByUserId(userId); // 再删除主表数据 userRepository.deleteById(userId); } } ``` ## 8. SQL查询规范 ### 8.1 使用JdbcTemplate处理动态SQL 对于需要动态拼接SQL的查询,使用JdbcTemplate: ```java @Autowired @Qualifier("writeJdbcTemplate") private JdbcTemplate writeJdbcTemplate; public List queryUsers(UserQuery query) { StringBuilder sql = new StringBuilder("SELECT * FROM Sys_User WHERE 1=1"); List 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函数处理字符串拼接 ```java // 错误:使用+号拼接字符串 @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: ```java public Page 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抛出业务异常: ```java if (userRepository.findByLoginID(userCreateDTO.getLoginId()) != null) { throw new RuntimeException("账号已存在,请使用其他账号"); } ``` ### 9.2 前端错误处理 ```javascript 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注解: ```java @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): ```java @Transactional(readOnly = true) public Page queryUsers(UserQuery query, Pageable pageable) { return userRepository.findAll(pageable); } ``` ## 11. 分页查询规范 ### 11.1 使用Spring Data JPA分页 ```java public Page queryUsers(UserQuery query, Pageable pageable) { return userRepository.findAll(pageable); } ``` ### 11.2 前端分页参数 ```javascript 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记录日志 ```java 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并重定向到登录页面: ```javascript 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: ```javascript 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 后端实现示例 ```java public String queryCustomerName(String agencyId, String shipownerId) { StringBuilder sql = new StringBuilder("SELECT "); List 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 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 实现示例 ```java @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 ports = dispPortRepository.findByName(portName); if (!ports.isEmpty()) { DispPort port = ports.get(0); if (port.getPortType() != null && port.getPortType() == 1) { // 确保是码头 portId = port.getPortId(); List 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注解生效: ```java @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 调用示例 ```java @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 实现示例 ```java 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); } ```