2
2

candidates.js 18 KB

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