feat(release): close handler and microphone gaps
This commit is contained in:
@@ -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`。
|
||||
- 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。
|
||||
|
||||
@@ -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 迁移,不代表当前工作树与线上完整包全部匹配。
|
||||
|
||||
@@ -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 项资料,全部生成片段并入库。后端目录导入接口仅保留为运维/调试能力。
|
||||
|
||||
+33
@@ -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<AdminFeedbackItem> reply(@PathVariable Long id, @Valid @RequestBody ReplyRequest request) {
|
||||
return R.ok(directService.reply(id, request));
|
||||
}
|
||||
|
||||
@GetMapping("/admin/handlers")
|
||||
public R<HandlerConfigResponse> handlers() {
|
||||
return R.ok(directService.handlers());
|
||||
}
|
||||
|
||||
@GetMapping("/admin/handlers/{channelCode}/candidates")
|
||||
public R<List<HandlerUserItem>> handlerCandidates(
|
||||
@PathVariable String channelCode,
|
||||
@RequestParam(required = false) String keyword
|
||||
) {
|
||||
return R.ok(directService.handlerCandidates(channelCode, keyword));
|
||||
}
|
||||
|
||||
@PostMapping("/admin/handlers/{channelCode}")
|
||||
public R<HandlerConfigResponse> assignHandlers(
|
||||
@PathVariable String channelCode,
|
||||
@Valid @RequestBody HandlerAssignRequest request
|
||||
) {
|
||||
return R.ok(directService.assignHandlers(channelCode, request));
|
||||
}
|
||||
|
||||
@PostMapping("/admin/handlers/{channelCode}/remove")
|
||||
public R<HandlerConfigResponse> removeHandler(
|
||||
@PathVariable String channelCode,
|
||||
@Valid @RequestBody HandlerRemoveRequest request
|
||||
) {
|
||||
return R.ok(directService.removeHandler(channelCode, request));
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -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<AdminFeedbackItem> rows
|
||||
) {
|
||||
}
|
||||
|
||||
public record HandlerUserItem(
|
||||
Long userId,
|
||||
String userName,
|
||||
String nickName
|
||||
) {
|
||||
}
|
||||
|
||||
public record HandlerChannelItem(
|
||||
String channelCode,
|
||||
String channelName,
|
||||
String roleKey,
|
||||
boolean roleAvailable,
|
||||
List<HandlerUserItem> users
|
||||
) {
|
||||
}
|
||||
|
||||
public record HandlerConfigResponse(
|
||||
String tenantId,
|
||||
int minimumHandlers,
|
||||
List<HandlerChannelItem> 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+180
@@ -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<DirectChannel> 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<HandlerUserItem> 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<Long> 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<Replay> rows = jdbcTemplate.query("""
|
||||
select id, submit_request_hash
|
||||
@@ -297,6 +384,92 @@ public class AihrDirectService {
|
||||
return allowed;
|
||||
}
|
||||
|
||||
private HandlerConfigResponse handlerConfig(String tenantId) {
|
||||
List<HandlerChannelItem> 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<HandlerUserItem> 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<DirectRole> 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<String> 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) {
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -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<String> 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);
|
||||
|
||||
@@ -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,日常岗位变化使用下方增量接口。 |
|
||||
|
||||
@@ -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. 当前结论
|
||||
|
||||
@@ -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` 只有主体、意图、工具、状态、来源和耗时等元数据,没有问题、答案或附件。生产无对应角色、媒体或撤权样本时必须记录未验收。
|
||||
|
||||
@@ -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 哈希、签认人、回滚点、最终结论 |
|
||||
|
||||
公司法务签认与企业签名可在负责人明确接受“仅限小范围受控内部测试”的前提下不阻断技术验收,但不能省略登录页协议门禁、权限反例、版本冻结、生产依赖匹配和核心业务闭环。
|
||||
|
||||
@@ -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 用户、超级管理员、停用/删除、跨租户和已绑定账号,写请求复核当前租户。机器门禁只输出每个角色的有效人数,不输出人员身份。
|
||||
|
||||
先审计当前回填进度:
|
||||
|
||||
|
||||
@@ -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<ApiResult<DirectHandlerConfig>> =>
|
||||
request({
|
||||
url: '/api/aihr/direct/admin/handlers',
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
export const listDirectHandlerCandidates = (
|
||||
channelCode: DirectChannelCode,
|
||||
keyword = ''
|
||||
): Promise<ApiResult<DirectHandlerUser[]>> =>
|
||||
request({
|
||||
url: `/api/aihr/direct/admin/handlers/${channelCode}/candidates`,
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
});
|
||||
|
||||
export const assignDirectHandlers = (
|
||||
channelCode: DirectChannelCode,
|
||||
data: { userIds: number[]; expectedTenantId: string }
|
||||
): Promise<ApiResult<DirectHandlerConfig>> =>
|
||||
request({
|
||||
url: `/api/aihr/direct/admin/handlers/${channelCode}`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
export const removeDirectHandler = (
|
||||
channelCode: DirectChannelCode,
|
||||
data: { userId: number; expectedTenantId: string }
|
||||
): Promise<ApiResult<DirectHandlerConfig>> =>
|
||||
request({
|
||||
url: `/api/aihr/direct/admin/handlers/${channelCode}/remove`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
@@ -14,7 +14,9 @@ describe('operations sidebar navigation contract', () => {
|
||||
expect(sidebar).toContain('<router-link');
|
||||
expect(sidebar).toContain('canAccessOperations');
|
||||
expect(sidebar).toContain('operationsConsoleRoles');
|
||||
expect(router).toContain("export { operationsConsoleRoles, tenantGovernanceRoles } from './access';");
|
||||
expect(router).toContain(
|
||||
"export { contentConsoleRoles, directHandlerRoles, operationsConsoleRoles, tenantGovernanceRoles } from './access';"
|
||||
);
|
||||
expect(router).toContain('roles: operationsConsoleRoles');
|
||||
expect(guard).toContain('canAccessRoute(to)');
|
||||
expect(sidebar).toContain('aria-labelledby');
|
||||
|
||||
@@ -16,6 +16,11 @@ describe('直达反馈管理端契约', () => {
|
||||
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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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} 人`;
|
||||
};
|
||||
@@ -15,6 +15,104 @@
|
||||
title="每条反馈只允许一条正式回复。回复后不可覆盖,避免处理口径被静默修改。"
|
||||
/>
|
||||
|
||||
<section v-if="isSuperAdmin" class="handler-panel panel">
|
||||
<div class="handler-head">
|
||||
<div>
|
||||
<h2>五通道处理人配置</h2>
|
||||
<p>每个通道至少配置 {{ handlerMinimumHandlers }} 名有效后台用户,分别承担主处理和替补;配置角色不会自动产生回复或工单。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="handlerLoading" @click="loadHandlerConfig">刷新配置</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
:type="allHandlersReady ? 'success' : 'warning'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="allHandlersReady ? `五个通道均已配置至少 ${handlerMinimumHandlers} 名有效处理人` : `${readyHandlerChannelCount}/5 个通道达到处理人配置要求`"
|
||||
/>
|
||||
<div v-loading="handlerLoading" class="handler-grid">
|
||||
<article v-for="item in handlerConfigs" :key="item.roleKey" class="handler-card">
|
||||
<div class="handler-card-head">
|
||||
<div>
|
||||
<strong>{{ item.channelName }}</strong>
|
||||
<small>{{ item.roleKey }}</small>
|
||||
</div>
|
||||
<el-tag :type="directHandlerReady(item.users.length, handlerMinimumHandlers) ? 'success' : 'danger'">
|
||||
{{ directHandlerReadinessText(item.users.length, handlerMinimumHandlers) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="item.users.length" class="handler-users">
|
||||
<div v-for="user in item.users" :key="user.userId" class="handler-user">
|
||||
<span>{{ user.nickName || user.userName }}</span>
|
||||
<small>{{ user.userName }}</small>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:disabled="handlerMutating"
|
||||
@click="removeHandler(item, user)"
|
||||
>
|
||||
解除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="handler-empty">尚无有效处理人,员工提交后将无人接收。</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!item.roleAvailable || handlerMutating"
|
||||
@click="openHandlerSelector(item)"
|
||||
>
|
||||
添加处理人
|
||||
</el-button>
|
||||
<small v-if="!item.roleAvailable" class="handler-role-missing">角色不存在或已停用,请先检查系统角色。</small>
|
||||
</article>
|
||||
</div>
|
||||
<el-dialog
|
||||
v-model="handlerDialogVisible"
|
||||
:title="selectedHandlerChannel ? `添加${selectedHandlerChannel.channelName}处理人` : '添加处理人'"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="candidate-toolbar">
|
||||
<el-input
|
||||
v-model="candidateKeyword"
|
||||
clearable
|
||||
placeholder="按用户名或昵称搜索后台用户"
|
||||
@keyup.enter="loadHandlerCandidates"
|
||||
/>
|
||||
<el-button :loading="candidateLoading" @click="loadHandlerCandidates">搜索</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="仅列出当前租户已启用的后台用户;移动端员工账号和超级管理员不会进入候选。"
|
||||
/>
|
||||
<el-checkbox-group v-model="selectedCandidateUserIds" class="candidate-list">
|
||||
<el-checkbox
|
||||
v-for="candidate in handlerCandidates"
|
||||
:key="candidate.userId"
|
||||
:value="candidate.userId"
|
||||
border
|
||||
>
|
||||
{{ candidate.nickName || candidate.userName }}
|
||||
<small>{{ candidate.userName }}</small>
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<el-empty v-if="!candidateLoading && !handlerCandidates.length" description="暂无可添加的后台用户" />
|
||||
<template #footer>
|
||||
<el-button @click="handlerDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="handlerMutating"
|
||||
:disabled="!selectedCandidateUserIds.length"
|
||||
@click="assignSelectedHandlers"
|
||||
>
|
||||
确认添加
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
|
||||
<section class="filter-panel panel">
|
||||
<el-select v-model="channelCode" clearable placeholder="全部授权通道" @change="resetAndLoad">
|
||||
<el-option v-for="item in channels" :key="item.value" :label="item.label" :value="item.value" />
|
||||
@@ -104,14 +202,31 @@
|
||||
<script setup name="ContentDirect" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
assignDirectHandlers,
|
||||
getDirectHandlerConfig,
|
||||
listDirectHandlerCandidates,
|
||||
listDirectAdminFeedback,
|
||||
removeDirectHandler,
|
||||
replyDirectFeedback,
|
||||
type DirectAdminFeedback,
|
||||
type DirectChannelCode,
|
||||
type DirectFeedbackStatus
|
||||
type DirectFeedbackStatus,
|
||||
type DirectHandlerChannel,
|
||||
type DirectHandlerConfig as DirectHandlerConfigResponse,
|
||||
type DirectHandlerUser
|
||||
} from '@/api/aihr/direct';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import {
|
||||
directHandlerDefinitions,
|
||||
directHandlerReadinessText,
|
||||
directHandlerReady,
|
||||
minimumDirectHandlers,
|
||||
normalizeDirectHandlerMinimum
|
||||
} from '@/utils/direct-handler-readiness';
|
||||
|
||||
type DirectHandlerConfig = DirectHandlerChannel;
|
||||
|
||||
const channels: Array<{ value: DirectChannelCode; label: string }> = [
|
||||
{ value: 'PRESIDENT', label: '总裁直达' },
|
||||
@@ -132,9 +247,127 @@ const drawerVisible = ref(false);
|
||||
const selected = ref<DirectAdminFeedback>();
|
||||
const replyContent = ref('');
|
||||
const replying = ref(false);
|
||||
const userStore = useUserStore();
|
||||
const isSuperAdmin = computed(() => userStore.roles.includes('superadmin'));
|
||||
const handlerLoading = ref(false);
|
||||
const handlerMutating = ref(false);
|
||||
const handlerTenantId = ref('');
|
||||
const handlerMinimumHandlers = ref(minimumDirectHandlers);
|
||||
const handlerDialogVisible = ref(false);
|
||||
const selectedHandlerChannel = ref<DirectHandlerConfig>();
|
||||
const candidateKeyword = ref('');
|
||||
const candidateLoading = ref(false);
|
||||
const handlerCandidates = ref<DirectHandlerUser[]>([]);
|
||||
const selectedCandidateUserIds = ref<number[]>([]);
|
||||
const handlerConfigs = ref<DirectHandlerConfig[]>(
|
||||
directHandlerDefinitions.map((item) => ({ ...item, roleAvailable: false, users: [] }))
|
||||
);
|
||||
const readyHandlerChannelCount = computed(() =>
|
||||
handlerConfigs.value.filter((item) =>
|
||||
directHandlerReady(item.users.length, handlerMinimumHandlers.value)
|
||||
).length
|
||||
);
|
||||
const allHandlersReady = computed(() => readyHandlerChannelCount.value === directHandlerDefinitions.length);
|
||||
|
||||
const displayTime = (value?: string) => value ? value.replace('T', ' ').slice(0, 16) : '—';
|
||||
|
||||
const applyHandlerConfig = (config: DirectHandlerConfigResponse) => {
|
||||
handlerTenantId.value = config.tenantId;
|
||||
handlerMinimumHandlers.value = normalizeDirectHandlerMinimum(config.minimumHandlers);
|
||||
handlerConfigs.value = config.channels || [];
|
||||
};
|
||||
|
||||
const loadHandlerConfig = async () => {
|
||||
if (!isSuperAdmin.value || handlerLoading.value) return;
|
||||
handlerLoading.value = true;
|
||||
try {
|
||||
const result = await getDirectHandlerConfig();
|
||||
applyHandlerConfig(result.data);
|
||||
} catch {
|
||||
handlerTenantId.value = '';
|
||||
handlerMinimumHandlers.value = minimumDirectHandlers;
|
||||
handlerConfigs.value = directHandlerDefinitions.map((item) => ({ ...item, roleAvailable: false, users: [] }));
|
||||
ElMessage.error('五通道处理人配置读取失败');
|
||||
} finally {
|
||||
handlerLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openHandlerSelector = (item: DirectHandlerConfig) => {
|
||||
if (!item.roleAvailable) return;
|
||||
selectedHandlerChannel.value = item;
|
||||
candidateKeyword.value = '';
|
||||
selectedCandidateUserIds.value = [];
|
||||
handlerCandidates.value = [];
|
||||
handlerDialogVisible.value = true;
|
||||
void loadHandlerCandidates();
|
||||
};
|
||||
|
||||
const loadHandlerCandidates = async () => {
|
||||
if (!selectedHandlerChannel.value || candidateLoading.value) return;
|
||||
candidateLoading.value = true;
|
||||
try {
|
||||
const result = await listDirectHandlerCandidates(
|
||||
selectedHandlerChannel.value.channelCode,
|
||||
candidateKeyword.value.trim()
|
||||
);
|
||||
handlerCandidates.value = result.data || [];
|
||||
selectedCandidateUserIds.value = selectedCandidateUserIds.value.filter((userId) =>
|
||||
handlerCandidates.value.some((candidate) => candidate.userId === userId)
|
||||
);
|
||||
} catch {
|
||||
handlerCandidates.value = [];
|
||||
selectedCandidateUserIds.value = [];
|
||||
ElMessage.error('候选处理人读取失败');
|
||||
} finally {
|
||||
candidateLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const assignSelectedHandlers = async () => {
|
||||
if (!selectedHandlerChannel.value || !selectedCandidateUserIds.value.length || handlerMutating.value) return;
|
||||
handlerMutating.value = true;
|
||||
try {
|
||||
const result = await assignDirectHandlers(selectedHandlerChannel.value.channelCode, {
|
||||
userIds: selectedCandidateUserIds.value,
|
||||
expectedTenantId: handlerTenantId.value
|
||||
});
|
||||
applyHandlerConfig(result.data);
|
||||
handlerDialogVisible.value = false;
|
||||
ElMessage.success('处理人已绑定;新角色将在该用户下次登录后生效');
|
||||
} finally {
|
||||
handlerMutating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeHandler = async (item: DirectHandlerConfig, user: DirectHandlerUser) => {
|
||||
if (!item.roleAvailable || handlerMutating.value) return;
|
||||
const remainingHandlers = Math.max(0, item.users.length - 1);
|
||||
const readinessWarning = directHandlerReady(remainingHandlers, handlerMinimumHandlers.value)
|
||||
? ''
|
||||
: `\n解除后该通道仅剩 ${remainingHandlers} 名有效处理人,不满足主处理和替补要求。`;
|
||||
await ElMessageBox.confirm(
|
||||
`确认解除“${user.nickName || user.userName}”的${item.channelName}处理权限?${readinessWarning}`,
|
||||
'解除处理人',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认解除',
|
||||
cancelButtonText: '取消'
|
||||
}
|
||||
);
|
||||
handlerMutating.value = true;
|
||||
try {
|
||||
const result = await removeDirectHandler(item.channelCode, {
|
||||
userId: user.userId,
|
||||
expectedTenantId: handlerTenantId.value
|
||||
});
|
||||
applyHandlerConfig(result.data);
|
||||
ElMessage.success('处理人权限已解除;已登录会话可能需要重新登录后更新');
|
||||
} finally {
|
||||
handlerMutating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -188,9 +421,12 @@ const submitReply = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void loadHandlerConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.direct-admin-page{padding:24px;color:#172033}.page-head,.filter-panel,.detail-meta{display:flex;align-items:center;gap:12px}.page-head{justify-content:space-between;margin-bottom:16px}.page-head h1{margin:0;font-size:28px}.page-head p{margin:8px 0 0;color:#667085}.panel{border:1px solid #e4e7ec;border-radius:12px;background:#fff;box-shadow:0 4px 18px rgba(16,24,40,.05)}.filter-panel{margin-top:14px;padding:14px}.filter-panel .el-select{width:180px}.table-panel{margin-top:14px;padding:8px}.pagination{justify-content:flex-end;padding:14px}.detail-meta{flex-wrap:wrap}.detail-time{color:#667085;font-size:13px}.feedback-copy,.reply-copy{white-space:pre-wrap;overflow-wrap:anywhere;padding:16px;border-radius:10px;background:#f7f8fa;line-height:1.8}.reply-copy{border-left:3px solid #c91825;background:#fff7f7}.reply-form{margin-top:24px}@media(max-width:900px){.direct-admin-page{padding:16px}.page-head{align-items:flex-start}.filter-panel{flex-wrap:wrap}}
|
||||
.direct-admin-page{padding:24px;color:#172033}.page-head,.filter-panel,.detail-meta,.handler-head,.handler-card-head,.handler-user,.candidate-toolbar{display:flex;align-items:center;gap:12px}.page-head,.handler-head{justify-content:space-between;margin-bottom:16px}.page-head h1,.handler-head h2{margin:0}.page-head h1{font-size:28px}.handler-head h2{font-size:20px}.page-head p,.handler-head p{margin:8px 0 0;color:#667085}.panel{border:1px solid #e4e7ec;border-radius:12px;background:#fff;box-shadow:0 4px 18px rgba(16,24,40,.05)}.handler-panel{margin-top:14px;padding:18px}.handler-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;min-height:120px;margin-top:14px}.handler-card{display:flex;min-width:0;flex-direction:column;gap:12px;padding:14px;border:1px solid #e4e7ec;border-radius:10px;background:#f8fafc}.handler-card-head{align-items:flex-start;justify-content:space-between}.handler-card-head strong,.handler-card-head small{display:block}.handler-card-head small,.handler-user small,.handler-role-missing,.candidate-list small{color:#667085;font-size:11px}.handler-users{display:grid;gap:7px}.handler-user{min-width:0;padding:7px 8px;border-radius:8px;background:#fff}.handler-user span{min-width:0;overflow:hidden;flex:1;text-overflow:ellipsis;white-space:nowrap}.handler-user small{display:none}.handler-empty{min-height:38px;color:#b42318;font-size:12px;line-height:1.55}.candidate-toolbar{margin-bottom:12px}.candidate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:14px}.candidate-list :deep(.el-checkbox){box-sizing:border-box;width:100%;height:auto;min-height:44px;margin:0}.candidate-list small{display:block;margin-top:2px}.filter-panel{margin-top:14px;padding:14px}.filter-panel .el-select{width:180px}.table-panel{margin-top:14px;padding:8px}.pagination{justify-content:flex-end;padding:14px}.detail-meta{flex-wrap:wrap}.detail-time{color:#667085;font-size:13px}.feedback-copy,.reply-copy{white-space:pre-wrap;overflow-wrap:anywhere;padding:16px;border-radius:10px;background:#f7f8fa;line-height:1.8}.reply-copy{border-left:3px solid #c91825;background:#fff7f7}.reply-form{margin-top:24px}@media(max-width:1400px){.handler-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:900px){.direct-admin-page{padding:16px}.page-head,.handler-head{align-items:flex-start}.filter-panel{flex-wrap:wrap}.handler-grid,.candidate-list{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
import type {
|
||||
AsrResponse,
|
||||
InterviewFinishResponse,
|
||||
@@ -501,7 +501,7 @@ onShow(() => {
|
||||
|
||||
onHide(() => {
|
||||
speechPlayback.stop();
|
||||
if (!isPlatformFileSelectionActive()) cancelQuestionRecording();
|
||||
if (!isPlatformNativeActivityActive()) cancelQuestionRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
|
||||
@@ -177,6 +177,7 @@ import { resetPageScroll } from '@/services/navigation';
|
||||
import { createPageRequestScope } from '@/services/request-scope';
|
||||
import { startSpeechCapture, type SpeechCapture, type SpeechCaptureSession } from '@/services/speech-capture';
|
||||
import { transcribeSpeechCapture } from '@/services/speech';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
|
||||
const path = '/pages/user/direct/index';
|
||||
const pageSize = 20;
|
||||
@@ -485,7 +486,7 @@ onShow(async () => {
|
||||
});
|
||||
onHide(() => {
|
||||
requests.invalidate();
|
||||
teardownVoice();
|
||||
if (!isPlatformNativeActivityActive()) teardownVoice();
|
||||
});
|
||||
onUnload(() => {
|
||||
requests.invalidate();
|
||||
|
||||
@@ -503,7 +503,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||
import RealtimePracticeAppBridge from '@/components/practice/RealtimePracticeAppBridge.vue';
|
||||
import type {
|
||||
@@ -1852,7 +1852,7 @@ onHide(() => {
|
||||
stopRealtime();
|
||||
invalidateRequests();
|
||||
speechPlayback.stop();
|
||||
if (!isPlatformFileSelectionActive()) cancelRecording();
|
||||
if (!isPlatformNativeActivityActive()) cancelRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { chooseKnowledgeMedia } from '@/services/knowledge';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
@@ -784,7 +784,7 @@ const teardown = (preservePlatformFileSelection = false) => {
|
||||
activeUploadTask?.abort();
|
||||
activeUploadTask = null;
|
||||
speechPlayback.stop();
|
||||
composerRef.value?.teardown();
|
||||
if (!preservePlatformFileSelection) composerRef.value?.teardown();
|
||||
organizing.value = false;
|
||||
uploading.value = false;
|
||||
submitting.value = false;
|
||||
@@ -838,7 +838,7 @@ onShow(() => {
|
||||
}
|
||||
});
|
||||
|
||||
onHide(() => teardown(isPlatformFileSelectionActive()));
|
||||
onHide(() => teardown(isPlatformNativeActivityActive()));
|
||||
onUnload(() => {
|
||||
teardown();
|
||||
speechPlayback.destroy();
|
||||
|
||||
@@ -424,7 +424,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import { isPlatformFileSelectionActive } from '@/services/file-selection-lifecycle';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
BroadcastKnowledgeContext,
|
||||
@@ -1565,8 +1565,10 @@ onHide(() => {
|
||||
candidateRefreshGeneration += 1;
|
||||
cancelPendingBroadcastConsultation();
|
||||
speechPlayback.stop();
|
||||
composerRef.value?.teardown();
|
||||
if (!isPlatformFileSelectionActive()) cancelVoiceTranscription();
|
||||
if (!isPlatformNativeActivityActive()) {
|
||||
composerRef.value?.teardown();
|
||||
cancelVoiceTranscription();
|
||||
}
|
||||
cancelPendingMediaSearch();
|
||||
});
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ import { ensureEmployeePosition } from '@/services/position';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
import { getBroadcastUnreadCount } from '@/services/broadcast';
|
||||
import { isFileSelectionCancelled } from '@/utils/file-selection';
|
||||
import { isPlatformNativeActivityActive } from '@/services/file-selection-lifecycle';
|
||||
|
||||
const home = ref<RoleHome | null>(null);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
@@ -578,8 +579,10 @@ onShow(() => {
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
holdGeneration += 1;
|
||||
cancelHold();
|
||||
if (!isPlatformNativeActivityActive()) {
|
||||
holdGeneration += 1;
|
||||
cancelHold();
|
||||
}
|
||||
requests.invalidate();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
let activePlatformFileSelections = 0;
|
||||
let activePlatformPermissionActivities = 0;
|
||||
|
||||
export const isPlatformFileSelectionActive = () => activePlatformFileSelections > 0;
|
||||
export const isPlatformNativeActivityActive = () =>
|
||||
activePlatformFileSelections > 0 || activePlatformPermissionActivities > 0;
|
||||
|
||||
export const withPlatformFileSelection = async <T>(select: () => Promise<T>): Promise<T> => {
|
||||
activePlatformFileSelections += 1;
|
||||
@@ -10,3 +13,12 @@ export const withPlatformFileSelection = async <T>(select: () => Promise<T>): Pr
|
||||
activePlatformFileSelections = Math.max(0, activePlatformFileSelections - 1);
|
||||
}
|
||||
};
|
||||
|
||||
export const withPlatformPermissionActivity = async <T>(request: () => Promise<T>): Promise<T> => {
|
||||
activePlatformPermissionActivities += 1;
|
||||
try {
|
||||
return await request();
|
||||
} finally {
|
||||
activePlatformPermissionActivities = Math.max(0, activePlatformPermissionActivities - 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,14 +8,25 @@
|
||||
* Android 运行时授权框。已授权设备上本调用为无害空转;若确实不弹,则它是唯一的授权入口。
|
||||
*/
|
||||
|
||||
import { withPlatformPermissionActivity } from '../services/file-selection-lifecycle';
|
||||
|
||||
const RECORD_AUDIO = 'android.permission.RECORD_AUDIO';
|
||||
|
||||
/** 授权申请超时:回调未触发时不能无限挂起,放行交由 getUserMedia 兜底。 */
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
/** Android 权限 Activity 返回后,必须等应用窗口重新获得焦点再启动录音或文件选择。 */
|
||||
const ACTIVITY_FOCUS_POLL_MS = 50;
|
||||
const ACTIVITY_FOCUS_TIMEOUT_MS = 1500;
|
||||
|
||||
interface AndroidActivityLike {
|
||||
hasWindowFocus?: () => boolean;
|
||||
}
|
||||
|
||||
export type MicPermissionResult = 'granted' | 'denied' | 'denied-always' | 'unsupported';
|
||||
|
||||
interface PlusAndroidLike {
|
||||
importClass?: (target: unknown) => unknown;
|
||||
runtimeMainActivity?: () => AndroidActivityLike;
|
||||
requestPermissions?: (
|
||||
permissions: string[],
|
||||
success: (event: { granted?: string[]; deniedPresent?: string[]; deniedAlways?: string[] }) => void,
|
||||
@@ -28,6 +39,42 @@ const androidBridge = (): PlusAndroidLike | null => {
|
||||
return plusApi?.android ?? null;
|
||||
};
|
||||
|
||||
const settleAfterPermissionActivity = (
|
||||
android: PlusAndroidLike,
|
||||
settle: (result: MicPermissionResult) => void,
|
||||
result: MicPermissionResult
|
||||
) => {
|
||||
let activity: AndroidActivityLike | undefined;
|
||||
try {
|
||||
activity = android.runtimeMainActivity?.();
|
||||
if (activity) android.importClass?.(activity);
|
||||
} catch {
|
||||
// 某些旧基座不暴露主 Activity;仍至少跨过当前权限回调任务。
|
||||
}
|
||||
if (!activity?.hasWindowFocus) {
|
||||
setTimeout(() => settle(result), ACTIVITY_FOCUS_POLL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + ACTIVITY_FOCUS_TIMEOUT_MS;
|
||||
const poll = () => {
|
||||
let focused = false;
|
||||
try {
|
||||
focused = Boolean(activity?.hasWindowFocus?.());
|
||||
} catch {
|
||||
settle(result);
|
||||
return;
|
||||
}
|
||||
if (focused || Date.now() >= deadline) {
|
||||
settle(result);
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, ACTIVITY_FOCUS_POLL_MS);
|
||||
};
|
||||
// 即使回调时已经恢复焦点,也跨过一次事件循环,避免和权限 Activity 的 finish 竞争。
|
||||
setTimeout(poll, ACTIVITY_FOCUS_POLL_MS);
|
||||
};
|
||||
|
||||
/**
|
||||
* 主动申请麦克风权限。
|
||||
* 非 Android App 运行时(H5、iOS)返回 unsupported,由各自平台的原生授权流程处理。
|
||||
@@ -36,7 +83,7 @@ export const requestMicrophonePermission = (): Promise<MicPermissionResult> => {
|
||||
const android = androidBridge();
|
||||
if (!android?.requestPermissions) return Promise.resolve('unsupported');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
return withPlatformPermissionActivity(() => new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result: MicPermissionResult) => {
|
||||
if (settled) return;
|
||||
@@ -50,17 +97,19 @@ export const requestMicrophonePermission = (): Promise<MicPermissionResult> => {
|
||||
android.requestPermissions!(
|
||||
[RECORD_AUDIO],
|
||||
(event) => {
|
||||
if (event?.granted?.length) settle('granted');
|
||||
else if (event?.deniedAlways?.length) settle('denied-always');
|
||||
else if (event?.deniedPresent?.length) settle('denied');
|
||||
else settle('denied');
|
||||
const result: MicPermissionResult = event?.granted?.length
|
||||
? 'granted'
|
||||
: event?.deniedAlways?.length
|
||||
? 'denied-always'
|
||||
: 'denied';
|
||||
settleAfterPermissionActivity(android, settle, result);
|
||||
},
|
||||
() => settle('unsupported')
|
||||
);
|
||||
} catch {
|
||||
settle('unsupported');
|
||||
}
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
/** 权限被拒时的中文引导文案。 */
|
||||
|
||||
@@ -195,6 +195,19 @@ test('Android 文件选择期间保持页面请求有效,并在完成后恢复
|
||||
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), false);
|
||||
});
|
||||
|
||||
test('Android 权限 Activity 期间保持原生交互状态,但不冒充文件选择', async () => {
|
||||
let finishPermission;
|
||||
const permission = fileSelectionLifecycle.withPlatformPermissionActivity(() =>
|
||||
new Promise((resolve) => { finishPermission = resolve; })
|
||||
);
|
||||
|
||||
assert.equal(fileSelectionLifecycle.isPlatformNativeActivityActive(), true);
|
||||
assert.equal(fileSelectionLifecycle.isPlatformFileSelectionActive(), false);
|
||||
finishPermission('denied');
|
||||
assert.equal(await permission, 'denied');
|
||||
assert.equal(fileSelectionLifecycle.isPlatformNativeActivityActive(), false);
|
||||
});
|
||||
|
||||
test('Android App 图片和视频使用系统媒体选择器', async () => {
|
||||
globalThis.plus = androidPickerRuntime();
|
||||
globalThis.uni = {
|
||||
@@ -251,19 +264,23 @@ test('所有使用文件选择器的页面都按取消信号恢复空闲态而
|
||||
assert.match(pages[6], /isFileSelectionCancelled\(error\)[\s\S]*?message\.value = ''[\s\S]*?return/);
|
||||
});
|
||||
|
||||
test('会作废转写请求的页面在 Android 系统选择器打开时保留选择任务', async () => {
|
||||
test('会作废转写请求的页面在 Android 系统权限框或选择器打开时保留选择任务', async () => {
|
||||
const pages = await Promise.all([
|
||||
'../src/pages/candidate/interview/index.vue',
|
||||
'../src/pages/user/practice/index.vue',
|
||||
'../src/pages/user/sop/index.vue',
|
||||
'../src/pages/user/report/index.vue'
|
||||
'../src/pages/user/report/index.vue',
|
||||
'../src/pages/user/today/index.vue',
|
||||
'../src/pages/user/direct/index.vue'
|
||||
].map(read));
|
||||
|
||||
for (const page of pages) assert.match(page, /isPlatformFileSelectionActive\(\)/);
|
||||
assert.match(pages[0], /if \(!isPlatformFileSelectionActive\(\)\) cancelQuestionRecording\(\)/);
|
||||
assert.match(pages[1], /if \(!isPlatformFileSelectionActive\(\)\) cancelRecording\(\)/);
|
||||
assert.match(pages[2], /if \(!isPlatformFileSelectionActive\(\)\) cancelVoiceTranscription\(\)/);
|
||||
assert.match(pages[3], /teardown\(isPlatformFileSelectionActive\(\)\)/);
|
||||
for (const page of pages) assert.match(page, /isPlatformNativeActivityActive\(\)/);
|
||||
assert.match(pages[0], /if \(!isPlatformNativeActivityActive\(\)\) cancelQuestionRecording\(\)/);
|
||||
assert.match(pages[1], /if \(!isPlatformNativeActivityActive\(\)\) cancelRecording\(\)/);
|
||||
assert.match(pages[2], /if \(!isPlatformNativeActivityActive\(\)\)[\s\S]*?cancelVoiceTranscription\(\)/);
|
||||
assert.match(pages[3], /teardown\(isPlatformNativeActivityActive\(\)\)/);
|
||||
assert.match(pages[4], /if \(!isPlatformNativeActivityActive\(\)\)[\s\S]*?holdGeneration \+= 1/);
|
||||
assert.match(pages[5], /if \(!isPlatformNativeActivityActive\(\)\) teardownVoice\(\)/);
|
||||
});
|
||||
|
||||
test('Android SAF 复制完全在 Java FileChannel 内完成,不回退 Native.js byte 数组', async () => {
|
||||
|
||||
@@ -54,6 +54,7 @@ test('Android 运行时主动申请 RECORD_AUDIO 并区分拒绝与永久拒绝'
|
||||
const requested = [];
|
||||
globalThis.plus = {
|
||||
android: {
|
||||
runtimeMainActivity: () => ({ hasWindowFocus: () => true }),
|
||||
requestPermissions: (permissions, success) => {
|
||||
requested.push(...permissions);
|
||||
success(pendingResult);
|
||||
@@ -75,6 +76,42 @@ test('Android 运行时主动申请 RECORD_AUDIO 并区分拒绝与永久拒绝'
|
||||
delete globalThis.plus;
|
||||
});
|
||||
|
||||
test('权限 Activity 未归还窗口焦点前不结束授权流程,避免文件选择器被系统吞掉', async () => {
|
||||
let focused = false;
|
||||
let importedActivity = false;
|
||||
const activity = { hasWindowFocus: () => focused };
|
||||
globalThis.plus = {
|
||||
android: {
|
||||
importClass: (target) => {
|
||||
assert.equal(target, activity);
|
||||
importedActivity = true;
|
||||
},
|
||||
runtimeMainActivity: () => activity,
|
||||
requestPermissions: (_permissions, success) => {
|
||||
success({ deniedAlways: ['android.permission.RECORD_AUDIO'] });
|
||||
}
|
||||
}
|
||||
};
|
||||
const { requestMicrophonePermission } = await load();
|
||||
const lifecycle = await vite.ssrLoadModule('/src/services/file-selection-lifecycle.ts');
|
||||
let resolved = false;
|
||||
const permission = requestMicrophonePermission().then((result) => {
|
||||
resolved = true;
|
||||
return result;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(importedActivity, true);
|
||||
assert.equal(lifecycle.isPlatformNativeActivityActive(), true);
|
||||
assert.equal(lifecycle.isPlatformFileSelectionActive(), false);
|
||||
assert.equal(resolved, false);
|
||||
|
||||
focused = true;
|
||||
assert.equal(await permission, 'denied-always');
|
||||
assert.equal(resolved, true);
|
||||
assert.equal(lifecycle.isPlatformNativeActivityActive(), false);
|
||||
});
|
||||
|
||||
test('非 App 运行时返回 unsupported,交由平台原生授权流程处理', async () => {
|
||||
delete globalThis.plus;
|
||||
const { requestMicrophonePermission } = await load();
|
||||
|
||||
@@ -115,7 +115,7 @@ test('异步请求具备 generation、上传取消和离页 teardown', async ()
|
||||
assert.match(page, /:disabled="draftLocked \|\| submitActionDisabled"/);
|
||||
assert.match(page, /composerRef\.value\?\.teardown\(\)/);
|
||||
assert.match(page, /speechPlayback\.stop\(\)/);
|
||||
assert.match(page, /onHide\(\(\) => teardown\(isPlatformFileSelectionActive\(\)\)\)/);
|
||||
assert.match(page, /onHide\(\(\) => teardown\(isPlatformNativeActivityActive\(\)\)\)/);
|
||||
assert.match(page, /onUnload/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user