diff --git a/AGENTS.md b/AGENTS.md index a9eddbda..4f00d237 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ - 管理端业务路由集中在 `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` 不阻断摘要、原文件、发布或追问。消息/知识回答不得把通用建议包装成当前员工的真实待办,强行动入口只能来自服务端已授权的真实任务数据。 -- 直通车固定为总裁、财务、人力、审计、运营五个渠道;业务匿名只隐藏处理端的提交人展示,服务端仍保留内部提交账号供本人查询、幂等和审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 只处理各自渠道,`superadmin` 可处理全部;当前产品边界只允许一次正式回复,不扩展为多轮聊天、工单、转派或 SLA。员工提交页支持语音转文字输入(复用 `/api/ai/asr` 与 `services/speech.ts`);该表单页以文字为主,录音不可用或权限失败时只提示改文字,是不提供音频文件降级的明确例外。 +- 直通车固定为总裁、财务、人力、审计、运营五个渠道;业务匿名只隐藏处理端的提交人展示,服务端仍保留内部提交账号供本人查询、幂等和审计。`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`。 - 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。 diff --git a/CHANGELOG.md b/CHANGELOG.md index f47d669a..68271ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Unreleased +- **2026-07-30,五通道处理人配置与 Android 权限 Activity 生命周期收口,已本地验证、尚未发布**:管理端“直通反馈”新增五通道处理人就绪概览和 `superadmin` 专用配置入口;服务端候选人只返回当前租户已启用的后台用户,排除 APP 用户、超级管理员和已绑定用户,写入时复核 `expectedTenantId`,页面按服务端 `minimumHandlers=2` 提示主处理人/替补缺口,并在移除会破坏就绪状态前明确告警。生产只读检查仍为五个角色各 `0` 人,且新接口当前返回 404;本轮没有擅自绑定人员或写生产数据库。Android 麦克风授权现在与系统文件选择器共用原生 Activity 活动标记,权限回调后等待应用窗口重新获得焦点,六个相关页面在授权框/选择器暂时离开 App 时不再把待完成录音或上传任务误判为取消。`ruoyi-aihr` 全量 708 项、管理端定向 28 项、移动端 208 项、移动端类型检查、管理端生产构建、App-Plus 构建及发布资源门禁均通过;管理端本地真实浏览器确认五张通道卡、`0/5` 就绪状态和候选人对话框正常。已生成的 `0.1.16 (116)` DCloud 测试签名 APK(SHA-256 `183E67137CE9B73FC6D41A728779D40EB0122F8F62E1951B65DDC356203937F5`)通过包名、API 35、明文流量和签名深检,但其内嵌 `app-service.js` 不等于当前构建且不含本轮窗口焦点/权限 Activity 修复,不能作为当前源码候选;后续须使用新版本号重新签名出包并在同一包上真机回归。本项尚未发布后端、管理端、H5、原生 App 或 schema。 - **2026-07-30,学习中心错题列表异常已修复、发布并完成真机验证**:vivo V2443A(Android 15)的线上会话进入“学习”页后显示“发生未知异常”,同页任务、知识引用等请求正常,生产日志将故障收敛到 `GET /api/aihr/mobile/practice/mistakes`;其 SQL 文本中的 JSON `LIKE '%"redFlag":true%'` 被 `String.formatted()` 误当成格式指令,触发 `UnknownFormatConversionException: Conversion = '"'`。当前改为只替换身份 `IN` 参数的专用占位符,保留两个 `LIKE` 通配符原样,并增加 SQL 生成回归测试;`AihrPracticeSeedServiceTest` 103 项、`ruoyi-aihr` 全量 707 项测试和 `ruoyi-admin` 生产包构建均通过。提交 `edb8c825` 的后端 JAR `346687564b38adbba643fc7d94d2faedbb51449198b4eef6c3fa90fb99646c83` 已通过定向发布脚本备份、原子切换、重启和发布后预检,旧包备份在 `/opt/wygj/backups/backend-20260730005606-be16c229cfc2`;远近端整包及内嵌 `ruoyi-aihr-5.6.2.jar` 哈希一致,服务、公开接口、64/64 schema 和生产禁运行时 DDL 均通过。同一真机从“今日”重新进入“学习中心”后不再显示异常,错题、分数和“去再练”入口正常;对应错题接口在 `22ms` 内结束,重启后日志未再出现 `UnknownFormatConversionException`。本次只发布后端,未发布移动 H5、原生 App、管理端或 schema 迁移。 - **2026-07-29,Android 文件选择、麦克风拒绝降级与上传路径修复,源码候选升级为 `0.1.16 (116)`,尚无签名 APK**:真机确认 `0.1.13` 在允许麦克风时可完成原生录音和生产 ASR,但拒绝后的降级误用 App 不支持的 `uni.chooseFile`。Android 非媒体文件现改用系统 `ACTION_OPEN_DOCUMENT`,通过 `ParcelFileDescriptor + FileChannel.transferFrom` 复制到应用专属外部缓存,避免 Native.js `byte[]` 参数兼容问题;图片/视频改走 App `uni.chooseMedia`,页面生命周期会保留系统选择器中的待完成任务,录音前统一检查麦克风权限。vivo Android 15 真机在 HBuilderX 5.22 标准调试基座完成取消、重复打开、`100,542` 字节 WAV 精确复制、multipart 上传和生产 ASR。首次成果投稿验证实际只进入追问态,当时并未出现“确认投稿”,此前“未点击确认投稿”的表述已纠正;继续补充时线上整理请求耗时 `94,965ms`,超过客户端 `45,000ms` 门限。当前源码改为仅首轮调用 AI,后续原话并入当前草稿后交由既有服务端本地复核,真机复验后续整理耗时 `1ms`,确认卡及“确认投稿”按钮均已显示但未点击,未形成正式投稿。移动端 206 项测试、类型检查、App 发布资源预检和 App-Plus 构建通过;调查用 `0.1.14/0.1.15` APK 已删除。DCloud 当日云打包额度耗尽,最终 `0.1.16` 签名 APK、深检和同包真机复验仍未完成,因此当前仍为 No-Go,未发布移动 H5、原生 App、后端、管理端或数据库。 - **2026-07-29,资料处理中心普通文档/图片单文件 500MB 已发布并完成生产验证**:管理端批量导入、后端异步队列、ZIP 子文件和 Tika 输入流统一允许受支持文件 `≤500MiB`,沿用生产 Spring/Undertow `500MB/520MB` 传输层配置;仍保留 ZIP 最多 1000 个子文件、解压总量 ≤2GB、禁止嵌套 ZIP 等安全边界,同步 SOP 上传和公司文件继续保持 100MB。生产实测发现首个候选虽已放宽页面和队列校验,但 Tika 内仍残留独立 `100MiB` 输入流上限,导致首批队列 `1194/1195` 上传成功后加工失败;补齐解析器上限及回归断言后,11 项定向后端测试和后端生产打包通过。管理端发布前备份整站到 `/opt/wygj/backups/www-20260729213203`,线上 `processing-BjvQ3qpr.js` 与本地产物 SHA-256 均为 `6ace59477519d6c53cbfe207a229518a7e2f7568e05961cfea28d669e1eeb305`,移动 H5 树哈希保持 `ba4aa656d64fc301141280357c204a67141d1e6b2e2a26a0be21c0dd48a25712` 未变。最终后端 JAR SHA-256 为 `be16c229cfc255e4cdf5ec32df047994b54d7d2c31b6c729dde6d949e191e53d`,旧包备份在 `/opt/wygj/backups/backend-20260729214901-5ef5dd484b5d`;整包及内嵌 `ruoyi-aihr-5.6.2.jar` 远近端哈希一致,服务、公开接口、64/64 schema 和生产禁运行时 DDL 预检均通过。已登录生产 Chrome 验收批次 `b-c2560e95-0cf7-46e0-9bdc-7690ea86ffd7` 中,`4、巡检表(事业部颗粒归仓专用).xlsx`(101.6MB,队列 `1196`、附件 `2036`)完成 5 个片段和 5 个向量,`新员工培训-社区发展中心-社区资源.pptx`(147.9MB,队列 `1197`、附件 `2037`)完成 7 个片段和 7 个向量;两项均显示“已完成/已入库”,数据库片段均含 embedding。资料总数从 1032 增至 1034,目标空间从 22 增至 24 个文件,处理中和需处理均为 0。本次只定向发布后端与管理端,未重发移动 H5、未执行 schema 迁移,不代表当前工作树与线上完整包全部匹配。 diff --git a/README.md b/README.md index 1104539a..8fe8a331 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - 基础链路:租户列表、验证码、`000000 / admin / admin123` 加密登录已验证。 - 管理端:除 AI 面试、训练、案例、SOP 和资料处理外,已包含大喇叭发布、单文件提炼与多岗位解读、五渠道直通反馈收件箱、内容/任务运营、问题榜运营、知识空间、全网 AI 配置和组织权限;员工个人训练、主管复盘、团队难题、成长证据与训练录音仅在认证 APP 按项目范围访问。 - 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;“问”已改为数字师傅 Agent,文字、ASR 转写和图片/视频统一先做意图规划,再按服务端权限调用知识、训练、待办、记忆、媒体或全网工具,寒暄和不完整表达不再机械落入 RAG。现有能力还包括手机号登录、30 分钟短期多轮、原文件/视频受控交付、大喇叭公司文件摘要/下载/多岗位解读/追问、五渠道直通反馈、“记一下”确认卡、三级字号、训练/题目反馈、问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式。Agent 代码已随 2026-07-25 完整包部署;2026-07-29 当前 DCloud 云端证书内测 APK 为 `0.1.13 (113)`,SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0`,启动阶段不展示 DCloud 原生隐私确认,法务同意统一由登录页承担。该版进一步让认证身份、角色和组织岗位异常失败关闭,不再回退为普通员工或公开首页;移动端 199 项、`ruoyi-aihr` 700 项、类型检查、App-Plus 资源和 APK 深检已通过。干净提交 `af8af2f6` 的统一 RC 已达到 `releaseEligible=true`,证据在 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`;冻结候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD`,其语义已包含在 2026-07-29 后续定向发布的生产 JAR `5376B67B…` 中。当前双提交交付包为 `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`,大小 `192,948,093` 字节,SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希已复核;严格门禁会核对固定码模式、5/5 场景、30/30 问题证据、五类负责人签认、每个直通角色的主处理人/替补、五通道正向授权/越权拒绝/一次正式回复验收,以及受控分发、法务例外和测试签名接受,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` 及更早 ZIP 只作历史追溯。生产现有在职 APP 账号的固定码链路已经完整通过首次获取/登录、认证只读主路径、退出、旧令牌失效、等待防刷窗口后的再次获取和重新登录,应用日志确认两次均未真实发送短信;固定码、登录和重新登录不再列为功能缺口。当前 x86_64 AVD 对仅含 ARM ABI 的 DCloud Weex 运行时出现 IPC 超时;浏览器截图只作布局和交互补充证据,不替代 App 运行、认证业务或设备能力验收,也不因此修改发布 ABI、增加降级路径或启用兜底内容。机器签认当前准确显示正式场景 `0/5`、有据问题 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING`,五个处理角色有效绑定均为 `0`;生产后端定向发布已经完成,但业务签认、受控分发和真机门禁仍未闭环,当前 Go/No-Go 仍为 No-Go。旧 `mobile/` 只用于历史回溯。 +- 2026-07-30 增量状态:五通道处理人自助配置和 Android 权限 Activity 生命周期修复已实现并完成本地自动化/浏览器/App-Plus 构建验证,但尚未发布;生产新配置接口仍为 404,五个角色各为 `0` 人。最新生成的签名 `0.1.16 (116)` APK 不包含最终窗口焦点修复且包内业务资源与当前构建不一致,不能作为当前源码候选,后续必须用新版本号重新签名并做同包真机回归。详见 [Changelog](CHANGELOG.md) 与 [Go/No-Go](docs/帮道8月1日Android内测Go-No-Go-20260728.md)。 - Android 当前边界:最后一个签名内测包仍是 `0.1.13 (113)`;它已在 vivo Android 15 真机完成真实页面登录、麦克风允许和生产 ASR,但麦克风拒绝后的文件降级存在缺陷。当前源码为 `0.1.16 (116)`,已改用 Android 系统文档选择器、SAF `FileChannel` 复制、应用专属外部缓存和 App 媒体选择器;206 项移动端测试、类型检查、App 发布资源预检、App-Plus 构建及标准调试基座的真实 WAV 选择/上传/生产 ASR 已通过。DCloud 当日云打包额度耗尽,尚无 `0.1.16` 签名 APK,调试基座不能替代最终签名包验收。 - 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,ASR/TTS 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;生产短音频 ASR 当前为阿里云百炼 `qwen3-asr-flash`,SiliconFlow audio 接口保留兼容。SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。 - 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示待重试、解析中、已完成、失败、片段数和向量化状态;任务列表按当前筛选结果前端分页(10/20/50 条,筛选或刷新回到第一页)。页面只保留“批量导入”,通过异步队列处理多文件或 ZIP,不提供浏览器目录选择和服务端目录导入入口。历史图片/视频若无片段会保持可重试失败,可直接从原 OSS 文件重新入队;2026-07-29 生产已用该链路恢复原卡住的 4 项资料,全部生成片段并入库。后端目录导入接口仅保留为运维/调试能力。 diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectController.java index c453dac5..12227db8 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectController.java @@ -8,6 +8,10 @@ import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackPage; import org.dromara.aihr.direct.AihrDirectDto.ChannelItem; import org.dromara.aihr.direct.AihrDirectDto.FeedbackItem; import org.dromara.aihr.direct.AihrDirectDto.FeedbackPage; +import org.dromara.aihr.direct.AihrDirectDto.HandlerAssignRequest; +import org.dromara.aihr.direct.AihrDirectDto.HandlerConfigResponse; +import org.dromara.aihr.direct.AihrDirectDto.HandlerRemoveRequest; +import org.dromara.aihr.direct.AihrDirectDto.HandlerUserItem; import org.dromara.aihr.direct.AihrDirectDto.ReplyRequest; import org.dromara.aihr.direct.AihrDirectDto.SubmitRequest; import org.dromara.common.core.domain.R; @@ -66,4 +70,33 @@ public class AihrDirectController { public R reply(@PathVariable Long id, @Valid @RequestBody ReplyRequest request) { return R.ok(directService.reply(id, request)); } + + @GetMapping("/admin/handlers") + public R handlers() { + return R.ok(directService.handlers()); + } + + @GetMapping("/admin/handlers/{channelCode}/candidates") + public R> handlerCandidates( + @PathVariable String channelCode, + @RequestParam(required = false) String keyword + ) { + return R.ok(directService.handlerCandidates(channelCode, keyword)); + } + + @PostMapping("/admin/handlers/{channelCode}") + public R assignHandlers( + @PathVariable String channelCode, + @Valid @RequestBody HandlerAssignRequest request + ) { + return R.ok(directService.assignHandlers(channelCode, request)); + } + + @PostMapping("/admin/handlers/{channelCode}/remove") + public R removeHandler( + @PathVariable String channelCode, + @Valid @RequestBody HandlerRemoveRequest request + ) { + return R.ok(directService.removeHandler(channelCode, request)); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectDto.java index b444027f..e6a32e88 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectDto.java @@ -1,6 +1,8 @@ package org.dromara.aihr.direct; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; @@ -82,4 +84,46 @@ public final class AihrDirectDto { List rows ) { } + + public record HandlerUserItem( + Long userId, + String userName, + String nickName + ) { + } + + public record HandlerChannelItem( + String channelCode, + String channelName, + String roleKey, + boolean roleAvailable, + List users + ) { + } + + public record HandlerConfigResponse( + String tenantId, + int minimumHandlers, + List channels + ) { + } + + public record HandlerAssignRequest( + @NotEmpty(message = "请选择处理人") + @Size(max = 20, message = "一次最多添加 20 名处理人") + List<@NotNull(message = "处理人编号不能为空") Long> userIds, + @NotBlank(message = "当前租户不能为空") + @Size(max = 64, message = "当前租户不能超过 64 个字符") + String expectedTenantId + ) { + } + + public record HandlerRemoveRequest( + @NotNull(message = "处理人编号不能为空") + Long userId, + @NotBlank(message = "当前租户不能为空") + @Size(max = 64, message = "当前租户不能超过 64 个字符") + String expectedTenantId + ) { + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectService.java index 4756e855..0ccc8094 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/direct/AihrDirectService.java @@ -6,6 +6,11 @@ import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackPage; import org.dromara.aihr.direct.AihrDirectDto.ChannelItem; import org.dromara.aihr.direct.AihrDirectDto.FeedbackItem; import org.dromara.aihr.direct.AihrDirectDto.FeedbackPage; +import org.dromara.aihr.direct.AihrDirectDto.HandlerAssignRequest; +import org.dromara.aihr.direct.AihrDirectDto.HandlerChannelItem; +import org.dromara.aihr.direct.AihrDirectDto.HandlerConfigResponse; +import org.dromara.aihr.direct.AihrDirectDto.HandlerRemoveRequest; +import org.dromara.aihr.direct.AihrDirectDto.HandlerUserItem; import org.dromara.aihr.direct.AihrDirectDto.ReplyRequest; import org.dromara.aihr.direct.AihrDirectDto.SubmitRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; @@ -44,6 +49,8 @@ public class AihrDirectService { private static final int DEFAULT_PAGE_SIZE = 20; private static final int MAX_PAGE_SIZE = 100; + private static final int MINIMUM_HANDLERS = 2; + private static final int MAX_HANDLER_CANDIDATES = 50; private static final Pattern REQUEST_ID_PATTERN = Pattern.compile("[A-Za-z0-9._:-]+"); private static final List CHANNELS = List.of( new DirectChannel("PRESIDENT", "总裁直达", "direct_president", true), @@ -206,6 +213,86 @@ public class AihrDirectService { return adminDetail(tenantId, feedbackId, channelCodes); } + public HandlerConfigResponse handlers() { + AihrKnowledgePrincipal principal = principalResolver.current(); + requireHandlerAdmin(principal); + return handlerConfig(adminTenant(principal)); + } + + public List handlerCandidates(String rawChannelCode, String rawKeyword) { + AihrKnowledgePrincipal principal = principalResolver.current(); + requireHandlerAdmin(principal); + String tenantId = adminTenant(principal); + DirectRole role = directRole(tenantId, channel(rawChannelCode)); + String keyword = clean(rawKeyword); + String like = "%" + keyword + "%"; + return jdbcTemplate.query(""" + select u.user_id, u.user_name, u.nick_name + from sys_user u + where binary u.tenant_id = binary ? + and u.user_type <> ? + and u.status = '0' and u.del_flag = '0' + and not exists ( + select 1 + from sys_user_role assigned + where assigned.user_id = u.user_id and assigned.role_id = ? + ) + and not exists ( + select 1 + from sys_user_role elevated + join sys_role elevated_role on elevated_role.role_id = elevated.role_id + where elevated.user_id = u.user_id + and binary elevated_role.tenant_id = binary u.tenant_id + and elevated_role.role_key = ? + and elevated_role.status = '0' and elevated_role.del_flag = '0' + ) + and (? = '' or u.user_name like ? or u.nick_name like ?) + order by u.nick_name, u.user_name, u.user_id + limit ? + """, (rs, rowNum) -> new HandlerUserItem( + rs.getLong("user_id"), rs.getString("user_name"), rs.getString("nick_name") + ), tenantId, UserType.APP_USER.getUserType(), role.roleId(), + TenantConstants.SUPER_ADMIN_ROLE_KEY, keyword, like, like, MAX_HANDLER_CANDIDATES); + } + + @Transactional + public HandlerConfigResponse assignHandlers(String rawChannelCode, HandlerAssignRequest request) { + AihrKnowledgePrincipal principal = principalResolver.current(); + requireHandlerAdmin(principal); + String tenantId = adminTenant(principal); + requireExpectedTenant(request.expectedTenantId(), tenantId); + DirectRole role = directRole(tenantId, channel(rawChannelCode)); + List userIds = request.userIds().stream().distinct().toList(); + for (Long userId : userIds) { + requireEligibleHandler(tenantId, userId); + jdbcTemplate.update(""" + insert into sys_user_role (user_id, role_id) + select ?, ? + where not exists ( + select 1 from sys_user_role where user_id = ? and role_id = ? + ) + """, userId, role.roleId(), userId, role.roleId()); + } + return handlerConfig(tenantId); + } + + @Transactional + public HandlerConfigResponse removeHandler(String rawChannelCode, HandlerRemoveRequest request) { + AihrKnowledgePrincipal principal = principalResolver.current(); + requireHandlerAdmin(principal); + String tenantId = adminTenant(principal); + requireExpectedTenant(request.expectedTenantId(), tenantId); + DirectRole role = directRole(tenantId, channel(rawChannelCode)); + jdbcTemplate.update(""" + delete assignment + from sys_user_role assignment + join sys_user u on u.user_id = assignment.user_id + where assignment.role_id = ? and assignment.user_id = ? + and binary u.tenant_id = binary ? + """, role.roleId(), request.userId(), tenantId); + return handlerConfig(tenantId); + } + private FeedbackItem replay(AihrKnowledgePrincipal principal, String requestKey, String requestHash) { List rows = jdbcTemplate.query(""" select id, submit_request_hash @@ -297,6 +384,92 @@ public class AihrDirectService { return allowed; } + private HandlerConfigResponse handlerConfig(String tenantId) { + List items = CHANNELS.stream().map(channel -> { + DirectRole role = optionalDirectRole(tenantId, channel); + if (role == null) { + return new HandlerChannelItem(channel.code(), channel.name(), channel.role(), false, List.of()); + } + List users = jdbcTemplate.query(""" + select u.user_id, u.user_name, u.nick_name + from sys_user_role assignment + join sys_user u on u.user_id = assignment.user_id + where assignment.role_id = ? + and binary u.tenant_id = binary ? + and u.user_type <> ? + and u.status = '0' and u.del_flag = '0' + and not exists ( + select 1 + from sys_user_role elevated + join sys_role elevated_role on elevated_role.role_id = elevated.role_id + where elevated.user_id = u.user_id + and binary elevated_role.tenant_id = binary u.tenant_id + and elevated_role.role_key = ? + and elevated_role.status = '0' and elevated_role.del_flag = '0' + ) + order by u.nick_name, u.user_name, u.user_id + """, (rs, rowNum) -> new HandlerUserItem( + rs.getLong("user_id"), rs.getString("user_name"), rs.getString("nick_name") + ), role.roleId(), tenantId, UserType.APP_USER.getUserType(), TenantConstants.SUPER_ADMIN_ROLE_KEY); + return new HandlerChannelItem(channel.code(), channel.name(), channel.role(), true, users); + }).toList(); + return new HandlerConfigResponse(tenantId, MINIMUM_HANDLERS, items); + } + + private DirectRole directRole(String tenantId, DirectChannel channel) { + DirectRole role = optionalDirectRole(tenantId, channel); + if (role == null) { + throw new ServiceException(channel.name() + "处理角色不存在或已停用", HttpStatus.CONFLICT); + } + return role; + } + + private DirectRole optionalDirectRole(String tenantId, DirectChannel channel) { + List roles = jdbcTemplate.query(""" + select role_id, role_key + from sys_role + where binary tenant_id = binary ? and role_key = ? + and status = '0' and del_flag = '0' + order by role_id + limit 1 + """, (rs, rowNum) -> new DirectRole(rs.getLong("role_id"), rs.getString("role_key")), + tenantId, channel.role()); + return roles.isEmpty() ? null : roles.get(0); + } + + private void requireEligibleHandler(String tenantId, Long userId) { + if (userId == null || userId < 1) { + throw new ServiceException("处理人编号无效", HttpStatus.BAD_REQUEST); + } + Integer count = jdbcTemplate.queryForObject(""" + select count(*) + from sys_user u + where binary u.tenant_id = binary ? and u.user_id = ? + and u.user_type <> ? + and u.status = '0' and u.del_flag = '0' + and not exists ( + select 1 + from sys_user_role elevated + join sys_role elevated_role on elevated_role.role_id = elevated.role_id + where elevated.user_id = u.user_id + and binary elevated_role.tenant_id = binary u.tenant_id + and elevated_role.role_key = ? + and elevated_role.status = '0' and elevated_role.del_flag = '0' + ) + """, Integer.class, tenantId, userId, UserType.APP_USER.getUserType(), + TenantConstants.SUPER_ADMIN_ROLE_KEY); + if (count == null || count != 1) { + throw new ServiceException("只能选择当前租户启用的非超级管理员后台用户", HttpStatus.BAD_REQUEST); + } + } + + private static void requireHandlerAdmin(AihrKnowledgePrincipal principal) { + if (UserType.APP_USER.getUserType().equals(principal.userType()) + || !principal.roles().contains(TenantConstants.SUPER_ADMIN_ROLE_KEY)) { + throw new ServiceException("仅超级管理员可配置直达处理人", HttpStatus.FORBIDDEN); + } + } + private static Query adminQuery(String tenantId, List channelCodes, String status) { String placeholders = String.join(",", Collections.nCopies(channelCodes.size(), "?")); String statusFilter = status == null ? "" : " and status = ?"; @@ -410,6 +583,10 @@ public class AihrDirectService { return normalized; } + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + private static String adminTenant(AihrKnowledgePrincipal principal) { String tenantId = TenantHelper.getTenantId(); return tenantId == null || tenantId.isBlank() ? principal.tenantId() : tenantId.trim(); @@ -433,6 +610,9 @@ public class AihrDirectService { private record DirectChannel(String code, String name, String role, boolean defaultAnonymous) { } + private record DirectRole(long roleId, String roleKey) { + } + private record Replay(long id, String requestHash) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/direct/AihrDirectSecurityContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/direct/AihrDirectSecurityContractTest.java index 16682165..d703bffc 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/direct/AihrDirectSecurityContractTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/direct/AihrDirectSecurityContractTest.java @@ -2,6 +2,7 @@ package org.dromara.aihr.direct; import cn.dev33.satoken.annotation.SaCheckLogin; import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackItem; +import org.dromara.aihr.direct.AihrDirectDto.HandlerUserItem; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.constant.TenantConstants; @@ -62,6 +63,12 @@ class AihrDirectSecurityContractTest { assertTrue(AihrDirectController.class.isAnnotationPresent(SaCheckLogin.class)); assertTrue(AihrDirectService.class.getMethod("reply", Long.class, AihrDirectDto.ReplyRequest.class) .isAnnotationPresent(Transactional.class)); + assertTrue(AihrDirectService.class.getMethod( + "assignHandlers", String.class, AihrDirectDto.HandlerAssignRequest.class) + .isAnnotationPresent(Transactional.class)); + assertTrue(AihrDirectService.class.getMethod( + "removeHandler", String.class, AihrDirectDto.HandlerRemoveRequest.class) + .isAnnotationPresent(Transactional.class)); } @Test @@ -73,6 +80,29 @@ class AihrDirectSecurityContractTest { assertFalse(source.contains("o.ext_party_id = binary u.user_name")); } + @Test + void handlerConfigurationRejectsNonSuperadminsAndNeverReturnsPhoneNumbers() throws Exception { + Method guard = AihrDirectService.class.getDeclaredMethod( + "requireHandlerAdmin", AihrKnowledgePrincipal.class); + guard.setAccessible(true); + + InvocationTargetException handler = assertThrows(InvocationTargetException.class, + () -> guard.invoke(null, principal("sys_user", Set.of("direct_audit")))); + assertEquals(HttpStatus.FORBIDDEN, ((ServiceException) handler.getCause()).getCode()); + + List fields = List.of(HandlerUserItem.class.getRecordComponents()).stream() + .map(component -> component.getName().toLowerCase()) + .toList(); + assertFalse(fields.contains("phone")); + assertFalse(fields.contains("phonenumber")); + + String source = Files.readString(Path.of( + "src/main/java/org/dromara/aihr/direct/AihrDirectService.java")); + assertTrue(source.contains("u.user_type <> ?")); + assertTrue(source.contains("elevated_role.role_key = ?")); + assertTrue(source.contains("只能选择当前租户启用的非超级管理员后台用户")); + } + private static List allowedChannels(AihrKnowledgePrincipal principal) throws Exception { Method method = AihrDirectService.class.getDeclaredMethod("allowedChannels", AihrKnowledgePrincipal.class); method.setAccessible(true); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index dc294220..4686a48a 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -21,7 +21,7 @@ | 工作助手确认式统一采集 `/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`。当前仍不包含消息修订、撤回后补推或短信/电话强触达。 | -| 员工直通车 `/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` | 员工可选择总裁、财务、人力、审计、运营并点对点提交;反馈内容支持语音转文字输入(复用 `/api/ai/asr`;文字为主路径,录音不可用或权限失败仅提示改文字输入)。总裁/审计默认匿名;匿名仅表示业务处理界面不显示提交人,系统仍保存内部账号和姓名快照供本人查询、幂等与审计。`direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 仅处理各自频道,`superadmin` 可处理全部;列表、详情和回复均由服务端按角色收窄。一个反馈只允许一次正式回复,员工可在“我的反馈”查看状态和回复;当前不扩展为工单 SLA、转派或多轮聊天。 | +| 员工直通车 `/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 文件重新入队,无需用户重复上传;不提供浏览器目录选择或服务端目录导入入口 | | 组织人员同步 | `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,日常岗位变化使用下方增量接口。 | diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 16a8be8a..2f0ccdb1 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -12,6 +12,8 @@ > > 2026-07-25 直通车员工提交页新增语音转文字输入与渠道图标:语音复用既有 `/api/ai/asr` 链路,录音或权限失败仅提示改文字输入(表单页文字为主路径,不做选文件降级);本地自动化(150/150)与 mock API 浏览器验证通过,已推送 `origin/main`,未发布生产、未做真机验收。 > +> 2026-07-30 本地增量新增五通道处理人配置:只有 `superadmin` 可查看当前租户处理人、检索合格候选并增删角色绑定;服务端排除 APP 用户、超级管理员、停用/删除和跨租户账号,写请求复核 `expectedTenantId`,管理端按每通道至少 2 人展示就绪状态并在移除后不足时告警。全量后端 708 项、管理端定向 28 项和本地真实浏览器通过;生产新接口当前仍为 404、五个角色有效绑定仍均为 0,本项尚未发布且未写生产数据。 +> > 本文只维护当前结论、证据层级和剩余门槛。逐提交发布流水已从当前真相文档移除,需要追溯时使用 Git 历史;不得再用 2026-07-14/15 的“尚未发布”记录判断现网。 ## 1. 当前结论 diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 01df927c..5946ca3d 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -284,7 +284,7 @@ ORDER BY d.dimension_code; 4. 用正式测试账号在“工作助手”选择当前项目并说“帮我记一下三栋 3203 需要保洁服务”;确认前 `aihr_assistant_capture` 不得新增。分别验证 `PRIVATE` 仅本人可召回、`COMPANY` 只显示 `PENDING` 且没有虚假送达回执;新开对话按房号查询必须只命中对应项目记录,双击确认不得重复写入。测试表达如不需长期保留,应在确认卡选择“暂不保存”,不要直接删除生产审计记录。 5. 在“今日工作成果”按员工 + 项目 + 日期重复生成两次,内容不变时版本哈希必须一致;员工访问团队结果、主管访问非授权项目必须被拒绝。生产无主管正式测试账号时必须明确记录未验收,不以本地 seed 代替。 6. 用员工测试账号进入“成果投稿”,确认其仍只承载优秀案例、操作视频、完整 SOP 和有用知识,不回退为日常事实表单。重复提交同一 `request_key` 只有一条 `PENDING` 记录;普通员工审核仍为 `403`,审核通过不自动写入案例、知识或积分表。 -7. 用员工账号分别向总裁、财务、人力、审计和运营频道提交;匿名反馈在处理端不得显示提交人,但员工本人仍可查看。分别用对应处理角色验证只能看到本频道且只能正式回复一次;`superadmin` 验证全频道兜底。 +7. 由五通道业务负责人先书面指定主处理人和替补;后端与管理端发布完成后,`superadmin` 在 `/content/direct` 的“处理人配置”中只从当前租户合格候选人绑定,每通道至少 2 人。不得把 APP 用户、超级管理员、停用用户、跨租户用户或未经业务批准的账号绑定为真实收件人;配置接口必须回传并校验当前 `tenantId`,机器证据只记录角色人数而不记录人员身份。随后用员工账号分别向总裁、财务、人力、审计和运营频道提交;匿名反馈在处理端不得显示提交人,但员工本人仍可查看。分别用对应处理角色验证只能看到本频道且只能正式回复一次;`superadmin` 验证全频道兜底。 8. 用 `superadmin` 或 `hr_operator` 上传一个受支持文件,等待状态变为 `READY` 后随消息发布;员工详情必须显示摘要并能鉴权下载原文件,“问这条消息”应引用服务端提取内容。再验证失败文件不能发布、非在职或跨租户账号不能下载、撤回后不能下载或继续追问。 9. 用 `superadmin` 或 `hr_operator` 在当前租户发布同一 `requestId` 两次:同内容必须只生成一条消息和一个 v1 快照,改动正文后必须返回 `409`。撤回时提交非空原因,员工端随即不可见;重复撤回不得改写首个撤回人、时间或原因。再确认管理列表不跨租户返回消息。 10. 用正式移动端员工账号验证数字师傅 Agent:寒暄不得调用 RAG;本人待办/训练显示实时来源;普通图片不要求知识空间;现场制度问题只在授权空间补充 SOP;全网查询必须先返回同意卡;“记一下”确认前不落库。再检查 `aihr_agent_run` 只有主体、意图、工具、状态、来源和耗时等元数据,没有问题、答案或附件。生产无对应角色、媒体或撤权样本时必须记录未验收。 diff --git a/docs/帮道8月1日Android内测Go-No-Go-20260728.md b/docs/帮道8月1日Android内测Go-No-Go-20260728.md index 956c1d92..f990b8f3 100644 --- a/docs/帮道8月1日Android内测Go-No-Go-20260728.md +++ b/docs/帮道8月1日Android内测Go-No-Go-20260728.md @@ -1,6 +1,6 @@ # 帮道 8 月 1 日 Android 内测 Go/No-Go -> 评估时间:2026-07-29 23:42(CST) +> 评估时间:2026-07-30(CST) > > 评估对象:2026-08-01 受控内部测试 APK,不代表正式发布、应用商店上架或阶段一严格试点验收。 > @@ -10,22 +10,22 @@ **当前为 No-Go,暂不能把现有 APK 标记为 8 月 1 日可分发版本。** -`0.1.13` DCloud 云端证书 APK 已生成并通过深检,且在 vivo Android 15 真机完成真实页面登录、麦克风允许和生产 ASR;干净提交 `af8af2f6` 的统一 RC 曾达到 `releaseEligible=true`。真机继续发现该签名包的麦克风拒绝降级错误使用 App 不支持的 `uni.chooseFile`。当前 `0.1.16` 源码已修复 Android 系统选择器、SAF 复制、上传路径和选择器生命周期,并在标准调试基座跑通取消、重复打开、真实 WAV 选择、上传、生产 ASR 和无提交草稿整理;但 DCloud 当日云打包额度耗尽,尚无 `0.1.16` 签名 APK。因此生产后端不再阻断,当前阻断点是最终签名候选包复验、正式业务内容/五通道处理人签认、企业发布签认,以及真实断网、WebRTC、图片/视频/文档和受控下载等剩余真机门禁。 +`0.1.13` DCloud 云端证书 APK 已生成并通过深检,且在 vivo Android 15 真机完成真实页面登录、麦克风允许和生产 ASR;干净提交 `af8af2f6` 的统一 RC 曾达到 `releaseEligible=true`。随后生成的签名 `0.1.16 (116)` APK 也通过包名、API 35、明文流量和签名深检,但其内嵌 `app-service.js` 早于 2026-07-30 最终麦克风权限 Activity/窗口焦点修复,与当前 App-Plus 构建不一致,不能作为当前源码候选。当前源码已完成 Android 系统选择器、SAF 复制、上传路径和授权/选择器生命周期收口,并通过本地自动化与 App-Plus 构建;生产后端不再阻断,当前阻断点是用新版本号生成最终签名候选并同包复验、发布五通道配置能力后绑定经业务批准的处理人、正式业务内容与企业发布签认,以及真实断网、WebRTC、图片/视频/文档和受控下载等剩余真机门禁。 ## 已通过 | 检查项 | 当前结果 | |---|---| | 当前运行时冻结 | 干净提交 `af8af2f6dd169e4616a4ca0ca724e8b809502ab3` 的 `0.1.13 (113)` 统一 RC 已完成,`releaseEligible=true`;证据目录为 `output/aug1-rc/0.1.13-113-af8af2f6-frozen/`,候选后端 JAR SHA-256 为 `93AD272118F6A28F88C2BFA305F5C450E163CBE22577FF5BE3E85B3BD3958DAD` | -| 移动端自动化 | 当前 206 项单元测试通过;TypeScript 类型检查通过;新增 Android SAF、FileChannel、应用专属外部缓存、麦克风预检和系统选择器生命周期回归,五个文件选择器和九个消费页面继续覆盖取消/平台失败分流 | -| 构建 | `0.1.16` App 发布资源预检与 App-Plus 构建通过;历史统一 RC 的 H5、管理端和后端 JAR 构建证据保持。DCloud 当日云打包额度耗尽,当前没有 `0.1.16` 签名 APK,不能把源码构建当成已出包 | -| P0 后端语义 | `AihrSopSeedServiceTest` 39 项、`AihrPracticeSeedServiceTest` 102 项通过;`ruoyi-aihr` 700 项全量测试通过,0 失败、0 错误;新增 2 项覆盖有效主管身份与空身份失败关闭 | +| 移动端自动化 | 当前 208 项单元测试通过;TypeScript 类型检查通过;新增 Android SAF、FileChannel、应用专属外部缓存、麦克风预检、权限 Activity 焦点恢复和系统选择器生命周期回归,相关页面继续覆盖取消/平台失败分流 | +| 构建 | 当前 App 发布资源预检与 App-Plus 构建通过;管理端生产构建与 28 项定向测试通过。已生成的签名 `0.1.16` APK 早于最终麦克风生命周期修复,包内业务资源与当前构建不一致,不能把它当成当前源码出包 | +| P0 后端语义 | `ruoyi-aihr` 708 项全量测试通过,0 失败、0 错误;本轮新增五通道处理人配置的超级管理员、租户和候选人资格契约覆盖 | | 内容候选门禁 | 已生成 5 个禁用场景候选和 30 道待审政策题,机器校验通过;正式可发布场景 `0`、正式有据标准答案 `0`,不得导入或启用 | | 最终业务就绪门禁 | `verify-aug1-formal-content.mjs --strict` 和 `verify-aug1-release-readiness.sh --execute` 已落地并覆盖正反例;当前机器签认准确返回场景 `0/5`、问题证据 `0/30`、通道验收 `0/5`、负责人签认 `0/5`、受控分发 `PENDING` 并失败关闭。签认完成后还会只读核对生产固定码模式、五角色各至少两名有效处理人和五个已发布场景的版本/哈希/审核链/SOP 引用;不请求验证码、不登录、不发短信、不写数据库 | | App 资源门禁 | 正式法务 URL、登录页同意门禁、原生 `prompt=none`、API 35、图标和启动图检查通过 | -| 独立 APK | `com.yincheng.wygj`,`0.1.13 (113)`,`targetSdkVersion=35`,v2 测试签名,明文 HTTP 关闭且网络安全资源已进入 APK;大小 `33,921,543` 字节,SHA-256 `FA2D2A6DC84AE759714E6D16D146C1E1552A0F3F53C046069422012361D1D0C0` | +| 独立 APK | 最新生成文件为 `com.yincheng.wygj 0.1.16 (116)`,大小 `33,922,835` 字节,SHA-256 `183E67137CE9B73FC6D41A728779D40EB0122F8F62E1951B65DDC356203937F5`,`targetSdkVersion=35`、v2 DCloud 测试签名、明文 HTTP 关闭;但该包不含当前最终麦克风生命周期修复,不能作为当前候选。`0.1.13` 仍只保留为历史统一 RC | | 双提交交付包 | `output/aug1-release/bangdao-aug1-0.1.13-113-af8af2f6-ops46f4616b.zip`;运行时 `af8af2f6`、operations `46f4616b`,大小 `192,948,093` 字节,SHA-256 `F6DF4AB2660A4D3523C7E9785C1F8B572770F1F795A841E2D95C88936A8AE9C0`,20 个条目和内部哈希复核通过;严格门禁覆盖正式场景、问题证据、五类负责人、五通道授权/越权/单次正式回复和受控分发/法务/签名决策,包内 Go/No-Go 已指向当前双提交包,旧 `ops9c53668c` ZIP 只作历史追溯 | -| APK 内容门禁 | 包内业务资源等于当前 App-Plus 编译产物;原生提示关闭、登录法务链接、未勾选拦截、身份失败关闭、手机号、固定测试验证码、私钥和常见令牌扫描通过 | +| APK 内容门禁 | `0.1.16` 的原生提示、登录法务、未勾选拦截、身份失败关闭、敏感值和网络安全扫描通过;但包内 `app-service.js` SHA-256 为 `24BC971FE8A13DDCF6C4C2640A8F6C2241299A8C5B24A6452DE4D58CCF33CFAC`,当前构建为 `1A13F998BB43F3298CD40FE28FC85EEDBE0A9E9EDF5F0C6B7AD1FD7519C20C5B`,资源一致性门禁失败 | | 真机安装启动 | vivo V2443A(Android 15/API 35)完成当前 `0.1.13` 从 `0.1.6` 保留数据升级、同版本再次覆盖安装和 Launcher 冷启动;四项敏感权限保持未授权,未见 Crash/ANR | | 虚拟机安装与生命周期 | 本机 `Medium_Phone` Android 15/API 35 已全新安装 `0.1.12`;启动原生隐私层不可见、四项敏感权限为 `false`、未见 Crash/ANR。AVD 为 `x86_64`、APK 仅含 ARM ABI,Weex 跨架构转译实际报 `spinWaitPeer`/IPC timeout;原生外壳证据有效,页面主体既不判失败也不判通过,不向发布包增加 x86 ABI或 H5 兜底 | | Android 统一取证 | 当前 `0.1.13 (113)` 进程和前台 Activity 正常、原生隐私层 `not-visible`、四项敏感权限为 `false`、退出记录无 Crash/ANR;Home 后进程保持,热恢复约 `0.2s`。证据在 `output/aug1-rc/android-acceptance/20260729-140453-upgrade-0.1.13-cold-launch/` | @@ -40,8 +40,8 @@ ## No-Go 阻断项 -1. **最终签名候选包尚不存在。** `0.1.13` 的真实登录、麦克风允许和 ASR 已通过,但拒绝后的文件降级存在缺陷;`0.1.14/0.1.15` 仅为调查包且 APK 已删除。`0.1.16` 源码在标准调试基座完成系统选择器和音频 ASR 全链路,但 DCloud 当日云打包额度耗尽。额度恢复后必须生成、深检并真机复跑 `0.1.16` 签名 APK,调试基座不能替代该门禁。 -2. **核心业务的非写入认证入口已通过,正式业务闭环仍受内容和处理人阻断。** 认证账号下身份、项目范围、首页、训练历史/画像、五通道列表、我的反馈、大喇叭、考试、案例和全网能力只读入口均已通过;音频选择与 ASR 已在当前源码调试基座跑通,但正式“练”仍没有可发布的 5 个场景,五通道仍没有真实收件人,图片/视频/文档、受控下载、WebRTC 和真实断网恢复仍待最终签名包真机验收。不得通过创建测试内容或绑定任意用户制造假闭环。 +1. **当前源码对应的最终签名候选包尚不存在。** `0.1.13` 的真实登录、麦克风允许和 ASR 已通过,但拒绝后的文件降级存在缺陷;`0.1.14/0.1.15` 仅为调查包且 APK 已删除。已生成的 `0.1.16` 签名包早于最终权限 Activity/窗口焦点修复,包内业务资源与当前构建不一致。必须使用新版本号生成、深检并真机复跑同一签名 APK,调试基座和旧 `0.1.16` 均不能替代该门禁。 +2. **核心业务的非写入认证入口已通过,正式业务闭环仍受内容和处理人阻断。** 认证账号下身份、项目范围、首页、训练历史/画像、五通道列表、我的反馈、大喇叭、考试、案例和全网能力只读入口均已通过;五通道处理人配置已在本地实现并验证,但生产新接口仍为 404、五个角色有效绑定仍均为 0。正式“练”仍没有可发布的 5 个场景,图片/视频/文档、受控下载、WebRTC 和真实断网恢复仍待最终签名包真机验收。不得通过创建测试内容或绑定任意用户制造假闭环。 3. **内容与业务验收不足。** 当前仅收到 1/24 份生活顾问访谈。技术侧已把调研整理为 30 道政策题候选和 5 个场景候选,并提供统一签认单,但全部保持待审/禁用,正式可发布场景仍为 0/5,正式有据标准答案仍为 0/30。生产只读审计确认 `direct_president/direct_finance/direct_hr/direct_audit/direct_operations` 五个角色的有效用户绑定数均为 0,必须由业务负责人指定主处理人和替补后再验收。 4. **APK 分发与企业发布尚未签认。** 后端发布授权、备份、原子切换、发布后验证和回滚点已经闭环;最后一个签名 APK `0.1.13` 已关闭明文流量但不含当前选择器修复,`0.1.16` 尚未云打包。受控分发批准、测试签名接受、企业签名和公司法务正式签认仍是对应发布层级的门槛。 @@ -55,7 +55,7 @@ | 已完成 | 使用生产现有在职 APP 主体和固定码完成非真机认证、退出、重新获取、重新登录及认证后只读主路径;固定码请求不真实发送短信 | `scripts/verify-aug1-authenticated-production.sh --execute` 全部通过,五通道数为 5,旧令牌按业务码 401 拒绝,两次验证码请求均有“未真实发送”日志;执行过程不输出手机号、固定码或令牌 | | 1 | 业务方按统一签认单审核 30 道题和 5 个场景候选,补齐正式来源;同时指定五渠道主处理人和替补 | [业务内容与五通道签认单](帮道8月1日业务内容与五通道签认单-20260729.md)、正式内容版本、授权矩阵;正式可发布场景从 0/5 提升到 5/5、有据标准答案从 0/30 提升到 30/30,五个角色有效绑定均不少于 2 | | 2 | 绑定获批处理人后完成五通道正例、跨通道反例和一次正式回复;用正式内容完成“查/练/问”业务写入闭环 | 每条用例的时间、角色、结果和脱敏证据;不记录手机号、验证码或反馈正文 | -| 3 | DCloud 额度恢复后生成并深检 `0.1.16` 签名 APK,在 Android 真机复跑麦克风拒绝、系统选择器取消/选择、音频 ASR,并继续完成真实断网、WebRTC、图片/视频/文档和受控下载 | 新 APK 版本、哈希、签名和同一包对应的设备证据;当前标准调试基座只证明源码链路,不得替代签名包验收 | +| 3 | 使用新版本号(建议 `0.1.17 (117)`)生成并深检当前源码签名 APK,在 Android 真机复跑麦克风允许/拒绝、权限 Activity 返回、系统选择器取消/选择、音频 ASR,并继续完成真实断网、WebRTC、图片/视频/文档和受控下载 | 新 APK 版本、哈希、签名、包内业务资源与冻结构建一致,以及同一包对应的设备证据;当前标准调试基座和旧 `0.1.16` 均不得替代签名包验收 | | 4 | 7 月 31 日复跑安装、升级、回滚和完整 Go/No-Go | RC APK 哈希、签认人、回滚点、最终结论 | 公司法务签认与企业签名可在负责人明确接受“仅限小范围受控内部测试”的前提下不阻断技术验收,但不能省略登录页协议门禁、权限反例、版本冻结、生产依赖匹配和核心业务闭环。 diff --git a/docs/帮道8月1日业务内容与五通道签认单-20260729.md b/docs/帮道8月1日业务内容与五通道签认单-20260729.md index 0f3c146a..1f8b400d 100644 --- a/docs/帮道8月1日业务内容与五通道签认单-20260729.md +++ b/docs/帮道8月1日业务内容与五通道签认单-20260729.md @@ -10,7 +10,7 @@ ## 1. 当前结论 -技术侧已经把生活顾问调研转成可逐项审核的 5 个场景候选和 30 道问题候选,并完成五通道接口、固定码登录和认证后只读主路径验证。但业务方尚未提供正式制度来源、内容审核结论和五通道处理人,因此当前不能启用候选内容,也不能把“通道可选”解释成“有人收件”。 +技术侧已经把生活顾问调研转成可逐项审核的 5 个场景候选和 30 道问题候选,并完成五通道接口、固定码登录和认证后只读主路径验证。2026-07-30 又在本地完成 `superadmin` 五通道处理人配置入口和租户/用户资格保护,但该增量尚未发布,生产五个角色的有效绑定仍均为 0。业务方尚未提供正式制度来源、内容审核结论和五通道处理人,因此当前不能启用候选内容,也不能把“通道可选”解释成“有人收件”。 截至 2026-07-29 的签认基线: @@ -80,7 +80,7 @@ 每个通道的验收动作固定为: -1. 业务负责人签认主处理人和替补,由系统管理员绑定对应角色。 +1. 业务负责人签认主处理人和替补;相关后端与管理端发布后,由 `superadmin` 在 `/content/direct` 的“处理人配置”中从当前租户合格候选人绑定对应角色,每个通道至少 2 人。技术侧不得替业务方选择人员。 2. 处理人只能看到本通道;其他通道列表、详情和回复必须拒绝。 3. `superadmin` 仅作系统管理全局能力,不代替五个真实业务处理人。 4. 员工匿名提交时,处理端不显示提交人;系统仍保留内部审计身份。 @@ -134,7 +134,7 @@ 4. 五个通道分别完成绑定、正例访问、其他通道拒绝和一次正式回复验收,把四类证据引用与 SHA-256 回填到 `channelApprovals`;清单不记录处理人身份或反馈正文。 5. 受控分发负责人在 `distributionApproval` 中明确法务决定、DCloud 测试签名或企业签名决定、实际签名摘要和分发证据;只有受控内部测试范围允许填写明确的例外接受。 6. 五类业务负责人签认后,将顶层 `releaseStatus` 改为 `APPROVED`。任何缺项、占位值、未知来源、证据哈希缺失、高风险同人二审、通道验收不足或签名摘要不一致都会失败关闭。 -7. 系统管理员按本单绑定五通道主处理人和替补;机器门禁只输出每个角色的有效人数,不输出人员身份。 +7. 相关后端与管理端发布后,`superadmin` 按本单在 `/content/direct` 的“处理人配置”中绑定五通道主处理人和替补;候选列表由服务端排除 APP 用户、超级管理员、停用/删除、跨租户和已绑定账号,写请求复核当前租户。机器门禁只输出每个角色的有效人数,不输出人员身份。 先审计当前回填进度: diff --git a/frontend/src/api/aihr/direct.ts b/frontend/src/api/aihr/direct.ts index ff1f5f49..f2f4c5c4 100644 --- a/frontend/src/api/aihr/direct.ts +++ b/frontend/src/api/aihr/direct.ts @@ -26,6 +26,26 @@ export type DirectAdminPage = { rows: DirectAdminFeedback[]; }; +export type DirectHandlerUser = { + userId: number; + userName: string; + nickName?: string; +}; + +export type DirectHandlerChannel = { + channelCode: DirectChannelCode; + channelName: string; + roleKey: string; + roleAvailable: boolean; + users: DirectHandlerUser[]; +}; + +export type DirectHandlerConfig = { + tenantId: string; + minimumHandlers: number; + channels: DirectHandlerChannel[]; +}; + export const listDirectAdminFeedback = (params: { pageNum: number; pageSize: number; @@ -48,3 +68,39 @@ export const replyDirectFeedback = ( data, headers: { repeatSubmit: false } }); + +export const getDirectHandlerConfig = (): Promise> => + request({ + url: '/api/aihr/direct/admin/handlers', + method: 'get' + }); + +export const listDirectHandlerCandidates = ( + channelCode: DirectChannelCode, + keyword = '' +): Promise> => + request({ + url: `/api/aihr/direct/admin/handlers/${channelCode}/candidates`, + method: 'get', + params: { keyword } + }); + +export const assignDirectHandlers = ( + channelCode: DirectChannelCode, + data: { userIds: number[]; expectedTenantId: string } +): Promise> => + request({ + url: `/api/aihr/direct/admin/handlers/${channelCode}`, + method: 'post', + data + }); + +export const removeDirectHandler = ( + channelCode: DirectChannelCode, + data: { userId: number; expectedTenantId: string } +): Promise> => + request({ + url: `/api/aihr/direct/admin/handlers/${channelCode}/remove`, + method: 'post', + data + }); diff --git a/frontend/src/layout/components/Sidebar/index.test.ts b/frontend/src/layout/components/Sidebar/index.test.ts index 0a181cf1..f82cd7d8 100644 --- a/frontend/src/layout/components/Sidebar/index.test.ts +++ b/frontend/src/layout/components/Sidebar/index.test.ts @@ -14,7 +14,9 @@ describe('operations sidebar navigation contract', () => { expect(sidebar).toContain(' { expect(page).toContain('senderDisplayName'); expect(page).toContain('匿名反馈不会显示提交人'); expect(page).toContain('回复后不可覆盖'); + expect(page).toContain('五通道处理人配置'); + expect(api).toContain('/api/aihr/direct/admin/handlers'); + expect(page).toContain('移动端员工账号和超级管理员不会进入候选'); + expect(page).toContain('assignDirectHandlers'); + expect(page).toContain('removeDirectHandler'); expect(access).toContain('direct_audit'); expect(router).toContain("path: 'direct'"); expect(router).toContain('roles: directHandlerRoles'); diff --git a/frontend/src/utils/direct-handler-readiness.test.ts b/frontend/src/utils/direct-handler-readiness.test.ts new file mode 100644 index 00000000..a8dbee7b --- /dev/null +++ b/frontend/src/utils/direct-handler-readiness.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { + directHandlerDefinitions, + directHandlerReadinessText, + directHandlerReady, + minimumDirectHandlers, + normalizeDirectHandlerMinimum +} from './direct-handler-readiness'; + +describe('五通道处理人就绪规则', () => { + it('固定五个业务通道且每个通道至少需要主处理人和替补', () => { + expect(directHandlerDefinitions.map((item) => item.roleKey)).toEqual([ + 'direct_president', + 'direct_finance', + 'direct_hr', + 'direct_audit', + 'direct_operations' + ]); + expect(minimumDirectHandlers).toBe(2); + }); + + it('少于两名有效处理人时明确提示缺口', () => { + expect(directHandlerReady(0)).toBe(false); + expect(directHandlerReadinessText(0)).toBe('尚未配置'); + expect(directHandlerReadinessText(1)).toBe('还差 1 人'); + expect(directHandlerReady(2)).toBe(true); + expect(directHandlerReadinessText(3)).toBe('已配置 3 人'); + }); + + it('以后端返回的最低人数为准,异常值回退到默认规则', () => { + expect(directHandlerReady(2, 3)).toBe(false); + expect(directHandlerReadinessText(2, 3)).toBe('还差 1 人'); + expect(normalizeDirectHandlerMinimum(3)).toBe(3); + expect(normalizeDirectHandlerMinimum(0)).toBe(minimumDirectHandlers); + expect(normalizeDirectHandlerMinimum(undefined)).toBe(minimumDirectHandlers); + }); +}); diff --git a/frontend/src/utils/direct-handler-readiness.ts b/frontend/src/utils/direct-handler-readiness.ts new file mode 100644 index 00000000..6440cf26 --- /dev/null +++ b/frontend/src/utils/direct-handler-readiness.ts @@ -0,0 +1,32 @@ +export const minimumDirectHandlers = 2; + +export const directHandlerDefinitions = [ + { channelCode: 'PRESIDENT', channelName: '总裁直达', roleKey: 'direct_president' }, + { channelCode: 'FINANCE', channelName: '财务直达', roleKey: 'direct_finance' }, + { channelCode: 'HR', channelName: '人力直达', roleKey: 'direct_hr' }, + { channelCode: 'AUDIT', channelName: '审计直达', roleKey: 'direct_audit' }, + { channelCode: 'OPERATIONS', channelName: '运营直达', roleKey: 'direct_operations' } +] as const; + +export type DirectHandlerDefinition = (typeof directHandlerDefinitions)[number]; + +export const normalizeDirectHandlerMinimum = (minimumHandlers: unknown): number => + typeof minimumHandlers === 'number' && Number.isInteger(minimumHandlers) && minimumHandlers > 0 + ? minimumHandlers + : minimumDirectHandlers; + +export const directHandlerReady = ( + activeHandlerCount: number, + minimumHandlers: number = minimumDirectHandlers +): boolean => activeHandlerCount >= normalizeDirectHandlerMinimum(minimumHandlers); + +export const directHandlerReadinessText = ( + activeHandlerCount: number, + minimumHandlers: number = minimumDirectHandlers +): string => { + const requiredHandlers = normalizeDirectHandlerMinimum(minimumHandlers); + if (directHandlerReady(activeHandlerCount, requiredHandlers)) return `已配置 ${activeHandlerCount} 人`; + return activeHandlerCount === 0 + ? '尚未配置' + : `还差 ${requiredHandlers - activeHandlerCount} 人`; +}; diff --git a/frontend/src/views/content/direct.vue b/frontend/src/views/content/direct.vue index d883fa73..43f4c36a 100644 --- a/frontend/src/views/content/direct.vue +++ b/frontend/src/views/content/direct.vue @@ -15,6 +15,104 @@ title="每条反馈只允许一条正式回复。回复后不可覆盖,避免处理口径被静默修改。" /> +
+
+
+

五通道处理人配置

+

每个通道至少配置 {{ handlerMinimumHandlers }} 名有效后台用户,分别承担主处理和替补;配置角色不会自动产生回复或工单。

+
+ 刷新配置 +
+ +
+
+
+
+ {{ item.channelName }} + {{ item.roleKey }} +
+ + {{ directHandlerReadinessText(item.users.length, handlerMinimumHandlers) }} + +
+
+
+ {{ user.nickName || user.userName }} + {{ user.userName }} + + 解除 + +
+
+
尚无有效处理人,员工提交后将无人接收。
+ + 添加处理人 + + 角色不存在或已停用,请先检查系统角色。 +
+
+ +
+ + 搜索 +
+ + + + {{ candidate.nickName || candidate.userName }} + {{ candidate.userName }} + + + + +
+
+
@@ -104,14 +202,31 @@ diff --git a/frontend/src/views/knowledge/spaces.test.ts b/frontend/src/views/knowledge/spaces.test.ts index 729f9f07..59320348 100644 --- a/frontend/src/views/knowledge/spaces.test.ts +++ b/frontend/src/views/knowledge/spaces.test.ts @@ -3,7 +3,8 @@ import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; const frontendRoot = existsSync(resolve(process.cwd(), 'src')) ? process.cwd() : resolve(process.cwd(), 'frontend'); -const src = (...parts: string[]) => readFileSync(resolve(frontendRoot, 'src', ...parts), 'utf8'); +const src = (...parts: string[]) => + readFileSync(resolve(frontendRoot, 'src', ...parts), 'utf8').replace(/\r\n/g, '\n'); describe('knowledge space administration contract', () => { it('registers the controlled route and sidebar entry', () => { diff --git a/mobile-uni/src/pages/candidate/interview/index.vue b/mobile-uni/src/pages/candidate/interview/index.vue index 389f660a..352ea563 100644 --- a/mobile-uni/src/pages/candidate/interview/index.vue +++ b/mobile-uni/src/pages/candidate/interview/index.vue @@ -137,7 +137,7 @@