fix: support authenticated video citation playback

This commit is contained in:
key
2026-08-02 22:04:09 +08:00
parent 6ad8a017a4
commit a07bbcca07
7 changed files with 83 additions and 26 deletions
@@ -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 {
@@ -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<Long> bucket = mock(RBucket.class);
HttpServletResponse response = mock(HttpServletResponse.class);
when(redisson.<Long>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);
+2 -2
View File
@@ -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)。
@@ -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 构建完成;未生产发布、未计算远端哈希 |
+7
View File
@@ -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 {
+2 -2
View File
@@ -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 => {
@@ -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;
}
});