From b8be1bb6e40021531cf4b1c2fea6aeabd20aac03 Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 11:15:07 +0800 Subject: [PATCH] fix(aihr): align app case permissions with brd --- .../aihr/controller/AihrCaseController.java | 6 +++++ .../org/dromara/aihr/domain/AihrCaseDto.java | 3 +++ .../aihr/service/AihrCaseServiceTest.java | 1 + docs/API_INTEGRATION.md | 2 +- docs/BRD_IMPLEMENTATION_AUDIT.md | 1 + mobile-uni/src/pages/user/cases/index.vue | 26 +++++++++++++++++-- mobile-uni/src/services/case.ts | 8 ++++++ mobile-uni/src/types/api.ts | 5 ++++ scripts/demo-check.sh | 2 ++ 9 files changed, 51 insertions(+), 3 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java index fe0fa832..642e82b2 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java @@ -9,6 +9,7 @@ import org.dromara.common.core.enums.UserType; import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.aihr.domain.AihrCaseDto.CurateRequest; import org.dromara.aihr.domain.AihrCaseDto.CurateResponse; +import org.dromara.aihr.domain.AihrCaseDto.CapabilitiesResponse; import org.dromara.aihr.domain.AihrCaseDto.DetailResponse; import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest; import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse; @@ -83,6 +84,11 @@ public class AihrCaseController { return detail == null ? R.fail("案例不存在或无权访问") : R.ok(detail); } + @GetMapping("/capabilities") + public R capabilities() { + return R.ok(new CapabilitiesResponse(canManageCases(), canViewCases())); + } + private List currentProjectScopes() { LoginUser loginUser = LoginHelper.getLoginUser(); if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java index 6ed0dbcc..0fd7bf58 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java @@ -26,6 +26,9 @@ public final class AihrCaseDto { public record CurateResponse(List selectedCaseIds, List records, String sampleHint) { } + public record CapabilitiesResponse(boolean canSubmit, boolean canView) { + } + public record SummaryResponse(String label, String text) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java index 2aedb3df..2194352f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java @@ -163,6 +163,7 @@ class AihrCaseServiceTest { assertTrue(controllerSource.contains("StpUtil.hasRoleOr")); assertTrue(controllerSource.contains("HR_OPERATOR_ROLE = \"hr_operator\"")); assertTrue(controllerSource.contains("private boolean canViewCases()")); + assertTrue(controllerSource.contains("@GetMapping(\"/capabilities\")")); } @Test diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 158182e0..c17dd101 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -11,7 +11,7 @@ | 三角色对练 `/train/practice` | `POST /api/train/practice/start`、`/turn`、`/finish` | 已接入编排 API;数据库启用 chat 模型后,`/turn` 客户回复按人设走真 LLM 生成(seed 剧本作剧情锚点),`/finish` 走单次 temperature=0 结构化评分(4 维分+导师改写+点评);移动端 `turn/finish` 校验当前手机号与启动会话归属,未知或他人会话拒绝;模型未配置或调用失败自动回退 seed,契约不变 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice}`,返回 `{audioUrl}` base64 dataURL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口(如硅基流动 SenseVoice/CosyVoice2);模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级文本;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | -| 案例沉淀 `/knowledge/cases` | `POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;移动端和管理端案例详情均可回放原始音频;管理端系统用户保留全局运营视图;预渲染视频样片、学习点和主管点评仍未接入 | +| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录保存 `mediaOssId/mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情均可回放原始音频;预渲染视频样片、学习点和主管点评仍未接入 | | SOP知识库 `/knowledge/sop` | `POST /api/knowledge/search`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`POST /api/knowledge/doc/upload` | 已接入 MySQL Fulltext + Qdrant 混合召回、岗位学习适配摘要、OSS-first 文档上传、txt/md/PDF/Word/Excel/PPT 解析和 embedding 写入,失败回退 seed;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次,SOP 人工评审记录同时保留答案生成提示词版本,管理端可继续复核;`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` 等字段。默认 `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 名试点人员手机号 | diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index b6a50acc..a553b7b2 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -187,3 +187,4 @@ - 2026-07-14 BRD SOP 人工评审结论完整性:`POST /api/knowledge/reviews/{id}` 现在要求明确提交 `usable=true/false`,空请求或缺少结论会拒绝,不再把未作答误记为“不可用”;`reviewer` 仍由当前登录操作人写入。已补服务层回归测试与 `demo-check` marker,但这只修正评审数据完整性,不代表住宅 SOP 可用率已达到正式试点门槛。 - 2026-07-14 线上只读核对:生产根站点、`/h5/` 和 `/prod-api/auth/tenant/list` 均返回 `200`,当前线上仍加载管理端 `index-CJZ3Ax3Z.js` 与 H5 `index-D4-NrEpb.js`;本地 `8fcc58fe` 的 SOP 评审结论校验尚未发布。`release-preflight.sh` 仍因两份既有 Figma 文档未提交而阻断,本轮未执行生产写入、静态资源同步或后端重启。 - 2026-07-14 BRD 4.6 案例发布状态复核:员工端案例列表和详情原先只按租户/项目范围读取,未限制案例状态,理论上可能看到仍处于“已转写/已整理”的管理流程半成品;现对 APP 项目范围读取增加 `status='已入库'`,管理端 HR/管理员仍可查看全部状态以继续处理。新增案例服务回归测试,AIHR 全量测试 `81/81` 通过;视频样片、学习点和主管点评仍需正式业务输入。 +- 2026-07-14 BRD 案例提交能力边界复核:移动端案例页原先对所有登录员工展示“提交素材”表单,但后端仅允许主管/项目负责人提交,普通员工点击后才得到权限错误;现新增只读 `GET /api/knowledge/case/capabilities`,复用服务端唯一贡献者判定,员工端仅在 `canSubmit=true` 时展示提交入口,其他员工显示案例学习说明。后端权限仍保留为最终防线。 diff --git a/mobile-uni/src/pages/user/cases/index.vue b/mobile-uni/src/pages/user/cases/index.vue index 0a145fdc..40dbacbd 100644 --- a/mobile-uni/src/pages/user/cases/index.vue +++ b/mobile-uni/src/pages/user/cases/index.vue @@ -25,7 +25,7 @@ - + 提交素材 {{ selectedFile?.name || '未选择音频' }} @@ -39,6 +39,11 @@ + + 案例学习 + 当前岗位可查看本项目已入库案例;提交案例素材需要主管或项目负责人权限。 + + ASR 转写 @@ -117,7 +122,7 @@ import { computed, ref } from 'vue'; import { onShow } from '@dcloudio/uni-app'; import type { CaseCurateResponse, CaseDetailResponse, CaseOrganizeResponse, CaseRecord, CaseSelectedFile, CaseUploadResponse } from '@/types/api'; -import { chooseCaseAudio, curateCase, getCaseDetail, getCaseRecords, organizeCase, uploadCaseAudio } from '@/services/case'; +import { chooseCaseAudio, curateCase, getCaseCapabilities, getCaseDetail, getCaseRecords, organizeCase, uploadCaseAudio } from '@/services/case'; import { isLoggedIn, rememberLoginRedirect } from '@/services/auth'; import { navigateBackToToday, resetPageScroll } from '@/services/navigation'; import { ensureEmployeePosition } from '@/services/position'; @@ -129,6 +134,7 @@ const organizeResult = ref(null); const curateResult = ref(null); const records = ref([]); const caseDetail = ref(null); +const canSubmitCases = ref(false); const status = ref<'idle' | 'uploading' | 'organizing' | 'curating'>('idle'); const recordsLoading = ref(false); const caseDetailLoading = ref(false); @@ -151,6 +157,7 @@ const clearCaseData = () => { selectedFile.value = null; records.value = []; caseDetail.value = null; + canSubmitCases.value = false; resetResult(); }; @@ -196,6 +203,20 @@ const loadRecords = async () => { } }; +const loadCaseCapabilities = async () => { + if (!loggedIn.value) { + canSubmitCases.value = false; + return; + } + try { + const capabilities = await getCaseCapabilities(); + canSubmitCases.value = Boolean(capabilities.canSubmit); + } catch (error) { + canSubmitCases.value = false; + handleRequestError(error, '案例权限加载失败', true); + } +}; + const refreshRecords = () => { if (!loggedIn.value) { goLogin(); @@ -256,6 +277,7 @@ const handleShow = async () => { if (!(await ensureEmployeePosition('/pages/user/cases/index'))) { return; } + void loadCaseCapabilities(); void loadRecords(); } else { clearCaseData(); diff --git a/mobile-uni/src/services/case.ts b/mobile-uni/src/services/case.ts index 906beb1d..ef521dc4 100644 --- a/mobile-uni/src/services/case.ts +++ b/mobile-uni/src/services/case.ts @@ -1,5 +1,6 @@ import type { CaseCurateResponse, + CaseCapabilities, CaseDetailResponse, CaseOrganizeResponse, CaseRecord, @@ -73,6 +74,13 @@ export const curateCase = (caseId: string) => timeout: 30000 }); +export const getCaseCapabilities = () => + apiRequest({ + url: '/api/knowledge/case/capabilities', + method: 'GET', + timeout: 15000 + }); + export const getCaseRecords = () => apiRequest({ url: '/api/knowledge/case/records?limit=10', diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index 4af58c98..7f3971f7 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -105,6 +105,11 @@ export interface KnowledgeSearchResponse { reviewId?: number | null; } +export interface CaseCapabilities { + canSubmit: boolean; + canView: boolean; +} + export interface PositionSopTopic { category: string; question: string; diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index 2bc8ad51..abbe07ca 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -355,6 +355,7 @@ contains mobile-uni/src/services/knowledge.ts "/api/knowledge/position-sop" contains mobile-uni/src/services/position.ts "removeStorageSync(roleKey)" contains mobile-uni/src/services/position.ts "/api/aihr/mobile/me" contains mobile-uni/src/services/case.ts "/api/knowledge/case/upload" +contains mobile-uni/src/services/case.ts "/api/knowledge/case/capabilities" contains mobile-uni/src/services/case.ts "/api/knowledge/case/records/" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/records/{caseId}" contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start" @@ -516,6 +517,7 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java "AihrSensitiveText.forModel(fileName)" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrOrgSyncService.java "外部响应体已隐藏" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "仅项目负责人或主管可提交案例素材" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "@GetMapping(\"/capabilities\")" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "StpUtil.hasRoleOr" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "private boolean canViewCases()" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "position_level IN ('主管', '项目经理')"