From a07bbcca07feae817b792dfdbb27f74cf538d4a5 Mon Sep 17 00:00:00 2001 From: key Date: Sun, 2 Aug 2026 22:04:09 +0800 Subject: [PATCH] fix: support authenticated video citation playback --- .../AihrKnowledgeResourceDownloadService.java | 4 +- .../AihrKnowledgeResourceRangeTest.java | 19 +++++++++ docs/API_INTEGRATION.md | 4 +- ...026-07-31-mobile-citation-source-viewer.md | 42 +++++++++---------- mobile-uni/src/services/api.ts | 7 ++++ mobile-uni/src/services/knowledge.ts | 4 +- .../tests/knowledge-citation-detail.test.mjs | 29 +++++++++++++ 7 files changed, 83 insertions(+), 26 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeResourceDownloadService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeResourceDownloadService.java index 7a12ad74..09dd835d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeResourceDownloadService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeResourceDownloadService.java @@ -56,7 +56,9 @@ public class AihrKnowledgeResourceDownloadService { } response.setHeader("Cache-Control", "no-store"); response.setHeader("Referrer-Policy", "no-referrer"); - ossService.download(ossId, response); + // Browsers may probe media without a Range header first. Keep that response + // inline and preserve the source MIME type so the video element can decode it. + ossService.downloadRange(ossId, null, response); } public void download(Long attachmentId, String ticket, String range, HttpServletResponse response) throws IOException { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceRangeTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceRangeTest.java index 1e0ecf93..5d573089 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceRangeTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceRangeTest.java @@ -10,12 +10,31 @@ import org.redisson.api.RBucket; import org.redisson.api.RedissonClient; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @Tag("dev") class AihrKnowledgeResourceRangeTest { + @Test + void streamsInitialMediaRequestInlineWithDetectedMimeType() throws Exception { + AihrKnowledgeQueryService query = mock(AihrKnowledgeQueryService.class); + ISysOssService oss = mock(ISysOssService.class); + RedissonClient redisson = mock(RedissonClient.class); + @SuppressWarnings("unchecked") RBucket bucket = mock(RBucket.class); + HttpServletResponse response = mock(HttpServletResponse.class); + when(redisson.getBucket(anyString())).thenReturn(bucket); + when(bucket.get()).thenReturn(777L); + String ticket = "a".repeat(43); + + new AihrKnowledgeResourceDownloadService(query, oss, redisson) + .download(321L, ticket, response); + + verify(oss).downloadRange(777L, null, response); + verify(oss, never()).download(777L, response); + } + @Test void forwardsAuthorizedRangeWithoutBufferingWholeObject() throws Exception { AihrKnowledgeQueryService query = mock(AihrKnowledgeQueryService.class); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 8928f4c7..c8c070a6 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -17,7 +17,7 @@ | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`POST /records/{caseId}/publish`、`POST /records/{caseId}/retire`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;`/curate` 只进入经验候选池,`/review` 只完成业务审核,`/publish` 才能在来源授权、脱敏、适用岗位、负责人、版本和生效期齐全时进入正式经验库,`/retire` 要求填写下线原因并立即停止员工召回;APP 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、候选、审核、发布、下线、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `knowledge_status=PUBLISHED` 且处于生效期的 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 问·数字师傅工作 Agent `/pages/user/sop/index` | `POST /api/aihr/agent/messages`、`POST /api/aihr/agent/messages/media`、`POST /api/aihr/agent/actions/{draftId}/confirm`、`POST /api/aihr/agent/actions/{draftId}/dismiss` | 移动端文字、ASR 转写和图片/视频统一进入 Agent。服务端根据消息规划 `KNOWLEDGE_QA/RESOURCE_DELIVERY/LIVE_MY_WORK/LIVE_TEAM_WORK/PRACTICE_COACHING/CAPTURE_FACT/MEDIA_UNDERSTANDING/WEB_RESEARCH/CLARIFY/SOCIAL`,再由固定策略校验当前登录身份和工具权限;客户端不提交 `toolCode`,模型也不能自由指定身份、SQL、URL 或任意工具。知识、训练概况、当前待办、确认式记忆、媒体分析和全网查询均复用现有领域服务;普通图片问答只做本次视觉分析,不要求先具备知识空间,用户明确问现场制度/流程时才校验授权空间并补充 SOP。全网查询先返回 `NEEDS_INPUT`,只有用户明确同意后才调用外部服务。统一响应使用结构化 `status/sourceSummary/citations/resources/actionDraft/clarification`;按钮不从答案文本推断。写入动作只接受 30 分钟有效的不透明 `draftId`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 | -| SOP 知识底层服务 `/knowledge/sop` 与旧客户端兼容 | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | Agent 按规划结果调用该层;管理端和旧客户端接口继续兼容。文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最新 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回、跨租户或非在职员工均拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。岗位 SOP 的项目范围复用当前 APP 已验证主体的 `projectCodes`,不得以管理端组织快照的模糊/别名查询作为后备。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED`。详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。 | +| SOP 知识底层服务 `/knowledge/sop` 与旧客户端兼容 | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/citations/{detailRef}`、`POST /api/knowledge/citations/{detailRef}/media-link`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/resources/{attachmentId}/download-link`、`GET /api/knowledge/resources/{attachmentId}/download?ticket=`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | Agent 按规划结果调用该层;管理端和旧客户端接口继续兼容。文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最新 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回、跨租户或非在职员工均拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。岗位 SOP 的项目范围复用当前 APP 已验证主体的 `projectCodes`,不得以管理端组织快照的模糊/别名查询作为后备。原文件、图片和视频只从当前命中且仍有授权的附件返回;回答只携带不透明 `detailRef` 和最小定位摘要,详情及媒体票据接口每次重新计算租户、应用、主体和来源状态。文本详情返回受控分段和命中标记,图片/视频通过短期单附件票据以内联 MIME 响应读取,视频支持 HTTP Range;不返回原始 OSS 地址或登录令牌。无资源时明确返回空列表,不生成假链接。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED`。详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)和[引用详情实施计划](superpowers/plans/2026-07-31-mobile-citation-source-viewer.md)。 | | 工作助手确认式统一采集 `/pages/user/sop/index`、`/pages/user/assistant/memories` | Agent 主入口返回 `actionDraft`,确认/忽略走 `/api/aihr/agent/actions/{draftId}/confirm|dismiss`;旧接口 `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm|dismiss` 继续兼容;确认记录读写使用 `GET /api/aihr/personal-assistant/assistant-captures?limit=50`、`POST /assistant-captures/{id}/status`、`GET /assistant-captures/{id}/status-history`、`GET /assistant-captures/{id}/source`,旧项目记录继续使用 `/api/aihr/service-memories` | 文字、ASR 转写和现场媒体复用同一候选检测。项目工作必须从 `/api/aihr/mobile/me` 返回的项目名称列表选择当前项目,内部 `projectCode` 仍由服务端按当前登录主体授权收窄;个人笔记可以不绑定项目。显式“记一下”生成 `ASSISTANT_CAPTURE/DRAFT`,系统建议 `ATTENDANCE/INSPECTION/SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE`。确认必须带 `expectedVersion + idempotencyKey + saveScope(PRIVATE|COMPANY)`;PRIVATE 仅本人可见,COMPANY 写入 PENDING 待流转。状态更新只允许 `RECORDED/PENDING/IN_PROGRESS/COMPLETED/VOID`,幂等键不得跨记录或目标状态复用。来源文件重新鉴权交付;正式外部接收端未配置前不得显示已送达。 | | 今日工作成果 `/pages/user/work-results/index`、`/pages/supervisor/work-results/index` | 员工 `POST /api/aihr/work-results/mine/generate?projectCode=...&workDate=YYYY-MM-DD`;主管 `GET /api/aihr/work-results/project?projectCode=...&workDate=YYYY-MM-DD` | 按员工 + 项目 + Asia/Shanghai 自然日确定性聚合已确认记录;两页默认当天并可按日期查看历史成果,客户端禁止选择未来日期。内容未变化时重复生成不增加版本,记录或状态变化后版本递增。主管只可查看本人在目标项目具备主管身份的团队结果;APP 登录身份同时兼容组织快照中的外部人员 ID 与手机号,普通员工或跨项目请求返回 403。外部接口未接通时只显示本地状态和 PENDING,不伪造派单或考勤同步成功 | | 银城大喇叭 | 员工 `GET /api/aihr/broadcast/unread-count`、`GET /api/aihr/broadcast/messages?pageNum=&pageSize=`、`GET /api/aihr/broadcast/messages/{id}`、`POST /api/aihr/broadcast/messages/{id}/read`、`GET /api/aihr/broadcast/attachments/{id}/view`、`GET /api/aihr/broadcast/attachments/{id}/content`;员工详情可经统一 `POST /api/knowledge/query` 的 `broadcastMessageId` 发起文字追问;管理 `GET /api/aihr/broadcast/admin/messages`、`POST /api/aihr/broadcast/admin/attachments`、`GET /api/aihr/broadcast/admin/attachments/{id}`、`PUT /api/aihr/broadcast/admin/attachments/{id}/topic-tags`、`POST /api/aihr/broadcast/messages`、`POST /api/aihr/broadcast/messages/{id}/withdraw` | 发布支持 `visibilityMode=ALL/TARGET_ONLY`、必读、单个公司文件与异步提炼;`TARGET_ONLY` 必须有目标,发布时冻结匹配收件人,列表、详情、未读、阅读、附件和追问统一复核可见性,非目标员工按不存在处理。`ALL` 下目标只作定向提醒。附件支持 txt/md/PDF/Word/Excel/PPT,100MB 内;只有 `READY` 且未绑定、属于当前租户和上传人的文件才可发布。`allowDownload=false` 时员工端不返回下载入口,后端 `/content` 也拒绝;`/view` 返回受控提取文本并记录独立查看审计,客户端叠加含内部账号和分钟的动态水印。水印用于追溯,不等于阻止截屏或泄露;下载副本水印仍须按文件格式另行实现和验收。主题标签仅由固定关键词规则或人工修正产生,均保存可在原文逐字找到的短依据,且绝不参与授权。文件岗位解读继续返回有原文依据的 `insightStatus/defaultPerspectiveCode/perspectiveLabels/perspectives`,不返回原始 OSS 地址。下载、查看和追问每次重新校验 APP 在职身份、租户、消息状态和可见范围;客户端追问仍只传 `broadcastMessageId`。发布、阅读、文件访问和撤回保持幂等与审计约束;管理接口只允许 `superadmin` 或 `hr_operator`。当前仍不包含消息修订、撤回后补推或短信/电话强触达。 | @@ -358,7 +358,7 @@ Content-Type: application/json {"queryText":"你们提供哪些服务?","spaceCodes":["mt_customer_service"],"limit":5} ``` -成功响应包含 `requestId`、`answer`、`citations`、`usedSpaceCodes`、`noEvidence` 和兼容 `legacy` 数据。引用携带 `spaceCode/sourceType/docId/title/snippet/fragmentId`;客户端不得把无引用回答包装成有知识依据的正式答案。 +成功响应包含 `requestId`、`answer`、`citations`、`usedSpaceCodes`、`noEvidence` 和兼容 `legacy` 数据。引用携带 `spaceCode/sourceType/docId/title/snippet/fragmentId`,可查看原始出处的 `DOCUMENT` 引用另带 `mediaType/detailRef/locatorSummary`;`detailRef` 是短期不透明引用,只能交给受认证的引用详情接口重新鉴权,客户端不得解析或拼接附件身份。客户端不得把无引用回答包装成有知识依据的正式答案。 空间、空间内分类、授权和应用管理接口统一位于 `/api/knowledge/admin`:`spaces`、`spaces/{id}/categories`、`spaces/{id}/documents`、`spaces/{id}/documents/{attachId}/category`、`spaces/{id}/grants`、`apps`、`apps/{id}/spaces`、`apps/{id}/rotate-token`。分类仅在当前有效租户和当前知识空间内维护;停用分类不能继续归类,删除前必须先移走其知识,分类不会扩大空间授权或调用应用的检索范围。`DELETE /spaces/{id}/documents/{attachId}` 只解绑当前空间成员;若该成员对应已批准或已发布的治理资产,服务端会先以当前人工操作人执行统一 `WITHDRAW`,立即切断 MySQL 生产可见性并通过 outbox 删除向量。其他空间或治理资产仍引用同一 OSS 时原文件不会删除,引用核验失败时也必须保留原件。仅知识平台管理角色可调用,API_TOKEN 创建或轮换后只返回一次明文。超级管理员由“租户管理”进入知识维护时,通过 `GET /system/tenant/dynamic/{tenantId}` 设置服务端动态租户,再由 `GET /system/tenant/dynamic` 回读当前上下文;仅正常状态的已存在租户允许切换。动态租户按当前 Sa-Token 与浏览器页面生成的匿名上下文共同隔离,客户端每次请求回传页面上下文及当前展示租户;两者与服务端不一致时服务端返回 `409` 并拒绝读写,避免多标签页误写。知识平台不接受客户端传入的 `tenantId` 来选租户。完整初始化、令牌保管、legacy 迁移与回滚步骤见 [KNOWLEDGE_PLATFORM_RUNBOOK.md](KNOWLEDGE_PLATFORM_RUNBOOK.md)。 diff --git a/docs/superpowers/plans/2026-07-31-mobile-citation-source-viewer.md b/docs/superpowers/plans/2026-07-31-mobile-citation-source-viewer.md index 88e142d5..13f56129 100644 --- a/docs/superpowers/plans/2026-07-31-mobile-citation-source-viewer.md +++ b/docs/superpowers/plans/2026-07-31-mobile-citation-source-viewer.md @@ -2,7 +2,7 @@ > 制定日期:2026-07-31 > -> 当前状态:阶段 0 基线冻结已完成;阶段 1—5 已实现部分能力但尚未满足完整技术通过门禁;阶段 6 未开始 +> 当前状态(2026-08-02):阶段 0 基线冻结已完成;阶段 1—5 已实现部分能力,本机 H5 已验证文本、图片、视频引用详情及视频 Range 实播,但完整越权矩阵、App-Plus/Android 真机和性能门禁未闭合;阶段 6 未开始 > > 适用范围:`mobile-uni` 员工端“问 · 数字师傅”、`/api/aihr/agent/**`、知识检索与受控原始资料接口 @@ -263,7 +263,7 @@ mvn -f backend/pom.xml -pl ruoyi-modules/ruoyi-aihr -am ` | 字段 | 实际值 | |---|---| | 状态 | 实现中 | -| commit | 工作区未提交,当前 HEAD `cafb836` | +| 构建源 | 阶段 1 实现已进入当前分支;2026-08-02 本轮未新增该阶段代码 | | JUnit | 未运行:当前 PowerShell 未找到 `java`/`mvn` | | 迁移幂等 | 本地 locator 迁移与回填已执行;未完成两次正式迁移的 JUnit 证明 | | 六类样本定位 | 当前本地 seed 仅有 5 个文本附件、15 个 TEXT locator;PDF/DOCX/PPTX/XLSX/IMAGE/VIDEO 固定样本未完成 | @@ -367,14 +367,14 @@ GET /api/knowledge/citations/{detailRef} | 字段 | 实际值 | |---|---| | 状态 | 实现中 | -| commit | 工作区未提交,当前 HEAD `cafb836` | -| JUnit | 未运行:当前 PowerShell 未找到 `java`/`mvn` | -| 正授权 | 未完成;本地 `aihr_knowledge_app` 为空,真实问答返回“当前登录端未启用知识问答应用” | +| 构建源 | 2026-08-02 本轮修复提交见 Git 历史;该提交不代表生产发布 | +| JUnit | `AihrKnowledgeResourceRangeTest`:`2/2` 通过,覆盖首次无 Range 请求的 inline/MIME 通路和带 Range 请求转发 | +| 正授权 | 本机已用已发布的合成文本、图片、视频资产通过 APP 登录态检索并打开详情 `3/3`;未执行生产正式账号验收 | | 越权矩阵 | 未完成;源码保留租户、应用、主体、来源治理复验,未形成后端运行证据 | -| Range | 未完成后端运行验证;实现已加入 OSS/MinIO Range 链路 | +| Range | 本机合成 MP4 完整请求 `200 video/mp4` 且 inline;Range 请求 `206 video/mp4`,返回合法 `Content-Range` 和 1024 字节 | | 日志敏感信息扫描 | 未完成后端运行日志扫描;未在移动端脱敏证据中写入手机号、令牌或 OSS 原址 | | 证据目录 | `output/citation-source-viewer/20260731-local/` | -| 遗留项 | 需要 Java/Maven、真实 APP 应用绑定、带 `oss_id` 的授权附件及后端正反权限矩阵 | +| 遗留项 | 生产正式账号、撤权/跨租户/伪造/过期票据反例、日志扫描和完整权限矩阵仍待验证 | --- @@ -452,14 +452,14 @@ npm --prefix mobile-uni run build:app | 字段 | 实际值 | |---|---| | 状态 | 实现中 | -| commit | 工作区未提交,当前 HEAD `cafb836` | -| Node tests | `node --test mobile-uni/tests/*.test.mjs`:`211/211` 通过;有既有 WebSocket 端口占用告警但无失败用例 | +| 构建源 | 2026-08-02 本轮修复提交见 Git 历史;该提交不代表生产发布 | +| Node tests | `node --test mobile-uni/tests/*.test.mjs`:`217/217` 通过,包含 H5 媒体地址不得落入 `/h5/dev-api/` 的回归测试 | | typecheck | `npm --prefix mobile-uni run typecheck`:通过 | -| H5/App build | `npm --prefix mobile-uni run build:h5` 与 `build:app`:均完成;仅有既有 Sass 弃用告警 | -| 390×844 文本/图片/视频 | 真实引用 `0/3`:员工首页和问页面可加载,但本地应用未启用知识问答,未生成真实 citation;未伪造图片/视频 fixture | -| 错误状态 | 详情页无引用参数正确显示不可查看状态;点击“返回问答”回到首页;问答业务错误明确显示应用未启用 | -| 截图目录 | `output/citation-source-viewer/20260731-local/`,含首页、问答错误和详情不可查看截图 | -| 遗留项 | 未完成真实文本高亮、图片缩放、视频定位;需配置本地 APP 绑定和受控附件后复核 | +| H5/App build | `npm --prefix mobile-uni run build:h5`:通过;本轮未重跑 App-Plus 构建 | +| 390×844 文本/图片/视频 | 本机合成已发布资产 `3/3`:文本详情及命中高亮、图片原图及放大、视频详情均按对应形式呈现 | +| 视频实播 | 合成 MP4 `readyState=4`、时长约 `10.03s`、尺寸 `320×176`,播放时间由 `0` 推进到约 `2.36s` | +| 错误状态 | 详情页无引用参数正确显示不可查看状态;点击“返回问答”回到首页 | +| 遗留项 | App-Plus/Android 真机、正式资源和正式账号验收仍未完成;本机合成素材不得作为生产样本 | 本次浏览器复核(2026-07-31,指定在职测试账号切换后):通过内置 Browser 完成退出旧会话、重新获取验证码、登录和进入员工端“问”页面;固定制度问题返回真实回答并展开 `3` 条引用。当前 3 条引用均为旧知识片段,DOM 中 `.citation-action` 与“查看出处”入口均为 `0`,因此没有可安全构造的 `detailRef`,未伪造详情 URL。已保存脱敏页面证据:`output/citation-source-viewer/20260731-local/question-citations-expanded.png`。本次验证结论为“引用展示通过,出处详情被定位数据缺失阻断”,不计入文本/图片/视频 `3/3` 详情门禁。 @@ -535,7 +535,7 @@ qdrant_points_unchanged | 字段 | 实际值 | |---|---| | 状态 | 实现中 | -| commit | 工作区未提交,当前 HEAD `cafb836` | +| 构建源 | 阶段 4 实现已进入当前分支;2026-08-02 本轮未新增该阶段代码 | | 计划分母 | `attachments_total=5`,`locators_before=15` | | exact/coarse/ambiguous/failed | `exact=15`,`coarse=0`,`ambiguous=0`,`failed=0`;`locators_created=0` | | 抽检 | 已核对本地 5 个 seed 附件及 15 条 locator;未覆盖计划要求的 PDF/PPT/图片/视频抽检样本 | @@ -588,17 +588,17 @@ qdrant_points_unchanged | 字段 | 实际值 | |---|---| | 状态 | 实现中 | -| commit | 工作区未提交,当前 HEAD `cafb836` | -| 后端测试 | 未运行:当前 PowerShell 未找到 `java`/`mvn` | -| 移动端测试 | Node `211/211` 通过;typecheck、H5/App build 通过 | +| 构建源 | 2026-08-02 本轮修复提交见 Git 历史;该提交不代表生产发布 | +| 后端测试 | `AihrKnowledgeResourceRangeTest`:`2/2` 通过 | +| 移动端测试 | Node `217/217`、typecheck、H5 build 通过;本轮未重跑 App-Plus 构建 | | 越权矩阵 | 未完成真实后端矩阵;仅完成源码级治理约束复核 | | 定位误差 | 未测量;无真实视频引用数据 | -| Range | 未测量;无带 `oss_id` 的本地授权视频 fixture | +| Range | 本机合成授权视频完整请求 `200`、Range 请求 `206`;未覆盖首段/中段/尾段/非法范围完整矩阵 | | p50/p95 | 未测量 | | 资源泄漏循环 | Node 单元覆盖 Blob URL 释放,未完成 20 次真实页面循环 | | 敏感信息扫描 | 移动端截图/DOM 证据未包含手机号、验证码、令牌、票据或 OSS 原址;后端日志扫描未完成 | | 证据目录 | `output/citation-source-viewer/20260731-local/` | -| 遗留项 | 后端运行时、正式/测试授权数据、Range 样本和性能基准均待补齐 | +| 遗留项 | 正反权限矩阵、完整 Range 矩阵、定位误差、性能基准、资源循环和生产/真机证据仍待补齐 | --- @@ -662,7 +662,7 @@ Android 真机: | 字段 | 实际值 | |---|---| | 状态 | 未开始 | -| release commit | 未发布;工作区未提交,当前 HEAD `cafb836` | +| release commit | 未发布;2026-08-02 本机修复提交不等于生产发布 | | schema 版本/迁移结果 | 仅本地 Docker 数据库验证;未执行生产迁移 | | 生产 JAR SHA-256 | 未生成/未部署 | | H5 构建与远端哈希 | 本地 H5 构建完成;未生产发布、未计算远端哈希 | diff --git a/mobile-uni/src/services/api.ts b/mobile-uni/src/services/api.ts index dcb2030b..e172d54a 100644 --- a/mobile-uni/src/services/api.ts +++ b/mobile-uni/src/services/api.ts @@ -38,6 +38,13 @@ const normalizeUrl = (url: string) => `${currentApiBase()}${url.startsWith('/') export const apiUrl = normalizeUrl; +export const apiMediaUrl = (url: string) => { + const normalized = normalizeUrl(url); + if (/^https?:\/\//i.test(normalized) || typeof window === 'undefined') return normalized; + const origin = window.location?.origin; + return /^https?:\/\//i.test(origin || '') ? new URL(normalized, `${origin}/`).toString() : normalized; +}; + export const authHeaders = (json = true, includeAuth = true) => { const auth = getAuth(); return { diff --git a/mobile-uni/src/services/knowledge.ts b/mobile-uni/src/services/knowledge.ts index d28bec3a..5858dadf 100644 --- a/mobile-uni/src/services/knowledge.ts +++ b/mobile-uni/src/services/knowledge.ts @@ -9,7 +9,7 @@ import type { SummaryCardResponse, KnowledgeCitationDetail } from '@/types/api'; -import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api'; +import { apiMediaUrl, apiRequest, apiUrl, authHeaders, readTextPayload } from './api'; import { getSelectedPosition } from './auth'; import { fileSelectionFailure } from '../utils/file-selection'; import { withPlatformFileSelection } from './file-selection-lifecycle'; @@ -264,7 +264,7 @@ export const loadKnowledgeCitationMediaUrl = async (detailRef: string) => { clearAuthOnUnauthorized: false }); if (!response.url?.startsWith('/api/knowledge/resources/')) throw new Error('视频地址无效'); - return apiUrl(response.url); + return apiMediaUrl(response.url); }; const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): KnowledgeSearchResponse => { diff --git a/mobile-uni/tests/knowledge-citation-detail.test.mjs b/mobile-uni/tests/knowledge-citation-detail.test.mjs index b39c6376..f5ec180d 100644 --- a/mobile-uni/tests/knowledge-citation-detail.test.mjs +++ b/mobile-uni/tests/knowledge-citation-detail.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; const source = (path) => readFile(new URL(path, import.meta.url), 'utf8'); @@ -28,3 +30,30 @@ test('引用详情页释放媒体临时 URL', async () => { assert.match(detail, /onUnmounted\(releaseMedia\)/); assert.match(detail, /releaseAuthenticatedFileUrl/); }); + +test('H5 video media URLs stay rooted at the dev API instead of the /h5 app base', async () => { + globalThis.window = { location: { origin: 'https://wygj-mobile-uni.localhost' } }; + globalThis.uni = { + getStorageSync: () => '', + removeStorageSync: () => {} + }; + const vite = await createServer({ + root: fileURLToPath(new URL('..', import.meta.url)), + configFile: false, + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, hmr: false }, + appType: 'custom' + }); + + try { + const { apiMediaUrl } = await vite.ssrLoadModule('/src/services/api.ts'); + assert.equal( + apiMediaUrl('/api/knowledge/resources/2069/download?ticket=opaque'), + 'https://wygj-mobile-uni.localhost/dev-api/api/knowledge/resources/2069/download?ticket=opaque' + ); + } finally { + await vite.close(); + delete globalThis.window; + delete globalThis.uni; + } +});