feat(cmd): assemble control plane via Wavelet plugins plus server

This commit is contained in:
ryan
2026-08-30 12:13:43 +08:00
parent e847a7adb3
commit e48485f27d
38 changed files with 1080 additions and 343 deletions
@@ -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()}
}
@@ -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:
@@ -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
}
+24 -16
View File
@@ -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
}
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
}
@@ -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)
@@ -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)
@@ -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)
}
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
}
}
@@ -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)
@@ -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)
@@ -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)
}
@@ -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()
}
@@ -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
}
+2 -32
View File
@@ -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)
},
}
+2 -5
View File
@@ -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)
},
}
+406
View File
@@ -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
}
+63
View File
@@ -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)
}
}
}
+18 -26
View File
@@ -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)
}
+4 -20
View File
@@ -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",
} {
-17
View File
@@ -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)
}
-66
View File
@@ -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
}
+127
View File
@@ -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"))
}
+15 -16
View File
@@ -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)
+10 -10
View File
@@ -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)
}
+58 -41
View File
@@ -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)
}
+2 -11
View File
@@ -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)
},
}
+2 -11
View File
@@ -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)
},
}
+1
View File
@@ -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.
+1
View File
@@ -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
### ✨ 新功能