refactor(layout): consolidate backend codebase into backend/ package and clean root directory

- Moved cmd/, core/, plugins/, pkg/, downstream/, and main.go into backend/ directory
- Batch updated all Go source files to import github.com/Rain-kl/Wavelet/backend/...
- Updated Makefile, scripts/swagger.sh, architecture guards, and platform skills
- Passed all quality gates (100% tests, 0 lint issues, clean build)
This commit is contained in:
ryan
2026-08-28 12:56:02 +08:00
parent 33b38f8687
commit 43dc97e48c
319 changed files with 912 additions and 1031 deletions
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package user
const (
errInvalidParams = "无效的请求参数"
errUserNotFound = "用户不存在"
//nolint:gosec // error message, not hardcoded credentials
errPasswordMismatch = "用户名或密码错误"
//nolint:gosec // error message, not hardcoded credentials
errOldPasswordIncorrect = "原密码不正确"
//nolint:gosec // error message, not hardcoded credentials
errTokenNotFound = "访问令牌不存在"
)
+336
View File
@@ -0,0 +1,336 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package user
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"time"
database "github.com/Rain-kl/Wavelet/backend/plugins/infra/database"
"github.com/Rain-kl/Wavelet/backend/core/contracts"
"github.com/Rain-kl/Wavelet/backend/pkg/response"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
type loginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type registerRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Email string `json:"email"`
}
type changePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
type updateProfileRequest struct {
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
Bio string `json:"bio"`
Phone string `json:"phone"`
Gender string `json:"gender"`
Website string `json:"website"`
Location string `json:"location"`
}
type createAccessTokenRequest struct {
Name string `json:"name" binding:"required"`
ExpiresAt *time.Time `json:"expires_at"`
IsAdmin bool `json:"is_admin"`
}
func getUserIDFromSession(c *gin.Context) uint64 {
defer func() { _ = recover() }()
session := sessions.Default(c)
val := session.Get(contracts.AuthUserIDKey)
if val == nil {
return 0
}
switch v := val.(type) {
case uint64:
return v
case int64:
if v < 0 {
return 0
}
return uint64(v)
case float64:
if v < 0 {
return 0
}
return uint64(v)
case string:
id, _ := strconv.ParseUint(v, 10, 64)
return id
default:
return 0
}
}
func invalidateUserCache(ctx context.Context, userID uint64) {
// Cache invalidation delegated to AuthService via IoC at plugin Apply time.
_ = ctx
_ = userID
}
func invalidateTokenCache(ctx context.Context, tokenHash string) {
_ = ctx
_ = tokenHash
}
// Login handles username and password authentication.
func Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, errInvalidParams)
return
}
user, err := GetUserByUsername(c.Request.Context(), req.Username)
if err != nil {
response.AbortUnauthorized(c, errPasswordMismatch)
return
}
if !user.CheckPassword(req.Password) {
response.AbortUnauthorized(c, errPasswordMismatch)
return
}
sess := sessions.Default(c)
sess.Set(contracts.AuthUserIDKey, user.ID)
sess.Set(contracts.AuthUserNameKey, user.Username)
_ = sess.Save()
c.JSON(http.StatusOK, response.OK(user))
}
// Register registers a new user.
func Register(c *gin.Context) {
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, errInvalidParams)
return
}
newUser := &User{
Username: req.Username,
Email: req.Email,
IsActive: true,
}
if err := newUser.SetEncryptedPassword(req.Password); err != nil {
response.AbortInternal(c, "密码加密失败")
return
}
gormDB := database.DB(c.Request.Context())
if err := gormDB.Create(newUser).Error; err != nil {
response.AbortBadRequest(c, "创建用户失败: "+err.Error())
return
}
c.JSON(http.StatusOK, response.OK(newUser))
}
// Logout logs out the current session.
func Logout(c *gin.Context) {
sess := sessions.Default(c)
sess.Clear()
_ = sess.Save()
c.JSON(http.StatusOK, response.OKNil())
}
// SendEmailCode sends an email verification code.
func SendEmailCode(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(gin.H{"sent": true}))
}
// ChangePassword changes the current user password.
func ChangePassword(c *gin.Context) {
var req changePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, errInvalidParams)
return
}
userID := getUserIDFromSession(c)
user, err := GetUserByID(c.Request.Context(), userID)
if err != nil {
response.AbortNotFound(c, errUserNotFound)
return
}
if !user.CheckPassword(req.OldPassword) {
response.AbortBadRequest(c, errOldPasswordIncorrect)
return
}
if err := user.SetEncryptedPassword(req.NewPassword); err != nil {
response.AbortInternal(c, "密码更新失败")
return
}
gormDB := database.DB(c.Request.Context())
_ = gormDB.Save(&user)
invalidateUserCache(c.Request.Context(), user.ID)
c.JSON(http.StatusOK, response.OKNil())
}
// UpdateProfile updates profile info.
func UpdateProfile(c *gin.Context) {
var req updateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, errInvalidParams)
return
}
userID := getUserIDFromSession(c)
user, err := GetUserByID(c.Request.Context(), userID)
if err != nil {
response.AbortNotFound(c, errUserNotFound)
return
}
user.Nickname = req.Nickname
user.AvatarURL = req.AvatarURL
user.Bio = req.Bio
user.Phone = req.Phone
user.Gender = req.Gender
user.Website = req.Website
user.Location = req.Location
gormDB := database.DB(c.Request.Context())
_ = gormDB.Save(&user)
invalidateUserCache(c.Request.Context(), user.ID)
c.JSON(http.StatusOK, response.OK(user))
}
// ListAccessTokens lists access tokens for the current user.
func ListAccessTokens(c *gin.Context) {
userID := getUserIDFromSession(c)
var tokens []AccessToken
gormDB := database.DB(c.Request.Context())
_ = gormDB.Where("user_id = ?", userID).Find(&tokens).Error
c.JSON(http.StatusOK, response.OK(tokens))
}
const (
tokenEntropyByteLength = 24
tokenMaskMinLength = 8
)
// CreateAccessToken generates a new access token.
func CreateAccessToken(c *gin.Context) {
var req createAccessTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, errInvalidParams)
return
}
userID := getUserIDFromSession(c)
rawBytes := make([]byte, tokenEntropyByteLength)
_, _ = rand.Read(rawBytes)
rawToken := "wvt_" + hex.EncodeToString(rawBytes)
hash := sha256.Sum256([]byte(rawToken))
tokenHash := hex.EncodeToString(hash[:])
masked := rawToken
if len(rawToken) > tokenMaskMinLength {
masked = rawToken[:4] + "..." + rawToken[len(rawToken)-4:]
}
token := AccessToken{
UserID: userID,
Name: req.Name,
TokenHash: tokenHash,
MaskedToken: masked,
IsAdmin: req.IsAdmin,
}
gormDB := database.DB(c.Request.Context())
if err := gormDB.Create(&token).Error; err != nil {
response.AbortInternal(c, "创建令牌失败")
return
}
c.JSON(http.StatusOK, response.OK(gin.H{
"token": token,
"raw_token": rawToken,
}))
}
// DeleteAccessToken deletes a specific access token.
func DeleteAccessToken(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.AbortBadRequest(c, errInvalidParams)
return
}
userID := getUserIDFromSession(c)
var token AccessToken
gormDB := database.DB(c.Request.Context())
if err := gormDB.Where("id = ? AND user_id = ?", id, userID).First(&token).Error; err != nil {
response.AbortNotFound(c, errTokenNotFound)
return
}
_ = gormDB.Delete(&token)
invalidateTokenCache(c.Request.Context(), token.TokenHash)
c.JSON(http.StatusOK, response.OKNil())
}
// RotateAccessToken rotates an access token value.
func RotateAccessToken(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
response.AbortBadRequest(c, errInvalidParams)
return
}
userID := getUserIDFromSession(c)
var token AccessToken
gormDB := database.DB(c.Request.Context())
if err := gormDB.Where("id = ? AND user_id = ?", id, userID).First(&token).Error; err != nil {
response.AbortNotFound(c, errTokenNotFound)
return
}
invalidateTokenCache(c.Request.Context(), token.TokenHash)
rawBytes := make([]byte, tokenEntropyByteLength)
_, _ = rand.Read(rawBytes)
rawToken := "wvt_" + hex.EncodeToString(rawBytes)
hash := sha256.Sum256([]byte(rawToken))
token.TokenHash = hex.EncodeToString(hash[:])
masked := rawToken
if len(rawToken) > tokenMaskMinLength {
masked = rawToken[:4] + "..." + rawToken[len(rawToken)-4:]
}
token.MaskedToken = masked
_ = gormDB.Save(&token)
c.JSON(http.StatusOK, response.OK(gin.H{
"token": token,
"raw_token": rawToken,
}))
}
@@ -0,0 +1,36 @@
-- +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);
-- Seed system user
INSERT INTO w_users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at)
VALUES (999, 'system', '*', '系统', '', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (username) DO NOTHING;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DELETE FROM w_users WHERE username = 'system';
DROP TABLE IF EXISTS w_users;
-- +goose StatementEnd
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package user
import (
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/backend/pkg/util"
)
// AccessToken 个人访问令牌实体
type AccessToken struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
UserID uint64 `json:"user_id" gorm:"index;not null"`
Name string `json:"name" gorm:"size:128;not null"`
TokenHash string `json:"-" gorm:"size:64;uniqueIndex;not null"`
MaskedToken string `json:"masked_token" gorm:"size:64;not null"`
IsAdmin bool `json:"is_admin" gorm:"default:false"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// TableName 表名
func (AccessToken) TableName() string {
return "w_access_tokens"
}
// User 用户表实体
type User struct {
ID uint64 `json:"id,string" gorm:"primaryKey;not null"`
Username string `json:"username" gorm:"size:64;uniqueIndex"`
Password string `json:"password,omitempty" gorm:"size:255"`
Nickname string `json:"nickname" gorm:"size:255"`
Email string `json:"email" gorm:"size:255;index"`
AvatarURL string `json:"avatar_url" gorm:"size:255"`
IsActive bool `json:"is_active" gorm:"default:true;index"`
IsAdmin bool `json:"is_admin" gorm:"default:false"`
Bio string `json:"bio" gorm:"size:500"`
Phone string `json:"phone" gorm:"size:32"`
Gender string `json:"gender" gorm:"size:16"`
Website string `json:"website" gorm:"size:255"`
Location string `json:"location" gorm:"size:255"`
LastLoginAt time.Time `json:"last_login_at" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
// TableName 表名
func (User) TableName() string {
return "w_users"
}
// SetEncryptedPassword 设置加密密码
func (u *User) SetEncryptedPassword(password string) error {
trimmed := strings.TrimSpace(password)
if trimmed == "" {
return errors.New("password cannot be empty")
}
hash, err := util.HashPassword(trimmed)
if err != nil {
return err
}
u.Password = hash
return nil
}
// CheckPassword 校验密码
func (u *User) CheckPassword(password string) bool {
if u.Password == "" {
util.DummyCheckPassword(password)
return false
}
return util.CheckPasswordHash(u.Password, password)
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// 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/backend/core"
"github.com/Rain-kl/Wavelet/backend/core/contracts"
"github.com/Rain-kl/Wavelet/backend/core/extpoints"
"github.com/gin-gonic/gin"
"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
}
// PluginName 用户插件唯一名称标识
const PluginName = "user"
// Name returns the unique identifier for the user domain plugin.
func (p *Plugin) Name() string {
return PluginName
}
// Manifest returns the plugin metadata.
func (p *Plugin) Manifest() core.Manifest {
return core.Manifest{
Name: PluginName,
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 {
// 0. Resolve auth service for middleware (via IoC, not direct import)
var loginMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
var noTokenMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil {
if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok {
loginMW = mw
}
if mw, ok := authSvc.DisallowTokenAuthMiddleware().(gin.HandlerFunc); ok {
noTokenMW = mw
}
}
// 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", Login)
userGroup.POST("/register", Register)
userGroup.GET("/logout", Logout)
userGroup.POST("/send-email-code", SendEmailCode)
userGroup.POST("/change-password", loginMW, ChangePassword)
userGroup.PUT("/profile", loginMW, UpdateProfile)
// Access Tokens
tokensGroup := userGroup.Group("/access-tokens", loginMW, noTokenMW)
{
tokensGroup.GET("", ListAccessTokens)
tokensGroup.POST("", CreateAccessToken)
tokensGroup.DELETE("/:id", DeleteAccessToken)
tokensGroup.POST("/:id/rotate", RotateAccessToken)
}
}
const defaultUserTaskRetry = 3
// 4. Register Asynq background tasks
ctx.Task().Register("user:send_email_code", func(_ context.Context, _ *asynq.Task) error {
// Asynq background task handler
return nil
}, extpoints.WithTaskRetry(defaultUserTaskRetry))
ctx.Task().Register("user:cleanup_inactive", func(_ context.Context, _ *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
}
+106
View File
@@ -0,0 +1,106 @@
// 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/backend/core"
"github.com/Rain-kl/Wavelet/backend/core/contracts"
"github.com/Rain-kl/Wavelet/backend/plugins/domain/user"
database "github.com/Rain-kl/Wavelet/backend/plugins/infra/database"
)
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(
&user.User{},
&user.AccessToken{},
))
database.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)
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package user
import (
"context"
"strings"
"github.com/Rain-kl/Wavelet/backend/pkg/util"
database "github.com/Rain-kl/Wavelet/backend/plugins/infra/database"
"gorm.io/gorm"
)
// GetUserByID 通过 ID 获取用户
func GetUserByID(ctx context.Context, id uint64) (*User, error) {
var u User
if err := database.DB(ctx).First(&u, id).Error; err != nil {
return nil, err
}
return &u, nil
}
// GetUserByUsername 通过用户名获取用户
func GetUserByUsername(ctx context.Context, username string) (*User, error) {
var u User
if err := database.DB(ctx).Where("username = ?", username).First(&u).Error; err != nil {
return nil, err
}
return &u, nil
}
// GetUserByEmail 通过邮箱获取用户
func GetUserByEmail(ctx context.Context, email string) (*User, error) {
var u User
if err := database.DB(ctx).Where("email = ?", email).First(&u).Error; err != nil {
return nil, err
}
return &u, nil
}
// CreateUser 创建用户
func CreateUser(ctx context.Context, u *User) error {
return database.DB(ctx).Create(u).Error
}
// UpdateUser 更新用户
func UpdateUser(ctx context.Context, u *User) error {
return database.DB(ctx).Save(u).Error
}
// ListUsers 分页查询用户
func ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*User, int64, error) {
db := database.DB(ctx).Model(&User{})
if keyword != "" {
escaped := util.EscapeLike(keyword)
db = db.Where("username LIKE ? ESCAPE '\\' OR nickname LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\'", "%"+escaped+"%", "%"+escaped+"%", "%"+escaped+"%")
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var users []*User
offset := (page - 1) * pageSize
if err := db.Offset(offset).Limit(pageSize).Order("id DESC").Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
// GetAccessTokenByHash 通过 Hash 查询访问令牌
func GetAccessTokenByHash(ctx context.Context, tokenHash string) (*AccessToken, error) {
var token AccessToken
if err := database.DB(ctx).Where("token_hash = ?", tokenHash).First(&token).Error; err != nil {
return nil, err
}
return &token, nil
}
// AdminUserListFilter 包含后台用户列表过滤条件
type AdminUserListFilter struct {
Username string
Keyword string
Page int
PageSize int
}
// ListAdminUsers 获取后台管理用户列表
func ListAdminUsers(ctx context.Context, filter AdminUserListFilter) (int64, []User, error) {
query := database.DB(ctx).Model(&User{})
if filter.Username != "" {
escaped := util.EscapeLike(strings.ToLower(filter.Username))
query = query.Where("LOWER(username) LIKE ? ESCAPE '\\'", "%"+escaped+"%")
}
if filter.Keyword != "" {
escaped := util.EscapeLike(strings.ToLower(filter.Keyword))
query = query.Where("LOWER(username) LIKE ? ESCAPE '\\' OR LOWER(nickname) LIKE ? ESCAPE '\\' OR LOWER(email) LIKE ? ESCAPE '\\'",
"%"+escaped+"%", "%"+escaped+"%", "%"+escaped+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, nil, err
}
var users []User
offset := (filter.Page - 1) * filter.PageSize
if err := query.Order("id DESC").Offset(offset).Limit(filter.PageSize).Find(&users).Error; err != nil {
return 0, nil, err
}
return total, users, nil
}
// UpdateUserActive 更新用户激活状态
func UpdateUserActive(ctx context.Context, id uint64, active bool) error {
return database.DB(ctx).Model(&User{}).Where("id = ?", id).Update("is_active", active).Error
}
// GetActiveUserByID 获取处于激活状态的用户
func GetActiveUserByID(ctx context.Context, id uint64) (*User, error) {
var u User
if err := database.DB(ctx).Where("id = ? AND is_active = ?", id, true).First(&u).Error; err != nil {
return nil, err
}
return &u, nil
}
// DeleteUserWithRelations 删除用户及其级联关系
func DeleteUserWithRelations(ctx context.Context, id uint64) error {
return database.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", id).Delete(&AccessToken{}).Error; err != nil {
return err
}
return tx.Where("id = ?", id).Delete(&User{}).Error
})
}
// GetFirstAdminUser 获取第一个管理员用户
func GetFirstAdminUser(ctx context.Context) (*User, error) {
var u User
if err := database.DB(ctx).Where("is_admin = ?", true).Order("id ASC").First(&u).Error; err != nil {
return nil, err
}
return &u, nil
}
// ListUsernamesMatchingBase 列出匹配基础用户名的所有用户名
func ListUsernamesMatchingBase(ctx context.Context, base string) ([]string, error) {
var usernames []string
escaped := util.EscapeLike(strings.ToLower(base))
if err := database.DB(ctx).Model(&User{}).
Where("LOWER(username) LIKE ? ESCAPE '\\'", escaped+"%").
Pluck("username", &usernames).Error; err != nil {
return nil, err
}
return usernames, nil
}
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package user provides user profiles, credentials, role management, and access token domain services.
package user
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/Rain-kl/Wavelet/backend/core/contracts"
"github.com/Rain-kl/Wavelet/backend/pkg/idgen"
database "github.com/Rain-kl/Wavelet/backend/plugins/infra/database"
pkgu "github.com/Rain-kl/Wavelet/backend/pkg/util"
)
func toUserDTO(u *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 := 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 := 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 User
if err := database.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 := 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 := 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 := database.DB(ctx).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 User
if err := database.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 database.DB(ctx).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 User
if err := database.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, _ string) error {
return database.DB(ctx).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 := AdminUserListFilter{
Username: keyword,
Page: page,
PageSize: pageSize,
}
total, users, err := 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 UpdateUserActive(ctx, id, active)
}
func (s *userServiceImpl) SetUserAdmin(ctx context.Context, id uint64, admin bool) error {
return database.DB(ctx).Model(&User{}).Where("id = ?", id).Update("is_admin", admin).Error
}
func (s *userServiceImpl) VerifyAccessToken(ctx context.Context, tokenHash string) (*contracts.UserDTO, bool, error) {
tokenRecord, err := GetAccessTokenByHash(ctx, tokenHash)
if err != nil {
return nil, false, err
}
user, err := GetActiveUserByID(ctx, tokenRecord.UserID)
if err != nil {
return nil, false, err
}
return toUserDTO(user), tokenRecord.IsAdmin, nil
}
func (s *userServiceImpl) DeleteUser(ctx context.Context, id uint64) error {
return DeleteUserWithRelations(ctx, id)
}
func (s *userServiceImpl) CountUsers(ctx context.Context) (int64, error) {
var count int64
err := database.DB(ctx).Model(&User{}).Count(&count).Error
return count, err
}
func (s *userServiceImpl) CountActiveUsers(ctx context.Context) (int64, error) {
var count int64
err := database.DB(ctx).Model(&User{}).Where("is_active = ?", true).Count(&count).Error
return count, err
}
func (s *userServiceImpl) GetFirstAdminUser(ctx context.Context) (*contracts.UserDTO, error) {
u, err := GetFirstAdminUser(ctx)
if err != nil {
return nil, err
}
return toUserDTO(u), nil
}
func (s *userServiceImpl) UniqueUsername(ctx context.Context, base string) (string, error) {
base = strings.TrimSpace(base)
if base == "" {
base = PluginName
}
existingUsernames, err := ListUsernamesMatchingBase(ctx, base)
if err != nil {
return "", err
}
exists := make(map[string]bool, len(existingUsernames))
for _, u := range existingUsernames {
exists[strings.ToLower(u)] = true
}
if !exists[strings.ToLower(base)] {
return base, nil
}
for i := 1; i <= 1000; i++ {
candidate := fmt.Sprintf("%s-%d", base, i)
if !exists[strings.ToLower(candidate)] {
return candidate, nil
}
}
return "", errors.New("failed to generate unique username")
}