mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 23:06:36 +08:00
refactor(core): align architecture with cordis spatiotemporal composability
This commit is contained in:
@@ -7,32 +7,24 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"Wavelet/pkg/response"
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/infra/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/response"
|
||||
)
|
||||
|
||||
// ListAuthSources lists all configured authentication sources.
|
||||
func ListAuthSources(c *gin.Context) {
|
||||
var sources []auth.AuthSource
|
||||
gormDB := database.DB(c.Request.Context())
|
||||
if err := gormDB.Order("id ASC").Find(&sources).Error; err != nil {
|
||||
response.AbortInternal(c, "获取认证源列表失败")
|
||||
authSvc := getAuthService(c.Request.Context())
|
||||
if authSvc == nil {
|
||||
response.AbortInternal(c, "认证服务未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
views := make([]auth.AuthSourceView, len(sources))
|
||||
for i := range sources {
|
||||
views[i] = auth.AuthSourceView{
|
||||
ID: sources[i].ID,
|
||||
Name: sources[i].Name,
|
||||
Type: sources[i].Type,
|
||||
DisplayName: sources[i].DisplayName,
|
||||
IsActive: sources[i].IsActive,
|
||||
IconURL: sources[i].IconURL,
|
||||
ClientSecretConfigured: sources[i].ClientSecret != "",
|
||||
}
|
||||
views, err := authSvc.ListAuthSources(c.Request.Context())
|
||||
if err != nil {
|
||||
response.AbortInternal(c, "获取认证源列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(views))
|
||||
@@ -40,25 +32,25 @@ func ListAuthSources(c *gin.Context) {
|
||||
|
||||
// CreateAuthSource creates a new authentication source.
|
||||
func CreateAuthSource(c *gin.Context) {
|
||||
var source auth.AuthSource
|
||||
var source contracts.AuthSourceDTO
|
||||
if err := c.ShouldBindJSON(&source); err != nil {
|
||||
response.AbortBadRequest(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
|
||||
if err := source.Validate(); err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
authSvc := getAuthService(c.Request.Context())
|
||||
if authSvc == nil {
|
||||
response.AbortInternal(c, "认证服务未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
gormDB := database.DB(c.Request.Context())
|
||||
if err := gormDB.Create(&source).Error; err != nil {
|
||||
created, err := authSvc.CreateAuthSource(c.Request.Context(), source)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, "创建认证源失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
source.Sanitize()
|
||||
c.JSON(http.StatusOK, response.OK(source))
|
||||
c.JSON(http.StatusOK, response.OK(created))
|
||||
}
|
||||
|
||||
// UpdateAuthSource updates an authentication source.
|
||||
@@ -70,40 +62,25 @@ func UpdateAuthSource(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
gormDB := database.DB(c.Request.Context())
|
||||
var existing auth.AuthSource
|
||||
if err := gormDB.First(&existing, id).Error; err != nil {
|
||||
response.AbortNotFound(c, "认证源不存在")
|
||||
return
|
||||
}
|
||||
|
||||
var req auth.AuthSource
|
||||
var req contracts.AuthSourceDTO
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
|
||||
existing.DisplayName = req.DisplayName
|
||||
existing.ClientID = req.ClientID
|
||||
if req.ClientSecret != "" {
|
||||
existing.ClientSecret = req.ClientSecret
|
||||
authSvc := getAuthService(c.Request.Context())
|
||||
if authSvc == nil {
|
||||
response.AbortInternal(c, "认证服务未就绪")
|
||||
return
|
||||
}
|
||||
existing.OpenIDDiscoveryURL = req.OpenIDDiscoveryURL
|
||||
existing.Scopes = req.Scopes
|
||||
existing.IconURL = req.IconURL
|
||||
|
||||
if err := existing.Validate(); err != nil {
|
||||
updated, err := authSvc.UpdateAuthSource(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := gormDB.Save(&existing).Error; err != nil {
|
||||
response.AbortInternal(c, "更新认证源失败")
|
||||
return
|
||||
}
|
||||
|
||||
existing.Sanitize()
|
||||
c.JSON(http.StatusOK, response.OK(existing))
|
||||
c.JSON(http.StatusOK, response.OK(updated))
|
||||
}
|
||||
|
||||
// ToggleAuthSource toggles the active state of an auth source.
|
||||
@@ -115,27 +92,19 @@ func ToggleAuthSource(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
gormDB := database.DB(c.Request.Context())
|
||||
var existing auth.AuthSource
|
||||
if err := gormDB.First(&existing, id).Error; err != nil {
|
||||
response.AbortNotFound(c, "认证源不存在")
|
||||
authSvc := getAuthService(c.Request.Context())
|
||||
if authSvc == nil {
|
||||
response.AbortInternal(c, "认证服务未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
existing.IsActive = !existing.IsActive
|
||||
if existing.IsActive {
|
||||
if err := existing.Validate(); err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := gormDB.Model(&existing).Update("is_active", existing.IsActive).Error; err != nil {
|
||||
response.AbortInternal(c, "切换认证源状态失败")
|
||||
toggled, err := authSvc.ToggleAuthSource(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.AbortInternal(c, "切换认证源状态失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(gin.H{"is_active": existing.IsActive}))
|
||||
c.JSON(http.StatusOK, response.OK(gin.H{"is_active": toggled.IsActive}))
|
||||
}
|
||||
|
||||
// DeleteAuthSource deletes an authentication source.
|
||||
@@ -147,9 +116,14 @@ func DeleteAuthSource(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
gormDB := database.DB(c.Request.Context())
|
||||
if err := gormDB.Delete(&auth.AuthSource{}, id).Error; err != nil {
|
||||
response.AbortInternal(c, "删除认证源失败")
|
||||
authSvc := getAuthService(c.Request.Context())
|
||||
if authSvc == nil {
|
||||
response.AbortInternal(c, "认证服务未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
if err := authSvc.DeleteAuthSource(c.Request.Context(), id); err != nil {
|
||||
response.AbortInternal(c, "删除认证源失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/logger"
|
||||
mail "Wavelet/pkg/mail"
|
||||
"Wavelet/pkg/response"
|
||||
"Wavelet/plugins/domain/cap"
|
||||
cachepkg "Wavelet/plugins/infra/cache"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
"Wavelet/plugins/infra/storage/objectstore"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -341,8 +340,8 @@ func invalidateSystemConfigCaches(ctx context.Context, key string) {
|
||||
if err := InvalidateSystemConfigCache(ctx, key); err != nil {
|
||||
logger.WarnF(ctx, "清理系统配置缓存失败: %v", err)
|
||||
}
|
||||
if cap.IsRuntimeConfigKey(key) {
|
||||
cap.InvalidateRuntimeSettings()
|
||||
if globalCoreCtx != nil {
|
||||
_ = globalCoreCtx.Events().Emit(ctx, contracts.EventTopicConfigChanged, contracts.ConfigChangedEvent{Key: key})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,17 +349,9 @@ func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
|
||||
invalidateSystemConfigCaches(ctx, key)
|
||||
|
||||
if key == ConfigKeyStorageConfig {
|
||||
if cachepkg.Redis != nil {
|
||||
_ = cachepkg.Redis.Publish(ctx, "upload:access_cache:invalidate", "reset").Err()
|
||||
}
|
||||
objectstore.ResetCache()
|
||||
objectstore.PublishCacheInvalidation(ctx)
|
||||
}
|
||||
if key == ConfigKeyFileAccessWhitelist {
|
||||
if cachepkg.Redis != nil {
|
||||
_ = cachepkg.Redis.Publish(ctx, "upload:access_cache:invalidate", "reset").Err()
|
||||
}
|
||||
}
|
||||
|
||||
if err := InvalidateVisibleSystemConfigsCache(ctx); err != nil {
|
||||
logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err)
|
||||
|
||||
@@ -4,27 +4,20 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/response"
|
||||
"Wavelet/pkg/util"
|
||||
"Wavelet/plugins/domain/auth"
|
||||
db "Wavelet/plugins/infra/database"
|
||||
)
|
||||
|
||||
const minPasswordLength = 8
|
||||
|
||||
// listUsersRequest 用户列表查询请求
|
||||
type listUsersRequest struct {
|
||||
Page int `form:"page" binding:"min=1"`
|
||||
@@ -136,7 +129,19 @@ func ListUsers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
total, dtos, err := listUsers(c.Request.Context(), req)
|
||||
userSvc := getUserService(c.Request.Context())
|
||||
if userSvc == nil {
|
||||
response.AbortInternal(c, "用户服务未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
total, dtos, err := userSvc.AdminListUsers(c.Request.Context(), contracts.AdminListUsersFilter{
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
UserID: req.UserID,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "List admin users failed: %v", err)
|
||||
response.AbortInternal(c, "获取用户列表失败")
|
||||
@@ -174,7 +179,13 @@ func GetUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
targetUser, err := getUserDetail(c.Request.Context(), id)
|
||||
userSvc := getUserService(c.Request.Context())
|
||||
if userSvc == nil {
|
||||
response.AbortInternal(c, "用户服务未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
targetUser, err := userSvc.AdminGetUser(c.Request.Context(), id)
|
||||
if abortUserLogicError(c, err, userNotFound, nil, nil) {
|
||||
return
|
||||
}
|
||||
@@ -215,7 +226,13 @@ func UpdateUserStatus(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := updateUserStatus(c.Request.Context(), id, req.IsActive); err != nil {
|
||||
userSvc := getUserService(c.Request.Context())
|
||||
if userSvc == nil {
|
||||
response.AbortInternal(c, "用户服务未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
if err := userSvc.AdminUpdateUserStatus(c.Request.Context(), id, req.IsActive); err != nil {
|
||||
if abortUserLogicError(c, err, userNotFound, []string{cannotDisable}, nil) {
|
||||
return
|
||||
}
|
||||
@@ -251,7 +268,14 @@ func DeleteUser(c *gin.Context) {
|
||||
response.AbortUnauthorized(c, AdminRequired)
|
||||
return
|
||||
}
|
||||
if err := deleteUser(c.Request.Context(), currUser.ID, id); err != nil {
|
||||
|
||||
userSvc := getUserService(c.Request.Context())
|
||||
if userSvc == nil {
|
||||
response.AbortInternal(c, "用户服务未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
if err := userSvc.AdminDeleteUser(c.Request.Context(), currUser.ID, id); err != nil {
|
||||
if abortUserLogicError(c, err, userNotFound, []string{cannotDelete, cannotDeleteSelf}, nil) {
|
||||
return
|
||||
}
|
||||
@@ -293,7 +317,20 @@ func CreateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
newUser, err := createUser(c.Request.Context(), req)
|
||||
userSvc := getUserService(c.Request.Context())
|
||||
if userSvc == nil {
|
||||
response.AbortInternal(c, "用户服务未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
newUser, err := userSvc.AdminCreateUser(c.Request.Context(), contracts.AdminCreateUserRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
IsActive: req.IsActive,
|
||||
IsAdmin: req.IsAdmin,
|
||||
})
|
||||
if abortUserLogicError(c, err, "", nil, []string{usernameRequired, emailRequired, passwordTooShort, usernameExists, emailExists}) {
|
||||
return
|
||||
}
|
||||
@@ -342,7 +379,14 @@ func UpdateUser(c *gin.Context) {
|
||||
response.AbortUnauthorized(c, AdminRequired)
|
||||
return
|
||||
}
|
||||
err := updateUser(c.Request.Context(), currUser.ID, updateUserParam{
|
||||
|
||||
userSvc := getUserService(c.Request.Context())
|
||||
if userSvc == nil {
|
||||
response.AbortInternal(c, "用户服务未就绪")
|
||||
return
|
||||
}
|
||||
|
||||
err := userSvc.AdminUpdateUser(c.Request.Context(), currUser.ID, contracts.AdminUpdateUserRequest{
|
||||
ID: id,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
@@ -360,255 +404,3 @@ func UpdateUser(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
}
|
||||
|
||||
func listUsers(ctx context.Context, req listUsersRequest) (int64, []*contracts.UserDTO, error) {
|
||||
query := db.DB(ctx).Table("w_users")
|
||||
if req.UserID != nil {
|
||||
query = query.Where("id = ?", *req.UserID)
|
||||
}
|
||||
if req.Username != "" {
|
||||
query = query.Where("username LIKE ? ESCAPE '\\'", util.EscapeLike(req.Username)+"%")
|
||||
}
|
||||
if req.Email != "" {
|
||||
query = query.Where("email LIKE ? ESCAPE '\\'", util.EscapeLike(req.Email)+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
var users []*contracts.UserDTO
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := query.
|
||||
Select("id, username, nickname, email, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at").
|
||||
Order("id ASC").
|
||||
Offset(offset).
|
||||
Limit(req.PageSize).
|
||||
Find(&users).Error; err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return total, users, nil
|
||||
}
|
||||
|
||||
func getUserDetail(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
|
||||
var user contracts.UserDTO
|
||||
if err := db.DB(ctx).Table("w_users").
|
||||
Select("id, username, nickname, email, avatar_url, is_active, is_admin, bio, phone, gender, website, location, last_login_at, created_at, updated_at").
|
||||
Where("id = ?", id).
|
||||
First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func updateUserStatus(ctx context.Context, id uint64, active bool) error {
|
||||
var flags struct {
|
||||
ID uint64
|
||||
IsAdmin bool
|
||||
}
|
||||
if err := db.DB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", id).First(&flags).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !active && flags.IsAdmin {
|
||||
return errors.New(cannotDisable)
|
||||
}
|
||||
|
||||
var tokenHashes []string
|
||||
if !active {
|
||||
_ = db.DB(ctx).Table("w_access_tokens").Where("user_id = ?", id).Pluck("token_hash", &tokenHashes).Error
|
||||
}
|
||||
|
||||
err := db.DB(ctx).Table("w_users").Where("id = ?", id).Update("is_active", active).Error
|
||||
if err == nil {
|
||||
auth.InvalidateCachedUser(ctx, id)
|
||||
if !active {
|
||||
for _, hash := range tokenHashes {
|
||||
auth.InvalidateCachedToken(ctx, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteUser(ctx context.Context, currentUserID, targetID uint64) error {
|
||||
if currentUserID == targetID {
|
||||
return errors.New(cannotDeleteSelf)
|
||||
}
|
||||
var flags struct {
|
||||
ID uint64
|
||||
IsAdmin bool
|
||||
}
|
||||
if err := db.DB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", targetID).First(&flags).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.IsAdmin {
|
||||
return errors.New(cannotDelete)
|
||||
}
|
||||
|
||||
var tokenHashes []string
|
||||
_ = db.DB(ctx).Table("w_access_tokens").Where("user_id = ?", targetID).Pluck("token_hash", &tokenHashes).Error
|
||||
|
||||
err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Table("w_access_tokens").Where("user_id = ?", targetID).Delete(map[string]any{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Table("w_external_accounts").Where("user_id = ?", targetID).Delete(map[string]any{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Table("w_users").Where("id = ?", targetID).Delete(map[string]any{}).Error
|
||||
})
|
||||
if err == nil {
|
||||
auth.InvalidateCachedUser(ctx, targetID)
|
||||
for _, hash := range tokenHashes {
|
||||
auth.InvalidateCachedToken(ctx, hash)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func createUser(ctx context.Context, req createUserRequest) (*contracts.UserDTO, error) {
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
req.Nickname = strings.TrimSpace(req.Nickname)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
|
||||
if req.Username == "" {
|
||||
return nil, errors.New(usernameRequired)
|
||||
}
|
||||
if req.Email == "" {
|
||||
return nil, errors.New(emailRequired)
|
||||
}
|
||||
if len(req.Password) < minPasswordLength {
|
||||
return nil, errors.New(passwordTooShort)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.DB(ctx).Table("w_users").Where("username = ?", req.Username).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, errors.New(usernameExists)
|
||||
}
|
||||
|
||||
var emailCount int64
|
||||
if err := db.DB(ctx).Table("w_users").Where("email = ?", req.Email).Count(&emailCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if emailCount > 0 {
|
||||
return nil, errors.New(emailExists)
|
||||
}
|
||||
|
||||
hash, err := util.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Nickname == "" {
|
||||
req.Nickname = req.Username
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
newUser := contracts.UserDTO{
|
||||
ID: idgen.NextUint64ID(),
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
IsActive: req.IsActive,
|
||||
IsAdmin: req.IsAdmin,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
row := map[string]any{
|
||||
"id": newUser.ID,
|
||||
"username": newUser.Username,
|
||||
"password": hash,
|
||||
"nickname": newUser.Nickname,
|
||||
"email": newUser.Email,
|
||||
"is_active": newUser.IsActive,
|
||||
"is_admin": newUser.IsAdmin,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if err := db.DB(ctx).Table("w_users").Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &newUser, nil
|
||||
}
|
||||
|
||||
type updateUserParam struct {
|
||||
ID uint64
|
||||
Nickname string
|
||||
Email string
|
||||
IsAdmin bool
|
||||
Password string
|
||||
}
|
||||
|
||||
func updateUser(ctx context.Context, currentUserID uint64, param updateUserParam) error {
|
||||
param.Nickname = strings.TrimSpace(param.Nickname)
|
||||
param.Email = strings.TrimSpace(param.Email)
|
||||
param.Password = strings.TrimSpace(param.Password)
|
||||
|
||||
if param.Email == "" {
|
||||
return errors.New(emailRequired)
|
||||
}
|
||||
|
||||
var targetUser contracts.UserDTO
|
||||
if err := db.DB(ctx).Table("w_users").Where("id = ?", param.ID).First(&targetUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if currentUserID == param.ID && !param.IsAdmin && targetUser.IsAdmin {
|
||||
return errors.New(cannotRevokeSelfAdmin)
|
||||
}
|
||||
|
||||
if targetUser.Email != param.Email {
|
||||
var count int64
|
||||
if err := db.DB(ctx).Table("w_users").Where("email = ? AND id != ?", param.Email, param.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(emailExists)
|
||||
}
|
||||
}
|
||||
|
||||
if param.Password != "" && len(param.Password) < minPasswordLength {
|
||||
return errors.New(passwordTooShort)
|
||||
}
|
||||
|
||||
needRevokeTokens := (param.Password != "") || (targetUser.IsAdmin && !param.IsAdmin)
|
||||
var tokenHashes []string
|
||||
if needRevokeTokens {
|
||||
_ = db.DB(ctx).Table("w_access_tokens").Where("user_id = ?", param.ID).Pluck("token_hash", &tokenHashes).Error
|
||||
}
|
||||
|
||||
if param.Nickname == "" {
|
||||
param.Nickname = targetUser.Username
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"nickname": param.Nickname,
|
||||
"email": param.Email,
|
||||
"is_admin": param.IsAdmin,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if param.Password != "" {
|
||||
hash, err := util.HashPassword(param.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates["password"] = hash
|
||||
}
|
||||
|
||||
err := db.DB(ctx).Table("w_users").Where("id = ?", param.ID).Updates(updates).Error
|
||||
if err == nil {
|
||||
auth.InvalidateCachedUser(ctx, param.ID)
|
||||
if needRevokeTokens {
|
||||
for _, hash := range tokenHashes {
|
||||
auth.InvalidateCachedToken(ctx, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -50,18 +50,65 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
globalUserSvc contracts.UserService
|
||||
globalAuthSvc contracts.AuthService
|
||||
globalCoreCtx *core.Context
|
||||
)
|
||||
|
||||
func getUserService(_ context.Context) contracts.UserService {
|
||||
if globalUserSvc != nil {
|
||||
return globalUserSvc
|
||||
}
|
||||
if globalCoreCtx != nil {
|
||||
if svc, err := core.Inject[contracts.UserService](globalCoreCtx); err == nil {
|
||||
globalUserSvc = svc
|
||||
return svc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAuthService(_ context.Context) contracts.AuthService {
|
||||
if globalAuthSvc != nil {
|
||||
return globalAuthSvc
|
||||
}
|
||||
if globalCoreCtx != nil {
|
||||
if svc, err := core.Inject[contracts.AuthService](globalCoreCtx); err == nil {
|
||||
globalAuthSvc = svc
|
||||
return svc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply registers admin 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)
|
||||
globalCoreCtx = ctx
|
||||
|
||||
// 0. Resolve auth and user services reactively via IoC
|
||||
var loginMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
|
||||
var adminMW gin.HandlerFunc = func(c *gin.Context) { c.Next() }
|
||||
if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil {
|
||||
globalAuthSvc = authSvc
|
||||
if mw, ok := authSvc.RequireAuthMiddleware().(gin.HandlerFunc); ok {
|
||||
loginMW = mw
|
||||
}
|
||||
if mw, ok := authSvc.RequireAdminMiddleware().(gin.HandlerFunc); ok {
|
||||
adminMW = mw
|
||||
}
|
||||
} else {
|
||||
core.When[contracts.AuthService](ctx, func(svc contracts.AuthService) {
|
||||
globalAuthSvc = svc
|
||||
})
|
||||
}
|
||||
|
||||
if userSvc, err := core.Inject[contracts.UserService](ctx); err == nil && userSvc != nil {
|
||||
globalUserSvc = userSvc
|
||||
} else {
|
||||
core.When[contracts.UserService](ctx, func(svc contracts.UserService) {
|
||||
globalUserSvc = svc
|
||||
})
|
||||
}
|
||||
|
||||
// 0a. Register migrations
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
|
||||
"Wavelet/core"
|
||||
@@ -110,5 +111,16 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
Category: "security",
|
||||
})
|
||||
|
||||
// 5. Register Event Listeners for domain events
|
||||
ctx.Events().On(contracts.EventTopicUserStatusChanged, func(c context.Context, e contracts.UserStatusChangedEvent) error {
|
||||
InvalidateCachedUser(c, e.UserID)
|
||||
return nil
|
||||
})
|
||||
|
||||
ctx.Events().On(contracts.EventTopicUserDeleted, func(c context.Context, e contracts.UserDeletedEvent) error {
|
||||
InvalidateCachedUser(c, e.TargetUserID)
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,6 +110,132 @@ func (s *authServiceImpl) DisallowTokenAuthMiddleware() any {
|
||||
return DisallowTokenAuth()
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) InvalidateCachedUser(ctx context.Context, userID uint64) {
|
||||
InvalidateCachedUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) InvalidateCachedToken(ctx context.Context, tokenHash string) {
|
||||
InvalidateCachedToken(ctx, tokenHash)
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) ListAuthSources(ctx context.Context) ([]contracts.AuthSourceViewDTO, error) {
|
||||
var sources []AuthSource
|
||||
if err := db.DB(ctx).Order("id ASC").Find(&sources).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
views := make([]contracts.AuthSourceViewDTO, len(sources))
|
||||
for i := range sources {
|
||||
views[i] = contracts.AuthSourceViewDTO{
|
||||
ID: sources[i].ID,
|
||||
Name: sources[i].Name,
|
||||
Type: sources[i].Type,
|
||||
DisplayName: sources[i].DisplayName,
|
||||
IsActive: sources[i].IsActive,
|
||||
IconURL: sources[i].IconURL,
|
||||
ClientSecretConfigured: sources[i].ClientSecret != "",
|
||||
}
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) CreateAuthSource(ctx context.Context, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
|
||||
model := AuthSource{
|
||||
ID: source.ID,
|
||||
Name: source.Name,
|
||||
Type: source.Type,
|
||||
DisplayName: source.DisplayName,
|
||||
ClientID: source.ClientID,
|
||||
ClientSecret: source.ClientSecret,
|
||||
OpenIDDiscoveryURL: source.OpenIDDiscoveryURL,
|
||||
Scopes: source.Scopes,
|
||||
IconURL: source.IconURL,
|
||||
IsActive: source.IsActive,
|
||||
}
|
||||
|
||||
if err := model.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Create(&model).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
model.Sanitize()
|
||||
return toAuthSourceDTO(&model), nil
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) UpdateAuthSource(ctx context.Context, id uint64, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
|
||||
var existing AuthSource
|
||||
if err := db.DB(ctx).First(&existing, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.DisplayName = source.DisplayName
|
||||
existing.ClientID = source.ClientID
|
||||
if source.ClientSecret != "" {
|
||||
existing.ClientSecret = source.ClientSecret
|
||||
}
|
||||
existing.OpenIDDiscoveryURL = source.OpenIDDiscoveryURL
|
||||
existing.Scopes = source.Scopes
|
||||
existing.IconURL = source.IconURL
|
||||
|
||||
if err := existing.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.DB(ctx).Save(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.Sanitize()
|
||||
return toAuthSourceDTO(&existing), nil
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) DeleteAuthSource(ctx context.Context, id uint64) error {
|
||||
var existing AuthSource
|
||||
if err := db.DB(ctx).First(&existing, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.DB(ctx).Delete(&existing).Error
|
||||
}
|
||||
|
||||
func (s *authServiceImpl) ToggleAuthSource(ctx context.Context, id uint64) (*contracts.AuthSourceDTO, error) {
|
||||
var existing AuthSource
|
||||
if err := db.DB(ctx).First(&existing, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.IsActive = !existing.IsActive
|
||||
if err := db.DB(ctx).Save(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.Sanitize()
|
||||
return toAuthSourceDTO(&existing), nil
|
||||
}
|
||||
|
||||
func toAuthSourceDTO(s *AuthSource) *contracts.AuthSourceDTO {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return &contracts.AuthSourceDTO{
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
Type: s.Type,
|
||||
DisplayName: s.DisplayName,
|
||||
ClientID: s.ClientID,
|
||||
ClientSecret: s.ClientSecret,
|
||||
OpenIDDiscoveryURL: s.OpenIDDiscoveryURL,
|
||||
Scopes: s.Scopes,
|
||||
IconURL: s.IconURL,
|
||||
IsActive: s.IsActive,
|
||||
CreatedAt: s.CreatedAt,
|
||||
UpdatedAt: s.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type authRegistryImpl struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]contracts.OAuthProvider
|
||||
|
||||
@@ -81,7 +81,7 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
|
||||
// 2. Initialize and provide UserService
|
||||
if p.userSvc == nil {
|
||||
p.userSvc = newUserService()
|
||||
p.userSvc = newUserService(ctx.Events())
|
||||
}
|
||||
core.Provide[contracts.UserService](ctx, p.userSvc)
|
||||
|
||||
|
||||
@@ -11,13 +11,17 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/idgen"
|
||||
database "Wavelet/plugins/infra/database"
|
||||
"gorm.io/gorm"
|
||||
|
||||
pkgu "Wavelet/pkg/util"
|
||||
)
|
||||
|
||||
const columnUpdatedAt = "updated_at"
|
||||
|
||||
func toUserDTO(u *User) *contracts.UserDTO {
|
||||
if u == nil {
|
||||
return nil
|
||||
@@ -41,10 +45,16 @@ func toUserDTO(u *User) *contracts.UserDTO {
|
||||
}
|
||||
}
|
||||
|
||||
type userServiceImpl struct{}
|
||||
type userServiceImpl struct {
|
||||
events *core.EventBus
|
||||
}
|
||||
|
||||
func newUserService() contracts.UserService {
|
||||
return &userServiceImpl{}
|
||||
func newUserService(events ...*core.EventBus) contracts.UserService {
|
||||
var bus *core.EventBus
|
||||
if len(events) > 0 {
|
||||
bus = events[0]
|
||||
}
|
||||
return &userServiceImpl{events: bus}
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) GetUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
|
||||
@@ -131,7 +141,7 @@ func (s *userServiceImpl) UpdateProfile(ctx context.Context, id uint64, req cont
|
||||
if req.Location != nil {
|
||||
updates["location"] = *req.Location
|
||||
}
|
||||
updates["updated_at"] = time.Now()
|
||||
updates[columnUpdatedAt] = time.Now()
|
||||
|
||||
if err := database.DB(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -156,8 +166,8 @@ func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPass
|
||||
|
||||
return database.DB(ctx).Model(&User{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"password": user.Password,
|
||||
"updated_at": time.Now(),
|
||||
"password": user.Password,
|
||||
columnUpdatedAt: time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -174,7 +184,7 @@ func (s *userServiceImpl) UpdateLastLogin(ctx context.Context, id uint64, _ stri
|
||||
return database.DB(ctx).Model(&User{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"last_login_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
columnUpdatedAt: time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -280,3 +290,243 @@ func (s *userServiceImpl) UniqueUsername(ctx context.Context, base string) (stri
|
||||
|
||||
return "", errors.New("failed to generate unique username")
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) AdminListUsers(ctx context.Context, filter contracts.AdminListUsersFilter) (int64, []*contracts.UserDTO, error) {
|
||||
query := database.DB(ctx).Table("w_users")
|
||||
if filter.UserID != nil {
|
||||
query = query.Where("id = ?", *filter.UserID)
|
||||
}
|
||||
if filter.Username != "" {
|
||||
query = query.Where("username LIKE ? ESCAPE '\\'", pkgu.EscapeLike(filter.Username)+"%")
|
||||
}
|
||||
if filter.Email != "" {
|
||||
query = query.Where("email LIKE ? ESCAPE '\\'", pkgu.EscapeLike(filter.Email)+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
if filter.Page <= 0 {
|
||||
filter.Page = 1
|
||||
}
|
||||
if filter.PageSize <= 0 {
|
||||
filter.PageSize = 20
|
||||
}
|
||||
|
||||
var users []*contracts.UserDTO
|
||||
offset := (filter.Page - 1) * filter.PageSize
|
||||
if err := query.
|
||||
Select("id, username, nickname, email, avatar_url, is_active, is_admin, bio, phone, gender, website, location, last_login_at, created_at, updated_at").
|
||||
Order("id ASC").
|
||||
Offset(offset).
|
||||
Limit(filter.PageSize).
|
||||
Find(&users).Error; err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return total, users, nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) AdminGetUser(ctx context.Context, id uint64) (*contracts.UserDTO, error) {
|
||||
var user contracts.UserDTO
|
||||
if err := database.DB(ctx).Table("w_users").
|
||||
Select("id, username, nickname, email, avatar_url, is_active, is_admin, bio, phone, gender, website, location, last_login_at, created_at, updated_at").
|
||||
Where("id = ?", id).
|
||||
First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) AdminCreateUser(ctx context.Context, req contracts.AdminCreateUserRequest) (*contracts.UserDTO, error) {
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
req.Nickname = strings.TrimSpace(req.Nickname)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
|
||||
if req.Username == "" {
|
||||
return nil, errors.New("用户名不能为空")
|
||||
}
|
||||
if req.Email == "" {
|
||||
return nil, errors.New("邮箱不能为空")
|
||||
}
|
||||
const minPasswordLen = 8
|
||||
if len(req.Password) < minPasswordLen {
|
||||
return nil, errors.New("密码长度至少为 8 位")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := database.DB(ctx).Table("w_users").Where("username = ?", req.Username).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, errors.New("用户名已被使用")
|
||||
}
|
||||
|
||||
var emailCount int64
|
||||
if err := database.DB(ctx).Table("w_users").Where("email = ?", req.Email).Count(&emailCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if emailCount > 0 {
|
||||
return nil, errors.New("邮箱已被使用")
|
||||
}
|
||||
|
||||
hash, err := pkgu.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Nickname == "" {
|
||||
req.Nickname = req.Username
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
newUser := contracts.UserDTO{
|
||||
ID: idgen.NextUint64ID(),
|
||||
Username: req.Username,
|
||||
Nickname: req.Nickname,
|
||||
Email: req.Email,
|
||||
IsActive: req.IsActive,
|
||||
IsAdmin: req.IsAdmin,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
row := map[string]any{
|
||||
"id": newUser.ID,
|
||||
"username": newUser.Username,
|
||||
"password": hash,
|
||||
"nickname": newUser.Nickname,
|
||||
"email": newUser.Email,
|
||||
"is_active": newUser.IsActive,
|
||||
"is_admin": newUser.IsAdmin,
|
||||
"created_at": now,
|
||||
columnUpdatedAt: now,
|
||||
}
|
||||
if err := database.DB(ctx).Table("w_users").Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.events != nil {
|
||||
_ = s.events.Emit(ctx, contracts.EventTopicUserCreated, contracts.UserCreatedEvent{
|
||||
User: &newUser,
|
||||
Password: req.Password,
|
||||
})
|
||||
}
|
||||
|
||||
return &newUser, nil
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) AdminUpdateUser(ctx context.Context, currentUserID uint64, req contracts.AdminUpdateUserRequest) error {
|
||||
req.Nickname = strings.TrimSpace(req.Nickname)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
|
||||
if req.Email == "" {
|
||||
return errors.New("邮箱不能为空")
|
||||
}
|
||||
|
||||
var targetUser contracts.UserDTO
|
||||
if err := database.DB(ctx).Table("w_users").Where("id = ?", req.ID).First(&targetUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if currentUserID == req.ID && !req.IsAdmin && targetUser.IsAdmin {
|
||||
return errors.New("不能取消自己的管理员权限")
|
||||
}
|
||||
|
||||
if targetUser.Email != req.Email {
|
||||
var count int64
|
||||
if err := database.DB(ctx).Table("w_users").Where("email = ? AND id != ?", req.Email, req.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New("邮箱已被使用")
|
||||
}
|
||||
}
|
||||
|
||||
const minPasswordLen = 8
|
||||
if req.Password != "" && len(req.Password) < minPasswordLen {
|
||||
return errors.New("密码长度至少为 8 位")
|
||||
}
|
||||
|
||||
if req.Nickname == "" {
|
||||
req.Nickname = targetUser.Username
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"nickname": req.Nickname,
|
||||
"email": req.Email,
|
||||
"is_admin": req.IsAdmin,
|
||||
columnUpdatedAt: time.Now(),
|
||||
}
|
||||
if req.Password != "" {
|
||||
hash, err := pkgu.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates["password"] = hash
|
||||
}
|
||||
|
||||
err := database.DB(ctx).Table("w_users").Where("id = ?", req.ID).Updates(updates).Error
|
||||
if err == nil && s.events != nil {
|
||||
_ = s.events.Emit(ctx, contracts.EventTopicUserUpdated, &targetUser)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) AdminUpdateUserStatus(ctx context.Context, id uint64, active bool) error {
|
||||
var flags struct {
|
||||
ID uint64
|
||||
IsAdmin bool
|
||||
}
|
||||
if err := database.DB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", id).First(&flags).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !active && flags.IsAdmin {
|
||||
return errors.New("管理员账号无法被禁用")
|
||||
}
|
||||
|
||||
err := database.DB(ctx).Table("w_users").Where("id = ?", id).Update("is_active", active).Error
|
||||
if err == nil && s.events != nil {
|
||||
_ = s.events.Emit(ctx, contracts.EventTopicUserStatusChanged, contracts.UserStatusChangedEvent{
|
||||
UserID: id,
|
||||
IsActive: active,
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *userServiceImpl) AdminDeleteUser(ctx context.Context, currentUserID, targetID uint64) error {
|
||||
if currentUserID == targetID {
|
||||
return errors.New("不能删除当前登录用户")
|
||||
}
|
||||
var flags struct {
|
||||
ID uint64
|
||||
IsAdmin bool
|
||||
}
|
||||
if err := database.DB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", targetID).First(&flags).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.IsAdmin {
|
||||
return errors.New("管理员账号无法被删除")
|
||||
}
|
||||
|
||||
err := database.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Table("w_access_tokens").Where("user_id = ?", targetID).Delete(map[string]any{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Table("w_external_accounts").Where("user_id = ?", targetID).Delete(map[string]any{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Table("w_users").Where("id = ?", targetID).Delete(map[string]any{}).Error
|
||||
})
|
||||
if err == nil && s.events != nil {
|
||||
_ = s.events.Emit(ctx, contracts.EventTopicUserDeleted, contracts.UserDeletedEvent{
|
||||
CurrentUserID: currentUserID,
|
||||
TargetUserID: targetID,
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user