修复 Pages 路由发布失败并报 pages module is not available:配置快照发布流程补齐 Pages 项目激活部署解析与 pages_deployment 写入。

This commit is contained in:
ryan
2026-06-21 11:12:37 +08:00
parent 6f267bbf21
commit e479ae75e6
6 changed files with 247 additions and 37 deletions
+2
View File
@@ -22,6 +22,8 @@ sidebar: false
### 修复
- 修复 Pages 路由发布失败并报 `pages module is not available`:配置快照发布流程补齐 Pages 项目激活部署解析与 `pages_deployment` 写入。
- 修复仪表盘与节点详情「24 小时网络趋势」误按速率展示:改为 OpenResty 入/出站小时流量与近 24 小时总量摘要,Y 轴与 tooltip 自动换算 B/KB/MB/GB。
- 修复 Pages 上传或节点同步时报 `pages file size out of bounds`:允许 ZIP 包内的 0 字节文件,并兼容未声明解压大小的 ZIP 条目。
@@ -16,6 +16,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/routeidentity"
"github.com/Rain-kl/Wavelet/internal/model"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
"gorm.io/gorm"
)
@@ -398,7 +399,26 @@ func snapshotRouteIdentityEqual(left, right snapshotRoute) bool {
func snapshotRouteOriginEqual(left, right snapshotRoute) bool {
return left.OriginURL == right.OriginURL &&
left.OriginHost == right.OriginHost &&
left.UpstreamType == right.UpstreamType
left.UpstreamType == right.UpstreamType &&
snapshotPagesDeploymentEqual(left.PagesDeployment, right.PagesDeployment)
}
func snapshotPagesDeploymentEqual(left, right *openrestyrender.PagesDeployment) bool {
if left == nil && right == nil {
return true
}
if left == nil || right == nil {
return false
}
leftJSON, err := json.Marshal(left)
if err != nil {
return false
}
rightJSON, err := json.Marshal(right)
if err != nil {
return false
}
return string(leftJSON) == string(rightJSON)
}
func snapshotRoutePolicyEqual(left, right snapshotRoute) bool {
@@ -0,0 +1,97 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"context"
"errors"
"fmt"
"strings"
"github.com/Rain-kl/Wavelet/internal/model"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
"gorm.io/gorm"
)
const defaultPagesSnapshotEntryFile = "index.html"
const defaultPagesSnapshotFallbackPath = "/index.html"
func buildPagesRouteSnapshot(
ctx context.Context,
route *model.ProxyRoute,
) (originURL string, upstreams []string, pagesProjectID *uint, deployment *openrestyrender.PagesDeployment, err error) {
if route == nil {
return "", nil, nil, nil, errors.New("pages 路由配置无效")
}
if !model.HasPagesProjectsTable(ctx) {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 模块不可用", route.Domain)
}
if route.PagesProjectID == nil || *route.PagesProjectID == 0 {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: 未绑定 Pages 项目", route.Domain)
}
project, err := model.GetPagesProjectByID(ctx, *route.PagesProjectID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", route.Domain)
}
return "", nil, nil, nil, err
}
if !project.Enabled {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", route.Domain)
}
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", route.Domain)
}
activeDeployment, err := model.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", route.Domain)
}
return "", nil, nil, nil, err
}
if activeDeployment.ProjectID != project.ID {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", route.Domain)
}
if strings.TrimSpace(activeDeployment.Checksum) == "" {
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", route.Domain)
}
pagesProjectID = route.PagesProjectID
deployment = buildSnapshotPagesDeployment(project, activeDeployment)
originURL = fmt.Sprintf("openflare-pages://project/%d", project.ID)
return originURL, []string{originURL}, pagesProjectID, deployment, nil
}
func buildSnapshotPagesDeployment(project *model.PagesProject, activeDeployment *model.PagesDeployment) *openrestyrender.PagesDeployment {
if project == nil || activeDeployment == nil {
return nil
}
entryFile := strings.TrimSpace(project.EntryFile)
if entryFile == "" {
entryFile = defaultPagesSnapshotEntryFile
}
fallbackPath := strings.TrimSpace(project.SPAFallbackPath)
if fallbackPath == "" {
fallbackPath = defaultPagesSnapshotFallbackPath
}
return &openrestyrender.PagesDeployment{
ProjectID: project.ID,
ProjectSlug: strings.TrimSpace(project.Slug),
DeploymentID: activeDeployment.ID,
DeploymentNumber: activeDeployment.DeploymentNumber,
Checksum: strings.TrimSpace(activeDeployment.Checksum),
EntryFile: entryFile,
SPAFallbackEnabled: project.SPAFallbackEnabled,
SPAFallbackPath: fallbackPath,
APIProxyEnabled: project.APIProxyEnabled,
APIProxyPath: strings.TrimSpace(project.APIProxyPath),
APIProxyPass: strings.TrimSpace(project.APIProxyPass),
APIProxyRewrite: strings.TrimSpace(project.APIProxyRewrite),
LocalRoot: fmt.Sprintf(
"%s/deployments/%d/current",
openrestyrender.PagesDirPlaceholder,
activeDeployment.ID,
),
}
}
@@ -0,0 +1,91 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config_version
import (
"context"
"encoding/json"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
openrestyrender "github.com/Rain-kl/Wavelet/pkg/render/openresty"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestBuildSnapshotRoutesPages(t *testing.T) {
cleanup := setupConfigVersionTestDB(t)
defer cleanup()
ctx := context.Background()
conn := requireDB(t, ctx)
project := &model.PagesProject{
Name: "Speed Test",
Slug: "speedtest",
Enabled: true,
SPAFallbackEnabled: true,
SPAFallbackPath: "/index.html",
EntryFile: "index.html",
}
require.NoError(t, conn.Create(project).Error)
deployment := &model.PagesDeployment{
ProjectID: project.ID,
DeploymentNumber: 1,
Checksum: "abc123checksum",
Status: model.PagesDeploymentStatusActive,
FileCount: 1,
TotalSize: 12,
}
require.NoError(t, conn.Create(deployment).Error)
require.NoError(t, conn.Model(project).Update("active_deployment_id", deployment.ID).Error)
route := &model.ProxyRoute{
SiteName: "speedtest",
Domain: "speedtest.arctel.net",
Domains: `["speedtest.arctel.net"]`,
OriginURL: "openflare-pages://project/1",
Upstreams: `["openflare-pages://project/1"]`,
Enabled: true,
UpstreamType: "pages",
PagesProjectID: &project.ID,
}
require.NoError(t, model.CreateProxyRouteRecord(ctx, route))
bundle, err := buildCurrentConfigBundle(ctx, true)
require.NoError(t, err)
require.Len(t, bundle.SnapshotRoutes, 1)
snapshotRoute := bundle.SnapshotRoutes[0]
assert.Equal(t, "pages", snapshotRoute.UpstreamType)
assert.Equal(t, "openflare-pages://project/1", snapshotRoute.OriginURL)
require.NotNil(t, snapshotRoute.PagesDeployment)
assert.Equal(t, deployment.ID, snapshotRoute.PagesDeployment.DeploymentID)
assert.Equal(t, deployment.Checksum, snapshotRoute.PagesDeployment.Checksum)
assert.Equal(t, "__OPENFLARE_PAGES_DIR__/deployments/1/current", snapshotRoute.PagesDeployment.LocalRoot)
_, err = renderSnapshotConfig(bundle.SnapshotJSON, nil)
require.NoError(t, err)
var decoded struct {
Routes []struct {
PagesDeployment *openrestyrender.PagesDeployment `json:"pages_deployment"`
} `json:"routes"`
}
require.NoError(t, json.Unmarshal([]byte(bundle.SnapshotJSON), &decoded))
require.NotNil(t, decoded.Routes[0].PagesDeployment)
}
func requireDB(t *testing.T, ctx context.Context) *gorm.DB {
t.Helper()
conn := db.DB(ctx)
require.NotNil(t, conn)
require.NoError(t, conn.AutoMigrate(
&model.PagesProject{},
&model.PagesDeployment{},
))
return conn
}
@@ -20,35 +20,36 @@ import (
const supportFilesPerCertificate = 2
type snapshotRoute struct {
ID uint `json:"id,omitempty"`
SiteName string `json:"site_name,omitempty"`
Domain string `json:"domain"`
Domains []string `json:"domains,omitempty"`
OriginURL string `json:"origin_url"`
OriginHost string `json:"origin_host,omitempty"`
Upstreams []string `json:"upstreams,omitempty"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id,omitempty"`
CertIDs []uint `json:"cert_ids,omitempty"`
DomainCertIDs []uint `json:"domain_cert_ids,omitempty"`
RedirectHTTP bool `json:"redirect_http"`
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
LimitRate string `json:"limit_rate,omitempty"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
CustomHeaders []customHeaderInput `json:"custom_headers,omitempty"`
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
Remark string `json:"remark,omitempty"`
UpstreamType string `json:"upstream_type,omitempty"`
TunnelNodeID *uint `json:"tunnel_node_id,omitempty"`
TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"`
TunnelTargetProto string `json:"tunnel_target_protocol,omitempty"`
PagesProjectID *uint `json:"pages_project_id,omitempty"`
ID uint `json:"id,omitempty"`
SiteName string `json:"site_name,omitempty"`
Domain string `json:"domain"`
Domains []string `json:"domains,omitempty"`
OriginURL string `json:"origin_url"`
OriginHost string `json:"origin_host,omitempty"`
Upstreams []string `json:"upstreams,omitempty"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id,omitempty"`
CertIDs []uint `json:"cert_ids,omitempty"`
DomainCertIDs []uint `json:"domain_cert_ids,omitempty"`
RedirectHTTP bool `json:"redirect_http"`
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
LimitRate string `json:"limit_rate,omitempty"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
CustomHeaders []customHeaderInput `json:"custom_headers,omitempty"`
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
Remark string `json:"remark,omitempty"`
UpstreamType string `json:"upstream_type,omitempty"`
TunnelNodeID *uint `json:"tunnel_node_id,omitempty"`
TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"`
TunnelTargetProto string `json:"tunnel_target_protocol,omitempty"`
PagesProjectID *uint `json:"pages_project_id,omitempty"`
PagesDeployment *openrestyrender.PagesDeployment `json:"pages_deployment,omitempty"`
}
type snapshotWAFRuleGroup struct {
@@ -230,6 +231,7 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
var tunnelTargetAddr string
var tunnelTargetProtocol string
var pagesProjectID *uint
var pagesDeployment *openrestyrender.PagesDeployment
switch upstreamType {
case "tunnel":
originURL = resolveTunnelOpenRestyUpstreamURL(ctx)
@@ -238,7 +240,10 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
tunnelTargetAddr = strings.TrimSpace(route.TunnelTargetAddr)
tunnelTargetProtocol = normalizeTunnelTargetProtocol(route.TunnelTargetProtocol)
case "pages":
return nil, fmt.Errorf("路由 %s Pages 配置无效: pages module is not available", route.Domain)
originURL, upstreams, pagesProjectID, pagesDeployment, err = buildPagesRouteSnapshot(ctx, route)
if err != nil {
return nil, err
}
}
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
if err != nil {
@@ -274,6 +279,7 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
TunnelTargetAddr: tunnelTargetAddr,
TunnelTargetProto: tunnelTargetProtocol,
PagesProjectID: pagesProjectID,
PagesDeployment: pagesDeployment,
})
}
return items, nil
-6
View File
@@ -45,14 +45,8 @@ var (
GitHubOAuthEnabled = false
WeChatAuthEnabled = false
GitHubClientID = ""
GitHubClientSecret = ""
WeChatServerAddress = ""
WeChatServerToken = ""
WeChatAccountQRCodeImageURL = ""
SMTPServer = ""
SMTPPort = 587
SMTPAccount = ""
SMTPToken = ""
AgentDiscoveryToken = ""
AgentHeartbeatInterval = 10000
AgentWebsocketUpgradeEnabled = true