From 8e0deaff48c022f92e9b53263ac0e23ea603ce83 Mon Sep 17 00:00:00 2001
From: ddCat <2695092045@qq.com>
Date: Tue, 4 Aug 2026 22:13:38 +0800
Subject: [PATCH] =?UTF-8?q?feat(media):=20=E6=9C=AC=E5=9C=B0=E5=AD=98?=
=?UTF-8?q?=E5=82=A8=20-=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89?=
=?UTF-8?q?=E8=AE=BF=E9=97=AE=E5=9C=B0=E5=9D=80=E4=B8=8E=E7=9F=AD=E6=97=B6?=
=?UTF-8?q?=E7=AD=BE=E5=90=8D=E9=93=BE=E6=8E=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/internal/handler/user_data.go | 25 +++++
backend/internal/service/provider.go | 46 +-------
backend/internal/service/resource.go | 102 +++++++++++++++++-
backend/internal/service/resource_test.go | 65 ++++++++++-
backend/internal/service/settings.go | 8 ++
.../admin/settings/storage-settings-page.tsx | 40 +++++--
6 files changed, 225 insertions(+), 61 deletions(-)
diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go
index 28680532c..d9b326aa9 100644
--- a/backend/internal/handler/user_data.go
+++ b/backend/internal/handler/user_data.go
@@ -261,6 +261,31 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
}
c.DataFromReader(stream.StatusCode, stream.ContentLength, resource.MimeType, stream.Body, nil)
})
+ r.GET("/public/resources/:id/file", func(c *gin.Context) {
+ stream, err := svc.OpenPublicResourceRange(c.Param("id"), c.Query("expires"), c.Query("signature"), c.GetHeader("Range"))
+ if err != nil {
+ failService(c, err)
+ return
+ }
+ defer stream.Body.Close()
+ resource := stream.Resource
+ if resource.MimeType == "" {
+ resource.MimeType = "application/octet-stream"
+ }
+ c.Header("Cache-Control", "public, max-age=0, must-revalidate")
+ c.Header("Accept-Ranges", "bytes")
+ c.Header("Referrer-Policy", "no-referrer")
+ c.Header("X-Content-Type-Options", "nosniff")
+ if stream.ContentRange != "" {
+ c.Header("Content-Range", stream.ContentRange)
+ }
+ if seeker, ok := stream.Body.(io.ReadSeeker); ok {
+ c.Header("Content-Type", resource.MimeType)
+ http.ServeContent(c.Writer, c.Request, resource.ID, resource.UpdatedAt, seeker)
+ return
+ }
+ c.DataFromReader(stream.StatusCode, stream.ContentLength, resource.MimeType, stream.Body, nil)
+ })
r.GET("/assets", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go
index fce80e958..d3b54bcfe 100644
--- a/backend/internal/service/provider.go
+++ b/backend/internal/service/provider.go
@@ -15,7 +15,6 @@ import (
"net/http"
"net/textproto"
"net/url"
- "path/filepath"
"strconv"
"strings"
"time"
@@ -251,50 +250,7 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requ
if resource.Status != "ready" {
return errors.New("任务参考资源尚未上传完成")
}
- // 本地素材自动迁移到 OSS
- if resource.Provider == "local" {
- setting, storageSettingID, useOSS, ossErr := s.activeResourceOSSSetting(userID)
- if ossErr != nil || !useOSS || setting.Provider != "aliyun" {
- return errors.New("当前 JSON 视频协议的参考素材需要公网可访问地址,请启用 OSS 后重新上传该素材")
- }
- // 读取本地文件并上传到 OSS
- _, body, readErr := s.OpenResource(userID, resourceID)
- if readErr != nil {
- return fmt.Errorf("读取本地素材失败:%w", readErr)
- }
- ossKey := ossObjectKey(setting, userID, resource.Kind, filepath.Base(resource.ObjectKey), time.Now())
- if _, uploadErr := putOSSObject(setting, ossKey, resource.MimeType, resource.Size, body); uploadErr != nil {
- body.Close()
- return fmt.Errorf("素材迁移到 OSS 失败:%w", uploadErr)
- }
- body.Close()
- // 更新资源记录
- resource.Provider = "aliyun"
- resource.ObjectKey = ossKey
- resource.Endpoint = setting.Endpoint
- resource.Bucket = setting.Bucket
- resource.StorageSettingID = storageSettingID
- if saveErr := s.repo.SaveResource(resource); saveErr != nil {
- return fmt.Errorf("更新资源记录失败:%w", saveErr)
- }
- // 生成签名 URL
- signedURL, signErr := signedOSSObjectURL(setting, ossKey, time.Now().Add(providerResourceURLTTL))
- if signErr != nil {
- return fmt.Errorf("生成 OSS 签名地址失败:%w", signErr)
- }
- media.URL = signedURL
- media.DataURL = ""
- media.MimeType = firstNonEmpty(media.MimeType, resource.MimeType)
- return nil
- }
- setting, err := s.ossSettingForResource(userID, resource)
- if err != nil {
- return err
- }
- if setting.Provider != "aliyun" {
- return errors.New("当前 JSON 视频协议的私有参考素材暂时只支持阿里云 OSS 签名地址")
- }
- signedURL, err := signedOSSObjectURL(setting, resource.ObjectKey, time.Now().Add(providerResourceURLTTL))
+ signedURL, err := s.directResourceURL(resource, time.Now().Add(providerResourceURLTTL))
if err != nil {
return fmt.Errorf("生成 JSON 视频协议参考素材地址失败:%w", err)
}
diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go
index 0f2526313..2cb7f2fc4 100644
--- a/backend/internal/service/resource.go
+++ b/backend/internal/service/resource.go
@@ -4,6 +4,7 @@ import (
"bytes"
"crypto/hmac"
"crypto/sha1"
+ "crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
@@ -57,26 +58,105 @@ func (s *Service) Resource(userID string, id string) (*model.Resource, error) {
return resource, err
}
-// DirectResourceURL 先校验资源归属,再为私有 OSS 对象签发短时下载地址;本地资源继续由应用流式读取。
+// DirectResourceURL 先校验资源归属,再按实际存储位置签发短时下载地址。
func (s *Service) DirectResourceURL(userID string, id string) (string, error) {
resource, err := s.repo.ResourceForUser(userID, id)
if err != nil {
return "", err
}
+ return s.directResourceURL(resource, time.Now().Add(directResourceURLTTL))
+}
+
+func (s *Service) directResourceURL(resource *model.Resource, expiresAt time.Time) (string, error) {
+ if resource == nil {
+ return "", errors.New("资源不存在")
+ }
if resource.Status != model.ResourceStatusReady {
return "", BadAuthRequest("资源尚未上传完成")
}
if resource.Provider == "local" {
- return "", BadAuthRequest("当前资源未存储在 OSS")
+ return s.signedPublicResourceURL(resource.ID, expiresAt)
}
- setting, err := s.ossSettingForResource(userID, resource)
+ setting, err := s.ossSettingForResource(resource.UserID, resource)
if err != nil {
return "", err
}
setting.Provider = firstNonEmpty(resource.Provider, setting.Provider)
setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint)
setting.Bucket = firstNonEmpty(resource.Bucket, setting.Bucket)
- return signedOSSObjectURL(setting, resource.ObjectKey, time.Now().Add(directResourceURLTTL))
+ return signedOSSObjectURL(setting, resource.ObjectKey, expiresAt)
+}
+
+func (s *Service) signedPublicResourceURL(resourceID string, expiresAt time.Time) (string, error) {
+ baseURL, err := s.publicResourceBaseURL()
+ if err != nil {
+ return "", err
+ }
+ expires := strconv.FormatInt(expiresAt.UTC().Unix(), 10)
+ signature, err := s.signPublicResource(resourceID, expires)
+ if err != nil {
+ return "", err
+ }
+ baseURL.Path = strings.TrimRight(baseURL.Path, "/") + "/api/public/resources/" + url.PathEscape(resourceID) + "/file"
+ query := baseURL.Query()
+ query.Set("expires", expires)
+ query.Set("signature", signature)
+ baseURL.RawQuery = query.Encode()
+ return baseURL.String(), nil
+}
+
+func (s *Service) verifyPublicResourceSignature(resourceID string, expires string, signature string) error {
+ if strings.TrimSpace(signature) == "" || !decimalDigits(expires) {
+ return Forbidden("匿名下载链接无效")
+ }
+ expiresAt, err := strconv.ParseInt(expires, 10, 64)
+ if err != nil || time.Now().UTC().Unix() > expiresAt {
+ return Forbidden("匿名下载链接已过期")
+ }
+ expected, err := s.signPublicResource(resourceID, expires)
+ if err != nil {
+ return err
+ }
+ if !hmac.Equal([]byte(expected), []byte(signature)) {
+ return Forbidden("匿名下载链接无效")
+ }
+ return nil
+}
+
+func (s *Service) signPublicResource(resourceID string, expires string) (string, error) {
+ key, err := s.settingsEncryptionKey()
+ if err != nil {
+ return "", err
+ }
+ mac := hmac.New(sha256.New, key)
+ _, _ = mac.Write([]byte(resourceID + "\n" + expires))
+ return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
+}
+
+func (s *Service) publicResourceBaseURL() (*url.URL, error) {
+ _, setting, err := s.readOSSSetting()
+ if err != nil {
+ return nil, err
+ }
+ raw := firstNonEmpty(setting.PublicBaseURL, os.Getenv("CANVAS_PUBLIC_BASE_URL"))
+ if raw == "" {
+ return nil, BadAuthRequest("服务器本地存储尚未配置服务器访问地址")
+ }
+ return validatePublicResourceBaseURL(raw)
+}
+
+func validatePublicResourceBaseURL(raw string) (*url.URL, error) {
+ parsed, err := ValidateOutboundURL(raw)
+ if err != nil {
+ return nil, err
+ }
+ if parsed.RawQuery != "" || parsed.Fragment != "" {
+ return nil, BadAuthRequest("服务器访问地址不能包含查询参数或片段")
+ }
+ if strings.HasSuffix(strings.TrimRight(parsed.Path, "/"), "/api") {
+ return nil, BadAuthRequest("服务器访问地址请填写根地址,不要包含 /api")
+ }
+ return parsed, nil
}
func (s *Service) UploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64) (*model.Resource, error) {
@@ -168,6 +248,20 @@ func (s *Service) OpenResourceRange(userID string, id string, rangeHeader string
return s.openResourceRange(userID, resource, rangeHeader)
}
+func (s *Service) OpenPublicResourceRange(id string, expires string, signature string, rangeHeader string) (*ResourceStream, error) {
+ resource, err := s.repo.Resource(id)
+ if err != nil {
+ return nil, Forbidden("匿名下载链接无效")
+ }
+ if resource.Provider != "local" {
+ return nil, Forbidden("匿名下载链接无效")
+ }
+ if err := s.verifyPublicResourceSignature(resource.ID, expires, signature); err != nil {
+ return nil, err
+ }
+ return s.openResourceRange(resource.UserID, resource, rangeHeader)
+}
+
func (s *Service) openResourceRange(userID string, resource *model.Resource, rangeHeader string) (*ResourceStream, error) {
if resource.Status != model.ResourceStatusReady {
return nil, BadAuthRequest("资源尚未上传完成")
diff --git a/backend/internal/service/resource_test.go b/backend/internal/service/resource_test.go
index 528c46c79..1c8ea5ca7 100644
--- a/backend/internal/service/resource_test.go
+++ b/backend/internal/service/resource_test.go
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "strconv"
"strings"
"testing"
"time"
@@ -109,16 +110,74 @@ func TestHydrateNewAPIChannel1ResourceUsesSignedOSSURL(t *testing.T) {
}
}
-func TestHydrateNewAPIChannel1ResourceRejectsLocalStorage(t *testing.T) {
+func TestHydrateNewAPIChannel1ResourceUsesSignedLocalURL(t *testing.T) {
+ t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
+ server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ defer server.Close()
svc := newResourceTestService(t)
+ settingJSON, _ := json.Marshal(ossSettingValue{Provider: "aliyun", PublicBaseURL: server.URL})
+ if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil {
+ t.Fatal(err)
+ }
resource := model.Resource{ID: "resource-local", UserID: "user-1", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "local.png"}
if err := svc.repo.CreateResource(&resource); err != nil {
t.Fatal(err)
}
- err := svc.hydrateProviderMedia("user-1", &providerMedia{StorageKey: "resource:resource-local"}, true)
- if err == nil || !strings.Contains(err.Error(), "启用 OSS") {
+ media := providerMedia{StorageKey: "resource:resource-local"}
+ if err := svc.hydrateProviderMedia("user-1", &media, true); err != nil {
t.Fatalf("hydrateProviderMedia() error = %v", err)
}
+ if !strings.HasPrefix(media.URL, server.URL+"/api/public/resources/resource-local/file?") || !strings.Contains(media.URL, "signature=") || media.DataURL != "" {
+ t.Fatalf("media = %#v", media)
+ }
+ stored, err := svc.repo.Resource("resource-local")
+ if err != nil || stored.Provider != "local" {
+ t.Fatalf("resource provider changed: %#v, %v", stored, err)
+ }
+}
+
+func TestPublicResourceSignatureRejectsExpiredAndAlteredLinks(t *testing.T) {
+ svc := newResourceTestService(t)
+ expires := strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10)
+ signature, err := svc.signPublicResource("resource-local", expires)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.verifyPublicResourceSignature("resource-local", expires, signature); err != nil {
+ t.Fatalf("verifyPublicResourceSignature() error = %v", err)
+ }
+ if err := svc.verifyPublicResourceSignature("resource-other", expires, signature); err == nil {
+ t.Fatal("verifyPublicResourceSignature() accepted another resource ID")
+ }
+ expired := strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)
+ expiredSignature, err := svc.signPublicResource("resource-local", expired)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.verifyPublicResourceSignature("resource-local", expired, expiredSignature); err == nil {
+ t.Fatal("verifyPublicResourceSignature() accepted an expired link")
+ }
+}
+
+func TestUpdateOSSSettingRequiresLocalServerAddress(t *testing.T) {
+ t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
+ server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ defer server.Close()
+ svc := newResourceTestService(t)
+ admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
+ if _, err := svc.UpdateOSSSetting(admin, OSSSettingRequest{Provider: "aliyun"}); err == nil || !strings.Contains(err.Error(), "服务器访问地址") {
+ t.Fatalf("UpdateOSSSetting() error = %v", err)
+ }
+ if _, err := svc.UpdateOSSSetting(admin, OSSSettingRequest{Provider: "aliyun", PublicBaseURL: server.URL + "/api"}); err == nil || !strings.Contains(err.Error(), "不要包含 /api") {
+ t.Fatalf("UpdateOSSSetting(/api) error = %v", err)
+ }
+ setting, err := svc.UpdateOSSSetting(admin, OSSSettingRequest{Provider: "aliyun", PublicBaseURL: server.URL})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if setting.Enabled || setting.PublicBaseURL != server.URL {
+ t.Fatalf("setting = %#v", setting)
+ }
}
func TestActiveResourceOSSSettingPrefersUserVersion(t *testing.T) {
diff --git a/backend/internal/service/settings.go b/backend/internal/service/settings.go
index 21ab8b07e..3e312cee0 100644
--- a/backend/internal/service/settings.go
+++ b/backend/internal/service/settings.go
@@ -85,6 +85,14 @@ func (s *Service) UpdateOSSSetting(actor *model.User, req OSSSettingRequest) (*P
if err != nil {
return nil, err
}
+ if !next.Enabled {
+ if next.PublicBaseURL == "" {
+ return nil, BadAuthRequest("服务器本地存储需要填写服务器访问地址")
+ }
+ if _, err := validatePublicResourceBaseURL(next.PublicBaseURL); err != nil {
+ return nil, fmt.Errorf("服务器访问地址无效:%w", err)
+ }
+ }
stored := next
stored.AccessKeySecret, err = s.encryptSettingSecret(next.AccessKeySecret)
if err != nil {
diff --git a/web/src/pages/admin/settings/storage-settings-page.tsx b/web/src/pages/admin/settings/storage-settings-page.tsx
index 85b08fe08..7ddf5e5d5 100644
--- a/web/src/pages/admin/settings/storage-settings-page.tsx
+++ b/web/src/pages/admin/settings/storage-settings-page.tsx
@@ -1,5 +1,5 @@
import { App, Button, Form, Input, Segmented, Space, Tag } from "antd";
-import { Cloud, HardDrive, Info, KeyRound, ShieldCheck } from "lucide-react";
+import { Cloud, Globe, HardDrive, Info, KeyRound, LocateFixed, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { getAdminOSSSetting, updateAdminOSSSetting, type AdminOSSSetting } from "@/services/api/auth";
@@ -8,7 +8,7 @@ import { AdminPageFrame } from "../components/admin-shell";
import { configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
type StorageMode = "local" | "oss";
-type OSSFormValues = { mode: StorageMode; region?: string; endpoint?: string; bucket?: string; accessKeyId?: string; accessKeySecret?: string; pathPrefix?: string };
+type OSSFormValues = { mode: StorageMode; publicBaseUrl?: string; region?: string; endpoint?: string; bucket?: string; accessKeyId?: string; accessKeySecret?: string; pathPrefix?: string };
export default function StorageSettingsPage() {
const { message } = App.useApp();
@@ -31,13 +31,14 @@ export default function StorageSettingsPage() {
await form.validateFields();
// 本地模式会卸载 OSS 字段,读取完整 store 才能保留已保存的 OSS 配置。
const values = form.getFieldsValue(true);
+ if (values.mode === "local" && !values.publicBaseUrl?.trim()) return message.error("请填写服务器访问地址");
if (values.mode === "oss" && !values.accessKeySecret?.trim() && !setting?.hasAccessKeySecret) return message.error("请填写 AccessKey Secret");
if (values.mode === "oss" && !values.endpoint?.trim()) return message.error("请填写 OSS Endpoint");
if (values.mode === "oss" && !values.bucket?.trim()) return message.error("请填写 OSS Bucket");
if (values.mode === "oss" && !values.accessKeyId?.trim()) return message.error("请填写 AccessKey ID");
setSaving(true);
try {
- const result = await updateAdminOSSSetting({ enabled: values.mode === "oss", provider: "aliyun", region: values.region?.trim() || "", endpoint: values.endpoint?.trim() || "", bucket: values.bucket?.trim() || "", accessKeyId: values.accessKeyId?.trim() || "", accessKeySecret: values.accessKeySecret?.trim() || "", publicBaseUrl: "", pathPrefix: values.pathPrefix?.trim() || "" });
+ const result = await updateAdminOSSSetting({ enabled: values.mode === "oss", provider: "aliyun", 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() || "" });
setSetting(result.setting);
form.setFieldsValue(formValues(result.setting));
message.success("存储配置已保存");
@@ -52,7 +53,7 @@ export default function StorageSettingsPage() {
存储类型只影响之后新增的媒体资源,不迁移或改写历史文件。服务器本地与 OSS 资源均通过登录鉴权接口读取。 存储类型只影响之后新增的媒体资源,不迁移或改写历史文件。日常读取继续校验登录态,仅在模型必须通过 URL 拉取本地素材时生成短时签名链接。resources/,部署时需持久化该数据卷。
- https://ddcat.pronhubcn.com。http://103.242.14.110:300。/api;保存前可在浏览器访问 <服务器访问地址>/api/health,看到 status: ok 即表示入口正确。CANVAS_BACKEND_DATA_DIR/resources/,部署时必须持久化该数据卷。供外部模型使用时,以上地址也必须能被模型服务访问,生产环境建议使用 HTTPS。