diff --git a/AGENTS.md b/AGENTS.md index 68af7460..7105d6df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,11 +53,13 @@ - 管理端业务路由集中在 `frontend/src/router/index.ts`,覆盖首页、AI 面试、训练运营、问题榜、案例、知识空间、SOP、资料处理、大喇叭、五渠道直通反馈、系统配置、组织权限和成长激励;侧栏过滤在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。 - 用户侧只改 `mobile-uni/src/pages/*`;员工、候选人、主管首页分别为 `/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index`,旧 `/h5/user|candidate|supervisor|employee|admin` 只做兼容重定向,底栏固定「今日/练/问/我」。“问”统一调用 `/api/aihr/agent/messages|messages/media`,服务端规划意图并按固定策略选择既有工具,客户端不得提交 `toolCode`;旧知识查询仅作底层/兼容。登录端短期会话为 30 分钟/最新 6 轮,原文件/视频经 `/api/knowledge/resources/{attachmentId}/content` 重新鉴权。总结卡只接受当前答案中的最多 5 个 `DOCUMENT` 引用 `fragmentIds`,服务端重新鉴权后仅基于这些片段生成固定「问题/建议/标准」结构;不得二次检索,失败不得拿原文冒充结构化步骤。全网外发必须先同意,显式记忆先出确认卡,再通过不透明 `draftId` 按 `PRIVATE/COMPANY` 确认;`COMPANY` 仅为 `PENDING`,不得冒充已派单或送达。`aihr_agent_run` 不保存问题、答案或附件,外部知识 API 保持无状态。 -- 大喇叭详情追问只让客户端传 `broadcastMessageId`,消息正文、附件文本、租户和身份均由服务端重新校验并加载;撤回、跨租户或非在职员工不得继续使用消息上下文。单条消息最多绑定一个属于当前租户、当前上传人且状态为 `READY` 的公司文件;员工端只返回摘要、受控下载入口和有原文短引用佐证的多岗位解读,不返回提取全文或原始 OSS 地址。默认岗位视角由服务端按认证员工岗位选择,客户端不得提交视角代码;解读处于 `PENDING/PARTIAL/FAILED` 不阻断摘要、原文件、发布或追问。消息/知识回答不得把通用建议包装成当前员工的真实待办,强行动入口只能来自服务端已授权的真实任务数据。 +- 大喇叭发布支持 `ALL/TARGET_ONLY`:`TARGET_ONLY` 必须有目标并冻结收件人,列表、详情、未读、阅读、附件和追问统一复核可见性,非目标按不存在处理;`ALL` 下目标只作定向提醒。详情追问只让客户端传 `broadcastMessageId`,消息正文、附件文本、租户和身份均由服务端重新校验并加载;撤回、跨租户或非在职员工不得继续使用消息上下文。单条消息最多绑定一个属于当前租户、当前上传人且状态为 `READY` 的公司文件;`allowDownload=false` 时客户端不展示且服务端拒绝下载,在线查看返回受控文本并记录审计,动态水印只用于追溯。主题标签必须带原文短依据且不得参与授权。员工端不返回提取全文或原始 OSS 地址;默认岗位视角由服务端按认证员工岗位选择,客户端不得提交视角代码;解读处于 `PENDING/PARTIAL/FAILED` 不阻断摘要、受控查看、发布或追问。消息/知识回答不得把通用建议包装成当前员工的真实待办,强行动入口只能来自服务端已授权的真实任务数据。 - 直通车固定为总裁、财务、人力、审计、运营五个渠道;业务匿名只隐藏处理端的提交人展示,服务端仍保留内部提交账号供本人查询、幂等和审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 只处理各自渠道,`superadmin` 可处理全部;只有 `superadmin` 可维护获批处理人,候选人必须由服务端限定为当前租户已启用的非 APP、非超级管理员后台用户,写入须校验租户上下文,系统不得自动把任意账号绑定成真实收件人。当前产品边界只允许一次正式回复,不扩展为多轮聊天、工单、转派或 SLA。员工提交页支持语音转文字输入(复用 `/api/ai/asr` 与 `services/speech.ts`);该表单页以文字为主,录音不可用或权限失败时只提示改文字,是不提供音频文件降级的明确例外。 - 术语和数据边界:工作助手承载日常事实与确认式记忆;现有 `/pages/user/report/index`、`/api/aihr/work-report/**` 和 `aihr_work_report` 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿审核,产品名为“成果投稿”;“今日工作成果”按员工 + 项目 + 自然日从已确认记录确定性聚合,员工页为 `/pages/user/work-results/index`,主管页为 `/pages/supervisor/work-results/index`,默认当日但允许查看历史日期且不得选择未来日期。不要把三条链路合表或自动互写。多项目交互只让用户按项目名称选择,内部项目编码必须由服务端按当前身份复核,不让用户输入编码。 - 不要把三端概括成全模态:员工对练/每日题、问师傅和直通车反馈支持文字/语音,问师傅图片/视频只用于本次分析,案例传音频;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管写文字复盘/指派并回听录音。员工对练图像/视频情境、服务录像回放和方言正式验收仍未完成;语音继续复用 `services/speech.ts` 与 `/api/ai/asr`。 - 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。 +- 问师傅的未解决答案只有在本人先提交 `down` 反馈并再次确认后,才能由服务端重读本人查询审计和反馈创建 `PENDING` 问题榜条目;客户端不得提交改写答案、引用、身份或奖励,重复请求必须返回同一问题。正式学习任务与每日三题分开:材料只允许当前租户 `READY` 附件,任务保存材料版本、周期、起止时间和完成规则;`EXAM_PASS` 只能由关联考试通过完成,随机组卷只从已审核启用题库抽取并在发布时冻结来源和答案。 +- 案例与训练高分只进入经验候选;业务审核不等于正式发布。正式经验必须另行确认来源授权、脱敏、适用岗位、负责人、版本和生效期后进入 `PUBLISHED`,员工只可召回当前生效的正式经验;下线后立即停止员工召回。案例、成果投稿和知识空间不得自动互写。 - 全网 AI 与企业问师傅必须分入口、分来源和分免责声明;提供方仅允许公网 HTTPS,修改地址/密钥后必须连接测试成功才能启用,未配置时明确不可用且不生成假答案。 - 多租户知识平台的有效范围是“租户 + 调用应用绑定 + 主体授权”的交集;不信任客户端传入身份,API_TOKEN 不得进入浏览器或小程序包。生产排序规则以 `aihr_knowledge_info.tenant_id` 为基准,新表最后执行 `aihr_20260718_release_collation_compat_mysql8.sql`。 - 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b819947..6f0ebf37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- **2026-08-02,功能复核清单首批增量已实现并完成本地验证,未部署**:当前工作树新增大喇叭 `ALL/TARGET_ONLY` 可见性、受控在线查看与动态水印、下载门禁和带原文依据的主题标签;问师傅未解决反馈确认转待审核问题;正式学习任务、审核题库随机组卷及考试关联;案例经验“候选→审核→发布→下线”;原生异常恢复回归和资料暂存盘容量健康门禁。管理端生产构建、移动端类型检查与 `216/216` 单测、后端 `838/838` 测试及迁移静态清单通过;`390×844` 已验证员工主要入口和登录门禁。Docker/MySQL 未运行,四个 `aihr_20260802_*` 迁移尚未执行,管理端认证业务、峰值压测、签名 APK、真机、生产发布和严格试点均未验收。 - **2026-07-31,8 月 1 日受控内部公测门禁收口为 Go**:最新后端 JAR SHA-256 `d5145194024ba3526a869e3270df6d71468931b96ba9aebb93121592e54012eb` 已上线,正式政策模式只允许当前有效、已批准、版本与原文件哈希完整且与问题领域相关的来源进入回答;没有相关制度时失败关闭,不再回退经验萃取或访谈材料。使用生产现有 APP 账号重跑 30 道策略题,11 题只引用已批准正式来源,分期缴费题返回 `NO_EVIDENCE`,其余冲突、无依据和越权题按边界返回,合计 `30/30`;证据不保存手机号、验证码、令牌、问题或答案正文。生产 5 个 `v1.1` 场景及独立审核链、五通道各 2 名有效处理人、本通道正例/跨通道拒绝/一次正式回复 `5/5`、五类负责人委托签认,以及 `0.1.17 (117)` 的 DCloud 测试签名受控分发例外均已回填。严格内容门禁和生产只读就绪门禁均通过;同时修复就绪脚本把含 `|` 的场景快照直接作为 SSH 参数导致远端误解析为管道的问题,改为 Base64 单参数传输。该 Go 仅适用于受控内部公测,不代表应用商店发布、企业正式签名或阶段一严格试点窗口通过。 - **2026-07-30,5 个真实访谈场景已落入生产停用草稿,正式内容门禁保持 No-Go**:从 `D:\20260730` 的 12 份访谈材料中选择 7 份代表性来源,以脱敏主题和 SHA-256 登记到 `aug1-life-advisor-candidate-v0.2`,形成管道漏水责任、物业费争议、维修延期、停车冲突、收费与整改并行五个真实业务场景。已登录生产管理端逐条保存并按编码回查,场景总数从 `100` 增至 `105`,当前启用数保持 `86`,五项均为 `草稿 / 草稿未提交 / 停用`;费用复核作为业务动作和审核职责保留,不把系统不支持的财务部门伪装成住宅训练岗位。候选文件仍为 `publishable=false`,30 道政策题仍无标准答案;严格门禁继续准确保持正式场景 `0/5`、问题证据 `0/30`,本项没有提交业务审核、启用或发布场景,也没有部署代码、重启服务或执行 schema 迁移。 - **2026-07-30,`0.1.17 (117)` Android 技术候选与五通道处理人配置完成收口,正式发布仍为 No-Go**:管理端“直通反馈”五通道处理人配置已在线可用,生产按有效后台用户规则只读复核后,`direct_president`、`direct_finance`、`direct_hr`、`direct_audit`、`direct_operations` 均为 `2/2`;Android 权限 Activity 生命周期修复已进入新的 DCloud 测试签名 APK `mobile-uni/dist/debug/bangdao-0.1.17-dcloud-test.apk`,包体 SHA-256 为 `0A8581D70A5988E4BDCF4BB3F0C88B4608FA8C6785FDCF1569DD56CE88E27F60`,包名 `com.yincheng.wygj`、版本 `0.1.17 (117)`、target SDK 35、禁止明文流量和签名深检均通过。vivo V2443A(Android 15/API 35)已在同一签名包上完成覆盖安装、正式账号登录、员工首页、学习中心、麦克风允许/拒绝/最终恢复、系统音频选择器、App 媒体选择器、生产 ASR、成果投稿确认和五通道入口回归;测试成果形成 `#7 / PENDING` 待审核记录,测试音频与会话已清理,最终无 Crash/ANR。移动端 209 项、后端定向 46 项、管理端定向 4 项、类型检查、App-Plus 构建、发布资源门禁和生产认证态冒烟均通过;认证态 UI 审计改为只从调用参数或环境变量接收手机号/验证码,报告、控制台和默认截图路由不再持久化认证凭据。该 APK 仅安装到已连接真机,未进行受控分发,也未在本项重发 H5、管理端、后端或 schema。严格门禁仍为正式场景 `0/5`、题库证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`,且同包离线、WebRTC、图片/视频/文档上传、受控下载和主管/候选人角色尚未验收,因此只能作为继续受控验收的技术候选,不能签署 8 月 1 日正式 Go。 diff --git a/backend/ruoyi-admin/src/main/resources/application-prod.yml b/backend/ruoyi-admin/src/main/resources/application-prod.yml index 276a287f..578000fe 100644 --- a/backend/ruoyi-admin/src/main/resources/application-prod.yml +++ b/backend/ruoyi-admin/src/main/resources/application-prod.yml @@ -4,6 +4,11 @@ spring.servlet.multipart.location: /ruoyi/server/temp spring.servlet.multipart.max-file-size: 500MB spring.servlet.multipart.max-request-size: 520MB +# RC-007: expose server request percentiles for the separately approved load model. +# Error rate remains queryable from the same metric by status/outcome tags. +management.metrics.distribution.percentiles-histogram.http.server.requests: true +management.metrics.distribution.percentiles.http.server.requests: 0.5,0.95,0.99 + --- # 监控中心配置 spring.boot.admin.client: # 增加客户端开关 diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java index 7a94033a..45af95d6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentService.java @@ -4,6 +4,10 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentViewResponse; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTopicTag; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTopicTagRequest; +import org.dromara.aihr.broadcast.AihrBroadcastDto.UpdateBroadcastTopicTagsRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser; import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; @@ -22,11 +26,13 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.io.InputStream; import java.sql.PreparedStatement; import java.sql.Statement; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Set; @@ -42,9 +48,20 @@ import java.util.concurrent.atomic.AtomicBoolean; public class AihrBroadcastAttachmentService { private static final long MAX_FILE_BYTES = 100L * 1024 * 1024; + private static final int MAX_VIEW_CHARACTERS = 200_000; private static final Set SUPPORTED_SUFFIXES = Set.of( "txt", "md", "markdown", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx" ); + private static final List TOPIC_RULES = List.of( + new TopicRule("保洁", List.of("保洁", "清洁", "消杀", "垃圾分类")), + new TopicRule("安保", List.of("安保", "保安", "秩序", "门岗", "巡逻")), + new TopicRule("收费", List.of("收费", "物业费", "催缴", "欠费")), + new TopicRule("工程维修", List.of("工程维修", "维修", "报修", "设施设备")), + new TopicRule("消防", List.of("消防", "灭火器", "消防通道")), + new TopicRule("客户服务", List.of("客户服务", "生活顾问", "管家", "投诉")), + new TopicRule("安全", List.of("安全生产", "安全隐患", "应急预案")), + new TopicRule("人力", List.of("人力资源", "员工培训", "考勤")) + ); private final JdbcTemplate jdbcTemplate; private final ISysOssService ossService; @@ -115,7 +132,7 @@ public class AihrBroadcastAttachmentService { insight_status, insights_json from aihr_broadcast_attachment where tenant_id = ? and id = ? - """, (rs, rowNum) -> response( + """, (rs, rowNum) -> response(tenantId, rs.getLong("id"), rs.getString("file_name"), rs.getLong("file_size"), rs.getString("content_type"), rs.getString("status"), rs.getString("summary"), rs.getString("error_message"), rs.getString("insight_status"), rs.getString("insights_json") @@ -127,21 +144,117 @@ public class AihrBroadcastAttachmentService { } public Long authorizedDownloadOssId(Long rawAttachmentId) { + AttachmentAccess access = authorizedAccess(rawAttachmentId, true); + return access == null ? null : access.ossId(); + } + + public BroadcastAttachmentViewResponse authorizedView(Long rawAttachmentId) { + long attachmentId = attachmentId(rawAttachmentId); + AihrKnowledgePrincipal principal = principalResolver.current(); + if (!UserType.APP_USER.getUserType().equals(principal.userType())) { + throw new ServiceException("仅在职员工可在线阅读公司文件", HttpStatus.FORBIDDEN); + } + List rows = jdbcTemplate.query(""" + select a.message_id, a.file_name, a.extracted_text + from aihr_broadcast_attachment a + join aihr_broadcast_message m + on m.tenant_id = a.tenant_id and m.id = a.message_id and m.status = 'PUBLISHED' + where a.tenant_id = ? and a.id = ? and a.status = 'READY' + """, (rs, rowNum) -> new AttachmentView( + rs.getLong("message_id"), rs.getString("file_name"), rs.getString("extracted_text") + ), principal.tenantId(), attachmentId); + if (rows.isEmpty()) { + throw new ServiceException("公司文件不存在或无权访问", HttpStatus.NOT_FOUND); + } + AttachmentView view = rows.get(0); + broadcastService.message(view.messageId()); + jdbcTemplate.update(""" + insert into aihr_broadcast_attachment_access_audit + (tenant_id, message_id, attachment_id, user_id, access_type, access_time) + values (?, ?, ?, ?, 'VIEW', now()) + """, principal.tenantId(), view.messageId(), attachmentId, principal.userId()); + String source = view.content() == null ? "" : view.content(); + boolean truncated = source.length() > MAX_VIEW_CHARACTERS; + String content = truncated ? source.substring(0, MAX_VIEW_CHARACTERS) : source; + return new BroadcastAttachmentViewResponse(attachmentId, view.fileName(), content, truncated, + watermarkText(principal), topicTags(principal.tenantId(), attachmentId)); + } + + private AttachmentAccess authorizedAccess(Long rawAttachmentId, boolean download) { long attachmentId = attachmentId(rawAttachmentId); AihrKnowledgePrincipal principal = principalResolver.current(); if (UserType.APP_USER.getUserType().equals(principal.userType())) { - List messageIds = jdbcTemplate.query(""" - select message_id from aihr_broadcast_attachment - where tenant_id = ? and id = ? and status = 'READY' and message_id is not null - """, (rs, rowNum) -> rs.getLong("message_id"), principal.tenantId(), attachmentId); - if (messageIds.isEmpty()) { + List rows = jdbcTemplate.query(""" + select a.oss_id, a.message_id, + case when m.allow_download = 1 then 1 else 0 end as allow_download + from aihr_broadcast_attachment a + join aihr_broadcast_message m + on m.tenant_id = a.tenant_id and m.id = a.message_id and m.status = 'PUBLISHED' + where a.tenant_id = ? and a.id = ? and a.status = 'READY' + """, (rs, rowNum) -> new AttachmentAccess( + rs.getLong("oss_id"), rs.getLong("message_id"), rs.getBoolean("allow_download") + ), principal.tenantId(), attachmentId); + if (rows.isEmpty()) { return null; } - broadcastService.message(messageIds.get(0)); - return ossId(principal.tenantId(), attachmentId); + AttachmentAccess access = rows.get(0); + broadcastService.message(access.messageId()); + if (download && !access.allowDownload()) { + throw new ServiceException("该公司文件仅允许在线阅读", HttpStatus.FORBIDDEN); + } + jdbcTemplate.update(""" + insert into aihr_broadcast_attachment_access_audit + (tenant_id, message_id, attachment_id, user_id, access_type, access_time) + values (?, ?, ?, ?, ?, now()) + """, principal.tenantId(), access.messageId(), attachmentId, principal.userId(), + download ? "DOWNLOAD" : "VIEW"); + return access; } requireBroadcastAdmin(principal); - return ossId(adminTenant(principal), attachmentId); + Long ossId = ossId(adminTenant(principal), attachmentId); + return ossId == null ? null : new AttachmentAccess(ossId, 0, true); + } + + @Transactional(rollbackFor = Exception.class) + public BroadcastAttachmentResponse updateTopicTags(Long rawAttachmentId, UpdateBroadcastTopicTagsRequest request) { + AihrKnowledgePrincipal principal = principalResolver.current(); + String tenantId = adminTenant(principal); + requireExpectedTenant(request == null ? null : request.expectedTenantId(), tenantId); + long attachmentId = attachmentId(rawAttachmentId); + List sourceRows = jdbcTemplate.query(""" + select extracted_text from aihr_broadcast_attachment + where tenant_id = ? and id = ? and uploaded_by = ? + and status = 'READY' and message_id is null + for update + """, (rs, rowNum) -> rs.getString("extracted_text"), tenantId, attachmentId, principal.userId()); + if (sourceRows.isEmpty()) { + throw new ServiceException("公司文件不存在、已发布或无权修正标签", HttpStatus.CONFLICT); + } + List tags = request == null ? List.of() : request.topicTags(); + String source = sourceRows.get(0) == null ? "" : sourceRows.get(0); + Set names = new java.util.LinkedHashSet<>(); + List normalizedTags = new ArrayList<>(); + for (BroadcastTopicTagRequest rawTag : tags) { + String name = clean(rawTag == null ? null : rawTag.name(), 30, "主题标签"); + String evidence = clean(rawTag == null ? null : rawTag.evidenceQuote(), 200, "主题标签原文依据"); + if (!source.contains(evidence)) { + throw new ServiceException("主题标签“" + name + "”的依据不是文件原文", HttpStatus.BAD_REQUEST); + } + if (!names.add(name)) { + throw new ServiceException("主题标签不能重复", HttpStatus.BAD_REQUEST); + } + normalizedTags.add(new BroadcastTopicTagRequest(name, evidence)); + } + jdbcTemplate.update("delete from aihr_broadcast_attachment_topic_tag where tenant_id = ? and attachment_id = ?", + tenantId, attachmentId); + for (BroadcastTopicTagRequest tag : normalizedTags) { + jdbcTemplate.update(""" + insert into aihr_broadcast_attachment_topic_tag + (tenant_id, attachment_id, tag_name, source_type, evidence_quote, created_by, create_time, update_time) + values (?, ?, ?, 'MANUAL', ?, ?, now(), now()) + """, tenantId, attachmentId, tag.name(), tag.evidenceQuote(), principal.userId()); + } + return adminAttachment(attachmentId); } private Long ossId(String tenantId, long attachmentId) { @@ -279,6 +392,8 @@ public class AihrBroadcastAttachmentService { """, text, summary, work.tenantId(), work.id()); if (updated != 1) { log.info("broadcast attachment {} completed after its state changed", work.id()); + } else { + persistAutoTopicTags(work.tenantId(), work.id(), text); } } catch (Exception error) { log.warn("broadcast attachment {} processing failed", work.id()); @@ -334,6 +449,7 @@ public class AihrBroadcastAttachmentService { } private BroadcastAttachmentResponse response( + String tenantId, long id, String fileName, long fileSize, @@ -349,11 +465,67 @@ public class AihrBroadcastAttachmentService { List labels = AihrBroadcastInsightService.readPerspectives(insightsJson).stream() .map(AihrBroadcastDto.BroadcastPerspective::label) .toList(); + String contentUrl = "READY".equals(status) ? "/api/aihr/broadcast/attachments/" + id + "/content" : null; return new BroadcastAttachmentResponse(id, fileName, fileSize, contentType, status, summary, - errorMessage, "READY".equals(status) ? "/api/aihr/broadcast/attachments/" + id + "/content" : null, + errorMessage, contentUrl, contentUrl, true, null, topicTags(tenantId, id), insightStatus, null, labels, List.of()); } + private List topicTags(String tenantId, long attachmentId) { + return jdbcTemplate.query(""" + select tag_name, source_type, evidence_quote + from aihr_broadcast_attachment_topic_tag + where tenant_id = ? and attachment_id = ? + order by source_type desc, tag_name + """, (rs, rowNum) -> new BroadcastTopicTag( + rs.getString("tag_name"), rs.getString("source_type"), rs.getString("evidence_quote") + ), tenantId, attachmentId); + } + + private void persistAutoTopicTags(String tenantId, long attachmentId, String source) { + Long manualCount = jdbcTemplate.queryForObject(""" + select count(*) from aihr_broadcast_attachment_topic_tag + where tenant_id = ? and attachment_id = ? and source_type = 'MANUAL' + """, Long.class, tenantId, attachmentId); + if (manualCount != null && manualCount > 0) { + return; + } + jdbcTemplate.update("delete from aihr_broadcast_attachment_topic_tag where tenant_id = ? and attachment_id = ?", + tenantId, attachmentId); + for (BroadcastTopicTag tag : autoTopicTags(source)) { + jdbcTemplate.update(""" + insert into aihr_broadcast_attachment_topic_tag + (tenant_id, attachment_id, tag_name, source_type, evidence_quote, created_by, create_time, update_time) + values (?, ?, ?, 'AUTO', ?, null, now(), now()) + """, tenantId, attachmentId, tag.name(), tag.evidenceQuote()); + } + } + + static List autoTopicTags(String rawSource) { + String source = rawSource == null ? "" : rawSource.replace("\r\n", "\n").replace('\r', '\n').trim(); + if (source.isEmpty()) return List.of(); + List tags = new ArrayList<>(); + for (TopicRule rule : TOPIC_RULES) { + int match = -1; + for (String keyword : rule.keywords()) { + int candidate = source.indexOf(keyword); + if (candidate >= 0 && (match < 0 || candidate < match)) match = candidate; + } + if (match < 0) continue; + int start = Math.max(0, source.lastIndexOf('\n', match) + 1); + int lineEnd = source.indexOf('\n', match); + int end = lineEnd < 0 ? source.length() : lineEnd; + String evidence = source.substring(start, end).trim(); + if (evidence.length() > 200) { + int excerptStart = Math.max(start, match - 60); + int excerptEnd = Math.min(source.length(), excerptStart + 200); + evidence = source.substring(excerptStart, excerptEnd).trim(); + } + if (!evidence.isEmpty()) tags.add(new BroadcastTopicTag(rule.name(), "AUTO", evidence)); + } + return List.copyOf(tags); + } + private static void requireBroadcastAdmin(AihrKnowledgePrincipal principal) { if (UserType.APP_USER.getUserType().equals(principal.userType()) || (!principal.roles().contains(TenantConstants.SUPER_ADMIN_ROLE_KEY) @@ -395,6 +567,32 @@ public class AihrBroadcastAttachmentService { return normalized.isEmpty() ? "application/octet-stream" : normalized.substring(0, Math.min(100, normalized.length())); } + private static String clean(String value, int maximumLength, String label) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isEmpty()) { + throw new ServiceException(label + "不能为空", HttpStatus.BAD_REQUEST); + } + if (normalized.length() > maximumLength) { + throw new ServiceException(label + "不能超过 " + maximumLength + " 个字符", HttpStatus.BAD_REQUEST); + } + return normalized; + } + + private static String watermarkText(AihrKnowledgePrincipal principal) { + return "帮道内部资料 · 用户#" + principal.userId() + " · " + + java.time.LocalDateTime.now().withSecond(0).withNano(0) + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + } + + private record TopicRule(String name, List keywords) { + } + + private record AttachmentAccess(long ossId, long messageId, boolean allowDownload) { + } + + private record AttachmentView(long messageId, String fileName, String content) { + } + private record AttachmentWork( long id, String tenantId, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java index d9e48ac1..166ac6a7 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastController.java @@ -11,15 +11,18 @@ import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastDetailResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastListResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetOptions; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentViewResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.UnreadCountResponse; +import org.dromara.aihr.broadcast.AihrBroadcastDto.UpdateBroadcastTopicTagsRequest; import org.dromara.aihr.broadcast.AihrBroadcastDto.WithdrawRequest; import org.dromara.common.core.constant.TenantConstants; import org.dromara.common.core.domain.R; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -88,6 +91,15 @@ public class AihrBroadcastController { return R.ok(attachmentService.adminAttachment(id)); } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PutMapping("/admin/attachments/{id}/topic-tags") + public R updateAttachmentTopicTags( + @PathVariable Long id, + @Valid @RequestBody UpdateBroadcastTopicTagsRequest request + ) { + return R.ok(attachmentService.updateTopicTags(id, request)); + } + @GetMapping("/messages/{id}") public R message(@PathVariable Long id) { return R.ok(broadcastService.message(id)); @@ -109,6 +121,11 @@ public class AihrBroadcastController { ossService.download(ossId, response); } + @GetMapping("/attachments/{id}/view") + public R attachmentView(@PathVariable Long id) { + return R.ok(attachmentService.authorizedView(id)); + } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @PostMapping("/messages") public R publish(@Valid @RequestBody PublishRequest request) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java index e44fc50d..45266f7e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastDto.java @@ -30,10 +30,11 @@ public final class AihrBroadcastDto { String publishedAt, boolean requiredRead, boolean targeted, - String targetReason + String targetReason, + String visibilityMode ) { public BroadcastListItem(Long id, String title, boolean read, String publishedAt) { - this(id, title, read, publishedAt, false, false, null); + this(id, title, read, publishedAt, false, false, null, "ALL"); } } @@ -46,10 +47,31 @@ public final class AihrBroadcastDto { boolean requiredRead, boolean targeted, String targetReason, + String visibilityMode, BroadcastAttachmentResponse attachment ) { public BroadcastDetailResponse(Long id, String title, String content, boolean read, String publishedAt) { - this(id, title, content, read, publishedAt, false, false, null, null); + this(id, title, content, read, publishedAt, false, false, null, "ALL", null); + } + } + + public record BroadcastTopicTag( + String name, + String sourceType, + String evidenceQuote + ) { + } + + public record BroadcastAttachmentViewResponse( + Long id, + String fileName, + String content, + boolean truncated, + String watermarkText, + List topicTags + ) { + public BroadcastAttachmentViewResponse { + topicTags = topicTags == null ? List.of() : List.copyOf(topicTags); } } @@ -61,13 +83,18 @@ public final class AihrBroadcastDto { String status, String summary, String errorMessage, + String viewUrl, String downloadUrl, + boolean allowDownload, + String watermarkText, + List topicTags, String insightStatus, String defaultPerspectiveCode, List perspectiveLabels, List perspectives ) { public BroadcastAttachmentResponse { + topicTags = topicTags == null ? List.of() : List.copyOf(topicTags); perspectiveLabels = perspectiveLabels == null ? List.of() : List.copyOf(perspectiveLabels); perspectives = perspectives == null ? List.of() : List.copyOf(perspectives); } @@ -83,7 +110,7 @@ public final class AihrBroadcastDto { String downloadUrl ) { this(id, fileName, fileSize, contentType, status, summary, errorMessage, downloadUrl, - "NOT_PROCESSED", null, List.of(), List.of()); + downloadUrl, true, null, List.of(), "NOT_PROCESSED", null, List.of(), List.of()); } } @@ -130,6 +157,8 @@ public final class AihrBroadcastDto { String withdrawnAt, String withdrawReason, boolean requiredRead, + String visibilityMode, + boolean allowDownload, long targetRuleCount, long matchedRecipientCount, long unmatchedTargetCount @@ -147,7 +176,7 @@ public final class AihrBroadcastDto { String withdrawReason ) { this(id, title, content, status, currentVersion, publishedBy, publishedAt, - withdrawnBy, withdrawnAt, withdrawReason, false, 0, 0, 0); + withdrawnBy, withdrawnAt, withdrawReason, false, "ALL", true, 0, 0, 0); } } @@ -195,11 +224,15 @@ public final class AihrBroadcastDto { @Size(max = 64, message = "当前租户不能超过 64 个字符") String expectedTenantId, Boolean requiredRead, + @Pattern(regexp = "ALL|TARGET_ONLY", message = "发布模式只支持 ALL 或 TARGET_ONLY") + String visibilityMode, + Boolean allowDownload, @Valid BroadcastTargetRequest targets, Long attachmentId ) { public PublishRequest(String requestId, String title, String content, String expectedTenantId) { - this(requestId, title, content, expectedTenantId, false, BroadcastTargetRequest.empty(), null); + this(requestId, title, content, expectedTenantId, false, "ALL", true, + BroadcastTargetRequest.empty(), null); } public PublishRequest( @@ -210,7 +243,43 @@ public final class AihrBroadcastDto { Boolean requiredRead, BroadcastTargetRequest targets ) { - this(requestId, title, content, expectedTenantId, requiredRead, targets, null); + this(requestId, title, content, expectedTenantId, requiredRead, "ALL", true, + targets, null); + } + + public PublishRequest( + String requestId, + String title, + String content, + String expectedTenantId, + Boolean requiredRead, + BroadcastTargetRequest targets, + Long attachmentId + ) { + this(requestId, title, content, expectedTenantId, requiredRead, "ALL", true, + targets, attachmentId); + } + } + + public record BroadcastTopicTagRequest( + @NotBlank(message = "主题标签不能为空") + @Size(max = 30, message = "主题标签不能超过 30 个字符") + String name, + @NotBlank(message = "主题标签必须提供原文依据") + @Size(max = 200, message = "主题标签原文依据不能超过 200 个字符") + String evidenceQuote + ) { + } + + public record UpdateBroadcastTopicTagsRequest( + @NotBlank(message = "当前租户不能为空") + @Size(max = 64, message = "当前租户不能超过 64 个字符") + String expectedTenantId, + @Size(max = 8, message = "主题标签不能超过 8 个") + List<@Valid BroadcastTopicTagRequest> topicTags + ) { + public UpdateBroadcastTopicTagsRequest { + topicTags = topicTags == null ? List.of() : List.copyOf(topicTags); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java index 335e4bcf..ff108f89 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/broadcast/AihrBroadcastService.java @@ -10,6 +10,7 @@ import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastListResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetOption; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetOptions; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTargetRequest; +import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastTopicTag; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.UnreadCountResponse; @@ -32,6 +33,7 @@ import java.security.NoSuchAlgorithmException; import java.sql.PreparedStatement; import java.sql.Statement; import java.sql.Timestamp; +import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; @@ -62,13 +64,20 @@ public class AihrBroadcastService { from aihr_broadcast_message m where m.tenant_id = ? and m.status = 'PUBLISHED' + and (m.visibility_mode = 'ALL' or exists ( + select 1 from aihr_broadcast_target_recipient visible_recipient + where visible_recipient.tenant_id = m.tenant_id + and visible_recipient.message_id = m.id + and visible_recipient.user_id = ? + and visible_recipient.target_status = 'MATCHED' + )) and not exists ( select 1 from aihr_broadcast_read r where r.tenant_id = m.tenant_id and r.message_id = m.id and r.user_id = ? ) - """, Long.class, principal.tenantId(), principal.userId()); + """, Long.class, principal.tenantId(), principal.userId(), principal.userId()); return new UnreadCountResponse(unreadCount == null ? 0 : unreadCount); } @@ -92,7 +101,7 @@ public class AihrBroadcastService { case when r.id is null then 0 else 1 end as read_flag, case when m.required_read = 1 then 1 else 0 end as required_read, case when tr.user_id is null then 0 else 1 end as targeted_flag, - tr.target_reason + tr.target_reason, m.visibility_mode """ + employeeMessages.fromWhere() + """ order by case when m.required_read = 1 @@ -110,7 +119,7 @@ public class AihrBroadcastService { """, (rs, rowNum) -> new BroadcastListItem( rs.getLong("id"), rs.getString("title"), rs.getBoolean("read_flag"), format(rs.getTimestamp("published_time")), rs.getBoolean("required_read"), - rs.getBoolean("targeted_flag"), rs.getString("target_reason") + rs.getBoolean("targeted_flag"), rs.getString("target_reason"), rs.getString("visibility_mode") ), parameters.toArray()); return new BroadcastListResponse(total == null ? 0 : total, page.pageNum(), page.pageSize(), rows); } @@ -136,6 +145,7 @@ public class AihrBroadcastService { select m.id, m.title, m.content, m.status, 1 as current_version, m.published_by, m.published_time, m.withdrawn_by, m.withdrawn_time, m.withdraw_reason, case when m.required_read = 1 then 1 else 0 end as required_read, + m.visibility_mode, case when m.allow_download = 1 then 1 else 0 end as allow_download, (select count(*) from aihr_broadcast_target_rule target_rule where target_rule.tenant_id = m.tenant_id and target_rule.message_id = m.id) as target_rule_count, (select count(distinct recipient.user_id) from aihr_broadcast_target_recipient recipient @@ -152,7 +162,8 @@ public class AihrBroadcastService { rs.getLong("id"), rs.getString("title"), rs.getString("content"), rs.getString("status"), rs.getInt("current_version"), rs.getLong("published_by"), format(rs.getTimestamp("published_time")), rs.getObject("withdrawn_by", Long.class), format(rs.getTimestamp("withdrawn_time")), - rs.getString("withdraw_reason"), rs.getBoolean("required_read"), rs.getLong("target_rule_count"), + rs.getString("withdraw_reason"), rs.getBoolean("required_read"), + rs.getString("visibility_mode"), rs.getBoolean("allow_download"), rs.getLong("target_rule_count"), rs.getLong("matched_recipient_count"), rs.getLong("unmatched_target_count") ), parameters.toArray()); return new AdminBroadcastListResponse(tenantId, total == null ? 0 : total, page.pageNum(), page.pageSize(), rows); @@ -206,11 +217,12 @@ public class AihrBroadcastService { String defaultPerspectiveCode = AihrBroadcastInsightService.defaultPerspectiveCode( currentPositionNames(principal)); List rows = jdbcTemplate.query(""" - select m.id, m.title, m.content, m.published_time, + select m.id, m.tenant_id, m.title, m.content, m.published_time, case when r.id is null then 0 else 1 end as read_flag, case when m.required_read = 1 then 1 else 0 end as required_read, case when tr.user_id is null then 0 else 1 end as targeted_flag, - tr.target_reason, + tr.target_reason, m.visibility_mode, + case when m.allow_download = 1 then 1 else 0 end as allow_download, a.id as attachment_id, a.file_name as attachment_file_name, a.file_size as attachment_file_size, a.content_type as attachment_content_type, a.status as attachment_status, a.summary as attachment_summary, @@ -231,12 +243,14 @@ public class AihrBroadcastService { left join aihr_broadcast_attachment a on a.tenant_id = m.tenant_id and a.id = m.attachment_id and a.status = 'READY' where m.tenant_id = ? and m.id = ? and m.status = 'PUBLISHED' + and (m.visibility_mode = 'ALL' or tr.user_id is not null) limit 1 """, (rs, rowNum) -> new BroadcastDetailResponse( rs.getLong("id"), rs.getString("title"), rs.getString("content"), rs.getBoolean("read_flag"), format(rs.getTimestamp("published_time")), rs.getBoolean("required_read"), rs.getBoolean("targeted_flag"), rs.getString("target_reason"), - attachment(rs, defaultPerspectiveCode) + rs.getString("visibility_mode"), attachment(rs, defaultPerspectiveCode, + rs.getBoolean("allow_download"), watermarkText(principal)) ), principal.userId(), principal.tenantId(), principal.userId(), principal.tenantId(), messageId); if (rows.isEmpty()) { throw unavailable(); @@ -261,13 +275,22 @@ public class AihrBroadcastService { from aihr_broadcast_message m left join aihr_broadcast_attachment a on a.tenant_id = m.tenant_id and a.id = m.attachment_id and a.status = 'READY' + left join ( + select recipient.tenant_id, recipient.message_id, recipient.user_id + from aihr_broadcast_target_recipient recipient + where recipient.tenant_id = ? and recipient.user_id = ? + and recipient.target_status = 'MATCHED' + group by recipient.tenant_id, recipient.message_id, recipient.user_id + ) visible_recipient on visible_recipient.tenant_id = m.tenant_id + and visible_recipient.message_id = m.id where m.tenant_id = ? and m.id = ? and m.status = 'PUBLISHED' + and (m.visibility_mode = 'ALL' or visible_recipient.user_id is not null) limit 1 """, (rs, rowNum) -> new BroadcastQuestionContext( rs.getLong("id"), rs.getString("title"), questionContent( rs.getString("content"), rs.getString("extracted_text")), format(rs.getTimestamp("published_time")) - ), principal.tenantId(), messageId); + ), principal.tenantId(), principal.userId(), principal.tenantId(), messageId); if (rows.isEmpty()) { throw unavailable(); } @@ -282,8 +305,14 @@ public class AihrBroadcastService { List messages = jdbcTemplate.query(""" select id from aihr_broadcast_message where tenant_id = ? and id = ? and status = 'PUBLISHED' + and (visibility_mode = 'ALL' or exists ( + select 1 from aihr_broadcast_target_recipient recipient + where recipient.tenant_id = aihr_broadcast_message.tenant_id + and recipient.message_id = aihr_broadcast_message.id + and recipient.user_id = ? and recipient.target_status = 'MATCHED' + )) for update - """, (rs, rowNum) -> rs.getLong("id"), principal.tenantId(), messageId); + """, (rs, rowNum) -> rs.getLong("id"), principal.tenantId(), messageId, principal.userId()); if (messages.isEmpty()) { throw unavailable(); } @@ -304,13 +333,20 @@ public class AihrBroadcastService { String title = required(request == null ? null : request.title(), "消息标题", 200); String content = required(request == null ? null : request.content(), "消息正文", 10_000); boolean requiredRead = request != null && Boolean.TRUE.equals(request.requiredRead()); + String visibilityMode = visibilityMode(request == null ? null : request.visibilityMode()); + boolean allowDownload = request == null || request.allowDownload() == null || Boolean.TRUE.equals(request.allowDownload()); TargetSelection targetSelection = targetSelection(request == null ? null : request.targets()); + if ("TARGET_ONLY".equals(visibilityMode) && !targetSelection.hasTargets()) { + throw new ServiceException("仅目标可见模式必须选择至少一个定向范围", HttpStatus.BAD_REQUEST); + } Long attachmentId = optionalAttachmentId(request == null ? null : request.attachmentId()); String requestHash = requestHash(title, content); - String targetPayloadHash = targetPayloadHash(requiredRead, targetSelection, attachmentId); + String targetPayloadHash = targetPayloadHash(requiredRead, visibilityMode, allowDownload, targetSelection, attachmentId); + String legacyTargetPayloadHash = targetPayloadHashLegacy(requiredRead, targetSelection, attachmentId); PublishReplay existing = findPublishReplay(tenantId, principal.userId(), requestKey); if (existing != null) { - return replayOrConflict(existing, title, content, requiredRead, targetSelection, targetPayloadHash); + return replayOrConflict(existing, title, content, requiredRead, targetSelection, + targetPayloadHash, legacyTargetPayloadHash, "ALL".equals(visibilityMode) && allowDownload); } requirePublishableAttachment(tenantId, principal.userId(), attachmentId); KeyHolder keyHolder = new GeneratedKeyHolder(); @@ -318,9 +354,9 @@ public class AihrBroadcastService { jdbcTemplate.update(connection -> { PreparedStatement statement = connection.prepareStatement(""" insert into aihr_broadcast_message - (tenant_id, title, content, attachment_id, required_read, status, published_by, publish_request_key, publish_request_hash, + (tenant_id, title, content, attachment_id, required_read, visibility_mode, allow_download, status, published_by, publish_request_key, publish_request_hash, target_payload_hash, published_time, create_time, update_time) - values (?, ?, ?, ?, ?, 'PUBLISHED', ?, ?, ?, ?, now(), now(), now()) + values (?, ?, ?, ?, ?, ?, ?, 'PUBLISHED', ?, ?, ?, ?, now(), now(), now()) """, Statement.RETURN_GENERATED_KEYS); statement.setString(1, tenantId); statement.setString(2, title); @@ -328,10 +364,12 @@ public class AihrBroadcastService { if (attachmentId == null) statement.setNull(4, java.sql.Types.BIGINT); else statement.setLong(4, attachmentId); statement.setBoolean(5, requiredRead); - statement.setLong(6, principal.userId()); - statement.setString(7, requestKey); - statement.setString(8, requestHash); - statement.setString(9, targetPayloadHash); + statement.setString(6, visibilityMode); + statement.setBoolean(7, allowDownload); + statement.setLong(8, principal.userId()); + statement.setString(9, requestKey); + statement.setString(10, requestHash); + statement.setString(11, targetPayloadHash); return statement; }, keyHolder); } catch (DuplicateKeyException duplicate) { @@ -340,7 +378,8 @@ public class AihrBroadcastService { // concurrent request that just won the unique-key race. PublishReplay raced = findPublishReplayForUpdate(tenantId, principal.userId(), requestKey); if (raced != null) { - return replayOrConflict(raced, title, content, requiredRead, targetSelection, targetPayloadHash); + return replayOrConflict(raced, title, content, requiredRead, targetSelection, + targetPayloadHash, legacyTargetPayloadHash, "ALL".equals(visibilityMode) && allowDownload); } throw duplicate; } @@ -430,6 +469,7 @@ public class AihrBroadcastService { group by recipient.tenant_id, recipient.message_id, recipient.user_id ) tr on tr.tenant_id = m.tenant_id and tr.message_id = m.id where m.tenant_id = ? and m.status = 'PUBLISHED' + and (m.visibility_mode = 'ALL' or tr.user_id is not null) """ + scopeFilter, parameters); } @@ -492,7 +532,9 @@ public class AihrBroadcastService { String content, boolean requiredRead, TargetSelection targetSelection, - String targetPayloadHash + String targetPayloadHash, + String legacyTargetPayloadHash, + boolean allowLegacyContract ) { // The target payload has an independent length-delimited digest, so a retry cannot // reuse a request ID while silently changing mandatory-read or target dimensions. @@ -500,7 +542,9 @@ public class AihrBroadcastService { boolean legacyM0TargetPayload = existing.targetPayloadHash() == null && !requiredRead && !targetSelection.hasTargets(); if (!title.equals(existing.title()) || !content.equals(existing.content()) - || (!legacyM0TargetPayload && !targetPayloadHash.equals(existing.targetPayloadHash()))) { + || (!legacyM0TargetPayload + && !targetPayloadHash.equals(existing.targetPayloadHash()) + && !(allowLegacyContract && legacyTargetPayloadHash.equals(existing.targetPayloadHash())))) { throw new ServiceException("该请求ID已用于不同的公司消息", 409); } return new PublishResponse(existing.id(), 1, @@ -620,10 +664,18 @@ public class AihrBroadcastService { return List.copyOf(sorted); } - private static String targetPayloadHash(boolean requiredRead, TargetSelection selection, Long attachmentId) { + private static String targetPayloadHash( + boolean requiredRead, + String visibilityMode, + boolean allowDownload, + TargetSelection selection, + Long attachmentId + ) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); updateDigestField(digest, requiredRead ? "1" : "0"); + updateDigestField(digest, visibilityMode); + updateDigestField(digest, allowDownload ? "1" : "0"); updateDigestField(digest, "PROJECT"); selection.projectCodes().forEach(value -> updateDigestField(digest, value)); updateDigestField(digest, "POSITION"); @@ -774,9 +826,11 @@ public class AihrBroadcastService { } } - private static BroadcastAttachmentResponse attachment( + private BroadcastAttachmentResponse attachment( java.sql.ResultSet rs, - String requestedDefaultPerspectiveCode + String requestedDefaultPerspectiveCode, + boolean allowDownload, + String watermarkText ) throws java.sql.SQLException { Long id = rs.getObject("attachment_id", Long.class); if (id == null) return null; @@ -792,7 +846,9 @@ public class AihrBroadcastService { id, rs.getString("attachment_file_name"), rs.getLong("attachment_file_size"), rs.getString("attachment_content_type"), rs.getString("attachment_status"), rs.getString("attachment_summary"), rs.getString("attachment_error_message"), - "/api/aihr/broadcast/attachments/" + id + "/content", + "/api/aihr/broadcast/attachments/" + id + "/view", + allowDownload ? "/api/aihr/broadcast/attachments/" + id + "/content" : null, + allowDownload, watermarkText, topicTags(rs.getString("tenant_id"), id), insightStatus, defaultPerspectiveCode, perspectives.stream().map(AihrBroadcastDto.BroadcastPerspective::label).toList(), @@ -800,6 +856,41 @@ public class AihrBroadcastService { ); } + private static String targetPayloadHashLegacy( + boolean requiredRead, + TargetSelection selection, + Long attachmentId + ) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + updateDigestField(digest, requiredRead ? "1" : "0"); + updateDigestField(digest, "PROJECT"); + selection.projectCodes().forEach(value -> updateDigestField(digest, value)); + updateDigestField(digest, "POSITION"); + selection.positionNames().forEach(value -> updateDigestField(digest, value)); + updateDigestField(digest, "LEVEL"); + selection.positionLevels().forEach(value -> updateDigestField(digest, value)); + if (attachmentId != null) { + updateDigestField(digest, "ATTACHMENT"); + updateDigestField(digest, attachmentId.toString()); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + private List topicTags(String tenantId, long attachmentId) { + return jdbcTemplate.query(""" + select tag_name, source_type, evidence_quote + from aihr_broadcast_attachment_topic_tag + where tenant_id = ? and attachment_id = ? + order by source_type desc, tag_name + """, (rs, rowNum) -> new BroadcastTopicTag( + rs.getString("tag_name"), rs.getString("source_type"), rs.getString("evidence_quote") + ), tenantId, attachmentId); + } + private static String questionContent(String messageContent, String attachmentText) { if (attachmentText == null || attachmentText.isBlank()) { return messageContent; @@ -818,6 +909,22 @@ public class AihrBroadcastService { throw new ServiceException("status 只支持 PUBLISHED、WITHDRAWN 或 all", HttpStatus.BAD_REQUEST); } + private static String visibilityMode(String rawMode) { + String mode = rawMode == null ? "ALL" : rawMode.trim().toUpperCase(Locale.ROOT); + if (mode.isEmpty() || "ALL".equals(mode)) { + return "ALL"; + } + if ("TARGET_ONLY".equals(mode)) { + return mode; + } + throw new ServiceException("发布模式只支持 ALL 或 TARGET_ONLY", HttpStatus.BAD_REQUEST); + } + + private static String watermarkText(AihrKnowledgePrincipal principal) { + return "帮道内部资料 · 用户#" + principal.userId() + " · " + + LocalDateTime.now().withSecond(0).withNano(0).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + } + private static String requestKey(String value) { String normalized = required(value, "请求ID", 100); if (!REQUEST_ID_PATTERN.matcher(normalized).matches()) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityController.java index 891de78b..35044a92 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityController.java @@ -3,6 +3,7 @@ package org.dromara.aihr.community; import cn.dev33.satoken.annotation.SaCheckLogin; import lombok.RequiredArgsConstructor; import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse; +import org.dromara.aihr.community.AihrCommunityDto.AnswerFeedbackQuestionRequest; import org.dromara.aihr.community.AihrCommunityDto.BestAnswerRequest; import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse; import org.dromara.aihr.community.AihrCommunityDto.CreateAnswerRequest; @@ -42,6 +43,12 @@ public class AihrCommunityController { return R.ok(communityService.createQuestion(request, false)); } + @PostMapping("/questions/from-answer-feedback") + public R createQuestionFromAnswerFeedback( + @RequestBody AnswerFeedbackQuestionRequest request) { + return R.ok(communityService.createQuestionFromAnswerFeedback(request)); + } + @GetMapping("/questions/{questionId}") public R question(@PathVariable Long questionId) { return R.ok(communityService.publicQuestion(questionId)); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java index ead6d4e1..103232ef 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java @@ -15,6 +15,9 @@ public final class AihrCommunityDto { String ruleCode) { } + public record AnswerFeedbackQuestionRequest(String requestId, String idempotencyKey) { + } + public record CreateAnswerRequest(String content) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java index 27bb2ca2..c04e8cdc 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java @@ -2,6 +2,7 @@ package org.dromara.aihr.community; import lombok.RequiredArgsConstructor; import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse; +import org.dromara.aihr.community.AihrCommunityDto.AnswerFeedbackQuestionRequest; import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse; import org.dromara.aihr.community.AihrCommunityDto.CreateAnswerRequest; import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest; @@ -17,6 +18,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -137,6 +139,57 @@ public class AihrCommunityService { return response(principal, findQuestion(principal, key == null ? 0L : key.longValue()), adminBounty); } + @Transactional(rollbackFor = Exception.class) + public QuestionResponse createQuestionFromAnswerFeedback(AnswerFeedbackQuestionRequest request) { + AihrKnowledgePrincipal principal = principalResolver.current(); + ensureTables(); + String requestId = requiredToken(request == null ? null : request.requestId(), "知识查询请求ID", 64); + String idempotencyKey = requiredToken( + request == null ? null : request.idempotencyKey(), "提交幂等键", 80); + String knowledgeRequestId = resolveOwnedKnowledgeRequestId(principal, requestId); + + LinkedQuestion idempotent = linkedQuestionByIdempotencyKey(principal, idempotencyKey); + if (idempotent != null) { + if (!knowledgeRequestId.equals(idempotent.sourceRequestId())) { + throw new ServiceException("提交幂等键已用于其他问题", 409); + } + return response(principal, findQuestion(principal, idempotent.questionId()), false); + } + + LinkedQuestion existing = linkedQuestionBySource(principal, knowledgeRequestId); + if (existing != null) { + return response(principal, findQuestion(principal, existing.questionId()), false); + } + + FeedbackSource source = ownedNegativeFeedback(principal, knowledgeRequestId); + String content = required(source.queryText(), "问题内容", 4000); + String title = truncate(content.replaceAll("\\s+", " "), 80); + String category = normalizeCategory(source.category()); + AihrIncentiveService.IncentiveRuleResponse rule = incentiveService.activeRule(principal, "BEST_ANSWER"); + int rewardPoints = AihrCommunityPolicy.rewardPoints(rule == null ? 0 : rule.value(), properties); + try { + jdbcTemplate.update(""" + insert into aihr_community_question + (tenant_id, author_user_id, author_ext_party_id, author_name, title, content, + question_type, source_type, source_request_id, submit_idempotency_key, category, + reward_rule_code, reward_points, question_status, create_time, update_time) + values (?, ?, ?, ?, ?, ?, 'OPEN', 'ANSWER_FEEDBACK', ?, ?, ?, + 'BEST_ANSWER', ?, 'PENDING', now(), now()) + """, principal.tenantId(), principal.userId(), clean(principal.extPartyId()), + displayName(principal), title, content, knowledgeRequestId, idempotencyKey, category, rewardPoints); + } catch (DuplicateKeyException ignored) { + LinkedQuestion replay = linkedQuestionBySource(principal, knowledgeRequestId); + if (replay == null) { + throw new ServiceException("提交幂等键已用于其他问题", 409); + } + } + LinkedQuestion created = linkedQuestionBySource(principal, knowledgeRequestId); + if (created == null) { + throw new IllegalStateException("问题提交失败,请稍后重试"); + } + return response(principal, findQuestion(principal, created.questionId()), false); + } + public AnswerResponse createAnswer(Long questionId, CreateAnswerRequest request) { AihrKnowledgePrincipal principal = principalResolver.current(); ensureTables(); @@ -351,6 +404,69 @@ public class AihrCommunityService { return rows.get(0); } + private String resolveOwnedKnowledgeRequestId(AihrKnowledgePrincipal principal, String requestId) { + if (!requestId.startsWith("agent_run_")) { + return requireOwnedKnowledgeQuery(principal, requestId); + } + List resultRefs = jdbcTemplate.query(""" + select result_ref from aihr_agent_run + where tenant_id = ? and user_id = ? and run_id = ? + order by id desc limit 1 + """, (rs, rowNum) -> rs.getString("result_ref"), + principal.tenantId(), principal.userId(), requestId); + if (resultRefs.isEmpty() || resultRefs.get(0) == null + || !resultRefs.get(0).startsWith("KNOWLEDGE:")) { + throw new ServiceException("未找到本人本次知识查询", 404); + } + return requireOwnedKnowledgeQuery( + principal, requiredToken(resultRefs.get(0).substring("KNOWLEDGE:".length()), "知识查询请求ID", 64)); + } + + private String requireOwnedKnowledgeQuery(AihrKnowledgePrincipal principal, String requestId) { + Integer count = jdbcTemplate.queryForObject(""" + select count(*) from aihr_knowledge_query_log + where tenant_id = ? and user_id = ? and request_id = ? + """, Integer.class, principal.tenantId(), principal.userId(), requestId); + if (count == null || count == 0) { + throw new ServiceException("未找到本人本次知识查询", 404); + } + return requestId; + } + + private FeedbackSource ownedNegativeFeedback(AihrKnowledgePrincipal principal, String requestId) { + List rows = jdbcTemplate.query(""" + select f.query_text, f.category + from aihr_knowledge_answer_feedback f + where f.tenant_id = ? and f.submitter_user_id = ? and f.request_id = ? and f.verdict = 'down' + order by f.id asc limit 1 + """, (rs, rowNum) -> new FeedbackSource( + rs.getString("query_text"), rs.getString("category")), + principal.tenantId(), principal.userId(), requestId); + if (rows.isEmpty()) { + throw new ServiceException("仅本人已提交的未解决反馈可转人工求助", 409); + } + return rows.get(0); + } + + private LinkedQuestion linkedQuestionBySource(AihrKnowledgePrincipal principal, String requestId) { + return linkedQuestion(principal, "source_type = 'ANSWER_FEEDBACK' and source_request_id = ?", requestId); + } + + private LinkedQuestion linkedQuestionByIdempotencyKey(AihrKnowledgePrincipal principal, String key) { + return linkedQuestion(principal, "submit_idempotency_key = ?", key); + } + + private LinkedQuestion linkedQuestion(AihrKnowledgePrincipal principal, String condition, String value) { + List rows = jdbcTemplate.query(""" + select id, source_request_id from aihr_community_question + where tenant_id = ? and author_user_id = ? and %s + limit 1 + """.formatted(condition), (rs, rowNum) -> new LinkedQuestion( + rs.getLong("id"), rs.getString("source_request_id")), + principal.tenantId(), principal.userId(), value); + return rows.isEmpty() ? null : rows.get(0); + } + private AnswerResponse findAnswer(AihrKnowledgePrincipal principal, Long id) { AnswerRow row = findAnswerRow(principal, id); return new AnswerResponse(row.id(), row.questionId(), row.authorName(), row.content(), row.status(), @@ -440,7 +556,16 @@ public class AihrCommunityService { } if (!runtimeSchemaBootstrap) { AihrSchemaMigrationGuard.requireTables(jdbcTemplate, - "aihr_community_question", "aihr_community_answer", "aihr_points_ledger", "aihr_incentive_rule"); + "aihr_community_question", "aihr_community_answer", "aihr_points_ledger", + "aihr_incentive_rule", "aihr_knowledge_answer_feedback", "aihr_knowledge_query_log"); + AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_community_question", + "source_type", "source_request_id", "submit_idempotency_key"); + AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_community_question", + "uk_aihr_community_question_source", true, + "tenant_id", "author_user_id", "source_type", "source_request_id"); + AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_community_question", + "uk_aihr_community_question_submit_key", true, + "tenant_id", "author_user_id", "submit_idempotency_key"); AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_points_ledger", "account_type"); tablesReady = true; return; @@ -461,6 +586,7 @@ public class AihrCommunityService { COMMENT 'POINTS/STUDY_CREDIT' AFTER `business_id` """); } + ensureQuestionBridgeSchema(); tablesReady = true; } } @@ -473,6 +599,8 @@ public class AihrCommunityService { `author_user_id` bigint NOT NULL, `author_ext_party_id` varchar(80) DEFAULT NULL, `author_name` varchar(100) NOT NULL, `title` varchar(160) NOT NULL, `content` text NOT NULL, `question_type` varchar(24) NOT NULL DEFAULT 'OPEN', + `source_type` varchar(32) DEFAULT NULL, `source_request_id` varchar(64) DEFAULT NULL, + `submit_idempotency_key` varchar(80) DEFAULT NULL, `category` varchar(40) NOT NULL DEFAULT '工作求助', `reward_rule_code` varchar(60) NOT NULL DEFAULT 'BEST_ANSWER', `reward_points` int NOT NULL DEFAULT 10, `deadline` datetime DEFAULT NULL, @@ -480,7 +608,9 @@ public class AihrCommunityService { `moderator_user_id` bigint DEFAULT NULL, `moderation_note` varchar(300) DEFAULT NULL, `moderated_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), KEY `idx_aihr_community_question_board` (`tenant_id`, `question_status`, `create_time`) + PRIMARY KEY (`id`), KEY `idx_aihr_community_question_board` (`tenant_id`, `question_status`, `create_time`), + UNIQUE KEY `uk_aihr_community_question_source` (`tenant_id`, `author_user_id`, `source_type`, `source_request_id`), + UNIQUE KEY `uk_aihr_community_question_submit_key` (`tenant_id`, `author_user_id`, `submit_idempotency_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci """); ddls.add(""" @@ -568,6 +698,47 @@ public class AihrCommunityService { return cleaned; } + private static String requiredToken(String value, String label, int max) { + String token = required(value, label, max); + if (!token.matches("[A-Za-z0-9:_-]+")) { + throw new IllegalArgumentException(label + "格式不正确"); + } + return token; + } + + private void ensureQuestionBridgeSchema() { + ensureQuestionColumn("source_type", + "ALTER TABLE aihr_community_question ADD COLUMN source_type varchar(32) DEFAULT NULL AFTER question_type"); + ensureQuestionColumn("source_request_id", + "ALTER TABLE aihr_community_question ADD COLUMN source_request_id varchar(64) DEFAULT NULL AFTER source_type"); + ensureQuestionColumn("submit_idempotency_key", + "ALTER TABLE aihr_community_question ADD COLUMN submit_idempotency_key varchar(80) DEFAULT NULL AFTER source_request_id"); + ensureQuestionIndex("uk_aihr_community_question_source", + "ALTER TABLE aihr_community_question ADD UNIQUE KEY uk_aihr_community_question_source (tenant_id, author_user_id, source_type, source_request_id)"); + ensureQuestionIndex("uk_aihr_community_question_submit_key", + "ALTER TABLE aihr_community_question ADD UNIQUE KEY uk_aihr_community_question_submit_key (tenant_id, author_user_id, submit_idempotency_key)"); + } + + private void ensureQuestionColumn(String column, String ddl) { + Integer count = jdbcTemplate.queryForObject(""" + select count(*) from information_schema.columns + where table_schema = database() and table_name = 'aihr_community_question' and column_name = ? + """, Integer.class, column); + if (count == null || count == 0) { + jdbcTemplate.execute(ddl); + } + } + + private void ensureQuestionIndex(String index, String ddl) { + Integer count = jdbcTemplate.queryForObject(""" + select count(*) from information_schema.statistics + where table_schema = database() and table_name = 'aihr_community_question' and index_name = ? + """, Integer.class, index); + if (count == null || count == 0) { + jdbcTemplate.execute(ddl); + } + } + private static String displayName(AihrKnowledgePrincipal principal) { String identity = clean(principal.extPartyId()); if (identity.isBlank()) { @@ -600,4 +771,10 @@ public class AihrCommunityService { private record Award(String accountType, int value) { } + + private record FeedbackSource(String queryText, String category) { + } + + private record LinkedQuestion(Long questionId, String sourceRequestId) { + } } 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 1182e8b6..30b78558 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 @@ -14,7 +14,9 @@ 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; +import org.dromara.aihr.domain.AihrCaseDto.PublishRequest; import org.dromara.aihr.domain.AihrCaseDto.RecordResponse; +import org.dromara.aihr.domain.AihrCaseDto.RetireRequest; import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; import org.dromara.aihr.service.AihrCaseService; @@ -114,6 +116,24 @@ public class AihrCaseController { return R.ok(); } + @PostMapping("/records/{caseId}/publish") + public R publish(@PathVariable String caseId, @RequestBody PublishRequest request) { + if (!canReviewCases()) { + return R.fail("仅企业知识运营人员可正式发布经验"); + } + caseService.publish(caseId, request, currentProjectScopes()); + return R.ok(); + } + + @PostMapping("/records/{caseId}/retire") + public R retire(@PathVariable String caseId, @RequestBody RetireRequest request) { + if (!canReviewCases()) { + return R.fail("仅企业知识运营人员可下线经验"); + } + caseService.retire(caseId, request, currentProjectScopes()); + return R.ok(); + } + @GetMapping("/capabilities") public R capabilities() { return R.ok(new CapabilitiesResponse(canManageCases(), canViewCases())); 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 3a55731b..80728074 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 @@ -17,6 +17,7 @@ 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.OnboardTaskProgressRequest; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; @@ -37,6 +38,8 @@ import org.dromara.aihr.learning.AihrLearningDto.DifficultyResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamAiGenerateRequest; import org.dromara.aihr.learning.AihrLearningDto.ExamAiGenerateResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamDetailResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDrawRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDrawResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamPublishRequest; import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitRequest; @@ -261,6 +264,14 @@ public class AihrMobileController { return mobileSeedService.completeOnboardTask(id, identity, evidenceRef) ? R.ok() : R.fail("任务不存在、已完成或无权操作"); } + @PostMapping("/onboard/tasks/{id}/progress") + public R updateOnboardTaskProgress(@PathVariable Long id, + @RequestBody OnboardTaskProgressRequest request) { + String identity = ownMobileExtPartyId(null); + return mobileSeedService.updateOnboardTaskProgress(id, identity, request) + ? R.ok() : R.fail("任务不存在、已完成或无权操作"); + } + @GetMapping("/qualification") public R qualification() { return R.ok(mobileSeedService.qualification(ownMobileExtPartyId(null))); @@ -311,17 +322,17 @@ public class AihrMobileController { @GetMapping("/exams") public R> exams() { - return R.ok(examService.employeeExams(ownMobileExtPartyId(null))); + return R.ok(examService.employeeExams(ownMobileOrganizationExtPartyId())); } @GetMapping("/exams/{id}") public R exam(@PathVariable Long id) { - return R.ok(examService.employeeExam(id, ownMobileExtPartyId(null))); + return R.ok(examService.employeeExam(id, ownMobileOrganizationExtPartyId())); } @PostMapping("/exams/{id}/submit") public R submitExam(@PathVariable Long id, @RequestBody ExamSubmitRequest request) { - return R.ok(examService.submit(id, request, ownMobileExtPartyId(null))); + return R.ok(examService.submit(id, request, ownMobileOrganizationExtPartyId())); } @GetMapping("/exams/supervisor") @@ -357,6 +368,11 @@ public class AihrMobileController { return R.ok(examService.generateQuestions(request, supervisorScopeExtPartyId())); } + @PostMapping("/exams/supervisor/question-bank/draw") + public R drawSupervisorExamQuestions(@RequestBody ExamQuestionDrawRequest request) { + return R.ok(examService.drawQuestions(request, supervisorScopeExtPartyId())); + } + @GetMapping("/exams/supervisor/{id}/results") public R> supervisorExamResults(@PathVariable Long id) { return R.ok(examService.results(id, supervisorScopeExtPartyId())); @@ -376,6 +392,14 @@ public class AihrMobileController { return mobileSeedService.requireMobileIdentity(username); } + private String ownMobileOrganizationExtPartyId() { + String username = currentAppUsername(); + if (username.isBlank()) { + throw new ServiceException("无权访问员工个人数据"); + } + return mobileSeedService.requireMobileOrganizationIdentity(username); + } + private boolean isSystemOperator() { LoginUser loginUser = LoginHelper.getLoginUser(); return loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType()); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java index 33dec38e..8a4ff5b3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java @@ -34,6 +34,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest; import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionEnabledRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionResponse; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionReviewRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest; import org.dromara.aihr.domain.AihrPracticeDto.ScenarioReviewStatusRequest; @@ -45,6 +46,12 @@ import org.dromara.aihr.domain.AihrPracticeDto.StartResponse; import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest; import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse; import org.dromara.aihr.service.AihrPracticeSeedService; +import org.dromara.aihr.learning.AihrLearningTaskService; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningExamOption; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningMaterialOption; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskBatchResponse; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskDispatchRequest; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskManageResponse; import org.dromara.aihr.service.AihrRealtimePracticeService; import org.dromara.aihr.service.AihrRealtimeSessionService; import org.dromara.aihr.service.AihrRealtimeToolService; @@ -82,6 +89,7 @@ public class AihrPracticeController { private static final String HR_OPERATOR_ROLE = "hr_operator"; private final AihrPracticeSeedService practiceSeedService; + private final AihrLearningTaskService learningTaskService; private final AihrRealtimePracticeService realtimePracticeService; private final AihrRealtimeSessionService realtimeSessionService; private final AihrRealtimeToolService realtimeToolService; @@ -150,6 +158,13 @@ public class AihrPracticeController { return practiceSeedService.updateQuestionEnabled(code, request) ? R.ok() : R.fail("训练题目不存在"); } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PatchMapping("/questions/{code}/review") + public R reviewQuestion(@PathVariable String code, + @RequestBody PracticeQuestionReviewRequest request) { + return R.ok(practiceSeedService.reviewQuestion(code, request, currentOperatorUsername())); + } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @GetMapping("/assignments/manage") public R> manageAssignments(@RequestParam(required = false) String position, @@ -166,6 +181,32 @@ public class AihrPracticeController { return R.ok(practiceSeedService.createAssignments(request, "")); } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @GetMapping("/onboard-tasks/manage") + public R> manageLearningTasks(@RequestParam(required = false) String status, + @RequestParam(required = false) String keyword, + @RequestParam(required = false, defaultValue = "200") Integer limit) { + return R.ok(learningTaskService.manageTasks(status, keyword, limit == null ? 200 : limit)); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @GetMapping("/onboard-tasks/manage/materials") + public R> learningMaterials() { + return R.ok(learningTaskService.materials()); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @GetMapping("/onboard-tasks/manage/exams") + public R> learningExams() { + return R.ok(learningTaskService.publishedExams()); + } + + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) + @PostMapping("/onboard-tasks/manage/batch") + public R dispatchLearningTasks(@RequestBody LearningTaskDispatchRequest request) { + return R.ok(learningTaskService.dispatch(request, currentOperatorUsername())); + } + @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @PatchMapping("/assignments/manage/{id}/cancel") public R cancelAssignment(@PathVariable Long id, @RequestBody(required = false) AssignmentCancelRequest request) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java index 065ad2e9..3854a40e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java @@ -147,7 +147,7 @@ public class AihrSopController { @SaCheckLogin @PostMapping("/answer-feedback") public R answerFeedback(@RequestBody AnswerFeedbackRequest request) { - return R.ok(sopSeedService.answerFeedback(request)); + return R.ok(sopSeedService.answerFeedback(request, knowledgePrincipalResolver.current().userId())); } @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) 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 0d942fc0..2e807685 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 @@ -27,6 +27,26 @@ public final class AihrCaseDto { public record ReviewRequest(String comment) { } + /** + * Explicit second-stage release metadata. A reviewed case remains a + * candidate until a content owner confirms these fields. + */ + public record PublishRequest( + String sourceType, + String applicablePositions, + String contentVersion, + String releaseOwner, + String releaseNote, + Boolean sourceAuthorized, + Boolean deidentified, + String effectiveFrom, + String effectiveTo + ) { + } + + public record RetireRequest(String reason) { + } + public record CurateResponse(List selectedCaseIds, List records, String sampleHint) { } @@ -39,15 +59,29 @@ public final class AihrCaseDto { public record TagResponse(String text, String type) { } - public record RecordResponse(String caseId, String title, String type, String status, String time, String owner, String summary) { + public record RecordResponse(String caseId, String title, String type, String status, String time, String owner, String summary, + String knowledgeStatus, String sourceType, String applicablePositions, String contentVersion) { + public RecordResponse(String caseId, String title, String type, String status, String time, String owner, String summary) { + this(caseId, title, type, status, time, owner, summary, "CANDIDATE", "", "", ""); + } + public RecordResponse(String title, String type, String status, String time, String owner, String summary) { - this("", title, type, status, time, owner, summary); + this("", title, type, status, time, owner, summary, "CANDIDATE", "", "", ""); } } public record DetailResponse(String caseId, String fileName, String title, String type, String status, String time, String owner, String transcript, String summary, String mediaUrl, Long mediaOssId, List learningPoints, - String supervisorComment) { + String supervisorComment, String knowledgeStatus, String sourceType, + String applicablePositions, String contentVersion, String effectiveFrom, + String effectiveTo, String releaseOwner, String releaseNote, String retireReason) { + public DetailResponse(String caseId, String fileName, String title, String type, String status, + String time, String owner, String transcript, String summary, + String mediaUrl, Long mediaOssId, List learningPoints, + String supervisorComment) { + this(caseId, fileName, title, type, status, time, owner, transcript, summary, mediaUrl, mediaOssId, + learningPoints, supervisorComment, "CANDIDATE", "", "", "", "", "", "", "", ""); + } } } 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 386b60b2..211d99ee 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 @@ -182,7 +182,15 @@ public final class AihrPracticeDto { String contentVersion, String contentHash, Boolean enabled, - Integer sortOrder + Integer sortOrder, + String examFormat, + List examOptions, + List examAnswers, + String examExplanation, + Boolean examEnabled, + String reviewStatus, + String reviewedBy, + String reviewedTime ) { } @@ -194,13 +202,26 @@ public final class AihrPracticeDto { String question, String referenceAnswer, Boolean enabled, - Integer sortOrder + Integer sortOrder, + String examFormat, + List examOptions, + List examAnswers, + String examExplanation, + Boolean examEnabled ) { + public PracticeQuestionSaveRequest(String code, String scenarioCode, String positionCode, String type, + String question, String referenceAnswer, Boolean enabled, Integer sortOrder) { + this(code, scenarioCode, positionCode, type, question, referenceAnswer, enabled, sortOrder, + null, List.of(), List.of(), null, false); + } } public record PracticeQuestionEnabledRequest(Boolean enabled) { } + public record PracticeQuestionReviewRequest(String decision, String note) { + } + public record AssignmentCancelRequest(String reason) { } @@ -590,13 +611,41 @@ public final class AihrPracticeDto { String assignedTime, String dueTime, String completedTime, - String evidenceRef + String evidenceRef, + String materialType, + Long materialAttachmentId, + String materialTitle, + String materialVersion, + Integer progressPercent, + String completionRule, + Long examId, + String recurrenceType, + String startedTime, + String lastViewedTime, + Integer lastPositionSeconds, + Integer mediaDurationSeconds ) { + public OnboardTaskResponse(Long id, String projectCode, String positionCode, Long sopId, Long courseId, + String taskType, String status, String assignBy, String assignedTime, String dueTime, + String completedTime, String evidenceRef) { + this(id, projectCode, positionCode, sopId, courseId, taskType, status, assignBy, assignedTime, dueTime, + completedTime, evidenceRef, null, null, null, null, 0, null, null, null, + null, null, 0, null); + } } public record OnboardTaskCompleteRequest(String evidenceRef) { } + public record OnboardTaskProgressRequest( + Integer progressPercent, + Integer positionSeconds, + Integer durationSeconds, + String materialVersion, + Boolean completed + ) { + } + public record QualificationGateResponse( Long id, String projectCode, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java index 20944062..70181a01 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeDataToolService.java @@ -28,10 +28,9 @@ public class AihrKnowledgeDataToolService { if (principal == null || !hasText(principal.extPartyId())) { throw new ServiceException("当前登录身份不能使用训练数据工具", HttpStatus.FORBIDDEN); } - // In the APP knowledge flow this field carries the authenticated mobile - // username. Validate it once at the tool boundary before any legacy - // training query can resolve aliases from organization data. - String mobilePhone = mobileSeedService.requireMobileIdentity(principal.extPartyId()); + // The knowledge principal stores the verified organization identity. + // Resolve its unique APP phone before entering legacy phone-keyed reads. + String mobilePhone = mobileSeedService.requireMobilePhoneForOrganizationIdentity(principal.extPartyId()); String toolCode = rawToolCode == null ? "" : rawToolCode.trim().toUpperCase(java.util.Locale.ROOT); return switch (toolCode) { case MY_PRACTICE_SUMMARY -> myPractice(mobilePhone); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java index a4f23532..d3e28235 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolver.java @@ -43,8 +43,9 @@ public class AihrKnowledgePrincipalResolver { } if (UserType.APP_USER.getUserType().equals(loginUser.getUserType())) { roles.add("employee"); - extPartyId = mobileSeedService.requireMobileIdentity(trim(loginUser.getUsername())); - List rows = orgRows(extPartyId); + String mobilePhone = mobileSeedService.requireMobileIdentity(trim(loginUser.getUsername())); + extPartyId = mobileSeedService.requireMobileOrganizationIdentity(mobilePhone); + List rows = orgRows(mobilePhone); if (rows.stream().anyMatch(AihrKnowledgePrincipalResolver::isSupervisor)) { roles.add("supervisor"); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java index a3d47c21..b7282b72 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java @@ -22,6 +22,7 @@ import org.dromara.aihr.memory.AihrMemoryService; import org.dromara.aihr.service.AihrModelSeedService; import org.dromara.aihr.service.AihrSopSeedService; import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.enums.UserType; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.tenant.helper.TenantHelper; import org.springframework.jdbc.core.JdbcTemplate; @@ -304,14 +305,13 @@ public class AihrKnowledgeQueryService { AihrKnowledgePrincipal principal = principalResolver.current(); AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); Set spaceIds = accessService.resolveInternalSpaceIds(principal, app, List.of(), "READ"); - if (spaceIds.isEmpty()) { - return null; - } - List args = new ArrayList<>(); - args.add(principal.tenantId()); - args.add(attachmentId); - args.addAll(spaceIds); - List rows = jdbcTemplate.queryForList(""" + List rows = List.of(); + if (!spaceIds.isEmpty()) { + List args = new ArrayList<>(); + args.add(principal.tenantId()); + args.add(attachmentId); + args.addAll(spaceIds); + rows = jdbcTemplate.queryForList(""" select a.oss_id from aihr_knowledge_attach a join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id @@ -334,8 +334,34 @@ public class AihrKnowledgeQueryService { and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date()) and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date()) ) - limit 1 + limit 1 """.formatted(placeholders(spaceIds.size())), Long.class, args.toArray()); + } + if (!rows.isEmpty()) { + return rows.get(0); + } + return authorizedLearningTaskResource(principal, attachmentId); + } + + private Long authorizedLearningTaskResource(AihrKnowledgePrincipal principal, Long attachmentId) { + if (principal == null || !UserType.APP_USER.getUserType().equals(principal.userType()) || !hasText(principal.extPartyId())) { + return null; + } + List rows = jdbcTemplate.queryForList(""" + select a.oss_id + from aihr_knowledge_attach a + where a.tenant_id = ? and a.id = ? and a.oss_id is not null and a.status = 2 + and exists ( + select 1 + from aihr_onboard_task task + where task.tenant_id = a.tenant_id + and task.material_attachment_id = a.id + and task.status in ('待完成', '进行中', '已完成') + and (task.assigned_time is null or task.assigned_time <= now()) + and task.ext_party_id = ? + ) + limit 1 + """, Long.class, principal.tenantId(), attachmentId, principal.extPartyId()); return rows.isEmpty() ? null : rows.get(0); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java index 421dbe98..cc880994 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java @@ -13,6 +13,8 @@ import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerResult; import org.dromara.aihr.learning.AihrLearningDto.ExamDetailResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamPublishRequest; import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDraft; +import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDrawRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionDrawResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamQuestionResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamSubmitRequest; @@ -57,6 +59,7 @@ public class AihrExamService { private static final String DEFAULT_TENANT = "000000"; private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final TypeReference> STRING_LIST = new TypeReference<>() { }; + private static final TypeReference> QUESTION_DRAFT_LIST = new TypeReference<>() { }; private static final Set AI_QUESTION_FORMATS = Set.of("single", "multiple", "judge", "short"); private final JdbcTemplate jdbcTemplate; @@ -141,6 +144,15 @@ public class AihrExamService { VALUES (?, ?, ?, ?, ?, ?, now()) """, tenantId(), attemptId, result.questionId(), json(answer), result.correct(), result.earnedScore()); } + if (passed) { + jdbcTemplate.update(""" + UPDATE aihr_onboard_task + SET status = '已完成', progress_percent = 100, completed_time = ?, + evidence_ref = CONCAT('exam_attempt:', ?), update_time = now() + WHERE tenant_id = ? AND exam_id = ? AND ext_party_id = ? + AND completion_rule = 'EXAM_PASS' AND status IN ('待完成', '进行中') + """, Timestamp.valueOf(submittedAt), attemptId, tenantId(), examId, identity); + } return new ExamSubmitResponse(attemptId, "SUBMITTED", score.score(), passed, submittedAt.format(TIME_FORMAT), score.results()); } @@ -167,12 +179,13 @@ public class AihrExamService { @Transactional(rollbackFor = Exception.class) public ExamDetailResponse create(ExamUpsertRequest request, String supervisorExtPartyId) { ensureSchema(); - AihrExamScoring.validate(request); String owner = fallback(supervisorExtPartyId, "system"); - LocalDate dueDate = parseDueDate(request.dueDate()); - List targets = resolveTargets(request.targets(), supervisorExtPartyId); - long examId = insertExam(request, owner, dueDate); - saveQuestions(examId, request.questions()); + ExamUpsertRequest resolved = resolveDrawQuestions(request, owner); + AihrExamScoring.validate(resolved); + LocalDate dueDate = parseDueDate(resolved.dueDate()); + List targets = resolveTargets(resolved.targets(), supervisorExtPartyId); + long examId = insertExam(resolved, owner, dueDate); + saveQuestions(examId, resolved.questions()); saveTargets(examId, targets); return supervisorExam(examId, supervisorExtPartyId); } @@ -185,25 +198,26 @@ public class AihrExamService { @Transactional(rollbackFor = Exception.class) public ExamDetailResponse update(Long examId, ExamUpsertRequest request, String supervisorExtPartyId) { ensureSchema(); - AihrExamScoring.validate(request); ExamHeader existing = requireSupervisorExam(examId, supervisorExtPartyId); if (!"DRAFT".equals(existing.status())) { throw new ServiceException("仅草稿考试可以修改"); } - LocalDate dueDate = parseDueDate(request.dueDate()); - List targets = resolveTargets(request.targets(), supervisorExtPartyId); + ExamUpsertRequest resolved = resolveDrawQuestions(request, existing.createdBy()); + AihrExamScoring.validate(resolved); + LocalDate dueDate = parseDueDate(resolved.dueDate()); + List targets = resolveTargets(resolved.targets(), supervisorExtPartyId); int updated = jdbcTemplate.update(""" UPDATE aihr_onboard_exam SET title = ?, project_code = ?, position_code = ?, pass_score = ?, due_date = ?, update_time = now() WHERE tenant_id = ? AND id = ? AND status = 'DRAFT' - """, clean(request.title()), clean(request.projectCode()), clean(request.positionCode()), - request.passScore() == null ? 80 : request.passScore(), dueDate == null ? null : Date.valueOf(dueDate), + """, clean(resolved.title()), clean(resolved.projectCode()), clean(resolved.positionCode()), + resolved.passScore() == null ? 80 : resolved.passScore(), dueDate == null ? null : Date.valueOf(dueDate), tenantId(), examId); if (updated == 0) { throw new ServiceException("考试不存在或状态已变化"); } jdbcTemplate.update("DELETE FROM aihr_onboard_exam_question WHERE tenant_id = ? AND exam_id = ?", tenantId(), examId); - saveQuestions(examId, request.questions()); + saveQuestions(examId, resolved.questions()); jdbcTemplate.update("DELETE FROM aihr_onboard_exam_target WHERE tenant_id = ? AND exam_id = ?", tenantId(), examId); saveTargets(examId, targets); return supervisorExam(examId, supervisorExtPartyId); @@ -255,6 +269,66 @@ public class AihrExamService { return publish(examId, null, supervisorExtPartyId); } + @Transactional(rollbackFor = Exception.class) + public ExamQuestionDrawResponse drawQuestions(ExamQuestionDrawRequest request, String supervisorExtPartyId) { + ensureSchema(); + String owner = fallback(supervisorExtPartyId, "system"); + String requestId = normalizeRequestId(request == null ? null : request.requestId()); + String position = clean(request == null ? null : request.positionCode()); + int count = request == null || request.count() == null ? 10 : request.count(); + if (position.isEmpty()) { + throw new ServiceException("请选择组卷岗位"); + } + if (count < 1 || count > 50) { + throw new ServiceException("随机组卷题数必须为 1-50"); + } + List formats = normalizeDrawFormats(request == null ? null : request.formats()); + String configHash = AihrLearningService.contentHash("exam-draw-v1", position + "\n" + count, json(formats)); + ExamQuestionDrawResponse existing = findQuestionDraw(owner, requestId, configHash); + if (existing != null) { + return existing; + } + List args = new ArrayList<>(); + args.add(tenantId()); + args.add(position); + args.addAll(formats); + args.add(requestId); + args.add(count); + List selected = jdbcTemplate.query(""" + SELECT id, question_code, exam_format, question_text, exam_options_json, exam_answers_json, + exam_explanation, content_version, content_hash + FROM aihr_learning_question + WHERE tenant_id = ? AND position_code = ? AND enabled = 1 AND exam_enabled = 1 + AND review_status = 'APPROVED' AND exam_format IN (%s) + ORDER BY SHA2(CONCAT(id, ':' , ?), 256), id + LIMIT ? + """.formatted(placeholders(formats.size())), (rs, rowNum) -> new ExamQuestionDraft( + null, rs.getString("exam_format"), rs.getString("question_text"), + stringList(rs.getString("exam_options_json")), stringList(rs.getString("exam_answers_json")), + 0, rs.getString("exam_explanation"), rowNum + 1, rs.getLong("id"), + rs.getString("question_code"), rs.getString("content_version"), rs.getString("content_hash") + ), args.toArray()); + if (selected.size() < count) { + throw new ServiceException("当前岗位已审核考试题不足 " + count + " 道,请先补充并审核题库"); + } + List scored = rebalanceQuestionScores(selected); + String snapshotJson = objectJson(scored); + try { + jdbcTemplate.update(""" + INSERT INTO aihr_exam_question_draw + (tenant_id, owner_ext_party_id, request_key, config_hash, question_snapshot_json, create_time) + VALUES (?, ?, ?, ?, ?, now()) + """, tenantId(), owner, requestId, configHash, snapshotJson); + } catch (DuplicateKeyException duplicate) { + ExamQuestionDrawResponse raced = findQuestionDraw(owner, requestId, configHash); + if (raced != null) { + return raced; + } + throw duplicate; + } + return new ExamQuestionDrawResponse(requestId, scored); + } + public List results(Long examId, String supervisorExtPartyId) { ensureSchema(); requireSupervisorExam(examId, supervisorExtPartyId); @@ -281,7 +355,8 @@ public class AihrExamService { List questions = loadQuestionDrafts(header.id()).stream().map(question -> new ExamQuestionResponse(question.id(), question.questionType(), question.stem(), question.options(), includeAnswers ? question.correctAnswers() : List.of(), question.score(), - includeAnswers ? question.explanation() : "", question.sortOrder())).toList(); + includeAnswers ? question.explanation() : "", question.sortOrder(), + question.sourceQuestionCode(), question.sourceContentVersion())).toList(); List targets = loadTargets(header.id(), includeAnswers ? null : employeeIdentity); return new ExamDetailResponse(header.id(), header.title(), header.projectCode(), header.positionCode(), header.status(), 100, header.passScore(), header.dueDate(), questions, targets); @@ -297,7 +372,16 @@ public class AihrExamService { ON t.tenant_id = e.tenant_id AND t.exam_id = e.id AND t.ext_party_id = ? WHERE e.tenant_id = ? AND e.id = ? AND e.status = 'PUBLISHED' AND (e.due_date IS NULL OR e.due_date >= CURRENT_DATE()) - """, (rs, rowNum) -> mapHeader(rs), identity, tenantId(), examId); + AND NOT EXISTS ( + SELECT 1 FROM aihr_onboard_task learning_task + WHERE learning_task.tenant_id = e.tenant_id + AND learning_task.exam_id = e.id + AND learning_task.ext_party_id = ? + AND learning_task.completion_rule = 'EXAM_PASS' + AND learning_task.status IN ('待完成', '进行中') + AND COALESCE(learning_task.progress_percent, 0) < 100 + ) + """, (rs, rowNum) -> mapHeader(rs), identity, tenantId(), examId, identity); if (rows.isEmpty()) { throw new ServiceException("考试不存在、已截止或不适用于当前岗位"); } @@ -322,12 +406,15 @@ public class AihrExamService { private List loadQuestionDrafts(Long examId) { return jdbcTemplate.query(""" - SELECT id, question_type, stem, options_json, correct_answers_json, score, explanation, sort_order + SELECT id, question_type, stem, options_json, correct_answers_json, score, explanation, sort_order, + source_question_id, source_question_code, source_content_version, source_content_hash FROM aihr_onboard_exam_question WHERE tenant_id = ? AND exam_id = ? ORDER BY sort_order, id """, (rs, rowNum) -> new ExamQuestionDraft( rs.getLong("id"), rs.getString("question_type"), rs.getString("stem"), stringList(rs.getString("options_json")), stringList(rs.getString("correct_answers_json")), - rs.getInt("score"), rs.getString("explanation"), rs.getInt("sort_order") + rs.getInt("score"), rs.getString("explanation"), rs.getInt("sort_order"), + (Long) rs.getObject("source_question_id"), rs.getString("source_question_code"), + rs.getString("source_content_version"), rs.getString("source_content_hash") ), tenantId(), examId); } @@ -388,14 +475,79 @@ public class AihrExamService { jdbcTemplate.update(""" INSERT INTO aihr_onboard_exam_question (tenant_id, exam_id, question_type, stem, options_json, correct_answers_json, score, - explanation, sort_order, create_time, update_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + explanation, sort_order, source_question_id, source_question_code, source_content_version, + source_content_hash, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) """, tenantId(), examId, clean(question.questionType()).toLowerCase(Locale.ROOT), clean(question.stem()), json(question.options()), json(question.correctAnswers()), question.score(), - truncate(question.explanation(), 1000), question.sortOrder() == null ? index : question.sortOrder()); + truncate(question.explanation(), 1000), question.sortOrder() == null ? index : question.sortOrder(), + question.sourceQuestionId(), nullable(question.sourceQuestionCode()), + nullable(question.sourceContentVersion()), nullable(question.sourceContentHash())); } } + private ExamUpsertRequest resolveDrawQuestions(ExamUpsertRequest request, String owner) { + if (request == null) { + throw new ServiceException("缺少考试配置"); + } + String drawRequestId = normalizeOptionalRequestId(request.questionDrawRequestId()); + if (drawRequestId.isEmpty()) { + return request; + } + ExamQuestionDrawResponse draw = findQuestionDraw(owner, drawRequestId, null); + if (draw == null) { + throw new ServiceException("随机组卷快照不存在或不属于当前主管,请重新组卷"); + } + return new ExamUpsertRequest(request.title(), request.projectCode(), request.positionCode(), + request.passScore(), request.dueDate(), draw.questions(), request.targets(), drawRequestId); + } + + private ExamQuestionDrawResponse findQuestionDraw(String owner, String requestId, String expectedConfigHash) { + List rows = jdbcTemplate.query(""" + SELECT config_hash, question_snapshot_json + FROM aihr_exam_question_draw + WHERE tenant_id = ? AND owner_ext_party_id = ? AND request_key = ? + LIMIT 1 + """, (rs, rowNum) -> new QuestionDrawRow( + rs.getString("config_hash"), rs.getString("question_snapshot_json") + ), tenantId(), owner, requestId); + if (rows.isEmpty()) { + return null; + } + QuestionDrawRow row = rows.get(0); + if (expectedConfigHash != null && !expectedConfigHash.equals(row.configHash())) { + throw new ServiceException("该随机组卷请求ID已用于其他岗位或题数"); + } + try { + return new ExamQuestionDrawResponse(requestId, + objectMapper.readValue(row.questionSnapshotJson(), QUESTION_DRAFT_LIST)); + } catch (JsonProcessingException error) { + throw new ServiceException("随机组卷快照数据损坏"); + } + } + + private static List normalizeDrawFormats(List requested) { + List formats = requested == null ? List.of() : requested.stream() + .map(AihrExamService::clean).map(value -> value.toLowerCase(Locale.ROOT)) + .filter(AI_QUESTION_FORMATS::contains).distinct().toList(); + return formats.isEmpty() ? List.of("single", "multiple", "judge", "short") : formats; + } + + private static List rebalanceQuestionScores(List questions) { + int base = 100 / questions.size(); + int remainder = 100 - base * questions.size(); + List result = new ArrayList<>(); + for (int index = 0; index < questions.size(); index++) { + ExamQuestionDraft question = questions.get(index); + int score = base + (index < remainder ? 1 : 0); + result.add(new ExamQuestionDraft(question.id(), question.questionType(), question.stem(), + question.options(), question.correctAnswers(), score, question.explanation(), index + 1, + question.sourceQuestionId(), question.sourceQuestionCode(), question.sourceContentVersion(), + question.sourceContentHash())); + } + return List.copyOf(result); + } + private void saveTargets(Long examId, List targets) { for (ResolvedTarget target : targets) { jdbcTemplate.update(""" @@ -736,11 +888,14 @@ public class AihrExamService { private String canonicalIdentity(String identity) { List rows = jdbcTemplate.query(""" - SELECT ext_party_id FROM aihr_org_snapshot - WHERE tenant_id = ? AND employment_status = 'active' AND (ext_party_id = ? OR person_phone = ?) - ORDER BY snapshot_date DESC, id DESC LIMIT 1 - """, (rs, rowNum) -> rs.getString("ext_party_id"), tenantId(), identity, identity); - return rows.isEmpty() ? identity : rows.get(0); + SELECT DISTINCT ext_party_id FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' AND ext_party_id = ? + ORDER BY ext_party_id + """, (rs, rowNum) -> rs.getString("ext_party_id"), tenantId(), identity); + if (rows.size() != 1 || clean(rows.get(0)).isEmpty()) { + throw new ServiceException("当前手机号未唯一匹配在职员工,暂不能访问考试"); + } + return rows.get(0).trim(); } private static ExamHeader mapHeader(java.sql.ResultSet rs) throws java.sql.SQLException { @@ -822,8 +977,17 @@ public class AihrExamService { if (!runtimeSchemaBootstrap) { AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_onboard_exam", "aihr_onboard_exam_question", "aihr_onboard_exam_target", - "aihr_onboard_exam_attempt", "aihr_onboard_exam_answer"); + "aihr_onboard_exam_attempt", "aihr_onboard_exam_answer", "aihr_exam_question_draw", + "aihr_onboard_task"); AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_onboard_exam", "publish_request_key"); + AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_onboard_exam_question", + "source_question_id", "source_question_code", "source_content_version", + "source_content_hash"); + AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_learning_question", + "exam_format", "exam_options_json", "exam_answers_json", "exam_explanation", + "exam_enabled", "review_status"); + AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_onboard_task", + "completion_rule", "progress_percent"); AihrSchemaMigrationGuard.requireIndexes(jdbcTemplate, "aihr_onboard_exam", "uk_aihr_onboard_exam_publish_request"); schemaReady = true; @@ -871,12 +1035,37 @@ public class AihrExamService { score int NOT NULL, explanation varchar(1000) DEFAULT NULL, sort_order int NOT NULL DEFAULT 0, + source_question_id bigint DEFAULT NULL, + source_question_code varchar(120) DEFAULT NULL, + source_content_version varchar(30) DEFAULT NULL, + source_content_hash char(64) DEFAULT NULL, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_aihr_onboard_exam_question (tenant_id, exam_id, sort_order) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci """); + ensureColumn("aihr_onboard_exam_question", "source_question_id", + "ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_question_id bigint DEFAULT NULL AFTER sort_order"); + ensureColumn("aihr_onboard_exam_question", "source_question_code", + "ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_question_code varchar(120) DEFAULT NULL AFTER source_question_id"); + ensureColumn("aihr_onboard_exam_question", "source_content_version", + "ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_content_version varchar(30) DEFAULT NULL AFTER source_question_code"); + ensureColumn("aihr_onboard_exam_question", "source_content_hash", + "ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_content_hash char(64) DEFAULT NULL AFTER source_content_version"); + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS aihr_exam_question_draw ( + id bigint NOT NULL AUTO_INCREMENT, + tenant_id varchar(20) NOT NULL DEFAULT '000000', + owner_ext_party_id varchar(100) NOT NULL, + request_key varchar(100) NOT NULL, + config_hash char(64) NOT NULL, + question_snapshot_json longtext NOT NULL, + create_time datetime DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_aihr_exam_question_draw_request (tenant_id, owner_ext_party_id, request_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + """); jdbcTemplate.execute(""" CREATE TABLE IF NOT EXISTS aihr_onboard_exam_target ( id bigint NOT NULL AUTO_INCREMENT, @@ -997,6 +1186,22 @@ public class AihrExamService { Integer passScore, String dueDate, String createdBy, String publishRequestKey) { } + private String objectJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException error) { + throw new ServiceException("考试快照序列化失败"); + } + } + + private static String nullable(String value) { + String normalized = clean(value); + return normalized.isEmpty() ? null : normalized; + } + + private record QuestionDrawRow(String configHash, String questionSnapshotJson) { + } + private record AttemptRow(Long id, String status, Integer score, Boolean passed, String submittedTime) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java index 0eb5ee6d..36c2b8a3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java @@ -81,8 +81,28 @@ public final class AihrLearningDto { List correctAnswers, Integer score, String explanation, - Integer sortOrder + Integer sortOrder, + Long sourceQuestionId, + String sourceQuestionCode, + String sourceContentVersion, + String sourceContentHash ) { + public ExamQuestionDraft(Long id, String questionType, String stem, List options, + List correctAnswers, Integer score, String explanation, Integer sortOrder) { + this(id, questionType, stem, options, correctAnswers, score, explanation, sortOrder, + null, null, null, null); + } + } + + public record ExamQuestionDrawRequest( + String requestId, + String positionCode, + Integer count, + List formats + ) { + } + + public record ExamQuestionDrawResponse(String requestId, List questions) { } /** A selected employee. traineeName is display-only; the server resolves the authoritative org name. */ @@ -104,11 +124,17 @@ public final class AihrLearningDto { Integer passScore, String dueDate, List questions, - List targets + List targets, + String questionDrawRequestId ) { public ExamUpsertRequest(String title, String projectCode, String positionCode, Integer passScore, String dueDate, List questions) { - this(title, projectCode, positionCode, passScore, dueDate, questions, List.of()); + this(title, projectCode, positionCode, passScore, dueDate, questions, List.of(), null); + } + + public ExamUpsertRequest(String title, String projectCode, String positionCode, Integer passScore, + String dueDate, List questions, List targets) { + this(title, projectCode, positionCode, passScore, dueDate, questions, targets, null); } } @@ -146,7 +172,9 @@ public final class AihrLearningDto { List correctAnswers, Integer score, String explanation, - Integer sortOrder + Integer sortOrder, + String sourceQuestionCode, + String sourceContentVersion ) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningTaskDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningTaskDto.java new file mode 100644 index 00000000..3a4ce655 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningTaskDto.java @@ -0,0 +1,90 @@ +package org.dromara.aihr.learning; + +import java.util.List; + +public final class AihrLearningTaskDto { + + private AihrLearningTaskDto() { + } + + public record LearningTaskTarget(String extPartyId, String traineeName) { + } + + public record LearningTaskDispatchRequest( + String requestId, + List targets, + String projectCode, + String positionCode, + String taskType, + String materialType, + Long materialAttachmentId, + String completionRule, + Long examId, + String recurrenceType, + String startTime, + String dueTime + ) { + } + + public record LearningTaskManageResponse( + Long id, + String extPartyId, + String traineeName, + String projectCode, + String positionCode, + String taskType, + String status, + String materialType, + Long materialAttachmentId, + String materialTitle, + String materialVersion, + Integer progressPercent, + String completionRule, + Long examId, + String examTitle, + String recurrenceType, + String assignedTime, + String dueTime, + String completedTime, + String assignBy + ) { + } + + public record LearningTaskBatchItemResponse( + String extPartyId, + String traineeName, + String status, + String message, + LearningTaskManageResponse task + ) { + } + + public record LearningTaskBatchResponse( + String requestId, + int total, + int created, + int duplicate, + int failed, + List items + ) { + } + + public record LearningMaterialOption( + Long attachmentId, + String title, + String fileType, + String materialVersion, + String updatedTime + ) { + } + + public record LearningExamOption( + Long examId, + String title, + String projectCode, + String positionCode, + String dueDate, + Integer targetCount + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningTaskService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningTaskService.java new file mode 100644 index 00000000..9bf9958a --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningTaskService.java @@ -0,0 +1,433 @@ +package org.dromara.aihr.learning; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningExamOption; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningMaterialOption; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskBatchItemResponse; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskBatchResponse; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskDispatchRequest; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskManageResponse; +import org.dromara.aihr.learning.AihrLearningTaskDto.LearningTaskTarget; +import org.dromara.aihr.service.AihrSchemaMigrationGuard; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.tenant.helper.TenantHelper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AihrLearningTaskService { + + private static final String DEFAULT_TENANT = "000000"; + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Set MATERIAL_TYPES = Set.of("DOCUMENT", "VIDEO", "COURSE", "SOP"); + private static final Set COMPLETION_RULES = Set.of("MANUAL_CONFIRM", "EXAM_PASS"); + private static final Set RECURRENCES = Set.of("ONCE", "DAILY", "WEEKLY", "MONTHLY"); + private final JdbcTemplate jdbcTemplate; + private volatile boolean schemaReady; + + public List manageTasks(String status, String keyword, int limit) { + ensureSchema(); + String normalizedStatus = clean(status); + String normalizedKeyword = clean(keyword); + int safeLimit = Math.max(1, Math.min(limit, 500)); + List args = new ArrayList<>(); + args.add(tenantId()); + StringBuilder where = new StringBuilder(); + if (!normalizedStatus.isEmpty()) { + where.append(" AND t.status = ?"); + args.add(normalizedStatus); + } + if (!normalizedKeyword.isEmpty()) { + where.append(" AND (t.task_type LIKE ? OR t.material_title LIKE ? OR t.ext_party_id LIKE ?"); + where.append(" OR EXISTS (SELECT 1 FROM aihr_org_snapshot os WHERE os.tenant_id=t.tenant_id"); + where.append(" AND os.ext_party_id=t.ext_party_id AND os.person_name LIKE ?))"); + String like = "%" + normalizedKeyword + "%"; + args.add(like); + args.add(like); + args.add(like); + args.add(like); + } + args.add(safeLimit); + return jdbcTemplate.query(""" + SELECT t.id, t.ext_party_id, + COALESCE((SELECT NULLIF(o.person_name, '') FROM aihr_org_snapshot o + WHERE o.tenant_id=t.tenant_id AND o.ext_party_id=t.ext_party_id + ORDER BY o.snapshot_date DESC, o.id DESC LIMIT 1), '员工') AS trainee_name, + t.project_code, t.position_code, t.task_type, t.status, t.material_type, + t.material_attachment_id, t.material_title, t.material_version, t.progress_percent, + t.completion_rule, t.exam_id, e.title AS exam_title, t.recurrence_type, + t.assigned_time, t.due_time, t.completed_time, t.assign_by + FROM aihr_onboard_task t + LEFT JOIN aihr_onboard_exam e ON e.tenant_id=t.tenant_id AND e.id=t.exam_id + WHERE t.tenant_id = ? %s + ORDER BY t.id DESC LIMIT ? + """.formatted(where), this::mapTask, args.toArray()); + } + + public List materials() { + ensureSchema(); + return jdbcTemplate.query(""" + SELECT id, name, type, update_time, create_time + FROM aihr_knowledge_attach + WHERE tenant_id = ? AND status = 2 + ORDER BY COALESCE(update_time, create_time) DESC, id DESC LIMIT 500 + """, (rs, rowNum) -> { + Timestamp updated = rs.getTimestamp("update_time"); + if (updated == null) updated = rs.getTimestamp("create_time"); + return new LearningMaterialOption(rs.getLong("id"), clean(rs.getString("name")), + clean(rs.getString("type")), materialVersion(rs.getLong("id"), updated), + format(updated)); + }, tenantId()); + } + + public List publishedExams() { + ensureSchema(); + return jdbcTemplate.query(""" + SELECT e.id, e.title, e.project_code, e.position_code, e.due_date, COUNT(t.id) target_count + FROM aihr_onboard_exam e + JOIN aihr_onboard_exam_target t ON t.tenant_id=e.tenant_id AND t.exam_id=e.id + WHERE e.tenant_id=? AND e.status='PUBLISHED' + AND (e.due_date IS NULL OR e.due_date >= CURRENT_DATE()) + GROUP BY e.id, e.title, e.project_code, e.position_code, e.due_date + ORDER BY e.id DESC LIMIT 200 + """, (rs, rowNum) -> new LearningExamOption(rs.getLong("id"), rs.getString("title"), + rs.getString("project_code"), rs.getString("position_code"), + rs.getDate("due_date") == null ? "" : rs.getDate("due_date").toLocalDate().toString(), + rs.getInt("target_count")), tenantId()); + } + + public LearningTaskBatchResponse dispatch(LearningTaskDispatchRequest request, String operator) { + ensureSchema(); + DispatchContext context = validateRequest(request); + List targets = request.targets(); + Map orgTargets = loadOrgTargets(targets, context.projectCode()); + Set seen = new LinkedHashSet<>(); + List items = new ArrayList<>(); + int created = 0; + int duplicate = 0; + int failed = 0; + for (LearningTaskTarget target : targets) { + String extPartyId = clean(target == null ? null : target.extPartyId()); + OrgTarget org = orgTargets.get(extPartyId); + String name = org == null ? clean(target == null ? null : target.traineeName()) : org.name(); + if (extPartyId.isEmpty()) { + items.add(new LearningTaskBatchItemResponse("", name, "FAILED", "缺少员工ID", null)); + failed++; + continue; + } + if (!seen.add(extPartyId)) { + items.add(new LearningTaskBatchItemResponse(extPartyId, name, "DUPLICATE", "本次名单中已包含该员工", null)); + duplicate++; + continue; + } + if (org == null) { + items.add(new LearningTaskBatchItemResponse(extPartyId, name, "FAILED", "员工不在所选项目的在职名单中", null)); + failed++; + continue; + } + if (!context.positionCode().equals(canonicalPosition(org.positionName()))) { + items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "FAILED", "员工岗位与任务岗位不匹配", null)); + failed++; + continue; + } + if (context.examId() != null && !isExamTarget(context.examId(), extPartyId)) { + items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "FAILED", "员工不在所选考试的发布名单中", null)); + failed++; + continue; + } + String dispatchKey = context.requestId() + ":0"; + LearningTaskManageResponse existing = findByDispatch(extPartyId, dispatchKey); + if (existing != null) { + if (!sameDispatch(existing, context)) { + items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "FAILED", + "派发请求ID已用于不同的任务配置", null)); + failed++; + } else { + items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "DUPLICATE", + "该批次已派发,无需重复创建", existing)); + duplicate++; + } + continue; + } + try { + jdbcTemplate.update(""" + INSERT INTO aihr_onboard_task + (tenant_id, ext_party_id, project_code, position_code, task_type, status, assign_by, + assigned_time, due_time, material_type, material_attachment_id, material_title, + material_version, progress_percent, completion_rule, exam_id, recurrence_type, + recurrence_series_key, dispatch_request_key, create_time, update_time) + VALUES (?, ?, ?, ?, ?, '待完成', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, now(), now()) + """, tenantId(), extPartyId, context.projectCode(), context.positionCode(), + context.taskType(), clean(operator), Timestamp.valueOf(context.startTime()), + Timestamp.valueOf(context.dueTime()), context.materialType(), context.material().id(), + context.material().title(), context.material().version(), context.completionRule(), + context.examId(), context.recurrenceType(), context.requestId(), dispatchKey); + LearningTaskManageResponse task = findByDispatch(extPartyId, dispatchKey); + items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "CREATED", "派发成功", task)); + created++; + } catch (DuplicateKeyException duplicateError) { + LearningTaskManageResponse task = findByDispatch(extPartyId, dispatchKey); + if (task != null && sameDispatch(task, context)) { + items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "DUPLICATE", "该批次已派发,无需重复创建", task)); + duplicate++; + } else { + items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "FAILED", "派发请求ID已用于不同的任务配置", null)); + failed++; + } + } catch (RuntimeException error) { + log.warn("learning task dispatch failed for request {} target {}", context.requestId(), extPartyId, error); + items.add(new LearningTaskBatchItemResponse(extPartyId, org.name(), "FAILED", "派发失败,请稍后重试", null)); + failed++; + } + } + return new LearningTaskBatchResponse(context.requestId(), items.size(), created, duplicate, failed, items); + } + + @Scheduled(cron = "${aihr.learning-task.recurrence-cron:0 */5 * * * *}", zone = "Asia/Shanghai") + public void createDueRecurrences() { + try { + ensureSchema(); + List rows = jdbcTemplate.query(""" + SELECT t.* FROM aihr_onboard_task t + WHERE t.recurrence_type IN ('DAILY','WEEKLY','MONTHLY') + AND t.recurrence_series_key IS NOT NULL AND t.recurrence_series_key <> '' + AND NOT EXISTS (SELECT 1 FROM aihr_onboard_task n + WHERE n.tenant_id=t.tenant_id AND n.ext_party_id=t.ext_party_id + AND n.recurrence_series_key=t.recurrence_series_key AND n.assigned_time > t.assigned_time) + ORDER BY t.assigned_time LIMIT 200 + """, (rs, rowNum) -> new RecurringTask(rs.getLong("id"), rs.getString("tenant_id"), + rs.getString("ext_party_id"), rs.getString("project_code"), rs.getString("position_code"), + rs.getString("task_type"), rs.getString("assign_by"), rs.getTimestamp("assigned_time").toLocalDateTime(), + rs.getTimestamp("due_time").toLocalDateTime(), rs.getString("material_type"), + (Long) rs.getObject("material_attachment_id"), rs.getString("material_title"), + rs.getString("material_version"), rs.getString("completion_rule"), + (Long) rs.getObject("exam_id"), rs.getString("recurrence_type"), + rs.getString("recurrence_series_key"))); + LocalDateTime now = LocalDateTime.now(); + for (RecurringTask row : rows) { + LocalDateTime nextStart = addRecurrence(row.assignedTime(), row.recurrenceType()); + if (nextStart.isAfter(now)) continue; + LocalDateTime nextDue = addRecurrence(row.dueTime(), row.recurrenceType()); + String key = row.seriesKey() + ":" + nextStart.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); + try { + jdbcTemplate.update(""" + INSERT INTO aihr_onboard_task + (tenant_id, ext_party_id, project_code, position_code, task_type, status, assign_by, + assigned_time, due_time, material_type, material_attachment_id, material_title, + material_version, progress_percent, completion_rule, exam_id, recurrence_type, + recurrence_series_key, dispatch_request_key, create_time, update_time) + VALUES (?, ?, ?, ?, ?, '待完成', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, now(), now()) + """, row.tenantId(), row.extPartyId(), row.projectCode(), row.positionCode(), row.taskType(), + row.assignBy(), Timestamp.valueOf(nextStart), Timestamp.valueOf(nextDue), row.materialType(), + row.materialAttachmentId(), row.materialTitle(), row.materialVersion(), row.completionRule(), + row.examId(), row.recurrenceType(), row.seriesKey(), key); + } catch (DuplicateKeyException ignored) { + // Another node created this recurrence first. + } + } + } catch (ServiceException error) { + log.debug("learning recurrence skipped until schema is available: {}", error.getMessage()); + } catch (RuntimeException error) { + log.warn("learning recurrence generation failed", error); + } + } + + private DispatchContext validateRequest(LearningTaskDispatchRequest request) { + if (request == null || request.targets() == null || request.targets().isEmpty()) throw new ServiceException("请至少选择一名员工"); + if (request.targets().size() > 100) throw new ServiceException("单次最多派发100名员工"); + String requestId = clean(request.requestId()); + if (requestId.isEmpty() || requestId.length() > 90 || !requestId.matches("[A-Za-z0-9._:-]+")) throw new ServiceException("派发请求ID格式不正确"); + String project = clean(request.projectCode()); + String position = canonicalPosition(request.positionCode()); + if (project.isEmpty() || position.isEmpty()) throw new ServiceException("请选择项目和岗位"); + String taskType = truncate(clean(request.taskType()), 20); + if (taskType.isEmpty()) throw new ServiceException("请填写任务名称"); + String materialType = clean(request.materialType()).toUpperCase(Locale.ROOT); + if (!MATERIAL_TYPES.contains(materialType)) throw new ServiceException("学习材料类型不正确"); + MaterialSnapshot material = requireMaterial(request.materialAttachmentId()); + String completionRule = clean(request.completionRule()).toUpperCase(Locale.ROOT); + if (!COMPLETION_RULES.contains(completionRule)) throw new ServiceException("完成条件不正确"); + String recurrence = clean(request.recurrenceType()).toUpperCase(Locale.ROOT); + if (!RECURRENCES.contains(recurrence)) throw new ServiceException("任务周期不正确"); + if ("EXAM_PASS".equals(completionRule) && request.examId() == null) throw new ServiceException("考试通过型任务必须选择考试"); + if ("EXAM_PASS".equals(completionRule) && !"ONCE".equals(recurrence)) throw new ServiceException("考试通过型任务当前只支持单次派发"); + ExamSnapshot exam = request.examId() == null ? null : requireExam(request.examId(), project, position); + LocalDateTime start = parseTime(request.startTime(), "生效时间"); + LocalDateTime due = parseTime(request.dueTime(), "截止时间"); + if (!due.isAfter(start)) throw new ServiceException("截止时间必须晚于生效时间"); + if (exam != null && exam.dueDate() != null && due.toLocalDate().isAfter(exam.dueDate())) throw new ServiceException("学习任务截止时间不能晚于考试截止日期"); + if (!"ONCE".equals(recurrence) && due.isAfter(addRecurrence(start, recurrence))) throw new ServiceException("周期任务的截止时间不能跨入下一周期"); + return new DispatchContext(requestId, project, position, taskType, materialType, material, completionRule, + request.examId(), recurrence, start, due); + } + + private Map loadOrgTargets(List targets, String projectCode) { + List ids = targets.stream().filter(item -> item != null).map(LearningTaskTarget::extPartyId) + .map(AihrLearningTaskService::clean).filter(value -> !value.isEmpty()).distinct().toList(); + if (ids.isEmpty()) return Map.of(); + List args = new ArrayList<>(); + args.add(tenantId()); args.add(projectCode); args.addAll(ids); + List rows = jdbcTemplate.query(""" + SELECT ext_party_id, person_name, position_name FROM aihr_org_snapshot + WHERE tenant_id=? AND project_code=? AND employment_status='active' AND ext_party_id IN (%s) + ORDER BY snapshot_date DESC, id DESC + """.formatted(placeholders(ids.size())), (rs, rowNum) -> new OrgTarget(rs.getString("ext_party_id"), + clean(rs.getString("person_name")).isEmpty() ? "员工" : rs.getString("person_name"), rs.getString("position_name")), args.toArray()); + Map result = new LinkedHashMap<>(); + rows.forEach(row -> result.putIfAbsent(row.extPartyId(), row)); + return result; + } + + private MaterialSnapshot requireMaterial(Long attachmentId) { + if (attachmentId == null || attachmentId <= 0) throw new ServiceException("请选择已解析的学习材料"); + List rows = jdbcTemplate.query(""" + SELECT id, name, update_time, create_time FROM aihr_knowledge_attach + WHERE tenant_id=? AND id=? AND status=2 LIMIT 1 + """, (rs, rowNum) -> { + Timestamp updated = rs.getTimestamp("update_time"); + if (updated == null) updated = rs.getTimestamp("create_time"); + return new MaterialSnapshot(rs.getLong("id"), clean(rs.getString("name")), materialVersion(rs.getLong("id"), updated)); + }, tenantId(), attachmentId); + if (rows.isEmpty()) throw new ServiceException("学习材料不存在、未解析完成或不属于当前租户"); + return rows.get(0); + } + + private ExamSnapshot requireExam(Long examId, String projectCode, String positionCode) { + List rows = jdbcTemplate.query(""" + SELECT project_code, position_code, due_date FROM aihr_onboard_exam + WHERE tenant_id=? AND id=? AND status='PUBLISHED' LIMIT 1 + """, (rs, rowNum) -> new ExamSnapshot(clean(rs.getString("project_code")), + canonicalPosition(rs.getString("position_code")), rs.getDate("due_date") == null ? null : rs.getDate("due_date").toLocalDate()), tenantId(), examId); + if (rows.isEmpty()) throw new ServiceException("考试不存在、未发布或不属于当前租户"); + ExamSnapshot exam = rows.get(0); + if (!exam.projectCode().isEmpty() && !exam.projectCode().equals(projectCode)) throw new ServiceException("考试与所选项目不匹配"); + if (!exam.positionCode().isEmpty() && !exam.positionCode().equals(positionCode)) throw new ServiceException("考试与所选岗位不匹配"); + return exam; + } + + private boolean isExamTarget(Long examId, String extPartyId) { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM aihr_onboard_exam_target WHERE tenant_id=? AND exam_id=? AND ext_party_id=?", Integer.class, tenantId(), examId, extPartyId); + return count != null && count > 0; + } + + private LearningTaskManageResponse findByDispatch(String extPartyId, String dispatchKey) { + List rows = jdbcTemplate.query(""" + SELECT t.*, COALESCE(NULLIF(o.person_name,''),'员工') trainee_name, e.title exam_title + FROM aihr_onboard_task t + LEFT JOIN aihr_org_snapshot o ON o.tenant_id=t.tenant_id AND o.ext_party_id=t.ext_party_id AND o.employment_status='active' + LEFT JOIN aihr_onboard_exam e ON e.tenant_id=t.tenant_id AND e.id=t.exam_id + WHERE t.tenant_id=? AND t.ext_party_id=? AND t.dispatch_request_key=? ORDER BY o.snapshot_date DESC, o.id DESC LIMIT 1 + """, this::mapTask, tenantId(), extPartyId, dispatchKey); + return rows.isEmpty() ? null : rows.get(0); + } + + private static boolean sameDispatch(LearningTaskManageResponse task, DispatchContext context) { + return task != null + && clean(task.projectCode()).equals(context.projectCode()) + && canonicalPosition(task.positionCode()).equals(context.positionCode()) + && clean(task.taskType()).equals(context.taskType()) + && clean(task.materialType()).equals(context.materialType()) + && java.util.Objects.equals(task.materialAttachmentId(), context.material().id()) + && clean(task.completionRule()).equals(context.completionRule()) + && java.util.Objects.equals(task.examId(), context.examId()) + && clean(task.recurrenceType()).equals(context.recurrenceType()) + && clean(task.assignedTime()).equals(context.startTime().format(TIME_FORMAT)) + && clean(task.dueTime()).equals(context.dueTime().format(TIME_FORMAT)); + } + + private LearningTaskManageResponse mapTask(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { + return new LearningTaskManageResponse(rs.getLong("id"), rs.getString("ext_party_id"), + rs.getString("trainee_name"), rs.getString("project_code"), rs.getString("position_code"), + rs.getString("task_type"), rs.getString("status"), rs.getString("material_type"), + (Long) rs.getObject("material_attachment_id"), rs.getString("material_title"), rs.getString("material_version"), + rs.getInt("progress_percent"), rs.getString("completion_rule"), (Long) rs.getObject("exam_id"), + rs.getString("exam_title"), rs.getString("recurrence_type"), format(rs.getTimestamp("assigned_time")), + format(rs.getTimestamp("due_time")), format(rs.getTimestamp("completed_time")), rs.getString("assign_by")); + } + + private void ensureSchema() { + if (schemaReady) return; + synchronized (this) { + if (schemaReady) return; + AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_onboard_task", "aihr_knowledge_attach", + "aihr_onboard_exam", "aihr_onboard_exam_target", "aihr_org_snapshot"); + AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_onboard_task", "material_type", + "material_attachment_id", "material_title", "material_version", "progress_percent", + "completion_rule", "exam_id", "recurrence_type", "recurrence_series_key", "dispatch_request_key"); + AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_onboard_task", + "uk_aihr_onboard_task_dispatch", true, "tenant_id", "ext_party_id", "dispatch_request_key"); + schemaReady = true; + } + } + + static LocalDateTime addRecurrence(LocalDateTime value, String recurrence) { + return switch (clean(recurrence).toUpperCase(Locale.ROOT)) { + case "DAILY" -> value.plusDays(1); + case "WEEKLY" -> value.plusWeeks(1); + case "MONTHLY" -> value.plusMonths(1); + default -> value; + }; + } + + private static LocalDateTime parseTime(String value, String label) { + String text = clean(value).replace('T', ' '); + try { + if (text.length() == 16) text += ":00"; + return LocalDateTime.parse(text, TIME_FORMAT); + } catch (DateTimeParseException error) { + throw new ServiceException(label + "格式不正确"); + } + } + + private String tenantId() { + return clean(TenantHelper.getTenantId()).isEmpty() ? DEFAULT_TENANT : clean(TenantHelper.getTenantId()); + } + + private static String materialVersion(long id, Timestamp updated) { + return "attachment-" + id + "@" + (updated == null ? "unknown" : updated.toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))); + } + + private static String canonicalPosition(String value) { + String text = clean(value); + if (text.contains("生活顾问") || text.contains("客服管家") || text.contains("物业管家") || "管家".equals(text)) return "生活顾问"; + if (text.contains("保安") || text.contains("秩序") || text.contains("安保")) return "保安"; + if (text.contains("保洁") || text.contains("环境")) return "保洁"; + if (text.contains("保修") || text.contains("维修") || text.contains("工程")) return "保修"; + if (text.contains("客服") || text.contains("前台")) return "客服"; + return text; + } + + private static String clean(String value) { return value == null ? "" : value.trim(); } + private static String truncate(String value, int length) { return value.length() <= length ? value : value.substring(0, length); } + private static String placeholders(int count) { return String.join(",", java.util.Collections.nCopies(Math.max(1, count), "?")); } + private static String format(Timestamp value) { return value == null ? "" : value.toLocalDateTime().format(TIME_FORMAT); } + + private record MaterialSnapshot(Long id, String title, String version) { } + private record ExamSnapshot(String projectCode, String positionCode, LocalDate dueDate) { } + private record OrgTarget(String extPartyId, String name, String positionName) { } + private record DispatchContext(String requestId, String projectCode, String positionCode, String taskType, + String materialType, MaterialSnapshot material, String completionRule, Long examId, + String recurrenceType, LocalDateTime startTime, LocalDateTime dueTime) { } + private record RecurringTask(Long id, String tenantId, String extPartyId, String projectCode, String positionCode, + String taskType, String assignBy, LocalDateTime assignedTime, LocalDateTime dueTime, + String materialType, Long materialAttachmentId, String materialTitle, String materialVersion, + String completionRule, Long examId, String recurrenceType, String seriesKey) { } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java index 847635dd..da5a7bb8 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java @@ -11,7 +11,9 @@ import org.dromara.aihr.domain.AihrCaseDto.CurateResponse; import org.dromara.aihr.domain.AihrCaseDto.DetailResponse; import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest; import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse; +import org.dromara.aihr.domain.AihrCaseDto.PublishRequest; import org.dromara.aihr.domain.AihrCaseDto.RecordResponse; +import org.dromara.aihr.domain.AihrCaseDto.RetireRequest; import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest; import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse; import org.dromara.aihr.domain.AihrCaseDto.TagResponse; @@ -24,8 +26,10 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -51,6 +55,7 @@ public class AihrCaseService { ".mp3", ".wav", ".m4a", ".webm", ".ogg", ".aac", ".flac" ); private static final String CASE_PROMPT_VERSION = "case-curation-v2"; + private static final String KNOWLEDGE_STATUS_CANDIDATE = "CANDIDATE"; private final AihrSpeechService speechService; private final AihrModelSeedService modelService; @@ -138,14 +143,100 @@ public class AihrCaseService { } jdbcTemplate.update(""" UPDATE aihr_case_record - SET supervisor_comment = ?, status = '已入库', reviewer_id = ?, reviewed_time = now(), update_time = now() + SET supervisor_comment = ?, status = '已审核', knowledge_status = 'CANDIDATE', + reviewer_id = ?, reviewed_time = now(), update_time = now() WHERE tenant_id = ? AND case_id = ? AND status = '待审核' + AND (knowledge_status IS NULL OR knowledge_status <> 'PUBLISHED') """, comment, currentReviewerId(), tenantId(), state.id()); cases.computeIfPresent(state.id(), (id, current) -> new CaseState(current.id(), current.fileName(), current.projectExtOrgId(), current.transcript(), current.tags(), current.summary(), current.learningPoints(), comment, current.mediaOssId(), current.mediaUrl(), current.owner())); } + /** + * Move a reviewed experience from the candidate pool into the formal + * knowledge-facing case library. This is deliberately a separate write + * from review so AI summaries, raw interviews, and unscoped drafts cannot + * become employee-visible facts by themselves. + */ + public void publish(String caseId, PublishRequest request, List projectScopes) { + CaseState state = requireCase(caseId, projectScopes); + PublishMetadata metadata = validatePublishRequest(request); + int updated = jdbcTemplate.update(""" + UPDATE aihr_case_record + SET status = '已入库', knowledge_status = 'PUBLISHED', source_type = ?, + applicable_positions = ?, content_version = ?, release_owner = ?, + release_note = ?, source_authorized = 1, deidentified = 1, + effective_from = ?, effective_to = ?, released_by = ?, released_time = now(), update_time = now() + WHERE tenant_id = ? AND case_id = ? AND status = '已审核' + AND (knowledge_status IS NULL OR knowledge_status = 'CANDIDATE') + """, metadata.sourceType(), metadata.applicablePositions(), metadata.contentVersion(), + metadata.releaseOwner(), metadata.releaseNote(), metadata.effectiveFrom(), metadata.effectiveTo(), + currentReviewerId(), tenantId(), state.id()); + if (updated != 1) { + throw new IllegalStateException("案例必须先完成业务审核,且不能重复发布"); + } + } + + public void retire(String caseId, RetireRequest request, List projectScopes) { + CaseState state = requireCase(caseId, projectScopes); + String reason = truncate(maskSensitiveText(request == null ? "" : request.reason()), 500); + if (reason.isBlank()) { + throw new IllegalArgumentException("下线必须填写原因"); + } + int updated = jdbcTemplate.update(""" + UPDATE aihr_case_record + SET status = '已下线', knowledge_status = 'OFFLINE', retire_reason = ?, + released_by = ?, released_time = now(), update_time = now() + WHERE tenant_id = ? AND case_id = ? AND knowledge_status = 'PUBLISHED' + """, reason, currentReviewerId(), tenantId(), state.id()); + if (updated != 1) { + throw new IllegalStateException("案例当前不是已发布状态,不能重复下线"); + } + } + + private PublishMetadata validatePublishRequest(PublishRequest request) { + if (request == null) { + throw new IllegalArgumentException("正式发布必须填写来源、适用岗位、版本和负责人"); + } + String sourceType = truncate(clean(request.sourceType()), 40); + String positions = truncate(clean(request.applicablePositions()), 500); + String contentVersion = truncate(clean(request.contentVersion()), 50); + String releaseOwner = truncate(maskSensitiveText(clean(request.releaseOwner())), 80); + String releaseNote = truncate(maskSensitiveText(clean(request.releaseNote())), 1000); + if (sourceType.isBlank() || positions.isBlank() || contentVersion.isBlank() || releaseOwner.isBlank()) { + throw new IllegalArgumentException("正式发布必须填写来源、适用岗位、版本和负责人"); + } + if (!contentVersion.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,49}")) { + throw new IllegalArgumentException("内容版本只能包含字母、数字、点、下划线或短横线"); + } + if (!Boolean.TRUE.equals(request.sourceAuthorized()) || !Boolean.TRUE.equals(request.deidentified())) { + throw new IllegalArgumentException("正式发布前必须确认来源已授权且内容已脱敏"); + } + LocalDate effectiveFrom = parseDate(request.effectiveFrom(), "生效日期", true); + LocalDate effectiveTo = parseDate(request.effectiveTo(), "失效日期", false); + if (effectiveTo != null && effectiveTo.isBefore(effectiveFrom)) { + throw new IllegalArgumentException("失效日期不能早于生效日期"); + } + return new PublishMetadata(sourceType, positions, contentVersion, releaseOwner, releaseNote, + java.sql.Date.valueOf(effectiveFrom), effectiveTo == null ? null : java.sql.Date.valueOf(effectiveTo)); + } + + private static LocalDate parseDate(String value, String label, boolean required) { + String text = clean(value); + if (text.isBlank()) { + if (required) { + throw new IllegalArgumentException(label + "不能为空"); + } + return null; + } + try { + return LocalDate.parse(text); + } catch (DateTimeParseException error) { + throw new IllegalArgumentException(label + "格式应为 yyyy-MM-dd"); + } + } + public List records(Integer limit, List projectScopes) { ensureCaseTable(); if (projectScopes != null && projectScopes.isEmpty()) { @@ -158,10 +249,13 @@ public class AihrCaseService { projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")"; args.addAll(projectScopes); } - String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'"; + String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'" + + " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())" + + " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())"; args.add(safeLimit); String sql = """ - SELECT case_id, title, type, status, update_time, owner, summary + SELECT case_id, title, type, status, knowledge_status, source_type, + applicable_positions, content_version, update_time, owner, summary FROM aihr_case_record WHERE tenant_id = ? """ + projectWhere + publishedWhere + """ @@ -170,14 +264,18 @@ public class AihrCaseService { """; args.add(0, tenantId()); return jdbcTemplate.query(sql, (rs, rowNum) -> new RecordResponse( - firstNonBlank(rs.getString("case_id"), ""), - firstNonBlank(rs.getString("title"), "现场案例"), - firstNonBlank(rs.getString("type"), "现场案例"), - firstNonBlank(rs.getString("status"), "已入库"), - rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT), - firstNonBlank(rs.getString("owner"), "培训组"), - firstNonBlank(rs.getString("summary"), "") - ), args.toArray()); + firstNonBlank(rs.getString("case_id"), ""), + firstNonBlank(rs.getString("title"), "现场案例"), + firstNonBlank(rs.getString("type"), "现场案例"), + firstNonBlank(rs.getString("status"), "已入库"), + rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT), + firstNonBlank(rs.getString("owner"), "培训组"), + firstNonBlank(rs.getString("summary"), ""), + firstNonBlank(rs.getString("knowledge_status"), KNOWLEDGE_STATUS_CANDIDATE), + firstNonBlank(rs.getString("source_type"), ""), + firstNonBlank(rs.getString("applicable_positions"), ""), + firstNonBlank(rs.getString("content_version"), "") + ), args.toArray()); } public DetailResponse detail(String caseId, List projectScopes) { @@ -193,9 +291,13 @@ public class AihrCaseService { projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")"; args.addAll(projectScopes); } - String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'"; + String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'" + + " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())" + + " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())"; String sql = """ - SELECT case_id, file_name, title, type, status, update_time, owner, transcript, summary, media_url, media_oss_id, + SELECT case_id, file_name, title, type, status, knowledge_status, source_type, + applicable_positions, content_version, effective_from, effective_to, + release_owner, release_note, retire_reason, update_time, owner, transcript, summary, media_url, media_oss_id, learning_points, supervisor_comment FROM aihr_case_record WHERE tenant_id = ? AND case_id = ? @@ -207,17 +309,26 @@ public class AihrCaseService { firstNonBlank(rs.getString("case_id"), normalizedCaseId), firstNonBlank(rs.getString("file_name"), "case-audio"), firstNonBlank(rs.getString("title"), "现场案例"), - firstNonBlank(rs.getString("type"), "现场案例"), - firstNonBlank(rs.getString("status"), "已入库"), - rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT), + firstNonBlank(rs.getString("type"), "现场案例"), + firstNonBlank(rs.getString("status"), "已入库"), + rs.getTimestamp("update_time") == null ? "" : rs.getTimestamp("update_time").toLocalDateTime().format(TIME_FORMAT), firstNonBlank(rs.getString("owner"), "培训组"), firstNonBlank(rs.getString("transcript"), ""), firstNonBlank(rs.getString("summary"), ""), "", - mediaOssId, - parseLearningPoints(rs.getString("learning_points")), - firstNonBlank(rs.getString("supervisor_comment"), "") - ); + mediaOssId, + parseLearningPoints(rs.getString("learning_points")), + firstNonBlank(rs.getString("supervisor_comment"), ""), + firstNonBlank(rs.getString("knowledge_status"), KNOWLEDGE_STATUS_CANDIDATE), + firstNonBlank(rs.getString("source_type"), ""), + firstNonBlank(rs.getString("applicable_positions"), ""), + firstNonBlank(rs.getString("content_version"), ""), + rs.getDate("effective_from") == null ? "" : rs.getDate("effective_from").toLocalDate().toString(), + rs.getDate("effective_to") == null ? "" : rs.getDate("effective_to").toLocalDate().toString(), + firstNonBlank(rs.getString("release_owner"), ""), + firstNonBlank(rs.getString("release_note"), ""), + firstNonBlank(rs.getString("retire_reason"), "") + ); }, args.toArray()).stream().findFirst().orElse(null); } @@ -238,7 +349,9 @@ public class AihrCaseService { projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")"; args.addAll(projectScopes); } - String publishedWhere = projectScopes == null ? "" : " AND status = '已入库'"; + String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'" + + " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())" + + " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())"; String sql = """ SELECT media_oss_id FROM aihr_case_record @@ -309,8 +422,9 @@ public class AihrCaseService { ensureCaseTable(); jdbcTemplate.update(""" INSERT INTO aihr_case_record - (tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, learning_points, supervisor_comment, media_oss_id, media_url, owner, create_time, update_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + (tenant_id, case_id, file_name, project_ext_org_id, title, type, status, knowledge_status, + transcript, summary, learning_points, supervisor_comment, media_oss_id, media_url, owner, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, 'CANDIDATE', ?, ?, ?, ?, ?, ?, ?, now(), now()) ON DUPLICATE KEY UPDATE file_name = VALUES(file_name), project_ext_org_id = VALUES(project_ext_org_id), @@ -382,7 +496,10 @@ public class AihrCaseService { AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_case_record", "media_oss_id", "media_url", "learning_points", "supervisor_comment", "synthetic_content", "generator_type", "model_name", "prompt_version", - "grounding_oss_id", "ai_summary_hash", "reviewer_id", "reviewed_time"); + "grounding_oss_id", "ai_summary_hash", "reviewer_id", "reviewed_time", + "knowledge_status", "source_type", "applicable_positions", "content_version", + "source_authorized", "deidentified", "effective_from", "effective_to", + "release_owner", "release_note", "released_by", "released_time", "retire_reason"); tableReady = true; return; } @@ -403,6 +520,19 @@ public class AihrCaseService { `media_oss_id` bigint DEFAULT NULL COMMENT '原始音频OSS文件ID', `media_url` varchar(500) DEFAULT NULL COMMENT '原始音频访问地址', `owner` varchar(80) DEFAULT '培训组' COMMENT '负责人', + `knowledge_status` varchar(20) NOT NULL DEFAULT 'CANDIDATE' COMMENT 'CANDIDATE/PUBLISHED/OFFLINE', + `source_type` varchar(40) DEFAULT NULL COMMENT '经验来源类型', + `applicable_positions` varchar(500) DEFAULT NULL COMMENT '适用岗位', + `content_version` varchar(50) DEFAULT NULL COMMENT '正式经验版本', + `source_authorized` tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源是否已授权', + `deidentified` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否已脱敏', + `effective_from` date DEFAULT NULL COMMENT '生效日期', + `effective_to` date DEFAULT NULL COMMENT '失效日期', + `release_owner` varchar(80) DEFAULT NULL COMMENT '正式发布负责人', + `release_note` varchar(1000) DEFAULT NULL COMMENT '正式发布说明', + `released_by` bigint DEFAULT NULL COMMENT '正式发布操作人', + `released_time` datetime DEFAULT NULL COMMENT '正式发布时间', + `retire_reason` varchar(500) DEFAULT NULL COMMENT '下线原因', `synthetic_content` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否包含AI或确定性生成内容', `generator_type` varchar(40) DEFAULT NULL COMMENT '生成器类型', `model_name` varchar(120) DEFAULT NULL COMMENT '生成模型', @@ -430,6 +560,19 @@ public class AihrCaseService { addCaseColumn("ai_summary_hash", "ALTER TABLE aihr_case_record ADD COLUMN ai_summary_hash char(64) DEFAULT NULL COMMENT '整理摘要SHA-256'"); addCaseColumn("reviewer_id", "ALTER TABLE aihr_case_record ADD COLUMN reviewer_id bigint DEFAULT NULL COMMENT '人工审核人'"); addCaseColumn("reviewed_time", "ALTER TABLE aihr_case_record ADD COLUMN reviewed_time datetime DEFAULT NULL COMMENT '人工审核时间'"); + addCaseColumn("knowledge_status", "ALTER TABLE aihr_case_record ADD COLUMN knowledge_status varchar(20) NOT NULL DEFAULT 'CANDIDATE' COMMENT 'CANDIDATE/PUBLISHED/OFFLINE'"); + addCaseColumn("source_type", "ALTER TABLE aihr_case_record ADD COLUMN source_type varchar(40) DEFAULT NULL COMMENT '经验来源类型'"); + addCaseColumn("applicable_positions", "ALTER TABLE aihr_case_record ADD COLUMN applicable_positions varchar(500) DEFAULT NULL COMMENT '适用岗位'"); + addCaseColumn("content_version", "ALTER TABLE aihr_case_record ADD COLUMN content_version varchar(50) DEFAULT NULL COMMENT '正式经验版本'"); + addCaseColumn("source_authorized", "ALTER TABLE aihr_case_record ADD COLUMN source_authorized tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源是否已授权'"); + addCaseColumn("deidentified", "ALTER TABLE aihr_case_record ADD COLUMN deidentified tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否已脱敏'"); + addCaseColumn("effective_from", "ALTER TABLE aihr_case_record ADD COLUMN effective_from date DEFAULT NULL COMMENT '生效日期'"); + addCaseColumn("effective_to", "ALTER TABLE aihr_case_record ADD COLUMN effective_to date DEFAULT NULL COMMENT '失效日期'"); + addCaseColumn("release_owner", "ALTER TABLE aihr_case_record ADD COLUMN release_owner varchar(80) DEFAULT NULL COMMENT '正式发布负责人'"); + addCaseColumn("release_note", "ALTER TABLE aihr_case_record ADD COLUMN release_note varchar(1000) DEFAULT NULL COMMENT '正式发布说明'"); + addCaseColumn("released_by", "ALTER TABLE aihr_case_record ADD COLUMN released_by bigint DEFAULT NULL COMMENT '正式发布操作人'"); + addCaseColumn("released_time", "ALTER TABLE aihr_case_record ADD COLUMN released_time datetime DEFAULT NULL COMMENT '正式发布时间'"); + addCaseColumn("retire_reason", "ALTER TABLE aihr_case_record ADD COLUMN retire_reason varchar(500) DEFAULT NULL COMMENT '下线原因'"); tableReady = true; } } @@ -768,6 +911,11 @@ public class AihrCaseService { private record MediaRef(Long ossId, String url) { } + private record PublishMetadata(String sourceType, String applicablePositions, String contentVersion, + String releaseOwner, String releaseNote, java.sql.Date effectiveFrom, + java.sql.Date effectiveTo) { + } + private record CaseSummary(List summary, List tags, String aiSummary, String source, String modelName, String promptVersion) { } 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 ad77ba97..31d268ee 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 @@ -11,6 +11,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskResponse; +import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskProgressRequest; import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; import org.dromara.aihr.domain.AihrPracticeDto.MistakeResponse; @@ -130,6 +131,14 @@ public class AihrMobileSeedService { return practiceSeedService.requireMobileIdentity(mobilePhone); } + public String requireMobileOrganizationIdentity(String mobilePhone) { + return practiceSeedService.requireMobileOrganizationIdentity(mobilePhone); + } + + public String requireMobilePhoneForOrganizationIdentity(String organizationExtPartyId) { + return practiceSeedService.requireMobilePhoneForOrganizationIdentity(organizationExtPartyId); + } + /** * Resolves an APP supervisor phone to the verified organization external * ID used by team-scope queries. @@ -226,6 +235,10 @@ public class AihrMobileSeedService { return practiceSeedService.completeOnboardTask(taskId, extPartyId, evidenceRef); } + public boolean updateOnboardTaskProgress(Long taskId, String extPartyId, OnboardTaskProgressRequest request) { + return practiceSeedService.updateOnboardTaskProgress(taskId, extPartyId, request); + } + 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 cf0e8b95..0d445045 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 @@ -32,6 +32,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentTarget; import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionEnabledRequest; +import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionReviewRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionResponse; import org.dromara.aihr.domain.AihrPracticeDto.PracticeQuestionSaveRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeTeamProfileResponse; @@ -40,6 +41,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest; import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse; import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse; import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskResponse; +import org.dromara.aihr.domain.AihrPracticeDto.OnboardTaskProgressRequest; import org.dromara.aihr.domain.AihrPracticeDto.QualificationGateResponse; import org.dromara.aihr.domain.AihrPracticeDto.QualificationResponse; import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse; @@ -123,6 +125,7 @@ public class AihrPracticeSeedService { ); private static final List GROWTH_LEVELS = List.of("初级", "中级", "高级"); private static final Set SCENARIO_REVIEW_STATUSES = Set.of("草稿", "待业务审核", "已发布", "已下线"); + private static final Set EXAM_QUESTION_FORMATS = Set.of("single", "multiple", "judge", "short"); private static final Set SCENARIO_RISK_LEVELS = Set.of("待评估", "常规", "高风险"); private static final List PUBLICATION_PLACEHOLDER_MARKERS = List.of( "待补", "待提供", "待完善", "待定", "占位内容", "占位文本", "占位符", "placeholder", "示例来源", "示例内容", @@ -797,7 +800,9 @@ public class AihrPracticeSeedService { return jdbcTemplate.query(""" SELECT q.question_code, q.scenario_code, COALESCE(s.scenario_name, q.scenario_code) AS scenario_name, q.position_code, q.question_type, q.question_text, q.reference_answer, - q.content_version, q.content_hash, q.enabled, q.sort_order + q.content_version, q.content_hash, q.enabled, q.sort_order, q.exam_format, + q.exam_options_json, q.exam_answers_json, q.exam_explanation, q.exam_enabled, + q.review_status, q.reviewed_by, q.reviewed_time FROM aihr_learning_question q LEFT JOIN aihr_practice_scenario s ON s.tenant_id = q.tenant_id AND s.scenario_code = q.scenario_code @@ -833,23 +838,40 @@ public class AihrPracticeSeedService { PracticeQuestionState existing = practiceQuestionState(code); boolean enabled = request == null || request.enabled() == null || request.enabled(); int sortOrder = Math.max(0, request == null || request.sortOrder() == null ? 100 : request.sortOrder()); - if (samePracticeQuestion(existing, scenarioCode, position, type, question, reference, enabled, sortOrder)) { + boolean examEnabled = request != null && Boolean.TRUE.equals(request.examEnabled()); + String examFormat = normalizeExamFormat(request == null ? null : request.examFormat(), examEnabled); + List examOptions = cleanExamValues(request == null ? null : request.examOptions()); + List examAnswers = cleanExamValues(request == null ? null : request.examAnswers()); + String examExplanation = truncateValue(firstNonBlank(request == null ? null : request.examExplanation(), ""), 1000); + validateExamQuestion(examEnabled, examFormat, examOptions, examAnswers); + if (samePracticeQuestion(existing, scenarioCode, position, type, question, reference, enabled, sortOrder, + examFormat, examOptions, examAnswers, examExplanation, examEnabled)) { return practiceQuestionResponse(code); } String version = nextQuestionContentVersion(code); - String hash = AihrLearningService.contentHash(version, question, reference); + String hash = AihrLearningService.contentHash(version, + question + "\n" + examFormat + "\n" + writeJson(examOptions), + reference + "\n" + writeJson(examAnswers) + "\n" + examExplanation); Timestamp now = Timestamp.valueOf(businessNow()); jdbcTemplate.update(""" INSERT INTO aihr_learning_question (tenant_id, question_code, scenario_code, position_code, question_type, question_text, reference_answer, - content_version, content_hash, enabled, sort_order, create_time, update_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + content_version, content_hash, enabled, sort_order, exam_format, exam_options_json, + exam_answers_json, exam_explanation, exam_enabled, review_status, review_note, reviewed_by, + reviewed_time, create_time, update_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', NULL, NULL, NULL, ?, ?) ON DUPLICATE KEY UPDATE scenario_code = VALUES(scenario_code), position_code = VALUES(position_code), question_type = VALUES(question_type), question_text = VALUES(question_text), reference_answer = VALUES(reference_answer), content_version = VALUES(content_version), content_hash = VALUES(content_hash), enabled = VALUES(enabled), - sort_order = VALUES(sort_order), update_time = VALUES(update_time) - """, tenantId(), code, scenarioCode, position, type, question, reference, version, hash, enabled, sortOrder, now, now); + sort_order = VALUES(sort_order), exam_format = VALUES(exam_format), + exam_options_json = VALUES(exam_options_json), exam_answers_json = VALUES(exam_answers_json), + exam_explanation = VALUES(exam_explanation), exam_enabled = VALUES(exam_enabled), + review_status = 'DRAFT', review_note = NULL, reviewed_by = NULL, reviewed_time = NULL, + update_time = VALUES(update_time) + """, tenantId(), code, scenarioCode, position, type, question, reference, version, hash, enabled, sortOrder, + emptyToNull(examFormat), writeJson(examOptions), writeJson(examAnswers), emptyToNull(examExplanation), + examEnabled, now, now); Map affectedScenarios = new LinkedHashMap<>(); if (existing != null && !scenarioCode.equals(existing.scenarioCode())) { affectedScenarios.put(existing.scenarioCode(), new QuestionReviewChange( @@ -881,10 +903,14 @@ public class AihrPracticeSeedService { } ScenarioResponse scenario = scenario(existing.scenarioCode(), true); String version = nextQuestionContentVersion(questionCode); - String hash = AihrLearningService.contentHash(version, existing.question(), existing.referenceAnswer()); + String hash = AihrLearningService.contentHash(version, + existing.question() + "\n" + firstNonBlank(existing.examFormat(), "") + "\n" + writeJson(existing.examOptions()), + existing.referenceAnswer() + "\n" + writeJson(existing.examAnswers()) + "\n" + + firstNonBlank(existing.examExplanation(), "")); int updated = jdbcTemplate.update(""" UPDATE aihr_learning_question - SET enabled = ?, content_version = ?, content_hash = ?, update_time = ? + SET enabled = ?, content_version = ?, content_hash = ?, review_status = 'DRAFT', + review_note = NULL, reviewed_by = NULL, reviewed_time = NULL, update_time = ? WHERE tenant_id = ? AND question_code = ? """, enabled, version, hash, Timestamp.valueOf(businessNow()), tenantId(), questionCode); if (updated <= 0) { @@ -895,13 +921,38 @@ public class AihrPracticeSeedService { return true; } + @Transactional(rollbackFor = Exception.class) + public PracticeQuestionResponse reviewQuestion(String code, PracticeQuestionReviewRequest request, String reviewer) { + ensureLearningQuestionTable(); + PracticeQuestionState existing = practiceQuestionState(code); + if (existing == null) { + throw new ServiceException("训练题目不存在"); + } + String decision = firstNonBlank(request == null ? null : request.decision(), "").toUpperCase(Locale.ROOT); + if (!Set.of("APPROVE", "REJECT").contains(decision)) { + throw new ServiceException("审核决定只能是 APPROVE 或 REJECT"); + } + if ("APPROVE".equals(decision)) { + validateExamQuestion(existing.examEnabled(), existing.examFormat(), existing.examOptions(), existing.examAnswers()); + } + String status = "APPROVE".equals(decision) ? "APPROVED" : "REJECTED"; + jdbcTemplate.update(""" + UPDATE aihr_learning_question + SET review_status = ?, review_note = ?, reviewed_by = ?, reviewed_time = now(), update_time = now() + WHERE tenant_id = ? AND question_code = ? + """, status, truncateValue(request == null ? null : request.note(), 500), + truncateValue(firstNonBlank(reviewer, "operator"), 100), tenantId(), code.trim()); + return practiceQuestionResponse(code.trim()); + } + private PracticeQuestionState practiceQuestionState(String questionCode) { if (isBlank(questionCode)) { return null; } List rows = jdbcTemplate.query(""" SELECT question_code, scenario_code, position_code, question_type, question_text, reference_answer, - content_version, content_hash, enabled, sort_order + content_version, content_hash, enabled, sort_order, exam_format, exam_options_json, + exam_answers_json, exam_explanation, exam_enabled, review_status FROM aihr_learning_question WHERE tenant_id = ? AND question_code = ? LIMIT 1 @@ -909,14 +960,17 @@ public class AihrPracticeSeedService { rs.getString("question_code"), rs.getString("scenario_code"), rs.getString("position_code"), rs.getString("question_type"), rs.getString("question_text"), rs.getString("reference_answer"), firstNonBlank(rs.getString("content_version"), "v1"), rs.getString("content_hash"), - rs.getBoolean("enabled"), rs.getInt("sort_order") + rs.getBoolean("enabled"), rs.getInt("sort_order"), rs.getString("exam_format"), + readStringList(rs.getString("exam_options_json")), readStringList(rs.getString("exam_answers_json")), + rs.getString("exam_explanation"), rs.getBoolean("exam_enabled"), rs.getString("review_status") ), tenantId(), questionCode.trim()); return rows.isEmpty() ? null : rows.get(0); } - private static boolean samePracticeQuestion(PracticeQuestionState existing, String scenarioCode, String position, + private boolean samePracticeQuestion(PracticeQuestionState existing, String scenarioCode, String position, String type, String question, String reference, boolean enabled, - int sortOrder) { + int sortOrder, String examFormat, List examOptions, + List examAnswers, String examExplanation, boolean examEnabled) { return existing != null && Objects.equals(existing.scenarioCode(), scenarioCode) && Objects.equals(existing.positionCode(), position) @@ -924,7 +978,84 @@ public class AihrPracticeSeedService { && Objects.equals(existing.question(), question) && Objects.equals(existing.referenceAnswer(), reference) && existing.enabled() == enabled - && existing.sortOrder() == sortOrder; + && existing.sortOrder() == sortOrder + && Objects.equals(firstNonBlank(existing.examFormat(), ""), examFormat) + && Objects.equals(existing.examOptions(), examOptions) + && Objects.equals(existing.examAnswers(), examAnswers) + && Objects.equals(firstNonBlank(existing.examExplanation(), ""), examExplanation) + && existing.examEnabled() == examEnabled; + } + + private String normalizeExamFormat(String value, boolean examEnabled) { + String format = firstNonBlank(value, "").toLowerCase(Locale.ROOT); + if (format.isEmpty() && !examEnabled) { + return ""; + } + if (!EXAM_QUESTION_FORMATS.contains(format)) { + throw new ServiceException("考试题型只能是单选、多选、判断或简答"); + } + return format; + } + + private List cleanExamValues(List values) { + if (values == null) { + return List.of(); + } + return values.stream().map(value -> firstNonBlank(value, "")).filter(value -> !value.isEmpty()) + .distinct().limit(20).toList(); + } + + private void validateExamQuestion(boolean examEnabled, String format, List options, + List answers) { + if (!examEnabled) { + return; + } + if (!EXAM_QUESTION_FORMATS.contains(firstNonBlank(format, ""))) { + throw new ServiceException("启用考试用途时必须选择题型"); + } + if (answers == null || answers.isEmpty()) { + throw new ServiceException("启用考试用途时必须填写正确答案"); + } + if (!"short".equals(format)) { + if (options == null || options.size() < 2) { + throw new ServiceException("客观题至少需要两个选项"); + } + if (!options.containsAll(answers)) { + throw new ServiceException("正确答案必须来自已填写选项"); + } + } + } + + private List readStringList(String json) { + if (isBlank(json)) { + return List.of(); + } + try { + JsonNode root = objectMapper.readTree(json); + if (!root.isArray()) { + return List.of(); + } + List result = new ArrayList<>(); + root.forEach(node -> { + String value = firstNonBlank(node.asText(), ""); + if (!value.isEmpty() && !result.contains(value)) { + result.add(value); + } + }); + return List.copyOf(result); + } catch (JsonProcessingException error) { + throw new ServiceException("题库选项数据损坏"); + } + } + + private String emptyToNull(String value) { + String normalized = firstNonBlank(value, ""); + return normalized.isEmpty() ? null : normalized; + } + + private static String truncateValue(String value, int maxLength) { + String normalized = value == null ? "" : value.trim(); + return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength); } private PracticeQuestionResponse practiceQuestionResponse(String questionCode) { @@ -3598,13 +3729,21 @@ public class AihrPracticeSeedService { args.add(limit); return jdbcTemplate.query(""" SELECT t.id, t.project_code, t.position_code, t.sop_id, t.course_id, t.task_type, - t.status, t.assign_by, t.assigned_time, t.due_time, t.completed_time, t.evidence_ref + t.status, t.assign_by, t.assigned_time, t.due_time, t.completed_time, t.evidence_ref, + t.material_type, t.material_attachment_id, t.material_title, t.material_version, + t.progress_percent, t.completion_rule, + CASE WHEN EXISTS (SELECT 1 FROM aihr_onboard_exam_target et + WHERE et.tenant_id = t.tenant_id AND et.exam_id = t.exam_id + AND et.ext_party_id IN (%s)) THEN t.exam_id ELSE NULL END AS exam_id, + t.recurrence_type, t.started_time, t.last_viewed_time, + t.last_position_seconds, t.media_duration_seconds FROM aihr_onboard_task t WHERE t.tenant_id = ? AND t.ext_party_id IN (%s) + AND (t.assigned_time IS NULL OR t.assigned_time <= now()) ORDER BY CASE t.status WHEN '待完成' THEN 0 WHEN '进行中' THEN 1 WHEN '已完成' THEN 2 ELSE 3 END, COALESCE(t.due_time, t.create_time) ASC, t.id DESC LIMIT ? - """.formatted(inClause(storageKeys.size())), (rs, rowNum) -> new OnboardTaskResponse( + """.formatted(inClause(storageKeys.size()), inClause(storageKeys.size())), (rs, rowNum) -> new OnboardTaskResponse( rs.getLong("id"), rs.getString("project_code"), rs.getString("position_code"), @@ -3616,8 +3755,14 @@ public class AihrPracticeSeedService { formatTimestamp(rs.getTimestamp("assigned_time")), formatTimestamp(rs.getTimestamp("due_time")), formatTimestamp(rs.getTimestamp("completed_time")), - rs.getString("evidence_ref") - ), args.toArray()); + rs.getString("evidence_ref"), rs.getString("material_type"), + (Long) rs.getObject("material_attachment_id"), rs.getString("material_title"), + rs.getString("material_version"), rs.getInt("progress_percent"), + rs.getString("completion_rule"), (Long) rs.getObject("exam_id"), + rs.getString("recurrence_type"), formatTimestamp(rs.getTimestamp("started_time")), + formatTimestamp(rs.getTimestamp("last_viewed_time")), + (Integer) rs.getObject("last_position_seconds"), (Integer) rs.getObject("media_duration_seconds") + ), currentTaskArgs(storageKeys, limit)); } private List queryCurrentAssignedTasks(List storageKeys, int limit) { @@ -3767,6 +3912,38 @@ public class AihrPracticeSeedService { return mobilePracticeIdentity(mobilePhone).mobilePhone(); } + /** Resolve the authenticated APP phone to the one verified organization ID. */ + public String requireMobileOrganizationIdentity(String mobilePhone) { + String phone = isBlank(mobilePhone) ? "" : mobilePhone.trim(); + if (phone.isEmpty()) { + throw new ServiceException("当前登录账号缺少手机号"); + } + String organizationId = resolveCurrentTaskIdentity(phone).organizationExtPartyId(); + if (isBlank(organizationId)) { + throw new ServiceException("当前手机号未唯一匹配在职员工"); + } + return organizationId; + } + + /** Resolve a verified organization ID back to its unique APP phone. */ + public String requireMobilePhoneForOrganizationIdentity(String organizationExtPartyId) { + String organizationId = isBlank(organizationExtPartyId) ? "" : organizationExtPartyId.trim(); + if (organizationId.isEmpty()) { + throw new ServiceException("当前员工组织身份缺失"); + } + List phones = jdbcTemplate.query(""" + SELECT DISTINCT person_phone + FROM aihr_org_snapshot + WHERE tenant_id = ? AND employment_status = 'active' + AND ext_party_id = ? AND person_phone IS NOT NULL AND person_phone <> '' + ORDER BY person_phone + """, (rs, rowNum) -> rs.getString("person_phone"), tenantId(), organizationId); + if (phones.size() != 1 || isBlank(phones.get(0))) { + throw new ServiceException("当前组织身份未唯一匹配手机号"); + } + return phones.get(0).trim(); + } + private void requireCurrentTaskTable(String tableName) { try { Integer count = jdbcTemplate.queryForObject(""" @@ -3794,6 +3971,7 @@ public class AihrPracticeSeedService { * no task is created from this endpoint. */ public List onboardTasks(String extPartyId, int limit) { + ensureOnboardTaskLearningColumns(); if (isBlank(extPartyId) || !formalPositionTablesExist("aihr_onboard_task")) { return List.of(); } @@ -3804,13 +3982,21 @@ public class AihrPracticeSeedService { args.add(normalizeLimit(limit)); return jdbcTemplate.query(""" SELECT t.id, t.project_code, t.position_code, t.sop_id, t.course_id, t.task_type, - t.status, t.assign_by, t.assigned_time, t.due_time, t.completed_time, t.evidence_ref + t.status, t.assign_by, t.assigned_time, t.due_time, t.completed_time, t.evidence_ref, + t.material_type, t.material_attachment_id, t.material_title, t.material_version, + t.progress_percent, t.completion_rule, + CASE WHEN EXISTS (SELECT 1 FROM aihr_onboard_exam_target et + WHERE et.tenant_id = t.tenant_id AND et.exam_id = t.exam_id + AND et.ext_party_id IN (%s)) THEN t.exam_id ELSE NULL END AS exam_id, + t.recurrence_type, t.started_time, t.last_viewed_time, + t.last_position_seconds, t.media_duration_seconds FROM aihr_onboard_task t WHERE t.tenant_id = ? AND t.ext_party_id IN (%s) + AND (t.assigned_time IS NULL OR t.assigned_time <= now()) ORDER BY CASE t.status WHEN '待完成' THEN 0 WHEN '进行中' THEN 1 WHEN '已完成' THEN 2 ELSE 3 END, COALESCE(t.due_time, t.create_time) ASC, t.id DESC LIMIT ? - """.formatted(inClause(identity.storageKeys().size())), (rs, rowNum) -> new OnboardTaskResponse( + """.formatted(inClause(identity.storageKeys().size()), inClause(identity.storageKeys().size())), (rs, rowNum) -> new OnboardTaskResponse( rs.getLong("id"), rs.getString("project_code"), rs.getString("position_code"), @@ -3822,11 +4008,117 @@ public class AihrPracticeSeedService { formatTimestamp(rs.getTimestamp("assigned_time")), formatTimestamp(rs.getTimestamp("due_time")), formatTimestamp(rs.getTimestamp("completed_time")), - rs.getString("evidence_ref") - ), args.toArray()); + rs.getString("evidence_ref"), rs.getString("material_type"), + (Long) rs.getObject("material_attachment_id"), rs.getString("material_title"), + rs.getString("material_version"), rs.getInt("progress_percent"), + rs.getString("completion_rule"), (Long) rs.getObject("exam_id"), + rs.getString("recurrence_type"), formatTimestamp(rs.getTimestamp("started_time")), + formatTimestamp(rs.getTimestamp("last_viewed_time")), + (Integer) rs.getObject("last_position_seconds"), (Integer) rs.getObject("media_duration_seconds") + ), currentTaskArgs(identity.storageKeys(), normalizeLimit(limit))); + } + + /** + * Record observed material progress for the authenticated employee. + * Progress is monotonic and the material version is checked against the + * dispatch snapshot so an old client cannot complete a replaced document. + */ + public boolean updateOnboardTaskProgress(Long taskId, String extPartyId, OnboardTaskProgressRequest request) { + ensureOnboardTaskLearningColumns(); + if (taskId == null || taskId <= 0 || isBlank(extPartyId) || request == null + || !formalPositionTablesExist("aihr_onboard_task")) { + return false; + } + EmployeePracticeIdentity identity = mobilePracticeIdentity(extPartyId); + if (identity.storageKeys().isEmpty()) { + return false; + } + List taskArgs = new ArrayList<>(); + taskArgs.add(tenantId()); + taskArgs.add(taskId); + taskArgs.addAll(identity.storageKeys()); + List rows = jdbcTemplate.query(""" + SELECT material_type, material_version, progress_percent, status, + started_time, last_position_seconds, media_duration_seconds + FROM aihr_onboard_task + WHERE tenant_id = ? AND id = ? AND ext_party_id IN (%s) + AND status IN ('待完成', '进行中') + LIMIT 1 + """.formatted(inClause(identity.storageKeys().size())), (rs, rowNum) -> new OnboardProgressRow( + rs.getString("material_type"), rs.getString("material_version"), + rs.getInt("progress_percent"), rs.getString("status"), + rs.getTimestamp("started_time"), (Integer) rs.getObject("last_position_seconds"), + (Integer) rs.getObject("media_duration_seconds") + ), taskArgs.toArray()); + if (rows.isEmpty()) { + return false; + } + OnboardProgressRow task = rows.get(0); + String expectedVersion = isBlank(request.materialVersion()) ? "" : request.materialVersion().trim(); + String storedVersion = isBlank(task.materialVersion()) ? "" : task.materialVersion().trim(); + if (!expectedVersion.isEmpty() && !expectedVersion.equals(storedVersion)) { + throw new ServiceException("学习材料版本已更新,请重新打开最新材料"); + } + int currentProgress = Math.max(0, Math.min(100, task.progressPercent())); + int requestedProgress = request.progressPercent() == null ? currentProgress : request.progressPercent(); + if (requestedProgress < 0 || requestedProgress > 100) { + throw new ServiceException("学习进度必须在 0-100 之间"); + } + boolean completed = Boolean.TRUE.equals(request.completed()); + String materialType = (isBlank(task.materialType()) ? "" : task.materialType().trim()).toUpperCase(java.util.Locale.ROOT); + int position = request.positionSeconds() == null + ? Math.max(0, task.lastPositionSeconds() == null ? 0 : task.lastPositionSeconds()) + : request.positionSeconds(); + Integer duration = request.durationSeconds() == null ? task.mediaDurationSeconds() : request.durationSeconds(); + if (position < 0 || (duration != null && (duration <= 0 || duration > 24 * 60 * 60)) + || (duration != null && position > duration)) { + throw new ServiceException("材料播放位置不合法"); + } + if ("VIDEO".equals(materialType)) { + if (duration == null || duration <= 0) { + throw new ServiceException("视频时长尚未读取,暂不能记录学习进度"); + } + int observedProgress = (int) Math.floor(position * 100d / duration); + requestedProgress = Math.min(requestedProgress, observedProgress); + boolean atEnd = position >= duration || observedProgress >= 100; + if (completed && !atEnd) { + throw new ServiceException("请播放至视频末尾后再完成学习"); + } + if (requestedProgress > currentProgress + 25 && !atEnd) { + throw new ServiceException("检测到视频快进,请按顺序观看材料"); + } + if (atEnd) { + requestedProgress = 100; + } + } else if (completed) { + requestedProgress = 100; + } + int nextProgress = Math.max(currentProgress, requestedProgress); + int nextPosition = Math.max(Math.max(0, task.lastPositionSeconds()), position); + List updateArgs = new ArrayList<>(); + updateArgs.add(nextProgress); + updateArgs.add(nextPosition); + updateArgs.add(duration); + updateArgs.add(nextProgress); + updateArgs.add(tenantId()); + updateArgs.add(taskId); + updateArgs.addAll(identity.storageKeys()); + return jdbcTemplate.update(""" + UPDATE aihr_onboard_task + SET progress_percent = ?, + started_time = COALESCE(started_time, now()), + last_viewed_time = now(), + last_position_seconds = GREATEST(COALESCE(last_position_seconds, 0), ?), + media_duration_seconds = COALESCE(?, media_duration_seconds), + status = CASE WHEN status = '待完成' AND ? > 0 THEN '进行中' ELSE status END, + update_time = now() + WHERE tenant_id = ? AND id = ? AND ext_party_id IN (%s) + AND status IN ('待完成', '进行中') + """.formatted(inClause(identity.storageKeys().size())), updateArgs.toArray()) > 0; } public boolean completeOnboardTask(Long taskId, String extPartyId, String evidenceRef) { + ensureOnboardTaskLearningColumns(); if (taskId == null || taskId <= 0 || isBlank(extPartyId) || !formalPositionTablesExist("aihr_onboard_task")) { return false; } @@ -3842,13 +4134,22 @@ public class AihrPracticeSeedService { args.addAll(identity.storageKeys()); return jdbcTemplate.update(""" UPDATE aihr_onboard_task - SET status = '已完成', completed_time = now(), + SET status = '已完成', progress_percent = 100, completed_time = now(), evidence_ref = COALESCE(NULLIF(?, ''), evidence_ref), update_time = now() WHERE tenant_id = ? AND id = ? AND ext_party_id IN (%s) - AND status IN ('待完成', '进行中') + AND status IN ('待完成', '进行中') AND completion_rule = 'MANUAL_CONFIRM' """.formatted(inClause(identity.storageKeys().size())), args.toArray()) > 0; } + private Object[] currentTaskArgs(List storageKeys, int limit) { + List args = new ArrayList<>(); + args.addAll(storageKeys); + args.add(tenantId()); + args.addAll(storageKeys); + args.add(limit); + return args.toArray(); + } + /** * Return qualification evidence for the current APP user without treating an * empty rules table as a pass. Formal qualification remains NOT_CONFIGURED until @@ -6163,7 +6464,15 @@ public class AihrPracticeSeedService { firstNonBlank(rs.getString("content_version"), "v1"), rs.getString("content_hash"), rs.getBoolean("enabled"), - rs.getInt("sort_order") + rs.getInt("sort_order"), + rs.getString("exam_format"), + readStringList(rs.getString("exam_options_json")), + readStringList(rs.getString("exam_answers_json")), + rs.getString("exam_explanation"), + rs.getBoolean("exam_enabled"), + firstNonBlank(rs.getString("review_status"), "DRAFT"), + rs.getString("reviewed_by"), + formatTimestamp(rs.getTimestamp("reviewed_time")) ); } @@ -7278,7 +7587,9 @@ public class AihrPracticeSeedService { requirePracticeSchemaColumns("aihr_learning_question", "tenant_id", "question_code", "scenario_code", "position_code", "question_type", "question_text", "reference_answer", "content_version", "content_hash", "enabled", - "sort_order"); + "sort_order", "exam_format", "exam_options_json", "exam_answers_json", + "exam_explanation", "exam_enabled", "review_status", "review_note", "reviewed_by", + "reviewed_time"); learningQuestionTableReady = true; return; } @@ -7300,18 +7611,88 @@ public class AihrPracticeSeedService { `content_hash` char(64) NOT NULL COMMENT '题目内容哈希', `enabled` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用', `sort_order` int NOT NULL DEFAULT 0 COMMENT '排序', + `exam_format` varchar(20) DEFAULT NULL COMMENT 'single/multiple/judge/short', + `exam_options_json` text COMMENT '考试选项JSON', + `exam_answers_json` text COMMENT '考试答案JSON', + `exam_explanation` varchar(1000) DEFAULT NULL COMMENT '考试解析', + `exam_enabled` tinyint NOT NULL DEFAULT 0 COMMENT '是否可用于考试组卷', + `review_status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/APPROVED/REJECTED', + `review_note` varchar(500) DEFAULT NULL COMMENT '审核说明', + `reviewed_by` varchar(100) DEFAULT NULL COMMENT '审核人', + `reviewed_time` datetime DEFAULT NULL COMMENT '审核时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_aihr_learning_question` (`tenant_id`, `question_code`), KEY `idx_aihr_learning_question_hash` (`tenant_id`, `content_hash`), - KEY `idx_aihr_learning_question_pick` (`tenant_id`, `position_code`, `question_type`, `enabled`, `sort_order`) + KEY `idx_aihr_learning_question_pick` (`tenant_id`, `position_code`, `question_type`, `enabled`, `sort_order`), + KEY `idx_aihr_learning_question_exam_pick` (`tenant_id`, `position_code`, `review_status`, `exam_enabled`, `enabled`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 训练题目库'; """); + ensureColumn("aihr_learning_question", "exam_format", + "ALTER TABLE aihr_learning_question ADD COLUMN exam_format varchar(20) DEFAULT NULL AFTER sort_order"); + ensureColumn("aihr_learning_question", "exam_options_json", + "ALTER TABLE aihr_learning_question ADD COLUMN exam_options_json text AFTER exam_format"); + ensureColumn("aihr_learning_question", "exam_answers_json", + "ALTER TABLE aihr_learning_question ADD COLUMN exam_answers_json text AFTER exam_options_json"); + ensureColumn("aihr_learning_question", "exam_explanation", + "ALTER TABLE aihr_learning_question ADD COLUMN exam_explanation varchar(1000) DEFAULT NULL AFTER exam_answers_json"); + ensureColumn("aihr_learning_question", "exam_enabled", + "ALTER TABLE aihr_learning_question ADD COLUMN exam_enabled tinyint NOT NULL DEFAULT 0 AFTER exam_explanation"); + ensureColumn("aihr_learning_question", "review_status", + "ALTER TABLE aihr_learning_question ADD COLUMN review_status varchar(20) NOT NULL DEFAULT 'DRAFT' AFTER exam_enabled"); + ensureColumn("aihr_learning_question", "review_note", + "ALTER TABLE aihr_learning_question ADD COLUMN review_note varchar(500) DEFAULT NULL AFTER review_status"); + ensureColumn("aihr_learning_question", "reviewed_by", + "ALTER TABLE aihr_learning_question ADD COLUMN reviewed_by varchar(100) DEFAULT NULL AFTER review_note"); + ensureColumn("aihr_learning_question", "reviewed_time", + "ALTER TABLE aihr_learning_question ADD COLUMN reviewed_time datetime DEFAULT NULL AFTER reviewed_by"); + ensureIndex("aihr_learning_question", "idx_aihr_learning_question_exam_pick", + "ALTER TABLE aihr_learning_question ADD KEY idx_aihr_learning_question_exam_pick " + + "(tenant_id, position_code, review_status, exam_enabled, enabled, id)"); learningQuestionTableReady = true; } } + private void ensureOnboardTaskLearningColumns() { + if (!formalPositionTablesExist("aihr_onboard_task")) { + return; + } + if (!runtimeSchemaBootstrap) { + requirePracticeSchemaColumns("aihr_onboard_task", "material_type", "material_attachment_id", + "material_title", "material_version", "progress_percent", "completion_rule", + "exam_id", "recurrence_type", "dispatch_request_key", "started_time", + "last_viewed_time", "last_position_seconds", "media_duration_seconds"); + return; + } + ensureColumn("aihr_onboard_task", "material_type", + "ALTER TABLE aihr_onboard_task ADD COLUMN material_type varchar(20) DEFAULT NULL AFTER course_id"); + ensureColumn("aihr_onboard_task", "material_attachment_id", + "ALTER TABLE aihr_onboard_task ADD COLUMN material_attachment_id bigint DEFAULT NULL AFTER material_type"); + ensureColumn("aihr_onboard_task", "material_title", + "ALTER TABLE aihr_onboard_task ADD COLUMN material_title varchar(200) DEFAULT NULL AFTER material_attachment_id"); + ensureColumn("aihr_onboard_task", "material_version", + "ALTER TABLE aihr_onboard_task ADD COLUMN material_version varchar(50) DEFAULT NULL AFTER material_title"); + ensureColumn("aihr_onboard_task", "progress_percent", + "ALTER TABLE aihr_onboard_task ADD COLUMN progress_percent int NOT NULL DEFAULT 0 AFTER material_version"); + ensureColumn("aihr_onboard_task", "completion_rule", + "ALTER TABLE aihr_onboard_task ADD COLUMN completion_rule varchar(30) NOT NULL DEFAULT 'MANUAL_CONFIRM' AFTER progress_percent"); + ensureColumn("aihr_onboard_task", "exam_id", + "ALTER TABLE aihr_onboard_task ADD COLUMN exam_id bigint DEFAULT NULL AFTER completion_rule"); + ensureColumn("aihr_onboard_task", "recurrence_type", + "ALTER TABLE aihr_onboard_task ADD COLUMN recurrence_type varchar(20) NOT NULL DEFAULT 'ONCE' AFTER exam_id"); + ensureColumn("aihr_onboard_task", "dispatch_request_key", + "ALTER TABLE aihr_onboard_task ADD COLUMN dispatch_request_key varchar(100) DEFAULT NULL AFTER recurrence_type"); + ensureColumn("aihr_onboard_task", "started_time", + "ALTER TABLE aihr_onboard_task ADD COLUMN started_time datetime DEFAULT NULL AFTER progress_percent"); + ensureColumn("aihr_onboard_task", "last_viewed_time", + "ALTER TABLE aihr_onboard_task ADD COLUMN last_viewed_time datetime DEFAULT NULL AFTER started_time"); + ensureColumn("aihr_onboard_task", "last_position_seconds", + "ALTER TABLE aihr_onboard_task ADD COLUMN last_position_seconds int NOT NULL DEFAULT 0 AFTER last_viewed_time"); + ensureColumn("aihr_onboard_task", "media_duration_seconds", + "ALTER TABLE aihr_onboard_task ADD COLUMN media_duration_seconds int DEFAULT NULL AFTER last_position_seconds"); + } + private void ensureRubricTables() { if (rubricTablesReady) { return; @@ -8353,7 +8734,9 @@ public class AihrPracticeSeedService { private record PracticeQuestionState(String questionCode, String scenarioCode, String positionCode, String questionType, String question, String referenceAnswer, - String contentVersion, String contentHash, boolean enabled, int sortOrder) { + String contentVersion, String contentHash, boolean enabled, int sortOrder, + String examFormat, List examOptions, List examAnswers, + String examExplanation, boolean examEnabled, String reviewStatus) { } private record QuestionReviewChange(String questionCode, String contentVersion, String contentHash, @@ -8385,6 +8768,11 @@ public class AihrPracticeSeedService { int phoneExternalIdCollisionCount, int externalIdPhoneCollisionCount) { } + private record OnboardProgressRow(String materialType, String materialVersion, int progressPercent, + String status, Timestamp startedTime, Integer lastPositionSeconds, + Integer mediaDurationSeconds) { + } + public record CurrentTaskSnapshot(List onboardTasks, List practiceAssignments) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index dd5da2e8..48e67863 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -108,6 +108,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; import java.util.function.Supplier; import java.util.stream.Stream; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * ponytail: deterministic RAG seed; swap to vector search when real SOP corpus exists. @@ -436,6 +438,10 @@ public class AihrSopSeedService { } public AnswerFeedbackResponse answerFeedback(AnswerFeedbackRequest request) { + return answerFeedback(request, null); + } + + public AnswerFeedbackResponse answerFeedback(AnswerFeedbackRequest request, Long submitterUserId) { String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim(); String queryNorm = normalizeFeedbackQuery(queryText); if (isBlank(queryNorm)) { @@ -468,9 +474,9 @@ public class AihrSopSeedService { for (Long fragmentId : fragmentIds) { saved += jdbcTemplate.update(""" INSERT INTO aihr_knowledge_answer_feedback - (tenant_id, query_text, query_norm, fragment_id, verdict, feedback_reason_codes, feedback_comment, + (tenant_id, submitter_user_id, query_text, query_norm, fragment_id, verdict, feedback_reason_codes, feedback_comment, category, position, source, review_id, request_id, review_status, create_time, update_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE query_text = VALUES(query_text), verdict = VALUES(verdict), @@ -487,6 +493,7 @@ public class AihrSopSeedService { update_time = VALUES(update_time) """, tenantId(), + submitterUserId, truncate(AihrSensitiveText.forModel(queryText), 1000), normalizeFeedbackQuery(AihrSensitiveText.forModel(queryText)), fragmentId, @@ -505,8 +512,8 @@ public class AihrSopSeedService { if ("down".equals(verdict)) { Long feedbackId = jdbcTemplate.queryForObject(""" select id from aihr_knowledge_answer_feedback - where tenant_id = ? and query_norm = ? and fragment_id = ? - """, Long.class, tenantId(), normalizeFeedbackQuery(AihrSensitiveText.forModel(queryText)), fragmentId); + where tenant_id = ? and submitter_user_id <=> ? and request_id <=> ? and fragment_id = ? + """, Long.class, tenantId(), submitterUserId, requestId, fragmentId); if (feedbackId != null) { createFeedbackQualityIssue(feedbackId, fragmentId, requestId, reasonCodes); } @@ -3146,8 +3153,11 @@ public class AihrSopSeedService { AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_knowledge_answer_feedback", "aihr_quality_issue", "aihr_query_evidence"); AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_knowledge_answer_feedback", - "review_id", "feedback_reason_codes", "feedback_comment", "request_id", "review_action", + "submitter_user_id", "review_id", "feedback_reason_codes", "feedback_comment", "request_id", "review_action", "review_note", "reviewer", "reviewed_time"); + AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_knowledge_answer_feedback", + "uk_aihr_answer_feedback_user_request", true, + "tenant_id", "submitter_user_id", "request_id", "fragment_id"); AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_quality_issue", "origin_feedback_id"); answerFeedbackTableReady = true; return; @@ -3156,6 +3166,7 @@ public class AihrSopSeedService { CREATE TABLE IF NOT EXISTS `aihr_knowledge_answer_feedback` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `submitter_user_id` bigint DEFAULT NULL COMMENT '反馈提交用户ID', `query_text` varchar(1000) NOT NULL COMMENT '问题原文', `query_norm` varchar(500) NOT NULL COMMENT '规范化问题', `fragment_id` bigint NOT NULL COMMENT '知识片段ID', @@ -3175,12 +3186,13 @@ public class AihrSopSeedService { `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), - UNIQUE KEY `uk_aihr_answer_feedback_pair` (`tenant_id`, `query_norm`, `fragment_id`), + UNIQUE KEY `uk_aihr_answer_feedback_user_request` (`tenant_id`, `submitter_user_id`, `request_id`, `fragment_id`), KEY `idx_aihr_answer_feedback_verdict` (`tenant_id`, `query_norm`, `fragment_id`, `verdict`), KEY `idx_aihr_answer_feedback_review` (`tenant_id`, `review_status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识答案反馈'; """); ensureColumn("aihr_knowledge_answer_feedback", "review_id", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `review_id` bigint DEFAULT NULL COMMENT '对应SOP问答评审ID' AFTER `source`"); + ensureColumn("aihr_knowledge_answer_feedback", "submitter_user_id", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `submitter_user_id` bigint DEFAULT NULL COMMENT '反馈提交用户ID' AFTER `tenant_id`"); ensureColumn("aihr_knowledge_answer_feedback", "feedback_reason_codes", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `feedback_reason_codes` varchar(255) DEFAULT NULL COMMENT '未解决原因代码' AFTER `verdict`"); ensureColumn("aihr_knowledge_answer_feedback", "feedback_comment", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `feedback_comment` varchar(500) DEFAULT NULL COMMENT '未解决补充说明' AFTER `feedback_reason_codes`"); ensureColumn("aihr_knowledge_answer_feedback", "request_id", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `request_id` varchar(64) DEFAULT NULL COMMENT '知识查询请求ID' AFTER `review_id`"); @@ -3188,6 +3200,7 @@ public class AihrSopSeedService { ensureColumn("aihr_knowledge_answer_feedback", "review_note", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `review_note` varchar(500) DEFAULT NULL COMMENT '复核结论' AFTER `review_action`"); ensureColumn("aihr_knowledge_answer_feedback", "reviewer", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `reviewer` varchar(100) DEFAULT NULL COMMENT '复核人' AFTER `review_status`"); ensureColumn("aihr_knowledge_answer_feedback", "reviewed_time", "ALTER TABLE aihr_knowledge_answer_feedback ADD COLUMN `reviewed_time` datetime DEFAULT NULL COMMENT '复核时间' AFTER `reviewer`"); + ensureAnswerFeedbackUserRequestIndex(); answerFeedbackTableReady = true; } } @@ -3389,6 +3402,29 @@ public class AihrSopSeedService { """.formatted(fragmentAlias); } + private void ensureAnswerFeedbackUserRequestIndex() { + Integer legacyIndex = jdbcTemplate.queryForObject(""" + select count(*) from information_schema.statistics + where table_schema = database() and table_name = 'aihr_knowledge_answer_feedback' + and index_name = 'uk_aihr_answer_feedback_pair' + """, Integer.class); + if (legacyIndex != null && legacyIndex > 0) { + jdbcTemplate.execute("ALTER TABLE aihr_knowledge_answer_feedback DROP INDEX uk_aihr_answer_feedback_pair"); + } + Integer currentIndex = jdbcTemplate.queryForObject(""" + select count(*) from information_schema.statistics + where table_schema = database() and table_name = 'aihr_knowledge_answer_feedback' + and index_name = 'uk_aihr_answer_feedback_user_request' + """, Integer.class); + if (currentIndex == null || currentIndex == 0) { + jdbcTemplate.execute(""" + ALTER TABLE aihr_knowledge_answer_feedback + ADD UNIQUE KEY uk_aihr_answer_feedback_user_request + (tenant_id, submitter_user_id, request_id, fragment_id) + """); + } + } + private static String normalizeFeedbackRequestId(String value) { if (isBlank(value)) { return null; @@ -4681,8 +4717,10 @@ public class AihrSopSeedService { """, (rs, n) -> new FragmentLocatorSeed(rs.getLong("id"), rs.getInt("idx"), rs.getString("content")), tenant, knowledgeId, docId); for (FragmentLocatorSeed fragment : fragments) { - Long timestamp = "VIDEO".equals(kind) ? firstTimestampMillis(fragment.content()) : null; - boolean frame = fragment.content() != null && fragment.content().contains("[画面"); + VideoMarker marker = "VIDEO".equals(kind) ? firstVideoMarker(fragment.content()) : null; + Long timestamp = marker == null ? null : marker.startMs(); + boolean frame = marker != null && marker.frame(); + Long endTimestamp = marker != null && !frame ? marker.endMs() : null; jdbcTemplate.update(""" insert into aihr_knowledge_fragment_locator (tenant_id, fragment_id, knowledge_id, doc_id, attachment_id, source_kind, @@ -4690,8 +4728,8 @@ public class AihrSopSeedService { create_time, update_time) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v1', now(), now()) """, tenant, fragment.id(), knowledgeId, docId, attachmentId, kind, - fragment.index(), fragment.index(), frame ? null : timestamp, frame ? null : timestamp, - frame ? timestamp : null); + fragment.index(), fragment.index(), frame ? null : timestamp, frame ? null : endTimestamp, + frame ? timestamp : null); } } @@ -4706,13 +4744,22 @@ public class AihrSopSeedService { return "TEXT"; } - private static Long firstTimestampMillis(String value) { - if (value == null) return null; - java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\[(?:画面\\s*)?(\\d{1,3}):(\\d{2})]").matcher(value); + private static VideoMarker firstVideoMarker(String value) { + if (value == null || value.isBlank()) return null; + Matcher matcher = VIDEO_MARKER.matcher(value); if (!matcher.find()) return null; - return (Long.parseLong(matcher.group(1)) * 60 + Long.parseLong(matcher.group(2))) * 1000; + long start = toMillis(matcher.group(2), matcher.group(3)); + Long end = matcher.group(4) == null ? null : toMillis(matcher.group(4), matcher.group(5)); + return new VideoMarker(matcher.group(1).contains("\u753b\u9762"), start, end); } + private static long toMillis(String minutes, String seconds) { + return (Long.parseLong(minutes) * 60 + Long.parseLong(seconds)) * 1000; + } + + private static final Pattern VIDEO_MARKER = Pattern.compile( + "\\[([^\\]]*?)(\\d{1,3}):(\\d{2})(?:\\s*-\\s*(\\d{1,3}):(\\d{2}))?\\]"); + private static FileFingerprint fileFingerprint(MultipartFile file) { try (InputStream input = file.getInputStream()) { return fileFingerprint(input); @@ -5146,6 +5193,9 @@ public class AihrSopSeedService { private record FragmentLocatorSeed(long id, int index, String content) { } + private record VideoMarker(boolean frame, long startMs, Long endMs) { + } + private record VectorMatch(String docId, int idx, double score) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadCapacityHealthIndicator.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadCapacityHealthIndicator.java new file mode 100644 index 00000000..1246a2ee --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadCapacityHealthIndicator.java @@ -0,0 +1,84 @@ +package org.dromara.aihr.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.FileStore; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Fails the application health check before upload staging exhausts its filesystem. + */ +@Component("aihrUploadCapacity") +public class AihrUploadCapacityHealthIndicator implements HealthIndicator { + + static final long DEFAULT_MINIMUM_FREE_BYTES = 10L * 1024 * 1024 * 1024; + + private final Path stagingRoot; + private final long minimumFreeBytes; + + @Autowired + public AihrUploadCapacityHealthIndicator( + @Value("${aihr.upload.staging:${AIHR_UPLOAD_STAGING:./.data/staging}}") String stagingRoot, + @Value("${aihr.upload.minimum-free-bytes:${AIHR_UPLOAD_MINIMUM_FREE_BYTES:10737418240}}") long minimumFreeBytes + ) { + this(Path.of(stagingRoot).toAbsolutePath().normalize(), minimumFreeBytes); + } + + AihrUploadCapacityHealthIndicator(Path stagingRoot, long minimumFreeBytes) { + this.stagingRoot = stagingRoot.toAbsolutePath().normalize(); + this.minimumFreeBytes = Math.max(1L, minimumFreeBytes); + } + + @Override + public Health health() { + try { + boolean stagingPathInvalid = Files.exists(stagingRoot) && !Files.isDirectory(stagingRoot); + Path existing = existingAncestor(stagingRoot); + FileStore store = Files.getFileStore(existing); + long usableBytes = store.getUsableSpace(); + boolean writable = !stagingPathInvalid && Files.isWritable(existing); + boolean enoughSpace = usableBytes >= minimumFreeBytes; + Health.Builder builder = writable && enoughSpace ? Health.up() : Health.down(); + return policyDetails(builder) + .withDetail("usableBytes", usableBytes) + .withDetail("totalBytes", store.getTotalSpace()) + .withDetail("minimumFreeBytes", minimumFreeBytes) + .withDetail("stagingDirectoryPresent", Files.isDirectory(stagingRoot)) + .withDetail("stagingWritable", writable) + .withDetail("capacityReady", enoughSpace) + .build(); + } catch (IOException | RuntimeException ex) { + return policyDetails(Health.down()) + .withDetail("minimumFreeBytes", minimumFreeBytes) + .withDetail("stagingWritable", false) + .withDetail("capacityReady", false) + .withDetail("message", "upload staging filesystem is unavailable") + .build(); + } + } + + private static Health.Builder policyDetails(Health.Builder builder) { + return builder + .withDetail("workerConcurrency", AihrUploadQueueService.WORKER_PERMITS) + .withDetail("maxFileBytes", AihrUploadQueueService.MAX_FILE_BYTES) + .withDetail("maxArchiveUnpackedBytes", AihrUploadQueueService.MAX_ARCHIVE_UNPACKED_BYTES) + .withDetail("stagingRetentionHours", AihrUploadQueueService.STAGING_RETENTION_HOURS); + } + + private static Path existingAncestor(Path path) throws IOException { + Path candidate = path; + while (candidate != null && !Files.exists(candidate)) { + candidate = candidate.getParent(); + } + if (candidate == null) { + throw new IOException("upload staging filesystem is unavailable"); + } + return candidate; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java index ab6ecee5..dd3b0c12 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java @@ -57,20 +57,20 @@ public class AihrUploadQueueService { private static final String TENANT_ID = "000000"; private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm:ss"); /** 资料处理中心统一支持单文件 500 MiB;Spring multipart 请求上限需保持不低于该值。 */ - private static final long MAX_FILE_BYTES = 500L * 1024 * 1024; - private static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 1024 * 1024; + static final long MAX_FILE_BYTES = 500L * 1024 * 1024; + static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 1024 * 1024; private static final int MAX_ARCHIVE_ENTRIES = 1000; private static final int MAX_ERROR_CHARS = 480; // ponytail: UTF-8 + GBK covers the current Chinese sources; add an explicit charset option only if another legacy encoding appears. private static final Charset ZIP_FALLBACK_CHARSET = Charset.forName("GBK"); /** 加工并发上限:链路含 LLM 归类与 embedding 外呼,并发过高会互相争抢配额并拖慢单文件耗时。 */ - private static final int WORKER_PERMITS = 2; + static final int WORKER_PERMITS = 2; /** 处理中超过该时长视为孤儿(服务重启/线程异常丢失),由清扫任务重置回待处理。需大于最慢单文件加工时长。 */ private static final int STUCK_MINUTES = 30; /** 视频加工链路(ffmpeg+分段ASR+关键帧OCR)耗时更长,卡死判定单独放宽。 */ private static final int VIDEO_STUCK_MINUTES = 120; /** 失败条目的暂存文件保留时长,超时由清扫回收磁盘(之后重试会提示重新上传)。 */ - private static final int STAGING_RETENTION_HOURS = 72; + static final int STAGING_RETENTION_HOURS = 72; private static final ObjectMapper JSON = new ObjectMapper(); private final AihrSopSeedService sopSeedService; @@ -89,7 +89,7 @@ public class AihrUploadQueueService { ISysOssService ossService, JdbcTemplate jdbcTemplate, ScheduledExecutorService scheduledExecutorService, - @Value("${aihr.upload.staging:./.data/staging}") String stagingRootConfig) { + @Value("${aihr.upload.staging:${AIHR_UPLOAD_STAGING:./.data/staging}}") String stagingRootConfig) { this.sopSeedService = sopSeedService; this.ossService = ossService; this.jdbcTemplate = jdbcTemplate; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java index f369b1a2..628a671b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrVideoService.java @@ -111,8 +111,10 @@ public class AihrVideoService { continue; } Optional piece = speechService.transcribe(Files.readAllBytes(segment), "segment-" + i + ".mp3", "audio/mpeg"); + long end = Math.min(durationSeconds, offset + AUDIO_SEGMENT_SECONDS); piece.filter(s -> !s.isBlank()) - .ifPresent(s -> transcript.append('[').append(formatTime(offset)).append("] ").append(s.strip()).append('\n')); + .ifPresent(s -> transcript.append('[').append(formatTime(offset)).append('-') + .append(formatTime(end)).append("] ").append(s.strip()).append('\n')); Files.deleteIfExists(segment); } return transcript.toString(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java index 1dab3aed..c39a0868 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastAttachmentContractTest.java @@ -3,11 +3,14 @@ package org.dromara.aihr.broadcast; import org.dromara.aihr.broadcast.AihrBroadcastDto.BroadcastAttachmentResponse; import org.dromara.aihr.broadcast.AihrBroadcastDto.PublishRequest; import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.service.AihrSopSeedService; import org.dromara.system.service.ISysOssService; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -21,11 +24,13 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.ScheduledExecutorService; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -63,6 +68,62 @@ class AihrBroadcastAttachmentContractTest { assertEquals(42L, attached.attachmentId()); } + @Test + void automaticTopicTagsKeepAnExactSourceExcerpt() { + String source = "第一条:门岗与巡逻人员每天核对安保记录。\n第二条:物业费收费异常应及时复核。"; + + List tags = AihrBroadcastAttachmentService.autoTopicTags(source); + + assertEquals(List.of("安保", "收费"), tags.stream().map(AihrBroadcastDto.BroadcastTopicTag::name).toList()); + assertTrue(tags.stream().allMatch(tag -> !tag.evidenceQuote().isBlank())); + assertTrue(tags.stream().allMatch(tag -> source.contains(tag.evidenceQuote()))); + } + + @Test + void onlineOnlyAttachmentRejectsDownloadWithoutWritingAnAudit() { + DownloadJdbcTemplate jdbcTemplate = new DownloadJdbcTemplate(false); + AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class); + when(resolver.current()).thenReturn(new AihrKnowledgePrincipal( + "tenant-a", 7L, "app_user", "staff-7", Set.of("employee"), Set.of(), "mobile")); + AihrBroadcastAttachmentService service = attachmentService(jdbcTemplate, resolver); + + ServiceException error = assertThrows(ServiceException.class, () -> service.authorizedDownloadOssId(9L)); + + assertEquals(HttpStatus.FORBIDDEN, error.getCode()); + assertTrue(jdbcTemplate.updateSql.isEmpty()); + } + + @Test + void downloadableAttachmentWritesADownloadAuditAfterVisibilityRecheck() { + DownloadJdbcTemplate jdbcTemplate = new DownloadJdbcTemplate(true); + AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class); + when(resolver.current()).thenReturn(new AihrKnowledgePrincipal( + "tenant-a", 7L, "app_user", "staff-7", Set.of("employee"), Set.of(), "mobile")); + AihrBroadcastService broadcastService = mock(AihrBroadcastService.class); + AihrBroadcastAttachmentService service = attachmentService(jdbcTemplate, resolver, broadcastService); + + assertEquals(88L, service.authorizedDownloadOssId(9L)); + assertEquals(1, jdbcTemplate.updateSql.size()); + assertTrue(jdbcTemplate.updateSql.get(0).contains("aihr_broadcast_attachment_access_audit")); + assertArrayEquals(new Object[]{"tenant-a", 42L, 9L, 7L, "DOWNLOAD"}, jdbcTemplate.updateArgs.get(0)); + org.mockito.Mockito.verify(broadcastService).message(42L); + } + + private static AihrBroadcastAttachmentService attachmentService( + JdbcTemplate jdbcTemplate, AihrKnowledgePrincipalResolver resolver + ) { + return attachmentService(jdbcTemplate, resolver, mock(AihrBroadcastService.class)); + } + + private static AihrBroadcastAttachmentService attachmentService( + JdbcTemplate jdbcTemplate, AihrKnowledgePrincipalResolver resolver, AihrBroadcastService broadcastService + ) { + return new AihrBroadcastAttachmentService( + jdbcTemplate, mock(ISysOssService.class), mock(KnowledgeDocumentParser.class), + mock(AihrSopSeedService.class), mock(AihrBroadcastInsightService.class), resolver, + broadcastService, mock(ScheduledExecutorService.class)); + } + @Test void backgroundWorkerReadsOssInsideTheOwningTenant() throws Exception { String source = Files.readString(Path.of( @@ -160,4 +221,37 @@ class AihrBroadcastAttachmentContractTest { }); } } + + private static final class DownloadJdbcTemplate extends JdbcTemplate { + private final boolean allowDownload; + private final List updateSql = new ArrayList<>(); + private final List updateArgs = new ArrayList<>(); + + private DownloadJdbcTemplate(boolean allowDownload) { + this.allowDownload = allowDownload; + } + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + try { + ResultSet resultSet = (ResultSet) Proxy.newProxyInstance( + DownloadJdbcTemplate.class.getClassLoader(), new Class[]{ResultSet.class}, + (proxy, method, methodArgs) -> switch (method.getName()) { + case "getLong" -> "oss_id".equals(methodArgs[0]) ? 88L : 42L; + case "getBoolean" -> allowDownload; + default -> throw new UnsupportedOperationException(method.getName()); + }); + return List.of(rowMapper.mapRow(resultSet, 0)); + } catch (SQLException error) { + throw new AssertionError(error); + } + } + + @Override + public int update(String sql, Object... args) { + updateSql.add(sql); + updateArgs.add(args); + return 1; + } + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java index 541be075..c100e42e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/broadcast/AihrBroadcastServiceTest.java @@ -51,7 +51,8 @@ class AihrBroadcastServiceTest { assertTrue(jdbcTemplate.queryForObjectSql.contains("m.status = 'PUBLISHED'")); assertTrue(jdbcTemplate.queryForObjectSql.contains("m.tenant_id = ?")); - assertArrayEquals(new Object[]{"tenant-a", 7L}, jdbcTemplate.queryForObjectArgs); + assertTrue(jdbcTemplate.queryForObjectSql.contains("m.visibility_mode = 'ALL'")); + assertArrayEquals(new Object[]{"tenant-a", 7L, 7L}, jdbcTemplate.queryForObjectArgs); } @Test @@ -114,7 +115,8 @@ class AihrBroadcastServiceTest { assertEquals("夏季高温作业提醒", context.title()); assertEquals("原始消息正文仅供受控模型上下文使用", context.content()); assertTrue(jdbcTemplate.querySql.contains("m.tenant_id = ? and m.id = ? and m.status = 'PUBLISHED'")); - assertArrayEquals(new Object[]{"tenant-a", 42L}, jdbcTemplate.queryArgs); + assertTrue(jdbcTemplate.querySql.contains("m.visibility_mode = 'ALL' or visible_recipient.user_id is not null")); + assertArrayEquals(new Object[]{"tenant-a", 7L, "tenant-a", 42L}, jdbcTemplate.queryArgs); } @Test @@ -132,11 +134,14 @@ class AihrBroadcastServiceTest { @Test void employeeAttachmentKeepsAllPerspectivesAndMarksOnlyTheMatchingDefault() throws Exception { + RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate(); + AihrBroadcastService service = new AihrBroadcastService(jdbcTemplate, RESOLVER); Method attachment = AihrBroadcastService.class.getDeclaredMethod( - "attachment", ResultSet.class, String.class); + "attachment", ResultSet.class, String.class, boolean.class, String.class); attachment.setAccessible(true); ResultSet row = RecordingJdbcTemplate.resultSet(Map.ofEntries( entry("attachment_id", 9L), + entry("tenant_id", "tenant-a"), entry("attachment_file_name", "制度.txt"), entry("attachment_file_size", 1024L), entry("attachment_content_type", "text/plain"), @@ -155,7 +160,7 @@ class AihrBroadcastServiceTest { )); AihrBroadcastDto.BroadcastAttachmentResponse response = - (AihrBroadcastDto.BroadcastAttachmentResponse) attachment.invoke(null, row, "finance"); + (AihrBroadcastDto.BroadcastAttachmentResponse) attachment.invoke(service, row, "finance", true, "internal"); assertEquals("finance", response.defaultPerspectiveCode()); assertEquals(List.of("财务", "人力"), response.perspectiveLabels()); @@ -256,6 +261,20 @@ class AihrBroadcastServiceTest { .isAnnotationPresent(Transactional.class)); } + @Test + void targetOnlyPublishRequiresAtLeastOneTargetBeforeWriting() { + RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate(); + AihrBroadcastService service = new AihrBroadcastService(jdbcTemplate, RESOLVER); + + ServiceException error = assertThrows(ServiceException.class, () -> service.publish( + new AihrBroadcastDto.PublishRequest( + "target-only-empty", "标题", "正文", "tenant-a", false, + "TARGET_ONLY", false, AihrBroadcastDto.BroadcastTargetRequest.empty(), null))); + + assertEquals(HttpStatus.BAD_REQUEST, error.getCode()); + assertTrue(jdbcTemplate.updateSql == null); + } + @Test void publishReplaysTheOriginalMessageForTheSameRequestIdAndPayload() { RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate(); @@ -365,7 +384,7 @@ class AihrBroadcastServiceTest { Method publish = AihrBroadcastController.class.getMethod( "publish", AihrBroadcastDto.PublishRequest.class); Constructor constructor = java.util.Arrays.stream(AihrBroadcastDto.PublishRequest.class.getDeclaredConstructors()) - .filter(candidate -> candidate.getParameterCount() == 7) + .filter(candidate -> candidate.getParameterCount() == 9) .findFirst() .orElseThrow(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/community/AihrFeedbackQuestionBridgeTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/community/AihrFeedbackQuestionBridgeTest.java new file mode 100644 index 00000000..bf2847a9 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/community/AihrFeedbackQuestionBridgeTest.java @@ -0,0 +1,154 @@ +package org.dromara.aihr.community; + +import org.dromara.aihr.community.AihrCommunityDto.AnswerFeedbackQuestionRequest; +import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.lang.reflect.Field; +import java.sql.ResultSet; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AihrFeedbackQuestionBridgeTest { + + @Test + @Tag("dev") + void duplicateSubmissionReplaysOnePendingQuestionWithoutPointsWrite() throws Exception { + BridgeJdbc jdbc = new BridgeJdbc(7L, true); + AihrCommunityService service = service(jdbc, 7L); + AnswerFeedbackQuestionRequest request = new AnswerFeedbackQuestionRequest("knowledge_req_1", "help-key-1"); + + QuestionResponse first = service.createQuestionFromAnswerFeedback(request); + QuestionResponse replay = service.createQuestionFromAnswerFeedback(request); + + assertEquals(first.id(), replay.id()); + assertEquals("PENDING", first.status()); + assertEquals(1, jdbc.questionInserts); + assertEquals(0, jdbc.pointsWrites); + } + + @Test + @Tag("dev") + void foreignKnowledgeRequestIsRejectedBeforeQuestionInsert() throws Exception { + BridgeJdbc jdbc = new BridgeJdbc(9L, true); + AihrCommunityService service = service(jdbc, 7L); + + assertThrows(ServiceException.class, () -> service.createQuestionFromAnswerFeedback( + new AnswerFeedbackQuestionRequest("knowledge_req_1", "help-key-2"))); + assertEquals(0, jdbc.questionInserts); + } + + @Test + @Tag("dev") + void requestWithoutNegativeFeedbackCannotBeBridged() throws Exception { + BridgeJdbc jdbc = new BridgeJdbc(7L, false); + AihrCommunityService service = service(jdbc, 7L); + + assertThrows(ServiceException.class, () -> service.createQuestionFromAnswerFeedback( + new AnswerFeedbackQuestionRequest("knowledge_req_1", "help-key-3"))); + assertEquals(0, jdbc.questionInserts); + } + + private static AihrCommunityService service(BridgeJdbc jdbc, long userId) throws Exception { + AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class); + when(resolver.current()).thenReturn(new AihrKnowledgePrincipal( + "000000", userId, "app_user", "staff-7", Set.of("employee"), Set.of("P1"), "mobile")); + AihrCommunityProperties properties = new AihrCommunityProperties(); + AihrIncentiveService incentive = mock(AihrIncentiveService.class); + AihrCommunityService service = new AihrCommunityService(jdbc, resolver, properties, incentive); + Field ready = AihrCommunityService.class.getDeclaredField("tablesReady"); + ready.setAccessible(true); + ready.set(service, true); + return service; + } + + private static final class BridgeJdbc extends JdbcTemplate { + private final long queryOwner; + private final boolean hasNegativeFeedback; + private boolean questionExists; + private int questionInserts; + private int pointsWrites; + + private BridgeJdbc(long queryOwner, boolean hasNegativeFeedback) { + this.queryOwner = queryOwner; + this.hasNegativeFeedback = hasNegativeFeedback; + } + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + if (sql.contains("aihr_knowledge_query_log")) { + long caller = ((Number) args[1]).longValue(); + return requiredType.cast(caller == queryOwner ? 1 : 0); + } + throw new AssertionError("Unexpected scalar query: " + sql); + } + + @Override + public int update(String sql, Object... args) { + if (sql.contains("insert into aihr_community_question")) { + questionExists = true; + questionInserts++; + return 1; + } + if (sql.contains("aihr_points_ledger")) { + pointsWrites++; + return 1; + } + throw new AssertionError("Unexpected update: " + sql); + } + + @Override + public List query(String sql, RowMapper mapper, Object... args) { + try { + if (sql.contains("from aihr_knowledge_answer_feedback")) { + if (!hasNegativeFeedback) return List.of(); + ResultSet rs = mock(ResultSet.class); + when(rs.getString("query_text")).thenReturn("地库积水一直没有处理,下一步应该怎么办?"); + when(rs.getString("category")).thenReturn("投诉处理"); + return List.of(mapper.mapRow(rs, 0)); + } + if (sql.contains("select id, source_request_id from aihr_community_question")) { + if (!questionExists) return List.of(); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(31L); + when(rs.getString("source_request_id")).thenReturn("knowledge_req_1"); + return List.of(mapper.mapRow(rs, 0)); + } + if (sql.contains("from aihr_community_question q where")) { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(31L); + when(rs.getLong("author_user_id")).thenReturn(7L); + when(rs.getString("author_name")).thenReturn("员工ff-7"); + when(rs.getString("question_type")).thenReturn("OPEN"); + when(rs.getString("category")).thenReturn("投诉处理"); + when(rs.getString("title")).thenReturn("地库积水一直没有处理"); + when(rs.getString("content")).thenReturn("地库积水一直没有处理,下一步应该怎么办?"); + when(rs.getInt("reward_points")).thenReturn(0); + when(rs.getString("reward_rule_code")).thenReturn("BEST_ANSWER"); + when(rs.getString("reward_account_type")).thenReturn("POINTS"); + when(rs.getString("question_status")).thenReturn("PENDING"); + when(rs.getInt("answer_count")).thenReturn(0); + when(rs.getTimestamp("create_time")).thenReturn(Timestamp.valueOf(LocalDateTime.now())); + return List.of(mapper.mapRow(rs, 0)); + } + if (sql.contains("from aihr_community_answer")) return List.of(); + } catch (Exception error) { + throw new AssertionError(error); + } + throw new AssertionError("Unexpected list query: " + sql); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java index b33695b9..8a8bd125 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeDataToolServiceTest.java @@ -30,15 +30,15 @@ class AihrKnowledgeDataToolServiceTest { @Test void myPracticeSummaryAlwaysUsesCurrentAppUserIdentity() { AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); - when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1"); - when(mobile.practiceHistory("employee-1")).thenReturn(List.of( + when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001"); + when(mobile.practiceHistory("13900000001")).thenReturn(List.of( new RecordResponse(9L, "session", "07-16 09:00", "张三", "投诉沟通", 88, "已完成", "表达清晰", "employee-1"))); var service = new AihrKnowledgeDataToolService(mobile); var result = service.execute(employee(), AihrKnowledgeDataToolService.MY_PRACTICE_SUMMARY); - verify(mobile).requireMobileIdentity("employee-1"); - verify(mobile).practiceHistory("employee-1"); + verify(mobile).requireMobilePhoneForOrganizationIdentity("employee-1"); + verify(mobile).practiceHistory("13900000001"); var data = (AihrKnowledgeDataToolService.MyPracticeSummary) result.data(); assertEquals(1, data.practiceCount()); assertEquals("投诉沟通", data.recentRecords().get(0).scene()); @@ -47,8 +47,8 @@ class AihrKnowledgeDataToolServiceTest { @Test void teamPracticeSummaryUsesResolvedSupervisorScope() { AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); - when(mobile.requireMobileIdentity("supervisor-1")).thenReturn("supervisor-1"); - when(mobile.requireMobileSupervisorIdentity("supervisor-1")).thenReturn("canonical-supervisor"); + when(mobile.requireMobilePhoneForOrganizationIdentity("supervisor-1")).thenReturn("13900000002"); + when(mobile.requireMobileSupervisorIdentity("13900000002")).thenReturn("canonical-supervisor"); when(mobile.practiceTeam("canonical-supervisor")) .thenReturn(new PracticeTeamResponse(List.of(), List.of(), List.of(), List.of(), List.of())); var service = new AihrKnowledgeDataToolService(mobile); @@ -57,15 +57,15 @@ class AihrKnowledgeDataToolServiceTest { service.execute(principal, AihrKnowledgeDataToolService.TEAM_PRACTICE_SUMMARY); - verify(mobile).requireMobileIdentity("supervisor-1"); - verify(mobile).requireMobileSupervisorIdentity("supervisor-1"); + verify(mobile).requireMobilePhoneForOrganizationIdentity("supervisor-1"); + verify(mobile).requireMobileSupervisorIdentity("13900000002"); verify(mobile).practiceTeam("canonical-supervisor"); } @Test void employeeCannotCallTeamPracticeSummary() { AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); - when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1"); + when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001"); var service = new AihrKnowledgeDataToolService(mobile); assertThrows(ServiceException.class, @@ -76,8 +76,8 @@ class AihrKnowledgeDataToolServiceTest { @Test void currentTasksUsesOnlyCurrentIdentityAndReturnsNoTaskExplicitly() { AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); - when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1"); - when(mobile.currentTasks("employee-1")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot( + when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001"); + when(mobile.currentTasks("13900000001")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot( List.of(), List.of())); var service = new AihrKnowledgeDataToolService(mobile); @@ -87,14 +87,14 @@ class AihrKnowledgeDataToolServiceTest { var data = (AihrKnowledgeDataToolService.CurrentTaskSummary) result.data(); assertEquals("NO_TASKS", data.state()); assertEquals(0, data.taskCount()); - verify(mobile).currentTasks("employee-1"); + verify(mobile).currentTasks("13900000001"); } @Test void currentTasksKeepsOnlyIncompleteOnboardingRows() { AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); - when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1"); - when(mobile.currentTasks("employee-1")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(List.of( + when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001"); + when(mobile.currentTasks("13900000001")).thenReturn(new AihrPracticeSeedService.CurrentTaskSnapshot(List.of( new OnboardTaskResponse(1L, "P1", "客服", null, null, "岗前学习", "待完成", "主管", null, "2026-07-30", null, null), new OnboardTaskResponse(2L, "P1", "客服", null, null, "已完成学习", "已完成", "主管", null, @@ -113,8 +113,8 @@ class AihrKnowledgeDataToolServiceTest { @Test void currentTasksNeverTurnsAnUnavailableSourceIntoNoTasks() { AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); - when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1"); - when(mobile.currentTasks("employee-1")) + when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001"); + when(mobile.currentTasks("13900000001")) .thenThrow(new ServiceException("当前任务数据读取失败,暂时无法确认待办,请稍后再试", HttpStatus.ERROR)); var service = new AihrKnowledgeDataToolService(mobile); @@ -129,7 +129,7 @@ class AihrKnowledgeDataToolServiceTest { @Test void unknownToolIsRejectedWithoutReadingTrainingData() { AihrMobileSeedService mobile = mock(AihrMobileSeedService.class); - when(mobile.requireMobileIdentity("employee-1")).thenReturn("employee-1"); + when(mobile.requireMobilePhoneForOrganizationIdentity("employee-1")).thenReturn("13900000001"); var service = new AihrKnowledgeDataToolService(mobile); assertThrows(ServiceException.class, () -> service.execute(employee(), "RAW_SQL")); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java index e62df427..3a043a76 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java @@ -681,7 +681,7 @@ class AihrKnowledgeQueryServiceTest { mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class)); assertEquals(null, service.authorizedResourceOssId(321L)); - verify(jdbc, never()).queryForList(anyString(), eq(Long.class), any(Object[].class)); + verify(jdbc).queryForList(anyString(), eq(Long.class), any(Object[].class)); } @Test diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java index 530b7b56..9dfa2fd1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgePrincipalResolverTest.java @@ -27,6 +27,7 @@ class AihrKnowledgePrincipalResolverTest { AihrOrgSyncService orgService = mock(AihrOrgSyncService.class); AihrMobileSeedService mobileSeedService = mock(AihrMobileSeedService.class); when(mobileSeedService.requireMobileIdentity("13900001111")).thenReturn("13900001111"); + when(mobileSeedService.requireMobileOrganizationIdentity("13900001111")).thenReturn("party-1"); when(orgService.activeByMobilePhone("13900001111")).thenReturn(List.of( new OrgPersonRow(1L, "P01", "", "", "party-1", "", "项目经理", "项目经理", "active", "2026-07-16") )); @@ -36,6 +37,7 @@ class AihrKnowledgePrincipalResolverTest { assertEquals(Set.of("employee", "supervisor"), principal.roles()); assertEquals(Set.of("P01"), principal.projectCodes()); + assertEquals("party-1", principal.extPartyId()); } @Test diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java index 94fc0261..83e386d1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java @@ -44,8 +44,17 @@ class AihrLearningContractTest { assertTrue(controller.contains("@GetMapping(\"/learning/matrix\")")); assertTrue(controller.contains("@GetMapping(\"/exams\")")); assertTrue(controller.contains("@PostMapping(\"/exams/{id}/submit\")")); + assertTrue(controller.contains("@PostMapping(\"/onboard/tasks/{id}/progress\")")); assertTrue(controller.contains("@PostMapping(\"/exams/supervisor/{id}/publish\")")); assertTrue(controller.contains("@GetMapping(\"/exams/supervisor/{id}/results\")")); + assertTrue(controller.contains("@PostMapping(\"/exams/supervisor/question-bank/draw\")")); + + String practiceController = Files.readString(projectPath( + "src/main/java/org/dromara/aihr/controller/AihrPracticeController.java")); + assertTrue(practiceController.contains("@GetMapping(\"/onboard-tasks/manage\")")); + assertTrue(practiceController.contains("@GetMapping(\"/onboard-tasks/manage/materials\")")); + assertTrue(practiceController.contains("@GetMapping(\"/onboard-tasks/manage/exams\")")); + assertTrue(practiceController.contains("@PostMapping(\"/onboard-tasks/manage/batch\")")); } @Test @@ -81,6 +90,50 @@ class AihrLearningContractTest { assertTrue(service.contains("publish_request_key = ?")); } + @Test + void approvedQuestionBankAndFormalLearningTaskContractsAreWired() throws Exception { + String examService = Files.readString(projectPath( + "src/main/java/org/dromara/aihr/learning/AihrExamService.java")); + assertTrue(examService.contains("enabled = 1 AND exam_enabled = 1")); + assertTrue(examService.contains("review_status = 'APPROVED'")); + assertTrue(examService.contains("aihr_exam_question_draw")); + assertTrue(examService.contains("source_question_id")); + assertTrue(examService.contains("return List.copyOf(result)")); + + String taskService = Files.readString(projectPath( + "src/main/java/org/dromara/aihr/learning/AihrLearningTaskService.java")); + String progressService = Files.readString(projectPath( + "src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java")); + assertTrue(taskService.contains("MANUAL_CONFIRM")); + assertTrue(taskService.contains("EXAM_PASS")); + assertTrue(taskService.contains("DAILY")); + assertTrue(taskService.contains("WEEKLY")); + assertTrue(taskService.contains("MONTHLY")); + assertTrue(taskService.contains("recurrence_series_key")); + assertTrue(taskService.contains("dispatch_request_key")); + assertTrue(progressService.contains("last_position_seconds")); + assertTrue(progressService.contains("检测到视频快进")); + assertTrue(taskService.contains("if (nextStart.isAfter(now)) continue")); + + String migration = Files.readString(projectPath( + "../../script/sql/update/aihr_20260802_learning_task_exam_bank_mysql8.sql")); + assertTrue(migration.contains("aihr_exam_question_draw")); + assertTrue(migration.contains("uk_aihr_onboard_task_dispatch")); + assertTrue(migration.contains("idx_aihr_onboard_task_due")); + assertTrue(migration.contains("started_time")); + assertTrue(migration.contains("last_viewed_time")); + assertTrue(migration.contains("media_duration_seconds")); + } + + @Test + void recurrenceCalculationUsesCalendarPeriods() { + java.time.LocalDateTime start = java.time.LocalDateTime.of(2026, 1, 31, 9, 0); + assertEquals(start.plusDays(1), AihrLearningTaskService.addRecurrence(start, "DAILY")); + assertEquals(start.plusWeeks(1), AihrLearningTaskService.addRecurrence(start, "WEEKLY")); + assertEquals(start.plusMonths(1), AihrLearningTaskService.addRecurrence(start, "MONTHLY")); + assertEquals(start, AihrLearningTaskService.addRecurrence(start, "ONCE")); + } + @Test void submitContractUsesAnExplicitAnswerListRatherThanAnAmbiguousRecordMap() { var answers = java.util.Arrays.stream(ExamSubmitRequest.class.getRecordComponents()) 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 8dea8b43..46fd0385 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 @@ -2,6 +2,7 @@ package org.dromara.aihr.service; import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; +import org.dromara.aihr.domain.AihrCaseDto.PublishRequest; import org.dromara.aihr.domain.AihrCaseDto.ReviewRequest; import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.service.ISysOssService; @@ -107,6 +108,30 @@ class AihrCaseServiceTest { assertEquals(upload.caseId(), jdbcTemplate.updateArgs[3]); } + @Test + @Tag("dev") + void formalExperiencePublishRequiresAuthorizationAndDeidentification() { + AihrSpeechService speechService = new AihrSpeechService(null, new ObjectMapper()) { + @Override + public Optional transcribe(byte[] audio, String filename, String contentType) { + return Optional.of("涓氫富鎶曡瘔宸插畬鎴愯窡杩?"); + } + }; + CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate(); + AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, null); + UploadResponse upload = service.upload(new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null); + + PublishRequest incomplete = new PublishRequest("访谈", "生活顾问", "experience-v1", "HR", "", false, true, + "2026-08-01", ""); + assertThrows(IllegalArgumentException.class, () -> service.publish(upload.caseId(), incomplete, null)); + + PublishRequest complete = new PublishRequest("访谈", "生活顾问", "experience-v1", "HR", "首批试点经验", + true, true, "2026-08-01", ""); + service.publish(upload.caseId(), complete, null); + assertTrue(jdbcTemplate.lastUpdateSql.contains("knowledge_status = 'PUBLISHED'")); + assertEquals("HR", jdbcTemplate.updateArgs[3]); + } + @Test @Tag("dev") void caseSummaryHashIsStableAndStoresNoPlaintextProvenance() { @@ -288,6 +313,7 @@ class AihrCaseServiceTest { private static class CaseJdbcTemplate extends JdbcTemplate { private Object[] updateArgs = new Object[0]; + private String lastUpdateSql = ""; private boolean failOnUpdate; @Override @@ -297,6 +323,7 @@ class AihrCaseServiceTest { @Override public int update(String sql, Object... args) { + this.lastUpdateSql = sql; if (failOnUpdate) { throw new IllegalStateException("simulated case record insert failure"); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java index fa01d328..98355013 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java @@ -768,6 +768,10 @@ public class AihrSopSeedServiceTest { assertTrue(code.contains("redirectOutput(ProcessBuilder.Redirect.DISCARD)")); assertTrue(code.contains("terminateProcess(process)")); assertTrue(code.contains("process.destroyForcibly();\n process.waitFor(5")); + assertTrue(code.contains("append(formatTime(offset)).append('-')")); + String locatorCode = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java")) + .replace("\r\n", "\n"); + assertTrue(locatorCode.contains("VIDEO_MARKER")); } @Test diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadCapacityHealthIndicatorTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadCapacityHealthIndicatorTest.java new file mode 100644 index 00000000..e293b6bb --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrUploadCapacityHealthIndicatorTest.java @@ -0,0 +1,48 @@ +package org.dromara.aihr.service; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.actuate.health.Status; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Tag("dev") +class AihrUploadCapacityHealthIndicatorTest { + + @TempDir + Path tempDir; + + @Test + void reportsQueuePolicyAndAvailableCapacity() { + var health = new AihrUploadCapacityHealthIndicator(tempDir.resolve("staging"), 1L).health(); + + assertEquals(Status.UP, health.getStatus()); + assertEquals(2, health.getDetails().get("workerConcurrency")); + assertEquals(500L * 1024 * 1024, health.getDetails().get("maxFileBytes")); + assertEquals(2L * 1024 * 1024 * 1024, health.getDetails().get("maxArchiveUnpackedBytes")); + assertEquals(72, health.getDetails().get("stagingRetentionHours")); + assertEquals(true, health.getDetails().get("capacityReady")); + } + + @Test + void failsHealthWhenFreeSpaceIsBelowTheConfiguredGate() { + var health = new AihrUploadCapacityHealthIndicator(tempDir, Long.MAX_VALUE).health(); + + assertEquals(Status.DOWN, health.getStatus()); + assertEquals(false, health.getDetails().get("capacityReady")); + } + + @Test + void failsHealthWhenTheConfiguredStagingPathIsAFile() throws Exception { + Path file = Files.writeString(tempDir.resolve("not-a-directory"), "fixture"); + + var health = new AihrUploadCapacityHealthIndicator(file, 1L).health(); + + assertEquals(Status.DOWN, health.getStatus()); + assertEquals(false, health.getDetails().get("stagingWritable")); + } +} diff --git a/backend/script/sql/update/aihr_20260802_broadcast_delivery_controls_mysql8.sql b/backend/script/sql/update/aihr_20260802_broadcast_delivery_controls_mysql8.sql new file mode 100644 index 00000000..caa561da --- /dev/null +++ b/backend/script/sql/update/aihr_20260802_broadcast_delivery_controls_mysql8.sql @@ -0,0 +1,93 @@ +-- Broadcast delivery controls: strict audience visibility, controlled downloads, +-- evidence-backed topic tags, and attachment access audit. +-- Idempotent; run after aihr_20260724_broadcast_attachment_mysql8.sql. + +SET @has_broadcast_visibility_mode := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message' + AND column_name = 'visibility_mode' +); +SET @broadcast_delivery_ddl := IF( + @has_broadcast_visibility_mode = 0, + 'ALTER TABLE aihr_broadcast_message ADD COLUMN visibility_mode varchar(20) NOT NULL DEFAULT ''ALL'' COMMENT ''ALL/TARGET_ONLY'' AFTER required_read', + 'SELECT 1' +); +PREPARE aihr_broadcast_delivery_stmt FROM @broadcast_delivery_ddl; EXECUTE aihr_broadcast_delivery_stmt; DEALLOCATE PREPARE aihr_broadcast_delivery_stmt; + +SET @has_broadcast_allow_download := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message' + AND column_name = 'allow_download' +); +SET @broadcast_delivery_ddl := IF( + @has_broadcast_allow_download = 0, + 'ALTER TABLE aihr_broadcast_message ADD COLUMN allow_download tinyint(1) NOT NULL DEFAULT 1 COMMENT ''附件是否允许下载'' AFTER visibility_mode', + 'SELECT 1' +); +PREPARE aihr_broadcast_delivery_stmt FROM @broadcast_delivery_ddl; EXECUTE aihr_broadcast_delivery_stmt; DEALLOCATE PREPARE aihr_broadcast_delivery_stmt; + +CREATE TABLE IF NOT EXISTS `aihr_broadcast_attachment_topic_tag` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主题标签ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `attachment_id` bigint NOT NULL COMMENT '公司文件ID', + `tag_name` varchar(30) NOT NULL COMMENT '主题标签', + `source_type` varchar(20) NOT NULL COMMENT 'AUTO/MANUAL', + `evidence_quote` varchar(200) NOT NULL COMMENT '经原文校验的短引用', + `created_by` bigint DEFAULT NULL COMMENT '人工修正账号;自动标签为空', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_broadcast_attachment_topic_tag` (`tenant_id`, `attachment_id`, `tag_name`), + KEY `idx_aihr_broadcast_attachment_topic_tag_attachment` (`tenant_id`, `attachment_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司文件主题标签及原文依据'; + +CREATE TABLE IF NOT EXISTS `aihr_broadcast_attachment_access_audit` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '访问审计ID', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `message_id` bigint NOT NULL COMMENT '消息ID', + `attachment_id` bigint NOT NULL COMMENT '公司文件ID', + `user_id` bigint NOT NULL COMMENT 'APP账号ID', + `access_type` varchar(20) NOT NULL COMMENT 'VIEW/DOWNLOAD', + `access_time` datetime NOT NULL COMMENT '访问时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_broadcast_attachment_access_user` (`tenant_id`, `user_id`, `access_time`), + KEY `idx_aihr_broadcast_attachment_access_file` (`tenant_id`, `attachment_id`, `access_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司文件在线查看与下载审计'; + +SET @broadcast_delivery_target_collation := ( + SELECT collation_name FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_info' AND column_name = 'tenant_id' + LIMIT 1 +); +SET @broadcast_delivery_target_collation := COALESCE(@broadcast_delivery_target_collation, @@collation_database); + +SET @broadcast_topic_tag_collation := ( + SELECT table_collation FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_attachment_topic_tag' + LIMIT 1 +); +SET @broadcast_delivery_ddl := IF( + @broadcast_topic_tag_collation = @broadcast_delivery_target_collation, + 'SELECT 1', + CONCAT('ALTER TABLE aihr_broadcast_attachment_topic_tag CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @broadcast_delivery_target_collation) +); +PREPARE aihr_broadcast_delivery_stmt FROM @broadcast_delivery_ddl; EXECUTE aihr_broadcast_delivery_stmt; DEALLOCATE PREPARE aihr_broadcast_delivery_stmt; + +SET @broadcast_access_audit_collation := ( + SELECT table_collation FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_attachment_access_audit' + LIMIT 1 +); +SET @broadcast_delivery_ddl := IF( + @broadcast_access_audit_collation = @broadcast_delivery_target_collation, + 'SELECT 1', + CONCAT('ALTER TABLE aihr_broadcast_attachment_access_audit CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @broadcast_delivery_target_collation) +); +PREPARE aihr_broadcast_delivery_stmt FROM @broadcast_delivery_ddl; EXECUTE aihr_broadcast_delivery_stmt; DEALLOCATE PREPARE aihr_broadcast_delivery_stmt; + +SET @has_broadcast_visibility_mode := NULL; +SET @has_broadcast_allow_download := NULL; +SET @broadcast_delivery_target_collation := NULL; +SET @broadcast_topic_tag_collation := NULL; +SET @broadcast_access_audit_collation := NULL; +SET @broadcast_delivery_ddl := NULL; diff --git a/backend/script/sql/update/aihr_20260802_case_experience_governance_mysql8.sql b/backend/script/sql/update/aihr_20260802_case_experience_governance_mysql8.sql new file mode 100644 index 00000000..568bdea5 --- /dev/null +++ b/backend/script/sql/update/aihr_20260802_case_experience_governance_mysql8.sql @@ -0,0 +1,95 @@ +-- Separate reviewed experience candidates from the formal employee-visible library. +-- This migration adds metadata only; it does not approve or publish existing rows. + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'knowledge_status'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `knowledge_status` varchar(20) NOT NULL DEFAULT ''CANDIDATE'' COMMENT ''CANDIDATE/PUBLISHED/OFFLINE'' AFTER `summary`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'source_type'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `source_type` varchar(40) DEFAULT NULL COMMENT ''经验来源类型'' AFTER `knowledge_status`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'applicable_positions'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `applicable_positions` varchar(500) DEFAULT NULL COMMENT ''适用岗位'' AFTER `source_type`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'content_version'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `content_version` varchar(50) DEFAULT NULL COMMENT ''正式经验版本'' AFTER `applicable_positions`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'source_authorized'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `source_authorized` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''来源已授权'' AFTER `content_version`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'deidentified'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `deidentified` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''内容已脱敏'' AFTER `source_authorized`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'effective_from'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `effective_from` date DEFAULT NULL COMMENT ''生效日期'' AFTER `deidentified`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'effective_to'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `effective_to` date DEFAULT NULL COMMENT ''失效日期'' AFTER `effective_from`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'release_owner'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `release_owner` varchar(80) DEFAULT NULL COMMENT ''正式发布负责人'' AFTER `effective_to`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'release_note'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `release_note` varchar(1000) DEFAULT NULL COMMENT ''正式发布说明'' AFTER `release_owner`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'released_by'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `released_by` bigint DEFAULT NULL COMMENT ''正式发布操作人'' AFTER `release_note`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'released_time'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `released_time` datetime DEFAULT NULL COMMENT ''正式发布时间'' AFTER `released_by`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_ddl := IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' AND column_name = 'retire_reason'), + 'ALTER TABLE `aihr_case_record` ADD COLUMN `retire_reason` varchar(500) DEFAULT NULL COMMENT ''下线原因'' AFTER `released_time`', 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; EXECUTE aihr_case_governance_stmt; DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'aihr_case_record' + AND index_name = 'idx_aihr_case_knowledge_status' +); +SET @aihr_case_governance_ddl := IF(@aihr_case_governance_index_exists = 0, + 'ALTER TABLE `aihr_case_record` ADD KEY `idx_aihr_case_knowledge_status` (`tenant_id`, `knowledge_status`, `effective_from`, `effective_to`)', + 'SELECT 1'); +PREPARE aihr_case_governance_stmt FROM @aihr_case_governance_ddl; +EXECUTE aihr_case_governance_stmt; +DEALLOCATE PREPARE aihr_case_governance_stmt; + +SET @aihr_case_governance_index_exists := NULL; +SET @aihr_case_governance_ddl := NULL; diff --git a/backend/script/sql/update/aihr_20260802_feedback_question_bridge_mysql8.sql b/backend/script/sql/update/aihr_20260802_feedback_question_bridge_mysql8.sql new file mode 100644 index 00000000..735f7794 --- /dev/null +++ b/backend/script/sql/update/aihr_20260802_feedback_question_bridge_mysql8.sql @@ -0,0 +1,114 @@ +-- Link an authenticated negative knowledge-answer feedback to one moderated question-board item. +-- Additive and idempotent; existing questions keep no source linkage. + +SET @aihr_column_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_answer_feedback' + AND column_name = 'request_id' +); +SET @aihr_ddl := IF(@aihr_column_exists = 0, + 'ALTER TABLE `aihr_knowledge_answer_feedback` ADD COLUMN `request_id` varchar(64) DEFAULT NULL COMMENT ''知识查询请求ID'' AFTER `review_id`', + 'SELECT 1'); +PREPARE aihr_stmt FROM @aihr_ddl; +EXECUTE aihr_stmt; +DEALLOCATE PREPARE aihr_stmt; + +SET @aihr_column_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_answer_feedback' + AND column_name = 'submitter_user_id' +); +SET @aihr_ddl := IF(@aihr_column_exists = 0, + 'ALTER TABLE `aihr_knowledge_answer_feedback` ADD COLUMN `submitter_user_id` bigint DEFAULT NULL COMMENT ''反馈提交用户ID;历史记录为空'' AFTER `tenant_id`', + 'SELECT 1'); +PREPARE aihr_stmt FROM @aihr_ddl; +EXECUTE aihr_stmt; +DEALLOCATE PREPARE aihr_stmt; + +SET @aihr_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_answer_feedback' + AND index_name = 'uk_aihr_answer_feedback_pair' +); +SET @aihr_ddl := IF(@aihr_index_exists > 0, + 'ALTER TABLE `aihr_knowledge_answer_feedback` DROP INDEX `uk_aihr_answer_feedback_pair`', + 'SELECT 1'); +PREPARE aihr_stmt FROM @aihr_ddl; +EXECUTE aihr_stmt; +DEALLOCATE PREPARE aihr_stmt; + +SET @aihr_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_answer_feedback' + AND index_name = 'uk_aihr_answer_feedback_user_request' +); +SET @aihr_ddl := IF(@aihr_index_exists = 0, + 'ALTER TABLE `aihr_knowledge_answer_feedback` ADD UNIQUE KEY `uk_aihr_answer_feedback_user_request` (`tenant_id`, `submitter_user_id`, `request_id`, `fragment_id`)', + 'SELECT 1'); +PREPARE aihr_stmt FROM @aihr_ddl; +EXECUTE aihr_stmt; +DEALLOCATE PREPARE aihr_stmt; + +SET @aihr_column_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'aihr_community_question' + AND column_name = 'source_type' +); +SET @aihr_ddl := IF(@aihr_column_exists = 0, + 'ALTER TABLE `aihr_community_question` ADD COLUMN `source_type` varchar(32) DEFAULT NULL COMMENT ''可信来源类型'' AFTER `question_type`', + 'SELECT 1'); +PREPARE aihr_stmt FROM @aihr_ddl; +EXECUTE aihr_stmt; +DEALLOCATE PREPARE aihr_stmt; + +SET @aihr_column_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'aihr_community_question' + AND column_name = 'source_request_id' +); +SET @aihr_ddl := IF(@aihr_column_exists = 0, + 'ALTER TABLE `aihr_community_question` ADD COLUMN `source_request_id` varchar(64) DEFAULT NULL COMMENT ''知识查询请求ID'' AFTER `source_type`', + 'SELECT 1'); +PREPARE aihr_stmt FROM @aihr_ddl; +EXECUTE aihr_stmt; +DEALLOCATE PREPARE aihr_stmt; + +SET @aihr_column_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'aihr_community_question' + AND column_name = 'submit_idempotency_key' +); +SET @aihr_ddl := IF(@aihr_column_exists = 0, + 'ALTER TABLE `aihr_community_question` ADD COLUMN `submit_idempotency_key` varchar(80) DEFAULT NULL COMMENT ''提交幂等键'' AFTER `source_request_id`', + 'SELECT 1'); +PREPARE aihr_stmt FROM @aihr_ddl; +EXECUTE aihr_stmt; +DEALLOCATE PREPARE aihr_stmt; + +SET @aihr_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'aihr_community_question' + AND index_name = 'uk_aihr_community_question_source' +); +SET @aihr_ddl := IF(@aihr_index_exists = 0, + 'ALTER TABLE `aihr_community_question` ADD UNIQUE KEY `uk_aihr_community_question_source` (`tenant_id`, `author_user_id`, `source_type`, `source_request_id`)', + 'SELECT 1'); +PREPARE aihr_stmt FROM @aihr_ddl; +EXECUTE aihr_stmt; +DEALLOCATE PREPARE aihr_stmt; + +SET @aihr_index_exists := ( + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'aihr_community_question' + AND index_name = 'uk_aihr_community_question_submit_key' +); +SET @aihr_ddl := IF(@aihr_index_exists = 0, + 'ALTER TABLE `aihr_community_question` ADD UNIQUE KEY `uk_aihr_community_question_submit_key` (`tenant_id`, `author_user_id`, `submit_idempotency_key`)', + 'SELECT 1'); +PREPARE aihr_stmt FROM @aihr_ddl; +EXECUTE aihr_stmt; +DEALLOCATE PREPARE aihr_stmt; + +SET @aihr_column_exists := NULL; +SET @aihr_index_exists := NULL; +SET @aihr_ddl := NULL; diff --git a/backend/script/sql/update/aihr_20260802_learning_task_exam_bank_mysql8.sql b/backend/script/sql/update/aihr_20260802_learning_task_exam_bank_mysql8.sql new file mode 100644 index 00000000..9d10b931 --- /dev/null +++ b/backend/script/sql/update/aihr_20260802_learning_task_exam_bank_mysql8.sql @@ -0,0 +1,113 @@ +-- Learning task traceability and approved question-bank exam assembly. +-- Existing questions remain DRAFT and exam_disabled; migration never fabricates approval. +SET @aihr_schema := DATABASE(); + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='exam_format')=0, + 'ALTER TABLE aihr_learning_question ADD COLUMN exam_format varchar(20) DEFAULT NULL COMMENT ''single/multiple/judge/short'' AFTER sort_order', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='exam_options_json')=0, + 'ALTER TABLE aihr_learning_question ADD COLUMN exam_options_json text COMMENT ''考试选项JSON'' AFTER exam_format', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='exam_answers_json')=0, + 'ALTER TABLE aihr_learning_question ADD COLUMN exam_answers_json text COMMENT ''考试答案JSON'' AFTER exam_options_json', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='exam_explanation')=0, + 'ALTER TABLE aihr_learning_question ADD COLUMN exam_explanation varchar(1000) DEFAULT NULL COMMENT ''考试解析'' AFTER exam_answers_json', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='exam_enabled')=0, + 'ALTER TABLE aihr_learning_question ADD COLUMN exam_enabled tinyint NOT NULL DEFAULT 0 COMMENT ''是否可用于考试组卷'' AFTER exam_explanation', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='review_status')=0, + 'ALTER TABLE aihr_learning_question ADD COLUMN review_status varchar(20) NOT NULL DEFAULT ''DRAFT'' COMMENT ''DRAFT/APPROVED/REJECTED'' AFTER exam_enabled', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='review_note')=0, + 'ALTER TABLE aihr_learning_question ADD COLUMN review_note varchar(500) DEFAULT NULL COMMENT ''审核说明'' AFTER review_status', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='reviewed_by')=0, + 'ALTER TABLE aihr_learning_question ADD COLUMN reviewed_by varchar(100) DEFAULT NULL COMMENT ''审核人'' AFTER review_note', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND column_name='reviewed_time')=0, + 'ALTER TABLE aihr_learning_question ADD COLUMN reviewed_time datetime DEFAULT NULL COMMENT ''审核时间'' AFTER reviewed_by', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=@aihr_schema AND table_name='aihr_learning_question' AND index_name='idx_aihr_learning_question_exam_pick')=0, + 'ALTER TABLE aihr_learning_question ADD KEY idx_aihr_learning_question_exam_pick (tenant_id, position_code, review_status, exam_enabled, enabled, id)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS aihr_exam_question_draw ( + id bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + tenant_id varchar(20) NOT NULL DEFAULT '000000' COMMENT '租户编号', + owner_ext_party_id varchar(100) NOT NULL COMMENT '主管组织身份', + request_key varchar(100) NOT NULL COMMENT '抽题幂等键', + config_hash char(64) NOT NULL COMMENT '岗位、题型和题数配置哈希', + question_snapshot_json longtext NOT NULL COMMENT '审核题目完整快照', + create_time datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_aihr_exam_question_draw_request (tenant_id, owner_ext_party_id, request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='已审核题库随机组卷快照'; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_exam_question' AND column_name='source_question_id')=0, + 'ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_question_id bigint DEFAULT NULL COMMENT ''题库来源主键'' AFTER sort_order', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_exam_question' AND column_name='source_question_code')=0, + 'ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_question_code varchar(120) DEFAULT NULL COMMENT ''题库来源编码'' AFTER source_question_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_exam_question' AND column_name='source_content_version')=0, + 'ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_content_version varchar(30) DEFAULT NULL COMMENT ''题库来源版本'' AFTER source_question_code', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_exam_question' AND column_name='source_content_hash')=0, + 'ALTER TABLE aihr_onboard_exam_question ADD COLUMN source_content_hash char(64) DEFAULT NULL COMMENT ''题库来源哈希'' AFTER source_content_version', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='material_type')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN material_type varchar(20) DEFAULT NULL COMMENT ''DOCUMENT/VIDEO/COURSE/SOP'' AFTER course_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='material_attachment_id')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN material_attachment_id bigint DEFAULT NULL COMMENT ''知识附件ID,受控读取'' AFTER material_type', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='material_title')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN material_title varchar(200) DEFAULT NULL COMMENT ''材料标题快照'' AFTER material_attachment_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='material_version')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN material_version varchar(50) DEFAULT NULL COMMENT ''材料版本快照'' AFTER material_title', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='progress_percent')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN progress_percent int NOT NULL DEFAULT 0 COMMENT ''0-100学习进度'' AFTER material_version', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='completion_rule')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN completion_rule varchar(30) NOT NULL DEFAULT ''MANUAL_CONFIRM'' COMMENT ''MANUAL_CONFIRM/EXAM_PASS'' AFTER progress_percent', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='exam_id')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN exam_id bigint DEFAULT NULL COMMENT ''完成学习后关联考试'' AFTER completion_rule', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='recurrence_type')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN recurrence_type varchar(20) NOT NULL DEFAULT ''ONCE'' COMMENT ''ONCE/DAILY/WEEKLY/MONTHLY'' AFTER exam_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='dispatch_request_key')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN dispatch_request_key varchar(100) DEFAULT NULL COMMENT ''派发幂等键'' AFTER recurrence_type', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='recurrence_series_key')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN recurrence_series_key varchar(100) DEFAULT NULL COMMENT ''周期任务系列键'' AFTER recurrence_type', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND index_name='uk_aihr_onboard_task_dispatch')=0, + 'ALTER TABLE aihr_onboard_task ADD UNIQUE KEY uk_aihr_onboard_task_dispatch (tenant_id, ext_party_id, dispatch_request_key)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND index_name='idx_aihr_onboard_task_due')=0, + 'ALTER TABLE aihr_onboard_task ADD KEY idx_aihr_onboard_task_due (tenant_id, ext_party_id, status, due_time)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- Learning material viewing must be auditable and monotonic. These fields are +-- server-owned; the client only reports observed playback/read progress. +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='started_time')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN started_time datetime DEFAULT NULL COMMENT ''材料首次打开时间'' AFTER progress_percent', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='last_viewed_time')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN last_viewed_time datetime DEFAULT NULL COMMENT ''材料最近查看时间'' AFTER started_time', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='last_position_seconds')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN last_position_seconds int NOT NULL DEFAULT 0 COMMENT ''视频最近播放位置'' AFTER last_viewed_time', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl := IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=@aihr_schema AND table_name='aihr_onboard_task' AND column_name='media_duration_seconds')=0, + 'ALTER TABLE aihr_onboard_task ADD COLUMN media_duration_seconds int DEFAULT NULL COMMENT ''视频时长快照'' AFTER last_position_seconds', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 9009eb54..8928f4c7 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -14,16 +14,16 @@ | 正式试点数据导出 | `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?, voiceProfile?:{role,voice?,speed?,emotion?,dialect?}, practiceContext?:{sessionId,turnIndex,role:'customer'}}`,`dialect` 仅接受 `mandarin/cantonese/sichuanese`,非法值返回业务码 `400`;旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | ASR 按供应商分流:SiliconFlow 等 OpenAI audio 兼容服务继续走 `/audio/transcriptions` multipart;阿里云百炼 `dashscope/qianwen + qwen3-asr-flash` 走工作空间 `/compatible-mode/v1/chat/completions`,将短音频编码为 Base64 `input_audio` 并读取 `choices[0].message.content`,请求上限仍由本接口收窄为 5MB。生产 TTS 优先阿里 `qwen-audio-3.0-tts-flash`,旧 SiliconFlow CosyVoice2 保留兼容;按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速;dialect 作为语气提示传给支持表现力提示词的 TTS,不含克隆,也不等同于方言正式验收。`practiceContext` 仅允许已认证 APP 员工把本次服务端生成的业主 TTS 绑定到本人当前或刚完成会话的指定原话术回合;服务端重新校验租户、员工身份、回合和文本,不接受员工端传 OSS ID 或任意角色。设备语音降级按方言选择语言:粤语使用 `zh-HK`,四川话无法由设备语音可靠模拟时明确提示用户转用服务端语音或文字,不伪装成普通话。模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;移动端 OSS 下载只允许经本人或主管项目范围验证的 APP 身份,后台账号若需回放必须使用未来独立、受审计的管理端契约。 | | 实时对练 Beta(WebRTC) | `POST /api/train/practice/realtime/sdp`、`GET /realtime/personas`、`POST /realtime/session`、`POST /realtime/tools/invoke` | H5 浏览器及 App-Plus renderjs WebView 内的 WebRTC 直连阿里云(音频不过服务端),后端仅代理 SDP 交换(3 次/分/账号,sessionId 仅作日志关联且限字符)。人设为服务端 `AihrRealtimePersonaRegistry` 白名单:`owner-calm` 业主·常规(默认)、`owner-impatient` 业主·急躁、`digital-mentor` 数字师傅;人设绑定默认音色,客户端只选不编。`session.update`(instructions/音色/turn_detection/tools 声明)由 `/realtime/session` 组装下发,客户端原样转发;personaId 空白回落默认人设;会话记录写 Redis 30min(成功工具调用滑动续期),创建限流 6 次/分/账号。`digital-mentor` 声明 `search_knowledge` 工具:模型 function calling 命中(`response.function_call_arguments.done`)时客户端回传 `/realtime/tools/invoke`,服务端按登录态 + Redis 会话归属 + 人设工具白名单三重校验后走 `searchAuthorized` 浅层检索(不生成 LLM 答案、不落 `aihr_agent_run`;`source=realtime` 跳过 SOP 评审与知识缺口写入),output 拼接 ≤3 条片段并截断 1200 字;工具按账号限流 20 次/分,无授权/无结果/超时一律返回软着陆文案让模型如实作答,不中断语音会话。实时 Beta 不计分、不持久化训练数据;H5 与 App-Plus 均支持,App-Plus 在 renderjs 视图层执行媒体/WebRTC 并通过逻辑层复用受认证 SDP 与工具接口;需 APP 员工登录态,不启用模型 `enable_search`。配置 `AIHR_QWEN_REALTIME_ENDPOINT` / `AIHR_QWEN_REALTIME_API_KEY` / `AIHR_QWEN_REALTIME_MODEL`(模型白名单 `qwen3.5-omni-(flash|plus)-realtime`),密钥只放 `.env.local` 或外部环境变量。 | -| 案例沉淀 `/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 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、入库、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | +| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`POST /records/{caseId}/publish`、`POST /records/{caseId}/retire`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;`/curate` 只进入经验候选池,`/review` 只完成业务审核,`/publish` 才能在来源授权、脱敏、适用岗位、负责人、版本和生效期齐全时进入正式经验库,`/retire` 要求填写下线原因并立即停止员工召回;APP 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、候选、审核、发布、下线、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `knowledge_status=PUBLISHED` 且处于生效期的 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 问·数字师傅工作 Agent `/pages/user/sop/index` | `POST /api/aihr/agent/messages`、`POST /api/aihr/agent/messages/media`、`POST /api/aihr/agent/actions/{draftId}/confirm`、`POST /api/aihr/agent/actions/{draftId}/dismiss` | 移动端文字、ASR 转写和图片/视频统一进入 Agent。服务端根据消息规划 `KNOWLEDGE_QA/RESOURCE_DELIVERY/LIVE_MY_WORK/LIVE_TEAM_WORK/PRACTICE_COACHING/CAPTURE_FACT/MEDIA_UNDERSTANDING/WEB_RESEARCH/CLARIFY/SOCIAL`,再由固定策略校验当前登录身份和工具权限;客户端不提交 `toolCode`,模型也不能自由指定身份、SQL、URL 或任意工具。知识、训练概况、当前待办、确认式记忆、媒体分析和全网查询均复用现有领域服务;普通图片问答只做本次视觉分析,不要求先具备知识空间,用户明确问现场制度/流程时才校验授权空间并补充 SOP。全网查询先返回 `NEEDS_INPUT`,只有用户明确同意后才调用外部服务。统一响应使用结构化 `status/sourceSummary/citations/resources/actionDraft/clarification`;按钮不从答案文本推断。写入动作只接受 30 分钟有效的不透明 `draftId`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 | | SOP 知识底层服务 `/knowledge/sop` 与旧客户端兼容 | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`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` | Agent 按规划结果调用该层;管理端和旧客户端接口继续兼容。文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最新 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回、跨租户或非在职员工均拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。岗位 SOP 的项目范围复用当前 APP 已验证主体的 `projectCodes`,不得以管理端组织快照的模糊/别名查询作为后备。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED`。详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。 | | 工作助手确认式统一采集 `/pages/user/sop/index`、`/pages/user/assistant/memories` | Agent 主入口返回 `actionDraft`,确认/忽略走 `/api/aihr/agent/actions/{draftId}/confirm|dismiss`;旧接口 `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm|dismiss` 继续兼容;确认记录读写使用 `GET /api/aihr/personal-assistant/assistant-captures?limit=50`、`POST /assistant-captures/{id}/status`、`GET /assistant-captures/{id}/status-history`、`GET /assistant-captures/{id}/source`,旧项目记录继续使用 `/api/aihr/service-memories` | 文字、ASR 转写和现场媒体复用同一候选检测。项目工作必须从 `/api/aihr/mobile/me` 返回的项目名称列表选择当前项目,内部 `projectCode` 仍由服务端按当前登录主体授权收窄;个人笔记可以不绑定项目。显式“记一下”生成 `ASSISTANT_CAPTURE/DRAFT`,系统建议 `ATTENDANCE/INSPECTION/SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE`。确认必须带 `expectedVersion + idempotencyKey + saveScope(PRIVATE|COMPANY)`;PRIVATE 仅本人可见,COMPANY 写入 PENDING 待流转。状态更新只允许 `RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID`,幂等键不得跨记录或目标状态复用。来源文件重新鉴权交付;正式外部接收端未配置前不得显示已送达。 | | 今日工作成果 `/pages/user/work-results/index`、`/pages/supervisor/work-results/index` | 员工 `POST /api/aihr/work-results/mine/generate?projectCode=...&workDate=YYYY-MM-DD`;主管 `GET /api/aihr/work-results/project?projectCode=...&workDate=YYYY-MM-DD` | 按员工 + 项目 + Asia/Shanghai 自然日确定性聚合已确认记录;两页默认当天并可按日期查看历史成果,客户端禁止选择未来日期。内容未变化时重复生成不增加版本,记录或状态变化后版本递增。主管只可查看本人在目标项目具备主管身份的团队结果;APP 登录身份同时兼容组织快照中的外部人员 ID 与手机号,普通员工或跨项目请求返回 403。外部接口未接通时只显示本地状态和 PENDING,不伪造派单或考勤同步成功 | -| 银城大喇叭 | 员工 `GET /api/aihr/broadcast/unread-count`、`GET /api/aihr/broadcast/messages?pageNum=&pageSize=`、`GET /api/aihr/broadcast/messages/{id}`、`POST /api/aihr/broadcast/messages/{id}/read`、`GET /api/aihr/broadcast/attachments/{id}/content`;员工详情可经统一 `POST /api/knowledge/query` 的 `broadcastMessageId` 发起文字追问;管理 `GET /api/aihr/broadcast/admin/messages`、`POST /api/aihr/broadcast/admin/attachments`、`GET /api/aihr/broadcast/admin/attachments/{id}`、`POST /api/aihr/broadcast/messages`、`POST /api/aihr/broadcast/messages/{id}/withdraw` | 发布支持全员或按部门/岗位/人员定向、必读、单个公司文件与异步提炼。文件支持 txt/md/PDF/Word/Excel/PPT,100MB 内;先受控写入 OSS,再异步解析并复用现有模型生成摘要,只有 `READY` 且未绑定、属于当前租户和上传人的文件才可随消息发布。文件提炼完成后异步生成生活顾问、保洁、保安、工程维修、财务、人力、运营、审计风控、管理层中有原文依据的岗位解读;员工详情附件返回 `insightStatus/defaultPerspectiveCode/perspectiveLabels/perspectives`,每项依据仅含原文段号与已校验的短引用,不返回提取全文或原始 OSS 地址。`defaultPerspectiveCode` 由服务端按当前 APP 手机号精确匹配在职岗位,客户端不能指定;所有有权限员工仍可查看并切换全部已生成视角。`PENDING/PARTIAL/FAILED` 均不阻断摘要、原文件下载、消息发布或追问。下载和追问每次重新校验当前 APP 在职身份、租户和消息状态,追问上下文由服务端拼接消息正文与提取文本,客户端仍只传 `broadcastMessageId`。定向目标在界面称“定向提醒”,用于必读与范围提示,不改变全租户公开频道的可见性。发布、阅读和撤回保持既有幂等与审计约束;管理接口只允许 `superadmin` 或 `hr_operator`。当前仍不包含消息修订、撤回后补推或短信/电话强触达。 | +| 银城大喇叭 | 员工 `GET /api/aihr/broadcast/unread-count`、`GET /api/aihr/broadcast/messages?pageNum=&pageSize=`、`GET /api/aihr/broadcast/messages/{id}`、`POST /api/aihr/broadcast/messages/{id}/read`、`GET /api/aihr/broadcast/attachments/{id}/view`、`GET /api/aihr/broadcast/attachments/{id}/content`;员工详情可经统一 `POST /api/knowledge/query` 的 `broadcastMessageId` 发起文字追问;管理 `GET /api/aihr/broadcast/admin/messages`、`POST /api/aihr/broadcast/admin/attachments`、`GET /api/aihr/broadcast/admin/attachments/{id}`、`PUT /api/aihr/broadcast/admin/attachments/{id}/topic-tags`、`POST /api/aihr/broadcast/messages`、`POST /api/aihr/broadcast/messages/{id}/withdraw` | 发布支持 `visibilityMode=ALL/TARGET_ONLY`、必读、单个公司文件与异步提炼;`TARGET_ONLY` 必须有目标,发布时冻结匹配收件人,列表、详情、未读、阅读、附件和追问统一复核可见性,非目标员工按不存在处理。`ALL` 下目标只作定向提醒。附件支持 txt/md/PDF/Word/Excel/PPT,100MB 内;只有 `READY` 且未绑定、属于当前租户和上传人的文件才可发布。`allowDownload=false` 时员工端不返回下载入口,后端 `/content` 也拒绝;`/view` 返回受控提取文本并记录独立查看审计,客户端叠加含内部账号和分钟的动态水印。水印用于追溯,不等于阻止截屏或泄露;下载副本水印仍须按文件格式另行实现和验收。主题标签仅由固定关键词规则或人工修正产生,均保存可在原文逐字找到的短依据,且绝不参与授权。文件岗位解读继续返回有原文依据的 `insightStatus/defaultPerspectiveCode/perspectiveLabels/perspectives`,不返回原始 OSS 地址。下载、查看和追问每次重新校验 APP 在职身份、租户、消息状态和可见范围;客户端追问仍只传 `broadcastMessageId`。发布、阅读、文件访问和撤回保持幂等与审计约束;管理接口只允许 `superadmin` 或 `hr_operator`。当前仍不包含消息修订、撤回后补推或短信/电话强触达。 | | 员工直通车 `/pages/user/direct/index`、管理端 `/content/direct` | 员工 `GET /api/aihr/direct/channels`、`POST /api/aihr/direct/feedback`、`GET /api/aihr/direct/mine`、`GET /api/aihr/direct/mine/{id}`;处理端 `GET /api/aihr/direct/admin/feedback`、`POST /api/aihr/direct/admin/feedback/{id}/reply`;处理人配置 `GET /api/aihr/direct/admin/handlers`、`GET /api/aihr/direct/admin/handlers/{channelCode}/candidates`、`POST /api/aihr/direct/admin/handlers/{channelCode}`、`POST /api/aihr/direct/admin/handlers/{channelCode}/remove` | 员工可选择总裁、财务、人力、审计、运营并点对点提交;反馈内容支持语音转文字输入(复用 `/api/ai/asr`;文字为主路径,录音不可用或权限失败仅提示改文字输入)。总裁/审计默认匿名;匿名仅表示业务处理界面不显示提交人,系统仍保存内部账号和姓名快照供本人查询、幂等与审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 仅处理各自频道,`superadmin` 可处理全部;列表、详情和回复均由服务端按角色收窄。只有 `superadmin` 可维护五通道处理人;候选人由服务端限定为当前租户已启用的后台用户并排除 APP 用户、超级管理员和已绑定用户,写请求必须回传当前 `tenantId` 防止租户上下文漂移;建议每个通道至少配置 2 人,但系统不自动绑定账号。一个反馈只允许一次正式回复,员工可在“我的反馈”查看状态和回复;当前不扩展为工单 SLA、转派或多轮聊天。 | | 成果投稿(当前页面名“工作上报”)`/pages/user/report/index` | `POST /api/aihr/work-report/organize`、`POST /attachment`、`POST /reports`、`GET /reports/mine`;主管/运营另有列表和审核接口 | 只承载 CASE/VIDEO/SOP/KNOWLEDGE 四类投稿。会话式页面、无状态整理、附件、幂等正式提交和历史状态已部署;审核通过不自动入知识库。当前整理服务不读取图片/视频内容,只把附件名称作为不可信元数据;不得与日常工作记录或“今日工作成果”混用 | -| 资料处理 `/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/processing-tasks/{attachmentId}/retry` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度。零片段媒体不再记为完成或永久“等待解析”,而是保留为可重试失败;管理端可从原 OSS 文件重新入队,无需用户重复上传;不提供浏览器目录选择或服务端目录导入入口 | +| 资料处理 `/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/processing-tasks/{attachmentId}/retry`;运维 `GET /actuator/health/aihrUploadCapacity` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(单实例并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度。单文件上限 500MiB、ZIP 解压总量 2GiB、失败暂存保留 72 小时;生产暂存盘通过 `AIHR_UPLOAD_STAGING` 指定,默认可用空间健康门禁为 10GiB。零片段媒体不再记为完成或永久“等待解析”,而是保留为可重试失败;管理端可从原 OSS 文件重新入队,无需用户重复上传;不提供浏览器目录选择或服务端目录导入入口。约 600 名用户不等于 600 并发,峰值、P95、错误率和压测模型仍须另行签认 | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee/employee_project_assignment` 快照,分页参数使用 `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 写入(含 `replaceExisting=false`)遇到脱敏手机号一律默认拒绝,避免空值覆盖本地登录身份;覆盖写入遇到员工被跳过、手机号不完整、疑似乱码或同项目重复关系时同样默认拒绝;`allowPartialReplace=true` 只能在身份字段契约一致且异常逐项确认后使用,不能绕过主体身份漂移或重复成员关系,此时脱敏员工的本地既有有效手机号会被保留而非清空。相同员工可由多条有效项目分配展开为多个项目成员行,唯一约束为 `tenant_id + project_code + ext_party_id`。2026-07-25 生产安全状态:既有快照 3417 行、2938 人在职、3392 个手机号映射;姓名已按稳定 `employee_id` 定向补齐,未执行全量覆盖。当前上游返回 3424 名员工、3398 个脱敏手机号和 1 条重复项目成员关系,优先字段 `employee_number` 仅匹配既有主体 `7/3417`,稳定 `employee_id` 匹配 `3417/3417`;全量覆盖仍只允许 dry-run,日常岗位变化使用下方增量接口。 | | 组织人员增量同步 | `POST /api/aihr/org/sync-changes` | 主动拉取上游 `/sync/changes?resource_type=employee`,即使事件没有进入 outbox、`changed_fields` 为空,也会按 `resource_id` 回源 `/employees/{id}`,并结合任职快照只替换受影响员工。主体固定使用稳定 `employee.id`;上游只返回脱敏手机号时保留本地既有有效手机号,上游显式清空手机号时同步清除本地值(对应移动端登录身份失效),手机号字段整体缺失则拒绝写入;范围字段明确不一致视为迁出,写入时删除该员工本地旧记录,范围字段缺失则保守跳过不删。任职快照失败、资源 ID 不一致或项目关系重复时拒绝写入。首次调用必须传 `sinceTime`,后续可传响应的 `nextCursor`;仍须先 `dryRun=true` 再以相同起点执行 `dryRun=false`,且只处理当前租户有效组织绑定范围。 | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;移动专用接口固定服务端默认租户,客户端不传也不能选择 `tenantId`。dev 默认 `aihr.sms.verification-enabled=false`,移动 APP 客户端可只提交手机号,便于本地自动化;只有同时设置后端 `AIHR_SMS_VERIFICATION_ENABLED=true` 与前端 `VITE_SMS_VERIFICATION_ENABLED=true` 才恢复验证码控件和校验。启用后,验证码按“默认租户 + 手机号”隔离,并在手机号粒度的分布式锁内完成校验:仅匹配成功才消费,输错不会作废原验证码。生产默认保持验证码校验开启。移动端令牌只关联 `app_user`;若同一手机号存在任何后台/系统账号,一律拒绝登录;手机号完全不存在时才自动注册 `app_user`。固定码仍仅用于显式开启验证后的联调;生产固定码必须同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true`。 | @@ -32,8 +32,10 @@ | 移动端员工训练与主管复盘闭环 | 员工复用 `POST /api/train/practice/start`、`/turn`、`/finish`,查询 `GET /api/aihr/mobile/practice/history`、`/practice/mistakes`、`/profile`;训练完成后提交 `POST /api/aihr/mobile/practice/satisfaction`;主管查询 `GET /api/aihr/mobile/practice/team`、`/practice/alerts`、`/practice/reviews`、`/practice/reviews/{id}`,标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`,指派 `POST /api/aihr/mobile/practice/assignments` | 员工端登录后带 `Authorization` 与 `clientid` 调用;服务端固定身份和 `mode=mobile` 后才写入 `aihr_practice_session` 并进入员工/主管统计。员工历史、错题、画像、成长进度、晋升证据和满意度均只使用当前认证手机号经服务端验证得到的受控历史别名;后台内容/任务运营只通过独立 `/api/train/practice/**` 契约,不得向 `/api/aihr/mobile/**` 传入外部 ID 读取个人或主管数据。主管 `/practice/team` 的范围只含已验证外部 ID;历史训练或任务若存的是同一员工的旧手机号,只在该手机号能无碰撞规范化到团队外部 ID 时才展示/统计,避免把另一人的外部 ID 误并入团队。管理端 `mode=preview` 仅是运营调试记录,不能伪装成员工训练、满意度、复盘、成长或试点证据。满意度接口只接受本人已完成训练的 1-5 分,意见脱敏后落库,未填写不补默认值。错题本按员工本人聚合低分/红线回合,并关联已有 `retry` assignment,不伪造错题结论。主管 `/practice/team` 在同一项目权限范围内额外返回 `mistakes` 聚合,按场景/归因统计次数、影响人数、平均分和最近发生时间;普通员工返回“无主管权限”。主管接口以后端当前登录手机号先解析为在职组织外部 ID,仅允许岗位为“主管/项目经理”的账号,并按租户和项目范围返回真实成员、全状态训练记录及非 daily 专项。复盘标记只允许首次 `待复盘 -> 已复盘` 创建后续专项,并发重复提交幂等;可带 `incentivePoint` 写入贡献度。每日三题正式只对 `hire_date` 在当前日期前三个月内的在职员工派发,且只会从已发布、已启用、风险审核完成的同岗位场景题库取题;选择时优先补最低未覆盖的成长层/能力项,仅作为训练推荐,不自动调整职级或形成硬性解锁。没有入职日期时不使用训练次数推断,开发 Demo 的旧回退只有在 `dev/local` profile 且由 `aihr.practice.allow-legacy-daily-drill-fallback` 显式开启时生效,生产 profile 强制关闭。 | | 移动端候选人闭环 | 页面拆为 `/pages/candidate/index/index`、`/interview/index`、`/materials/index`、`/progress/index`、`/study/index`;面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish` 和 `GET /records`;资料 `POST/GET /api/aihr/mobile/candidate/materials`;预习 `POST /api/knowledge/search`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;首页只按当前手机号对应的真实面试记录和最新资料状态分流,不读取公开 home seed。APP 候选人身份以后端登录手机号为准,前端 `candidateId/candidateName` 只作非 APP 场景兼容参数;面试支持文字作答和复用 `/api/ai/asr` 的录音转文字/选音频,`/answer` 可带可选 `answerAudioOssId`,服务端只接受当前候选人名下处于 `staged/bound` 状态的音频并将 `ossId` 写入问题快照,不向客户端暴露原始 OSS URL;`/answer` 与 `/finish` 还会校验当前 APP 手机号与启动会话的候选人 ID 一致,未知或他人会话直接拒绝;面试拉题/评分走真实模型优先;资料写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 审核后进度页同步三态;岗前预习查询正式 SOP,不兜前端示例答案 | | 每日题反馈与常见难题 | `POST /api/aihr/mobile/practice/assignments/{id}/feedback`、`GET /api/aihr/mobile/practice/difficulties`、`POST /api/aihr/mobile/practice/difficulties/assign` | 反馈区分题目有用性与答案正确性;仅认证 APP 主管可按其项目范围聚合岗位、时间、次数、人数和均分,并基于难题创建专项。管理端不得调用移动主管接口;后台人工任务只走受角色保护的 `/api/train/practice/assignments/manage/**`。 | -| 岗位考试 | 员工 `GET /api/aihr/mobile/exams`、`GET /exams/{id}`、`POST /exams/{id}/submit`;主管 `GET/POST /exams/supervisor`、`GET/PUT /exams/supervisor/{id}`、`POST /exams/supervisor/{id}/publish`、`GET /exams/supervisor/{id}/results` | 主管按项目范围选择真实员工、组卷、发布和查成绩;员工只读本人目标考试。发布和提交接受请求 ID 并保持幂等,不得重复生成成绩或奖励 | +| 岗位考试 | 员工 `GET /api/aihr/mobile/exams`、`GET /exams/{id}`、`POST /exams/{id}/submit`;主管 `GET/POST /exams/supervisor`、`GET/PUT /exams/supervisor/{id}`、`POST /exams/supervisor/{id}/publish`、`GET /exams/supervisor/{id}/results`、`POST /exams/supervisor/question-bank/draw` | 主管按项目范围选择真实员工、组卷、发布和查成绩;员工只读本人目标考试。题库只有 `APPROVED + enabled + exam_enabled` 的题目可抽取,抽题请求 ID 会冻结岗位/题型/题数与题目快照,配置变更复用同一 ID 会拒绝;保存试卷时服务端重新加载快照并保存来源题目 ID、版本和 hash,后续题库编辑不改写已发布试卷。发布和提交接受请求 ID 并保持幂等,不得重复生成成绩或奖励。 | +| 正式学习中心与任务 | 管理端 `/api/train/practice/onboard-tasks/manage`、`/materials`、`/exams`、`/batch`;员工 `GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete` | 管理端按租户、项目、规范岗位和在职外部 ID 派发正式学习任务;材料只允许当前租户 `READY` 附件并保存标题/版本快照,任务支持一次/每日/每周/月度周期、起效时间、截止时间、进度和到期提醒。`MANUAL_CONFIRM` 才能人工确认,`EXAM_PASS` 只能由关联考试通过完成;重复请求 ID 返回逐人 `DUPLICATE`,配置不一致拒绝。员工只读本人已生效任务,原文继续经受保护资源接口鉴权;“每日三题”仍留在练习入口,不混入学习中心主任务列表。 | | 开放问题榜与积分 | 移动端 `/api/aihr/community/questions`、`questions/{id}`、`questions/{id}/answers`、`questions/{id}/best-answer`、`points/me`;后台 `/api/aihr/question-admin/**`、`/api/aihr/incentive-admin/rules` | 问题、答案、最佳答案和奖励全程按租户/身份授权;重复选优不重复入账。奖励只有积分与学习学分,不代表现金或提现 | +| 未解决反馈转人工 | `POST /api/aihr/community/questions/from-answer-feedback`,仅提交 `requestId`、`idempotencyKey` | 服务端重读本人 `down` 反馈和查询审计,问题固定进入 `PENDING`;重复请求返回同一问题,不自动公开或奖励,客户端不得提交重构答案、引用或身份 | | 全网 AI | 员工 `GET /api/aihr/web-ai/capabilities`、`POST /api/aihr/web-ai/query`;提供方管理 `/api/aihr/web-search/providers/**` | 与企业知识问答分入口和来源。仅接受公网 HTTPS 提供方,地址或密钥变更后必须重新连接测试;未配置/未启用时返回明确不可用,不生成假答案 | ## 数字师傅 Agent 接入 diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index fc42ceaf..c789cd34 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -20,19 +20,29 @@ 客户在 2026-07-17 反馈的主要回退和新增项已经进入当前生产包,包括语音输入与关键内容播报、总结卡图片、全局三级字号、主管学习端、姓名多选与快捷截止日期、每日题轮换和题目反馈、常见难题、开放问题榜与积分、完整岗位考试、全网 AI 独立入口、多租户知识空间管理,以及问师傅短期多轮、追问改写和受控原文件/视频交付。 +当前工作树另有一批 **2026-08-02 已实现并完成本地验证、尚未部署** 的增量:大喇叭 `ALL/TARGET_ONLY` 真正可见性、受控在线查看与动态水印、下载门禁和带原文依据的主题标签;问师傅未解决反馈确认转问题榜;正式学习任务、审核题库随机组卷及考试关联;案例经验“候选→审核→发布→下线”;原生异常恢复回归,以及资料暂存盘容量健康门禁。以下生产结论不包含这批增量,生产仍以本文件第 5 节的已验证发布证据为准。 + 完成状态必须分层表达: | 层级 | 当前状态 | 证据与边界 | |---|---|---| -| 已实现 | 是 | 已有阶段一主功能,公司消息 R1—R3 已实现直通车与公司文件提炼闭环;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) | -| 本地自动化/API 验证 | 是 | 训练运营定向后端测试 `76` 项;公司消息新增后端 `30` 项、管理端 `2` 项、移动端 `2` 项;管理端/H5 构建通过 | -| 本地浏览器/视觉验证 | 部分 | 已检查管理端目录/任务、两轮动态对练、岗位不匹配拦截、直通处理和文件消息;线上已以认证态员工完成公司消息回归。Android 标准基座生产 ASR 已通过,训练完整写入和原生全项验收仍未完成 | -| 已部署 | 是 | 2026-07-25 完整包已包含公司消息 R1—R3、训练内容运营、M2 求助事件和租户组织治理;2026-07-29 又发布资料处理媒体恢复的 schema、管理端和后端,移动 H5 未重发 | +| 已实现 | 是 | 已有阶段一主功能;当前工作树另实现大喇叭投放控制、反馈转人工、正式学习任务/随机组卷、案例经验治理、原生异常恢复和上传容量门禁;接口事实见 [API_INTEGRATION.md](API_INTEGRATION.md) | +| 本地自动化/API 验证 | 是 | 当前工作树管理端生产构建、移动端类型检查和 `216/216` 单测通过;后端 Surefire 共 `107` 份报告、`838` 项测试,失败/错误/跳过均为 `0`;迁移静态清单通过,Docker/MySQL 幂等执行未完成 | +| 本地浏览器/视觉验证 | 部分 | `390×844` 已实际点击员工“今日/练/问”、大喇叭登录门禁和直通车登录门禁,无横向溢出或重叠;管理端认证后的案例发布弹窗因后端/MySQL 未运行而未验证。既有生产公司消息回归和 Android ASR 证据不外推到本批增量 | +| 已部署 | 部分 | 2026-07-25 至 2026-07-31 的既有生产基线已部署;当前工作树的四个 `aihr_20260802_*` 迁移及对应后端、管理端和移动端增量均未部署 | | 生产基础验证 | 是 | 服务 `active`;根站、租户接口和移动首页 API 正常;媒体重试列/索引契约通过,4 个历史零片段任务均恢复为已完成且有片段/向量。当前未执行包含移动 H5 的完整包匹配 | | 生产完整业务验收 | 部分 | 公司消息已完成认证态全员文件消息和直通车闭环,单台 Android 标准基座已完成生产 ASR;训练写入、主管复盘、定向人群正反例、正式处理人绑定、其他真机媒体与严格试点窗口仍未完成 | “已部署”不等于“正式试点通过”。当前可以确认生产包和基础运行状态,不能把未执行的正式账号、真机和业务窗口验收写成已完成。 +### 2026-08-02 当前工作树未发布增量 + +- 大喇叭:`TARGET_ONLY` 冻结目标收件人并在所有读取链路执行同一可见性校验;在线查看、下载策略、主题标签和文件访问审计由正式迁移补齐。下载副本按文件格式加水印仍未实现,动态页面水印不得宣传为防泄漏。 +- 问题榜与学习:本人 `down` 反馈可经再次确认创建待审核问题;学习任务保存材料版本、周期和考试完成规则,随机组卷只使用审核启用题库并冻结题目来源。正式题库内容和任务规则仍须业务审核。 +- 经验治理:案例审核与正式发布拆分,只有来源授权、已脱敏、适用岗位、版本、负责人和生效期完整的 `PUBLISHED` 经验才向员工开放;下线立即停止召回。 +- 运行门禁:资料暂存盘默认要求至少 10GiB 可用空间,生产 HTTP 请求启用 P50/P95/P99 指标;约 600 名用户不是 600 并发,压测模型和通过阈值仍待业务、实施和运维签认。 +- 发布边界:四个正式迁移已纳入 Runbook;本地 Docker 未运行,未执行 MySQL 首次/重复迁移、完整后端认证 API、签名 APK、真机或生产验证。 + ### 2026-07-29 资料处理媒体恢复发布边界 历史图片/视频在视觉模型不接受原始分辨率/编码,或模型、ASR、关键帧链路没有返回可检索文字时,旧逻辑会留下状态 0、片段 0 的附件,页面长期显示“等待解析”,却没有从原件重新入队的入口。当前实现先对图片纠正方向、缩放和重编码;异步队列只有生成至少一个片段才进入完成态,零片段明确保持可重试失败。管理端可调用 `POST /api/knowledge/doc/processing-tasks/{attachmentId}/retry`,服务端按当前租户从原 OSS 对象重新暂存,通过附件状态 CAS 防止重复入队,并用 `source_attach_id` 关联新队列条目。 @@ -80,15 +90,18 @@ | 问师傅语音与播报 | 录音、选择音频、ASR、TTS/设备语音降级恢复;答案、总结卡、每日题、点评等关键内容可播报 | 生产短音频 ASR 已切换为 `dashscope/qwen3-asr-flash`,并在一台 Android HBuilderX 标准基座完成“按住说话”请求;自定义基座权限、弱网、方言、多机型和完整播报仍未验收 | | 问师傅连续对话与资料 | 认证用户短期会话保留 30 分钟/最新 6 轮,追问改写并路由 `QA/FILE/VIDEO/DATA_TOOL`;原文件/视频只从当前命中且实时授权的附件交付,无资源不造假链接 | 公司文件消息的认证下载与追问已生产验证;一般知识命中文件/视频的真机下载仍待复验 | | 问页面有效会话区与消息追问 | 会话开始后收起占位辅助区;大喇叭详情仅以消息 ID 受控进入短会话,消息/知识回答不伪造待办 | 生产认证态发布—阅读—文件追问已验证;390×844 最终截图和真机体验仍待补 | -| 银城大喇叭 | 全员/定向、必读、未读/已读、发布/撤回、单文件异步提炼、摘要、受控下载和文件追问已部署 | 全员文件消息闭环已生产认证验证,测试消息已撤回;仍不含修订、补推和外部强触达,定向正反例待补 | +| 银城大喇叭 | 生产已有全员公开、定向提醒、必读、单文件提炼和受控追问;当前工作树新增 `TARGET_ONLY` 真正可见性、受控在线查看/动态水印、下载门禁和带依据主题标签 | 全员文件消息闭环已生产认证验证;当前工作树增量未部署,目标可见性正反例、在线查看审计和禁止下载仍待迁移后验证;下载副本水印、修订、补推和外部强触达仍不包含 | | 员工直通车 | 已部署高管、财务、人力、审计、运营五个点对点频道,业务匿名、分角色收件和一次正式回复 | 生产已验证员工提交—审计处理—员工查看;五类角色已建,真实处理人员仍须按业务名单绑定 | | 图片/视频与总结卡图片 | 问师傅附件仅用于本次现场分析;总结卡可下载 PNG | 生产包已部署;iOS/Android H5 下载和相册/文件权限未复验 | | 录音归属、提交无反馈、筛选错位 | 账号/页面代次隔离、旧请求回写保护、明确加载/失败状态和窄屏布局已修 | 本地双账号/交互测试通过;生产认证态未重放 | | 每日题变化与题目反馈 | 五岗位题库按日期、岗位和员工身份轮换;每日题/专项支持有用/无用反馈 | 生产 schema 和包已部署;正式题库内容审核与业务样本待补 | | 常见难题与一键派专项 | 管理端按岗位、时间、次数、人数、均分排序并可生成专项 | 生产页面已打开复核;正式团队样本待补 | | 指派姓名、多选、快捷日期 | 项目内在职人员姓名选择,最多 100 人,四档快捷日期,幂等批量提交和逐人结果 | 本地真实 API 已验证;生产大团队、时区边界和权限正反例待补 | -| 开放问题榜与奖励 | 提问、回答、审核、最佳答案、积分规则和积分/学习学分双账户已实现;重复选优不重复发奖 | 生产页面/schema 已验证;正式运营规则和认证写链路待验收;不是现金、不可提现 | -| 岗位入职考试 | 主管选择岗位/项目/员工、组卷、发布、截止日期和成绩;员工答题、播报、提交、逐题结果;提交幂等 | 本地发布与 100 分提交/重放通过;生产正式账号未重放 | +| 开放问题榜与奖励 | 提问、回答、审核、最佳答案和积分/学习学分已实现;当前工作树新增本人未解决反馈确认转待审核问题,创建和选优均幂等 | 既有生产页面/schema 已验证;反馈转人工增量未部署,正式运营规则和认证写链路仍待验收;不是现金、不可提现 | +| 正式学习中心 | 当前工作树支持按人员派发带材料版本、周期、截止时间和完成规则的正式任务,`EXAM_PASS` 与关联考试结果绑定;每日三题保持独立 | 未部署、未执行 MySQL 迁移或正式账号验证;材料、任务规则和提醒内容仍须业务审核,不包含外部强触达 | +| 岗位入职考试 | 既有固定试卷、发布、作答和幂等提交保留;当前工作树新增从审核启用题库幂等随机抽题,并在发布时冻结题目来源、答案和解析 | 本地服务测试通过;新增题库/抽题迁移未在 MySQL 执行,生产正式账号未重放 | +| 案例经验治理 | 当前工作树将候选、业务审核、正式发布和下线拆分;员工仅可读取当前生效的 `PUBLISHED` 经验 | 未部署、未执行迁移或正式内容审核;不能用模型分数、投稿审核或测试数据冒充正式经验资产 | +| 资料容量门禁 | 当前工作树新增暂存盘可写/可用空间健康指标,默认门槛 10GiB,并开启生产 HTTP P50/P95/P99 指标 | 只完成本地单测和文档契约;峰值并发、流量模型、P95 和错误率目标未签认,未做隔离环境压测 | | 问 AI(全网) | 与企业问师傅分入口、接口、来源和提示;只允许已测试启用的公网 HTTPS 提供方 | 管理页已生产打开;未配置正式提供方密钥时明确不可用,不生成假答案 | | 多租户知识空间 | 空间、授权、调用应用、统一问答、审计、限流和受控数据工具已实现 | 生产页面/schema 已验证;银城/美途正式空间、授权矩阵、令牌和内容迁移仍需业务确认后执行 | diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 9babf98b..45e95947 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -108,6 +108,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_case_media_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_case_learning_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260802_case_experience_governance_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260714_practice_five_dimensions_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260715_practice_session_evidence_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_practice_m2_help_event_mysql8.sql @@ -124,6 +125,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_practice_growth_curriculum_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260802_learning_task_exam_bank_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql @@ -142,6 +144,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260725_broadcast_multi_role_insight_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260802_broadcast_delivery_controls_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql @@ -152,6 +155,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260802_data_quality_observability_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260803_data_quality_structure_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260804_data_quality_feedback_loop_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260802_feedback_question_bridge_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260805_knowledge_attachment_immutability_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260806_data_quality_extraction_evidence_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260807_data_quality_semantic_conflict_mysql8.sql @@ -173,6 +177,8 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_perso 顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;正式知识来源治理迁移只创建附件级权威类型、生命周期、版本、生效期、适用范围和原文件哈希契约,不自动批准任何历史附件。移动端政策问答只使用有效的 `FORMAL_POLICY + APPROVED` 注册项,治理表缺失、来源未批准、已失效、版本或哈希为空时均按无正式依据关闭。银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 +`aihr_20260802_broadcast_delivery_controls_mysql8.sql` 必须位于公司文件及多岗位解读之后、消息追问上下文之前。它增加 `ALL/TARGET_ONLY` 可见模式、下载控制、带原文依据的主题标签表和文件访问审计表,不自动修改既有消息受众、下载策略或标签。 + ## 迁移后只读核验 执行后只读检查结构和内置内容,不修改业务数据: @@ -189,7 +195,7 @@ WHERE table_schema = DATABASE() 'aihr_knowledge_space_grant', 'aihr_knowledge_app', 'aihr_knowledge_app_space', 'aihr_knowledge_category', 'aihr_knowledge_query_log', 'aihr_knowledge_admin_audit', 'aihr_knowledge_conversation', 'aihr_agent_run', 'aihr_practice_question_feedback', - 'aihr_onboard_exam', 'aihr_onboard_exam_target', 'aihr_onboard_exam_question', + 'aihr_onboard_exam', 'aihr_onboard_exam_target', 'aihr_onboard_exam_question', 'aihr_exam_question_draw', 'aihr_onboard_exam_attempt', 'aihr_onboard_exam_answer', 'aihr_web_search_provider', 'aihr_web_ai_secret', 'aihr_web_ai_query_audit', 'aihr_community_question', 'aihr_community_answer', @@ -214,7 +220,7 @@ SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND ( - (table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url', 'learning_points', 'supervisor_comment')) + (table_name = 'aihr_case_record' AND column_name IN ('media_oss_id', 'media_url', 'learning_points', 'supervisor_comment', 'knowledge_status', 'source_type', 'applicable_positions', 'content_version', 'source_authorized', 'deidentified', 'effective_from', 'effective_to', 'release_owner', 'release_note', 'released_by', 'released_time', 'retire_reason')) OR (table_name = 'aihr_practice_session' AND column_name IN ('dim_task_completion', 'dim_response_timeliness', 'response_latency_ms', 'rubric_code', 'rubric_version', 'rubric_snapshot_json', 'annotations_json', 'review_advice', 'incentive_point', 'satisfaction_score', 'satisfaction_comment', 'position_snapshot', 'project_type_snapshot', 'growth_level', 'competency_code', 'collaboration_positions', 'curriculum_version', 'growth_confirmation_level', 'growth_confirmed_by', 'growth_confirmed_time')) OR (table_name = 'aihr_practice_scenario' AND column_name IN ('content_version', 'content_hash', 'growth_level', 'competency_code', 'collaboration_positions', 'review_status', 'risk_level', 'curriculum_version', 'reviewer', 'reviewer_user_id', 'reviewed_time', 'second_reviewer', 'second_reviewer_user_id', 'second_reviewed_time')) OR (table_name = 'aihr_practice_assignment' AND column_name IN ('score', 'score_mode', 'due_date', 'request_key', 'content_version', 'question_snapshot', 'reference_answer_snapshot', 'content_hash')) @@ -227,7 +233,9 @@ WHERE table_schema = DATABASE() OR (table_name = 'aihr_interview_result' AND column_name IN ('questions_json', 'reviewed_score', 'review_status', 'reviewer', 'review_note', 'reviewed_time')) OR (table_name = 'aihr_position_responsibility' AND column_name IN ('position_code', 'responsibility_text', 'sop_id', 'sop_code')) OR (table_name = 'aihr_sop_applicability' AND column_name IN ('sop_id', 'sop_code', 'project_type', 'industry', 'org_scope')) - OR (table_name = 'aihr_onboard_task' AND column_name IN ('ext_party_id', 'position_code', 'task_type', 'status', 'assign_by')) + OR (table_name = 'aihr_learning_question' AND column_name IN ('exam_format', 'exam_options_json', 'exam_answers_json', 'exam_explanation', 'exam_enabled', 'review_status', 'review_note', 'reviewed_by', 'reviewed_time')) + OR (table_name = 'aihr_onboard_exam_question' AND column_name IN ('source_question_id', 'source_question_code', 'source_content_version', 'source_content_hash')) + OR (table_name = 'aihr_onboard_task' AND column_name IN ('ext_party_id', 'position_code', 'task_type', 'status', 'assign_by', 'material_type', 'material_attachment_id', 'material_title', 'material_version', 'progress_percent', 'completion_rule', 'exam_id', 'recurrence_type', 'recurrence_series_key', 'dispatch_request_key')) OR (table_name = 'aihr_qualification_gate' AND column_name IN ('ext_party_id', 'position_code', 'cert_id', 'passed', 'valid_thru')) OR (table_name = 'aihr_work_report' AND column_name IN ('request_key', 'request_hash')) OR (table_name = 'aihr_broadcast_message' AND column_name IN ('publish_request_key', 'publish_request_hash', 'withdraw_reason', 'attachment_id')) @@ -312,6 +320,8 @@ ORDER BY d.dimension_code; 本批完整发布要求 `release-preflight` 的 `64/64` 必需表与列契约均存在,新增项包括 `aihr_practice_help_event`、`aihr_direct_feedback`、`aihr_broadcast_attachment`、`aihr_broadcast_message.attachment_id` 和 `aihr_agent_run`;资料重试还必须通过 `remote_media_reprocess_contract=1/1`,即 `source_attach_id` 与 `(tenant_id, source_attach_id, id)` 索引同时存在。此前 `57/57`、`62/62` 都只代表较窄的历史发布清单。2026-07-25 生产只读预检已达到 `64/64`,后端 JAR/AIHR 模块与发布源一致,并确认公司文件附件契约 `15/15` 及多岗位解读队列索引存在;这只证明迁移与产物已发布,不替代 Agent、训练或试点业务验收。Agent 审计字段应为 `14/14`,且表中不得出现问题、答案或附件列。工作上报幂等字段为 `2/2`,大喇叭发布/撤回审计字段为 `3/3`,其发布幂等索引为 `0 | tenant_id,published_by,publish_request_key | 0`,M1 必读/目标载荷字段契约为 `2/2`,两张目标表字段契约为 `16/16`,定向规则与对象快照唯一约束分别为 `4/4`、`3/3`,公司文件附件字段和消息绑定字段契约为 `15/15`,并应存在 `(insight_status,status,update_time)` 解读队列索引;公司消息追问会话字段为 `1/1`,工作助手新增字段为 `8/8`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。私有资料配置只应返回 `ruoyi-personal`、私有策略和 `configured` 状态,不显示访问密钥。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;结构、索引和内置内容通过只证明 schema/seed 与发布产物一致,不证明员工直达反馈、文件提炼、多岗位解读、员工确认记忆、完整个人知识库或严格试点已经完成业务验收。 +新增学习任务/题库迁移后,预检还必须确认 `aihr_exam_question_draw`、题库审核字段、试卷来源快照字段,以及任务材料/周期/幂等字段和唯一索引;这只证明结构可用,不代表题库内容已完成业务审核或正式试点验收。 + ## 迁移后应用回归 1. 重启 `wygj-aihr.service`,确认状态为 `active`。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 644b5ab8..160dbcd7 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -255,7 +255,7 @@ docker compose -f docker-compose.dev.yml stop - 本地组织 seed 带演示手机号;从 `aihr_org_snapshot` 按角色查询测试账号,分别验证员工端岗位识别和主管端项目范围,不在文档保存具体号码 - 生产移动端回归同样由执行者从 `aihr_org_snapshot` 只读选择账号,不等待人工提供手机号:限定目标岗位、`employment_status='active'`、11 位 `person_phone`,并确认手机号与 `ext_party_id` 双向唯一。账号值只注入当前浏览器进程,不输出到终端、截图、报告或仓库。试点固定码开启时,登录顺序仍为先请求 `/resource/sms/code`、再提交固定码;直接提交固定码会得到 `Captcha invalid`,不应误判为账号或功能故障。 - SOP 知识库支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx` 上传到 MinIO 后解析入库,接口为 `POST /api/knowledge/doc/upload`,单文件上限 100MB;管理端上传请求单独放宽到 180s,PDF 解析/归类/向量化较慢时不要改全局 axios 超时。 -- 资料处理中心异步接口支持文档、图片、视频和 `.zip` 单文件 ≤500MB;生产 Spring/Undertow multipart 配置必须保持 `max-file-size=500MB`、`max-request-size=520MB`(源文件 `backend/ruoyi-admin/src/main/resources/application-prod.yml`,线上外置 `/opt/wygj/config/application-prod.yml`),`TikaKnowledgeDocumentParser.MAX_INPUT_BYTES` 必须同步保持 500MiB,修改后重启 `wygj-aihr.service`。ZIP 最多 1000 个子文件、单个受支持子文件 ≤500MB、解压总量 ≤2GB,不支持嵌套 ZIP。同步 SOP 上传和公司文件仍按各自的 100MB 契约执行。 +- 资料处理中心异步接口支持文档、图片、视频和 `.zip` 单文件 ≤500MB;生产 Spring/Undertow multipart 配置必须保持 `max-file-size=500MB`、`max-request-size=520MB`(源文件 `backend/ruoyi-admin/src/main/resources/application-prod.yml`,线上外置 `/opt/wygj/config/application-prod.yml`),`TikaKnowledgeDocumentParser.MAX_INPUT_BYTES` 必须同步保持 500MiB,修改后重启 `wygj-aihr.service`。ZIP 最多 1000 个子文件、单个受支持子文件 ≤500MB、解压总量 ≤2GB,不支持嵌套 ZIP。同步 SOP 上传和公司文件仍按各自的 100MB 契约执行。异步 worker 单实例并发固定为 2;暂存盘通过 `AIHR_UPLOAD_STAGING` 配置,`/actuator/health/aihrUploadCapacity` 默认以 10GiB 可用空间为健康门禁,低磁盘必须先扩容,完整口径见 `docs/KNOWLEDGE_PLATFORM_RUNBOOK.md`。 - 视频(`.mp4/.mov/.avi/.mkv/.webm/.m4v`,≤500MB、≤60 分钟)只走资料处理中心批量导入(异步队列):ffmpeg 抽音轨分段调 asr 转写 + 抽关键帧调 vision 提取画面文字,合并后归类切片入库,片段带 `[mm:ss]` 时间戳;**依赖服务器安装 ffmpeg/ffprobe**(macOS `brew install ffmpeg`,Linux `apt install ffmpeg`) - 服务端目录导入接口为 `POST /api/knowledge/doc/import-local-task`,只读取导入根目录下的相对目录,后台逐文件复用同一上传解析链路;`POST /api/knowledge/doc/import-tasks/{id}/cancel` 可取消运行中任务;`POST /api/knowledge/doc/import-local` 保留为同步调试接口。这组接口仅供运维/调试,不在资料处理页提供入口。 - 启用 `aihr_model_config.category='vector'` 的模型配置后,上传会同步写入片段 embedding,并尽力 upsert 到 Qdrant;模型配置页可查看 Qdrant 维度、点数和片段向量数,并在维度不一致时重建索引;未配置或 Qdrant 不可用时只走 MySQL Fulltext/seed fallback,不影响检索。 diff --git a/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md b/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md index 908d7508..68c7ffff 100644 --- a/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md +++ b/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md @@ -151,7 +151,22 @@ order by id desc; 队列状态固定为 `0=待处理、1=处理中、2=完成、3=失败`。成功条件是最新队列条目为 2、附件为 2、片段数大于 0,并按当前向量配置确认相应片段已向量化;零片段必须保持失败态,不能显示为完成。普通失败条目只有在 72 小时暂存保留期内才可调用 `POST /api/knowledge/doc/upload-items/{id}/retry`;暂存已清理时应重新上传。历史附件重试不依赖旧暂存文件,而是从 OSS 原件重新入队。 -### 5.3 细粒度 SOP 迁入 `yc_property_sop` +### 5.3 RC-007 容量与存储门禁 + +当前资料加工 worker 固定为单实例并发 2,单文件上限 500MiB,ZIP 解压总量上限 2GiB,失败暂存保留 72 小时。默认暂存目录为 `./.data/staging`,生产必须通过 `AIHR_UPLOAD_STAGING` 或 `aihr.upload.staging` 指向独立、可监控的持久磁盘,不得依赖系统临时目录。`aihrUploadCapacity` 健康组件默认要求可用空间不少于 10GiB;可用 `AIHR_UPLOAD_MINIMUM_FREE_BYTES` 调整,但只能在完成容量测算后修改。 + +发布前先只读检查: + +```bash +curl -fsS https:///actuator/health/aihrUploadCapacity +curl -fsS https:///actuator/metrics/http.server.requests +``` + +健康详情必须满足 `status=UP`、`stagingWritable=true`、`capacityReady=true`,并核对 `workerConcurrency=2`、`maxFileBytes=524288000`、`maxArchiveUnpackedBytes=2147483648`。生产配置已为 `http.server.requests` 开启 P50/P95/P99 分位统计;错误率按同一指标的 `status/outcome` 标签计算。出现低磁盘健康失败时先扩容或迁移暂存盘,不得通过降低门禁值冒充恢复。 + +录音中的约 600 名生活管家只是用户规模,不是 600 并发。压测前由业务、实施和运维另行签认峰值在线人数、并发到达率、业务流量占比、文件大小分布、弱网条件、P95 目标、错误率目标和持续时长;未签认前只可验证健康门禁和现有并发上限,不得宣称容量验收通过。压测必须使用隔离环境和脱敏/合成数据,不对生产执行写压测。 + +### 5.4 细粒度 SOP 迁入 `yc_property_sop` 现有“投诉处理 SOP、催缴沟通 SOP、报修跟进 SOP”等 `legacy_*` 空间不直接改名。按以下顺序执行: diff --git a/docs/P0_PILOT_ACCEPTANCE_CHECKLIST.md b/docs/P0_PILOT_ACCEPTANCE_CHECKLIST.md index c74239ad..89ec7372 100644 --- a/docs/P0_PILOT_ACCEPTANCE_CHECKLIST.md +++ b/docs/P0_PILOT_ACCEPTANCE_CHECKLIST.md @@ -13,12 +13,18 @@ | 员工正例 | 1 | 当前项目在职、可完成训练与每日题 | | 主管正例 | 1 | 当前项目带教范围、可查看复盘并派发专项 | | 员工越权反例 | 1 | 无主管权限,用于验证主管页与写接口均被拒绝 | -| 真机体验样本 | iOS/Android 各 1 | 已获测试同意,用于媒体、下载、字号与读屏验证 | +| Android App-Plus 真机样本 | 2 | 一台 Android 10 基线机型和一台当前主流机型,均安装本批同一签名 APK | +| iOS App-Plus 真机样本 | 0 或 2 | 仅当本批明确包含 iOS 原生包时启用;不得拿 H5 Safari 代替原生包验收 | +| H5 兼容样本 | 0 或 2 | 仅当本批包含 H5 发布时启用;iOS Safari 与 Android Chrome 各一台,不与 App-Plus 结果合并 | 试点批次:`<项目名称>` 统计窗口:`` 至 `` +本批交付面:`Android App-Plus / iOS App-Plus / H5`(逐项勾选;未勾选的交付面不得写入通过结论) + +版本证据:`APK version/versionCode/SHA-256`、`后端 JAR SHA-256`、`管理端/H5 构建哈希`、`schema 预检结果`、`回滚点` + 内容负责人 / 业务审核人 / 试点负责人:`<在受控系统登记>` --- @@ -79,16 +85,24 @@ ## 三、P0-2 真机能力验收 -**设备要求**: -- iOS真机(iPhone,iOS 15+,微信内置浏览器 + Safari各一台) -- Android真机(Android 10+,微信内置浏览器 + Chrome各一台) +**设备矩阵**: +- 当前 Android 正式试点:同一签名 APK 至少覆盖 Android 10 基线机型和一台当前主流机型;所有 App-Plus 项目均在这两台设备执行。 +- 本批包含 iOS 原生包时:同一签名包至少覆盖 iOS 15 基线机型和一台当前刘海或灵动岛机型;结果独立记录。 +- 本批包含 H5 时:另用 iOS Safari 和 Android Chrome 各一台执行 H5 兼容验收;微信内置浏览器仅在业务明确要求时加入,不代替系统浏览器或原生包。 +- 每条证据必须记录交付面、设备型号、系统版本、应用版本和结果;不得把一个交付面的成功外推到另一个交付面。 ### 语音输入与播报 - [ ] **真机体验样本** → 问师傅页 → 点击麦克风图标 → 允许麦克风权限 - [ ] 说话5秒("今天客户投诉了停车费问题怎么处理") → 停止录音 → 文本框自动填入识别后的文字 - [ ] 点击"搜索" → 等待回答 → 点击"播报"按钮 → 听到TTS语音播报回答内容 +- [ ] 首次拒绝麦克风权限 → 页面明确提示可再次授权或改选音频,不启动录音;再次点击仍可申请权限 +- [ ] 选择“以后不再询问”后再次点击麦克风 → 页面明确引导到系统设置,不反复弹出无效授权框 - [ ] 真机验证降级:飞行模式 → 点击"播报" → 检查是否降级到浏览器`speechSynthesis`(iOS Safari可能无声,属已知限制) - [ ] 真机验证录音兜底:长按5秒说话但ASR接口超时 → 检查是否显示"选择音频文件"兜底选项 +- [ ] 系统音频/附件选择器取消 → 页面安静回到可操作状态,不显示失败;选择器权限或读取失败 → 显示稳定中文错误并可重试 +- [ ] 弱网或接口超时 → 当前请求终止、loading 结束、原输入保留;恢复网络后可重试且不重复提交 +- [ ] 会话过期返回 401 → 清理旧登录态并回到登录页;完成 OTP 重登后返回原业务页,403 业务拒绝不得误退出 +- [ ] 录音、转写、播报或附件选择期间切到后台再回前台 → 不残留播放/录音/loading;系统权限页和文件选择器返回时不得误取消当前选择 ### 图片/视频上传 - [ ] **真机体验样本** → 问师傅页 → 点击"上传图片"图标 → 从相册选择一张图片(≤10MB) @@ -157,12 +171,11 @@ - [ ] 员工越权访问主管功能被拦截,3个反例全部拦截成功 ### P0-2 真机能力 -- [ ] 语音录音+ASR转文字在iOS/Android微信+浏览器各通过1次 -- [ ] 语音播报+降级在iOS/Android各通过1次 -- [ ] 图片/视频上传在iOS/Android各通过1次 -- [ ] 总结卡下载在iOS/Android各保存成功1次 -- [ ] 全局字号三档在iOS/Android各验证持久化+全局覆盖 -- [ ] 读屏/键盘导航在iOS VoiceOver + Android TalkBack各通过1次 +- [ ] Android App-Plus 两台设备均完成语音录音、ASR、TTS、图片/视频/文档、受控下载、字号持久化和前后台恢复 +- [ ] Android TalkBack 和外接键盘至少在一台设备完成核心路径验证 +- [ ] 若本批包含 iOS App-Plus,两台 iOS 设备执行同一能力矩阵,并另行完成 VoiceOver 验收 +- [ ] 若本批包含 H5,iOS Safari 与 Android Chrome 各完成一次同等 H5 能力矩阵;结果不得与 App-Plus 合并 +- [ ] 文件和媒体失败时只允许重新鉴权后重试或回到受控在线内容,不产生可复制、长期复用的原文件链接 ### P0-3 严格试点窗口 - [ ] 窗口结束时,完成率≥80%(≥16人完成≥10次训练) @@ -177,7 +190,7 @@ | 相对时间 | 里程碑 | 责任人 | 状态 | |---|---|---|---| | 窗口开始前 | P0-1权限正反例全部验证 | 开发+测试 | 待执行 | -| 窗口开始前 | P0-2真机能力验证(iOS+Android各2台) | 测试 | 待执行 | +| 窗口开始前 | P0-2 按本批交付面执行设备矩阵;Android App-Plus 固定 2 台 | 测试 | 待执行 | | 窗口中点 | P0-3中期检查(完成率/校准样本) | 项目经理 | 待执行 | | 窗口结束前 5 天 | P0-3末期检查(完成率冲刺) | 主管团队 | 待执行 | | 窗口结束日 | 统计最终数据 | 项目经理 | 待执行 | @@ -200,7 +213,7 @@ - **应急预案**: - 语音失败 → 检查ASR/TTS接口配额,切换备用提供方或降级到纯文本 - 上传失败 → 检查OSS配额,临时扩容或限制单日上传次数 - - 下载失败 → 降级为"复制链接"分享,用户自行浏览器打开下载 + - 下载失败 → 重新鉴权后重试或回到受控在线内容;不得复制原文件地址、OSS 地址或长期下载链接 - 字号/读屏失败 → 记录为P1缺陷,不阻塞试点(可访问性为增强项) ### 风险3:越权拦截失效 diff --git a/docs/README.md b/docs/README.md index 1b665eb4..850a7086 100644 --- a/docs/README.md +++ b/docs/README.md @@ -62,7 +62,7 @@ | [帮道8月1日Android安装包测试版任务计划-20260727.md](帮道8月1日Android安装包测试版任务计划-20260727.md)([Word 同步版](帮道8月1日Android安装包测试版任务计划-20260727.docx)) | 2026-08-01 Android 受控内测 APK 的冻结范围、P0 任务、倒排日程、真机门禁、Go/No-Go 与后续边界;Markdown 为事实源 | | [AI陪练二期开发推进计划.md](AI陪练二期开发推进计划.md) | 2026-07-11 历史推进记录;保留 M0-M5 的工程追溯,不再作为当前施工范围 | | [员工端版本功能对比与功能对齐清单-20260717.md](员工端版本功能对比与功能对齐清单-20260717.md) | 旧版/历史需求/当前实现/生产验收的版本对比,以及 2026-07-17 客户反馈逐项处理结果 | -| [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | 当前生产基线、三端输入/BRD 匹配、客户反馈完成分层和正式试点剩余硬门槛;旧发布流水从当前真相源移除 | +| [BRD_IMPLEMENTATION_AUDIT.md](BRD_IMPLEMENTATION_AUDIT.md) | 当前生产基线、尚未部署的本地增量、三端输入/BRD 匹配、客户反馈完成分层和正式试点剩余硬门槛;旧发布流水从当前真相源移除 | | [BRD_PRODUCTION_MIGRATION_RUNBOOK.md](BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | AIHR BRD 生产 schema/内置内容迁移顺序、结构核验、当前运行基线、单项热修复回滚和发布后回归口径 | | [20260708/uni-app用户侧前端重建计划.md](20260708/uni-app用户侧前端重建计划.md) | `mobile-uni/` 重建阶段留档;当前完成状态以 BRD 审查和版本功能对比为准 | | [20260708/数字师傅学练问报整合方案.md](20260708/数字师傅学练问报整合方案.md) | 产品定义(学练问报+问题榜+学分晋升+个人 AI 助理)及 v1.8 “问”模块 Agent 关联与实施边界 | diff --git a/docs/data-quality-audit.md b/docs/data-quality-audit.md index c4b9de52..4d6b0930 100644 --- a/docs/data-quality-audit.md +++ b/docs/data-quality-audit.md @@ -86,7 +86,7 @@ flowchart LR |---|---|---| | 正式制度 | 普通知识接入后,另由 `aihr_knowledge_source_governance` 登记 `APPROVED`、版本、生效/失效日期;只有识别为正式制度意图时附加过滤 | 特殊意图受控;普通检索仍可能命中相同片段。证据:`backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql:5`;`backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java:662` | | 个人知识 | `POST /api/aihr/personal-assistant/items/text|file|url` -> 私有 item/fragment;HR/超级管理员审核发布 -> 脱敏后的 fragment 直接复制到企业知识表 | 是,审批后直接写企业表,但绕过通用附件/版本/质量/重新解析链。证据:`backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/controller/PersonalAssistantController.java:89`、`:94`、`:103`;`backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/service/PersonalPublishService.java:107`、`:232` | -| 案例录音 | `/api/knowledge/case/upload` -> OSS + ASR -> `aihr_case_record`;`organize` 用模型或本地兜底总结;`curate` 直接标“已入库”;主管 `review` 只是事后点评 | 是案例库可见数据,但没有前置发布审批。证据:`backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java:53`、`:61`、`:69`、`:108`;`backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java:96`、`:107`、`:127` | +| 案例录音 | `/api/knowledge/case/upload` -> OSS + ASR -> `aihr_case_record`;`organize` 用模型或本地兜底总结;`curate` 只进入候选池;`review` 完成业务审核;`publish` 在来源授权、脱敏、适用岗位、版本和负责人齐全后才标记 `knowledge_status=PUBLISHED` | 员工列表/详情只读取 `knowledge_status=PUBLISHED`,审核通过不会自动进入正式知识库。迁移:`backend/script/sql/update/aihr_20260802_case_experience_governance_mysql8.sql`;服务:`backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java` | | AI 考试题 | `AihrExamService.generateDraft` 从租户片段抽取 grounding,模型/规则生成草稿;主管创建并发布考试 | 不直接自动发布,但 grounding 未要求来源批准、附件有效或授权空间。证据:`backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java:185`、`:482`、`:541` | | 陪练 | 用户回答、音频、LLM/seed 评分写 session/assignment;可选 calibration 保存人工校准 | 不写企业知识表,但属于运行/评测数据,当前缺少统一 synthetic、置信度、模型版本和数据用途隔离。证据:`backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java:1513`;`backend/script/sql/aihr_practice_mysql8.sql:27`、`:168` | | 陪练场景 | 场景有草稿、待业务审核、已发布、已下线;高风险需不同用户双审并校验内容版本/哈希 | 局部强控制。证据:`backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java:599`、`:675`、`:696`;`backend/script/sql/aihr_practice_mysql8.sql:184` | diff --git a/frontend/.eslintrc-auto-import.json b/frontend/.eslintrc-auto-import.json index 2ca3adf9..d9799257 100644 --- a/frontend/.eslintrc-auto-import.json +++ b/frontend/.eslintrc-auto-import.json @@ -5,6 +5,10 @@ "ComputedRef": true, "DirectiveBinding": true, "EffectScope": true, + "ElLoading": true, + "ElMessage": true, + "ElMessageBox": true, + "ElNotification": true, "ExtractDefaultPropTypes": true, "ExtractPropTypes": true, "ExtractPublicPropTypes": true, diff --git a/frontend/src/api/aihr/broadcast.ts b/frontend/src/api/aihr/broadcast.ts index 3ff2cf85..c5e7b304 100644 --- a/frontend/src/api/aihr/broadcast.ts +++ b/frontend/src/api/aihr/broadcast.ts @@ -8,6 +8,15 @@ type ApiResult = { export type BroadcastStatus = 'PUBLISHED' | 'WITHDRAWN'; export type BroadcastInsightStatus = 'NOT_PROCESSED' | 'PENDING' | 'READY' | 'PARTIAL' | 'FAILED'; +export type BroadcastVisibilityMode = 'ALL' | 'TARGET_ONLY'; + +export type BroadcastTopicTag = { + name: string; + sourceType: 'AUTO' | 'MANUAL'; + evidenceQuote: string; +}; + +export type BroadcastTopicTagRequest = Pick; export type BroadcastTargetRequest = { projectCodes: string[]; @@ -38,6 +47,8 @@ export type BroadcastAdminMessage = { withdrawnAt?: string; withdrawReason?: string; requiredRead: boolean; + visibilityMode: BroadcastVisibilityMode; + allowDownload: boolean; targetRuleCount: number; matchedRecipientCount: number; unmatchedTargetCount: number; @@ -51,7 +62,11 @@ export type BroadcastAttachment = { status: 'QUEUED' | 'PROCESSING' | 'READY' | 'FAILED'; summary?: string; errorMessage?: string; + viewUrl?: string; downloadUrl?: string; + allowDownload: boolean; + watermarkText?: string; + topicTags: BroadcastTopicTag[]; insightStatus?: BroadcastInsightStatus; perspectiveLabels?: string[]; }; @@ -71,6 +86,8 @@ export type PublishBroadcastRequest = { content: string; expectedTenantId: string; requiredRead: boolean; + visibilityMode: BroadcastVisibilityMode; + allowDownload: boolean; targets: BroadcastTargetRequest; attachmentId?: number; }; @@ -119,6 +136,17 @@ export function getBroadcastAttachment(id: number): Promise> { + return request({ + url: `/api/aihr/broadcast/admin/attachments/${id}/topic-tags`, + method: 'put', + data + }); +} + export function publishBroadcast(data: PublishBroadcastRequest): Promise> { + return request({ + url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/publish`, + method: 'post', + data, + headers: { repeatSubmit: false } + }); +} + +export function retireKnowledgeCase(caseId: string, reason: string): Promise> { + return request({ + url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/retire`, + method: 'post', + data: { reason }, + headers: { repeatSubmit: false } + }); +} diff --git a/frontend/src/api/aihr/practice.ts b/frontend/src/api/aihr/practice.ts index 1f72fd68..2da08ead 100644 --- a/frontend/src/api/aihr/practice.ts +++ b/frontend/src/api/aihr/practice.ts @@ -152,9 +152,32 @@ export type PracticeQuestion = { contentHash: string; enabled: boolean; sortOrder: number; + examFormat?: 'single' | 'multiple' | 'judge' | 'short' | ''; + examOptions?: string[]; + examAnswers?: string[]; + examExplanation?: string; + examEnabled?: boolean; + reviewStatus?: 'DRAFT' | 'APPROVED' | 'REJECTED'; + reviewedBy?: string; + reviewedTime?: string; }; -export type PracticeQuestionSaveRequest = Omit; +export type PracticeQuestionSaveRequest = Pick< + PracticeQuestion, + | 'code' + | 'scenarioCode' + | 'positionCode' + | 'type' + | 'question' + | 'referenceAnswer' + | 'enabled' + | 'sortOrder' + | 'examFormat' + | 'examOptions' + | 'examAnswers' + | 'examExplanation' + | 'examEnabled' +>; export type ManagedPracticeAssignment = { id: number; @@ -190,6 +213,70 @@ export type ManagedPracticeAssignmentBatchResponse = { results: Array<{ extPartyId: string; traineeName: string; status: string; message: string }>; }; +export type ManagedLearningTask = { + id: number; + extPartyId: string; + traineeName: string; + projectCode: string; + positionCode: string; + taskType: string; + status: string; + materialType: 'DOCUMENT' | 'VIDEO' | 'COURSE' | 'SOP'; + materialAttachmentId: number; + materialTitle: string; + materialVersion: string; + progressPercent: number; + completionRule: 'MANUAL_CONFIRM' | 'EXAM_PASS'; + examId?: number | null; + examTitle?: string | null; + recurrenceType: 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY'; + assignedTime: string; + dueTime: string; + completedTime?: string; + assignBy: string; +}; + +export type LearningMaterialOption = { + attachmentId: number; + title: string; + fileType: string; + materialVersion: string; + updatedTime: string; +}; + +export type LearningExamOption = { + examId: number; + title: string; + projectCode: string; + positionCode: string; + dueDate: string; + targetCount: number; +}; + +export type ManagedLearningTaskBatchRequest = { + requestId: string; + targets: Array<{ extPartyId: string; traineeName?: string }>; + projectCode: string; + positionCode: string; + taskType: string; + materialType: ManagedLearningTask['materialType']; + materialAttachmentId: number; + completionRule: ManagedLearningTask['completionRule']; + examId?: number | null; + recurrenceType: ManagedLearningTask['recurrenceType']; + startTime: string; + dueTime: string; +}; + +export type ManagedLearningTaskBatchResponse = { + requestId: string; + total: number; + created: number; + duplicate: number; + failed: number; + items: Array<{ extPartyId: string; traineeName: string; status: string; message: string }>; +}; + export type PracticePrepCardResponse = { keyPoints: string[]; redLines: string[]; @@ -328,6 +415,30 @@ export function updatePracticeQuestionEnabled(code: string, enabled: boolean): P return request({ url: `/api/train/practice/questions/${encodeURIComponent(code)}/enabled`, method: 'patch', data: { enabled } }); } +export function reviewPracticeQuestion(code: string, decision: 'APPROVE' | 'REJECT', note = ''): Promise> { + return request({ + url: `/api/train/practice/questions/${encodeURIComponent(code)}/review`, + method: 'patch', + data: { decision, note } + }); +} + +export function listManagedLearningTasks(params?: { status?: string; keyword?: string; limit?: number }): Promise> { + return request({ url: '/api/train/practice/onboard-tasks/manage', method: 'get', params }); +} + +export function listLearningMaterials(): Promise> { + return request({ url: '/api/train/practice/onboard-tasks/manage/materials', method: 'get' }); +} + +export function listLearningExams(): Promise> { + return request({ url: '/api/train/practice/onboard-tasks/manage/exams', method: 'get' }); +} + +export function createManagedLearningTasks(data: ManagedLearningTaskBatchRequest): Promise> { + return request({ url: '/api/train/practice/onboard-tasks/manage/batch', method: 'post', data, headers: { repeatSubmit: false } }); +} + export function updatePracticeScenarioEnabled(id: string, data: PracticeScenarioEnabledRequest): Promise> { return request({ url: `/api/train/practice/scenarios/${id}/enabled`, diff --git a/frontend/src/utils/broadcast-pending.test.ts b/frontend/src/utils/broadcast-pending.test.ts index 275e64ca..16cfc0ae 100644 --- a/frontend/src/utils/broadcast-pending.test.ts +++ b/frontend/src/utils/broadcast-pending.test.ts @@ -41,6 +41,8 @@ const pending: PendingBroadcastPublish = { title: '夏季服务标准通知', content: '请按最新标准执行。', requiredRead: true, + visibilityMode: 'TARGET_ONLY', + allowDownload: false, targets: { projectCodes: ['PRJ-FCW'], positionNames: ['物业管家'], @@ -49,6 +51,19 @@ const pending: PendingBroadcastPublish = { }; describe('pending broadcast publish storage', () => { + it('defaults legacy delivery controls to public and downloadable', () => { + const sessionStorage = new MemoryStorage(); + const storage = createPendingBroadcastStorage({ sessionStorage }); + const key = broadcastPendingStorageKey(pending.tenantId, 'operator-1'); + const { visibilityMode: _visibilityMode, allowDownload: _allowDownload, ...legacy } = pending; + sessionStorage.setItem(key, JSON.stringify(legacy)); + + expect(storage.read('operator-1', pending.tenantId)).toMatchObject({ + visibilityMode: 'ALL', + allowDownload: true + }); + }); + it('writes a retryable publish only into session storage', () => { const sessionStorage = new MemoryStorage(); const legacyLocalStorage = new MemoryStorage(); diff --git a/frontend/src/utils/broadcast-pending.ts b/frontend/src/utils/broadcast-pending.ts index 95543d97..be589b8f 100644 --- a/frontend/src/utils/broadcast-pending.ts +++ b/frontend/src/utils/broadcast-pending.ts @@ -1,4 +1,4 @@ -import type { BroadcastTargetRequest } from '@/api/aihr/broadcast'; +import type { BroadcastTargetRequest, BroadcastVisibilityMode } from '@/api/aihr/broadcast'; export const pendingBroadcastStoragePrefix = 'aihr.broadcast.pending.v2'; @@ -8,6 +8,8 @@ export interface PendingBroadcastPublish { title: string; content: string; requiredRead: boolean; + visibilityMode: BroadcastVisibilityMode; + allowDownload: boolean; targets: BroadcastTargetRequest; attachmentId?: number; } @@ -58,12 +60,16 @@ const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | unde const candidate = JSON.parse(raw) as Partial; const targets = parseTargets(candidate.targets); const requiredRead = candidate.requiredRead === undefined ? false : candidate.requiredRead; + const visibilityMode = candidate.visibilityMode === undefined ? 'ALL' : candidate.visibilityMode; + const allowDownload = candidate.allowDownload === undefined ? true : candidate.allowDownload; if ( typeof candidate.tenantId !== 'string' || typeof candidate.requestId !== 'string' || typeof candidate.title !== 'string' || typeof candidate.content !== 'string' || typeof requiredRead !== 'boolean' + || !['ALL', 'TARGET_ONLY'].includes(visibilityMode) + || typeof allowDownload !== 'boolean' || !targets ) { return undefined; @@ -74,6 +80,8 @@ const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | unde title: candidate.title, content: candidate.content, requiredRead, + visibilityMode, + allowDownload, targets, attachmentId: Number.isSafeInteger(candidate.attachmentId) && Number(candidate.attachmentId) > 0 ? Number(candidate.attachmentId) diff --git a/frontend/src/views/content/broadcast.vue b/frontend/src/views/content/broadcast.vue index 9debcb3d..8aa76f04 100644 --- a/frontend/src/views/content/broadcast.vue +++ b/frontend/src/views/content/broadcast.vue @@ -3,7 +3,7 @@

帮道大喇叭

-

向当前租户全员公开发布公司通知;可额外定向提醒项目、岗位或职级人员,并设置必读。撤回会停止员工端展示,但保留发布记录。

+

向当前租户发布公司通知,可选择全员公开或仅目标员工可见,并控制必读和文件下载。撤回会停止员工端展示,但保留发布记录。

发布消息
@@ -42,9 +42,12 @@ @@ -104,7 +107,9 @@ type="warning" :closable="false" show-icon - title="消息会立即向当前租户员工端公开,请确认内容不含敏感人员、薪酬或审批材料。" + :title="form.visibilityMode === 'TARGET_ONLY' + ? '消息只对发布时匹配到的目标员工可见;非目标员工无法查看或推断消息存在。' + : '消息会立即向当前租户员工端公开,请确认内容不含敏感人员、薪酬或审批材料。'" /> 刷新状态 +
+
+ 文件主题标签(仅用于展示和检索,不参与权限判断) + 人工修正 +
+
+ + {{ tag.name }} + +
+ 未识别到主题标签 +
+ + 允许员工下载原文件(关闭后仅可在带动态水印的页面内阅读) + 设为必读(员工端会优先显示未读内容) - + + + 全员公开 + 仅目标可见 + + +
{{ publishPending ? '重试发布' : '确认发布' }} + + + +
+
+ + + +
+ 添加标签 +
+ +
diff --git a/mobile-uni/src/pages.json b/mobile-uni/src/pages.json index 737f1a26..69725ebc 100644 --- a/mobile-uni/src/pages.json +++ b/mobile-uni/src/pages.json @@ -151,12 +151,18 @@ "navigationBarTitleText": "工作成果" } }, - { - "path": "pages/user/exam/index", + { + "path": "pages/user/exam/index", "style": { "navigationBarTitleText": "岗前考试" - } - }, + } + }, + { + "path": "pages/user/learning/material", + "style": { + "navigationBarTitleText": "学习材料" + } + }, { "path": "pages/auth/login/index", "style": { diff --git a/mobile-uni/src/pages/supervisor/exams/edit.vue b/mobile-uni/src/pages/supervisor/exams/edit.vue index 676c90d7..41ded733 100644 --- a/mobile-uni/src/pages/supervisor/exams/edit.vue +++ b/mobile-uni/src/pages/supervisor/exams/edit.vue @@ -69,6 +69,17 @@ 当前项目暂无可派发的在职员工。 + + 审核题库组卷 + 仅从当前岗位已审核、已启用的考试题中随机抽取,抽取后冻结题目版本 + + + + + + AI 出题 输入考察提示,AI 按岗位生成题目与答案,请逐题核对后发布 @@ -99,22 +110,22 @@ {{ expandedIndex === index ? '⌄' : '›' }} -