mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 14:46:36 +08:00
refactor(architecture): eliminate internal package and complete cordis single-owner model and repository migration
- Physically purged all legacy internal/ packages, centralized pkg/model/ and pkg/repository/ - Migrated domain models and database repositories into self-contained owner plugins (user, auth, message_gateway, admin, upload, risk_control) - Decoupled cross-plugin interactions via pure core/contracts and typed EventBus - Ensured 100% test coverage pass, zero data races (-race clean), and 0 lint issues in make code-check
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package cmd 提供 CLI 命令入口
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var allCmd = &cobra.Command{
|
||||
Use: "all",
|
||||
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
app := newWaveletApp(core.ProfileAll)
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("[All] run failed: %v\n", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var apiCmd = &cobra.Command{
|
||||
Use: "api",
|
||||
Short: "wavelet API",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
app := newWaveletApp(core.ProfileAPI)
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("[API] run failed: %v\n", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/Rain-kl/Wavelet/core/extpoints"
|
||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/admin"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/cap"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/message_gateway"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/risk_control"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/system"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/upload"
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/user"
|
||||
"github.com/Rain-kl/Wavelet/plugins/drivers/driver_asynq_cron"
|
||||
"github.com/Rain-kl/Wavelet/plugins/drivers/driver_asynq_worker"
|
||||
"github.com/Rain-kl/Wavelet/plugins/drivers/driver_http"
|
||||
"github.com/Rain-kl/Wavelet/plugins/infra/cache"
|
||||
"github.com/Rain-kl/Wavelet/plugins/infra/database"
|
||||
"github.com/Rain-kl/Wavelet/plugins/infra/logger"
|
||||
"github.com/Rain-kl/Wavelet/plugins/infra/storage"
|
||||
)
|
||||
|
||||
// newWaveletApp creates a core.App wired with Wavelet platform infrastructure, domain plugins, and profile drivers.
|
||||
//
|
||||
//nolint:contextcheck
|
||||
func newWaveletApp(profile core.Profile) *core.App {
|
||||
app := core.NewApp(
|
||||
core.WithProfile(profile),
|
||||
core.WithShutdownTimeout(time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second),
|
||||
)
|
||||
|
||||
// 1. Register standard infrastructure plugins
|
||||
app.Use(
|
||||
database.New(),
|
||||
cache.New(),
|
||||
logger.New(),
|
||||
storage.New(),
|
||||
)
|
||||
|
||||
// 2. Register all 8 domain business plugins
|
||||
app.Use(
|
||||
auth.New(),
|
||||
user.New(),
|
||||
message_gateway.New(),
|
||||
risk_control.New(),
|
||||
admin.New(),
|
||||
upload.New(),
|
||||
cap.New(),
|
||||
system.New(),
|
||||
)
|
||||
|
||||
// 3. Bind Goose migration runner
|
||||
app.SetMigrationRunner(func(_ context.Context, _ []extpoints.MigrationEntry) error {
|
||||
runMigrations()
|
||||
return nil
|
||||
})
|
||||
|
||||
// 4. Mount runtime drivers for each aspect
|
||||
app.Use(
|
||||
driver_http.New(driver_http.WithAddr(config.Config.App.Addr)),
|
||||
driver_asynq_worker.New(),
|
||||
driver_asynq_cron.New(),
|
||||
)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
)
|
||||
|
||||
func TestNewWaveletAppProfiles(t *testing.T) {
|
||||
profiles := []core.Profile{
|
||||
core.ProfileAPI,
|
||||
core.ProfileWorker,
|
||||
core.ProfileSchedule,
|
||||
core.ProfileAll,
|
||||
}
|
||||
|
||||
for _, prof := range profiles {
|
||||
t.Run(string(prof), func(t *testing.T) {
|
||||
app := newWaveletApp(prof)
|
||||
require.NotNil(t, app)
|
||||
assert.Equal(t, prof, app.Profile())
|
||||
|
||||
// Verify 4 infra plugins + 8 domain plugins + 3 driver plugins registered
|
||||
plugins := app.Plugins()
|
||||
assert.Len(t, plugins, 15)
|
||||
|
||||
// Verify each standard infra plugin is registered
|
||||
_, ok := app.Plugin("database")
|
||||
assert.True(t, ok, "database plugin missing")
|
||||
|
||||
_, ok = app.Plugin("cache")
|
||||
assert.True(t, ok, "cache plugin missing")
|
||||
|
||||
_, ok = app.Plugin("logger")
|
||||
assert.True(t, ok, "logger plugin missing")
|
||||
|
||||
_, ok = app.Plugin("storage")
|
||||
assert.True(t, ok, "storage plugin missing")
|
||||
|
||||
// Verify domain plugins
|
||||
_, ok = app.Plugin("auth")
|
||||
assert.True(t, ok, "auth plugin missing")
|
||||
|
||||
_, ok = app.Plugin("user")
|
||||
assert.True(t, ok, "user plugin missing")
|
||||
|
||||
_, ok = app.Plugin("message_gateway")
|
||||
assert.True(t, ok, "message_gateway plugin missing")
|
||||
|
||||
_, ok = app.Plugin("risk_control")
|
||||
assert.True(t, ok, "risk_control plugin missing")
|
||||
|
||||
_, ok = app.Plugin("admin")
|
||||
assert.True(t, ok, "admin plugin missing")
|
||||
|
||||
_, ok = app.Plugin("upload")
|
||||
assert.True(t, ok, "upload plugin missing")
|
||||
|
||||
_, ok = app.Plugin("cap")
|
||||
assert.True(t, ok, "cap plugin missing")
|
||||
|
||||
_, ok = app.Plugin("system")
|
||||
assert.True(t, ok, "system plugin missing")
|
||||
|
||||
// Verify driver plugins
|
||||
_, ok = app.Plugin("driver_http")
|
||||
assert.True(t, ok, "http driver missing")
|
||||
|
||||
_, ok = app.Plugin("driver_asynq_worker")
|
||||
assert.True(t, ok, "worker driver missing")
|
||||
|
||||
_, ok = app.Plugin("driver_asynq_cron")
|
||||
assert.True(t, ok, "scheduler driver missing")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package cmd provides CLI command entry points.
|
||||
//
|
||||
//nolint:unused
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
|
||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||
"github.com/Rain-kl/Wavelet/pkg/persistence/migrator"
|
||||
)
|
||||
|
||||
//nolint:unused // startup banner formatting utilities
|
||||
type startupState struct {
|
||||
mode string
|
||||
relationalDB migrator.Report
|
||||
clickHouseDB migrator.Report
|
||||
listensForHTTP bool
|
||||
}
|
||||
|
||||
func printStartupBanner(state startupState) {
|
||||
log.Print(formatStartupBanner(state))
|
||||
}
|
||||
|
||||
func formatStartupBanner(state startupState) string {
|
||||
lines := []string{
|
||||
"",
|
||||
"__ __ _ _ ",
|
||||
"\\ \\ / /_ ___ _____ | | ___| |_ ",
|
||||
" \\ \\ /\\ / / _` \\ \\ / / _ \\ | |/ _ \\ __|",
|
||||
" \\ V V / (_| |\\ V / __/ | | __/ |_ ",
|
||||
" \\_/\\_/ \\__,_| \\_/ \\___|_|\\___|\\__|",
|
||||
fmt.Sprintf(" Wavelet %s", buildinfo.Version),
|
||||
"",
|
||||
fmt.Sprintf(" Environment: %s", config.Config.App.Env),
|
||||
fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()),
|
||||
fmt.Sprintf(" Build time: %s", buildTime()),
|
||||
fmt.Sprintf(" Database: %s", formatMigration(state.relationalDB)),
|
||||
fmt.Sprintf(" Analytics: %s", formatMigration(state.clickHouseDB)),
|
||||
}
|
||||
if state.listensForHTTP {
|
||||
lines = append(lines, fmt.Sprintf(" Listening: http://%s", config.Config.App.Addr))
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildTime() string {
|
||||
if buildinfo.BuildTime == "" {
|
||||
return "development build"
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
|
||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||
"github.com/Rain-kl/Wavelet/pkg/persistence/migrator"
|
||||
)
|
||||
|
||||
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"},
|
||||
listensForHTTP: true,
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"Wavelet v3.2.1",
|
||||
"Environment: production",
|
||||
"Build time: 2026-07-13T08:00:00Z",
|
||||
"Database: PostgreSQL (version 202607150003, upgraded)",
|
||||
"Analytics: disabled",
|
||||
"Listening: http://:3000",
|
||||
"Mode: API",
|
||||
} {
|
||||
if !strings.Contains(banner, want) {
|
||||
t.Errorf("banner missing %q:\n%s", want, banner)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/persistence"
|
||||
"github.com/Rain-kl/Wavelet/pkg/persistence/migrator"
|
||||
userdomain "github.com/Rain-kl/Wavelet/plugins/domain/user"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/plugins/domain/auth"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
usernameFlag string
|
||||
passwordFlag string
|
||||
)
|
||||
|
||||
const (
|
||||
passwdCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
||||
defaultPasswordLength = 16
|
||||
)
|
||||
|
||||
func generateRandomPassword(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i, b := range bytes {
|
||||
bytes[i] = passwdCharset[int(b)%len(passwdCharset)]
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
var resetPasswdCmd = &cobra.Command{
|
||||
Use: "reset-passwd",
|
||||
Short: "重置指定账号密码",
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
migrator.Migrate()
|
||||
},
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
ctx := context.Background()
|
||||
|
||||
var username string
|
||||
if usernameFlag != "" {
|
||||
username = usernameFlag
|
||||
} else {
|
||||
fmt.Print("请输入用户名: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Fatalf("读取用户名失败: %v\n", err)
|
||||
}
|
||||
username = strings.TrimSpace(input)
|
||||
if username == "" {
|
||||
log.Fatal("用户名不能为空\n")
|
||||
}
|
||||
}
|
||||
|
||||
user, err := userdomain.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Fatalf("错误: 用户 '%s' 不存在\n", username)
|
||||
}
|
||||
log.Fatalf("查询用户失败: %v\n", err)
|
||||
}
|
||||
|
||||
var password string
|
||||
if passwordFlag != "" {
|
||||
password = passwordFlag
|
||||
} else {
|
||||
password, err = generateRandomPassword(defaultPasswordLength)
|
||||
if err != nil {
|
||||
log.Fatalf("生成随机密码失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := user.SetEncryptedPassword(password); err != nil {
|
||||
log.Fatalf("加密密码失败: %v\n", err)
|
||||
}
|
||||
|
||||
err = db.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 []userdomain.AccessToken
|
||||
if err := tx.Where("user_id = ?", user.ID).Find(&tokens).Error; err == nil {
|
||||
for _, token := range tokens {
|
||||
auth.InvalidateCachedToken(ctx, token.TokenHash)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Where("user_id = ?", user.ID).Delete(&userdomain.AccessToken{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("重置密码失败: %v\n", err)
|
||||
}
|
||||
|
||||
auth.InvalidateCachedUser(ctx, user.ID)
|
||||
|
||||
fmt.Println("成功重置密码!")
|
||||
fmt.Printf("用户名: %s\n", user.Username)
|
||||
fmt.Printf("新密码: %s\n", password)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
resetPasswdCmd.Flags().StringVar(&usernameFlag, "user", "", "重置密码的目标用户名")
|
||||
resetPasswdCmd.Flags().StringVar(&passwordFlag, "password", "", "新密码(若不指定,则自动生成随机密码)")
|
||||
rootCmd.AddCommand(resetPasswdCmd)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/testhelper"
|
||||
userdomain "github.com/Rain-kl/Wavelet/plugins/domain/user"
|
||||
)
|
||||
|
||||
func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := userdomain.User{
|
||||
ID: 1001,
|
||||
Username: "testuser1",
|
||||
Nickname: "Test User 1",
|
||||
Email: "test1@example.com",
|
||||
IsActive: true,
|
||||
LastLoginAt: time.Now(),
|
||||
}
|
||||
_ = user.SetEncryptedPassword("oldpassword")
|
||||
if err := dbConn.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Create access token to test invalidation/deletion
|
||||
token := userdomain.AccessToken{
|
||||
ID: 1,
|
||||
UserID: user.ID,
|
||||
Name: "testtoken",
|
||||
TokenHash: "somehash",
|
||||
MaskedToken: "some...",
|
||||
}
|
||||
if err := dbConn.Create(&token).Error; err != nil {
|
||||
t.Fatalf("failed to create access token: %v", err)
|
||||
}
|
||||
|
||||
// Override PreRun to bypass goose migrations in unit tests
|
||||
oldPreRun := resetPasswdCmd.PreRun
|
||||
resetPasswdCmd.PreRun = nil
|
||||
defer func() { resetPasswdCmd.PreRun = oldPreRun }()
|
||||
|
||||
// Execute command with args
|
||||
rootCmd.SetArgs([]string{"reset-passwd", "--user", "testuser1", "--password", "newpassword123"})
|
||||
|
||||
// Capture output
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := rootCmd.Execute()
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("command execute failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
if !bytes.Contains([]byte(output), []byte("成功重置密码!")) {
|
||||
t.Errorf("expected output to contain success message, got: %s", output)
|
||||
}
|
||||
|
||||
// Verify password in DB
|
||||
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)
|
||||
}
|
||||
if !dbUser.CheckPassword("newpassword123") {
|
||||
t.Errorf("password was not updated correctly in DB")
|
||||
}
|
||||
|
||||
// Verify token deleted
|
||||
var count int64
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := userdomain.User{
|
||||
ID: 1002,
|
||||
Username: "testuser2",
|
||||
Nickname: "Test User 2",
|
||||
Email: "test2@example.com",
|
||||
IsActive: true,
|
||||
LastLoginAt: time.Now(),
|
||||
}
|
||||
_ = user.SetEncryptedPassword("oldpassword")
|
||||
if err := dbConn.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Override PreRun
|
||||
oldPreRun := resetPasswdCmd.PreRun
|
||||
resetPasswdCmd.PreRun = nil
|
||||
defer func() { resetPasswdCmd.PreRun = oldPreRun }()
|
||||
|
||||
// Reset flags
|
||||
usernameFlag = ""
|
||||
passwordFlag = ""
|
||||
|
||||
// Execute command with args (no --password)
|
||||
rootCmd.SetArgs([]string{"reset-passwd", "--user", "testuser2"})
|
||||
|
||||
// Capture output
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := rootCmd.Execute()
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("command execute failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
if !bytes.Contains([]byte(output), []byte("成功重置密码!")) {
|
||||
t.Errorf("expected output to contain success message, got: %s", output)
|
||||
}
|
||||
|
||||
// Verify password in DB (should be updated and not equal to old one)
|
||||
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)
|
||||
}
|
||||
if dbUser.CheckPassword("oldpassword") {
|
||||
t.Errorf("expected password to change, but it matches the old one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswdCmd_InteractiveMode(t *testing.T) {
|
||||
dbConn, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := userdomain.User{
|
||||
ID: 1003,
|
||||
Username: "testuser3",
|
||||
Nickname: "Test User 3",
|
||||
Email: "test3@example.com",
|
||||
IsActive: true,
|
||||
LastLoginAt: time.Now(),
|
||||
}
|
||||
_ = user.SetEncryptedPassword("oldpassword")
|
||||
if err := dbConn.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Override PreRun
|
||||
oldPreRun := resetPasswdCmd.PreRun
|
||||
resetPasswdCmd.PreRun = nil
|
||||
defer func() { resetPasswdCmd.PreRun = oldPreRun }()
|
||||
|
||||
// Mock stdin
|
||||
inR, inW, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer inR.Close()
|
||||
defer inW.Close()
|
||||
|
||||
oldStdin := os.Stdin
|
||||
os.Stdin = inR
|
||||
defer func() { os.Stdin = oldStdin }()
|
||||
|
||||
// Write username to stdin
|
||||
_, _ = inW.WriteString("testuser3\n")
|
||||
inW.Close()
|
||||
|
||||
// Reset flags
|
||||
usernameFlag = ""
|
||||
passwordFlag = ""
|
||||
rootCmd.SetArgs([]string{"reset-passwd"})
|
||||
|
||||
// Capture output
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err = rootCmd.Execute()
|
||||
w.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("command execute failed: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
output := buf.String()
|
||||
|
||||
if !bytes.Contains([]byte(output), []byte("成功重置密码!")) {
|
||||
t.Errorf("expected output to contain success message, got: %s", output)
|
||||
}
|
||||
|
||||
// Verify user password changed in DB
|
||||
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)
|
||||
}
|
||||
if dbUser.CheckPassword("oldpassword") {
|
||||
t.Errorf("expected password to change, but it matches the old one")
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/buildinfo"
|
||||
"github.com/Rain-kl/Wavelet/pkg/config"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"github.com/Rain-kl/Wavelet/pkg/persistence/migrator"
|
||||
"github.com/Rain-kl/Wavelet/pkg/trace"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const traceShutdownTimeout = 10 * time.Second
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "wavelet",
|
||||
PersistentPreRun: func(_ *cobra.Command, _ []string) {
|
||||
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,
|
||||
})
|
||||
trace.Init(trace.Config{
|
||||
AppName: config.Config.App.AppName,
|
||||
SamplingRate: config.Config.Otel.SamplingRate,
|
||||
TracerName: config.Config.Otel.TracerName,
|
||||
})
|
||||
},
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
runMigrations()
|
||||
},
|
||||
PersistentPostRun: func(_ *cobra.Command, _ []string) {
|
||||
shutdownTraceProvider()
|
||||
},
|
||||
Run: func(_ *cobra.Command, args []string) {
|
||||
// 无参数时默认以融合模式启动所有服务
|
||||
allCmd.Run(allCmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
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()
|
||||
trace.Shutdown(ctx)
|
||||
}
|
||||
|
||||
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 校验限制
|
||||
rootCmd.AddCommand(allCmd, apiCmd, workerCmd, schedulerCmd)
|
||||
}
|
||||
|
||||
// Execute 执行根命令
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
log.Fatalf("[CMD] execute failed; %s\n", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var schedulerCmd = &cobra.Command{
|
||||
Use: "scheduler",
|
||||
Short: "wavelet Scheduler",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
app := newWaveletApp(core.ProfileSchedule)
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("[Scheduler] run failed: %v\n", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var workerCmd = &cobra.Command{
|
||||
Use: "worker",
|
||||
Short: "wavelet Worker",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
app := newWaveletApp(core.ProfileWorker)
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("[Worker] run failed: %v\n", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user