feat(plugins): migrate auth, user, message_gateway, risk_control, admin to domain plugins

This commit is contained in:
ryan
2026-08-27 23:59:38 +08:00
parent 75f226cfbf
commit a4c3f70f0b
18 changed files with 2082 additions and 0 deletions
@@ -0,0 +1,44 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS w_users (
id BIGINT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(255),
nickname VARCHAR(255),
email VARCHAR(255),
avatar_url VARCHAR(255),
is_active BOOLEAN DEFAULT TRUE,
is_admin BOOLEAN DEFAULT FALSE,
bio VARCHAR(500),
phone VARCHAR(32),
gender VARCHAR(16),
website VARCHAR(255),
location VARCHAR(255),
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_users_email ON w_users (email);
CREATE INDEX IF NOT EXISTS idx_w_users_is_active ON w_users (is_active);
CREATE INDEX IF NOT EXISTS idx_w_users_last_login_at ON w_users (last_login_at);
CREATE INDEX IF NOT EXISTS idx_w_users_created_at ON w_users (created_at);
CREATE TABLE IF NOT EXISTS w_access_tokens (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
token_hash VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(128) NOT NULL,
description VARCHAR(255),
is_admin BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_w_access_tokens_user_id ON w_access_tokens (user_id);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS w_access_tokens;
DROP TABLE IF EXISTS w_users;
-- +goose StatementEnd
+130
View File
@@ -0,0 +1,130 @@
// Package user provides the user profile, credential management, role management, and access token domain plugin for Cordis.
package user
import (
"context"
"embed"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/core/extpoints"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/apps/user"
"github.com/hibiken/asynq"
)
//go:embed migrations/*.sql
var userMigrations embed.FS
// Option configures the user plugin.
type Option func(*Plugin)
// WithUserService sets a custom UserService implementation.
func WithUserService(svc contracts.UserService) Option {
return func(p *Plugin) {
p.userSvc = svc
}
}
// Plugin implements core.Plugin to provide user account and credential domain services.
type Plugin struct {
userSvc contracts.UserService
}
// New creates a new user domain plugin.
func New(opts ...Option) *Plugin {
p := &Plugin{}
for _, opt := range opts {
if opt != nil {
opt(p)
}
}
return p
}
// Name returns the unique identifier for the user domain plugin.
func (p *Plugin) Name() string {
return "user"
}
// Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{
Name: "user",
Version: "1.0.0",
Description: "User profiles, credentials, role management, and access token domain plugin",
Author: "Wavelet Team",
}
}
// Apply registers user migrations, services, routes, tasks, schedules, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
// 1. Register migrations
ctx.Migrations().Register("user", userMigrations)
// 2. Initialize and provide UserService
if p.userSvc == nil {
p.userSvc = newUserService()
}
core.Provide[contracts.UserService](ctx, p.userSvc)
// 3. Register HTTP Routes
userGroup := ctx.Router().Group("/api/v1/user")
{
userGroup.POST("/login", user.Login)
userGroup.POST("/register", user.Register)
userGroup.GET("/logout", user.Logout)
userGroup.POST("/send-email-code", user.SendEmailCode)
userGroup.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
userGroup.PUT("/profile", oauth.LoginRequired(), user.UpdateProfile)
// Access Tokens
tokensGroup := userGroup.Group("/access-tokens", oauth.LoginRequired(), oauth.DisallowTokenAuth())
{
tokensGroup.GET("", user.ListAccessTokens)
tokensGroup.POST("", user.CreateAccessToken)
tokensGroup.DELETE("/:id", user.DeleteAccessToken)
tokensGroup.POST("/:id/rotate", user.RotateAccessToken)
}
}
// 4. Register Asynq background tasks
ctx.Task().Register("user:send_email_code", func(c context.Context, t *asynq.Task) error {
// Asynq background task handler
return nil
}, extpoints.WithTaskRetry(3))
ctx.Task().Register("user:cleanup_inactive", func(c context.Context, t *asynq.Task) error {
return nil
})
// 5. Register Cron Schedules
ctx.Schedule().RegisterCron("0 3 * * *", "user:daily_audit", map[string]string{"type": "audit"})
// 6. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "user.registration_enabled",
Default: true,
Description: "Whether new user registration is enabled",
Type: "boolean",
Category: "general",
Public: true,
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "user.password_login_enabled",
Default: true,
Description: "Whether password login is enabled",
Type: "boolean",
Category: "general",
Public: true,
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "user.min_password_length",
Default: 8,
Description: "Minimum password length required for user accounts",
Type: "integer",
Category: "security",
})
return nil
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package user_test
import (
"context"
"path/filepath"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/plugins/domain/user"
)
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "user_test.db")
testDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, testDB.AutoMigrate(
&model.User{},
&model.AccessToken{},
))
db.SetDB(testDB)
return testDB
}
func TestUserPluginUnit(t *testing.T) {
ctx := core.NewContext(context.Background())
_ = setupTestDB(t)
p := user.New()
assert.Equal(t, "user", p.Name())
assert.Equal(t, "1.0.0", p.Manifest().Version)
require.NoError(t, p.Apply(ctx))
userSvc, err := core.Inject[contracts.UserService](ctx)
require.NoError(t, err)
require.NotNil(t, userSvc)
testCtx := context.Background()
// 1. Create User
u, err := userSvc.CreateUser(testCtx, contracts.CreateUserRequest{
Username: "charlie",
Password: "Password789!",
Email: "charlie@example.com",
})
require.NoError(t, err)
assert.Equal(t, "charlie", u.Username)
// 2. Empty username error
_, err = userSvc.CreateUser(testCtx, contracts.CreateUserRequest{})
assert.Error(t, err)
// 3. Verify Password
assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "Password789!"))
assert.False(t, userSvc.VerifyPassword(testCtx, u.ID, "Wrong"))
// 4. Update Password with wrong old password
err = userSvc.UpdatePassword(testCtx, u.ID, "WrongOld", "NewPass999!")
assert.Error(t, err)
// Update Password success
err = userSvc.UpdatePassword(testCtx, u.ID, "Password789!", "NewPass999!")
require.NoError(t, err)
assert.True(t, userSvc.VerifyPassword(testCtx, u.ID, "NewPass999!"))
// 5. Update Profile
nickname := "Charlie Brown"
email := "charlie.new@example.com"
gender := "male"
website := "https://charlie.me"
loc := "SF"
updated, err := userSvc.UpdateProfile(testCtx, u.ID, contracts.UpdateUserProfileRequest{
Nickname: &nickname,
Email: &email,
Gender: &gender,
Website: &website,
Location: &loc,
})
require.NoError(t, err)
assert.Equal(t, "Charlie Brown", updated.Nickname)
assert.Equal(t, "charlie.new@example.com", updated.Email)
assert.Equal(t, "male", updated.Gender)
assert.Equal(t, "https://charlie.me", updated.Website)
assert.Equal(t, "SF", updated.Location)
// 6. List and Status
require.NoError(t, userSvc.SetUserAdmin(testCtx, u.ID, true))
require.NoError(t, userSvc.SetUserActive(testCtx, u.ID, true))
list, total, err := userSvc.ListUsers(testCtx, 1, 10, "")
require.NoError(t, err)
assert.GreaterOrEqual(t, total, int64(1))
assert.NotEmpty(t, list)
}
+210
View File
@@ -0,0 +1,210 @@
// Package user provides user profiles, credentials, role management, and access token domain services.
package user
import (
"context"
"errors"
"time"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
pkgu "github.com/Rain-kl/Wavelet/pkg/util"
)
func toUserDTO(u *model.User) *contracts.UserDTO {
if u == nil {
return nil
}
return &contracts.UserDTO{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Email: u.Email,
AvatarURL: u.AvatarURL,
IsActive: u.IsActive,
IsAdmin: u.IsAdmin,
Bio: u.Bio,
Phone: u.Phone,
Gender: u.Gender,
Website: u.Website,
Location: u.Location,
LastLoginAt: u.LastLoginAt,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
type userServiceImpl struct{}
func newUserService() contracts.UserService {
return &userServiceImpl{}
}
func (s *userServiceImpl) GetUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
u, err := repository.GetUserByID(ctx, id)
if err != nil {
return nil, err
}
return toUserDTO(&u), nil
}
func (s *userServiceImpl) GetUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) {
u, err := repository.GetUserByUsername(ctx, username)
if err != nil {
return nil, err
}
return toUserDTO(&u), nil
}
func (s *userServiceImpl) GetUserByEmail(ctx context.Context, email string) (*contracts.UserDTO, error) {
var u model.User
if err := db.DB(ctx).Where("email = ?", email).First(&u).Error; err != nil {
return nil, err
}
return toUserDTO(&u), nil
}
func (s *userServiceImpl) CreateUser(ctx context.Context, req contracts.CreateUserRequest) (*contracts.UserDTO, error) {
if req.Username == "" {
return nil, errors.New("user: username cannot be empty")
}
user := model.User{
ID: idgen.NextUint64ID(),
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
IsActive: true,
IsAdmin: req.IsAdmin,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
LastLoginAt: time.Now(),
}
if user.Nickname == "" {
user.Nickname = req.Username
}
if req.Password != "" {
if err := user.SetEncryptedPassword(req.Password); err != nil {
return nil, err
}
}
if err := repository.CreateUser(ctx, &user); err != nil {
return nil, err
}
return toUserDTO(&user), nil
}
func (s *userServiceImpl) UpdateProfile(ctx context.Context, id uint64, req contracts.UpdateUserProfileRequest) (*contracts.UserDTO, error) {
updates := make(map[string]any)
if req.Nickname != nil {
updates["nickname"] = *req.Nickname
}
if req.Email != nil {
updates["email"] = *req.Email
}
if req.AvatarURL != nil {
updates["avatar_url"] = *req.AvatarURL
}
if req.Bio != nil {
updates["bio"] = *req.Bio
}
if req.Phone != nil {
updates["phone"] = *req.Phone
}
if req.Gender != nil {
updates["gender"] = *req.Gender
}
if req.Website != nil {
updates["website"] = *req.Website
}
if req.Location != nil {
updates["location"] = *req.Location
}
updates["updated_at"] = time.Now()
if err := db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return nil, err
}
return s.GetUserByID(ctx, id)
}
func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error {
var user model.User
if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil {
return err
}
if !user.CheckPassword(oldPassword) {
return errors.New("user: incorrect old password")
}
if err := user.SetEncryptedPassword(newPassword); err != nil {
return err
}
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).
Updates(map[string]any{
"password": user.Password,
"updated_at": time.Now(),
}).Error
}
func (s *userServiceImpl) VerifyPassword(ctx context.Context, id uint64, password string) bool {
var user model.User
if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil {
pkgu.DummyCheckPassword(password)
return false
}
return user.CheckPassword(password)
}
func (s *userServiceImpl) UpdateLastLogin(ctx context.Context, id uint64, ip string) error {
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).
Updates(map[string]any{
"last_login_at": time.Now(),
"updated_at": time.Now(),
}).Error
}
func (s *userServiceImpl) ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*contracts.UserDTO, int64, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
filter := repository.AdminUserListFilter{
Username: keyword,
Page: page,
PageSize: pageSize,
}
total, users, err := repository.ListAdminUsers(ctx, filter)
if err != nil {
return nil, 0, err
}
dtos := make([]*contracts.UserDTO, 0, len(users))
for i := range users {
dtos = append(dtos, toUserDTO(&users[i]))
}
return dtos, total, nil
}
func (s *userServiceImpl) SetUserActive(ctx context.Context, id uint64, active bool) error {
return repository.UpdateUserActive(ctx, id, active)
}
func (s *userServiceImpl) SetUserAdmin(ctx context.Context, id uint64, admin bool) error {
return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Update("is_admin", admin).Error
}