fix(*): 资源与技能 - 精简列表载荷并禁止对象存储静默降级
This commit is contained in:
@@ -58,7 +58,7 @@ func (s *Service) UserAssetsPage(userID string, page int, pageSize int, filter U
|
||||
}
|
||||
rawAssets := make([]json.RawMessage, 0, len(assets))
|
||||
for _, asset := range assets {
|
||||
if payload := clientAssetPayload(asset); len(payload) > 0 {
|
||||
if payload := clientAssetListPayload(asset); len(payload) > 0 {
|
||||
rawAssets = append(rawAssets, payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
_ "image/png"
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -432,10 +431,9 @@ func writeLocalResourceObject(filePath string, body io.Reader) error {
|
||||
return closeErr
|
||||
}
|
||||
|
||||
// A cloud write is successful only after the configured origin accepts it.
|
||||
// storeResourceObject writes to the configured origin and degrades to local storage
|
||||
// when the external origin is unavailable. The resource binding is rewritten before
|
||||
// the caller persists the ready state, so later reads follow the actual object location.
|
||||
// storeResourceObject writes to the configured origin. When object storage is
|
||||
// enabled, a failed origin write must fail the resource instead of silently
|
||||
// changing its storage location to local disk.
|
||||
func (s *Service) storeResourceObject(resource *model.Resource, fileName string, body io.Reader) (string, error) {
|
||||
if resource == nil {
|
||||
return "", errors.New("资源不存在")
|
||||
@@ -444,35 +442,14 @@ func (s *Service) storeResourceObject(resource *model.Resource, fileName string,
|
||||
return "", writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)), body)
|
||||
}
|
||||
setting, settingErr := s.ossSettingForResource(resource.UserID, resource)
|
||||
var etag string
|
||||
var putErr error
|
||||
if settingErr == nil {
|
||||
etag, putErr = putOSSObject(setting, resource.ObjectKey, resource.MimeType, resource.Size, body)
|
||||
if settingErr != nil {
|
||||
return "", settingErr
|
||||
}
|
||||
if putErr == nil && settingErr == nil {
|
||||
return etag, nil
|
||||
etag, err := putOSSObject(setting, resource.ObjectKey, resource.MimeType, resource.Size, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fallbackErr := putErr
|
||||
if fallbackErr == nil {
|
||||
fallbackErr = settingErr
|
||||
}
|
||||
if seeker, ok := body.(io.Seeker); ok {
|
||||
if _, seekErr := seeker.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return "", errors.Join(fallbackErr, fmt.Errorf("降级本地存储时重置读取位置失败:%w", seekErr))
|
||||
}
|
||||
}
|
||||
localKey := localObjectKey(resource.UserID, resource.Kind, fileName, resource.MimeType, time.Now())
|
||||
resource.Provider = "local"
|
||||
resource.ObjectKey = localKey
|
||||
resource.Endpoint = ""
|
||||
resource.Bucket = ""
|
||||
resource.StorageSettingID = ""
|
||||
resource.ETag = ""
|
||||
if localErr := writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(localKey)), body); localErr != nil {
|
||||
return "", errors.Join(fallbackErr, fmt.Errorf("降级本地存储失败:%w", localErr))
|
||||
}
|
||||
log.Printf("object storage upload degraded to local storage: resource=%s error=%v", resource.ID, fallbackErr)
|
||||
return "", nil
|
||||
return etag, nil
|
||||
}
|
||||
|
||||
func (s *Service) retryStoredResource(userID string, resource *model.Resource, kind string, mimeType string, size int64, body io.Reader) (*model.Resource, error) {
|
||||
|
||||
@@ -31,10 +31,9 @@ func newResourceFallbackTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
return &Service{repo: repository.New(db), dataDir: t.TempDir()}, db
|
||||
}
|
||||
|
||||
// TestStoreResourceDegradesToLocalWhenOSSUnavailable 覆盖对象存储不可用时的降级:
|
||||
// 配置了 OSS(enabled=1、endpoint 不可达)时上传应回退本地存储成功,
|
||||
// 而不是把上传标记为失败——本地媒体导入不应因外部存储故障整体失败。
|
||||
func TestStoreResourceDegradesToLocalWhenOSSUnavailable(t *testing.T) {
|
||||
// TestStoreResourceFailsWhenOSSUnavailable ensures an enabled object-storage
|
||||
// configuration does not silently change the resource to local storage.
|
||||
func TestStoreResourceFailsWhenOSSUnavailable(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1")
|
||||
service, db := newResourceFallbackTestService(t)
|
||||
seedOSSEnabled(t, db, "user-1", "http://127.0.0.1:1")
|
||||
@@ -43,28 +42,28 @@ func TestStoreResourceDegradesToLocalWhenOSSUnavailable(t *testing.T) {
|
||||
"user-1", "video", "intro.mp4", "video/mp4", 1024,
|
||||
1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil, false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("storeResource: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("storeResource() error = nil, want OSS upload error")
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("storeResource returned created=false, want true")
|
||||
t.Fatal("storeResource returned created=false, want true for persisted failed resource")
|
||||
}
|
||||
if resource.Status != model.ResourceStatusReady {
|
||||
t.Fatalf("resource.Status = %q, want %q", resource.Status, model.ResourceStatusReady)
|
||||
if resource != nil {
|
||||
t.Fatalf("storeResource returned resource = %#v, want nil on failed upload", resource)
|
||||
}
|
||||
if resource.Provider != "local" {
|
||||
t.Fatalf("resource.Provider = %q, want local (degraded from OSS)", resource.Provider)
|
||||
|
||||
var stored model.Resource
|
||||
if err := db.First(&stored).Error; err != nil {
|
||||
t.Fatalf("failed resource was not persisted: %v", err)
|
||||
}
|
||||
if resource.Endpoint != "" || resource.Bucket != "" || resource.StorageSettingID != "" {
|
||||
t.Fatalf("resource storage binding not cleared after degrade: endpoint=%q bucket=%q setting=%q",
|
||||
resource.Endpoint, resource.Bucket, resource.StorageSettingID)
|
||||
if stored.Status != model.ResourceStatusFailed {
|
||||
t.Fatalf("resource.Status = %q, want %q", stored.Status, model.ResourceStatusFailed)
|
||||
}
|
||||
payload, err := os.ReadFile(filepath.Join(service.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)))
|
||||
if err != nil {
|
||||
t.Fatalf("local object not written: %v", err)
|
||||
if stored.Provider != "aliyun" || stored.Endpoint != "http://127.0.0.1:1" || stored.Bucket != "test-bucket" || stored.StorageSettingID == "" {
|
||||
t.Fatalf("resource OSS binding changed after failed upload: provider=%q endpoint=%q bucket=%q setting=%q", stored.Provider, stored.Endpoint, stored.Bucket, stored.StorageSettingID)
|
||||
}
|
||||
if string(payload) != "fake-mp4-bytes" {
|
||||
t.Fatalf("local object content = %q, want fake-mp4-bytes", payload)
|
||||
if _, err := os.Stat(filepath.Join(service.dataDir, "resources")); !os.IsNotExist(err) {
|
||||
t.Fatalf("local resources directory exists after failed OSS upload: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +97,7 @@ func TestStoreResourceLocalPathUnaffectedByOSS(t *testing.T) {
|
||||
func seedOSSEnabled(t *testing.T, db *gorm.DB, userID string, endpoint string) {
|
||||
t.Helper()
|
||||
setting := model.UserOSSSetting{
|
||||
ID: newID(),
|
||||
UserID: userID,
|
||||
Enabled: true,
|
||||
ValueJSON: `{"provider":"aliyun","endpoint":"` + endpoint + `","bucket":"test-bucket","accessKeyId":"ak-test","accessKeySecret":"sk-plaintext","region":"cn-shenzhen"}`,
|
||||
|
||||
@@ -13,6 +13,7 @@ type (
|
||||
SkillShowcaseMedia = skills.SkillShowcaseMedia
|
||||
SkillEffectiveUser = skills.SkillEffectiveUser
|
||||
SkillItem = skills.SkillItem
|
||||
AddedSkillReference = skills.AddedSkillReference
|
||||
SkillCategory = skills.SkillCategory
|
||||
SkillListRequest = skills.SkillListRequest
|
||||
SkillList = skills.SkillList
|
||||
@@ -41,7 +42,7 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error
|
||||
return s.skillDomain().Skills(userID, req)
|
||||
}
|
||||
|
||||
func (s *Service) AddedSkills(userID string) ([]SkillItem, error) {
|
||||
func (s *Service) AddedSkills(userID string) ([]AddedSkillReference, error) {
|
||||
return s.skillDomain().AddedSkills(userID)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,51 @@ func ClientAssetPayload(asset model.Asset) json.RawMessage {
|
||||
return encoded
|
||||
}
|
||||
|
||||
// ClientAssetListPayload is the list variant used by the paginated asset
|
||||
// library. The list only needs enough metadata to render cards and perform
|
||||
// an action; generation prompts and other verbose metadata belong to the
|
||||
// asset detail/snapshot paths. Keeping them out of every page also avoids
|
||||
// repeatedly parsing and copying the same prompt strings in the browser.
|
||||
func ClientAssetListPayload(asset model.Asset) json.RawMessage {
|
||||
raw := ClientAssetPayload(asset)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || payload == nil {
|
||||
return raw
|
||||
}
|
||||
if metadata, ok := payload["metadata"].(map[string]any); ok {
|
||||
// 列表只需要项目归属提示。canvasId 仍保留,避免列表卡片把画布素材
|
||||
// 错显示成“未关联项目”;节点 ID、生成提示和其他同步上下文在详情路径读取。
|
||||
for key := range metadata {
|
||||
if key != "projectName" && key != "projectIds" && key != "canvasId" {
|
||||
delete(metadata, key)
|
||||
}
|
||||
}
|
||||
if len(metadata) == 0 {
|
||||
delete(payload, "metadata")
|
||||
}
|
||||
}
|
||||
// 图片封面和 dataUrl、视频封面和 url 通常是同一个地址;列表保留
|
||||
// coverUrl 字段以满足前端合同,但不再重复传输这段长 URL。
|
||||
if cover, ok := payload["coverUrl"].(string); ok {
|
||||
if data, ok := payload["data"].(map[string]any); ok {
|
||||
for _, key := range []string{"dataUrl", "url"} {
|
||||
if value, ok := data[key].(string); ok && value == cover {
|
||||
payload["coverUrl"] = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func deriveAssetCoverURL(payload map[string]any) string {
|
||||
if value, ok := payload["coverUrl"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
|
||||
@@ -60,3 +60,24 @@ func TestClientAssetPayloadPreservesCompleteDocument(t *testing.T) {
|
||||
t.Fatalf("payload changed: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAssetListPayloadDropsVerboseMetadata(t *testing.T) {
|
||||
asset := model.Asset{PayloadJSON: `{"id":"asset-1","kind":"image","title":"列表","coverUrl":"https://example.com/a.png","tags":[],"data":{"dataUrl":"https://example.com/a.png","width":2,"height":3,"bytes":1,"mimeType":"image/png"},"metadata":{"prompt":"很长的生成提示词","nodeId":"node-1","projectIds":["project-1"]}}`}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(ClientAssetListPayload(asset), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := payload["metadata"].(map[string]any)
|
||||
if _, found := metadata["prompt"]; found {
|
||||
t.Fatalf("list payload still contains prompt: %#v", metadata)
|
||||
}
|
||||
if _, found := metadata["nodeId"]; found {
|
||||
t.Fatalf("list payload still contains node metadata: %#v", metadata)
|
||||
}
|
||||
if metadata["projectIds"].([]any)[0] != "project-1" {
|
||||
t.Fatalf("list payload dropped useful metadata: %#v", metadata)
|
||||
}
|
||||
if payload["coverUrl"] != "" {
|
||||
t.Fatalf("duplicate cover URL was not removed: %#v", payload["coverUrl"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,22 @@ type SkillItem struct {
|
||||
IsOwner bool `json:"isOwner"`
|
||||
}
|
||||
|
||||
// AddedSkillReference is the small contract used by the runtime catalog.
|
||||
// The full SkillItem contains editor, sync, author and showcase fields that
|
||||
// are only needed by the skill detail/market pages, not when selecting an
|
||||
// installed skill for a generation request.
|
||||
type AddedSkillReference struct {
|
||||
SkillID string `json:"skillId"`
|
||||
SkillName string `json:"skillName"`
|
||||
Description string `json:"description"`
|
||||
VersionID string `json:"versionId"`
|
||||
Version string `json:"version"`
|
||||
Tag string `json:"tag"`
|
||||
IsLike bool `json:"isLike"`
|
||||
IsAdded bool `json:"isAdded"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
}
|
||||
|
||||
type SkillCategory struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
@@ -151,12 +167,24 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddedSkills(userID string) ([]SkillItem, error) {
|
||||
func (s *Service) AddedSkills(userID string) ([]AddedSkillReference, error) {
|
||||
rows, _, err := s.repo.Skills(repository.SkillListFilter{UserID: userID, Scope: "mine", Sort: "updated", Limit: -1})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.skillItems(userID, rows, false)
|
||||
items, err := s.skillItems(userID, rows, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
references := make([]AddedSkillReference, 0, len(items))
|
||||
for _, item := range items {
|
||||
references = append(references, AddedSkillReference{
|
||||
SkillID: item.SkillID, SkillName: item.SkillName, Description: item.Description,
|
||||
VersionID: item.VersionID, Version: item.Version, Tag: item.Tag,
|
||||
IsLike: item.IsLike, IsAdded: item.IsAdded, IsOwner: item.IsOwner,
|
||||
})
|
||||
}
|
||||
return references, nil
|
||||
}
|
||||
|
||||
func (s *Service) SkillDetail(userID string, id string) (*SkillItem, error) {
|
||||
|
||||
@@ -58,3 +58,24 @@ func TestParseShowcaseMediaAcceptsStoredSnakeCase(t *testing.T) {
|
||||
t.Fatalf("unexpected camelCase parse: %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddedSkillReferenceJSONExcludesEditorAndSyncPayload(t *testing.T) {
|
||||
body, err := json.Marshal(AddedSkillReference{
|
||||
SkillID: "skill-1", SkillName: "镜头拆解", Description: "用于镜头规划", VersionID: "v1",
|
||||
Version: "1.0.0", Tag: "creative", IsAdded: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(body)
|
||||
for _, key := range []string{"instruction", "extraInfo", "showcaseMedia", "sourceUrl", "syncStatus", "effectiveUser"} {
|
||||
if strings.Contains(text, `"`+key+`"`) {
|
||||
t.Fatalf("added skill reference contains %s: %s", key, text)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"skillId", "skillName", "versionId", "tag", "isAdded"} {
|
||||
if !strings.Contains(text, `"`+key+`"`) {
|
||||
t.Fatalf("added skill reference misses %s: %s", key, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user