feat(media): 对象存储 CDN - 支持阿里云 OSS 与腾讯云 COS 下载加速
合并平台与个人对象存储 CDN 下载域名配置,上传继续使用原始 Endpoint。\n\n后续修复对象路径二次编码并补充 CDN 访问控制说明。
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 阿里云 OSS 与腾讯云 COS 的平台、个人存储支持配置可选 CDN 加速域名,上传继续使用 Endpoint,下载与预览在配置后改用 CDN 域名。
|
||||
|
||||
## v1.0.57
|
||||
|
||||
- 修复已有媒体节点再次生成失败时污染原成功节点状态的问题,视频新版本仅在生成成功后切换为主版本。
|
||||
|
||||
@@ -254,7 +254,7 @@ docker compose -f docker-compose.local.yml up -d --build
|
||||
|
||||
- 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。
|
||||
- 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。
|
||||
- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;删除业务记录不会自动清理远端对象。
|
||||
- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名,让上传继续使用 Endpoint、下载与预览使用 CDN;删除业务记录不会自动清理远端对象。
|
||||
- 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。
|
||||
|
||||
## 公网部署安全
|
||||
|
||||
@@ -747,7 +747,11 @@ type ossObjectStream struct {
|
||||
}
|
||||
|
||||
func getOSSObjectRange(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) {
|
||||
if normalizeOSSSetting(setting).Provider == tencentCOSProvider {
|
||||
setting = normalizeOSSSetting(setting)
|
||||
if setting.CDNBaseURL != "" {
|
||||
return getOSSObjectRangeViaCDN(setting, objectKey, rangeHeader)
|
||||
}
|
||||
if setting.Provider == tencentCOSProvider {
|
||||
return getCOSObjectRange(setting, objectKey, rangeHeader)
|
||||
}
|
||||
return getAliyunOSSObjectRange(setting, objectKey, rangeHeader)
|
||||
@@ -795,7 +799,11 @@ func decimalDigits(value string) bool {
|
||||
}
|
||||
|
||||
func signedOSSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) {
|
||||
if normalizeOSSSetting(setting).Provider == tencentCOSProvider {
|
||||
setting = normalizeOSSSetting(setting)
|
||||
if setting.CDNBaseURL != "" {
|
||||
return ossCDNObjectURL(setting.CDNBaseURL, objectKey)
|
||||
}
|
||||
if setting.Provider == tencentCOSProvider {
|
||||
return signedCOSObjectURL(setting, objectKey, expiresAt)
|
||||
}
|
||||
return signedAliyunOSSObjectURL(setting, objectKey, expiresAt)
|
||||
@@ -858,6 +866,31 @@ func getCOSObjectRange(setting ossSettingValue, objectKey string, rangeHeader st
|
||||
return &ossObjectStream{body: resp.Body, statusCode: resp.StatusCode, contentLength: resp.ContentLength, contentRange: resp.Header.Get("Content-Range"), acceptRanges: firstNonEmpty(resp.Header.Get("Accept-Ranges"), "bytes")}, nil
|
||||
}
|
||||
|
||||
func getOSSObjectRangeViaCDN(setting ossSettingValue, objectKey string, rangeHeader string) (*ossObjectStream, error) {
|
||||
signedURL, err := signedOSSObjectURL(setting, objectKey, time.Now().Add(directResourceURLTTL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, signedURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
ApplyDefaultOutboundHeaders(req)
|
||||
resp, err := OutboundHTTPClient(2 * time.Minute).Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("对象存储 CDN 读取失败:%w", err)
|
||||
}
|
||||
if (resp.StatusCode < 200 || resp.StatusCode >= 300) && resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
|
||||
defer resp.Body.Close()
|
||||
detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("对象存储 CDN 读取失败:%s %s", resp.Status, strings.TrimSpace(string(detail)))
|
||||
}
|
||||
return &ossObjectStream{body: resp.Body, statusCode: resp.StatusCode, contentLength: resp.ContentLength, contentRange: resp.Header.Get("Content-Range"), acceptRanges: firstNonEmpty(resp.Header.Get("Accept-Ranges"), "bytes")}, nil
|
||||
}
|
||||
|
||||
func signedCOSObjectURL(setting ossSettingValue, objectKey string, expiresAt time.Time) (string, error) {
|
||||
if strings.TrimSpace(setting.AccessKeyID) == "" || strings.TrimSpace(setting.AccessKeySecret) == "" {
|
||||
return "", errors.New("COS 访问密钥不可用")
|
||||
@@ -921,6 +954,35 @@ func cosBucketBaseURL(setting ossSettingValue) (*url.URL, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func ossCDNBaseURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return nil, errors.New("对象存储 CDN 加速域名格式不正确")
|
||||
}
|
||||
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
||||
return nil, errors.New("对象存储 CDN 加速域名只支持 http/https")
|
||||
}
|
||||
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Trim(parsed.Path, "/") != "" {
|
||||
return nil, errors.New("对象存储 CDN 加速域名不能包含认证信息、路径、查询参数或片段")
|
||||
}
|
||||
parsed.Path = ""
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func ossCDNObjectURL(raw string, objectKey string) (string, error) {
|
||||
baseURL, err := ossCDNBaseURL(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
objectKey = strings.TrimLeft(strings.TrimSpace(objectKey), "/")
|
||||
if objectKey == "" {
|
||||
return "", errors.New("对象存储对象路径为空")
|
||||
}
|
||||
// CDN 使用自己的访问鉴权与私有桶回源鉴权,不能携带 OSS/COS 的预签名参数。
|
||||
baseURL.Path = "/" + escapeObjectKey(objectKey)
|
||||
return baseURL.String(), nil
|
||||
}
|
||||
|
||||
func newOSSRequest(method string, setting ossSettingValue, objectKey string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
baseURL, err := ossBucketBaseURL(setting)
|
||||
if err != nil {
|
||||
|
||||
@@ -63,6 +63,53 @@ func TestSignedOSSObjectURLSupportsTencentCOS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedOSSObjectURLUsesAliyunCDNBaseURLForDownloads(t *testing.T) {
|
||||
value, err := signedOSSObjectURL(ossSettingValue{
|
||||
Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: "https://media.example.com",
|
||||
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
}, "users/u-1/image/test image.png", time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("signedOSSObjectURL() error = %v", err)
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Host != "media.example.com" || parsed.Path != "/users/u-1/image/test image.png" || parsed.RawQuery != "" {
|
||||
t.Fatalf("Aliyun OSS CDN URL = %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunOSSUploadRequestStillUsesEndpointWhenCDNConfigured(t *testing.T) {
|
||||
req, err := newOSSRequest(http.MethodPut, ossSettingValue{
|
||||
Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: "https://media.example.com",
|
||||
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
}, "users/u-1/image/test.png", "image/png", strings.NewReader("payload"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.URL.Host != "private-bucket.oss-cn-test.aliyuncs.com" || req.URL.Path != "/users/u-1/image/test.png" {
|
||||
t.Fatalf("Aliyun OSS upload URL = %q", req.URL.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedOSSObjectURLUsesTencentCOSCDNBaseURLWithoutCOSSignature(t *testing.T) {
|
||||
value, err := signedOSSObjectURL(ossSettingValue{
|
||||
Provider: tencentCOSProvider, Region: "ap-guangzhou", Bucket: "private-bucket-1250000000",
|
||||
CDNBaseURL: "https://media.example.com", AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
|
||||
}, "users/u-1/image/test image.png", time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("signedOSSObjectURL() error = %v", err)
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Host != "media.example.com" || parsed.Path != "/users/u-1/image/test image.png" || parsed.RawQuery != "" {
|
||||
t.Fatalf("signed COS CDN URL = %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutOSSObjectSupportsTencentCOS(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
payload := []byte("cos upload payload")
|
||||
@@ -92,7 +139,7 @@ func TestPutOSSObjectSupportsTencentCOS(t *testing.T) {
|
||||
|
||||
etag, err := putOSSObject(ossSettingValue{
|
||||
Provider: tencentCOSProvider, Endpoint: server.URL, Bucket: "private-bucket-1250000000",
|
||||
AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
|
||||
CDNBaseURL: "https://media.example.com", AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
|
||||
}, "users/u-1/image/test.png", "image/png", int64(len(payload)), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -136,6 +183,72 @@ func TestGetOSSObjectRangeSupportsTencentCOS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOSSObjectRangeUsesTencentCOSCDNBaseURL(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Range") != "bytes=0-3" {
|
||||
t.Errorf("Range = %q", r.Header.Get("Range"))
|
||||
}
|
||||
if r.Header.Get("Authorization") != "" || r.URL.RawQuery != "" {
|
||||
t.Errorf("Tencent CDN request should not carry COS authentication: header %q, query %q", r.Header.Get("Authorization"), r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Range", "bytes 0-3/7")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("data"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
stream, err := getOSSObjectRange(ossSettingValue{
|
||||
Provider: tencentCOSProvider, Endpoint: "https://cos.ap-guangzhou.myqcloud.com", CDNBaseURL: server.URL,
|
||||
Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
|
||||
}, "users/u-1/image/test.png", "bytes=0-3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stream.body.Close()
|
||||
data, err := io.ReadAll(stream.body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stream.statusCode != http.StatusPartialContent || stream.contentRange != "bytes 0-3/7" || string(data) != "data" {
|
||||
t.Fatalf("stream = %#v, data = %q", stream, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOSSObjectRangeUsesAliyunCDNBaseURLWithoutOSSSignature(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Range") != "bytes=0-3" {
|
||||
t.Errorf("Range = %q", r.Header.Get("Range"))
|
||||
}
|
||||
if r.Header.Get("Authorization") != "" || r.URL.RawQuery != "" {
|
||||
t.Errorf("Aliyun CDN request should not carry OSS authentication: header %q, query %q", r.Header.Get("Authorization"), r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Range", "bytes 0-3/7")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write([]byte("data"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
stream, err := getOSSObjectRange(ossSettingValue{
|
||||
Provider: aliyunOSSProvider, Endpoint: "https://oss-cn-test.aliyuncs.com", CDNBaseURL: server.URL,
|
||||
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
}, "users/u-1/image/test.png", "bytes=0-3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stream.body.Close()
|
||||
data, err := io.ReadAll(stream.body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stream.statusCode != http.StatusPartialContent || stream.contentRange != "bytes 0-3/7" || string(data) != "data" {
|
||||
t.Fatalf("stream = %#v, data = %q", stream, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTencentCOSSettingDerivesEndpointAndDoesNotReuseAliyunSecret(t *testing.T) {
|
||||
normalized := normalizeOSSSetting(ossSettingValue{Provider: tencentCOSProvider, Region: "ap-shanghai"})
|
||||
if normalized.Endpoint != "https://cos.ap-shanghai.myqcloud.com" {
|
||||
@@ -151,6 +264,34 @@ func TestTencentCOSSettingDerivesEndpointAndDoesNotReuseAliyunSecret(t *testing.
|
||||
if err == nil || !strings.Contains(err.Error(), "AccessKey Secret") {
|
||||
t.Fatalf("ossSettingFromRequest() error = %v", err)
|
||||
}
|
||||
next, err := ossSettingFromRequest(OSSSettingRequest{
|
||||
Enabled: true, Provider: tencentCOSProvider, Endpoint: server.URL, CDNBaseURL: server.URL,
|
||||
Bucket: "private-bucket-1250000000", AccessKeyID: "secret-id", AccessKeySecret: "secret-key",
|
||||
}, ossSettingValue{})
|
||||
if err != nil || next.CDNBaseURL != server.URL {
|
||||
t.Fatalf("Tencent COS CDN setting = %#v, %v", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunOSSSettingKeepsCDNBaseURL(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer server.Close()
|
||||
next, err := ossSettingFromRequest(OSSSettingRequest{
|
||||
Enabled: true, Provider: aliyunOSSProvider, Endpoint: server.URL, CDNBaseURL: server.URL,
|
||||
Bucket: "private-bucket", AccessKeyID: "access-id", AccessKeySecret: "secret-value",
|
||||
}, ossSettingValue{})
|
||||
if err != nil || next.CDNBaseURL != server.URL {
|
||||
t.Fatalf("Aliyun OSS CDN setting = %#v, %v", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSSCDNBaseURLRejectsNonDomainParts(t *testing.T) {
|
||||
for _, value := range []string{"ftp://media.example.com", "https://media.example.com/assets", "https://media.example.com?token=value", "https://user@media.example.com"} {
|
||||
if _, err := ossCDNBaseURL(value); err == nil {
|
||||
t.Fatalf("ossCDNBaseURL(%q) should fail", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformProviderSwitchKeepsHistoricalCredentials(t *testing.T) {
|
||||
|
||||
@@ -32,6 +32,7 @@ type OSSSettingRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Region string `json:"region"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CDNBaseURL string `json:"cdnBaseUrl"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
AccessKeySecret string `json:"accessKeySecret"`
|
||||
@@ -44,6 +45,7 @@ type PublicOSSSetting struct {
|
||||
Provider string `json:"provider"`
|
||||
Region string `json:"region"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CDNBaseURL string `json:"cdnBaseUrl"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
HasAccessKeySecret bool `json:"hasAccessKeySecret"`
|
||||
@@ -59,6 +61,7 @@ type ossSettingValue struct {
|
||||
Provider string `json:"provider"`
|
||||
Region string `json:"region"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CDNBaseURL string `json:"cdnBaseUrl"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
AccessKeySecret string `json:"accessKeySecret"`
|
||||
@@ -447,6 +450,7 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS
|
||||
Provider: strings.TrimSpace(req.Provider),
|
||||
Region: strings.TrimSpace(req.Region),
|
||||
Endpoint: strings.TrimRight(strings.TrimSpace(req.Endpoint), "/"),
|
||||
CDNBaseURL: strings.TrimRight(strings.TrimSpace(req.CDNBaseURL), "/"),
|
||||
Bucket: strings.TrimSpace(req.Bucket),
|
||||
AccessKeyID: strings.TrimSpace(req.AccessKeyID),
|
||||
AccessKeySecret: strings.TrimSpace(req.AccessKeySecret),
|
||||
@@ -474,6 +478,14 @@ func ossSettingFromRequest(req OSSSettingRequest, current ossSettingValue) (ossS
|
||||
if _, err := ValidateOutboundURL(next.Endpoint); err != nil {
|
||||
return next, err
|
||||
}
|
||||
if next.CDNBaseURL != "" {
|
||||
if _, err := ossCDNBaseURL(next.CDNBaseURL); err != nil {
|
||||
return next, BadAuthRequest(err.Error())
|
||||
}
|
||||
if _, err := ValidateOutboundURL(next.CDNBaseURL); err != nil {
|
||||
return next, err
|
||||
}
|
||||
}
|
||||
if next.AccessKeyID == "" {
|
||||
return next, BadAuthRequest("请填写 AccessKey ID")
|
||||
}
|
||||
@@ -520,6 +532,7 @@ func normalizeOSSSetting(value ossSettingValue) ossSettingValue {
|
||||
if value.Provider == tencentCOSProvider && value.Endpoint == "" && value.Region != "" {
|
||||
value.Endpoint = "https://cos." + value.Region + ".myqcloud.com"
|
||||
}
|
||||
value.CDNBaseURL = strings.TrimRight(strings.TrimSpace(value.CDNBaseURL), "/")
|
||||
value.Bucket = strings.TrimSpace(value.Bucket)
|
||||
value.AccessKeyID = strings.TrimSpace(value.AccessKeyID)
|
||||
value.AccessKeySecret = strings.TrimSpace(value.AccessKeySecret)
|
||||
@@ -539,6 +552,7 @@ func publicOSSSetting(setting *model.SystemSetting, value ossSettingValue) Publi
|
||||
Provider: value.Provider,
|
||||
Region: value.Region,
|
||||
Endpoint: value.Endpoint,
|
||||
CDNBaseURL: value.CDNBaseURL,
|
||||
Bucket: value.Bucket,
|
||||
AccessKeyID: value.AccessKeyID,
|
||||
HasAccessKeySecret: strings.TrimSpace(value.AccessKeySecret) != "",
|
||||
@@ -559,6 +573,7 @@ func publicUserOSSSetting(setting *model.UserOSSSetting, value ossSettingValue)
|
||||
Provider: value.Provider,
|
||||
Region: value.Region,
|
||||
Endpoint: value.Endpoint,
|
||||
CDNBaseURL: value.CDNBaseURL,
|
||||
Bucket: value.Bucket,
|
||||
AccessKeyID: value.AccessKeyID,
|
||||
HasAccessKeySecret: strings.TrimSpace(value.AccessKeySecret) != "",
|
||||
|
||||
@@ -10,6 +10,7 @@ type OSSFormValues = {
|
||||
provider: "aliyun" | "tencent";
|
||||
region?: string;
|
||||
endpoint?: string;
|
||||
cdnBaseUrl?: string;
|
||||
bucket?: string;
|
||||
accessKeyId?: string;
|
||||
accessKeySecret?: string;
|
||||
@@ -60,6 +61,7 @@ export function UserOSSSettingsForm() {
|
||||
provider: values.provider || "aliyun",
|
||||
region: values.region?.trim() || "",
|
||||
endpoint: values.endpoint?.trim() || "",
|
||||
cdnBaseUrl: values.cdnBaseUrl?.trim() || "",
|
||||
bucket: values.bucket?.trim() || "",
|
||||
accessKeyId: values.accessKeyId?.trim() || "",
|
||||
accessKeySecret: values.accessKeySecret?.trim() || "",
|
||||
@@ -101,7 +103,7 @@ export function UserOSSSettingsForm() {
|
||||
<Select
|
||||
options={[{ label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }]}
|
||||
onChange={(nextProvider: OSSFormValues["provider"]) => {
|
||||
if (nextProvider !== provider) form.setFieldsValue({ region: "", endpoint: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
|
||||
if (nextProvider !== provider) form.setFieldsValue({ region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -111,6 +113,17 @@ export function UserOSSSettingsForm() {
|
||||
<Form.Item name="endpoint" label="Endpoint" extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined} className="mb-3">
|
||||
<Input inputMode="url" spellCheck={false} placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="cdnBaseUrl"
|
||||
label="CDN 加速域名"
|
||||
extra={isTencentCOS
|
||||
? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问,CDN URL 按官方要求不附带 COS 签名。"
|
||||
: "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源,CDN URL 按官方要求不附带 OSS 签名。"}
|
||||
rules={[{ type: "url", message: "请填写完整的 http/https CDN 加速域名" }]}
|
||||
className="mb-3"
|
||||
>
|
||||
<Input inputMode="url" spellCheck={false} placeholder="https://media.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bucket" label="Bucket" className="mb-3">
|
||||
<Input spellCheck={false} placeholder={isTencentCOS ? "my-canvas-assets-1250000000" : "my-canvas-assets"} />
|
||||
</Form.Item>
|
||||
@@ -148,6 +161,7 @@ function toFormValues(setting: UserOSSSetting): OSSFormValues {
|
||||
provider: setting.provider || "aliyun",
|
||||
region: setting.region,
|
||||
endpoint: setting.endpoint,
|
||||
cdnBaseUrl: setting.cdnBaseUrl,
|
||||
bucket: setting.bucket,
|
||||
accessKeyId: setting.accessKeyId,
|
||||
accessKeySecret: "",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
type StorageMode = "local" | "aliyun" | "tencent";
|
||||
type OSSFormValues = { mode: StorageMode; publicBaseUrl?: string; region?: string; endpoint?: string; bucket?: string; accessKeyId?: string; accessKeySecret?: string; pathPrefix?: string };
|
||||
type OSSFormValues = { mode: StorageMode; publicBaseUrl?: string; region?: string; endpoint?: string; cdnBaseUrl?: string; bucket?: string; accessKeyId?: string; accessKeySecret?: string; pathPrefix?: string };
|
||||
|
||||
export default function StorageSettingsPage() {
|
||||
const { message } = App.useApp();
|
||||
@@ -45,7 +45,7 @@ export default function StorageSettingsPage() {
|
||||
if (values.mode !== "local" && !values.accessKeyId?.trim()) return message.error(`请填写${values.mode === "tencent" ? " SecretId" : " AccessKey ID"}`);
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateAdminOSSSetting({ enabled: values.mode !== "local", provider: values.mode === "local" ? setting?.provider || "aliyun" : values.mode, region: values.region?.trim() || "", endpoint: values.endpoint?.trim() || "", bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", publicBaseUrl: values.publicBaseUrl?.trim() || "", pathPrefix: values.pathPrefix?.trim() || "" });
|
||||
const result = await updateAdminOSSSetting({ enabled: values.mode !== "local", provider: values.mode === "local" ? setting?.provider || "aliyun" : values.mode, region: values.region?.trim() || "", endpoint: values.endpoint?.trim() || "", cdnBaseUrl: values.cdnBaseUrl?.trim() || "", bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", publicBaseUrl: values.publicBaseUrl?.trim() || "", pathPrefix: values.pathPrefix?.trim() || "" });
|
||||
setSetting(result.setting);
|
||||
form.setFieldsValue(formValues(result.setting));
|
||||
message.success("存储配置已保存");
|
||||
@@ -82,7 +82,7 @@ export default function StorageSettingsPage() {
|
||||
onChange={(value) => {
|
||||
const nextMode = value as StorageMode;
|
||||
const switchingProvider = nextMode !== "local" && ((mode !== "local" && mode !== nextMode) || (mode === "local" && setting?.provider !== nextMode));
|
||||
if (switchingProvider) form.setFieldsValue({ region: "", endpoint: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
|
||||
if (switchingProvider) form.setFieldsValue({ region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -90,6 +90,9 @@ export default function StorageSettingsPage() {
|
||||
<>
|
||||
<Form.Item name="region" label="Region"><Input autoComplete="off" placeholder={isTencentCOS ? "例如:ap-guangzhou" : "例如:oss-cn-hangzhou"} /></Form.Item>
|
||||
<Form.Item name="endpoint" label="Endpoint" extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined}><Input autoComplete="off" placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : "https://oss-cn-hangzhou.aliyuncs.com"} /></Form.Item>
|
||||
<Form.Item name="cdnBaseUrl" label="CDN 加速域名" extra={isTencentCOS ? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问,CDN URL 按官方要求不附带 COS 签名。" : "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源,CDN URL 按官方要求不附带 OSS 签名。"} rules={[{ type: "url", message: "请填写完整的 http/https CDN 加速域名" }]}>
|
||||
<Input autoComplete="off" inputMode="url" placeholder="https://media.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bucket" label="Bucket"><Input autoComplete="off" placeholder={isTencentCOS ? "例如:my-canvas-assets-1250000000" : "例如:my-canvas-assets"} /></Form.Item>
|
||||
<Form.Item name="pathPrefix" label="路径前缀"><Input autoComplete="off" placeholder="例如:uploads/infinite-canvas" /></Form.Item>
|
||||
<Form.Item name="accessKeyId" label={accessKeyIdLabel}><Input autoComplete="off" placeholder={isTencentCOS ? "腾讯云 SecretId" : "阿里云 AccessKey ID"} /></Form.Item>
|
||||
@@ -130,7 +133,7 @@ export default function StorageSettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function formValues(setting?: AdminOSSSetting | null): OSSFormValues { return { mode: setting?.enabled ? setting.provider === "tencent" ? "tencent" : "aliyun" : "local", publicBaseUrl: setting?.publicBaseUrl || "", region: setting?.region || "", endpoint: setting?.endpoint || "", bucket: setting?.bucket || "", accessKeyId: setting?.accessKeyId || "", accessKeySecret: "", pathPrefix: setting?.pathPrefix || "" }; }
|
||||
function formValues(setting?: AdminOSSSetting | null): OSSFormValues { return { mode: setting?.enabled ? setting.provider === "tencent" ? "tencent" : "aliyun" : "local", publicBaseUrl: setting?.publicBaseUrl || "", region: setting?.region || "", endpoint: setting?.endpoint || "", cdnBaseUrl: setting?.cdnBaseUrl || "", bucket: setting?.bucket || "", accessKeyId: setting?.accessKeyId || "", accessKeySecret: "", pathPrefix: setting?.pathPrefix || "" }; }
|
||||
function storageProviderLabel(provider?: AdminOSSSetting["provider"]) { return provider === "tencent" ? "腾讯云 COS" : "阿里云 OSS"; }
|
||||
function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }
|
||||
function Notice({ icon, text }: { icon: ReactNode; text: string }) { return <div className="flex items-center gap-2 px-3 py-2.5"><span className="text-foreground/40">{icon}</span><span>{text}</span></div>; }
|
||||
|
||||
@@ -252,6 +252,7 @@ export type AdminOSSSetting = {
|
||||
provider: "aliyun" | "tencent";
|
||||
region: string;
|
||||
endpoint: string;
|
||||
cdnBaseUrl: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
accessKeySecret?: string;
|
||||
|
||||
@@ -28,6 +28,7 @@ export type UserOSSSetting = {
|
||||
provider: "aliyun" | "tencent";
|
||||
region: string;
|
||||
endpoint: string;
|
||||
cdnBaseUrl: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
hasAccessKeySecret: boolean;
|
||||
@@ -36,7 +37,7 @@ export type UserOSSSetting = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type UserOSSSettingInput = Pick<UserOSSSetting, "enabled" | "provider" | "region" | "endpoint" | "bucket" | "accessKeyId" | "pathPrefix"> & {
|
||||
export type UserOSSSettingInput = Pick<UserOSSSetting, "enabled" | "provider" | "region" | "endpoint" | "cdnBaseUrl" | "bucket" | "accessKeyId" | "pathPrefix"> & {
|
||||
accessKeySecret?: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user