candidates.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. const express = require('express');
  2. const router = express.Router();
  3. const { pool } = require('../config/db');
  4. const crypto = require('crypto');
  5. const { successResponse, errorResponse } = require('../utils/apiResponse');
  6. // 辅助方法:格式化技能数据
  7. function formatSkills(skillsStr) {
  8. if (!skillsStr) return [];
  9. try {
  10. // 处理多行JSON对象的情况
  11. if (skillsStr.trim().startsWith('{')) {
  12. const skillObjects = skillsStr.split('\n')
  13. .filter(line => line.trim())
  14. .map(line => {
  15. try {
  16. return JSON.parse(line.trim());
  17. } catch {
  18. return null;
  19. }
  20. })
  21. .filter(Boolean);
  22. return skillObjects;
  23. }
  24. // 处理标准JSON数组
  25. const parsed = JSON.parse(skillsStr);
  26. return Array.isArray(parsed) ? parsed : [parsed];
  27. } catch {
  28. return [];
  29. }
  30. }
  31. // 辅助方法:格式化亮点数据
  32. function formatHighlights(highlightsStr) {
  33. if (!highlightsStr) return [];
  34. try {
  35. // 处理一个字一行的情况
  36. if (highlightsStr.includes('\n') && highlightsStr.length < 100) {
  37. return highlightsStr.split('\n')
  38. .map(line => line.trim())
  39. .filter(line => line);
  40. }
  41. // 处理标准JSON数组
  42. const parsed = JSON.parse(highlightsStr);
  43. return Array.isArray(parsed) ? parsed : [parsed];
  44. } catch {
  45. return [];
  46. }
  47. }
  48. // 格式化候选人数据
  49. function formatCandidate(candidate) {
  50. return {
  51. id: candidate.id,
  52. name: candidate.name,
  53. jobId: candidate.job_id || candidate.jobId,
  54. jobTitle: candidate.job_title || candidate.jobTitle,
  55. matchScore: candidate.match_score || candidate.matchScore,
  56. status: candidate.status,
  57. highlights: formatHighlights(candidate.highlights),
  58. concerns: candidate.concerns ? JSON.parse(candidate.concerns) : [],
  59. summary: candidate.summary,
  60. resumeText: candidate.resume_text || candidate.resumeText,
  61. education: candidate.education,
  62. experience: candidate.experience,
  63. skills: candidate.skills ? formatSkills(candidate.skills) : [],
  64. submittedAt: candidate.submitted_at || candidate.submittedAt,
  65. reviewedAt: candidate.reviewed_at || candidate.reviewedAt || null
  66. };
  67. }
  68. // 获取所有候选人
  69. router.get('/', async (req, res) => {
  70. try {
  71. const { status, jobId } = req.query;
  72. let query = `
  73. SELECT
  74. c.id,
  75. c.name,
  76. c.job_id AS jobId,
  77. c.job_title AS jobTitle,
  78. c.match_score AS matchScore,
  79. c.status,
  80. c.highlights,
  81. c.concerns,
  82. c.summary,
  83. c.resume_text AS resumeText,
  84. c.education,
  85. c.experience,
  86. c.skills,
  87. c.submitted_at AS submittedAt,
  88. c.reviewed_at AS reviewedAt,
  89. j.title AS jobTitle
  90. FROM candidates c
  91. LEFT JOIN jobs j ON c.job_id = j.id
  92. WHERE 1=1
  93. `;
  94. const params = [];
  95. if (status) {
  96. query += ' AND c.status = ?';
  97. params.push(status);
  98. }
  99. if (jobId) {
  100. query += ' AND c.job_id = ?';
  101. params.push(jobId);
  102. }
  103. query += ' ORDER BY c.submitted_at DESC';
  104. const [candidates] = await pool.query(query, params);
  105. // 获取候选人技能(兼容两种方式)
  106. for (const candidate of candidates) {
  107. try {
  108. // 如果skills字段有数据则使用,否则查询关联表
  109. if (!candidate.skills) {
  110. const [skills] = await pool.query(`
  111. SELECT s.name, cs.proficiency
  112. FROM candidate_skills cs
  113. JOIN skills s ON cs.skill_id = s.id
  114. WHERE cs.candidate_id = ?
  115. `, [candidate.id]);
  116. candidate.skills = skills || [];
  117. }
  118. } catch (error) {
  119. console.error(`获取候选人技能错误 ${candidate.id}:`, error);
  120. candidate.skills = [];
  121. }
  122. }
  123. const formattedCandidates = candidates.map(candidate => ({
  124. ...formatCandidate(candidate),
  125. // 保留原始技能查询结果
  126. skills: candidate.skills && candidate.skills.length > 0 && typeof candidate.skills[0] === 'object'
  127. ? candidate.skills
  128. : formatSkills(candidate.skills)
  129. }));
  130. successResponse(res, formattedCandidates);
  131. } catch (error) {
  132. console.error('获取候选人列表错误:', error);
  133. errorResponse(res, error);
  134. }
  135. });
  136. // 获取特定候选人
  137. router.get('/:id', async (req, res) => {
  138. try {
  139. const [candidates] = await pool.query(`
  140. SELECT
  141. c.*,
  142. j.title AS jobTitle
  143. FROM candidates c
  144. LEFT JOIN jobs j ON c.job_id = j.id
  145. WHERE c.id = ?
  146. `, [req.params.id]);
  147. if (candidates.length === 0) {
  148. return errorResponse(res, '候选人不存在', 404);
  149. }
  150. // 获取技能
  151. const [skills] = await pool.query(`
  152. SELECT s.name, cs.proficiency
  153. FROM candidate_skills cs
  154. JOIN skills s ON cs.skill_id = s.id
  155. WHERE cs.candidate_id = ?
  156. `, [req.params.id]);
  157. const candidateData = formatCandidate(candidates[0]);
  158. candidateData.skills = skills.length > 0 ? skills : formatSkills(candidates[0].skills);
  159. successResponse(res, candidateData);
  160. } catch (error) {
  161. console.error('获取候选人错误:', error);
  162. errorResponse(res, error);
  163. }
  164. });
  165. // 创建新候选人
  166. router.post('/', async (req, res) => {
  167. try {
  168. const {
  169. name,
  170. jobId,
  171. jobTitle,
  172. matchScore,
  173. highlights = [],
  174. concerns = [],
  175. summary = '',
  176. resumeText,
  177. education,
  178. experience,
  179. skills = []
  180. } = req.body;
  181. const id = crypto.randomUUID();
  182. const now = new Date();
  183. await pool.query(
  184. `INSERT INTO candidates (
  185. id, name, job_id, job_title, match_score, status,
  186. highlights, concerns, summary, resume_text, education,
  187. experience, skills, submitted_at
  188. ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)`,
  189. [
  190. id, name, jobId, jobTitle, matchScore,
  191. JSON.stringify(highlights),
  192. JSON.stringify(concerns),
  193. summary, resumeText, education, experience,
  194. JSON.stringify(skills),
  195. now
  196. ]
  197. );
  198. // 添加技能关联
  199. if (skills && skills.length > 0) {
  200. const skillPromises = skills.map(skill =>
  201. pool.query(
  202. `INSERT INTO candidate_skills
  203. (candidate_id, skill_id, proficiency)
  204. VALUES (?, ?, ?)`,
  205. [id, skill.id, skill.proficiency || 'intermediate']
  206. )
  207. );
  208. await Promise.all(skillPromises);
  209. }
  210. // 更新职位待筛选简历数
  211. await pool.query(
  212. `UPDATE jobs
  213. SET pending_resumes = pending_resumes + 1, updated_at = ?
  214. WHERE id = ?`,
  215. [now, jobId]
  216. );
  217. const [newCandidate] = await pool.query(
  218. 'SELECT * FROM candidates WHERE id = ?',
  219. [id]
  220. );
  221. const [candidateSkills] = await pool.query(
  222. `SELECT s.name, cs.proficiency
  223. FROM candidate_skills cs
  224. JOIN skills s ON cs.skill_id = s.id
  225. WHERE cs.candidate_id = ?`,
  226. [id]
  227. );
  228. const responseData = formatCandidate(newCandidate[0]);
  229. responseData.skills = candidateSkills;
  230. successResponse(res, responseData, 201);
  231. } catch (error) {
  232. console.error('创建候选人错误:', error);
  233. errorResponse(res, error);
  234. }
  235. });
  236. // 更新候选人
  237. router.put('/:id', async (req, res) => {
  238. console.log('收到更新请求:', req.params.id, req.body);
  239. try {
  240. const { id } = req.params;
  241. const updates = req.body;
  242. // 获取当前数据
  243. const [current] = await pool.query(
  244. 'SELECT * FROM candidates WHERE id = ?',
  245. [id]
  246. );
  247. if (current.length === 0) {
  248. return errorResponse(res, '候选人不存在', 404);
  249. }
  250. const currentData = current[0];
  251. // 构建更新字段
  252. const fields = [];
  253. const params = [];
  254. const updateFields = {
  255. name: updates.name,
  256. job_id: updates.jobId,
  257. job_title: updates.jobTitle,
  258. match_score: updates.matchScore,
  259. status: updates.status,
  260. highlights: updates.highlights ? JSON.stringify(updates.highlights) : undefined,
  261. concerns: updates.concerns ? JSON.stringify(updates.concerns) : undefined,
  262. summary: updates.summary,
  263. resume_text: updates.resumeText,
  264. education: updates.education,
  265. experience: updates.experience,
  266. skills: updates.skills ? JSON.stringify(updates.skills) : undefined
  267. };
  268. Object.entries(updateFields).forEach(([key, value]) => {
  269. if (value !== undefined && value !== currentData[key]) {
  270. fields.push(`${key} = ?`);
  271. params.push(value);
  272. }
  273. });
  274. if (fields.length === 0) {
  275. return errorResponse(res, '没有需要更新的字段', 400);
  276. }
  277. params.push(id);
  278. await pool.query(
  279. `UPDATE candidates SET ${fields.join(', ')} WHERE id = ?`,
  280. params
  281. );
  282. // 更新技能关联
  283. if (updates.skills) {
  284. await pool.query(
  285. 'DELETE FROM candidate_skills WHERE candidate_id = ?',
  286. [id]
  287. );
  288. if (updates.skills.length > 0) {
  289. const skillPromises = updates.skills.map(skill =>
  290. pool.query(
  291. `INSERT INTO candidate_skills
  292. (candidate_id, skill_id, proficiency)
  293. VALUES (?, ?, ?)`,
  294. [id, skill.id, skill.proficiency || 'intermediate']
  295. )
  296. );
  297. await Promise.all(skillPromises);
  298. }
  299. }
  300. // 获取更新后的数据
  301. const [updated] = await pool.query(
  302. 'SELECT * FROM candidates WHERE id = ?',
  303. [id]
  304. );
  305. const [skills] = await pool.query(
  306. `SELECT s.name, cs.proficiency
  307. FROM candidate_skills cs
  308. JOIN skills s ON cs.skill_id = s.id
  309. WHERE cs.candidate_id = ?`,
  310. [id]
  311. );
  312. const responseData = formatCandidate(updated[0]);
  313. responseData.skills = skills;
  314. successResponse(res, responseData);
  315. } catch (error) {
  316. console.error('更新候选人错误:', error);
  317. errorResponse(res, error);
  318. }
  319. });
  320. // 更新候选人状态
  321. router.put('/:id/status', async (req, res) => {
  322. try {
  323. const { id } = req.params;
  324. const { status, reason } = req.body;
  325. const userId = req.user?.id || 'system';
  326. if (!['pending', 'passed', 'rejected', 'interviewed'].includes(status)) {
  327. return errorResponse(res, '无效的状态值', 400);
  328. }
  329. // 获取当前状态和职位ID
  330. const [current] = await pool.query(
  331. 'SELECT status, job_id FROM candidates WHERE id = ?',
  332. [id]
  333. );
  334. if (current.length === 0) {
  335. return errorResponse(res, '候选人不存在', 404);
  336. }
  337. const oldStatus = current[0].status;
  338. const jobId = current[0].job_id;
  339. const now = new Date();
  340. // 更新状态
  341. await pool.query(
  342. `UPDATE candidates
  343. SET status = ?, reviewed_at = ?
  344. WHERE id = ?`,
  345. [status, now, id]
  346. );
  347. // 记录状态变更历史
  348. await pool.query(
  349. `INSERT INTO candidate_status_history (
  350. id, candidate_id, old_status, new_status,
  351. changed_by, change_reason, change_time
  352. ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
  353. [crypto.randomUUID(), id, oldStatus, status, userId, reason || '状态更新', now]
  354. );
  355. // 更新职位统计
  356. if (status === 'passed' && oldStatus !== 'passed') {
  357. await pool.query(
  358. `UPDATE jobs
  359. SET pending_resumes = pending_resumes - 1,
  360. passed_resumes = passed_resumes + 1,
  361. updated_at = ?
  362. WHERE id = ?`,
  363. [now, jobId]
  364. );
  365. } else if (oldStatus === 'passed' && status !== 'passed') {
  366. await pool.query(
  367. `UPDATE jobs
  368. SET passed_resumes = passed_resumes - 1,
  369. updated_at = ?
  370. WHERE id = ?`,
  371. [now, jobId]
  372. );
  373. }
  374. // 返回更新后的候选人数据
  375. const [updated] = await pool.query(
  376. 'SELECT * FROM candidates WHERE id = ?',
  377. [id]
  378. );
  379. const [skills] = await pool.query(
  380. `SELECT s.name, cs.proficiency
  381. FROM candidate_skills cs
  382. JOIN skills s ON cs.skill_id = s.id
  383. WHERE cs.candidate_id = ?`,
  384. [id]
  385. );
  386. const responseData = formatCandidate(updated[0]);
  387. responseData.skills = skills;
  388. successResponse(res, responseData);
  389. } catch (error) {
  390. console.error('更新候选人状态错误:', error);
  391. errorResponse(res, error);
  392. }
  393. });
  394. // 批量筛选候选人
  395. router.post('/batch-screen', async (req, res) => {
  396. try {
  397. const { candidateIds } = req.body;
  398. const userId = req.user?.id || 'system';
  399. if (!Array.isArray(candidateIds) || candidateIds.length === 0) {
  400. return errorResponse(res, '无效的候选人ID列表', 400);
  401. }
  402. const now = new Date();
  403. // 获取当前候选人数据
  404. const [candidates] = await pool.query(
  405. `SELECT id, status, job_id, match_score
  406. FROM candidates
  407. WHERE id IN (?)`,
  408. [candidateIds]
  409. );
  410. if (candidates.length === 0) {
  411. return errorResponse(res, '未找到候选人', 404);
  412. }
  413. // 按职位分组统计
  414. const jobUpdates = {};
  415. candidates.forEach(candidate => {
  416. const newStatus = candidate.match_score >= 75 ? 'passed' : 'rejected';
  417. if (!jobUpdates[candidate.job_id]) {
  418. jobUpdates[candidate.job_id] = {
  419. pendingDecrease: 0,
  420. passedIncrease: 0
  421. };
  422. }
  423. jobUpdates[candidate.job_id].pendingDecrease += 1;
  424. if (newStatus === 'passed') {
  425. jobUpdates[candidate.job_id].passedIncrease += 1;
  426. }
  427. });
  428. // 批量更新候选人状态
  429. await pool.query(
  430. `UPDATE candidates
  431. SET status = CASE
  432. WHEN match_score >= 75 THEN 'passed'
  433. ELSE 'rejected'
  434. END,
  435. reviewed_at = ?
  436. WHERE id IN (?)`,
  437. [now, candidateIds]
  438. );
  439. // 记录状态变更历史
  440. const historyRecords = candidates.map(candidate => {
  441. const newStatus = candidate.match_score >= 75 ? 'passed' : 'rejected';
  442. return [
  443. crypto.randomUUID(),
  444. candidate.id,
  445. candidate.status,
  446. newStatus,
  447. userId,
  448. '批量筛选',
  449. now
  450. ];
  451. });
  452. await pool.query(
  453. `INSERT INTO candidate_status_history (
  454. id, candidate_id, old_status, new_status,
  455. changed_by, change_reason, change_time
  456. ) VALUES ?`,
  457. [historyRecords]
  458. );
  459. // 更新职位统计
  460. const jobUpdatePromises = Object.entries(jobUpdates).map(([jobId, stats]) => {
  461. return pool.query(
  462. `UPDATE jobs
  463. SET pending_resumes = pending_resumes - ?,
  464. passed_resumes = passed_resumes + ?,
  465. updated_at = ?
  466. WHERE id = ?`,
  467. [stats.pendingDecrease, stats.passedIncrease, now, jobId]
  468. );
  469. });
  470. await Promise.all(jobUpdatePromises);
  471. // 返回更新后的候选人列表
  472. const [updatedCandidates] = await pool.query(
  473. `SELECT * FROM candidates WHERE id IN (?)`,
  474. [candidateIds]
  475. );
  476. // 获取所有候选人的技能
  477. const [allSkills] = await pool.query(
  478. `SELECT cs.candidate_id, s.name, cs.proficiency
  479. FROM candidate_skills cs
  480. JOIN skills s ON cs.skill_id = s.id
  481. WHERE cs.candidate_id IN (?)`,
  482. [candidateIds]
  483. );
  484. const skillsMap = allSkills.reduce((map, skill) => {
  485. if (!map[skill.candidate_id]) {
  486. map[skill.candidate_id] = [];
  487. }
  488. map[skill.candidate_id].push({
  489. name: skill.name,
  490. proficiency: skill.proficiency
  491. });
  492. return map;
  493. }, {});
  494. const responseData = updatedCandidates.map(candidate => {
  495. const formatted = formatCandidate(candidate);
  496. formatted.skills = skillsMap[candidate.id] || formatSkills(candidate.skills);
  497. return formatted;
  498. });
  499. successResponse(res, responseData);
  500. } catch (error) {
  501. console.error('批量筛选候选人错误:', error);
  502. errorResponse(res, error);
  503. }
  504. });
  505. // 删除候选人
  506. router.delete('/:id', async (req, res) => {
  507. try {
  508. // 先获取候选人信息以便更新职位统计
  509. const [candidate] = await pool.query(
  510. 'SELECT job_id, status FROM candidates WHERE id = ?',
  511. [req.params.id]
  512. );
  513. if (candidate.length === 0) {
  514. return errorResponse(res, '候选人不存在', 404);
  515. }
  516. const jobId = candidate[0].job_id;
  517. const status = candidate[0].status;
  518. const now = new Date();
  519. // 删除候选人
  520. const [result] = await pool.query(
  521. 'DELETE FROM candidates WHERE id = ?',
  522. [req.params.id]
  523. );
  524. if (result.affectedRows === 0) {
  525. return errorResponse(res, '删除候选人失败', 500);
  526. }
  527. // 更新职位统计
  528. if (status === 'pending') {
  529. await pool.query(
  530. `UPDATE jobs
  531. SET pending_resumes = pending_resumes - 1,
  532. updated_at = ?
  533. WHERE id = ?`,
  534. [now, jobId]
  535. );
  536. } else if (status === 'passed') {
  537. await pool.query(
  538. `UPDATE jobs
  539. SET passed_resumes = passed_resumes - 1,
  540. updated_at = ?
  541. WHERE id = ?`,
  542. [now, jobId]
  543. );
  544. }
  545. successResponse(res, { id: req.params.id, deleted: true });
  546. } catch (error) {
  547. console.error('删除候选人错误:', error);
  548. errorResponse(res, error);
  549. }
  550. });
  551. // 获取候选人技能
  552. router.get('/:id/skills', async (req, res) => {
  553. try {
  554. const [skills] = await pool.query(`
  555. SELECT
  556. s.id,
  557. s.name,
  558. s.category,
  559. cs.proficiency
  560. FROM candidate_skills cs
  561. JOIN skills s ON cs.skill_id = s.id
  562. WHERE cs.candidate_id = ?
  563. `, [req.params.id]);
  564. successResponse(res, skills);
  565. } catch (error) {
  566. console.error('获取候选人技能错误:', error);
  567. errorResponse(res, error);
  568. }
  569. });
  570. // 获取候选人状态历史
  571. router.get('/:id/status-history', async (req, res) => {
  572. try {
  573. const [history] = await pool.query(`
  574. SELECT
  575. id,
  576. old_status AS oldStatus,
  577. new_status AS newStatus,
  578. changed_by AS changedBy,
  579. change_reason AS changeReason,
  580. change_time AS changeTime
  581. FROM candidate_status_history
  582. WHERE candidate_id = ?
  583. ORDER BY change_time DESC
  584. `, [req.params.id]);
  585. successResponse(res, history);
  586. } catch (error) {
  587. console.error('获取候选人状态历史错误:', error);
  588. errorResponse(res, error);
  589. }
  590. });
  591. module.exports = router;