Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
赵月辉
/
fastgpt-migrated
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
99673b1f
authored
Jun 30, 2026
by
Xianquan
Committed by
GitHub
Jun 30, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: use proxy download links for files (#7217)
parent
6b26bee6
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
57 additions
and
36 deletions
+57
-36
.agents/design/core/ai/sandbox/get-file-url.md
+13
-10
packages/service/common/s3/security/token.ts
+14
-5
packages/service/core/ai/sandbox/application/toolCall/getFileUrl.tool.ts
+6
-2
packages/service/test/common/s3/token.test.ts
+10
-4
packages/service/test/core/ai/sandbox/application/toolCall/getFileUrl.tool.test.ts
+10
-8
packages/service/test/core/ai/sandbox/application/toolCall/index.test.ts
+4
-7
No files found.
.agents/design/core/ai/sandbox/get-file-url.md
View file @
99673b1f
...
...
@@ -12,7 +12,7 @@
**工具返回格式**
:
```
json
{
"url"
:
"https://
xxx.s3.amazonaws.com/...
"
,
"url"
:
"https://
app.xxx.com/api/system/file/download/<token>?filename=output.csv
"
,
"expired"
:
"2 hours"
,
"filename"
:
"output.csv"
}
...
...
@@ -33,11 +33,12 @@
|
`packages/service/common/s3/sources/chat/index.ts`
| 修改 |
`uploadChatFile`
透传
`filename`
、
`expiredTime`
|
|
`packages/service/common/s3/type.ts`
| 修改 |
`UploadFileByBodySchema`
新增
`filename`
、
`expiredTime`
字段 |
|
`packages/service/common/s3/sources/chat/type.ts`
| 修改 |
`UploadChatFileSchema`
新增
`expiredTime`
字段 |
|
`projects/app/src/pages/api/system/file/[jwt].ts`
| 修改 | 下载接口支持
`Content-Disposition`
响应头 |
|
`packages/service/common/s3/security/token.ts`
| 修改 | 旧
`jwtSignS3ObjectKey`
签名入口兼容保留,但新生成链接统一走 download token |
|
`projects/app/src/pages/api/system/file/download/[token].ts`
| 使用 | 代理下载接口支持
`Content-Disposition`
响应头和 inline 预览 |
> **架构说明**:新增 `callSandboxTool` 作为纯执行层,不绑定业务响应格式。两条调用链路(普通工作流 / Agent 模式)均复用该层,消除重复逻辑。
> **URL 生成**:文件上传到 S3 后,通过 `
jwtSignS3ObjectKey(key, expiredAt)` 生成 JWT 签名的内网访问 URL(有效期 2 小时),不依赖 S3 签名 URL
。
> **URL 生成**:文件上传到 S3 后,通过 `
chatBucket.createGetChatFileURL({ key, expiredHours: 2, external: true, mode: 'proxy' })` 生成代理下载 URL(有效期 2 小时),不依赖 S3 签名 URL。新生成的下载/预览链接统一使用 `/api/system/file/download/[token]`,旧 `/api/system/file/[jwt]` 仅保留历史链接兼容
。
---
...
...
@@ -121,18 +122,18 @@ export const callSandboxTool = async (params: SandboxToolCallParams): Promise<Sa
-
通过
`instance.provider.readFileStream(filePath)`
流式读取文件内容
-
聚合 chunks 为
`Buffer`
-
调用
`chatBucket.uploadChatFile({ ..., expiredTime: addHours(now, 2) })`
上传,TTL 设为 2 小时
-
用
`
jwtSignS3ObjectKey(key, addHours(now, 2))`
生成 JWT 签名访问
URL
-
用
`
chatBucket.createGetChatFileURL({ key, expiredHours: 2, external: true, mode: 'proxy' })`
生成代理下载
URL
3.
返回
`Array<{ fileUrl: string, filename: string }>`
**工具返回格式**
(JSON 序列化后作为 response):
```
json
[
{
"fileUrl"
:
"https://app.xxx.com/api/system/file/
eyJ...
"
,
"filename"
:
"output.csv"
},
{
"fileUrl"
:
"https://app.xxx.com/api/system/file/
eyJ...
"
,
"filename"
:
"report.txt"
}
{
"fileUrl"
:
"https://app.xxx.com/api/system/file/
download/eyJ...?filename=output.csv
"
,
"filename"
:
"output.csv"
},
{
"fileUrl"
:
"https://app.xxx.com/api/system/file/
download/eyJ...?filename=report.txt
"
,
"filename"
:
"report.txt"
}
]
```
> **注意**:URL 不再使用 S3 签名 URL(`accessUrl`),而是
通过 `jwtSignS3ObjectKey` 生成 JWT 签名的内部访问 URL,由 `/api/system/file/[jwt]` 接口代理下载
。
> **注意**:URL 不再使用 S3 签名 URL(`accessUrl`),而是
生成内部代理下载 URL,由 `/api/system/file/download/[token]` 接口代理下载/预览。`/api/system/file/[jwt]` 仅用于历史 objectKey token 链接兼容,新增链路不得再生成该地址
。
---
...
...
@@ -276,7 +277,8 @@ sequenceDiagram
CallLayer->>S3: uploadChatFile({ filename, buffer, expiredTime: +2h })
S3->>DB: MongoS3TTL.create(expiredTime = now + 2h)
S3-->>CallLayer: { key }
CallLayer->>CallLayer: jwtSignS3ObjectKey(key, +2h) → fileUrl
CallLayer->>S3: createGetChatFileURL({ key, expiredHours: 2, mode: 'proxy' })
S3-->>CallLayer: { url: fileUrl }
end
CallLayer-->>Handler: { input, response: JSON([{fileUrl, filename}...]), durationSeconds }
Handler-->>Tool: response + flowResponse
...
...
@@ -300,7 +302,8 @@ sequenceDiagram
SandboxProvider-->>CallLayer: stream chunks
CallLayer->>S3: uploadChatFile({ expiredTime: +2h })
S3-->>CallLayer: { key }
CallLayer->>CallLayer: jwtSignS3ObjectKey(key, +2h)
CallLayer->>S3: createGetChatFileURL({ key, expiredHours: 2, mode: 'proxy' })
S3-->>CallLayer: { url: fileUrl }
CallLayer-->>Dispatch: { input, response, durationSeconds }
Dispatch-->>Handler: { response, nodeResponse }
Handler-->>Agent: { response, usages }
...
...
@@ -374,5 +377,5 @@ await this.client.uploadObject({ key, body: stream, contentType: '...' });
-
[
x
]
`packages/service/core/workflow/dispatch/ai/tool/toolCall.ts`
:合并拦截逻辑,复用
`callSandboxTool`
-
[
x
]
`packages/service/core/workflow/dispatch/ai/agent/master/call.ts`
:新增
`SANDBOX_GET_FILE_URL_TOOL_NAME`
拦截逻辑
-
[
x
]
`packages/service/common/s3/type.ts`
、
`buckets/base.ts`
、
`sources/chat/index.ts`
、
`sources/chat/type.ts`
:扩展
`filename`
、
`expiredTime`
参数
-
[
x
]
`projects/app/src/pages/api/system/file/
[jwt].ts`
:支持
`Content-Disposition`
下载头
-
[
x
]
`projects/app/src/pages/api/system/file/
download/[token].ts`
:支持
`Content-Disposition`
下载/预览响应头;旧
`/api/system/file/[jwt]`
仅保留历史链接兼容
-
[
]
大文件限制或流式上传优化(见六、待优化)
packages/service/common/s3/security/token.ts
View file @
99673b1f
...
...
@@ -7,7 +7,6 @@ import { serviceEnv } from '../../../env';
/* ==================== 路由与类型 ==================== */
const
FileApiPath
=
{
legacyFile
:
'/api/system/file'
,
proxyDownload
:
'/api/system/file/download'
,
proxyUpload
:
'/api/system/file/upload'
}
as
const
;
...
...
@@ -127,11 +126,21 @@ const isS3UploadTokenPayload = (value: unknown): value is S3UploadTokenPayload =
);
};
/* ==================== 旧版文件链接 token ==================== */
/* ==================== 旧版 objectKey token 兼容 ==================== */
/**
* 兼容旧调用方的文件链接签名入口。
*
* 历史实现会生成 `/api/system/file/[jwt]` 链接;现在统一签发代理下载 token,
* 避免新增下载/预览链接继续落到旧接口。旧 objectKey token 的验证能力仍保留,
* 用于兼容已经发出的历史链接。
*/
export
function
jwtSignS3ObjectKey
(
objectKey
:
string
,
expiredTime
:
Date
)
{
const
token
=
signToken
({
objectKey
}
satisfies
S3ObjectKeyTokenPayload
,
expiredTime
);
return
buildFileApiUrl
(
FileApiPath
.
legacyFile
,
token
);
return
jwtSignS3DownloadToken
({
objectKey
,
bucketName
:
serviceEnv
.
STORAGE_PRIVATE_BUCKET
,
expiredTime
,
filename
:
path
.
basename
(
objectKey
)
});
}
export
function
jwtVerifyS3ObjectKey
(
token
:
string
)
{
...
...
packages/service/core/ai/sandbox/application/toolCall/getFileUrl.tool.ts
View file @
99673b1f
...
...
@@ -9,7 +9,6 @@ import { Readable } from 'stream';
import
{
addHours
}
from
'date-fns'
;
import
{
defineTool
}
from
'./type'
;
import
{
getS3ChatSource
}
from
'../../../../../common/s3/sources/chat'
;
import
{
jwtSignS3ObjectKey
}
from
'../../../../../common/s3/utils'
;
import
{
SANDBOX_GET_FILE_URL_TOOL_NAME
}
from
'@fastgpt/global/core/ai/sandbox/tools'
;
const
SandboxGetFileUrlToolSchema
=
z
.
object
({
...
...
@@ -36,7 +35,12 @@ export const sandboxGetFileUrlTool = defineTool({
body
:
readable
,
expiredTime
});
const
fileUrl
=
jwtSignS3ObjectKey
(
key
,
expiredTime
);
const
{
url
:
fileUrl
}
=
await
chatBucket
.
createGetChatFileURL
({
key
,
expiredHours
:
2
,
external
:
true
,
mode
:
'proxy'
});
return
{
fileUrl
,
filename
};
})
...
...
packages/service/test/common/s3/token.test.ts
View file @
99673b1f
...
...
@@ -34,12 +34,16 @@ describe('s3 token validation', () => {
vi
.
restoreAllMocks
();
});
it
(
'
accepts legacy object key tokens that do not include a type
'
,
async
()
=>
{
const
{
jwtSignS3ObjectKey
,
jwtVerifyS3
ObjectKey
}
=
await
loadTokenModule
();
it
(
'
signs object key urls with proxy download tokens
'
,
async
()
=>
{
const
{
jwtSignS3ObjectKey
,
jwtVerifyS3
DownloadToken
}
=
await
loadTokenModule
();
const
objectKey
=
'chat/appId/userId/chatId/file.txt'
;
const
token
=
extractTokenFromUrl
(
jwtSignS3ObjectKey
(
objectKey
,
getExpiredTime
()));
await
expect
(
jwtVerifyS3ObjectKey
(
token
)).
resolves
.
toMatchObject
({
objectKey
});
await
expect
(
jwtVerifyS3DownloadToken
(
token
)).
resolves
.
toMatchObject
({
objectKey
,
bucketName
:
'fastgpt-private'
,
type
:
'download'
});
});
it
(
'rejects upload tokens when verifying legacy object key tokens'
,
async
()
=>
{
...
...
@@ -81,6 +85,8 @@ describe('s3 token validation', () => {
const
{
jwtSignS3ObjectKey
}
=
await
loadTokenModule
();
const
url
=
jwtSignS3ObjectKey
(
'chat/appId/userId/chatId/file.txt'
,
getExpiredTime
());
expect
(
url
).
toMatch
(
/^https:
\/\/
files
\.
example
\.
com
\/
fastgpt
\/
api
\/
system
\/
file
\/[^/
?#
]
+$/
);
expect
(
url
).
toMatch
(
/^https:
\/\/
files
\.
example
\.
com
\/
fastgpt
\/
api
\/
system
\/
file
\/
download
\/[^/
?#
]
+
\?
filename=file
\.
txt$/
);
});
});
packages/service/test/core/ai/sandbox/application/toolCall/getFileUrl.tool.test.ts
View file @
99673b1f
...
...
@@ -4,19 +4,16 @@ import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
const
s3Mock
=
vi
.
hoisted
(()
=>
({
uploadChatFile
:
vi
.
fn
(),
jwtSignS3ObjectKey
:
vi
.
fn
()
createGetChatFileURL
:
vi
.
fn
()
}));
vi
.
mock
(
'@fastgpt/service/common/s3/sources/chat'
,
()
=>
({
getS3ChatSource
:
()
=>
({
uploadChatFile
:
s3Mock
.
uploadChatFile
uploadChatFile
:
s3Mock
.
uploadChatFile
,
createGetChatFileURL
:
s3Mock
.
createGetChatFileURL
})
}));
vi
.
mock
(
'@fastgpt/service/common/s3/utils'
,
()
=>
({
jwtSignS3ObjectKey
:
s3Mock
.
jwtSignS3ObjectKey
}));
import
{
sandboxGetFileUrlTool
}
from
'@fastgpt/service/core/ai/sandbox/application/toolCall/getFileUrl.tool'
;
const
createSandboxInstance
=
()
=>
...
...
@@ -30,7 +27,7 @@ describe('sandboxGetFileUrlTool', () => {
beforeEach
(()
=>
{
vi
.
clearAllMocks
();
s3Mock
.
uploadChatFile
.
mockResolvedValue
({
key
:
'chat/file.txt'
});
s3Mock
.
jwtSignS3ObjectKey
.
mockReturnValue
(
'signed-url'
);
s3Mock
.
createGetChatFileURL
.
mockResolvedValue
({
url
:
'signed-url'
}
);
});
it
(
'uploads sandbox files and returns signed urls'
,
async
()
=>
{
...
...
@@ -56,6 +53,11 @@ describe('sandboxGetFileUrlTool', () => {
filename
:
'file.txt'
})
);
expect
(
s3Mock
.
jwtSignS3ObjectKey
).
toHaveBeenCalledWith
(
'chat/file.txt'
,
expect
.
any
(
Date
));
expect
(
s3Mock
.
createGetChatFileURL
).
toHaveBeenCalledWith
({
key
:
'chat/file.txt'
,
expiredHours
:
2
,
external
:
true
,
mode
:
'proxy'
});
});
});
packages/service/test/core/ai/sandbox/application/toolCall/index.test.ts
View file @
99673b1f
...
...
@@ -21,7 +21,7 @@ const mirrorMock = vi.hoisted(() => ({
const
s3Mock
=
vi
.
hoisted
(()
=>
({
uploadChatFile
:
vi
.
fn
(),
jwtSignS3ObjectKey
:
vi
.
fn
()
createGetChatFileURL
:
vi
.
fn
()
}));
vi
.
mock
(
'@fastgpt/service/core/ai/sandbox/application/runtime/client'
,
()
=>
({
...
...
@@ -38,14 +38,11 @@ vi.mock('@fastgpt/service/core/ai/sandbox/application/runtime/mirrors', () => ({
vi
.
mock
(
'@fastgpt/service/common/s3/sources/chat'
,
()
=>
({
getS3ChatSource
:
()
=>
({
uploadChatFile
:
s3Mock
.
uploadChatFile
uploadChatFile
:
s3Mock
.
uploadChatFile
,
createGetChatFileURL
:
s3Mock
.
createGetChatFileURL
})
}));
vi
.
mock
(
'@fastgpt/service/common/s3/utils'
,
()
=>
({
jwtSignS3ObjectKey
:
s3Mock
.
jwtSignS3ObjectKey
}));
import
{
getSandboxToolInfo
,
prepareSandboxToolRuntime
,
...
...
@@ -71,7 +68,7 @@ describe('sandbox toolCall index', () => {
vi
.
clearAllMocks
();
runtimeMock
.
getSandboxClient
.
mockResolvedValue
(
createSandboxInstance
());
s3Mock
.
uploadChatFile
.
mockResolvedValue
({
key
:
'chat/file.txt'
});
s3Mock
.
jwtSignS3ObjectKey
.
mockReturnValue
(
'signed-url'
);
s3Mock
.
createGetChatFileURL
.
mockResolvedValue
({
url
:
'signed-url'
}
);
});
it
(
'executes known tools through a fetched sandbox client'
,
async
()
=>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment