From 82d93a1af173922e9ee3a4b39d809a590c403e1d Mon Sep 17 00:00:00 2001 From: let5sne Date: Wed, 15 Jul 2026 02:49:40 +0800 Subject: [PATCH] feat(aihr): allow employees to complete onboarding tasks --- .../aihr/controller/AihrMobileController.java | 9 +++++ .../dromara/aihr/domain/AihrPracticeDto.java | 3 ++ .../aihr/service/AihrMobileSeedService.java | 4 ++ .../aihr/service/AihrPracticeSeedService.java | 32 ++++++++++++++++ .../service/AihrPracticeSeedServiceTest.java | 5 ++- docs/API_INTEGRATION.md | 2 +- docs/BRD_IMPLEMENTATION_AUDIT.md | 2 + mobile-uni/src/pages/user/learning/index.vue | 38 ++++++++++++++++++- mobile-uni/src/services/practice.ts | 8 ++++ mobile-uni/tests/mobile-user-pages.test.mjs | 11 ++++++ scripts/demo-check.sh | 3 ++ 11 files changed, 112 insertions(+), 5 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java index c622f9ad..34847a40 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java @@ -15,6 +15,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskResponse; +import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskCompleteRequest; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; @@ -205,6 +206,14 @@ public class AihrMobileController { return R.ok(mobileSeedService.onboardTasks(ownMobileExtPartyId(null))); } + @PostMapping("/onboard/tasks/{id}/complete") + public R completeOnboardTask(@PathVariable Long id, + @RequestBody(required = false) OnboardTaskCompleteRequest request) { + String identity = ownMobileExtPartyId(null); + String evidenceRef = request == null ? null : request.evidenceRef(); + return mobileSeedService.completeOnboardTask(id, identity, evidenceRef) ? R.ok() : R.fail("任务不存在、已完成或无权操作"); + } + @GetMapping("/qualification") public R qualification() { return R.ok(mobileSeedService.qualification(ownMobileExtPartyId(null))); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index 1da894e9..d3053d61 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -293,6 +293,9 @@ public final class AihrPracticeDto { ) { } + public record OnboardTaskCompleteRequest(String evidenceRef) { + } + public record QualificationGateResponse( Long id, String projectCode, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java index 626114e7..cfa57bec 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java @@ -172,6 +172,10 @@ public class AihrMobileSeedService { return practiceSeedService.onboardTasks(extPartyId, 20); } + public boolean completeOnboardTask(Long taskId, String extPartyId, String evidenceRef) { + return practiceSeedService.completeOnboardTask(taskId, extPartyId, evidenceRef); + } + public QualificationResponse qualification(String extPartyId) { return practiceSeedService.qualification(extPartyId); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index 4dcdc49e..555549e5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -2086,6 +2086,38 @@ public class AihrPracticeSeedService { ), args.toArray()); } + public boolean completeOnboardTask(Long taskId, String extPartyId, String evidenceRef) { + String identity = isBlank(extPartyId) ? "" : extPartyId.trim(); + if (taskId == null || taskId <= 0 || identity.isEmpty() || !formalPositionTablesExist("aihr_onboard_task")) { + return false; + } + String normalizedEvidenceRef = isBlank(evidenceRef) ? null : evidenceRef.trim(); + if (normalizedEvidenceRef != null && normalizedEvidenceRef.length() > 500) { + normalizedEvidenceRef = normalizedEvidenceRef.substring(0, 500); + } + boolean hasOrgSnapshot = orgSnapshotTableExists() && orgSnapshotColumnExists("person_phone"); + String identityPredicate = hasOrgSnapshot + ? "(ext_party_id = ? OR ext_party_id IN (SELECT o.ext_party_id FROM aihr_org_snapshot o WHERE o.tenant_id = ? AND o.employment_status = 'active' AND (o.ext_party_id = ? OR o.person_phone = ?)))" + : "ext_party_id = ?"; + List args = new ArrayList<>(); + args.add(normalizedEvidenceRef); + args.add(tenantId()); + args.add(taskId); + args.add(identity); + if (hasOrgSnapshot) { + args.add(tenantId()); + args.add(identity); + args.add(identity); + } + return jdbcTemplate.update(""" + UPDATE aihr_onboard_task + SET status = '已完成', completed_time = now(), + evidence_ref = COALESCE(NULLIF(?, ''), evidence_ref), update_time = now() + WHERE tenant_id = ? AND id = ? AND %s + AND status IN ('待完成', '进行中') + """.formatted(identityPredicate), args.toArray()) > 0; + } + /** * Return qualification evidence for the current APP user without treating an * empty rules table as a pass. Formal qualification remains NOT_CONFIGURED until diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 409e9566..a49906c1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -660,7 +660,7 @@ public class AihrPracticeSeedServiceTest { } @Test - public void formalOnboardingReadsAreLoginScopedAndReadOnly() throws Exception { + public void formalOnboardingReadsAndCompletionAreLoginScoped() throws Exception { Path source = Path.of("src/main/java/org/dromara/aihr/controller/AihrMobileController.java"); if (!Files.exists(source)) { source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java"); @@ -672,7 +672,8 @@ public class AihrPracticeSeedServiceTest { assertTrue(controllerSource.contains("@GetMapping(\"/qualification\")")); assertTrue(controllerSource.contains("mobileSeedService.onboardTasks(ownMobileExtPartyId(null))")); assertTrue(controllerSource.contains("mobileSeedService.qualification(ownMobileExtPartyId(null))")); - assertFalse(controllerSource.contains("@PostMapping(\"/onboard/tasks\")")); + assertTrue(controllerSource.contains("@PostMapping(\"/onboard/tasks/{id}/complete\")")); + assertTrue(controllerSource.contains("mobileSeedService.completeOnboardTask(id, identity, evidenceRef)")); assertFalse(controllerSource.contains("@PostMapping(\"/qualification\")")); } diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 6058a117..b6fe4bf0 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -13,7 +13,7 @@ | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | -| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;员工学习页只读读取当前 APP 身份对应的正式岗前/入职任务与资格证据,无正式数据时明确返回空任务和 `NOT_CONFIGURED`,不以 AI 分数代替上岗资格;`position-sop` 仍保留一期生活顾问学习导航语义 | +| SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;员工学习页按当前 APP 身份读取正式岗前/入职任务,员工只能将本人处于“待完成/进行中”的任务确认完成,服务端按组织快照/手机号归属更新 `status/completed_time`,不接受前端身份参数;资格证据无正式数据时明确返回 `NOT_CONFIGURED`,不以 AI 分数代替上岗资格;`position-sop` 仍保留一期生活顾问学习导航语义 | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 已接入解析任务状态聚合;**批量上传走异步队列**:接口只暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;服务端目录导入、进度轮询和任务取消保留,失败回退 seed | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-10 源接口实测 3474 人仅 1 个可用手机号、3473 个脱敏手机号、8 条疑似乱码,因此未执行覆盖同步,正式试点需上游先开放至少 20 名试点人员手机号 | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) | diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index d37b644a..4c0153fe 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -1,5 +1,7 @@ # BRD 功能审查与未完成项记录 +- 2026-07-15 BRD 3.1/P1 岗前任务完成回写:正式 `aihr_onboard_task` 已有 `待完成/进行中/已完成` 状态字段,现新增登录 APP 用户本人范围内的 `POST /api/aihr/mobile/onboard/tasks/{id}/complete`,只允许组织快照/手机号归属命中的未完成任务更新 `status/completed_time`,可选保存员工提供的 `evidence_ref`;移动端学习页同步提供“确认完成”,完成后明确提示正式资格仍以 HR 复核为准,不把任务完成冒充资格通过。补充服务/控制器契约测试与 demo marker,未写入正式任务数据。 + - 2026-07-15 BRD 4.1 候选人语音/文本作答补齐:候选人面试页新增“录音转文字”和“选择音频”,复用现有 `/api/ai/asr`、MediaRecorder 与文件选择兜底;`/answer` 接受可选 `answerAudioOssId`,服务端校验音频处于当前 APP 身份的 `staged/bound` 状态后,才把 `ossId` 写入问题快照,既保留旧文字客户端兼容,也不向客户端暴露原始 OSS URL。补充归属拒绝单测、候选人页面单测和 demo marker,待本轮构建验证后再提交。 - 2026-07-15 BRD 4.1 候选人进度状态修正:候选人进度页此前用最近一份补充资料的审核状态作为“面试人工复核”状态,可能在面试已复核但资料未提交时误显示“人工复核中”;现改为面试状态读取 `InterviewRecord.reviewStatus`,并将补充资料审核单独展示为未提交/待审核/已通过/已驳回。仅修正移动端展示与类型契约,不改 API、数据库或审核结论;`mobile-uni` 类型检查、单元测试 `56/56` 和 H5 构建通过。 diff --git a/mobile-uni/src/pages/user/learning/index.vue b/mobile-uni/src/pages/user/learning/index.vue index b37c1e15..cf8cdd33 100644 --- a/mobile-uni/src/pages/user/learning/index.vue +++ b/mobile-uni/src/pages/user/learning/index.vue @@ -140,7 +140,17 @@ {{ item.status }}{{ item.projectCode ? ` · ${item.projectCode}` : '' }} 截止:{{ item.dueTime }} - {{ item.status }} + + {{ item.status }} + + 暂无正式岗前/入职任务,等待 HR 或组织系统派发 @@ -258,7 +268,7 @@ import type { PracticePrepCard, QualificationSummary } from '@/types/api'; -import { getOnboardTasks, getPracticeAssignments, getPracticeMistakes, getPracticePrepCard, getQualification } from '@/services/practice'; +import { completeOnboardTask, getOnboardTasks, getPracticeAssignments, getPracticeMistakes, getPracticePrepCard, getQualification } from '@/services/practice'; import { getPositionSopSummary, searchKnowledge } from '@/services/knowledge'; import { getSelectedPositionProfile, isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { buildLearningTopics, selectLearningTopic } from '@/services/learning'; @@ -287,6 +297,7 @@ const positionProfile = ref(getSelectedPositionProfile()); const onboardTasks = ref([]); const qualification = ref(null); const onboardingLoading = ref(false); +const onboardingCompletingId = ref(null); const qualificationLoading = ref(false); const defaultTopic = computed(() => `${positionProfile.value.value} 岗前 SOP`); @@ -418,6 +429,22 @@ const loadOnboarding = async () => { } }; +const completeOnboarding = async (task: OnboardTask) => { + if (onboardingCompletingId.value !== null || task.status === '已完成') return; + onboardingCompletingId.value = task.id; + try { + await completeOnboardTask(task.id); + onboardTasks.value = onboardTasks.value.map((item) => item.id === task.id + ? { ...item, status: '已完成', completedTime: new Date().toISOString() } + : item); + message.value = '任务已记录为完成,正式资格仍以 HR 复核为准'; + } catch (error) { + handleRequestError(error, '任务完成记录失败'); + } finally { + onboardingCompletingId.value = null; + } +}; + const loadQualification = async () => { if (!loggedIn.value) return; qualificationLoading.value = true; @@ -594,6 +621,13 @@ onShow(() => { min-width: 0; } +.onboarding-actions { + display: grid; + justify-items: end; + gap: 6px; + flex-shrink: 0; +} + .onboarding-title, .qualification-cert { color: #1f2d3d; diff --git a/mobile-uni/src/services/practice.ts b/mobile-uni/src/services/practice.ts index 4fe4d9c9..00669623 100644 --- a/mobile-uni/src/services/practice.ts +++ b/mobile-uni/src/services/practice.ts @@ -179,6 +179,14 @@ export const getOnboardTasks = () => timeout: 15000 }); +export const completeOnboardTask = (id: number, evidenceRef?: string) => + apiRequest({ + url: `/api/aihr/mobile/onboard/tasks/${id}/complete`, + method: 'POST', + data: evidenceRef ? { evidenceRef } : {}, + timeout: 15000 + }); + export const getQualification = () => apiRequest({ url: '/api/aihr/mobile/qualification', diff --git a/mobile-uni/tests/mobile-user-pages.test.mjs b/mobile-uni/tests/mobile-user-pages.test.mjs index f610b93b..99fa5486 100644 --- a/mobile-uni/tests/mobile-user-pages.test.mjs +++ b/mobile-uni/tests/mobile-user-pages.test.mjs @@ -197,6 +197,17 @@ test('学习页展示真实错题本并可进入已有再练任务', async () => assert.match(practiceService, /\/api\/aihr\/mobile\/practice\/mistakes/); }); +test('学习页允许员工确认本人正式岗前任务完成', async () => { + const [source, practiceService] = await Promise.all([ + pageSource('../src/pages/user/learning/index.vue'), + pageSource('../src/services/practice.ts') + ]); + + assert.match(source, /completeOnboarding/); + assert.match(source, /确认完成/); + assert.match(practiceService, /\/api\/aihr\/mobile\/onboard\/tasks\/\$\{id\}\/complete/); +}); + test('学习页显示岗位 SOP 适配状态而不冒充上岗资格', async () => { const [source, knowledgeService] = await Promise.all([ pageSource('../src/pages/user/learning/index.vue'), diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index 841bc897..f88815ac 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -556,6 +556,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPromptTemplateController.java "/api/aihr/prompt-templates" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPromptTemplateController.java "public R> list" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "mobileCompletedCount" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "completeOnboardTask" +contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/onboard/tasks/" +contains mobile-uni/src/pages/user/learning/index.vue "确认完成" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java "validateAnswerAudioOwnership" contains mobile-uni/src/pages/candidate/interview/index.vue "录音转文字" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "任务完成度"