Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 22 additions & 21 deletions internal/model/device_templates.http.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,8 @@ type PublishToMarketReq struct {
MarketName string `json:"market_name"` // 市场展示名称
Brand string `json:"brand"` // 品牌
Model string `json:"model"` // 型号
Category string `json:"category"` // 分类
Version string `json:"version"` // 版本号
Category string `json:"category"` // 分类
Version string `json:"version"` // 版本号
Author string `json:"author"` // 作者
Description string `json:"description"` // 描述
}
Expand All @@ -128,16 +128,17 @@ type DeviceConfigPayload struct {

// PublishTemplateReq 发布模板到市场的业务契约对象(发送给 market-service)
type PublishTemplateReq struct {
CoverURL string `json:"cover_url,omitempty"`
Name string `json:"name"`
Brand string `json:"brand"`
Model string `json:"model"`
Category string `json:"category"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description"`
CoverURL string `json:"cover_url,omitempty"`
SourceTemplateID string `json:"source_template_id"`
Name string `json:"name"`
Brand string `json:"brand"`
Model string `json:"model"`
Category string `json:"category"`
Author string `json:"author"`
Version string `json:"version"`
Description string `json:"description"`
// DeviceConfig 凭证协议配置
DeviceConfig *DeviceConfigPayload `json:"device_config"`
DeviceConfig *DeviceConfigPayload `json:"device_config"`
// TemplateDefinition 模板定义(面板+物模型)
TemplateDefinition map[string]interface{} `json:"template_definition"`
PluginDependencies []PluginDependency `json:"plugin_dependencies"`
Expand Down Expand Up @@ -176,20 +177,20 @@ type MarketTemplateListReq struct {

// MarketTemplateFullData 市场模板完整数据(从市场下载的完整定义)
type MarketTemplateFullData struct {
Name string `json:"name"`
Brand string `json:"brand"`
ModelNumber string `json:"model_number"`
Category string `json:"category"`
Author string `json:"author"`
VersionID string `json:"version_id"`
Version string `json:"version"`
Description string `json:"description"`
CoverURL string `json:"cover_url"`
Name string `json:"name"`
Brand string `json:"brand"`
ModelNumber string `json:"model_number"`
Category string `json:"category"`
Author string `json:"author"`
VersionID string `json:"version_id"`
Version string `json:"version"`
Description string `json:"description"`
CoverURL string `json:"cover_url"`
// 设备配置(凭证协议配置)
DeviceConfig *DeviceConfigPayload `json:"device_config"`
// 模板定义(物模型 + 面板配置)
TemplateDefinition *TemplateDefinitionPayload `json:"template_definition"`
PluginDependencies []PluginDependency `json:"plugin_dependencies"`
PluginDependencies []PluginDependency `json:"plugin_dependencies"`
}

// TemplateDefinitionPayload 模板定义(面板配置)
Expand Down
40 changes: 20 additions & 20 deletions internal/service/device_template_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package service
import (
"context"
"encoding/json"
"strings"
"os"
"time"

"project/internal/dal"
Expand All @@ -30,6 +30,23 @@ func (*DeviceTemplate) InstallFromMarket(req model.InstallFromMarketReq, claims
})
}

// Copy the resource-center cover locally before creating database records.
// The local file is removed if any later installation step fails.
localImageURL, localImagePath, err := localizeMarketTemplateImage(context.Background(), client, fullData)
if err != nil {
return nil, errcode.WithData(errcode.CodeSystemError, map[string]interface{}{
"error": "Failed to localize market template cover: " + err.Error(),
})
}
installCommitted := false
defer func() {
if !installCommitted && localImagePath != "" {
if removeErr := os.Remove(localImagePath); removeErr != nil && !os.IsNotExist(removeErr) {
logrus.Errorf("Failed to clean up localized market template cover %s: %v", localImagePath, removeErr)
}
}
}()

// 2. Check plugin dependencies (before any DB writes)
missingPlugins := checkMissingPlugins(fullData.PluginDependencies)

Expand Down Expand Up @@ -260,7 +277,7 @@ func (*DeviceTemplate) InstallFromMarket(req model.InstallFromMarketReq, claims
DeviceTemplateID: &templateID, // 引用新创建的 DeviceTemplate
DeviceType: "1", // 默认直连设备
TenantID: claims.TenantID,
ImageURL: resolveMarketTemplateImageURL(fullData),
ImageURL: localImageURL,
CreatedAt: now,
UpdatedAt: now,
}
Expand Down Expand Up @@ -313,6 +330,7 @@ func (*DeviceTemplate) InstallFromMarket(req model.InstallFromMarketReq, claims
"error": "Failed to commit transaction: " + err.Error(),
})
}
installCommitted = true

// 4. Report successful local use to the resource center (async, non-blocking).
// dcID is stable and acts as the idempotency key for this real local import.
Expand Down Expand Up @@ -343,24 +361,6 @@ func (*DeviceTemplate) InstallFromMarket(req model.InstallFromMarketReq, claims
}, nil
}

// resolveMarketTemplateImageURL prefers the resource-center-owned cover and
// only falls back to the publisher's original device-config image for legacy
// download payloads.
func resolveMarketTemplateImageURL(fullData *model.MarketTemplateFullData) *string {
if fullData == nil {
return nil
}
if coverURL := strings.TrimSpace(fullData.CoverURL); coverURL != "" {
return &coverURL
}
if fullData.DeviceConfig != nil {
if imageURL := strings.TrimSpace(fullData.DeviceConfig.ImageURL); imageURL != "" {
return &imageURL
}
}
return nil
}

// checkMissingPlugins checks which plugin dependencies are not installed locally
func checkMissingPlugins(deps []model.PluginDependency) []model.PluginDependency {
if len(deps) == 0 {
Expand Down
183 changes: 143 additions & 40 deletions internal/service/device_template_install_test.go
Original file line number Diff line number Diff line change
@@ -1,61 +1,164 @@
package service

import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"project/internal/model"
)

func TestResolveMarketTemplateImageURL(t *testing.T) {
var testPNG = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0x00, 0x00, 0x0d, 'I', 'H', 'D', 'R'}

func TestDownloadMarketTemplateImage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/market/templates/assets/covers/template/cover.png" {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(testPNG)
}))
defer server.Close()

storageDir := t.TempDir()
client := &MarketClient{baseURL: server.URL, httpClient: &http.Client{Timeout: time.Second}}
localURL, diskPath, err := downloadMarketTemplateImage(
context.Background(), client,
server.URL+"/api/market/templates/assets/covers/template/cover.png",
storageDir,
)
if err != nil {
t.Fatalf("downloadMarketTemplateImage() error = %v", err)
}
if !strings.HasPrefix(localURL, "./files/deviceConfig/") || !strings.HasSuffix(localURL, ".png") {
t.Fatalf("unexpected local URL %q", localURL)
}
if filepath.Ext(diskPath) != ".png" {
t.Fatalf("unexpected disk path %q", diskPath)
}
data, err := os.ReadFile(diskPath)
if err != nil {
t.Fatalf("read localized image: %v", err)
}
if string(data) != string(testPNG) {
t.Fatalf("localized image content differs")
}
entries, err := os.ReadDir(filepath.Join(storageDir, ".staging"))
if err != nil || len(entries) != 0 {
t.Fatalf("staging directory was not cleaned: entries=%d err=%v", len(entries), err)
}
}

func TestLocalizeMarketTemplateImageWithoutCover(t *testing.T) {
client := &MarketClient{baseURL: "https://resources.example.com", httpClient: &http.Client{}}
tests := []struct {
name string
data *model.MarketTemplateFullData
expected string
wantNil bool
name string
data *model.MarketTemplateFullData
}{
{name: "nil payload", data: nil},
{name: "empty cover", data: &model.MarketTemplateFullData{}},
{
name: "resource center cover takes precedence",
data: &model.MarketTemplateFullData{
CoverURL: " https://r.thingspanel.cn/api/market/templates/assets/covers/template/1.0.0.png ",
DeviceConfig: &model.DeviceConfigPayload{
ImageURL: "https://publisher.example/files/old.png",
},
},
expected: "https://r.thingspanel.cn/api/market/templates/assets/covers/template/1.0.0.png",
},
{
name: "legacy device config image is used as fallback",
data: &model.MarketTemplateFullData{
DeviceConfig: &model.DeviceConfigPayload{
ImageURL: " https://publisher.example/files/legacy.png ",
},
},
expected: "https://publisher.example/files/legacy.png",
},
{
name: "missing image remains empty",
data: &model.MarketTemplateFullData{DeviceConfig: &model.DeviceConfigPayload{}},
wantNil: true,
},
{
name: "nil payload remains empty",
data: nil,
wantNil: true,
name: "legacy publisher image is not downloaded",
data: &model.MarketTemplateFullData{DeviceConfig: &model.DeviceConfigPayload{
ImageURL: "https://publisher.example/files/legacy.png",
}},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := resolveMarketTemplateImageURL(tt.data)
if tt.wantNil {
if actual != nil {
t.Fatalf("expected nil, got %q", *actual)
}
return
localURL, diskPath, err := localizeMarketTemplateImage(context.Background(), client, tt.data)
if err != nil {
t.Fatalf("localizeMarketTemplateImage() error = %v", err)
}
if actual == nil || *actual != tt.expected {
t.Fatalf("expected %q, got %v", tt.expected, actual)
if localURL != nil || diskPath != "" {
t.Fatalf("expected no localized image, got url=%v path=%q", localURL, diskPath)
}
})
}
}

func TestDownloadMarketTemplateImageRejectsUntrustedOrigin(t *testing.T) {
client := &MarketClient{baseURL: "https://resources.example.com", httpClient: &http.Client{}}
_, _, err := downloadMarketTemplateImage(
context.Background(), client,
"https://publisher.example/api/market/templates/assets/covers/cover.png",
t.TempDir(),
)
if err == nil || !strings.Contains(err.Error(), "not hosted by the configured resource center") {
t.Fatalf("expected untrusted origin error, got %v", err)
}
}

func TestValidateMarketTemplateAssetURLAllowsEquivalentLoopbackHost(t *testing.T) {
err := validateMarketTemplateAssetURL(
"http://127.0.0.1:18000",
"http://localhost:18000/api/market/templates/assets/covers/cover.png",
)
if err != nil {
t.Fatalf("expected equivalent loopback hosts to be allowed, got %v", err)
}
}

func TestDownloadMarketTemplateImageRejectsInvalidContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("not an image"))
}))
defer server.Close()

client := &MarketClient{baseURL: server.URL, httpClient: server.Client()}
_, _, err := downloadMarketTemplateImage(
context.Background(), client,
server.URL+"/api/market/templates/assets/covers/cover.png",
t.TempDir(),
)
if err == nil || !strings.Contains(err.Error(), "unsupported market cover content type") {
t.Fatalf("expected invalid content error, got %v", err)
}
}

func TestDownloadMarketTemplateImageRejectsOversizeBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "5242881")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

client := &MarketClient{baseURL: server.URL, httpClient: server.Client()}
_, _, err := downloadMarketTemplateImage(
context.Background(), client,
server.URL+"/api/market/templates/assets/covers/cover.png",
t.TempDir(),
)
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("expected oversize error, got %v", err)
}
}

func TestDownloadMarketTemplateImageRejectsCrossOriginRedirect(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(testPNG)
}))
defer target.Close()

source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL+"/api/market/templates/assets/covers/cover.png", http.StatusFound)
}))
defer source.Close()

client := &MarketClient{baseURL: source.URL, httpClient: source.Client()}
_, _, err := downloadMarketTemplateImage(
context.Background(), client,
source.URL+"/api/market/templates/assets/covers/cover.png",
t.TempDir(),
)
if err == nil || !strings.Contains(err.Error(), "not hosted by the configured resource center") {
t.Fatalf("expected cross-origin redirect error, got %v", err)
}
}
19 changes: 10 additions & 9 deletions internal/service/device_template_publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,16 @@ func (*DeviceTemplate) PublishToMarket(req model.PublishToMarketReq, _ *utils.Us

// 8. Build publish request to market
marketReq := &model.PublishTemplateReq{
Name: name,
Brand: brand,
Model: devModel,
Category: category,
Author: author,
Version: version,
Description: description,
CoverURL: coverURL,
DeviceConfig: deviceConfig,
SourceTemplateID: tplID,
Name: name,
Brand: brand,
Model: devModel,
Category: category,
Author: author,
Version: version,
Description: description,
CoverURL: coverURL,
DeviceConfig: deviceConfig,
TemplateDefinition: map[string]interface{}{
"web_chart_config": tplDef["web_chart_config"],
"app_chart_config": tplDef["app_chart_config"],
Expand Down
Loading