fix(knowledge): make protected file downloads WebView-safe

This commit is contained in:
2026-07-24 13:31:30 +08:00
parent 83c497c5f1
commit 442ba66848
9 changed files with 233 additions and 20 deletions
@@ -1,11 +1,14 @@
package org.dromara.aihr.knowledge.controller; package org.dromara.aihr.knowledge.controller;
import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; 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.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeResourceDownloadService;
import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot; import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot;
import org.dromara.aihr.memory.AihrMemoryService; import org.dromara.aihr.memory.AihrMemoryService;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
@@ -31,6 +34,7 @@ import java.util.List;
public class AihrKnowledgeQueryController { public class AihrKnowledgeQueryController {
private final AihrKnowledgeQueryService queryService; private final AihrKnowledgeQueryService queryService;
private final AihrKnowledgeResourceDownloadService resourceDownloadService;
private final ISysOssService ossService; private final ISysOssService ossService;
private final AihrMemoryService memoryService; private final AihrMemoryService memoryService;
@@ -90,4 +94,18 @@ public class AihrKnowledgeQueryController {
} }
ossService.download(ossId, response); ossService.download(ossId, response);
} }
@SaCheckLogin
@PostMapping("/resources/{attachmentId}/download-link")
public R<ResourceDownloadLink> 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);
}
} }
@@ -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. * 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 * The message body is intentionally absent: the employee can only obtain it from the
@@ -488,11 +488,13 @@ public class AihrKnowledgeQueryService {
order by a.id order by a.id
""".formatted(placeholders(spaceIds.size()), placeholders(docIds.size())), """.formatted(placeholders(spaceIds.size()), placeholders(docIds.size())),
(rs, rowNum) -> new AttachmentResource(rs.getLong("id"), rs.getString("name")), args.toArray()); (rs, rowNum) -> new AttachmentResource(rs.getLong("id"), rs.getString("name")), args.toArray());
return rows.stream() Map<String, AttachmentResource> firstByTitle = new LinkedHashMap<>();
rows.stream()
.filter(row -> "VIDEO".equals(intent) == isVideo(row.title())) .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, .map(row -> new Resource(row.attachmentId(), row.title(), intent,
"/api/knowledge/resources/" + row.attachmentId() + "/content")) "/api/knowledge/resources/" + row.attachmentId() + "/content"))
.distinct()
.limit(5) .limit(5)
.toList(); .toList();
} }
@@ -653,6 +655,13 @@ public class AihrKnowledgeQueryService {
|| value.endsWith(".mkv") || value.endsWith(".webm") || value.endsWith(".m4v"); || 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 PROJECT_SERVICE = "PROJECT_SERVICE";
private static final String BROADCAST_PROMPT_VERSION = "broadcast-context-v1"; private static final String BROADCAST_PROMPT_VERSION = "broadcast-context-v1";
private static final int BROADCAST_MODEL_ANSWER_MAX = 1200; private static final int BROADCAST_MODEL_ANSWER_MAX = 1200;
@@ -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<Long> 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_-]+");
}
}
@@ -237,11 +237,14 @@ class AihrKnowledgeQueryServiceTest {
when(rs.getString("title")).thenReturn("自动扶梯困人应急操作 SOP.pdf"); when(rs.getString("title")).thenReturn("自动扶梯困人应急操作 SOP.pdf");
when(rs.getString("content")).thenReturn("立即停梯并设置警示"); when(rs.getString("content")).thenReturn("立即停梯并设置警示");
when(rs.getLong("fragment_id")).thenReturn(88L); when(rs.getLong("fragment_id")).thenReturn(88L);
} else { return List.of(mapper.mapRow(rs, 0));
when(rs.getLong("id")).thenReturn(321L);
when(rs.getString("name")).thenReturn("自动扶梯困人应急操作 SOP.pdf");
} }
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, var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations, mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations,
@@ -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<Long> bucket = mock(RBucket.class);
when(queryService.authorizedResourceOssId(321L)).thenReturn(777L);
when(redisson.<Long>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<Long> bucket = mock(RBucket.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String ticket = "a".repeat(43);
when(redisson.<Long>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));
}
}
+27 -14
View File
@@ -229,7 +229,7 @@
<view class="resource-copy"> <view class="resource-copy">
<text class="resource-title">{{ resource.title }}</text> <text class="resource-title">{{ resource.title }}</text>
<text class="resource-meta"> <text class="resource-meta">
{{ resource.loading ? '正在安全读取…' : resource.type === 'VIDEO' ? (resource.localUrl ? '已加载,可直接播放' : '点击加载操作视频') : '点击查看或下载原文件' }} {{ resource.loading ? (resource.type === 'VIDEO' ? '正在安全读取…' : '正在生成安全下载链接…') : resource.type === 'VIDEO' ? (resource.localUrl ? '已加载,可直接播放' : '点击加载操作视频') : '点击下载原文件' }}
</text> </text>
</view> </view>
<text class="resource-arrow">›</text> <text class="resource-arrow">›</text>
@@ -423,6 +423,7 @@ import {
chooseKnowledgeMedia, chooseKnowledgeMedia,
generateSummaryCard, generateSummaryCard,
queryKnowledgeTool, queryKnowledgeTool,
requestKnowledgeResourceDownloadLink,
searchKnowledge, searchKnowledge,
searchKnowledgeWithMedia, searchKnowledgeWithMedia,
submitAnswerFeedback submitAnswerFeedback
@@ -1206,24 +1207,36 @@ const openKnowledgeResource = async (resource: KnowledgeResource) => {
if (resource.type === 'VIDEO' && resource.localUrl) return; if (resource.type === 'VIDEO' && resource.localUrl) return;
resource.loading = true; resource.loading = true;
try { 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); const localUrl = await authenticatedFileUrl(resource.contentUrl);
if (resource.localUrl && resource.localUrl !== localUrl) { if (resource.localUrl && resource.localUrl !== localUrl) {
releaseAuthenticatedFileUrl(resource.localUrl); releaseAuthenticatedFileUrl(resource.localUrl);
} }
resource.localUrl = 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) { } catch (error) {
uni.showToast({ title: requestErrorText(error, '资料读取失败'), icon: 'none' }); uni.showToast({ title: requestErrorText(error, '资料读取失败'), icon: 'none' });
} finally { } finally {
+11
View File
@@ -167,6 +167,17 @@ export const queryKnowledgeTool = async (
return normalizeUnifiedResponse(response); 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 normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): KnowledgeSearchResponse => {
const legacy = response.legacy || ({} as KnowledgeSearchResponse); const legacy = response.legacy || ({} as KnowledgeSearchResponse);
const citationSnippets = (response.citations || []).map((item) => normalizeSnippet({ const citationSnippets = (response.citations || []).map((item) => normalizeSnippet({
+4
View File
@@ -24,9 +24,13 @@ test('问师傅支持显式新对话、版本回写和受控原文件或视频
assert.match(header, /class="new-chat-text">新对话/); assert.match(header, /class="new-chat-text">新对话/);
assert.match(page, /const contextVersion = ref\(0\)/); assert.match(page, /const contextVersion = ref\(0\)/);
assert.match(page, /applyConversationState\(nextResult\)/); assert.match(page, /applyConversationState\(nextResult\)/);
assert.match(page, /requestKnowledgeResourceDownloadLink\(resource\.attachmentId\)/);
assert.match(page, /authenticatedFileUrl\(resource\.contentUrl\)/); assert.match(page, /authenticatedFileUrl\(resource\.contentUrl\)/);
assert.match(page, /releaseAuthenticatedFileUrl\(resource\.localUrl\)/); assert.match(page, /releaseAuthenticatedFileUrl\(resource\.localUrl\)/);
assert.match(page, /resource\.type === 'VIDEO' && 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, /msg\.result\.rewrittenQuery !== msg\.result\.queryText/);
assert.match(page, /已结合本次对话理解/); assert.match(page, /已结合本次对话理解/);
}); });