diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java index 26122de6..943d1c67 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java @@ -1,11 +1,14 @@ package org.dromara.aihr.knowledge.controller; import cn.dev33.satoken.annotation.SaCheckLogin; +import cn.dev33.satoken.annotation.SaIgnore; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.ResourceDownloadLink; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeResourceDownloadService; import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot; import org.dromara.aihr.memory.AihrMemoryService; import org.dromara.common.core.domain.R; @@ -31,6 +34,7 @@ import java.util.List; public class AihrKnowledgeQueryController { private final AihrKnowledgeQueryService queryService; + private final AihrKnowledgeResourceDownloadService resourceDownloadService; private final ISysOssService ossService; private final AihrMemoryService memoryService; @@ -90,4 +94,18 @@ public class AihrKnowledgeQueryController { } ossService.download(ossId, response); } + + @SaCheckLogin + @PostMapping("/resources/{attachmentId}/download-link") + public R resourceDownloadLink(@PathVariable Long attachmentId) { + return R.ok(resourceDownloadService.issue(attachmentId)); + } + + @SaIgnore + @GetMapping("/resources/{attachmentId}/download") + public void resourceDownload(@PathVariable Long attachmentId, + @RequestParam(value = "ticket", required = false) String ticket, + HttpServletResponse response) throws IOException { + resourceDownloadService.download(attachmentId, ticket, response); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java index d948437f..9f539cb7 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java @@ -70,6 +70,13 @@ public final class AihrKnowledgeQueryDto { ) { } + /** + * A short-lived, opaque download URL issued only after the current employee's + * knowledge-space access has been checked. It deliberately contains no login token. + */ + public record ResourceDownloadLink(String url) { + } + /** * A response-safe reference to the company message used as trusted question context. * The message body is intentionally absent: the employee can only obtain it from the diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java index 536acd8c..f244266b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java @@ -488,11 +488,13 @@ public class AihrKnowledgeQueryService { order by a.id """.formatted(placeholders(spaceIds.size()), placeholders(docIds.size())), (rs, rowNum) -> new AttachmentResource(rs.getLong("id"), rs.getString("name")), args.toArray()); - return rows.stream() + Map firstByTitle = new LinkedHashMap<>(); + rows.stream() .filter(row -> "VIDEO".equals(intent) == isVideo(row.title())) + .forEach(row -> firstByTitle.putIfAbsent(resourceTitleKey(row), row)); + return firstByTitle.values().stream() .map(row -> new Resource(row.attachmentId(), row.title(), intent, "/api/knowledge/resources/" + row.attachmentId() + "/content")) - .distinct() .limit(5) .toList(); } @@ -653,6 +655,13 @@ public class AihrKnowledgeQueryService { || value.endsWith(".mkv") || value.endsWith(".webm") || value.endsWith(".m4v"); } + private static String resourceTitleKey(AttachmentResource resource) { + String title = resource.title(); + return hasText(title) + ? title.trim().toLowerCase(Locale.ROOT) + : "attachment:" + resource.attachmentId(); + } + private static final String PROJECT_SERVICE = "PROJECT_SERVICE"; private static final String BROADCAST_PROMPT_VERSION = "broadcast-context-v1"; private static final int BROADCAST_MODEL_ANSWER_MAX = 1200; 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 new file mode 100644 index 00000000..5e674636 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeResourceDownloadService.java @@ -0,0 +1,73 @@ +package org.dromara.aihr.knowledge.service; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.ResourceDownloadLink; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.system.service.ISysOssService; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; + +/** + * Bridges an authenticated knowledge lookup to a browser-downloadable URL. + * The opaque ticket is intentionally short-lived so a mobile WebView never needs a + * bearer token in its download URL. + */ +@Service +@RequiredArgsConstructor +public class AihrKnowledgeResourceDownloadService { + + static final Duration TICKET_TTL = Duration.ofMinutes(2); + private static final String TICKET_PREFIX = "aihr:knowledge:resource-download:"; + private static final SecureRandom TICKET_RANDOM = new SecureRandom(); + + private final AihrKnowledgeQueryService queryService; + private final ISysOssService ossService; + private final RedissonClient redissonClient; + + public ResourceDownloadLink issue(Long attachmentId) { + Long ossId = queryService.authorizedResourceOssId(attachmentId); + if (ossId == null) { + throw new ServiceException("资料不存在或无权访问", HttpStatus.NOT_FOUND); + } + String ticket = ticket(); + bucket(attachmentId, ticket).set(ossId, TICKET_TTL); + return new ResourceDownloadLink("/api/knowledge/resources/" + attachmentId + "/download?ticket=" + ticket); + } + + public void download(Long attachmentId, String ticket, HttpServletResponse response) throws IOException { + if (attachmentId == null || attachmentId <= 0 || !validTicket(ticket)) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问"); + return; + } + Long ossId = bucket(attachmentId, ticket).get(); + if (ossId == null || ossId <= 0) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问"); + return; + } + response.setHeader("Cache-Control", "no-store"); + response.setHeader("Referrer-Policy", "no-referrer"); + ossService.download(ossId, response); + } + + private RBucket bucket(Long attachmentId, String ticket) { + return redissonClient.getBucket(TICKET_PREFIX + attachmentId + ":" + ticket); + } + + private static String ticket() { + byte[] bytes = new byte[32]; + TICKET_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static boolean validTicket(String value) { + return value != null && value.length() == 43 && value.matches("[A-Za-z0-9_-]+"); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java index f14578ba..8687674b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java @@ -237,11 +237,14 @@ class AihrKnowledgeQueryServiceTest { when(rs.getString("title")).thenReturn("自动扶梯困人应急操作 SOP.pdf"); when(rs.getString("content")).thenReturn("立即停梯并设置警示"); when(rs.getLong("fragment_id")).thenReturn(88L); - } else { - when(rs.getLong("id")).thenReturn(321L); - when(rs.getString("name")).thenReturn("自动扶梯困人应急操作 SOP.pdf"); + return List.of(mapper.mapRow(rs, 0)); } - return List.of(mapper.mapRow(rs, 0)); + when(rs.getLong("id")).thenReturn(321L); + when(rs.getString("name")).thenReturn("自动扶梯困人应急操作 SOP.pdf"); + ResultSet duplicate = mock(ResultSet.class); + when(duplicate.getLong("id")).thenReturn(322L); + when(duplicate.getString("name")).thenReturn("自动扶梯困人应急操作 SOP.pdf"); + return List.of(mapper.mapRow(rs, 0), mapper.mapRow(duplicate, 1)); }); var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceDownloadServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceDownloadServiceTest.java new file mode 100644 index 00000000..892384f7 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeResourceDownloadServiceTest.java @@ -0,0 +1,75 @@ +package org.dromara.aihr.knowledge; + +import jakarta.servlet.http.HttpServletResponse; +import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeResourceDownloadService; +import org.dromara.system.service.ISysOssService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrKnowledgeResourceDownloadServiceTest { + + @Test + void issuesOpaqueShortLivedLinkOnlyAfterAuthorizedLookup() { + AihrKnowledgeQueryService queryService = mock(AihrKnowledgeQueryService.class); + ISysOssService ossService = mock(ISysOssService.class); + RedissonClient redisson = mock(RedissonClient.class); + @SuppressWarnings("unchecked") + RBucket bucket = mock(RBucket.class); + when(queryService.authorizedResourceOssId(321L)).thenReturn(777L); + when(redisson.getBucket(anyString())).thenReturn(bucket); + + var link = new AihrKnowledgeResourceDownloadService(queryService, ossService, redisson).issue(321L); + + assertTrue(link.url().matches("/api/knowledge/resources/321/download\\?ticket=[A-Za-z0-9_-]{43}")); + verify(bucket).set(eq(777L), eq(Duration.ofMinutes(2))); + } + + @Test + void downloadsOnlyWhenTheAttachmentScopedTicketIsPresent() throws Exception { + AihrKnowledgeQueryService queryService = mock(AihrKnowledgeQueryService.class); + ISysOssService ossService = mock(ISysOssService.class); + RedissonClient redisson = mock(RedissonClient.class); + @SuppressWarnings("unchecked") + RBucket bucket = mock(RBucket.class); + HttpServletResponse response = mock(HttpServletResponse.class); + String ticket = "a".repeat(43); + when(redisson.getBucket(anyString())).thenReturn(bucket); + when(bucket.get()).thenReturn(777L); + + new AihrKnowledgeResourceDownloadService(queryService, ossService, redisson) + .download(321L, ticket, response); + + verify(response).setHeader("Cache-Control", "no-store"); + verify(response).setHeader("Referrer-Policy", "no-referrer"); + verify(ossService).download(777L, response); + } + + @Test + void rejectsMalformedTicketBeforeItCanReadStorage() throws Exception { + AihrKnowledgeQueryService queryService = mock(AihrKnowledgeQueryService.class); + ISysOssService ossService = mock(ISysOssService.class); + RedissonClient redisson = mock(RedissonClient.class); + HttpServletResponse response = mock(HttpServletResponse.class); + + new AihrKnowledgeResourceDownloadService(queryService, ossService, redisson) + .download(321L, "bad", response); + + verify(response).sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问"); + verify(redisson, never()).getBucket(anyString()); + verify(ossService, never()).download(org.mockito.ArgumentMatchers.anyLong(), eq(response)); + } +} diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue index f8a4f9e4..1f3399c6 100644 --- a/mobile-uni/src/pages/user/sop/index.vue +++ b/mobile-uni/src/pages/user/sop/index.vue @@ -229,7 +229,7 @@ {{ resource.title }} - {{ resource.loading ? '正在安全读取…' : resource.type === 'VIDEO' ? (resource.localUrl ? '已加载,可直接播放' : '点击加载操作视频') : '点击查看或下载原文件' }} + {{ resource.loading ? (resource.type === 'VIDEO' ? '正在安全读取…' : '正在生成安全下载链接…') : resource.type === 'VIDEO' ? (resource.localUrl ? '已加载,可直接播放' : '点击加载操作视频') : '点击下载原文件' }} › @@ -423,6 +423,7 @@ import { chooseKnowledgeMedia, generateSummaryCard, queryKnowledgeTool, + requestKnowledgeResourceDownloadLink, searchKnowledge, searchKnowledgeWithMedia, submitAnswerFeedback @@ -1206,24 +1207,36 @@ const openKnowledgeResource = async (resource: KnowledgeResource) => { if (resource.type === 'VIDEO' && resource.localUrl) return; resource.loading = true; try { + if (resource.type === 'FILE') { + const downloadUrl = await requestKnowledgeResourceDownloadLink(resource.attachmentId); + if (typeof document !== 'undefined') { + const link = document.createElement('a'); + link.href = downloadUrl; + link.target = '_blank'; + link.rel = 'noreferrer'; + document.body.appendChild(link); + link.click(); + link.remove(); + return; + } + uni.downloadFile({ + url: downloadUrl, + success: (response) => { + if (response.statusCode >= 200 && response.statusCode < 300 && response.tempFilePath) { + uni.openDocument({ filePath: response.tempFilePath, showMenu: true }); + return; + } + uni.showToast({ title: '文件下载失败,请稍后重试', icon: 'none' }); + }, + fail: () => uni.showToast({ title: '文件下载失败,请稍后重试', icon: 'none' }) + }); + return; + } const localUrl = await authenticatedFileUrl(resource.contentUrl); if (resource.localUrl && resource.localUrl !== localUrl) { releaseAuthenticatedFileUrl(resource.localUrl); } resource.localUrl = localUrl; - if (resource.type === 'FILE') { - if (typeof document !== 'undefined') { - const link = document.createElement('a'); - link.href = localUrl; - link.download = resource.title || '资料文件'; - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - link.remove(); - } else { - uni.openDocument({ filePath: localUrl, showMenu: true }); - } - } } catch (error) { uni.showToast({ title: requestErrorText(error, '资料读取失败'), icon: 'none' }); } finally { diff --git a/mobile-uni/src/services/knowledge.ts b/mobile-uni/src/services/knowledge.ts index b92616ef..25a58a3c 100644 --- a/mobile-uni/src/services/knowledge.ts +++ b/mobile-uni/src/services/knowledge.ts @@ -167,6 +167,17 @@ export const queryKnowledgeTool = async ( return normalizeUnifiedResponse(response); }; +export const requestKnowledgeResourceDownloadLink = async (attachmentId: number) => { + const response = await apiRequest<{ url: string }>({ + url: `/api/knowledge/resources/${encodeURIComponent(String(attachmentId))}/download-link`, + method: 'POST' + }); + if (!response.url?.startsWith('/api/knowledge/resources/')) { + throw new Error('资料下载链接无效'); + } + return apiUrl(response.url); +}; + const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): KnowledgeSearchResponse => { const legacy = response.legacy || ({} as KnowledgeSearchResponse); const citationSnippets = (response.citations || []).map((item) => normalizeSnippet({ diff --git a/mobile-uni/tests/ask-memory.test.mjs b/mobile-uni/tests/ask-memory.test.mjs index 27995e0e..867c4f1a 100644 --- a/mobile-uni/tests/ask-memory.test.mjs +++ b/mobile-uni/tests/ask-memory.test.mjs @@ -24,9 +24,13 @@ test('问师傅支持显式新对话、版本回写和受控原文件或视频 assert.match(header, /class="new-chat-text">新对话/); assert.match(page, /const contextVersion = ref\(0\)/); assert.match(page, /applyConversationState\(nextResult\)/); + assert.match(page, /requestKnowledgeResourceDownloadLink\(resource\.attachmentId\)/); assert.match(page, /authenticatedFileUrl\(resource\.contentUrl\)/); assert.match(page, /releaseAuthenticatedFileUrl\(resource\.localUrl\)/); assert.match(page, /resource\.type === 'VIDEO' && resource\.localUrl/); + assert.doesNotMatch(page, /link\.href = localUrl/); + assert.match(page, /link\.rel = 'noreferrer'/); + assert.match(page, /点击下载原文件/); assert.match(page, /msg\.result\.rewrittenQuery !== msg\.result\.queryText/); assert.match(page, /已结合本次对话理解/); });