This commit is contained in:
ryan
2026-06-18 16:51:06 +08:00
parent 61cfacba55
commit e3bfd9ca6d
40 changed files with 2819 additions and 116 deletions
+19 -16
View File
@@ -4,20 +4,23 @@
package pages
const (
errPagesProjectNotFound = "Pages 项目不存在"
errPagesSlugExists = "Pages 项目标识已存在"
errPagesNameRequired = "Pages 项目名称不能为空"
errPagesSlugInvalid = "Pages 项目标识只能包含小写字母、数字和连字符"
errPagesDeleteReferenced = "Pages 项目已被规则引用,不能删除"
errPagesDeploymentNotFound = "Pages 部署不存在"
errPagesDeploymentMismatch = "Pages 部署不属于该项目"
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
errPagesPackageMissing = "缺少 Pages 部署包"
errPagesPackageNotZip = "Pages 部署包必须是 .zip 文件"
errPagesPackageInvalidZip = "Pages 部署包不是有效 zip 文件"
errPagesPackageEmpty = "Pages 部署包不能为空"
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空"
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL"
errPagesProjectNotFound = "Pages 项目不存在"
errPagesSlugExists = "Pages 项目标识已存在"
errPagesNameRequired = "Pages 项目名称不能为空"
errPagesSlugInvalid = "Pages 项目标识只能包含小写字母、数字和连字符"
errPagesDeleteReferenced = "Pages 项目已被规则引用,不能删除"
errPagesDeploymentNotFound = "Pages 部署不存在"
errPagesDeploymentMismatch = "Pages 部署不属于该项目"
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
errPagesPackageMissing = "缺少 Pages 部署包"
errPagesPackageNotZip = "Pages 部署包必须是 .zip 文件"
errPagesPackageInvalidZip = "Pages 部署包不是有效 zip 文件"
errPagesPackageEmpty = "Pages 部署包不能为空"
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空"
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL"
errPagesPackagePathEmpty = "Pages 部署包路径为空"
errPagesPackageNotInActiveConfig = "Pages 部署尚未进入激活配置"
errPagesInvalidSnapshotFormat = "配置快照格式无效"
)
@@ -5,6 +5,7 @@ package pages
import (
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
@@ -341,6 +342,77 @@ func ActivateDeployment(ctx context.Context, projectID uint, deploymentID uint)
return GetProject(ctx, project.ID)
}
// GetDeploymentPackagePath returns the on-disk artifact path and download filename for an agent package request.
func GetDeploymentPackagePath(ctx context.Context, deploymentID uint) (string, string, error) {
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
if err != nil {
return "", "", err
}
if err = ensureDeploymentInActiveSnapshot(ctx, deployment.ID); err != nil {
return "", "", err
}
if strings.TrimSpace(deployment.ArtifactPath) == "" {
return "", "", errors.New(errPagesPackagePathEmpty)
}
if _, err = os.Stat(deployment.ArtifactPath); err != nil {
return "", "", fmt.Errorf("Pages 部署包不存在: %w", err)
}
return deployment.ArtifactPath, fmt.Sprintf("pages-deployment-%d.zip", deployment.ID), nil
}
func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) error {
version, err := model.GetActiveConfigVersion(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errPagesPackageNotInActiveConfig)
}
return err
}
routes, err := parseSnapshotRoutes(version.SnapshotJSON)
if err != nil {
return err
}
for _, route := range routes {
if route.UpstreamType != "pages" || route.PagesDeployment == nil {
continue
}
if route.PagesDeployment.DeploymentID == deploymentID {
return nil
}
}
return errors.New(errPagesPackageNotInActiveConfig)
}
type snapshotPagesDeployment struct {
DeploymentID uint `json:"deployment_id"`
}
type snapshotRouteRef struct {
UpstreamType string `json:"upstream_type"`
PagesDeployment *snapshotPagesDeployment `json:"pages_deployment"`
}
func parseSnapshotRoutes(snapshotJSON string) ([]snapshotRouteRef, error) {
text := strings.TrimSpace(snapshotJSON)
if text == "" {
return []snapshotRouteRef{}, nil
}
if strings.HasPrefix(text, "[") {
var routes []snapshotRouteRef
if err := json.Unmarshal([]byte(text), &routes); err != nil {
return nil, errors.New(errPagesInvalidSnapshotFormat)
}
return routes, nil
}
var snapshot struct {
Routes []snapshotRouteRef `json:"routes"`
}
if err := json.Unmarshal([]byte(text), &snapshot); err != nil {
return nil, errors.New(errPagesInvalidSnapshotFormat)
}
return snapshot.Routes, nil
}
// DeleteDeployment 删除 Pages 部署。
func DeleteDeployment(ctx context.Context, projectID uint, deploymentID uint) error {
project, err := model.GetPagesProjectByID(ctx, projectID)
@@ -4,7 +4,13 @@
package pages
import (
"archive/zip"
"bytes"
"context"
"fmt"
"mime/multipart"
"net/http/httptest"
"strconv"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
@@ -26,6 +32,7 @@ func setupPagesTestDB(t *testing.T) func() {
&model.PagesProject{},
&model.PagesDeployment{},
&model.PagesDeploymentFile{},
&model.ConfigVersion{},
))
db.SetDB(sqliteDB)
@@ -82,3 +89,80 @@ func TestCreateProjectRejectsUnsafeFallbackPath(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "回退路径")
}
func TestGetDeploymentPackagePathRequiresActiveConfigSnapshot(t *testing.T) {
cleanup := setupPagesTestDB(t)
defer cleanup()
ctx := context.Background()
project, err := CreateProject(ctx, Input{
Name: "Published Site",
Slug: "published-site",
Enabled: true,
})
require.NoError(t, err)
deployment, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "site.zip", testPagesZip(t, map[string]string{
"index.html": "ok",
})), "root")
require.NoError(t, err)
_, err = ActivateDeployment(ctx, project.ID, deployment.ID)
require.NoError(t, err)
_, _, err = GetDeploymentPackagePath(ctx, deployment.ID)
require.Error(t, err)
assert.Contains(t, err.Error(), "激活配置")
require.NoError(t, db.DB(ctx).Create(&model.ConfigVersion{
Version: "v2026-001",
SnapshotJSON: fmt.Sprintf(`{"routes":[{"upstream_type":"pages","pages_deployment":{"deployment_id":%d}}]}`, deployment.ID),
MainConfig: "",
RenderedConfig: "",
SupportFilesJSON: "[]",
Checksum: "test-checksum",
IsActive: true,
CreatedBy: "test",
}).Error)
filePath, fileName, err := GetDeploymentPackagePath(ctx, deployment.ID)
require.NoError(t, err)
assert.NotEmpty(t, filePath)
assert.Equal(t, "pages-deployment-"+strconv.FormatUint(uint64(deployment.ID), 10)+".zip", fileName)
}
func testPagesZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
writer := zip.NewWriter(&buffer)
for name, content := range files {
file, err := writer.Create(name)
require.NoError(t, err)
_, err = file.Write([]byte(content))
require.NoError(t, err)
}
require.NoError(t, writer.Close())
return buffer.Bytes()
}
func testPagesMultipartFile(t *testing.T, fileName string, content []byte) *multipart.FileHeader {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("package", fileName)
require.NoError(t, err)
_, err = part.Write(content)
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest("POST", "/", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
require.NoError(t, req.ParseMultipartForm(int64(len(content))+1024))
file, header, err := req.FormFile("package")
require.NoError(t, err)
file.Close()
return header
}