完善招聘信息主键关联与工作经历维护

This commit is contained in:
wkc
2026-05-07 01:04:23 +08:00
parent 4d1acc7484
commit 3bc60fedeb
22 changed files with 584 additions and 191 deletions

View File

@@ -51,6 +51,7 @@
"msg": "查询成功",
"rows": [
{
"id": 1002,
"recruitId": "REC20250205001",
"recruitName": "2025春季校园招聘",
"posName": "Java开发工程师",
@@ -80,19 +81,19 @@
### 1.2 查询招聘信息详情
**接口描述:** 根据招聘项目编号查询详细信息
**接口描述:** 根据招聘信息主键ID查询详细信息
**请求方式:** `GET`
**接口路径:** `/ccdi/staffRecruitment/{recruitId}`
**接口路径:** `/ccdi/staffRecruitment/{id}`
**权限标识:** `ccdi:staffRecruitment:query`
**路径参数:**
| 参数名 | 类型 | 必填 | 说明 | 示例值 |
|-----------|--------|----|--------|----------------|
| recruitId | String | 是 | 招聘项目编号 | REC20250205001 |
| 参数名 | 类型 | 必填 | 说明 | 示例值 |
|------|------|----|--------------|-----|
| id | Long | 是 | 招聘信息主键ID | 1002 |
**响应示例:**
@@ -101,6 +102,7 @@
"code": 200,
"msg": "操作成功",
"data": {
"id": 1002,
"recruitId": "REC20250205001",
"recruitName": "2025春季校园招聘",
"posName": "Java开发工程师",
@@ -237,15 +239,15 @@
**请求方式:** `DELETE`
**接口路径:** `/ccdi/staffRecruitment/{recruitIds}`
**接口路径:** `/ccdi/staffRecruitment/{ids}`
**权限标识:** `ccdi:staffRecruitment:remove`
**路径参数:**
| 参数名 | 类型 | 必填 | 说明 | 示例值 |
|------------|----------|----|------------------|-------------------------------|
| recruitIds | String[] | 是 | 招聘项目编号数组,多个用逗号分隔 | REC20250205001,REC20250205002 |
| 参数名 | 类型 | 必填 | 说明 | 示例值 |
|------|------|----|-----------------------|----------|
| ids | Long[] | 是 | 招聘信息主键ID数组,多个用逗号分隔 | 1002,1003 |
**响应示例:**
@@ -276,7 +278,7 @@
| 序号 | 字段名 | 说明 | 必填 |
|----|----------|-----------|----|
| 1 | 招聘项目编号 | 唯一标识 | 是 |
| 1 | 招聘项目编号 | 允许重复 | 是 |
| 2 | 招聘项目名称 | - | 是 |
| 3 | 职位名称 | - | 是 |
| 4 | 职位类别 | - | 是 |
@@ -326,7 +328,7 @@
```json
{
"code": 500,
"msg": "很抱歉,导入完成!成功 8 条,失败 2 条,错误如下:<br/>1、招聘项目编号 REC001 导入失败:该招聘项目编号已存在<br/>2、招聘项目编号 REC002 导入失败:证件号码格式不正确"
"msg": "很抱歉,导入完成!成功 8 条,失败 2 条,错误如下:<br/>1、招聘项目编号 REC001 导入失败:历史工作经历匹配到多条招聘主信息<br/>2、招聘项目编号 REC002 导入失败:证件号码格式不正确"
}
```
@@ -375,14 +377,14 @@ Excel导入导出对象,使用EasyExcel注解。
| 401 | 未授权,请先登录 |
| 403 | 无权限访问 |
| 404 | 资源不存在 |
| 409 | 主键冲突 |
| 409 | 数据冲突 |
| 500 | 服务器内部错误 |
### 常见业务错误
| 错误信息 | 说明 |
|------------|--------------------|
| 该招聘项目编号已存在 | 新增时recruitId重复 |
| 历史工作经历匹配到多条招聘主信息 | 招聘项目编号重复且候选人、项目名、职位名仍无法唯一匹配从表归属 |
| 招聘项目编号不能为空 | recruitId字段为空 |
| 证件号码格式不正确 | 身份证号格式验证失败 |
| 毕业年月格式不正确 | candGrad不是YYYYMM格式 |

View File

@@ -1,22 +1,23 @@
4.员工招聘信息表ccdi_staff_recruitment,,,,,,
序号,字段名,类型,默认值,是否可为空,是否主键,注释
1,recruit_id,VARCHAR(32),,,,招聘项目编号
2,recruit_name,VARCHAR(100),,,,招聘项目名称
3,pos_name,VARCHAR(100),,,,职位名称
4,pos_category,VARCHAR(50),,,,职位类别
5,pos_desc,TEXT,,,,职位描述
6,cand_name,VARCHAR(20),,,,应聘人员姓名
7,cand_edu,VARCHAR(20),,,,应聘人员学历
8,cand_id,VARCHAR(18),,,,应聘人员证件号码
9,cand_school,VARCHAR(50),,,,应聘人员毕业院校
10,cand_major,VARCHAR(30),,,,应聘人员专业
11,cand_grad,VARCHAR(6),,,,应聘人员毕业年月
12,admit_status,VARCHAR(10),,,,记录录用情况:录用、未录用、放弃等
13,interviewer_name1,VARCHAR(20),,,,面试官1姓名
14,interviewer_id1,VARCHAR(10),,,,面试官1工号
13,interviewer_name2,VARCHAR(20),,,,面试官2姓名
14,interviewer_id2,VARCHAR(10),,,,面试官2工号
16,created_by,VARCHAR(20),-,,,记录创建人
17,updated_by,VARCHAR(20),-,,,记录更新
18,create_time,VARCHAR(10),0000-00-00,,,创建时间
19,update_time,VARCHAR(10),0000-00-00,,,更新时间
1,id,BIGINT,,,,主键ID
2,recruit_id,VARCHAR(32),,,,招聘项目编号(允许重复)
3,recruit_name,VARCHAR(100),,,,招聘项目名称
4,pos_name,VARCHAR(100),,,,职位名称
5,pos_category,VARCHAR(50),,,,职位类别
6,pos_desc,TEXT,,,,职位描述
7,cand_name,VARCHAR(20),,,,应聘人员姓名
8,cand_edu,VARCHAR(20),,,,应聘人员学历
9,cand_id,VARCHAR(18),,,,应聘人员证件号码
10,cand_school,VARCHAR(50),,,,应聘人员毕业院校
11,cand_major,VARCHAR(30),,,,应聘人员专业
12,cand_grad,VARCHAR(6),,,,应聘人员毕业年月
13,admit_status,VARCHAR(10),,,,记录录用情况:录用、未录用、放弃等
14,interviewer_name1,VARCHAR(20),,,,面试官1姓名
15,interviewer_id1,VARCHAR(10),,,,面试官1工号
16,interviewer_name2,VARCHAR(20),,,,面试官2姓名
17,interviewer_id2,VARCHAR(10),,,,面试官2工号
18,created_by,VARCHAR(20),-,,,记录创建
19,updated_by,VARCHAR(20),-,,,记录更新人
20,create_time,VARCHAR(10),0000-00-00,,,创建时间
21,update_time,VARCHAR(10),0000-00-00,,,更新时间
1 4.员工招聘信息表:ccdi_staff_recruitment
2 序号 字段名 类型 默认值 是否可为空 是否主键 注释
3 1 recruit_id id VARCHAR(32) BIGINT 招聘项目编号 主键ID
4 2 recruit_name recruit_id VARCHAR(100) VARCHAR(32) 招聘项目名称 招聘项目编号(允许重复)
5 3 pos_name recruit_name VARCHAR(100) 职位名称 招聘项目名称
6 4 pos_category pos_name VARCHAR(50) VARCHAR(100) 职位类别 职位名称
7 5 pos_desc pos_category TEXT VARCHAR(50) 职位描述 职位类别
8 6 cand_name pos_desc VARCHAR(20) TEXT 应聘人员姓名 职位描述
9 7 cand_edu cand_name VARCHAR(20) 应聘人员学历 应聘人员姓名
10 8 cand_id cand_edu VARCHAR(18) VARCHAR(20) 应聘人员证件号码 应聘人员学历
11 9 cand_school cand_id VARCHAR(50) VARCHAR(18) 应聘人员毕业院校 应聘人员证件号码
12 10 cand_major cand_school VARCHAR(30) VARCHAR(50) 应聘人员专业 应聘人员毕业院校
13 11 cand_grad cand_major VARCHAR(6) VARCHAR(30) 应聘人员毕业年月 应聘人员专业
14 12 admit_status cand_grad VARCHAR(10) VARCHAR(6) 记录录用情况:录用、未录用、放弃等 应聘人员毕业年月
15 13 interviewer_name1 admit_status VARCHAR(20) VARCHAR(10) 面试官1姓名 记录录用情况:录用、未录用、放弃等
16 14 interviewer_id1 interviewer_name1 VARCHAR(10) VARCHAR(20) 面试官1工号 面试官1姓名
17 13 15 interviewer_name2 interviewer_id1 VARCHAR(20) VARCHAR(10) 面试官2姓名 面试官1工号
18 14 16 interviewer_id2 interviewer_name2 VARCHAR(10) VARCHAR(20) 面试官2工号 面试官2姓名
19 16 17 created_by interviewer_id2 VARCHAR(20) VARCHAR(10) - 记录创建人 面试官2工号
20 17 18 updated_by created_by VARCHAR(20) - 记录更新人 记录创建人
21 18 19 create_time updated_by VARCHAR(10) VARCHAR(20) 0000-00-00 - 创建时间 记录更新人
22 19 20 update_time create_time VARCHAR(10) 0000-00-00 更新时间 创建时间
23 21 update_time VARCHAR(10) 0000-00-00 更新时间

View File

@@ -69,9 +69,9 @@ public class CcdiStaffRecruitmentController extends BaseController {
*/
@Operation(summary = "获取招聘信息详细信息")
@PreAuthorize("@ss.hasPermi('ccdi:staffRecruitment:query')")
@GetMapping(value = "/{recruitId}")
public AjaxResult getInfo(@PathVariable String recruitId) {
return success(recruitmentService.selectRecruitmentById(recruitId));
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable Long id) {
return success(recruitmentService.selectRecruitmentById(id));
}
/**
@@ -102,9 +102,9 @@ public class CcdiStaffRecruitmentController extends BaseController {
@Operation(summary = "删除招聘信息")
@PreAuthorize("@ss.hasPermi('ccdi:staffRecruitment:remove')")
@Log(title = "员工招聘信息", businessType = BusinessType.DELETE)
@DeleteMapping("/{recruitIds}")
public AjaxResult remove(@PathVariable String[] recruitIds) {
return toAjax(recruitmentService.deleteRecruitmentByIds(recruitIds));
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids) {
return toAjax(recruitmentService.deleteRecruitmentByIds(ids));
}
/**

View File

@@ -22,8 +22,11 @@ public class CcdiStaffRecruitment implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 主键ID */
@TableId(type = IdType.AUTO)
private Long id;
/** 招聘记录编号 */
@TableId(type = IdType.INPUT)
private String recruitId;
/** 招聘项目名称 */

View File

@@ -28,6 +28,9 @@ public class CcdiStaffRecruitmentWork implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
/** 关联招聘信息主键ID */
private Long recruitmentId;
/** 关联招聘记录编号 */
private String recruitId;

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.domain.dto;
import com.ruoyi.info.collection.annotation.EnumValid;
import com.ruoyi.info.collection.enums.AdmitStatus;
import com.ruoyi.info.collection.enums.RecruitType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
@@ -10,6 +11,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
/**
* 员工招聘信息新增DTO
@@ -102,4 +104,8 @@ public class CcdiStaffRecruitmentAddDTO implements Serializable {
/** 面试官2工号 */
@Size(max = 10, message = "面试官2工号长度不能超过10个字符")
private String interviewerId2;
/** 历史工作经历列表 */
@Valid
private List<CcdiStaffRecruitmentWorkEditDTO> workExperienceList;
}

View File

@@ -26,8 +26,13 @@ public class CcdiStaffRecruitmentEditDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 主键ID */
@NotNull(message = "招聘信息ID不能为空")
private Long id;
/** 招聘记录编号 */
@NotNull(message = "招聘记录编号不能为空")
@NotBlank(message = "招聘记录编号不能为空")
@Size(max = 32, message = "招聘记录编号长度不能超过32个字符")
private String recruitId;
/** 招聘项目名称 */

View File

@@ -19,6 +19,9 @@ public class CcdiStaffRecruitmentVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 主键ID */
private Long id;
/** 招聘记录编号 */
private String recruitId;

View File

@@ -30,10 +30,10 @@ public interface CcdiStaffRecruitmentMapper extends BaseMapper<CcdiStaffRecruitm
/**
* 查询招聘信息详情
*
* @param recruitId 招聘项目编号
* @param id 主键ID
* @return 招聘信息VO
*/
CcdiStaffRecruitmentVO selectRecruitmentById(@Param("recruitId") String recruitId);
CcdiStaffRecruitmentVO selectRecruitmentById(@Param("id") Long id);
/**
* 批量插入招聘信息数据

View File

@@ -46,10 +46,10 @@ public interface ICcdiStaffRecruitmentService {
/**
* 查询招聘信息详情
*
* @param recruitId 招聘项目编号
* @param id 主键ID
* @return 招聘信息VO
*/
CcdiStaffRecruitmentVO selectRecruitmentById(String recruitId);
CcdiStaffRecruitmentVO selectRecruitmentById(Long id);
/**
* 新增招聘信息
@@ -70,10 +70,10 @@ public interface ICcdiStaffRecruitmentService {
/**
* 批量删除招聘信息
*
* @param recruitIds 需要删除的招聘项目编号
* @param ids 需要删除的招聘信息ID
* @return 结果
*/
int deleteRecruitmentByIds(String[] recruitIds);
int deleteRecruitmentByIds(Long[] ids);
/**
* 导入招聘信息数据

View File

@@ -165,12 +165,8 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
return new MainImportResult(Collections.emptyMap(), 0);
}
Set<String> existingRecruitIds = getExistingRecruitIds(
mainRows.stream().map(MainImportRow::data).toList()
);
Set<String> processedRecruitIds = new HashSet<>();
List<CcdiStaffRecruitment> newRecords = new ArrayList<>();
Map<String, CcdiStaffRecruitment> importedRecruitmentMap = new LinkedHashMap<>();
Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> importedRecruitmentMap = new LinkedHashMap<>();
int successCount = 0;
for (int index = 0; index < mainRows.size(); index++) {
MainImportRow mainRow = mainRows.get(index);
@@ -183,31 +179,17 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
validateRecruitmentData(addDTO, mainRow.sheetRowNum());
String recruitId = trim(excel.getRecruitId());
if (existingRecruitIds.contains(recruitId)) {
throw buildValidationException(
MAIN_SHEET_NAME,
List.of(mainRow.sheetRowNum()),
String.format("招聘记录编号[%s]已存在,请勿重复导入", recruitId)
);
}
if (!processedRecruitIds.add(recruitId)) {
throw buildValidationException(
MAIN_SHEET_NAME,
List.of(mainRow.sheetRowNum()),
String.format("招聘记录编号[%s]在导入文件中重复,已跳过此条记录", recruitId)
);
}
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
BeanUtils.copyProperties(excel, recruitment);
recruitment.setRecruitId(recruitId);
recruitment.setRecruitType(addDTO.getRecruitType());
recruitment.setCreatedBy(userName);
recruitment.setUpdatedBy(userName);
newRecords.add(recruitment);
importedRecruitmentMap.put(recruitId, recruitment);
recruitmentMapper.insert(recruitment);
successCount++;
addRecruitment(importedRecruitmentMap, recruitment);
ImportLogUtils.logProgress(log, taskId, index + 1, mainRows.size(), newRecords.size(), failures.size());
ImportLogUtils.logProgress(log, taskId, index + 1, mainRows.size(), successCount, failures.size());
} catch (Exception exception) {
FailureMeta failureMeta = resolveFailureMeta(exception, List.of(mainRow.sheetRowNum()), MAIN_SHEET_NAME);
failures.add(buildFailure(excel, failureMeta.sheetName(), failureMeta.sheetRowNum(), exception.getMessage()));
@@ -221,16 +203,11 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
}
}
if (!newRecords.isEmpty()) {
ImportLogUtils.logBatchOperationStart(log, taskId, "插入招聘信息", (newRecords.size() + 499) / 500, 500);
saveBatch(newRecords, 500);
}
return new MainImportResult(importedRecruitmentMap, newRecords.size());
return new MainImportResult(importedRecruitmentMap, successCount);
}
private int importWorkSheet(List<WorkImportRow> workRows,
Map<String, CcdiStaffRecruitment> importedRecruitmentMap,
Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> importedRecruitmentMap,
List<RecruitmentImportFailureVO> failures,
String userName,
String taskId) {
@@ -238,7 +215,7 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
return 0;
}
Map<String, CcdiStaffRecruitment> existingRecruitmentMap =
Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> existingRecruitmentMap =
getExistingRecruitmentMap(workRows, importedRecruitmentMap);
Map<String, List<WorkImportRow>> groupedRows = groupWorkRows(workRows);
int successCount = 0;
@@ -248,15 +225,18 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
processedGroups++;
WorkImportRow firstRow = recruitWorkRows.get(0);
String recruitId = trim(firstRow.data().getRecruitId());
CcdiStaffRecruitment recruitment = importedRecruitmentMap.get(recruitId);
if (recruitment == null) {
recruitment = existingRecruitmentMap.get(recruitId);
}
try {
RecruitmentMatchKey matchKey = buildMatchKey(firstRow.data());
CcdiStaffRecruitment recruitment = resolveMatchedRecruitment(
matchKey,
importedRecruitmentMap,
existingRecruitmentMap,
extractWorkRowNums(recruitWorkRows)
);
validateWorkGroup(recruitWorkRows, recruitment);
if (StringUtils.isNotEmpty(recruitId) && hasExistingWorkHistory(recruitId)) {
if (recruitment != null && hasExistingWorkHistory(recruitment.getId())) {
throw buildValidationException(
WORK_SHEET_NAME,
extractWorkRowNums(recruitWorkRows),
@@ -264,7 +244,7 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
);
}
List<CcdiStaffRecruitmentWork> entities = buildWorkEntities(recruitWorkRows, userName);
List<CcdiStaffRecruitmentWork> entities = buildWorkEntities(recruitWorkRows, recruitment, userName);
entities.forEach(entity -> recruitmentWorkMapper.insert(entity));
successCount += recruitWorkRows.size();
@@ -299,33 +279,59 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
}
private String buildWorkGroupKey(WorkImportRow workRow) {
String recruitId = trim(workRow.data().getRecruitId());
if (StringUtils.isNotEmpty(recruitId)) {
return recruitId;
RecruitmentMatchKey key = buildMatchKey(workRow.data());
if (key.isComplete()) {
return key.value();
}
return "__ROW__" + workRow.sheetRowNum();
}
private Map<String, CcdiStaffRecruitment> getExistingRecruitmentMap(List<WorkImportRow> workRows,
Map<String, CcdiStaffRecruitment> importedRecruitmentMap) {
private Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> getExistingRecruitmentMap(
List<WorkImportRow> workRows,
Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> importedRecruitmentMap
) {
LinkedHashSet<String> recruitIds = workRows.stream()
.filter(row -> !importedRecruitmentMap.containsKey(buildMatchKey(row.data())))
.map(row -> trim(row.data().getRecruitId()))
.filter(StringUtils::isNotEmpty)
.filter(recruitId -> !importedRecruitmentMap.containsKey(recruitId))
.collect(Collectors.toCollection(LinkedHashSet::new));
if (recruitIds.isEmpty()) {
return Collections.emptyMap();
}
List<CcdiStaffRecruitment> recruitments = recruitmentMapper.selectBatchIds(recruitIds);
return recruitments.stream().collect(Collectors.toMap(CcdiStaffRecruitment::getRecruitId, item -> item));
List<CcdiStaffRecruitment> recruitments = selectRecruitmentsByRecruitIds(recruitIds);
Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> result = new LinkedHashMap<>();
recruitments.forEach(item -> addRecruitment(result, item));
return result;
}
private List<CcdiStaffRecruitmentWork> buildWorkEntities(List<WorkImportRow> workRows, String userName) {
private CcdiStaffRecruitment resolveMatchedRecruitment(
RecruitmentMatchKey matchKey,
Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> importedRecruitmentMap,
Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> existingRecruitmentMap,
List<Integer> rowNums
) {
List<CcdiStaffRecruitment> matchedRecruitments = new ArrayList<>();
matchedRecruitments.addAll(importedRecruitmentMap.getOrDefault(matchKey, Collections.emptyList()));
matchedRecruitments.addAll(existingRecruitmentMap.getOrDefault(matchKey, Collections.emptyList()));
if (matchedRecruitments.size() > 1) {
throw buildValidationException(
WORK_SHEET_NAME,
rowNums,
String.format("招聘记录编号[%s]匹配到多条招聘主信息,无法确定历史工作经历归属", matchKey.recruitId())
);
}
return matchedRecruitments.isEmpty() ? null : matchedRecruitments.get(0);
}
private List<CcdiStaffRecruitmentWork> buildWorkEntities(List<WorkImportRow> workRows,
CcdiStaffRecruitment recruitment,
String userName) {
List<CcdiStaffRecruitmentWork> entities = new ArrayList<>();
for (WorkImportRow workRow : workRows) {
CcdiStaffRecruitmentWork entity = new CcdiStaffRecruitmentWork();
BeanUtils.copyProperties(workRow.data(), entity);
entity.setRecruitId(trim(workRow.data().getRecruitId()));
entity.setRecruitmentId(recruitment.getId());
entity.setRecruitId(recruitment.getRecruitId());
entity.setCreatedBy(userName);
entity.setUpdatedBy(userName);
entities.add(entity);
@@ -333,29 +339,9 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
return entities;
}
private Set<String> getExistingRecruitIds(List<CcdiStaffRecruitmentExcel> recruitmentList) {
List<String> recruitIds = recruitmentList.stream()
.map(CcdiStaffRecruitmentExcel::getRecruitId)
.map(this::trim)
.filter(StringUtils::isNotEmpty)
.toList();
if (recruitIds.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<CcdiStaffRecruitment> wrapper = new LambdaQueryWrapper<>();
wrapper.in(CcdiStaffRecruitment::getRecruitId, recruitIds);
List<CcdiStaffRecruitment> existingRecruitments = recruitmentMapper.selectList(wrapper);
return existingRecruitments.stream()
.map(CcdiStaffRecruitment::getRecruitId)
.collect(Collectors.toSet());
}
private boolean hasExistingWorkHistory(String recruitId) {
private boolean hasExistingWorkHistory(Long recruitmentId) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiStaffRecruitmentWork::getRecruitId, recruitId);
wrapper.eq(CcdiStaffRecruitmentWork::getRecruitmentId, recruitmentId);
return recruitmentWorkMapper.selectCount(wrapper) > 0;
}
@@ -568,30 +554,36 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
redisTemplate.opsForHash().putAll(key, statusData);
}
private void saveBatch(List<CcdiStaffRecruitment> list, int batchSize) {
for (int i = 0; i < list.size(); i += batchSize) {
int end = Math.min(i + batchSize, list.size());
List<CcdiStaffRecruitment> subList = list.subList(i, end);
List<String> recruitIds = subList.stream()
.map(CcdiStaffRecruitment::getRecruitId)
.toList();
if (recruitIds.isEmpty()) {
continue;
}
List<CcdiStaffRecruitment> existingRecords = recruitmentMapper.selectBatchIds(recruitIds);
Set<String> existingIds = existingRecords.stream()
.map(CcdiStaffRecruitment::getRecruitId)
.collect(Collectors.toSet());
List<CcdiStaffRecruitment> toInsert = subList.stream()
.filter(record -> !existingIds.contains(record.getRecruitId()))
.toList();
if (!toInsert.isEmpty()) {
recruitmentMapper.insertBatch(toInsert);
}
private List<CcdiStaffRecruitment> selectRecruitmentsByRecruitIds(Set<String> recruitIds) {
if (recruitIds == null || recruitIds.isEmpty()) {
return Collections.emptyList();
}
LambdaQueryWrapper<CcdiStaffRecruitment> wrapper = new LambdaQueryWrapper<>();
wrapper.in(CcdiStaffRecruitment::getRecruitId, recruitIds);
return recruitmentMapper.selectList(wrapper);
}
private void addRecruitment(Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> map,
CcdiStaffRecruitment recruitment) {
map.computeIfAbsent(buildMatchKey(recruitment), key -> new ArrayList<>()).add(recruitment);
}
private RecruitmentMatchKey buildMatchKey(CcdiStaffRecruitment recruitment) {
return new RecruitmentMatchKey(
trim(recruitment.getRecruitId()),
trim(recruitment.getCandName()),
trim(recruitment.getRecruitName()),
trim(recruitment.getPosName())
);
}
private RecruitmentMatchKey buildMatchKey(CcdiStaffRecruitmentWorkExcel excel) {
return new RecruitmentMatchKey(
trim(excel.getRecruitId()),
trim(excel.getCandName()),
trim(excel.getRecruitName()),
trim(excel.getPosName())
);
}
private List<MainImportRow> buildMainImportRows(List<CcdiStaffRecruitmentExcel> recruitmentList) {
@@ -641,10 +633,25 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
private record WorkImportRow(CcdiStaffRecruitmentWorkExcel data, int sheetRowNum) {}
private record MainImportResult(Map<String, CcdiStaffRecruitment> importedRecruitmentMap, int successCount) {}
private record MainImportResult(Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> importedRecruitmentMap,
int successCount) {}
private record FailureMeta(String sheetName, String sheetRowNum) {}
private record RecruitmentMatchKey(String recruitId, String candName, String recruitName, String posName) {
private boolean isComplete() {
return StringUtils.isNotEmpty(recruitId)
&& StringUtils.isNotEmpty(candName)
&& StringUtils.isNotEmpty(recruitName)
&& StringUtils.isNotEmpty(posName);
}
private String value() {
return String.join("|", recruitId, candName, recruitName, posName);
}
}
private static class ImportValidationException extends RuntimeException {
private final String sheetName;

View File

@@ -1,6 +1,7 @@
package com.ruoyi.info.collection.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitment;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork;
@@ -27,6 +28,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -108,15 +110,15 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
/**
* 查询招聘信息详情
*
* @param recruitId 招聘记录编号
* @param id 主键ID
* @return 招聘信息VO
*/
@Override
public CcdiStaffRecruitmentVO selectRecruitmentById(String recruitId) {
CcdiStaffRecruitmentVO vo = recruitmentMapper.selectRecruitmentById(recruitId);
public CcdiStaffRecruitmentVO selectRecruitmentById(Long id) {
CcdiStaffRecruitmentVO vo = recruitmentMapper.selectRecruitmentById(id);
if (vo != null) {
vo.setAdmitStatusDesc(AdmitStatus.getDescByCode(vo.getAdmitStatus()));
vo.setWorkExperienceList(selectWorkExperienceList(recruitId));
vo.setWorkExperienceList(selectWorkExperienceList(vo.getId()));
}
return vo;
}
@@ -130,15 +132,14 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
@Override
@Transactional
public int insertRecruitment(CcdiStaffRecruitmentAddDTO addDTO) {
// 检查招聘记录编号唯一性
if (recruitmentMapper.selectById(addDTO.getRecruitId()) != null) {
throw new RuntimeException("该招聘记录编号已存在");
}
String recruitId = trim(addDTO.getRecruitId());
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
BeanUtils.copyProperties(addDTO, recruitment);
int result = recruitmentMapper.insert(recruitment);
recruitment.setRecruitId(recruitId);
int result = recruitmentMapper.insert(recruitment);
insertWorkExperienceList(recruitment.getId(), recruitId, addDTO.getRecruitType(), addDTO.getWorkExperienceList());
return result;
}
@@ -151,9 +152,20 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
@Override
@Transactional
public int updateRecruitment(CcdiStaffRecruitmentEditDTO editDTO) {
CcdiStaffRecruitment existing = recruitmentMapper.selectById(editDTO.getId());
if (existing == null) {
throw new RuntimeException("招聘信息不存在");
}
String recruitId = trim(editDTO.getRecruitId());
editDTO.setRecruitId(recruitId);
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
BeanUtils.copyProperties(editDTO, recruitment);
int result = recruitmentMapper.updateById(recruitment);
if (!Objects.equals(existing.getRecruitId(), recruitId)) {
updateWorkRecruitId(editDTO.getId(), recruitId);
}
replaceWorkExperienceList(editDTO);
return result;
@@ -162,16 +174,19 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
/**
* 批量删除招聘信息
*
* @param recruitIds 需要删除的招聘记录编号
* @param ids 需要删除的招聘信息ID
* @return 结果
*/
@Override
@Transactional
public int deleteRecruitmentByIds(String[] recruitIds) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> workWrapper = new LambdaQueryWrapper<>();
workWrapper.in(CcdiStaffRecruitmentWork::getRecruitId, List.of(recruitIds));
recruitmentWorkMapper.delete(workWrapper);
return recruitmentMapper.deleteBatchIds(List.of(recruitIds));
public int deleteRecruitmentByIds(Long[] ids) {
List<Long> idList = Arrays.asList(ids);
if (!idList.isEmpty()) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> workWrapper = new LambdaQueryWrapper<>();
workWrapper.in(CcdiStaffRecruitmentWork::getRecruitmentId, idList);
recruitmentWorkMapper.delete(workWrapper);
}
return recruitmentMapper.deleteBatchIds(idList);
}
/**
@@ -216,9 +231,9 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
return taskId;
}
private List<CcdiStaffRecruitmentWorkVO> selectWorkExperienceList(String recruitId) {
private List<CcdiStaffRecruitmentWorkVO> selectWorkExperienceList(Long recruitmentId) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiStaffRecruitmentWork::getRecruitId, recruitId)
wrapper.eq(CcdiStaffRecruitmentWork::getRecruitmentId, recruitmentId)
.orderByAsc(CcdiStaffRecruitmentWork::getSortOrder)
.orderByDesc(CcdiStaffRecruitmentWork::getId);
List<CcdiStaffRecruitmentWork> workList = recruitmentWorkMapper.selectList(wrapper);
@@ -232,9 +247,20 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
}).toList();
}
private void updateWorkRecruitId(Long recruitmentId, String newRecruitId) {
LambdaUpdateWrapper<CcdiStaffRecruitmentWork> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(CcdiStaffRecruitmentWork::getRecruitmentId, recruitmentId)
.set(CcdiStaffRecruitmentWork::getRecruitId, newRecruitId);
recruitmentWorkMapper.update(null, updateWrapper);
}
private String trim(String value) {
return value == null ? null : value.trim();
}
private void replaceWorkExperienceList(CcdiStaffRecruitmentEditDTO editDTO) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> deleteWrapper = new LambdaQueryWrapper<>();
deleteWrapper.eq(CcdiStaffRecruitmentWork::getRecruitId, editDTO.getRecruitId());
deleteWrapper.eq(CcdiStaffRecruitmentWork::getRecruitmentId, editDTO.getId());
if (!Objects.equals(RecruitType.SOCIAL.getCode(), editDTO.getRecruitType())) {
recruitmentWorkMapper.delete(deleteWrapper);
@@ -246,12 +272,28 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
}
recruitmentWorkMapper.delete(deleteWrapper);
List<CcdiStaffRecruitmentWork> workList = buildWorkExperienceEntities(editDTO);
List<CcdiStaffRecruitmentWork> workList = buildWorkExperienceEntities(
editDTO.getId(),
editDTO.getRecruitId(),
editDTO.getWorkExperienceList()
);
workList.forEach(recruitmentWorkMapper::insert);
}
private List<CcdiStaffRecruitmentWork> buildWorkExperienceEntities(CcdiStaffRecruitmentEditDTO editDTO) {
List<CcdiStaffRecruitmentWorkEditDTO> workExperienceList = editDTO.getWorkExperienceList();
private void insertWorkExperienceList(Long recruitmentId,
String recruitId,
String recruitType,
List<CcdiStaffRecruitmentWorkEditDTO> workExperienceList) {
if (!Objects.equals(RecruitType.SOCIAL.getCode(), recruitType)) {
return;
}
List<CcdiStaffRecruitmentWork> workList = buildWorkExperienceEntities(recruitmentId, recruitId, workExperienceList);
workList.forEach(recruitmentWorkMapper::insert);
}
private List<CcdiStaffRecruitmentWork> buildWorkExperienceEntities(Long recruitmentId,
String recruitId,
List<CcdiStaffRecruitmentWorkEditDTO> workExperienceList) {
if (workExperienceList == null || workExperienceList.isEmpty()) {
return new ArrayList<>();
}
@@ -264,7 +306,8 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
}
CcdiStaffRecruitmentWork work = new CcdiStaffRecruitmentWork();
BeanUtils.copyProperties(item, work);
work.setRecruitId(editDTO.getRecruitId());
work.setRecruitmentId(recruitmentId);
work.setRecruitId(recruitId);
work.setSortOrder(i + 1);
entityList.add(work);
}

View File

@@ -6,7 +6,8 @@
<!-- 招聘信息ResultMap -->
<resultMap type="com.ruoyi.info.collection.domain.vo.CcdiStaffRecruitmentVO" id="CcdiStaffRecruitmentVOResult">
<id property="recruitId" column="recruit_id"/>
<id property="id" column="id"/>
<result property="recruitId" column="recruit_id"/>
<result property="recruitName" column="recruit_name"/>
<result property="posName" column="pos_name"/>
<result property="posCategory" column="pos_category"/>
@@ -33,17 +34,17 @@
<!-- 分页查询招聘信息列表 -->
<select id="selectRecruitmentPage" resultMap="CcdiStaffRecruitmentVOResult">
SELECT
r.recruit_id, r.recruit_name, r.pos_name, r.pos_category, r.pos_desc,
r.id, r.recruit_id, r.recruit_name, r.pos_name, r.pos_category, r.pos_desc,
r.cand_name, r.recruit_type, r.cand_edu, r.cand_id, r.cand_school, r.cand_major, r.cand_grad,
r.admit_status, COALESCE(w.work_experience_count, 0) AS work_experience_count,
r.interviewer_name1, r.interviewer_id1, r.interviewer_name2, r.interviewer_id2,
r.created_by, r.create_time, r.updated_by, r.update_time
FROM ccdi_staff_recruitment r
LEFT JOIN (
SELECT recruit_id COLLATE utf8mb4_general_ci AS recruit_id, COUNT(1) AS work_experience_count
SELECT recruitment_id, COUNT(1) AS work_experience_count
FROM ccdi_staff_recruitment_work
GROUP BY recruit_id COLLATE utf8mb4_general_ci
) w ON w.recruit_id COLLATE utf8mb4_general_ci = r.recruit_id COLLATE utf8mb4_general_ci
GROUP BY recruitment_id
) w ON w.recruitment_id = r.id
<where>
<if test="query.recruitName != null and query.recruitName != ''">
AND r.recruit_name LIKE CONCAT('%', #{query.recruitName}, '%')
@@ -78,16 +79,16 @@
<!-- 查询招聘信息详情 -->
<select id="selectRecruitmentById" resultMap="CcdiStaffRecruitmentVOResult">
SELECT
recruit_id, recruit_name, pos_name, pos_category, pos_desc,
id, recruit_id, recruit_name, pos_name, pos_category, pos_desc,
cand_name, recruit_type, cand_edu, cand_id, cand_school, cand_major, cand_grad,
admit_status, interviewer_name1, interviewer_id1, interviewer_name2, interviewer_id2,
created_by, create_time, updated_by, update_time
FROM ccdi_staff_recruitment
WHERE recruit_id = #{recruitId}
WHERE id = #{id}
</select>
<!-- 批量插入招聘信息数据 -->
<insert id="insertBatch">
<insert id="insertBatch" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ccdi_staff_recruitment
(recruit_id, recruit_name, pos_name, pos_category, pos_desc,
cand_name, recruit_type, cand_edu, cand_id, cand_school, cand_major, cand_grad,
@@ -124,7 +125,7 @@
interviewer_id2 = #{item.interviewerId2},
updated_by = #{item.updatedBy},
update_time = NOW()
WHERE recruit_id = #{item.recruitId}
WHERE id = #{item.id}
</foreach>
</update>

View File

@@ -1,6 +1,8 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitment;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel;
import com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO;
import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentMapper;
@@ -27,6 +29,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -55,7 +58,7 @@ class CcdiStaffRecruitmentImportServiceImplTest {
void shouldFailWholeWorkGroupWhenExistingHistoryExists() {
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(recruitmentMapper.selectBatchIds(any())).thenReturn(List.of(buildRecruitment("RC001")));
when(recruitmentMapper.selectList(any())).thenReturn(List.of(buildRecruitment("RC001")));
when(recruitmentWorkMapper.selectCount(any())).thenReturn(1L);
CcdiStaffRecruitmentWorkExcel workRow = new CcdiStaffRecruitmentWorkExcel();
@@ -86,13 +89,81 @@ class CcdiStaffRecruitmentImportServiceImplTest {
assertEquals("招聘记录编号[RC001]已存在历史工作经历,不允许重复导入", failure.getErrorMessage());
}
@Test
void shouldAllowDuplicateRecruitIdsWhenImportingMainSheet() {
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(recruitmentMapper.insert(any(CcdiStaffRecruitment.class))).thenReturn(1);
CcdiStaffRecruitmentExcel first = buildRecruitmentExcel("RC001", "张三");
CcdiStaffRecruitmentExcel second = buildRecruitmentExcel("RC001", "李四");
service.importRecruitmentAsync(List.of(first, second), Collections.emptyList(), "task-2", "admin");
verify(recruitmentMapper, times(2)).insert(any(CcdiStaffRecruitment.class));
verify(valueOperations, never()).set(eq("import:recruitment:task-2:failures"), any(), anyLong(), any());
}
@Test
void shouldAttachWorkToMatchedRecruitmentWhenRecruitIdIsDuplicated() {
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
CcdiStaffRecruitment matched = buildRecruitment(10L, "RC001", "张三");
CcdiStaffRecruitment other = buildRecruitment(11L, "RC001", "李四");
when(recruitmentMapper.selectList(any())).thenReturn(List.of(matched, other));
when(recruitmentWorkMapper.selectCount(any())).thenReturn(0L);
CcdiStaffRecruitmentWorkExcel workRow = buildWorkExcel("RC001", "张三");
service.importRecruitmentAsync(Collections.emptyList(), List.of(workRow), "task-3", "admin");
ArgumentCaptor<CcdiStaffRecruitmentWork> workCaptor = ArgumentCaptor.forClass(CcdiStaffRecruitmentWork.class);
verify(recruitmentWorkMapper).insert(workCaptor.capture());
assertEquals(10L, workCaptor.getValue().getRecruitmentId());
assertEquals("RC001", workCaptor.getValue().getRecruitId());
}
private CcdiStaffRecruitment buildRecruitment(String recruitId) {
return buildRecruitment(1L, recruitId, "张三");
}
private CcdiStaffRecruitment buildRecruitment(Long id, String recruitId, String candName) {
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
recruitment.setId(id);
recruitment.setRecruitId(recruitId);
recruitment.setRecruitType("SOCIAL");
recruitment.setCandName("张三");
recruitment.setCandName(candName);
recruitment.setRecruitName("社会招聘项目");
recruitment.setPosName("Java工程师");
return recruitment;
}
private CcdiStaffRecruitmentExcel buildRecruitmentExcel(String recruitId, String candName) {
CcdiStaffRecruitmentExcel excel = new CcdiStaffRecruitmentExcel();
excel.setRecruitId(recruitId);
excel.setRecruitName("社会招聘项目");
excel.setPosName("Java工程师");
excel.setPosCategory("技术类");
excel.setPosDesc("负责系统开发");
excel.setAdmitStatus("录用");
excel.setCandName(candName);
excel.setRecruitType("SOCIAL");
excel.setCandEdu("本科");
excel.setCandId(candName.equals("张三") ? "110105199001010010" : "110105199002020026");
excel.setCandGrad("202110");
excel.setCandSchool("四川大学");
excel.setCandMajor("法学");
return excel;
}
private CcdiStaffRecruitmentWorkExcel buildWorkExcel(String recruitId, String candName) {
CcdiStaffRecruitmentWorkExcel workRow = new CcdiStaffRecruitmentWorkExcel();
workRow.setRecruitId(recruitId);
workRow.setCandName(candName);
workRow.setRecruitName("社会招聘项目");
workRow.setPosName("Java工程师");
workRow.setSortOrder(1);
workRow.setCompanyName("测试科技");
workRow.setPositionName("开发工程师");
workRow.setJobStartMonth("2022-01");
return workRow;
}
}

View File

@@ -0,0 +1,44 @@
# 招聘信息新增弹窗工作经历维护实施记录
## 背景
招聘信息维护页面的编辑弹窗已支持维护社招候选人的历史工作经历,但新增弹窗隐藏了同一维护区域,且新增提交时未携带 `workExperienceList`,导致新增招聘记录时无法同步维护候选人工作经历。
## 修改内容
- 前端 `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- 将候选人历史工作经历维护区域从“仅编辑弹窗展示”调整为“社招新增和编辑弹窗均展示”。
- 新增提交时保留 `workExperienceList`,不再删除工作经历数据。
- 工作经历校验同步覆盖新增和编辑场景,仍仅对社招记录生效。
- 后端 `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffRecruitmentAddDTO.java`
- 新增 `workExperienceList` 入参,并使用 `@Valid` 复用现有工作经历字段校验。
- 后端 `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java`
- 新增招聘信息保存后,如为社招且提交了工作经历,按新增记录主键写入 `ccdi_staff_recruitment_work`
- 复用编辑场景的工作经历实体构造逻辑,保持字段、排序号和必填过滤规则一致。
## 影响范围
- 仅影响招聘信息维护页面的新增弹窗和新增接口。
- 编辑弹窗、详情展示、导入功能和校招新增逻辑不改变。
## 验证记录
- 后端相关模块编译通过:
- `mvn -pl ccdi-info-collection -am test -DskipTests`
- 前端生产构建通过:
- `cd ruoyi-ui && source ~/.nvm/nvm.sh && nvm use && node -v && npm run build:prod`
- 使用 Node `v14.21.3`,构建仅保留既有资源体积 warning。
- 真实接口新增与编辑验证通过:
- 新增社招招聘记录 `AUTO-WORK-202605070053`,新增详情回查 `workExperienceList` 为 1 条。
- 编辑同一条招聘记录,工作经历由 1 条覆盖为 2 条,详情回查包含 `编辑后公司B``编辑新增公司C`
- 主键关联数据库回查通过:
- 使用 `bin/mysql_utf8_exec.sh output/sql/2026-05-07-staff-recruitment-work-pk-check.sql` 回查,主表自增主键 `id=6006` 下关联 2 条子表记录。
- 回查 `work_links``6006:1:编辑后公司B | 6006:2:编辑新增公司C``orphan_work_count=0`
- 真实页面验证通过:
- 使用 `browser-use` 打开真实页面 `http://localhost:1025/maintain/staffRecruitment`
- 新增弹窗已展示“候选人历史工作经历”区域,点击“新增经历”后出现工作单位、入职时间等输入列。
- 编辑同一条测试记录时,编辑弹窗按主键回显 2 条历史工作经历,列表页显示历史工作经历为 `2段`
- 清理验证通过:
- 测试结束后调用删除接口清理 `id=6006`,再次数据库回查 `orphan_work_count=0`,无测试工作经历孤儿数据残留。

View File

@@ -0,0 +1,43 @@
# 招聘项目编号允许重复实施记录
## 保存路径确认
- 实施记录保存路径:`docs/reports/implementation/2026-05-07-staff-recruitment-allow-duplicate-recruit-id.md`
- 本次为招聘信息主从表关联修正,使用实施记录目录保存。
## 修改内容
1. 移除新增、编辑、导入时对 `recruit_id` 的重复拦截,招聘项目编号允许重复。
2. `ccdi_staff_recruitment_work` 新增 `recruitment_id`,历史工作经历改为关联招聘主表自增 `id`
3. 列表历史工作经历条数、详情历史工作经历、编辑保存和删除清理均改为按 `recruitment_id` 处理。
4. 导入模板字段保持不变,历史工作经历导入时通过“招聘项目编号 + 候选人姓名 + 招聘项目名称 + 职位名称”匹配主记录;匹配多条时返回失败,避免错误归属。
5. 更新初始化 SQL、增量 SQL、数据库字段说明和 API 文档。
## 影响范围
- 后端服务:招聘信息 CRUD、双 Sheet 导入、历史工作经历查询与清理。
- 数据库表:`ccdi_staff_recruitment``ccdi_staff_recruitment_work`
- 前端页面:接口仍传主表 `id`,页面字段无新增。
- 导入功能:模板不新增主键列。
## 验证情况
1. 数据库迁移已执行:
- `sh bin/mysql_utf8_exec.sh sql/migration/2026-05-07-allow-duplicate-staff-recruitment-id.sql`
- 回查确认 `ccdi_staff_recruitment.recruit_id` 为普通索引,`ccdi_staff_recruitment_work.recruitment_id` 为非空字段并已建立索引。
2. 后端定向测试通过:
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiStaffRecruitmentImportServiceImplTest,CcdiStaffRecruitmentDualImportContractTest -Dsurefire.failIfNoSpecifiedTests=false test`
3. 前端构建通过:
- `cd ruoyi-ui && nvm use && npm run build:prod`
- 构建仅保留既有资源体积 warning。
4. 真实页面验证:
- 已按项目规则优先尝试 `browser-use`,但当前 Codex 环境没有可用 in-app browser pane运行时返回 `No active Codex browser pane available`
- 使用本机 Playwright 兜底打开真实页面 `http://localhost:8080/maintain/staffRecruitment` 验证。
- 通过接口新增两条相同招聘项目编号 `RC-DUP-20260507003554` 的招聘记录,均返回操作成功。
- 给第一条记录插入 1 条历史工作经历后,页面列表展示两条同编号记录:`TestA``1段``TestB``0段`
- 打开 `TestA` 详情可见 `Company A` 历史工作经历;打开 `TestB` 详情显示“暂无历史工作经历”,未发生串数据。
- 后端日志确认列表按 `recruitment_id` 聚合历史工作经历,详情查询分别按 `recruitment_id = 6003``recruitment_id = 6004` 查询从表。
5. 清理情况:
- 已删除本轮测试主表和从表造数。
- 已关闭 Playwright 浏览器。
- 已停止本轮启动的前端 `8080` 与后端 `62318` 进程,端口回查无监听。

View File

@@ -0,0 +1,47 @@
# 招聘信息自增主键实施记录
## 保存路径确认
- 实施记录保存路径:`docs/reports/implementation/2026-05-07-staff-recruitment-auto-id-primary-key.md`
- 本次为招聘信息主键结构调整,使用实施记录目录保存。
## 修改内容
1. `ccdi_staff_recruitment` 新增 `id` 自增主键,`recruit_id` 调整为普通业务编号。
2. 后端实体 `CcdiStaffRecruitment` 的 MyBatis Plus 主键从 `recruitId` 切换为 `id`
3. 招聘信息详情、编辑、删除接口改为按 `id` 定位记录。
4. 招聘信息列表和详情 SQL 返回 `id` 字段,前端列表行操作改为传递 `id`
5. 导入链路继续在模板中填写 `recruit_id`,后续补充改为由后端解析到招聘主表 `id`
6. 补充迁移脚本 `sql/migration/2026-05-07-add-staff-recruitment-auto-id.sql`,同步更新初始化 SQL 与数据库字段说明。
7. 同步更新招聘信息 API 文档中详情、编辑、删除的主键参数说明。
## 影响范围
- 后端接口:`/ccdi/staffRecruitment/{id}``PUT /ccdi/staffRecruitment``DELETE /ccdi/staffRecruitment/{ids}`
- 前端页面:`ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- 数据库表:`ccdi_staff_recruitment`
- 导入功能:招聘主信息与历史工作经历模板字段不变,后续补充改为落库时关联招聘主表 `id`
## 数据库执行
- 已通过 `bin/mysql_utf8_exec.sh sql/migration/2026-05-07-add-staff-recruitment-auto-id.sql` 执行本地 dev 库迁移。
- 回查结果:
- `id``PRIMARY``auto_increment`
- `recruit_id`:普通索引,允许重复。
## 验证情况
1. 后端定向测试通过:
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiStaffRecruitmentImportServiceImplTest,CcdiStaffRecruitmentDualImportContractTest -Dsurefire.failIfNoSpecifiedTests=false test`
2. 前端生产构建通过:
- `cd ruoyi-ui && nvm use && npm run build:prod`
- 构建仅保留既有资源体积 warning。
3. 后端重启验证通过:
- `sh bin/restart_java_backend.sh restart`
- 应用启动成功。
4. 真实页面验证通过:
- 使用 browser-use 打开 `http://localhost:8080/maintain/staffRecruitment`
- 登录后招聘信息维护列表正常展示。
- 点击 `RC2025001001` 详情,后端日志显示详情 SQL 使用 `WHERE id = ?` 查询主表。
- 历史工作经历后续补充改为使用招聘主表 `id` 查询,避免编号重复时串数据。
5. 测试后已关闭本轮启动的前端 `8080` 与后端 `62318` 进程,端口回查无监听。

View File

@@ -10,9 +10,9 @@ export function listStaffRecruitment(query) {
}
// 查询招聘信息详细
export function getStaffRecruitment(recruitId) {
export function getStaffRecruitment(id) {
return request({
url: '/ccdi/staffRecruitment/' + recruitId,
url: '/ccdi/staffRecruitment/' + id,
method: 'get'
})
}
@@ -36,9 +36,9 @@ export function updateStaffRecruitment(data) {
}
// 删除招聘信息
export function delStaffRecruitment(recruitIds) {
export function delStaffRecruitment(ids) {
return request({
url: '/ccdi/staffRecruitment/' + recruitIds,
url: '/ccdi/staffRecruitment/' + ids,
method: 'delete'
})
}

View File

@@ -317,10 +317,10 @@
</el-col>
</el-row>
<template v-if="!isAdd && isSocialRecruitment(form)">
<template v-if="isSocialRecruitment(form)">
<el-divider content-position="left">候选人历史工作经历</el-divider>
<div class="work-experience-toolbar">
<span class="work-experience-tip">支持在编辑页手动补录候选人的历史工作经历保存后会覆盖当前记录下已有的工作经历</span>
<span class="work-experience-tip">支持手动维护候选人的历史工作经历保存后随招聘记录一起提交</span>
<el-button type="primary" plain size="mini" icon="el-icon-plus" @click="handleAddWorkExperience">新增经历</el-button>
</div>
<el-table
@@ -624,6 +624,7 @@ const gradPattern = /^((19|20)\d{2})(0[1-9]|1[0-2])$/;
const workMonthPattern = /^((19|20)\d{2})-(0[1-9]|1[0-2])$/;
const previewRecruitmentList = [
{
id: 1,
recruitId: "RC2025001205",
recruitName: "2024年社会招聘-技术部",
posName: "Java开发工程师",
@@ -666,6 +667,7 @@ const previewRecruitmentList = [
]
},
{
id: 2,
recruitId: "RC2025001206",
recruitName: "2024年社会招聘-技术部",
posName: "数据分析师",
@@ -700,6 +702,7 @@ const previewRecruitmentList = [
]
},
{
id: 3,
recruitId: "RC2025001003",
recruitName: "2024年春季校园招聘",
posName: "Java开发工程师",
@@ -735,6 +738,8 @@ export default {
loading: true,
// 选中数组
ids: [],
// 选中招聘记录编号
recruitIds: [],
// 非单个禁用
single: true,
// 非多个禁用
@@ -910,6 +915,7 @@ export default {
// 表单重置
reset() {
this.form = {
id: null,
recruitId: null,
recruitName: null,
posName: null,
@@ -944,7 +950,8 @@ export default {
},
/** 多选框选中数据 */
handleSelectionChange(selection) {
this.ids = selection.map(item => item.recruitId);
this.ids = selection.map(item => item.id);
this.recruitIds = selection.map(item => item.recruitId);
this.single = selection.length !== 1;
this.multiple = !selection.length;
},
@@ -958,7 +965,8 @@ export default {
/** 修改按钮操作 */
handleUpdate(row) {
this.reset();
const recruitId = row.recruitId || this.ids[0];
const id = row.id || this.ids[0];
const recruitId = row.recruitId || this.recruitIds[0];
if (this.isPreviewMode()) {
const target = this.findPreviewRecruitment(recruitId);
if (target) {
@@ -973,7 +981,7 @@ export default {
this.isAdd = false;
return;
}
getStaffRecruitment(recruitId).then(response => {
getStaffRecruitment(id).then(response => {
this.form = {
...this.form,
...response.data,
@@ -986,6 +994,7 @@ export default {
},
/** 详情按钮操作 */
handleDetail(row) {
const id = row.id;
const recruitId = row.recruitId;
if (this.isPreviewMode()) {
const target = this.findPreviewRecruitment(recruitId);
@@ -998,7 +1007,7 @@ export default {
}
return;
}
getStaffRecruitment(recruitId).then(response => {
getStaffRecruitment(id).then(response => {
this.recruitmentDetail = {
...response.data,
workExperienceList: this.normalizeWorkExperienceList(response.data && response.data.workExperienceList)
@@ -1102,7 +1111,7 @@ export default {
},
/** 校验工作经历 */
validateWorkExperienceList() {
if (this.isAdd || !this.isSocialRecruitment(this.form)) {
if (!this.isSocialRecruitment(this.form)) {
return true;
}
const workExperienceList = this.normalizeWorkExperienceList(this.form.workExperienceList);
@@ -1183,9 +1192,7 @@ export default {
return;
}
if (this.isAdd) {
const addData = { ...formData };
delete addData.workExperienceList;
addStaffRecruitment(addData).then(response => {
addStaffRecruitment(formData).then(response => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
@@ -1202,13 +1209,14 @@ export default {
},
/** 删除按钮操作 */
handleDelete(row) {
const recruitIds = row.recruitId || this.ids;
const ids = row.id || this.ids;
const recruitIds = row.recruitId || this.recruitIds;
if (this.isPreviewMode()) {
this.$modal.msgSuccess(`预览模式:已模拟删除 ${recruitIds}`);
return;
}
this.$modal.confirm('是否确认删除招聘信息编号为"' + recruitIds + '"的数据项?').then(function() {
return delStaffRecruitment(recruitIds);
return delStaffRecruitment(ids);
}).then(() => {
this.getList();
this.$modal.msgSuccess("删除成功");

View File

@@ -1046,6 +1046,7 @@ DROP TABLE IF EXISTS `ccdi_staff_recruitment`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `ccdi_staff_recruitment` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`recruit_id` varchar(32) COLLATE utf8mb4_general_ci NOT NULL COMMENT '招聘项目编号',
`recruit_name` varchar(100) COLLATE utf8mb4_general_ci NOT NULL COMMENT '招聘项目名称',
`pos_name` varchar(100) COLLATE utf8mb4_general_ci NOT NULL COMMENT '职位名称',
@@ -1067,7 +1068,8 @@ CREATE TABLE `ccdi_staff_recruitment` (
`updated_by` varchar(20) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '记录更新人',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`recruit_id`),
PRIMARY KEY (`id`),
KEY `idx_staff_recruitment_recruit_id` (`recruit_id`),
KEY `idx_cand_id` (`cand_id`),
KEY `idx_admit_status` (`admit_status`),
KEY `idx_interviewer_id1` (`interviewer_id1`)
@@ -1083,6 +1085,7 @@ DROP TABLE IF EXISTS `ccdi_staff_recruitment_work`;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `ccdi_staff_recruitment_work` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`recruitment_id` bigint(20) NOT NULL COMMENT '关联招聘信息主键ID',
`recruit_id` varchar(32) NOT NULL COMMENT '关联招聘记录编号',
`sort_order` int(11) NOT NULL DEFAULT '1' COMMENT '排序号1 表示最近一段经历',
`company_name` varchar(200) NOT NULL COMMENT '工作单位',
@@ -1098,6 +1101,8 @@ CREATE TABLE `ccdi_staff_recruitment_work` (
`updated_by` varchar(20) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_recruitment_id` (`recruitment_id`),
KEY `idx_recruitment_id_sort_order` (`recruitment_id`,`sort_order`),
KEY `idx_recruit_id` (`recruit_id`),
KEY `idx_recruit_id_sort_order` (`recruit_id`,`sort_order`)
) ENGINE=InnoDB AUTO_INCREMENT=36 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='招聘信息历史工作经历表';

View File

@@ -0,0 +1,12 @@
-- 招聘信息主表增加自增主键ID
-- 1. recruit_id 从物理主键调整为普通业务编号,允许重复。
-- 2. 新增 id 作为 ccdi_staff_recruitment 的自增主键。
ALTER TABLE `ccdi_staff_recruitment`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
ALTER TABLE `ccdi_staff_recruitment`
DROP PRIMARY KEY,
ADD COLUMN `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID' FIRST,
ADD PRIMARY KEY (`id`),
ADD KEY `idx_staff_recruitment_recruit_id` (`recruit_id`);

View File

@@ -0,0 +1,89 @@
-- 招聘信息允许招聘项目编号重复
-- 1. 移除 ccdi_staff_recruitment.recruit_id 唯一索引,保留普通查询索引。
-- 2. ccdi_staff_recruitment_work 增加 recruitment_id改为关联招聘信息自增主键。
SET @schema_name = DATABASE();
SET @drop_unique_sql = (
SELECT IF(
COUNT(1) > 0,
'ALTER TABLE `ccdi_staff_recruitment` DROP INDEX `uk_staff_recruitment_recruit_id`',
'SELECT 1'
)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'ccdi_staff_recruitment'
AND index_name = 'uk_staff_recruitment_recruit_id'
);
PREPARE stmt FROM @drop_unique_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @add_recruit_id_index_sql = (
SELECT IF(
COUNT(1) = 0,
'ALTER TABLE `ccdi_staff_recruitment` ADD KEY `idx_staff_recruitment_recruit_id` (`recruit_id`)',
'SELECT 1'
)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'ccdi_staff_recruitment'
AND index_name = 'idx_staff_recruitment_recruit_id'
);
PREPARE stmt FROM @add_recruit_id_index_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @add_work_recruitment_id_sql = (
SELECT IF(
COUNT(1) = 0,
'ALTER TABLE `ccdi_staff_recruitment_work` ADD COLUMN `recruitment_id` BIGINT NULL COMMENT ''关联招聘信息主键ID'' AFTER `id`',
'SELECT 1'
)
FROM information_schema.columns
WHERE table_schema = @schema_name
AND table_name = 'ccdi_staff_recruitment_work'
AND column_name = 'recruitment_id'
);
PREPARE stmt FROM @add_work_recruitment_id_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE `ccdi_staff_recruitment_work` w
JOIN `ccdi_staff_recruitment` r
ON w.`recruit_id` COLLATE utf8mb4_general_ci = r.`recruit_id` COLLATE utf8mb4_general_ci
SET w.`recruitment_id` = r.`id`
WHERE w.`recruitment_id` IS NULL;
ALTER TABLE `ccdi_staff_recruitment_work`
MODIFY COLUMN `recruitment_id` BIGINT NOT NULL COMMENT '关联招聘信息主键ID';
SET @add_work_recruitment_id_index_sql = (
SELECT IF(
COUNT(1) = 0,
'ALTER TABLE `ccdi_staff_recruitment_work` ADD KEY `idx_recruitment_id` (`recruitment_id`)',
'SELECT 1'
)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'ccdi_staff_recruitment_work'
AND index_name = 'idx_recruitment_id'
);
PREPARE stmt FROM @add_work_recruitment_id_index_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @add_work_recruitment_sort_index_sql = (
SELECT IF(
COUNT(1) = 0,
'ALTER TABLE `ccdi_staff_recruitment_work` ADD KEY `idx_recruitment_id_sort_order` (`recruitment_id`, `sort_order`)',
'SELECT 1'
)
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'ccdi_staff_recruitment_work'
AND index_name = 'idx_recruitment_id_sort_order'
);
PREPARE stmt FROM @add_work_recruitment_sort_index_sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;