From e48485f27da7876dc20e317610ae421458961ba1 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 12:13:43 +0800 Subject: [PATCH] feat(cmd): assemble control plane via Wavelet plugins plus server --- .../server/openflare/apiutil/middleware.go | 11 +- .../plugins/server/openflare/node/helpers.go | 5 - .../plugins/server/openflare/node/version.go | 227 ++++++++++ backend/OpenFlare/plugins/server/plugin.go | 40 +- .../server/router/v1/openflare/openflare.go | 7 +- .../router/v1/openflare/register_apply_log.go | 5 +- .../v1/openflare/register_cloudflare.go | 5 +- .../v1/openflare/register_config_version.go | 5 +- .../router/v1/openflare/register_dashboard.go | 5 +- .../router/v1/openflare/register_node.go | 5 +- .../v1/openflare/register_observability.go | 5 +- .../router/v1/openflare/register_option.go | 7 +- .../router/v1/openflare/register_origin.go | 5 +- .../router/v1/openflare/register_pages.go | 5 +- .../v1/openflare/register_proxy_route.go | 5 +- .../router/v1/openflare/register_tls.go | 9 +- .../router/v1/openflare/register_updater.go | 27 ++ .../router/v1/openflare/register_waf.go | 5 +- .../router/v1/openflare/register_zone.go | 5 +- .../plugins/server/router/v1/openflare/v1.go | 37 +- .../OpenFlare/plugins/server/router/v1/v1.go | 19 +- .../OpenFlare/plugins/server/stamp/legacy.go | 13 + backend/cmd/all.go | 34 +- backend/cmd/api.go | 7 +- backend/cmd/app.go | 406 ++++++++++++++++++ backend/cmd/app_test.go | 63 +++ backend/cmd/banner.go | 44 +- backend/cmd/banner_test.go | 24 +- backend/cmd/bootstrap.go | 17 - backend/cmd/http_server.go | 66 --- backend/cmd/migrate_test.go | 127 ++++++ backend/cmd/reset_passwd.go | 31 +- backend/cmd/reset_passwd_test.go | 20 +- backend/cmd/root.go | 99 +++-- backend/cmd/scheduler.go | 13 +- backend/cmd/worker.go | 13 +- config.example.yaml | 1 + docs/changelog/index.md | 1 + 38 files changed, 1080 insertions(+), 343 deletions(-) create mode 100644 backend/OpenFlare/plugins/server/openflare/node/version.go create mode 100644 backend/OpenFlare/plugins/server/router/v1/openflare/register_updater.go create mode 100644 backend/OpenFlare/plugins/server/stamp/legacy.go create mode 100644 backend/cmd/app.go create mode 100644 backend/cmd/app_test.go delete mode 100644 backend/cmd/bootstrap.go delete mode 100644 backend/cmd/http_server.go create mode 100644 backend/cmd/migrate_test.go diff --git a/backend/OpenFlare/plugins/server/openflare/apiutil/middleware.go b/backend/OpenFlare/plugins/server/openflare/apiutil/middleware.go index f05acdbd..77b42c42 100644 --- a/backend/OpenFlare/plugins/server/openflare/apiutil/middleware.go +++ b/backend/OpenFlare/plugins/server/openflare/apiutil/middleware.go @@ -3,17 +3,14 @@ package apiutil -import ( - "Wavelet/OpenFlare/plugins/server/admin" - "Wavelet/OpenFlare/plugins/server/oauth" -) +import "Wavelet/core/contracts" // AdminMiddlewares returns Wavelet-standard middlewares for OpenFlare console routes. // OpenFlare no longer distinguishes Admin vs Root tiers; all management endpoints share -// the same gate: user.IsAdmin for session users, token_admin for Access Token callers. +// the same gate: RequireAuth + RequireAdmin from the platform AuthService. // // 返回 []any 而非 []gin.HandlerFunc:内核 RouterExtension.Use 收 ...any, // 而 Go 不允许把 []T 直接展开成 ...any。 -func AdminMiddlewares() []any { - return []any{oauth.LoginRequired(), admin.LoginAdminRequired()} +func AdminMiddlewares(auth contracts.AuthService) []any { + return []any{auth.RequireAuthMiddleware(), auth.RequireAdminMiddleware()} } diff --git a/backend/OpenFlare/plugins/server/openflare/node/helpers.go b/backend/OpenFlare/plugins/server/openflare/node/helpers.go index 0f557932..6f205cd5 100644 --- a/backend/OpenFlare/plugins/server/openflare/node/helpers.go +++ b/backend/OpenFlare/plugins/server/openflare/node/helpers.go @@ -20,7 +20,6 @@ import ( "Wavelet/OpenFlare/plugins/server/model" ofws "Wavelet/OpenFlare/plugins/server/openflare/websocket" - "Wavelet/pkg/util" ) const ( @@ -309,10 +308,6 @@ func isVersionNewer(current string, latest string) bool { return compareVersions(current, latest) < 0 } -func compareVersions(local, remote string) int { - return util.CompareVersions(local, remote) -} - func fetchLatestGitHubRelease(ctx context.Context, repo string, channel releaseChannel) (*githubReleaseResponse, error) { switch normalizeReleaseChannel(string(channel)) { case releaseChannelPreview: diff --git a/backend/OpenFlare/plugins/server/openflare/node/version.go b/backend/OpenFlare/plugins/server/openflare/node/version.go new file mode 100644 index 00000000..10b288c4 --- /dev/null +++ b/backend/OpenFlare/plugins/server/openflare/node/version.go @@ -0,0 +1,227 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package node + +import ( + "strconv" + "strings" +) + +const gitDescribeMinIdentifiers = 2 + +type versionInfo struct { + valid bool + isDev bool + numbers []int + prerelease []string + gitDescribeDistance int + gitDescribeTail []string +} + +func parseVersionInfo(version string) versionInfo { + normalized := strings.TrimSpace(strings.TrimPrefix(version, "v")) + if normalized == "" || normalized == "dev" { + return versionInfo{isDev: strings.EqualFold(normalized, "dev")} + } + base := normalized + prerelease := "" + if separator := strings.IndexRune(normalized, '-'); separator >= 0 { + base = normalized[:separator] + prerelease = normalized[separator+1:] + } + + segments := strings.Split(base, ".") + parts := make([]int, 0, len(segments)) + for _, segment := range segments { + segment = strings.TrimSpace(segment) + if segment == "" { + parts = append(parts, 0) + continue + } + + numeric := strings.Builder{} + for _, r := range segment { + if r < '0' || r > '9' { + break + } + numeric.WriteRune(r) + } + if numeric.Len() == 0 { + parts = append(parts, 0) + continue + } + value, err := strconv.Atoi(numeric.String()) + if err != nil { + return versionInfo{} + } + parts = append(parts, value) + } + info := versionInfo{valid: len(parts) > 0, numbers: parts} + if prerelease != "" { + identifiers := splitPrereleaseIdentifiers(prerelease) + if distance, tail, ok := parseGitDescribeIdentifiers(identifiers); ok { + info.gitDescribeDistance = distance + info.gitDescribeTail = tail + } else { + info.prerelease = identifiers + } + } + return info +} + +func parseGitDescribeIdentifiers(identifiers []string) (int, []string, bool) { + if len(identifiers) < gitDescribeMinIdentifiers { + return 0, nil, false + } + distance, err := strconv.Atoi(strings.TrimSpace(identifiers[0])) + if err != nil || distance <= 0 { + return 0, nil, false + } + commitToken := strings.TrimSpace(identifiers[1]) + if commitToken == "" || !strings.HasPrefix(strings.ToLower(commitToken), "g") { + return 0, nil, false + } + return distance, identifiers[1:], true +} + +func splitPrereleaseIdentifiers(value string) []string { + parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool { + return r == '.' || r == '-' + }) + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + filtered = append(filtered, part) + } + } + return filtered +} + +func compareVersions(local, remote string) int { + left := parseVersionInfo(local) + right := parseVersionInfo(remote) + if left.isDev { + if right.valid { + return -1 + } + return 0 + } + if !left.valid || !right.valid { + return 0 + } + + if result := compareVersionNumbers(left, right); result != 0 { + return result + } + if result := compareGitDescribeDistance(left, right); result != 0 { + return result + } + if left.gitDescribeDistance > 0 || right.gitDescribeDistance > 0 { + return compareGitDescribeTails(left, right) + } + return comparePrereleaseIdentifiers(left, right) +} + +func compareVersionNumbers(left, right versionInfo) int { + maxLen := max(len(right.numbers), len(left.numbers)) + for index := range maxLen { + leftValue := 0 + rightValue := 0 + if index < len(left.numbers) { + leftValue = left.numbers[index] + } + if index < len(right.numbers) { + rightValue = right.numbers[index] + } + if leftValue < rightValue { + return -1 + } + if leftValue > rightValue { + return 1 + } + } + return 0 +} + +func compareGitDescribeDistance(left, right versionInfo) int { + if left.gitDescribeDistance == right.gitDescribeDistance { + return 0 + } + if left.gitDescribeDistance < right.gitDescribeDistance { + return -1 + } + return 1 +} + +func compareGitDescribeTails(left, right versionInfo) int { + maxLen := max(len(right.gitDescribeTail), len(left.gitDescribeTail)) + for index := range maxLen { + if index >= len(left.gitDescribeTail) { + return -1 + } + if index >= len(right.gitDescribeTail) { + return 1 + } + if left.gitDescribeTail[index] < right.gitDescribeTail[index] { + return -1 + } + if left.gitDescribeTail[index] > right.gitDescribeTail[index] { + return 1 + } + } + return 0 +} + +func comparePrereleaseIdentifiers(left, right versionInfo) int { + if len(left.prerelease) == 0 && len(right.prerelease) == 0 { + return 0 + } + if len(left.prerelease) == 0 { + return 1 + } + if len(right.prerelease) == 0 { + return -1 + } + + maxLen := max(len(right.prerelease), len(left.prerelease)) + for index := range maxLen { + if index >= len(left.prerelease) { + return -1 + } + if index >= len(right.prerelease) { + return 1 + } + if result := comparePrereleasePart(left.prerelease[index], right.prerelease[index]); result != 0 { + return result + } + } + return 0 +} + +func comparePrereleasePart(leftPart, rightPart string) int { + leftNumber, leftErr := strconv.Atoi(leftPart) + rightNumber, rightErr := strconv.Atoi(rightPart) + switch { + case leftErr == nil && rightErr == nil: + if leftNumber < rightNumber { + return -1 + } + if leftNumber > rightNumber { + return 1 + } + case leftErr == nil: + return -1 + case rightErr == nil: + return 1 + default: + if leftPart < rightPart { + return -1 + } + if leftPart > rightPart { + return 1 + } + } + return 0 +} diff --git a/backend/OpenFlare/plugins/server/plugin.go b/backend/OpenFlare/plugins/server/plugin.go index 11b689bd..feeb4b08 100644 --- a/backend/OpenFlare/plugins/server/plugin.go +++ b/backend/OpenFlare/plugins/server/plugin.go @@ -1,16 +1,16 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -// Package server 装载 OpenFlare 控制面插件:站点/区域、Cloudflare 接入、Pages 部署、 -// WAF、节点与回源管理、边缘协议接口以及根级默认路由,全部经 ctx.Router() 声明, -// 由 driver_http 挂载到装配根提供的 gin engine 上。 +// Package server 装载 OpenFlare 控制面业务路由:站点/区域、Cloudflare 接入、Pages 部署、 +// WAF、节点与回源管理以及边缘协议接口。平台路径(user/admin/cap/health)由 Wavelet +// 域插件注册,本插件只挂 OpenFlare 自己的业务。 package server import ( - "Wavelet/OpenFlare/plugins/server/infra/config" - router_root "Wavelet/OpenFlare/plugins/server/router/root" - v1 "Wavelet/OpenFlare/plugins/server/router/v1" + ofrouter "Wavelet/OpenFlare/plugins/server/router/v1/openflare" "Wavelet/core" + "Wavelet/core/contracts" + "reflect" ) // Plugin 实现 core.Plugin,是 OpenFlare 控制面的装载入口。 @@ -22,16 +22,24 @@ func New() *Plugin { return &Plugin{} } // Name 返回插件标识。 func (p *Plugin) Name() string { return "server" } -// Apply 声明控制面 HTTP 路由树。 -// -// 中间件(Recovery/CORS/session/otelgin/错误与日志)留在引擎层,由装配根的 -// router.BuildEngine 提供;内核暂无 engine 级中间件贡献点,见计划附录 A。 -// 任务、设置与迁移仍由 platform/bootstrap 显式装配,待后续迁入 Apply。 -func (p *Plugin) Apply(ctx *core.Context) error { - root := ctx.Router() - router_root.RegisterRootRoutes(root) +// Inject waits for the same services as the Wavelet admin plugin so Apply runs +// after platform admin routes exist and OpenFlare can replace /admin/update*. +func (p *Plugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[contracts.DBService](), + reflect.TypeFor[contracts.CacheService](), + reflect.TypeFor[contracts.UserService](), + reflect.TypeFor[contracts.AuthService](), + } +} - api := root.Group(config.Config.App.APIPrefix) - v1.RegisterV1Routes(api.Group("/v1"), api) +// Apply 声明 OpenFlare 业务 HTTP 路由树。 +func (p *Plugin) Apply(ctx *core.Context) error { + var auth contracts.AuthService + if err := core.Using[contracts.AuthService](ctx, func(s contracts.AuthService) { auth = s }); err != nil { + return err + } + ofrouter.RegisterV1Routes(ctx.Router().Group("/api/v1"), auth) + ofrouter.RegisterRoutes(ctx.Router().Group("/api/v1"), auth) return nil } diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/openflare.go b/backend/OpenFlare/plugins/server/router/v1/openflare/openflare.go index 6b6e2896..948d9695 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/openflare.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/openflare.go @@ -6,10 +6,13 @@ // Agent/Relay/Tunnel protocol routes are mounted via RegisterRoutes under /api/v1. package openflare -import "Wavelet/core" +import ( + "Wavelet/core" + "Wavelet/core/contracts" +) // RegisterRoutes mounts Agent/Relay/Tunnel protocol routes under the /api/v1 group. -func RegisterRoutes(apiV1Router core.RouterExtension) { +func RegisterRoutes(apiV1Router core.RouterExtension, _ contracts.AuthService) { registerAgentRoutes(apiV1Router) registerRelayRoutes(apiV1Router) registerTunnelRoutes(apiV1Router) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_apply_log.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_apply_log.go index e44c011d..2a2ea603 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_apply_log.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_apply_log.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/apply_log" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerApplyLogRoutes(apiGroup core.RouterExtension) { +func registerApplyLogRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { applyLogRoute := apiGroup.Group("/apply-logs") - applyLogRoute.Use(apiutil.AdminMiddlewares()...) + applyLogRoute.Use(apiutil.AdminMiddlewares(auth)...) { apiutil.RegisterCollection(applyLogRoute, "GET", apply_log.GetApplyLogs) applyLogRoute.POST("/cleanup", apply_log.CleanupApplyLogs) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_cloudflare.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_cloudflare.go index 68f9e937..326e4f65 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_cloudflare.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_cloudflare.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" cf "Wavelet/OpenFlare/plugins/server/openflare/cloudflare" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerCloudflareRoutes(apiGroup core.RouterExtension) { +func registerCloudflareRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { route := apiGroup.Group("/cloudflare") - route.Use(apiutil.AdminMiddlewares()...) + route.Use(apiutil.AdminMiddlewares(auth)...) route.GET("/connection", cf.GetConnectionHandler) route.PUT("/connection", cf.SaveConnectionHandler) route.POST("/connection/verify", cf.VerifyConnectionHandler) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_config_version.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_config_version.go index aedb1687..da959eb2 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_config_version.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_config_version.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/config_version" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerConfigVersionRoutes(apiGroup core.RouterExtension) { +func registerConfigVersionRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { configVersionGroup := apiGroup.Group("/config-versions") - configVersionGroup.Use(apiutil.AdminMiddlewares()...) + configVersionGroup.Use(apiutil.AdminMiddlewares(auth)...) { apiutil.RegisterCollection(configVersionGroup, "GET", config_version.ListConfigVersionsHandler) configVersionGroup.GET("/active", config_version.GetActiveConfigVersionHandler) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_dashboard.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_dashboard.go index 6648ad97..9170106c 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_dashboard.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_dashboard.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/dashboard" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerDashboardRoutes(apiGroup core.RouterExtension) { +func registerDashboardRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { dashboardRoute := apiGroup.Group("/dashboard") - dashboardRoute.Use(apiutil.AdminMiddlewares()...) + dashboardRoute.Use(apiutil.AdminMiddlewares(auth)...) { dashboardRoute.GET("/overview", dashboard.GetOverviewHandler) } diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_node.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_node.go index cd301e49..e6045823 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_node.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_node.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/node" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerNodeRoutes(apiGroup core.RouterExtension) { +func registerNodeRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { nodeRoute := apiGroup.Group("/nodes") - nodeRoute.Use(apiutil.AdminMiddlewares()...) + nodeRoute.Use(apiutil.AdminMiddlewares(auth)...) { nodeRoute.GET("/bootstrap-token", node.GetBootstrapTokenHandler) nodeRoute.POST("/bootstrap-token/rotate", node.RotateBootstrapTokenHandler) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_observability.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_observability.go index 6c5d417b..23061d65 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_observability.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_observability.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/observability" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerObservabilityRoutes(apiGroup core.RouterExtension) { +func registerObservabilityRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { accessLogRoute := apiGroup.Group("/access-logs") - accessLogRoute.Use(apiutil.AdminMiddlewares()...) + accessLogRoute.Use(apiutil.AdminMiddlewares(auth)...) { apiutil.RegisterCollection(accessLogRoute, "GET", observability.GetAccessLogsHandler) accessLogRoute.GET("/overview", observability.GetAccessLogOverviewHandler) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_option.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_option.go index 7c0ac37b..9c14f45e 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_option.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_option.go @@ -7,13 +7,14 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/option" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerOptionRoutes(apiGroup core.RouterExtension) { +func registerOptionRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { apiGroup.GET("/status", option.GetStatusHandler) optionRoute := apiGroup.Group("/option") - optionRoute.Use(apiutil.AdminMiddlewares()...) + optionRoute.Use(apiutil.AdminMiddlewares(auth)...) { apiutil.RegisterCollection(optionRoute, "GET", option.ListOptionsHandler) optionRoute.POST("/update", option.UpdateOptionHandler) @@ -22,7 +23,7 @@ func registerOptionRoutes(apiGroup core.RouterExtension) { } uptimeKumaRoute := apiGroup.Group("/uptimekuma") - uptimeKumaRoute.Use(apiutil.AdminMiddlewares()...) + uptimeKumaRoute.Use(apiutil.AdminMiddlewares(auth)...) { uptimeKumaRoute.POST("/sync", option.SyncUptimeKumaHandler) } diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_origin.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_origin.go index 454f62af..184847c0 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_origin.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_origin.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/origin" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerOriginRoutes(apiGroup core.RouterExtension) { +func registerOriginRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { originRoute := apiGroup.Group("/origins") - originRoute.Use(apiutil.AdminMiddlewares()...) + originRoute.Use(apiutil.AdminMiddlewares(auth)...) { apiutil.RegisterCollection(originRoute, "GET", origin.GetOrigins) originRoute.GET("/:id", origin.GetOrigin) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_pages.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_pages.go index f10112b0..5f67883b 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_pages.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_pages.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/pages" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerPagesRoutes(apiGroup core.RouterExtension) { +func registerPagesRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { pagesRoute := apiGroup.Group("/pages") - pagesRoute.Use(apiutil.AdminMiddlewares()...) + pagesRoute.Use(apiutil.AdminMiddlewares(auth)...) { apiutil.RegisterCollection(pagesRoute, "GET", pages.ListProjectsHandler) pagesRoute.GET("/:id", pages.GetProjectHandler) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_proxy_route.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_proxy_route.go index 9e6e7623..6270ab7b 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_proxy_route.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_proxy_route.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/proxy_route" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerProxyRouteRoutes(apiGroup core.RouterExtension) { +func registerProxyRouteRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { proxyRouteGroup := apiGroup.Group("/proxy-routes") - proxyRouteGroup.Use(apiutil.AdminMiddlewares()...) + proxyRouteGroup.Use(apiutil.AdminMiddlewares(auth)...) { apiutil.RegisterCollection(proxyRouteGroup, "GET", proxy_route.GetProxyRoutes) proxyRouteGroup.GET("/:id", proxy_route.GetProxyRouteHandler) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_tls.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_tls.go index f9605e38..886d301b 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_tls.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_tls.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/tls" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerTLSRoutes(apiGroup core.RouterExtension) { +func registerTLSRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { tlsCertificateRoute := apiGroup.Group("/tls-certificates") - tlsCertificateRoute.Use(apiutil.AdminMiddlewares()...) + tlsCertificateRoute.Use(apiutil.AdminMiddlewares(auth)...) { apiutil.RegisterCollection(tlsCertificateRoute, "GET", tls.GetCertificates) tlsCertificateRoute.GET("/:id", tls.GetCertificateDetail) @@ -27,13 +28,13 @@ func registerTLSRoutes(apiGroup core.RouterExtension) { } acmeAccountRoute := apiGroup.Group("/acme-accounts") - acmeAccountRoute.Use(apiutil.AdminMiddlewares()...) + acmeAccountRoute.Use(apiutil.AdminMiddlewares(auth)...) { acmeAccountRoute.GET("/default", tls.GetDefaultAcmeAccountHandler) } dnsAccountRoute := apiGroup.Group("/dns-accounts") - dnsAccountRoute.Use(apiutil.AdminMiddlewares()...) + dnsAccountRoute.Use(apiutil.AdminMiddlewares(auth)...) { apiutil.RegisterCollection(dnsAccountRoute, "GET", tls.GetDNSAccounts) apiutil.RegisterCollection(dnsAccountRoute, "POST", tls.CreateDNSAccountHandler) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_updater.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_updater.go new file mode 100644 index 00000000..a63d7e48 --- /dev/null +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_updater.go @@ -0,0 +1,27 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package openflare + +import ( + "Wavelet/OpenFlare/plugins/server/admin/updater" + "Wavelet/OpenFlare/plugins/server/openflare/apiutil" + "Wavelet/core" + "Wavelet/core/contracts" +) + +func registerUpdaterRoutes(apiV1Router core.RouterExtension, auth contracts.AuthService) { + // Wavelet admin already registered these paths. Replace them so gin does + // not panic on duplicate method+path at driver_http Start, and so the + // OpenFlare updater implementation is the one that runs. + _ = apiV1Router.Unregister("GET", "/admin/update") + _ = apiV1Router.Unregister("POST", "/admin/update/apply") + + adminRouter := apiV1Router.Group("/admin") + adminRouter.Use(apiutil.AdminMiddlewares(auth)...) + update := adminRouter.Group("/update") + { + update.GET("", updater.GetUpdateStatus) + update.POST("/apply", updater.ApplyUpdate) + } +} diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_waf.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_waf.go index 7af19c6e..407ffd78 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_waf.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_waf.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/waf" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerWAFRoutes(apiGroup core.RouterExtension) { +func registerWAFRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { wafRoute := apiGroup.Group("/waf") - wafRoute.Use(apiutil.AdminMiddlewares()...) + wafRoute.Use(apiutil.AdminMiddlewares(auth)...) { wafRoute.GET("/ip-groups", waf.ListIPGroupsHandler) wafRoute.GET("/ip-groups/:id", waf.GetIPGroupHandler) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/register_zone.go b/backend/OpenFlare/plugins/server/router/v1/openflare/register_zone.go index bbf69ef3..c49247d2 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/register_zone.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/register_zone.go @@ -7,11 +7,12 @@ import ( "Wavelet/OpenFlare/plugins/server/openflare/apiutil" "Wavelet/OpenFlare/plugins/server/openflare/zone" "Wavelet/core" + "Wavelet/core/contracts" ) -func registerZoneRoutes(apiGroup core.RouterExtension) { +func registerZoneRoutes(apiGroup core.RouterExtension, auth contracts.AuthService) { zoneGroup := apiGroup.Group("/zones") - zoneGroup.Use(apiutil.AdminMiddlewares()...) + zoneGroup.Use(apiutil.AdminMiddlewares(auth)...) apiutil.RegisterCollection(zoneGroup, "GET", zone.ListHandler) apiutil.RegisterCollection(zoneGroup, "POST", zone.CreateHandler) zoneGroup.GET("/:id/overview", zone.GetOverviewHandler) diff --git a/backend/OpenFlare/plugins/server/router/v1/openflare/v1.go b/backend/OpenFlare/plugins/server/router/v1/openflare/v1.go index 4f3c38d4..e1015226 100644 --- a/backend/OpenFlare/plugins/server/router/v1/openflare/v1.go +++ b/backend/OpenFlare/plugins/server/router/v1/openflare/v1.go @@ -3,25 +3,30 @@ package openflare -import "Wavelet/core" +import ( + "Wavelet/core" + "Wavelet/core/contracts" +) // V1BasePath is the OpenFlare console API prefix under /api/v1. const V1BasePath = "/api/v1/d" -// RegisterV1Routes mounts OpenFlare management console APIs under /api/v1/d. -func RegisterV1Routes(apiV1Router core.RouterExtension) { +// RegisterV1Routes mounts OpenFlare management console APIs under /api/v1/d +// and OpenFlare-owned admin updater routes under /api/v1/admin/update*. +func RegisterV1Routes(apiV1Router core.RouterExtension, auth contracts.AuthService) { group := apiV1Router.Group("/d") - registerOptionRoutes(group) - registerOriginRoutes(group) - registerApplyLogRoutes(group) - registerProxyRouteRoutes(group) - registerNodeRoutes(group) - registerWAFRoutes(group) - registerTLSRoutes(group) - registerCloudflareRoutes(group) - registerZoneRoutes(group) - registerConfigVersionRoutes(group) - registerPagesRoutes(group) - registerDashboardRoutes(group) - registerObservabilityRoutes(group) + registerOptionRoutes(group, auth) + registerOriginRoutes(group, auth) + registerApplyLogRoutes(group, auth) + registerProxyRouteRoutes(group, auth) + registerNodeRoutes(group, auth) + registerWAFRoutes(group, auth) + registerTLSRoutes(group, auth) + registerCloudflareRoutes(group, auth) + registerZoneRoutes(group, auth) + registerConfigVersionRoutes(group, auth) + registerPagesRoutes(group, auth) + registerDashboardRoutes(group, auth) + registerObservabilityRoutes(group, auth) + registerUpdaterRoutes(apiV1Router, auth) } diff --git a/backend/OpenFlare/plugins/server/router/v1/v1.go b/backend/OpenFlare/plugins/server/router/v1/v1.go index bd1385f0..9d85072c 100644 --- a/backend/OpenFlare/plugins/server/router/v1/v1.go +++ b/backend/OpenFlare/plugins/server/router/v1/v1.go @@ -7,20 +7,13 @@ package v1 import ( ofrouter "Wavelet/OpenFlare/plugins/server/router/v1/openflare" "Wavelet/core" + "Wavelet/core/contracts" ) -// RegisterV1Routes registers all routes under API V1. -func RegisterV1Routes(apiV1Router core.RouterExtension, apiGroup core.RouterExtension) { - // 1. User & Public routes (OAuth, User, Upload, CAPTCHA, Health, Config) - RegisterUserRoutes(apiV1Router, apiGroup) - - // 2. Admin routes - RegisterAdminRoutes(apiV1Router) - - // 3. OpenFlare management console APIs and Agent/Relay/Tunnel protocol routes - ofrouter.RegisterV1Routes(apiV1Router) - ofrouter.RegisterRoutes(apiV1Router) - - // 4. Custom business routes (example only) +// RegisterV1Routes registers OpenFlare business routes under API V1. +// Platform user/admin/cap/health routes are owned by Wavelet domain plugins. +func RegisterV1Routes(apiV1Router core.RouterExtension, auth contracts.AuthService) { + ofrouter.RegisterV1Routes(apiV1Router, auth) + ofrouter.RegisterRoutes(apiV1Router, auth) RegisterCustomRoutes() } diff --git a/backend/OpenFlare/plugins/server/stamp/legacy.go b/backend/OpenFlare/plugins/server/stamp/legacy.go new file mode 100644 index 00000000..3663c838 --- /dev/null +++ b/backend/OpenFlare/plugins/server/stamp/legacy.go @@ -0,0 +1,13 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package stamp records pre-Cordis schema versions into w_schema_versions. +package stamp + +import "Wavelet/core" + +// Legacy is the pre-Cordis schema stamp hook. Task 14 fills the body; until then +// it is a no-op so plugin migrations can proceed on a fresh database. +func Legacy(*core.Context) error { + return nil +} diff --git a/backend/cmd/all.go b/backend/cmd/all.go index f9959934..66984459 100644 --- a/backend/cmd/all.go +++ b/backend/cmd/all.go @@ -1,4 +1,3 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 @@ -6,12 +5,7 @@ package cmd import ( - "log" - "sync" - - "Wavelet/OpenFlare/plugins/server/infra/task/scheduler" - "Wavelet/OpenFlare/plugins/server/infra/task/worker" - "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "Wavelet/core" "github.com/spf13/cobra" ) @@ -20,30 +14,6 @@ var allCmd = &cobra.Command{ Use: "all", Short: "以融合模式同时启动 API、Worker 和 Scheduler", Run: func(_ *cobra.Command, _ []string) { - log.Println("[All] 融合模式启动") - bootstrap.RegisterAll() - runBootstrap(bootstrap.Options{API: true}) - - var wg sync.WaitGroup - - // 启动 Asynq Worker 任务处理服务 - wg.Go(func() { - log.Println("[All] 启动 Worker 服务") - if err := worker.StartWorker(); err != nil { - log.Printf("[All] Worker 启动失败: %v\n", err) - } - }) - - // 启动 Asynq 定时任务调度器 - wg.Go(func() { - log.Println("[All] 启动 Scheduler 服务") - if err := scheduler.StartScheduler(); err != nil { - log.Printf("[All] Scheduler 启动失败: %v\n", err) - } - }) - - // API 服务持有前台阻塞与退出信号处理(runHTTPApp 返回后即已优雅退出) - runHTTPApp("API + Worker + Scheduler") - wg.Wait() + runProfileApp(core.ProfileAll, "all (API + Worker + Scheduler)", true) }, } diff --git a/backend/cmd/api.go b/backend/cmd/api.go index b3e1ca51..c335b81b 100644 --- a/backend/cmd/api.go +++ b/backend/cmd/api.go @@ -1,11 +1,10 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 package cmd import ( - "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "Wavelet/core" "github.com/spf13/cobra" ) @@ -14,8 +13,6 @@ var apiCmd = &cobra.Command{ Use: "api", Short: "wavelet API", Run: func(_ *cobra.Command, _ []string) { - bootstrap.RegisterAPI() - runBootstrap(bootstrap.Options{API: true}) - runHTTPApp("API") + runProfileApp(core.ProfileAPI, "api", true) }, } diff --git a/backend/cmd/app.go b/backend/cmd/app.go new file mode 100644 index 00000000..b0fbd09a --- /dev/null +++ b/backend/cmd/app.go @@ -0,0 +1,406 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + ofserver "Wavelet/OpenFlare/plugins/server" + "Wavelet/OpenFlare/plugins/server/stamp" + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/plugins/domain/admin" + "Wavelet/plugins/domain/auth" + "Wavelet/plugins/domain/cap" + "Wavelet/plugins/domain/message_gateway" + "Wavelet/plugins/domain/risk_control" + "Wavelet/plugins/domain/system" + "Wavelet/plugins/domain/upload" + "Wavelet/plugins/domain/user" + "Wavelet/plugins/drivers/driver_asynq_cron" + "Wavelet/plugins/drivers/driver_asynq_worker" + "Wavelet/plugins/drivers/driver_http" + "Wavelet/plugins/drivers/driver_inproc_cron" + "Wavelet/plugins/drivers/driver_inproc_worker" + "Wavelet/plugins/infra/cache" + "Wavelet/plugins/infra/cache_memory" + "Wavelet/plugins/infra/config" + "Wavelet/plugins/infra/logger" + "Wavelet/plugins/infra/storage" + "context" + "database/sql" + "fmt" + "io/fs" + "log" + "path/filepath" + "time" + + "github.com/pressly/goose/v3" + goosedb "github.com/pressly/goose/v3/database" + + infradb "Wavelet/plugins/infra/database" +) + +const ( + defaultShutdownTimeout = 15 * time.Second + defaultHTTPAddr = "127.0.0.1:8000" + + // migrationAdvisoryLockKey serializes baseline + plugin Up across Postgres + // sessions (ASCII "wave"). SQLite is single-writer and needs no extra lock. + migrationAdvisoryLockKey int64 = 0x77617665 +) + +// runProfileApp prepares and runs the application for a given profile. +func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) { + app := newOpenFlareApp(profile) + if err := app.Prepare(); err != nil { + log.Fatalf("[%s] prepare failed: %v\n", mode, err) + } + state := startupState{ + mode: mode, + listensForHTTP: listensForHTTP, + env: app.Context().Config().String("app.env", "production"), + } + if listensForHTTP { + state.addr = app.Context().Config().String("app.addr", defaultHTTPAddr) + } + printStartupBanner(state) + if err := app.Run(); err != nil { + log.Fatalf("[%s] run failed: %v\n", mode, err) + } +} + +// newOpenFlareApp creates a core.App wired with Wavelet platform plugins plus the OpenFlare server plugin. +func newOpenFlareApp(profile core.Profile, opts ...core.AppOption) *core.App { + src, err := config.NewSource() + if err != nil { + log.Fatalf("[App] load config source failed: %v\n", err) + } + + appOpts := []core.AppOption{ + core.WithProfile(profile), + core.WithConfigSource(src), + core.WithShutdownTimeout(defaultShutdownTimeout), + core.WithMigrationBaseline(stamp.Legacy), + } + appOpts = append(appOpts, opts...) + + app := core.NewApp(appOpts...) + + // 1. Register standard infrastructure plugins + app.Use( + infradb.New(), + logger.New(), + storage.New(), + ) + + // 2. Register Cache and Async/Cron Drivers (both gated: cache vs cache_memory, asynq vs inproc) + app.Use( + cache.New(), + cache_memory.New(), + driver_asynq_worker.New(), + driver_inproc_worker.New(), + driver_asynq_cron.New(), + driver_inproc_cron.New(), + ) + + // 3. Register all 8 domain business plugins (admin first to ensure schema and base config tables exist) + app.Use( + admin.New(), + user.New(), + auth.New(), + message_gateway.New(), + risk_control.New(), + upload.New(), + cap.New(), + system.New(), + ) + + // 4. OpenFlare business routes (after domain plugins, before the HTTP driver) + app.Use( + ofserver.New(), + ) + + // 5. Bind Goose migration engine + app.SetMigrationEngine(&gooseEngine{}) + + // 6. Mount HTTP runtime driver + app.Use( + driver_http.New(), + ) + + return app +} + +// ─── Schema Version Store ────────────────────────────────────────────────────── + +// sharedStore implements database.Store using a single w_schema_versions table. +// All plugins share this table, with plugin_id as the discriminator. +// +// Schema: +// +// w_schema_versions ( +// plugin_id VARCHAR(64) NOT NULL, +// version_id BIGINT NOT NULL, +// applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, +// PRIMARY KEY (plugin_id, version_id) +// ) +type sharedStore struct { + pluginID string + dialect string // "postgres" or "sqlite3" +} + +func (s *sharedStore) Tablename() string { return "w_schema_versions" } + +func (s *sharedStore) CreateVersionTable(ctx context.Context, db goosedb.DBTxConn) error { + _, err := db.ExecContext(ctx, schemaVersionsDDL(s.dialect)) + return err +} + +func schemaVersionsDDL(dialect string) string { + timeType := "TIMESTAMPTZ" + if dialect == "sqlite3" || dialect == "sqlite" { + timeType = "DATETIME" + } + return fmt.Sprintf(`CREATE TABLE IF NOT EXISTS w_schema_versions ( + plugin_id VARCHAR(64) NOT NULL, + version_id BIGINT NOT NULL, + applied_at %s NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (plugin_id, version_id) + )`, timeType) +} + +//nolint:mnd +func (s *sharedStore) Insert(ctx context.Context, db goosedb.DBTxConn, req goosedb.InsertRequest) error { + p := s.placeholder + _, err := db.ExecContext(ctx, + fmt.Sprintf("INSERT INTO w_schema_versions (plugin_id, version_id) VALUES (%s, %s) ON CONFLICT (plugin_id, version_id) DO NOTHING", p(1), p(2)), + s.pluginID, req.Version) + return err +} + +//nolint:mnd +func (s *sharedStore) Delete(ctx context.Context, db goosedb.DBTxConn, version int64) error { + p := s.placeholder + _, err := db.ExecContext(ctx, + fmt.Sprintf("DELETE FROM w_schema_versions WHERE plugin_id = %s AND version_id = %s", p(1), p(2)), + s.pluginID, version) + return err +} + +//nolint:mnd +func (s *sharedStore) GetMigration(ctx context.Context, db goosedb.DBTxConn, version int64) (*goosedb.GetMigrationResult, error) { + p := s.placeholder + var t time.Time + err := db.QueryRowContext(ctx, + fmt.Sprintf("SELECT applied_at FROM w_schema_versions WHERE plugin_id = %s AND version_id = %s", p(1), p(2)), + s.pluginID, version).Scan(&t) + if err == sql.ErrNoRows { + return nil, goosedb.ErrVersionNotFound + } + if err != nil { + return nil, err + } + return &goosedb.GetMigrationResult{Timestamp: t, IsApplied: true}, nil +} + +func (s *sharedStore) GetLatestVersion(ctx context.Context, db goosedb.DBTxConn) (int64, error) { + p := s.placeholder + var version int64 + err := db.QueryRowContext(ctx, + fmt.Sprintf("SELECT COALESCE(MAX(version_id), 0) FROM w_schema_versions WHERE plugin_id = %s", p(1)), + s.pluginID).Scan(&version) + if err != nil { + return 0, err + } + return version, nil +} + +func (s *sharedStore) ListMigrations(ctx context.Context, db goosedb.DBTxConn) ([]*goosedb.ListMigrationsResult, error) { + p := s.placeholder + rows, err := db.QueryContext(ctx, + fmt.Sprintf("SELECT version_id, TRUE FROM w_schema_versions WHERE plugin_id = %s ORDER BY version_id DESC", p(1)), + s.pluginID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var results []*goosedb.ListMigrationsResult + for rows.Next() { + var r goosedb.ListMigrationsResult + if err := rows.Scan(&r.Version, &r.IsApplied); err != nil { + return nil, err + } + results = append(results, &r) + } + return results, rows.Err() +} + +func (s *sharedStore) placeholder(n int) string { + if s.dialect == "postgres" { + return fmt.Sprintf("$%d", n) + } + return "?" +} + +// ─── Migration Engine ────────────────────────────────────────────────────────── + +// gooseEngine implements core.MigrationEngine by iterating all plugin-registered +// migration entries and applying each plugin's migrations against the shared DB. +// +// Each plugin owns its own `migrations/*.sql` directory, embedded via go:embed +// and registered via ctx.Migrations().Register(pluginID, embedFS). +// +// Version tracking: all plugins share a single w_schema_versions table with +// plugin_id as the discriminator column. Querying this table shows the current +// migration version of every plugin at a glance. +type gooseEngine struct{} + +func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry) error { + if len(entries) == 0 { + return nil + } + + // Resolve DBService from the IoC container. + var dbSvc contracts.DBService + if err := core.Using[contracts.DBService](ctx, func(svc contracts.DBService) { + dbSvc = svc + }); err != nil { + return fmt.Errorf("migration: resolve DBService: %w", err) + } + + gormDB := dbSvc.GORM() + if gormDB == nil { + return fmt.Errorf("migration: DBService.GORM() returned nil") + } + + sqlDB, err := gormDB.DB() + if err != nil { + return fmt.Errorf("migration: get underlying DB from GORM: %w", err) + } + + dialect := gooseDialect(ctx) + dialectStr := string(dialect) + goCtx := context.Background() + if ctx != nil { + goCtx = ctx.GoContext() + } + if goCtx == nil { + goCtx = context.Background() + } + + bootstrap := &sharedStore{dialect: dialectStr} + if err := bootstrap.CreateVersionTable(goCtx, sqlDB); err != nil { + return fmt.Errorf("migration: create version table: %w", err) + } + + if dialect == goose.DialectPostgres { + conn, lockErr := sqlDB.Conn(goCtx) + if lockErr != nil { + return fmt.Errorf("migration: pin connection for advisory lock: %w", lockErr) + } + defer func() { _ = conn.Close() }() + if _, lockErr = conn.ExecContext(goCtx, "SELECT pg_advisory_lock($1)", migrationAdvisoryLockKey); lockErr != nil { + return fmt.Errorf("migration: advisory lock: %w", lockErr) + } + defer func() { + _, _ = conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", migrationAdvisoryLockKey) + }() + } + + if fn := ctx.MigrationBaseline(); fn != nil { + if err := fn(ctx); err != nil { + return fmt.Errorf("migration baseline: %w", err) + } + } + + for _, entry := range entries { + store := &sharedStore{ + pluginID: entry.PluginID, + dialect: dialectStr, + } + + migrationFS := findMigrationFS(entry.FS, dialect) + provider, err := goose.NewProvider(goose.DialectCustom, sqlDB, migrationFS, goose.WithStore(store)) + if err != nil { + return fmt.Errorf("migration %s: create provider: %w", entry.PluginID, err) + } + + results, err := provider.Up(context.Background()) + if err != nil { + return fmt.Errorf("migration %s: apply %w", entry.PluginID, err) + } + + version, vErr := provider.GetDBVersion(context.Background()) + if vErr != nil { + version = 0 + } + + if len(results) > 0 { + log.Printf("[migrate] %s: applied %d migration(s) (v%d)", entry.PluginID, len(results), version) + } else { + log.Printf("[migrate] %s: v%d", entry.PluginID, version) + } + } + + return nil +} + +// gooseDialect returns the goose dialect based on the configured database engine. +func gooseDialect(ctx *core.Context) goose.Dialect { + if ctx != nil && ctx.Config() != nil && ctx.Config().Bool("database.enabled", false) { + return goose.DialectPostgres + } + return goose.DialectSQLite3 +} + +func findMigrationFS(rootFS fs.FS, dialect goose.Dialect) fs.FS { + dialectDir := "postgres" + if dialect == goose.DialectSQLite3 { + dialectDir = "sqlite" + } + + // 1. Direct search for dialect folder (e.g., "sqlite", "migrations/sqlite", "logstore/migrations/sqlite") + for _, subDir := range []string{ + dialectDir, + "migrations/" + dialectDir, + "logstore/migrations/" + dialectDir, + } { + if sub, err := fs.Sub(rootFS, subDir); err == nil { + if matches, err := fs.Glob(sub, "*.sql"); err == nil && len(matches) > 0 { + return sub + } + } + } + + // 2. Recursive walk to find a directory named dialectDir with *.sql files + var foundDir string + _ = fs.WalkDir(rootFS, ".", func(path string, d fs.DirEntry, err error) error { + if err == nil && d.IsDir() && filepath.Base(path) == dialectDir { + if sub, subErr := fs.Sub(rootFS, path); subErr == nil { + if matches, globErr := fs.Glob(sub, "*.sql"); globErr == nil && len(matches) > 0 { + foundDir = path + return fs.SkipAll + } + } + } + return nil + }) + + if foundDir != "" && foundDir != "." { + if sub, err := fs.Sub(rootFS, foundDir); err == nil { + return sub + } + } + + // 3. Fallback to generic migrations / root if dialect specific is not present + for _, subDir := range []string{"migrations", "logstore/migrations"} { + if sub, err := fs.Sub(rootFS, subDir); err == nil { + if matches, err := fs.Glob(sub, "*.sql"); err == nil && len(matches) > 0 { + return sub + } + } + } + + return rootFS +} diff --git a/backend/cmd/app_test.go b/backend/cmd/app_test.go new file mode 100644 index 00000000..f70e37d6 --- /dev/null +++ b/backend/cmd/app_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "path/filepath" + "testing" + + "Wavelet/core" +) + +func testSource(t *testing.T) core.ConfigSource { + t.Helper() + return core.NewMapSource(map[string]any{ + "app": map[string]any{ + "addr": "127.0.0.1:0", + "env": "testing", + }, + "redis": map[string]any{ + "enabled": false, + }, + "database": map[string]any{ + "enabled": false, + "sqlite_path": filepath.Join(t.TempDir(), "openflare-cmd.db"), + }, + }) +} + +func TestNewOpenFlareAppRegistersServerAndWaveletUser(t *testing.T) { + app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t))) + if err := app.Prepare(); err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, p := range app.Plugins() { + names[p.Name()] = true + } + for _, n := range []string{"user", "auth", "cap", "admin", "server"} { + if !names[n] { + t.Errorf("missing plugin %s", n) + } + } + + if err := app.Reconcile(); err != nil { + t.Fatal(err) + } + + got := map[string]bool{} + for _, rd := range app.Context().Router().Routes() { + got[rd.Method+" "+rd.Path] = true + } + for _, want := range []string{ + "GET /api/health", + "GET /api/v1/user/self", + "GET /api/v1/d/nodes", + "POST /api/cap/challenge", + } { + if !got[want] { + t.Errorf("missing route %s", want) + } + } +} diff --git a/backend/cmd/banner.go b/backend/cmd/banner.go index dfdb90ee..229d8185 100644 --- a/backend/cmd/banner.go +++ b/backend/cmd/banner.go @@ -4,28 +4,33 @@ package cmd import ( + "Wavelet/pkg/buildinfo" "fmt" - "log" "runtime" "strings" - - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/infra/persistence/migrator" - "Wavelet/pkg/buildinfo" ) type startupState struct { mode string - relationalDB migrator.Report - clickHouseDB migrator.Report listensForHTTP bool + env string + addr string } func printStartupBanner(state startupState) { - log.Print(formatStartupBanner(state)) + fmt.Println(formatStartupBanner(state)) } func formatStartupBanner(state startupState) string { + env := state.env + if env == "" { + env = "production" + } + addr := state.addr + if addr == "" { + addr = "127.0.0.1:3000" + } + lines := []string{ "", " ____ ________ ", @@ -34,18 +39,16 @@ func formatStartupBanner(state startupState) string { "/ /_/ / /_/ / __/ / / / __/ / / /_/ / / / __/", "\\____/ .___/\\___/_/ /_/_/ /_/\\__,_/_/ \\___/ ", " /_/ ", - " OpenFlare " + buildinfo.Version, + fmt.Sprintf(" OpenFlare %s", buildinfo.Version), "", - " Environment: " + config.Config.App.Env, + fmt.Sprintf(" Environment: %s", env), fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()), - " Build time: " + buildTime(), - " Database: " + formatMigration(state.relationalDB), - " Analytics: " + formatMigration(state.clickHouseDB), + fmt.Sprintf(" Build time: %s", buildTime()), } if state.listensForHTTP { - lines = append(lines, " Listening: http://"+config.Config.App.Addr) + lines = append(lines, fmt.Sprintf(" Listening: http://%s", addr)) } - lines = append(lines, " Mode: "+state.mode, "") + lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "") return strings.Join(lines, "\n") } @@ -55,14 +58,3 @@ func buildTime() string { } return buildinfo.BuildTime } - -func formatMigration(report migrator.Report) string { - if !report.Enabled { - return "disabled" - } - state := "up to date" - if report.Applied { - state = "upgraded" - } - return fmt.Sprintf("%s (version %d, %s)", report.Backend, report.Version, state) -} diff --git a/backend/cmd/banner_test.go b/backend/cmd/banner_test.go index 4d029dab..83ab12ee 100644 --- a/backend/cmd/banner_test.go +++ b/backend/cmd/banner_test.go @@ -4,49 +4,33 @@ package cmd import ( + "Wavelet/pkg/buildinfo" "strings" "testing" - - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/infra/persistence/migrator" - "Wavelet/pkg/buildinfo" ) func TestFormatStartupBanner(t *testing.T) { previousVersion := buildinfo.Version previousBuildTime := buildinfo.BuildTime - previousEnv := config.Config.App.Env - previousAddr := config.Config.App.Addr t.Cleanup(func() { buildinfo.Version = previousVersion buildinfo.BuildTime = previousBuildTime - config.Config.App.Env = previousEnv - config.Config.App.Addr = previousAddr }) buildinfo.Version = "v3.2.1" buildinfo.BuildTime = "2026-07-13T08:00:00Z" - config.Config.App.Env = "production" - config.Config.App.Addr = ":3000" banner := formatStartupBanner(startupState{ - mode: "API", - relationalDB: migrator.Report{ - Backend: "PostgreSQL", - Enabled: true, - Version: 202607150003, - Applied: true, - }, - clickHouseDB: migrator.Report{Backend: "ClickHouse"}, + mode: "API", listensForHTTP: true, + env: "production", + addr: ":3000", }) for _, want := range []string{ "OpenFlare v3.2.1", "Environment: production", "Build time: 2026-07-13T08:00:00Z", - "Database: PostgreSQL (version 202607150003, upgraded)", - "Analytics: disabled", "Listening: http://:3000", "Mode: API", } { diff --git a/backend/cmd/bootstrap.go b/backend/cmd/bootstrap.go deleted file mode 100644 index 421c0337..00000000 --- a/backend/cmd/bootstrap.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "context" - - "Wavelet/OpenFlare/plugins/server/platform/bootstrap" - "Wavelet/pkg/trace" -) - -func runBootstrap(opts bootstrap.Options) { - ctx, span := trace.Start(context.Background(), "bootstrap.Init") - defer span.End() - bootstrap.Init(ctx, opts) -} diff --git a/backend/cmd/http_server.go b/backend/cmd/http_server.go deleted file mode 100644 index 0fe41a8a..00000000 --- a/backend/cmd/http_server.go +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package cmd - -import ( - "context" - "log" - "time" - - "Wavelet/OpenFlare/plugins/server" - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/platform/bootstrap" - "Wavelet/OpenFlare/plugins/server/router" - "Wavelet/core" - "Wavelet/plugins/drivers/driver_http" -) - -// defaultShutdownTimeout 在配置未给出优雅退出超时时兜底。 -const defaultShutdownTimeout = 10 * time.Second - -// runHTTPApp 以 Cordis 方式启动控制面:装配根持有 gin engine(引擎级中间件与前端 -// SPA 兜底),server 插件经 ctx.Router() 声明路由,driver_http 负责监听与优雅退出。 -// 本函数阻塞至收到退出信号。 -func runHTTPApp(mode string) { - engine := router.BuildEngine() - timeout := shutdownTimeout() - - app := core.NewApp( - core.WithProfile(core.ProfileAPI), - core.WithShutdownTimeout(timeout), - ) - httpDriver := driver_http.New( - driver_http.WithEngine(engine), - driver_http.WithAddr(config.Config.App.Addr), - ) - app.Use(server.New()) - app.Use(httpDriver) - - if err := app.Prepare(); err != nil { - log.Fatalf("[API] prepare failed: %v\n", err) - } - - printStartupBanner(startupState{ - mode: mode, - relationalDB: latestMigrationState.relationalDB, - clickHouseDB: latestMigrationState.clickHouseDB, - listensForHTTP: true, - }) - - if err := app.Run(); err != nil { - log.Printf("[API] server failed: %v\n", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - bootstrap.Stop(ctx) - log.Println("[API] server exited") -} - -func shutdownTimeout() time.Duration { - if config.Config.App.GracefulShutdownTimeout <= 0 { - return defaultShutdownTimeout - } - return time.Duration(config.Config.App.GracefulShutdownTimeout) * time.Second -} diff --git a/backend/cmd/migrate_test.go b/backend/cmd/migrate_test.go new file mode 100644 index 00000000..d391cba7 --- /dev/null +++ b/backend/cmd/migrate_test.go @@ -0,0 +1,127 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type migrateTestDB struct { + db *gorm.DB +} + +func (s migrateTestDB) GORM() *gorm.DB { return s.db } + +func (s migrateTestDB) DB(ctx context.Context) *gorm.DB { return s.db.WithContext(ctx) } + +func (s migrateTestDB) Named(string) *gorm.DB { return s.db } + +type migrateTestPlugin struct { + db *gorm.DB + fs fstest.MapFS +} + +func (p *migrateTestPlugin) Name() string { return "t" } + +func (p *migrateTestPlugin) Apply(ctx *core.Context) error { + core.Provide[contracts.DBService](ctx, migrateTestDB{db: p.db}) + ctx.Migrations().Register("t", p.fs) + return nil +} + +func sqliteTableExists(t *testing.T, db *gorm.DB, name string) bool { + t.Helper() + var n int + err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", name).Scan(&n).Error + require.NoError(t, err) + return n > 0 +} + +func testMigrationFS() fstest.MapFS { + return fstest.MapFS{ + "migrations/sqlite/00001_init.sql": &fstest.MapFile{Data: []byte(`-- +goose Up +CREATE TABLE t_up (id INTEGER PRIMARY KEY); + +-- +goose Down +DROP TABLE t_up; +`)}, + } +} + +func openMigrateTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "migrate.db") + gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + return gdb +} + +func TestGooseEngineMigrateOrderCreateTableBaselineUp(t *testing.T) { + gdb := openMigrateTestDB(t) + var order []string + + app := core.NewApp( + core.WithMigrationEngine(&gooseEngine{}), + core.WithMigrationBaseline(func(*core.Context) error { + require.True(t, sqliteTableExists(t, gdb, "w_schema_versions"), "version table must exist before baseline") + require.False(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must not run before baseline") + order = append(order, "create-table", "baseline") + return nil + }), + core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}), + ) + + require.NoError(t, app.Prepare()) + require.NoError(t, app.ApplyPlugins()) + require.NoError(t, app.RunMigrations()) + + require.True(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must run after baseline") + order = append(order, "up") + assert.Equal(t, []string{"create-table", "baseline", "up"}, order) +} + +func TestGooseEngineBaselineErrorSkipsUp(t *testing.T) { + gdb := openMigrateTestDB(t) + + app := core.NewApp( + core.WithMigrationEngine(&gooseEngine{}), + core.WithMigrationBaseline(func(*core.Context) error { + require.True(t, sqliteTableExists(t, gdb, "w_schema_versions"), "version table must exist before baseline") + return assert.AnError + }), + core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}), + ) + + require.NoError(t, app.Prepare()) + require.NoError(t, app.ApplyPlugins()) + err := app.RunMigrations() + require.Error(t, err) + assert.ErrorContains(t, err, "migration baseline") + assert.False(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must not run when baseline fails") +} + +func TestGooseEngineNilBaselineStillMigrates(t *testing.T) { + gdb := openMigrateTestDB(t) + + app := core.NewApp( + core.WithMigrationEngine(&gooseEngine{}), + core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}), + ) + + require.NoError(t, app.Prepare()) + require.NoError(t, app.ApplyPlugins()) + require.NoError(t, app.RunMigrations()) + assert.True(t, sqliteTableExists(t, gdb, "w_schema_versions")) + assert.True(t, sqliteTableExists(t, gdb, "t_up")) +} diff --git a/backend/cmd/reset_passwd.go b/backend/cmd/reset_passwd.go index 944f4cd9..00087de0 100644 --- a/backend/cmd/reset_passwd.go +++ b/backend/cmd/reset_passwd.go @@ -4,6 +4,8 @@ package cmd import ( + "Wavelet/plugins/domain/auth" + "Wavelet/plugins/infra/database" "bufio" "context" "crypto/rand" @@ -13,12 +15,7 @@ import ( "os" "strings" - db "Wavelet/OpenFlare/plugins/server/infra/persistence" - "Wavelet/OpenFlare/plugins/server/infra/persistence/migrator" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/oauth" - "Wavelet/OpenFlare/plugins/server/platform/bootstrap" - "Wavelet/OpenFlare/plugins/server/repository" + userdomain "Wavelet/plugins/domain/user" "github.com/spf13/cobra" "gorm.io/gorm" @@ -48,12 +45,14 @@ func generateRandomPassword(length int) (string, error) { var resetPasswdCmd = &cobra.Command{ Use: "reset-passwd", Short: "重置指定账号密码", - PreRun: func(_ *cobra.Command, _ []string) { - migrator.Migrate() - }, Run: func(_ *cobra.Command, _ []string) { ctx := context.Background() - runBootstrap(bootstrap.Options{}) + + // Ensure database is initialized + dbConn := database.DB(ctx) + if dbConn != nil { + userdomain.SetDBService(database.NewService(dbConn)) + } var username string if usernameFlag != "" { @@ -71,7 +70,7 @@ var resetPasswdCmd = &cobra.Command{ } } - user, err := repository.GetUserByUsername(ctx, username) + user, err := userdomain.GetUserByUsername(ctx, username) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { log.Fatalf("错误: 用户 '%s' 不存在\n", username) @@ -93,26 +92,26 @@ var resetPasswdCmd = &cobra.Command{ log.Fatalf("加密密码失败: %v\n", err) } - err = db.DB(ctx).Transaction(func(tx *gorm.DB) error { + err = database.DB(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&user).Update("password", user.Password).Error; err != nil { return err } // Invalidate existing tokens - var tokens []model.AccessToken + var tokens []userdomain.AccessToken if err := tx.Where("user_id = ?", user.ID).Find(&tokens).Error; err == nil { for _, token := range tokens { - oauth.InvalidateCachedToken(ctx, token.TokenHash) + auth.InvalidateCachedToken(ctx, token.TokenHash) } } - return tx.Where("user_id = ?", user.ID).Delete(&model.AccessToken{}).Error + return tx.Where("user_id = ?", user.ID).Delete(&userdomain.AccessToken{}).Error }) if err != nil { log.Fatalf("重置密码失败: %v\n", err) } - oauth.InvalidateCachedUser(ctx, user.ID) + auth.InvalidateCachedUser(ctx, user.ID) fmt.Println("成功重置密码!") fmt.Printf("用户名: %s\n", user.Username) diff --git a/backend/cmd/reset_passwd_test.go b/backend/cmd/reset_passwd_test.go index d95254e0..0ad5b19b 100644 --- a/backend/cmd/reset_passwd_test.go +++ b/backend/cmd/reset_passwd_test.go @@ -4,14 +4,14 @@ package cmd import ( + "Wavelet/pkg/testhelper" "bytes" "io" "os" "testing" "time" - "Wavelet/OpenFlare/plugins/server/model" - "Wavelet/OpenFlare/plugins/server/testhelper" + userdomain "Wavelet/plugins/domain/user" ) func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { @@ -19,7 +19,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { defer cleanup() // Seed test user - user := model.User{ + user := userdomain.User{ ID: 1001, Username: "testuser1", Nickname: "Test User 1", @@ -33,7 +33,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { } // Create access token to test invalidation/deletion - token := model.AccessToken{ + token := userdomain.AccessToken{ ID: 1, UserID: user.ID, Name: "testtoken", @@ -74,7 +74,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { } // Verify password in DB - var dbUser model.User + var dbUser userdomain.User if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil { t.Fatalf("failed to query user from DB: %v", err) } @@ -84,7 +84,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { // Verify token deleted var count int64 - dbConn.Model(&model.AccessToken{}).Where("user_id = ?", user.ID).Count(&count) + dbConn.Model(&userdomain.AccessToken{}).Where("user_id = ?", user.ID).Count(&count) if count != 0 { t.Errorf("expected access tokens to be deleted, got %d", count) } @@ -95,7 +95,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) { defer cleanup() // Seed test user - user := model.User{ + user := userdomain.User{ ID: 1002, Username: "testuser2", Nickname: "Test User 2", @@ -142,7 +142,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) { } // Verify password in DB (should be updated and not equal to old one) - var dbUser model.User + var dbUser userdomain.User if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil { t.Fatalf("failed to query user from DB: %v", err) } @@ -156,7 +156,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) { defer cleanup() // Seed test user - user := model.User{ + user := userdomain.User{ ID: 1003, Username: "testuser3", Nickname: "Test User 3", @@ -217,7 +217,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) { } // Verify user password changed in DB - var dbUser model.User + var dbUser userdomain.User if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil { t.Fatalf("failed to query user from DB: %v", err) } diff --git a/backend/cmd/root.go b/backend/cmd/root.go index 93c48f74..4049fcb7 100644 --- a/backend/cmd/root.go +++ b/backend/cmd/root.go @@ -1,47 +1,83 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 package cmd import ( + "Wavelet/core/extpoints" + "Wavelet/pkg/buildinfo" + "Wavelet/pkg/idgen" + "Wavelet/pkg/logger" + "Wavelet/pkg/trace" + "Wavelet/plugins/infra/config" "context" "log" "time" - "Wavelet/OpenFlare/plugins/server/infra/config" - "Wavelet/OpenFlare/plugins/server/infra/persistence/migrator" - "Wavelet/pkg/buildinfo" - "Wavelet/pkg/logger" - "Wavelet/pkg/trace" - "github.com/spf13/cobra" ) const traceShutdownTimeout = 10 * time.Second +type hostConfig struct { + App struct { + AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"` + Env string `config:"env" env:"APP_ENV" default:"production"` + NodeID int64 `config:"node_id" env:"APP_NODE_ID" default:"1"` + Addr string `config:"addr" env:"APP_ADDR" default:"127.0.0.1:3000"` + } `config:"app"` + Log struct { + Level string `config:"level" env:"LOG_LEVEL" default:"info"` + Format string `config:"format" env:"LOG_FORMAT" default:"json"` + Output string `config:"output" env:"LOG_OUTPUT" default:"stdout"` + FilePath string `config:"file_path" env:"LOG_FILE_PATH" default:"./logs/app.log"` + MaxSize int `config:"max_size" env:"LOG_MAX_SIZE" default:"100"` + MaxAge int `config:"max_age" env:"LOG_MAX_AGE" default:"30"` + MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS" default:"10"` + Compress bool `config:"compress" env:"LOG_COMPRESS" default:"true"` + } `config:"log"` + OTel struct { + SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE" default:"1.0"` + TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"` + } `config:"otel"` +} + var rootCmd = &cobra.Command{ Use: "wavelet", PersistentPreRun: func(_ *cobra.Command, _ []string) { + src, err := config.NewSource() + if err != nil { + log.Fatalf("[CMD] load config source failed: %v", err) + } + var cfg hostConfig + reg := extpoints.NewConfigRegistry(src) + _ = reg.Declare("host", extpoints.ConfigBinding{Target: &cfg}) + if err := reg.Resolve(); err != nil { + log.Fatalf("[CMD] resolve host config failed: %v", err) + } + _ = reg.Bind("", &cfg) + + // Initialize idgen snowflake generator + if err := idgen.Init(cfg.App.NodeID); err != nil { + log.Fatalf("[CMD] init idgen failed: %v", err) + } + logger.Init(logger.Config{ - Level: config.Config.Log.Level, - Format: config.Config.Log.Format, - Output: config.Config.Log.Output, - FilePath: config.Config.Log.FilePath, - MaxSize: config.Config.Log.MaxSize, - MaxAge: config.Config.Log.MaxAge, - MaxBackups: config.Config.Log.MaxBackups, - Compress: config.Config.Log.Compress, + Level: cfg.Log.Level, + Format: cfg.Log.Format, + Output: cfg.Log.Output, + FilePath: cfg.Log.FilePath, + MaxSize: cfg.Log.MaxSize, + MaxAge: cfg.Log.MaxAge, + MaxBackups: cfg.Log.MaxBackups, + Compress: cfg.Log.Compress, }) trace.Init(trace.Config{ - AppName: config.Config.App.AppName, - SamplingRate: config.Config.Otel.SamplingRate, - TracerName: config.Config.Otel.TracerName, + AppName: cfg.App.AppName, + SamplingRate: cfg.OTel.SamplingRate, + TracerName: cfg.OTel.TracerName, }) }, - PreRun: func(_ *cobra.Command, _ []string) { - runMigrations() - }, PersistentPostRun: func(_ *cobra.Command, _ []string) { shutdownTraceProvider() }, @@ -51,16 +87,6 @@ var rootCmd = &cobra.Command{ }, } -var latestMigrationState struct { - relationalDB migrator.Report - clickHouseDB migrator.Report -} - -func runMigrations() { - latestMigrationState.relationalDB = migrator.Migrate() - latestMigrationState.clickHouseDB = migrator.MigrateClickHouse() -} - func shutdownTraceProvider() { ctx, cancel := context.WithTimeout(context.Background(), traceShutdownTimeout) defer cancel() @@ -71,16 +97,7 @@ func init() { rootCmd.Version = buildinfo.Version rootCmd.CompletionOptions.DisableDefaultCmd = true - // 1. 为需要迁移的子命令动态绑定原先 rootCmd.PreRun 拥有的数据库迁移行为 - migratePreRun := func(_ *cobra.Command, _ []string) { - runMigrations() - } - allCmd.PreRun = migratePreRun - apiCmd.PreRun = migratePreRun - workerCmd.PreRun = migratePreRun - schedulerCmd.PreRun = migratePreRun - - // 2. 集中将这些命令注册为真正的子命令,以解决 Cobra 的 unknown command 校验限制 + // 集中将子命令注册到根命令,以解决 Cobra 的 unknown command 校验限制 rootCmd.AddCommand(allCmd, apiCmd, workerCmd, schedulerCmd) } diff --git a/backend/cmd/scheduler.go b/backend/cmd/scheduler.go index 53e6aa21..3afdb9ce 100644 --- a/backend/cmd/scheduler.go +++ b/backend/cmd/scheduler.go @@ -1,14 +1,10 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 package cmd import ( - "log" - - "Wavelet/OpenFlare/plugins/server/infra/task/scheduler" - "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "Wavelet/core" "github.com/spf13/cobra" ) @@ -17,11 +13,6 @@ var schedulerCmd = &cobra.Command{ Use: "scheduler", Short: "wavelet Scheduler", Run: func(_ *cobra.Command, _ []string) { - runBootstrap(bootstrap.Options{}) - printStartupBanner(startupState{mode: "Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB}) - log.Println("[Scheduler] 启动定时任务调度服务") - if err := scheduler.StartScheduler(); err != nil { - log.Fatalf("[调度器] 启动失败: %v", err) - } + runProfileApp(core.ProfileSchedule, "scheduler", false) }, } diff --git a/backend/cmd/worker.go b/backend/cmd/worker.go index d968fec0..5011341b 100644 --- a/backend/cmd/worker.go +++ b/backend/cmd/worker.go @@ -1,14 +1,10 @@ -// Copyright 2025 linux.do // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 package cmd import ( - "log" - - "Wavelet/OpenFlare/plugins/server/infra/task/worker" - "Wavelet/OpenFlare/plugins/server/platform/bootstrap" + "Wavelet/core" "github.com/spf13/cobra" ) @@ -17,11 +13,6 @@ var workerCmd = &cobra.Command{ Use: "worker", Short: "wavelet Worker", Run: func(_ *cobra.Command, _ []string) { - runBootstrap(bootstrap.Options{}) - printStartupBanner(startupState{mode: "Worker", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB}) - log.Println("[Worker] 启动任务处理服务") - if err := worker.StartWorker(); err != nil { - log.Fatalf("[工作器] 启动失败: %v", err) - } + runProfileApp(core.ProfileWorker, "worker", false) }, } diff --git a/config.example.yaml b/config.example.yaml index a35e1049..932cbdba 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -16,6 +16,7 @@ app: session_secure: false session_http_only: false api_prefix: "/api" + redirect_trailing_slash: false # ─── PostgreSQL ───────────────────────────────────────────────────────────────── # Supports Standalone and Primary-Replica (read/write split) modes. diff --git a/docs/changelog/index.md b/docs/changelog/index.md index cf802657..6e59ce0a 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -18,6 +18,7 @@ sidebar: false - 控制台 API 改由 Cordis 内核提供服务:`server` 插件以声明式路由注册,HTTP 监听与优雅退出交给内核的 http 驱动。全部 256 条路由(含 20 条带尾部斜杠的历史列表接口)与改造前逐条一致,232 条对外 API 操作无变化。 - 清理与上游 Wavelet 重复的平台实现:响应封装、日志、邮件、链路追踪、HTTP 连接池、内存/磁盘缓存、批量写入等 8 个本地副本删除并改为使用上游能力(约 600 行重复代码消失,接口形状与文档定义完全一致);顺带把磁盘缓存的类型断言健壮性修复回流上游。 +- 控制面改为与上游 Wavelet 同构装配:`newOpenFlareApp` 挂载 Wavelet 平台插件后再挂 OpenFlare `server` 业务路由,健康检查/用户/验证码由上游插件提供;`app.redirect_trailing_slash` 默认关闭,避免列表接口尾部斜杠被 301。 ## [v3.5.4] - 2026-08-29 ### ✨ 新功能