feat(media): 对象存储 CDN - 支持阿里云 OSS 与腾讯云 COS 下载加速

合并平台与个人对象存储 CDN 下载域名配置,上传继续使用原始 Endpoint。\n\n后续修复对象路径二次编码并补充 CDN 访问控制说明。
This commit is contained in:
MasterChiefCN
2026-08-15 11:21:42 +08:00
committed by GitHub
parent 52131a53fc
commit 7ade97e0a3
9 changed files with 249 additions and 10 deletions

View File

@@ -2,6 +2,8 @@
## Unreleased
- 阿里云 OSS 与腾讯云 COS 的平台、个人存储支持配置可选 CDN 加速域名,上传继续使用 Endpoint,下载与预览在配置后改用 CDN 域名。
## v1.0.57
- 修复已有媒体节点再次生成失败时污染原成功节点状态的问题,视频新版本仅在生成成功后切换为主版本。

View File

@@ -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。
## 公网部署安全

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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) != "",

View File

@@ -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: "",

View File

@@ -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>; }

View File

@@ -252,6 +252,7 @@ export type AdminOSSSetting = {
provider: "aliyun" | "tencent";
region: string;
endpoint: string;
cdnBaseUrl: string;
bucket: string;
accessKeyId: string;
accessKeySecret?: string;

View File

@@ -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;
};