2025-12-18 13:50:39 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
2026-04-20 17:39:57 +08:00
|
|
|
|
"crypto/sha256"
|
2026-04-12 02:48:57 +08:00
|
|
|
|
"crypto/subtle"
|
2026-04-20 17:39:57 +08:00
|
|
|
|
"encoding/base64"
|
|
|
|
|
|
"encoding/hex"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
"fmt"
|
2026-04-20 18:28:44 +08:00
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
2026-04-13 13:59:35 +08:00
|
|
|
|
"log/slog"
|
2026-04-20 17:39:57 +08:00
|
|
|
|
"net/url"
|
2026-04-20 18:28:44 +08:00
|
|
|
|
"sort"
|
2026-04-12 02:48:57 +08:00
|
|
|
|
"strings"
|
2026-02-07 19:46:42 +08:00
|
|
|
|
"time"
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
2026-04-14 07:43:08 +08:00
|
|
|
|
ErrUserNotFound = infraerrors.NotFound("USER_NOT_FOUND", "user not found")
|
|
|
|
|
|
ErrPasswordIncorrect = infraerrors.BadRequest("PASSWORD_INCORRECT", "current password is incorrect")
|
|
|
|
|
|
ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
|
2026-04-13 13:59:35 +08:00
|
|
|
|
ErrNotifyCodeUserRateLimit = infraerrors.TooManyRequests("NOTIFY_CODE_USER_RATE_LIMIT", "too many verification codes requested, please try again later")
|
2026-04-20 17:39:57 +08:00
|
|
|
|
ErrAvatarInvalid = infraerrors.BadRequest("AVATAR_INVALID", "avatar must be a valid image data URL or http(s) URL")
|
|
|
|
|
|
ErrAvatarTooLarge = infraerrors.BadRequest("AVATAR_TOO_LARGE", "avatar image must be 100KB or smaller")
|
|
|
|
|
|
ErrAvatarNotImage = infraerrors.BadRequest("AVATAR_NOT_IMAGE", "avatar content must be an image")
|
2026-04-20 18:28:44 +08:00
|
|
|
|
ErrIdentityProviderInvalid = infraerrors.BadRequest("IDENTITY_PROVIDER_INVALID", "identity provider is invalid")
|
|
|
|
|
|
ErrIdentityRedirectInvalid = infraerrors.BadRequest("IDENTITY_REDIRECT_INVALID", "identity redirect path is invalid")
|
2025-12-18 13:50:39 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-13 13:59:35 +08:00
|
|
|
|
const (
|
2026-04-20 17:39:57 +08:00
|
|
|
|
maxNotifyEmails = 3 // Maximum number of notification emails per user
|
|
|
|
|
|
maxInlineAvatarBytes = 100 * 1024
|
2026-04-13 13:59:35 +08:00
|
|
|
|
|
|
|
|
|
|
// User-level rate limiting for notify email verification codes
|
|
|
|
|
|
notifyCodeUserRateLimit = 5
|
|
|
|
|
|
notifyCodeUserRateWindow = 10 * time.Minute
|
2026-04-20 18:28:44 +08:00
|
|
|
|
|
|
|
|
|
|
defaultUserIdentityRedirect = "/settings/profile"
|
2026-04-13 13:59:35 +08:00
|
|
|
|
)
|
2026-04-12 02:48:57 +08:00
|
|
|
|
|
2026-01-01 18:59:38 +08:00
|
|
|
|
// UserListFilters contains all filter options for listing users
|
|
|
|
|
|
type UserListFilters struct {
|
2026-01-01 19:09:06 +08:00
|
|
|
|
Status string // User status filter
|
|
|
|
|
|
Role string // User role filter
|
|
|
|
|
|
Search string // Search in email, username
|
2026-03-18 23:28:11 +08:00
|
|
|
|
GroupName string // Filter by allowed group name (fuzzy match)
|
2026-01-01 19:09:06 +08:00
|
|
|
|
Attributes map[int64]string // Custom attribute filters: attributeID -> value
|
2026-03-04 13:45:49 +08:00
|
|
|
|
// IncludeSubscriptions controls whether ListWithFilters should load active subscriptions.
|
|
|
|
|
|
// For large datasets this can be expensive; admin list pages should enable it on demand.
|
2026-03-04 14:07:17 +08:00
|
|
|
|
// nil means not specified (default: load subscriptions for backward compatibility).
|
|
|
|
|
|
IncludeSubscriptions *bool
|
2026-01-01 18:59:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 17:15:01 +08:00
|
|
|
|
type UserRepository interface {
|
2025-12-26 15:40:24 +08:00
|
|
|
|
Create(ctx context.Context, user *User) error
|
|
|
|
|
|
GetByID(ctx context.Context, id int64) (*User, error)
|
|
|
|
|
|
GetByEmail(ctx context.Context, email string) (*User, error)
|
|
|
|
|
|
GetFirstAdmin(ctx context.Context) (*User, error)
|
|
|
|
|
|
Update(ctx context.Context, user *User) error
|
2025-12-25 17:15:01 +08:00
|
|
|
|
Delete(ctx context.Context, id int64) error
|
2026-04-20 17:39:57 +08:00
|
|
|
|
GetUserAvatar(ctx context.Context, userID int64) (*UserAvatar, error)
|
|
|
|
|
|
UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error)
|
|
|
|
|
|
DeleteUserAvatar(ctx context.Context, userID int64) error
|
2025-12-25 17:15:01 +08:00
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error)
|
2026-01-01 18:59:38 +08:00
|
|
|
|
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error)
|
2025-12-25 17:15:01 +08:00
|
|
|
|
|
|
|
|
|
|
UpdateBalance(ctx context.Context, id int64, amount float64) error
|
|
|
|
|
|
DeductBalance(ctx context.Context, id int64, amount float64) error
|
|
|
|
|
|
UpdateConcurrency(ctx context.Context, id int64, amount int) error
|
|
|
|
|
|
ExistsByEmail(ctx context.Context, email string) (bool, error)
|
|
|
|
|
|
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
|
2026-02-28 17:33:30 +08:00
|
|
|
|
// AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略)
|
|
|
|
|
|
AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error
|
2026-03-18 23:28:11 +08:00
|
|
|
|
// RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限
|
|
|
|
|
|
RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error
|
2026-04-20 18:28:44 +08:00
|
|
|
|
ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error)
|
2026-02-02 22:13:50 +08:00
|
|
|
|
|
2026-02-02 22:20:08 +08:00
|
|
|
|
// TOTP 双因素认证
|
2026-02-02 22:13:50 +08:00
|
|
|
|
UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error
|
|
|
|
|
|
EnableTotp(ctx context.Context, userID int64) error
|
|
|
|
|
|
DisableTotp(ctx context.Context, userID int64) error
|
2025-12-25 17:15:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 18:28:44 +08:00
|
|
|
|
type UserAuthIdentityRecord struct {
|
|
|
|
|
|
ProviderType string
|
|
|
|
|
|
ProviderKey string
|
|
|
|
|
|
ProviderSubject string
|
|
|
|
|
|
VerifiedAt *time.Time
|
|
|
|
|
|
Issuer *string
|
|
|
|
|
|
Metadata map[string]any
|
|
|
|
|
|
CreatedAt time.Time
|
|
|
|
|
|
UpdatedAt time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type UserIdentitySummary struct {
|
|
|
|
|
|
Provider string `json:"provider"`
|
|
|
|
|
|
Bound bool `json:"bound"`
|
|
|
|
|
|
BoundCount int `json:"bound_count"`
|
|
|
|
|
|
DisplayName string `json:"display_name,omitempty"`
|
|
|
|
|
|
SubjectHint string `json:"subject_hint,omitempty"`
|
|
|
|
|
|
ProviderKey string `json:"provider_key,omitempty"`
|
|
|
|
|
|
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
|
|
|
|
|
BindStartPath string `json:"bind_start_path,omitempty"`
|
|
|
|
|
|
CanBind bool `json:"can_bind"`
|
|
|
|
|
|
CanUnbind bool `json:"can_unbind"`
|
|
|
|
|
|
Note string `json:"note,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type UserIdentitySummarySet struct {
|
|
|
|
|
|
Email UserIdentitySummary `json:"email"`
|
|
|
|
|
|
LinuxDo UserIdentitySummary `json:"linuxdo"`
|
|
|
|
|
|
OIDC UserIdentitySummary `json:"oidc"`
|
|
|
|
|
|
WeChat UserIdentitySummary `json:"wechat"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type StartUserIdentityBindingRequest struct {
|
|
|
|
|
|
Provider string
|
|
|
|
|
|
RedirectTo string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type StartUserIdentityBindingResult struct {
|
|
|
|
|
|
Provider string `json:"provider"`
|
|
|
|
|
|
AuthorizeURL string `json:"authorize_url"`
|
|
|
|
|
|
Method string `json:"method"`
|
|
|
|
|
|
UseBrowserRedirect bool `json:"use_browser_redirect"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// UpdateProfileRequest 更新用户资料请求
|
|
|
|
|
|
type UpdateProfileRequest struct {
|
2026-04-12 15:01:10 +08:00
|
|
|
|
Email *string `json:"email"`
|
|
|
|
|
|
Username *string `json:"username"`
|
2026-04-20 17:39:57 +08:00
|
|
|
|
AvatarURL *string `json:"avatar_url"`
|
2026-04-12 15:01:10 +08:00
|
|
|
|
Concurrency *int `json:"concurrency"`
|
|
|
|
|
|
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
|
|
|
|
|
|
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 17:39:57 +08:00
|
|
|
|
type UserAvatar struct {
|
|
|
|
|
|
StorageProvider string
|
|
|
|
|
|
StorageKey string
|
|
|
|
|
|
URL string
|
|
|
|
|
|
ContentType string
|
|
|
|
|
|
ByteSize int
|
|
|
|
|
|
SHA256 string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type UpsertUserAvatarInput struct {
|
|
|
|
|
|
StorageProvider string
|
|
|
|
|
|
StorageKey string
|
|
|
|
|
|
URL string
|
|
|
|
|
|
ContentType string
|
|
|
|
|
|
ByteSize int
|
|
|
|
|
|
SHA256 string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 18:28:44 +08:00
|
|
|
|
type userAuthIdentityReader interface {
|
|
|
|
|
|
ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// ChangePasswordRequest 修改密码请求
|
|
|
|
|
|
type ChangePasswordRequest struct {
|
|
|
|
|
|
CurrentPassword string `json:"current_password"`
|
|
|
|
|
|
NewPassword string `json:"new_password"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UserService 用户服务
|
|
|
|
|
|
type UserService struct {
|
2026-01-10 22:23:51 +08:00
|
|
|
|
userRepo UserRepository
|
2026-04-12 02:48:57 +08:00
|
|
|
|
settingRepo SettingRepository
|
2026-01-10 22:23:51 +08:00
|
|
|
|
authCacheInvalidator APIKeyAuthCacheInvalidator
|
2026-02-07 19:46:42 +08:00
|
|
|
|
billingCache BillingCache
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewUserService 创建用户服务实例
|
2026-04-12 02:48:57 +08:00
|
|
|
|
func NewUserService(userRepo UserRepository, settingRepo SettingRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCache BillingCache) *UserService {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return &UserService{
|
2026-01-10 22:23:51 +08:00
|
|
|
|
userRepo: userRepo,
|
2026-04-12 02:48:57 +08:00
|
|
|
|
settingRepo: settingRepo,
|
2026-01-10 22:23:51 +08:00
|
|
|
|
authCacheInvalidator: authCacheInvalidator,
|
2026-02-07 19:46:42 +08:00
|
|
|
|
billingCache: billingCache,
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 10:42:08 +08:00
|
|
|
|
// GetFirstAdmin 获取首个管理员用户(用于 Admin API Key 认证)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *UserService) GetFirstAdmin(ctx context.Context) (*User, error) {
|
2025-12-26 10:42:08 +08:00
|
|
|
|
admin, err := s.userRepo.GetFirstAdmin(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get first admin: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return admin, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// GetProfile 获取用户资料
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *UserService) GetProfile(ctx context.Context, userID int64) (*User, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get user: %w", err)
|
|
|
|
|
|
}
|
2026-04-20 17:39:57 +08:00
|
|
|
|
if err := s.hydrateUserAvatar(ctx, user); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get user avatar: %w", err)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return user, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 18:28:44 +08:00
|
|
|
|
func (s *UserService) GetProfileIdentitySummaries(ctx context.Context, userID int64, user *User) (UserIdentitySummarySet, error) {
|
|
|
|
|
|
if user == nil {
|
|
|
|
|
|
var err error
|
|
|
|
|
|
user, err = s.userRepo.GetByID(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return UserIdentitySummarySet{}, fmt.Errorf("get user: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
records, err := s.listUserAuthIdentities(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return UserIdentitySummarySet{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return UserIdentitySummarySet{
|
|
|
|
|
|
Email: s.buildEmailIdentitySummary(user),
|
|
|
|
|
|
LinuxDo: s.buildProviderIdentitySummary("linuxdo", records),
|
|
|
|
|
|
OIDC: s.buildProviderIdentitySummary("oidc", records),
|
|
|
|
|
|
WeChat: s.buildProviderIdentitySummary("wechat", records),
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUserIdentityBindingRequest) (*StartUserIdentityBindingResult, error) {
|
|
|
|
|
|
provider := normalizeUserIdentityProvider(req.Provider)
|
|
|
|
|
|
if provider == "" {
|
|
|
|
|
|
return nil, ErrIdentityProviderInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
authorizeURL, err := buildUserIdentityBindAuthorizeURL(provider, req.RedirectTo)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &StartUserIdentityBindingResult{
|
|
|
|
|
|
Provider: provider,
|
|
|
|
|
|
AuthorizeURL: authorizeURL,
|
|
|
|
|
|
Method: "GET",
|
|
|
|
|
|
UseBrowserRedirect: true,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// UpdateProfile 更新用户资料
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get user: %w", err)
|
|
|
|
|
|
}
|
2026-01-10 22:23:51 +08:00
|
|
|
|
oldConcurrency := user.Concurrency
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
// 更新字段
|
|
|
|
|
|
if req.Email != nil {
|
|
|
|
|
|
// 检查新邮箱是否已被使用
|
|
|
|
|
|
exists, err := s.userRepo.ExistsByEmail(ctx, *req.Email)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("check email exists: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if exists && *req.Email != user.Email {
|
|
|
|
|
|
return nil, ErrEmailExists
|
|
|
|
|
|
}
|
|
|
|
|
|
user.Email = *req.Email
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 11:26:22 +08:00
|
|
|
|
if req.Username != nil {
|
|
|
|
|
|
user.Username = *req.Username
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 17:39:57 +08:00
|
|
|
|
if req.AvatarURL != nil {
|
|
|
|
|
|
avatarValue := strings.TrimSpace(*req.AvatarURL)
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case avatarValue == "":
|
|
|
|
|
|
if err := s.userRepo.DeleteUserAvatar(ctx, userID); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("delete avatar: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
applyUserAvatar(user, nil)
|
|
|
|
|
|
default:
|
|
|
|
|
|
avatarInput, err := normalizeUserAvatarInput(avatarValue)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
avatar, err := s.userRepo.UpsertUserAvatar(ctx, userID, avatarInput)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("upsert avatar: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
applyUserAvatar(user, avatar)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if req.Concurrency != nil {
|
|
|
|
|
|
user.Concurrency = *req.Concurrency
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 02:48:57 +08:00
|
|
|
|
if req.BalanceNotifyEnabled != nil {
|
|
|
|
|
|
user.BalanceNotifyEnabled = *req.BalanceNotifyEnabled
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.BalanceNotifyThreshold != nil {
|
|
|
|
|
|
if *req.BalanceNotifyThreshold <= 0 {
|
|
|
|
|
|
user.BalanceNotifyThreshold = nil // clear to system default
|
|
|
|
|
|
} else {
|
|
|
|
|
|
user.BalanceNotifyThreshold = req.BalanceNotifyThreshold
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("update user: %w", err)
|
|
|
|
|
|
}
|
2026-01-10 22:23:51 +08:00
|
|
|
|
if s.authCacheInvalidator != nil && user.Concurrency != oldConcurrency {
|
|
|
|
|
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
return user, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 17:39:57 +08:00
|
|
|
|
func applyUserAvatar(user *User, avatar *UserAvatar) {
|
|
|
|
|
|
if user == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if avatar == nil {
|
|
|
|
|
|
user.AvatarURL = ""
|
|
|
|
|
|
user.AvatarSource = ""
|
|
|
|
|
|
user.AvatarMIME = ""
|
|
|
|
|
|
user.AvatarByteSize = 0
|
|
|
|
|
|
user.AvatarSHA256 = ""
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
user.AvatarURL = avatar.URL
|
|
|
|
|
|
user.AvatarSource = avatar.StorageProvider
|
|
|
|
|
|
user.AvatarMIME = avatar.ContentType
|
|
|
|
|
|
user.AvatarByteSize = avatar.ByteSize
|
|
|
|
|
|
user.AvatarSHA256 = avatar.SHA256
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
|
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
|
if raw == "" {
|
|
|
|
|
|
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.HasPrefix(raw, "data:") {
|
|
|
|
|
|
return normalizeInlineUserAvatarInput(raw)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
parsed, err := url.Parse(raw)
|
|
|
|
|
|
if err != nil || parsed == nil {
|
|
|
|
|
|
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
|
|
|
|
|
|
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(parsed.Host) == "" {
|
|
|
|
|
|
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return UpsertUserAvatarInput{
|
|
|
|
|
|
StorageProvider: "remote_url",
|
|
|
|
|
|
URL: raw,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeInlineUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
|
|
|
|
|
|
body := strings.TrimPrefix(raw, "data:")
|
|
|
|
|
|
meta, encoded, ok := strings.Cut(body, ",")
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
meta = strings.TrimSpace(meta)
|
|
|
|
|
|
encoded = strings.TrimSpace(encoded)
|
|
|
|
|
|
if !strings.HasSuffix(strings.ToLower(meta), ";base64") {
|
|
|
|
|
|
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
contentType := strings.TrimSpace(meta[:len(meta)-len(";base64")])
|
|
|
|
|
|
if contentType == "" || !strings.HasPrefix(strings.ToLower(contentType), "image/") {
|
|
|
|
|
|
return UpsertUserAvatarInput{}, ErrAvatarNotImage
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return UpsertUserAvatarInput{}, ErrAvatarInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(decoded) > maxInlineAvatarBytes {
|
|
|
|
|
|
return UpsertUserAvatarInput{}, ErrAvatarTooLarge
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sum := sha256.Sum256(decoded)
|
|
|
|
|
|
return UpsertUserAvatarInput{
|
|
|
|
|
|
StorageProvider: "inline",
|
|
|
|
|
|
URL: raw,
|
|
|
|
|
|
ContentType: contentType,
|
|
|
|
|
|
ByteSize: len(decoded),
|
|
|
|
|
|
SHA256: hex.EncodeToString(sum[:]),
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 18:28:44 +08:00
|
|
|
|
func (s *UserService) buildEmailIdentitySummary(user *User) UserIdentitySummary {
|
|
|
|
|
|
summary := UserIdentitySummary{
|
|
|
|
|
|
Provider: "email",
|
|
|
|
|
|
CanBind: false,
|
|
|
|
|
|
CanUnbind: false,
|
|
|
|
|
|
Note: "Primary account email is managed from the profile form.",
|
|
|
|
|
|
}
|
|
|
|
|
|
if user == nil {
|
|
|
|
|
|
return summary
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
email := strings.TrimSpace(user.Email)
|
|
|
|
|
|
if email == "" || isReservedEmail(email) {
|
|
|
|
|
|
return summary
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
summary.Bound = true
|
|
|
|
|
|
summary.BoundCount = 1
|
|
|
|
|
|
summary.DisplayName = email
|
|
|
|
|
|
summary.SubjectHint = maskEmailIdentity(email)
|
|
|
|
|
|
summary.ProviderKey = "email"
|
|
|
|
|
|
return summary
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *UserService) buildProviderIdentitySummary(provider string, records []UserAuthIdentityRecord) UserIdentitySummary {
|
|
|
|
|
|
summary := UserIdentitySummary{
|
|
|
|
|
|
Provider: provider,
|
|
|
|
|
|
CanUnbind: false,
|
|
|
|
|
|
}
|
|
|
|
|
|
filtered := filterUserAuthIdentities(records, provider)
|
|
|
|
|
|
if len(filtered) == 0 {
|
|
|
|
|
|
summary.CanBind = true
|
|
|
|
|
|
bindStartPath, err := buildUserIdentityBindAuthorizeURL(provider, "")
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
summary.BindStartPath = bindStartPath
|
|
|
|
|
|
}
|
|
|
|
|
|
return summary
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
primary := selectPrimaryUserAuthIdentity(filtered)
|
|
|
|
|
|
summary.Bound = true
|
|
|
|
|
|
summary.BoundCount = len(filtered)
|
|
|
|
|
|
summary.DisplayName = userAuthIdentityDisplayName(primary)
|
|
|
|
|
|
summary.SubjectHint = maskOpaqueIdentity(primary.ProviderSubject)
|
|
|
|
|
|
summary.ProviderKey = strings.TrimSpace(primary.ProviderKey)
|
|
|
|
|
|
summary.VerifiedAt = primary.VerifiedAt
|
|
|
|
|
|
summary.Note = "Unbind is not available yet."
|
|
|
|
|
|
return summary
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *UserService) listUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error) {
|
|
|
|
|
|
if userID <= 0 || s == nil || s.userRepo == nil {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.userRepo.ListUserAuthIdentities(ctx, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildUserIdentityBindAuthorizeURL(provider, redirectTo string) (string, error) {
|
|
|
|
|
|
provider = normalizeUserIdentityProvider(provider)
|
|
|
|
|
|
if provider == "" || provider == "email" {
|
|
|
|
|
|
return "", ErrIdentityProviderInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
redirectTo, err := normalizeUserIdentityRedirect(redirectTo)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
path := ""
|
|
|
|
|
|
switch provider {
|
|
|
|
|
|
case "linuxdo":
|
|
|
|
|
|
path = "/api/v1/auth/oauth/linuxdo/start"
|
|
|
|
|
|
case "oidc":
|
|
|
|
|
|
path = "/api/v1/auth/oauth/oidc/start"
|
|
|
|
|
|
case "wechat":
|
|
|
|
|
|
path = "/api/v1/auth/oauth/wechat/start"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "", ErrIdentityProviderInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
query := url.Values{}
|
|
|
|
|
|
query.Set("redirect", redirectTo)
|
|
|
|
|
|
query.Set("intent", "bind_current_user")
|
|
|
|
|
|
return path + "?" + query.Encode(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeUserIdentityProvider(provider string) string {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
|
|
|
|
|
case "linuxdo":
|
|
|
|
|
|
return "linuxdo"
|
|
|
|
|
|
case "oidc":
|
|
|
|
|
|
return "oidc"
|
|
|
|
|
|
case "wechat":
|
|
|
|
|
|
return "wechat"
|
|
|
|
|
|
case "email":
|
|
|
|
|
|
return "email"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeUserIdentityRedirect(raw string) (string, error) {
|
|
|
|
|
|
redirect := strings.TrimSpace(raw)
|
|
|
|
|
|
if redirect == "" {
|
|
|
|
|
|
return defaultUserIdentityRedirect, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(redirect) > 2048 || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
|
|
|
|
|
|
return "", ErrIdentityRedirectInvalid
|
|
|
|
|
|
}
|
|
|
|
|
|
return redirect, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func filterUserAuthIdentities(records []UserAuthIdentityRecord, provider string) []UserAuthIdentityRecord {
|
|
|
|
|
|
if len(records) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
filtered := make([]UserAuthIdentityRecord, 0, len(records))
|
|
|
|
|
|
for _, record := range records {
|
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(record.ProviderType), provider) {
|
|
|
|
|
|
filtered = append(filtered, record)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return filtered
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func selectPrimaryUserAuthIdentity(records []UserAuthIdentityRecord) UserAuthIdentityRecord {
|
|
|
|
|
|
if len(records) == 0 {
|
|
|
|
|
|
return UserAuthIdentityRecord{}
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.SliceStable(records, func(i, j int) bool {
|
|
|
|
|
|
left := userAuthIdentitySortTime(records[i])
|
|
|
|
|
|
right := userAuthIdentitySortTime(records[j])
|
|
|
|
|
|
if !left.Equal(right) {
|
|
|
|
|
|
return left.After(right)
|
|
|
|
|
|
}
|
|
|
|
|
|
return records[i].ProviderKey < records[j].ProviderKey
|
|
|
|
|
|
})
|
|
|
|
|
|
return records[0]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func userAuthIdentitySortTime(record UserAuthIdentityRecord) time.Time {
|
|
|
|
|
|
if record.VerifiedAt != nil && !record.VerifiedAt.IsZero() {
|
|
|
|
|
|
return record.VerifiedAt.UTC()
|
|
|
|
|
|
}
|
|
|
|
|
|
if !record.UpdatedAt.IsZero() {
|
|
|
|
|
|
return record.UpdatedAt.UTC()
|
|
|
|
|
|
}
|
|
|
|
|
|
if !record.CreatedAt.IsZero() {
|
|
|
|
|
|
return record.CreatedAt.UTC()
|
|
|
|
|
|
}
|
|
|
|
|
|
return time.Time{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func userAuthIdentityDisplayName(record UserAuthIdentityRecord) string {
|
|
|
|
|
|
if displayName := firstStringIdentityValue(record.Metadata,
|
|
|
|
|
|
"display_name",
|
|
|
|
|
|
"suggested_display_name",
|
|
|
|
|
|
"username",
|
|
|
|
|
|
"name",
|
|
|
|
|
|
"nickname",
|
|
|
|
|
|
"email",
|
|
|
|
|
|
); displayName != "" {
|
|
|
|
|
|
return displayName
|
|
|
|
|
|
}
|
|
|
|
|
|
if subject := strings.TrimSpace(record.ProviderSubject); subject != "" {
|
|
|
|
|
|
return subject
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.TrimSpace(record.ProviderType)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func firstStringIdentityValue(values map[string]any, keys ...string) string {
|
|
|
|
|
|
for _, key := range keys {
|
|
|
|
|
|
raw, ok := values[key]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
switch value := raw.(type) {
|
|
|
|
|
|
case string:
|
|
|
|
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
|
|
|
|
return trimmed
|
|
|
|
|
|
}
|
|
|
|
|
|
case fmt.Stringer:
|
|
|
|
|
|
if trimmed := strings.TrimSpace(value.String()); trimmed != "" {
|
|
|
|
|
|
return trimmed
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func maskEmailIdentity(email string) string {
|
|
|
|
|
|
local, domain, ok := strings.Cut(strings.TrimSpace(email), "@")
|
|
|
|
|
|
if !ok || local == "" || domain == "" {
|
|
|
|
|
|
return maskOpaqueIdentity(email)
|
|
|
|
|
|
}
|
|
|
|
|
|
runes := []rune(local)
|
|
|
|
|
|
if len(runes) == 1 {
|
|
|
|
|
|
return string(runes[0]) + "***@" + domain
|
|
|
|
|
|
}
|
|
|
|
|
|
return string(runes[0]) + "***" + string(runes[len(runes)-1]) + "@" + domain
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func maskOpaqueIdentity(value string) string {
|
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
|
runes := []rune(value)
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case len(runes) == 0:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
case len(runes) <= 4:
|
|
|
|
|
|
return string(runes[0]) + "***"
|
|
|
|
|
|
case len(runes) <= 8:
|
|
|
|
|
|
return string(runes[:2]) + "***" + string(runes[len(runes)-1:])
|
|
|
|
|
|
default:
|
|
|
|
|
|
return string(runes[:3]) + "***" + string(runes[len(runes)-3:])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func cloneAnyMap(values map[string]any) map[string]any {
|
|
|
|
|
|
if len(values) == 0 {
|
|
|
|
|
|
return map[string]any{}
|
|
|
|
|
|
}
|
|
|
|
|
|
cloned := make(map[string]any, len(values))
|
|
|
|
|
|
for key, value := range values {
|
|
|
|
|
|
cloned[key] = value
|
|
|
|
|
|
}
|
|
|
|
|
|
return cloned
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// ChangePassword 修改密码
|
2025-12-29 17:18:17 -05:00
|
|
|
|
// Security: Increments TokenVersion to invalidate all existing JWT tokens
|
2025-12-18 13:50:39 +08:00
|
|
|
|
func (s *UserService) ChangePassword(ctx context.Context, userID int64, req ChangePasswordRequest) error {
|
|
|
|
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("get user: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证当前密码
|
2025-12-26 15:40:24 +08:00
|
|
|
|
if !user.CheckPassword(req.CurrentPassword) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return ErrPasswordIncorrect
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 15:40:24 +08:00
|
|
|
|
if err := user.SetPassword(req.NewPassword); err != nil {
|
|
|
|
|
|
return fmt.Errorf("set password: %w", err)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 17:18:17 -05:00
|
|
|
|
// Increment TokenVersion to invalidate all existing tokens
|
|
|
|
|
|
// This ensures that any tokens issued before the password change become invalid
|
|
|
|
|
|
user.TokenVersion++
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
|
|
|
|
|
return fmt.Errorf("update user: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetByID 根据ID获取用户(管理员功能)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
user, err := s.userRepo.GetByID(ctx, id)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get user: %w", err)
|
|
|
|
|
|
}
|
2026-04-20 17:39:57 +08:00
|
|
|
|
if err := s.hydrateUserAvatar(ctx, user); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("get user avatar: %w", err)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return user, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 17:39:57 +08:00
|
|
|
|
func (s *UserService) hydrateUserAvatar(ctx context.Context, user *User) error {
|
|
|
|
|
|
if s == nil || s.userRepo == nil || user == nil || user.ID == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
avatar, err := s.userRepo.GetUserAvatar(ctx, user.ID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
applyUserAvatar(user, avatar)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// List 获取用户列表(管理员功能)
|
2025-12-26 15:40:24 +08:00
|
|
|
|
func (s *UserService) List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
|
2025-12-18 13:50:39 +08:00
|
|
|
|
users, pagination, err := s.userRepo.List(ctx, params)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, nil, fmt.Errorf("list users: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return users, pagination, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UpdateBalance 更新用户余额(管理员功能)
|
|
|
|
|
|
func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount float64) error {
|
|
|
|
|
|
if err := s.userRepo.UpdateBalance(ctx, userID, amount); err != nil {
|
|
|
|
|
|
return fmt.Errorf("update balance: %w", err)
|
|
|
|
|
|
}
|
2026-01-10 22:23:51 +08:00
|
|
|
|
if s.authCacheInvalidator != nil {
|
|
|
|
|
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
|
|
|
|
|
}
|
2026-02-07 19:46:42 +08:00
|
|
|
|
if s.billingCache != nil {
|
|
|
|
|
|
go func() {
|
2026-04-13 14:21:37 +08:00
|
|
|
|
defer func() {
|
|
|
|
|
|
if r := recover(); r != nil {
|
|
|
|
|
|
slog.Error("panic in balance cache invalidation", "user_id", userID, "recover", r)
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
2026-02-07 19:46:42 +08:00
|
|
|
|
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
if err := s.billingCache.InvalidateUserBalance(cacheCtx, userID); err != nil {
|
2026-04-13 13:59:35 +08:00
|
|
|
|
slog.Error("invalidate user balance cache failed", "user_id", userID, "error", err)
|
2026-02-07 19:46:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 22:19:18 +08:00
|
|
|
|
// UpdateConcurrency 更新用户并发数(管理员功能)
|
|
|
|
|
|
func (s *UserService) UpdateConcurrency(ctx context.Context, userID int64, concurrency int) error {
|
|
|
|
|
|
if err := s.userRepo.UpdateConcurrency(ctx, userID, concurrency); err != nil {
|
|
|
|
|
|
return fmt.Errorf("update concurrency: %w", err)
|
|
|
|
|
|
}
|
2026-01-10 22:23:51 +08:00
|
|
|
|
if s.authCacheInvalidator != nil {
|
|
|
|
|
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
|
|
|
|
|
}
|
2025-12-28 22:19:18 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
|
// UpdateStatus 更新用户状态(管理员功能)
|
|
|
|
|
|
func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status string) error {
|
|
|
|
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("get user: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
user.Status = status
|
|
|
|
|
|
|
|
|
|
|
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
|
|
|
|
|
return fmt.Errorf("update user: %w", err)
|
|
|
|
|
|
}
|
2026-01-10 22:23:51 +08:00
|
|
|
|
if s.authCacheInvalidator != nil {
|
|
|
|
|
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Delete 删除用户(管理员功能)
|
|
|
|
|
|
func (s *UserService) Delete(ctx context.Context, userID int64) error {
|
2026-01-10 22:23:51 +08:00
|
|
|
|
if s.authCacheInvalidator != nil {
|
|
|
|
|
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
|
|
|
|
|
}
|
2026-01-10 22:52:13 +08:00
|
|
|
|
if err := s.userRepo.Delete(ctx, userID); err != nil {
|
|
|
|
|
|
return fmt.Errorf("delete user: %w", err)
|
|
|
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-04-12 02:48:57 +08:00
|
|
|
|
|
|
|
|
|
|
// SendNotifyEmailCode sends a verification code to the extra notification email.
|
|
|
|
|
|
func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, email string, emailService *EmailService, cache EmailCache) error {
|
2026-04-13 13:59:35 +08:00
|
|
|
|
if err := checkNotifyCodeRateLimit(ctx, cache, userID, email); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
code, err := emailService.GenerateVerifyCode()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("generate code: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 23:35:59 +08:00
|
|
|
|
// Send email first — if SMTP fails, don't write cache or increment counters,
|
|
|
|
|
|
// so the user is not locked out by cooldown/rate-limit for a code they never received.
|
|
|
|
|
|
if err := s.sendNotifyVerifyEmail(ctx, emailService, email, code); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 13:59:35 +08:00
|
|
|
|
if err := saveNotifyVerifyCode(ctx, cache, email, code); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Increment user-level counter after successful save
|
|
|
|
|
|
if _, err := cache.IncrNotifyCodeUserRate(ctx, userID, notifyCodeUserRateWindow); err != nil {
|
|
|
|
|
|
slog.Error("failed to increment notify code user rate", "user_id", userID, "error", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 23:35:59 +08:00
|
|
|
|
return nil
|
2026-04-13 13:59:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// checkNotifyCodeRateLimit checks both email cooldown and user-level rate limit.
|
|
|
|
|
|
func checkNotifyCodeRateLimit(ctx context.Context, cache EmailCache, userID int64, email string) error {
|
2026-04-12 02:48:57 +08:00
|
|
|
|
existing, err := cache.GetNotifyVerifyCode(ctx, email)
|
|
|
|
|
|
if err == nil && existing != nil {
|
|
|
|
|
|
if time.Since(existing.CreatedAt) < verifyCodeCooldown {
|
|
|
|
|
|
return ErrVerifyCodeTooFrequent
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-13 13:59:35 +08:00
|
|
|
|
count, err := cache.GetNotifyCodeUserRate(ctx, userID)
|
|
|
|
|
|
if err == nil && count >= notifyCodeUserRateLimit {
|
|
|
|
|
|
return ErrNotifyCodeUserRateLimit
|
2026-04-12 02:48:57 +08:00
|
|
|
|
}
|
2026-04-13 13:59:35 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-04-12 02:48:57 +08:00
|
|
|
|
|
2026-04-13 13:59:35 +08:00
|
|
|
|
// saveNotifyVerifyCode saves the verification code to cache.
|
|
|
|
|
|
func saveNotifyVerifyCode(ctx context.Context, cache EmailCache, email, code string) error {
|
2026-04-12 02:48:57 +08:00
|
|
|
|
data := &VerificationCodeData{
|
|
|
|
|
|
Code: code,
|
|
|
|
|
|
Attempts: 0,
|
|
|
|
|
|
CreatedAt: time.Now(),
|
2026-04-14 00:26:20 +08:00
|
|
|
|
ExpiresAt: time.Now().Add(verifyCodeTTL),
|
2026-04-12 02:48:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil {
|
|
|
|
|
|
return fmt.Errorf("save verify code: %w", err)
|
|
|
|
|
|
}
|
2026-04-13 13:59:35 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-04-12 02:48:57 +08:00
|
|
|
|
|
2026-04-13 13:59:35 +08:00
|
|
|
|
// sendNotifyVerifyEmail builds and sends the verification email.
|
|
|
|
|
|
func (s *UserService) sendNotifyVerifyEmail(ctx context.Context, emailService *EmailService, email, code string) error {
|
2026-04-12 02:48:57 +08:00
|
|
|
|
siteName := "Sub2API"
|
|
|
|
|
|
if s.settingRepo != nil {
|
|
|
|
|
|
if name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && name != "" {
|
|
|
|
|
|
siteName = name
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
subject := fmt.Sprintf("[%s] 通知邮箱验证码 / Notification Email Verification", siteName)
|
|
|
|
|
|
body := buildNotifyVerifyEmailBody(code, siteName)
|
|
|
|
|
|
return emailService.SendEmail(ctx, email, subject, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// VerifyAndAddNotifyEmail verifies the code and adds the email to user's extra emails.
|
|
|
|
|
|
func (s *UserService) VerifyAndAddNotifyEmail(ctx context.Context, userID int64, email, code string, cache EmailCache) error {
|
2026-04-13 13:59:35 +08:00
|
|
|
|
if err := verifyNotifyCode(ctx, cache, email, code); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = cache.DeleteNotifyVerifyCode(ctx, email)
|
|
|
|
|
|
return s.addOrVerifyNotifyEmail(ctx, userID, email)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// verifyNotifyCode validates the verification code against the cached data.
|
|
|
|
|
|
func verifyNotifyCode(ctx context.Context, cache EmailCache, email, code string) error {
|
2026-04-12 02:48:57 +08:00
|
|
|
|
data, err := cache.GetNotifyVerifyCode(ctx, email)
|
|
|
|
|
|
if err != nil || data == nil {
|
|
|
|
|
|
return ErrInvalidVerifyCode
|
|
|
|
|
|
}
|
|
|
|
|
|
if data.Attempts >= maxVerifyCodeAttempts {
|
|
|
|
|
|
return ErrVerifyCodeMaxAttempts
|
|
|
|
|
|
}
|
|
|
|
|
|
if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 {
|
|
|
|
|
|
data.Attempts++
|
2026-04-14 00:26:20 +08:00
|
|
|
|
remaining := time.Until(data.ExpiresAt)
|
|
|
|
|
|
if remaining <= 0 {
|
|
|
|
|
|
return ErrInvalidVerifyCode
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := cache.SetNotifyVerifyCode(ctx, email, data, remaining); err != nil {
|
2026-04-13 14:21:37 +08:00
|
|
|
|
slog.Error("failed to update notify verify code attempts", "email", email, "error", err)
|
|
|
|
|
|
}
|
2026-04-12 02:48:57 +08:00
|
|
|
|
if data.Attempts >= maxVerifyCodeAttempts {
|
|
|
|
|
|
return ErrVerifyCodeMaxAttempts
|
|
|
|
|
|
}
|
|
|
|
|
|
return ErrInvalidVerifyCode
|
|
|
|
|
|
}
|
2026-04-13 13:59:35 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-04-12 02:48:57 +08:00
|
|
|
|
|
2026-04-13 13:59:35 +08:00
|
|
|
|
// addOrVerifyNotifyEmail adds the email to user's extra notification emails or marks it as verified.
|
|
|
|
|
|
// Note: concurrent calls for the same user could race on the read-modify-write of
|
|
|
|
|
|
// BalanceNotifyExtraEmails. The window is small (requires two verify flows completing
|
|
|
|
|
|
// simultaneously), and the worst case is a duplicate entry which is harmless.
|
|
|
|
|
|
func (s *UserService) addOrVerifyNotifyEmail(ctx context.Context, userID int64, email string) error {
|
2026-04-12 02:48:57 +08:00
|
|
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-04-13 01:40:13 +08:00
|
|
|
|
for i, e := range user.BalanceNotifyExtraEmails {
|
2026-04-13 00:52:42 +08:00
|
|
|
|
if strings.EqualFold(e.Email, email) {
|
2026-04-13 01:40:13 +08:00
|
|
|
|
if !e.Verified {
|
|
|
|
|
|
user.BalanceNotifyExtraEmails[i].Verified = true
|
|
|
|
|
|
return s.userRepo.Update(ctx, user)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil // Already verified
|
2026-04-12 02:48:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-13 00:52:42 +08:00
|
|
|
|
if len(user.BalanceNotifyExtraEmails) >= maxNotifyEmails {
|
|
|
|
|
|
return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d notification emails allowed", maxNotifyEmails))
|
2026-04-12 02:48:57 +08:00
|
|
|
|
}
|
2026-04-13 00:52:42 +08:00
|
|
|
|
user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, NotifyEmailEntry{
|
|
|
|
|
|
Email: email,
|
|
|
|
|
|
Disabled: false,
|
|
|
|
|
|
Verified: true,
|
|
|
|
|
|
})
|
2026-04-12 02:48:57 +08:00
|
|
|
|
return s.userRepo.Update(ctx, user)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RemoveNotifyEmail removes an email from user's extra notification emails.
|
|
|
|
|
|
func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email string) error {
|
|
|
|
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 00:52:42 +08:00
|
|
|
|
filtered := make([]NotifyEmailEntry, 0, len(user.BalanceNotifyExtraEmails))
|
2026-04-14 00:26:20 +08:00
|
|
|
|
found := false
|
2026-04-12 02:48:57 +08:00
|
|
|
|
for _, e := range user.BalanceNotifyExtraEmails {
|
2026-04-14 00:26:20 +08:00
|
|
|
|
if strings.EqualFold(e.Email, email) {
|
|
|
|
|
|
found = true
|
|
|
|
|
|
} else {
|
2026-04-12 02:48:57 +08:00
|
|
|
|
filtered = append(filtered, e)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-14 00:26:20 +08:00
|
|
|
|
if !found {
|
|
|
|
|
|
return infraerrors.BadRequest("EMAIL_NOT_FOUND", "notification email not found")
|
|
|
|
|
|
}
|
2026-04-12 02:48:57 +08:00
|
|
|
|
user.BalanceNotifyExtraEmails = filtered
|
|
|
|
|
|
return s.userRepo.Update(ctx, user)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 00:52:42 +08:00
|
|
|
|
// ToggleNotifyEmail toggles the disabled state of a notification email entry.
|
|
|
|
|
|
func (s *UserService) ToggleNotifyEmail(ctx context.Context, userID int64, email string, disabled bool) error {
|
|
|
|
|
|
user, err := s.userRepo.GetByID(ctx, userID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
found := false
|
|
|
|
|
|
for i, e := range user.BalanceNotifyExtraEmails {
|
|
|
|
|
|
if strings.EqualFold(e.Email, email) {
|
|
|
|
|
|
user.BalanceNotifyExtraEmails[i].Disabled = disabled
|
|
|
|
|
|
found = true
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !found {
|
|
|
|
|
|
return infraerrors.BadRequest("EMAIL_NOT_FOUND", "notification email not found")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return s.userRepo.Update(ctx, user)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 13:59:35 +08:00
|
|
|
|
// notifyVerifyEmailTemplate is the HTML template for notify email verification.
|
|
|
|
|
|
// Format args: siteName, code.
|
|
|
|
|
|
const notifyVerifyEmailTemplate = `<!DOCTYPE html>
|
2026-04-12 02:48:57 +08:00
|
|
|
|
<html>
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<style>
|
|
|
|
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
|
|
|
|
|
|
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
|
|
|
|
.header { background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); color: white; padding: 30px; text-align: center; }
|
|
|
|
|
|
.header h1 { margin: 0; font-size: 24px; }
|
|
|
|
|
|
.content { padding: 40px 30px; text-align: center; }
|
|
|
|
|
|
.code { font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #333; background-color: #f8f9fa; padding: 20px 30px; border-radius: 8px; display: inline-block; margin: 20px 0; font-family: monospace; }
|
|
|
|
|
|
.info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; }
|
|
|
|
|
|
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="container">
|
|
|
|
|
|
<div class="header">
|
|
|
|
|
|
<h1>%s</h1>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="content">
|
|
|
|
|
|
<p style="font-size: 18px; color: #333;">通知邮箱验证码 / Notification Email Verification</p>
|
|
|
|
|
|
<div class="code">%s</div>
|
|
|
|
|
|
<div class="info">
|
|
|
|
|
|
<p>您正在添加额外的通知邮箱,请输入此验证码完成验证。</p>
|
|
|
|
|
|
<p>You are adding an extra notification email. Please enter this code to verify.</p>
|
|
|
|
|
|
<p>此验证码将在 <strong>15 分钟</strong>后失效。</p>
|
|
|
|
|
|
<p>This code will expire in <strong>15 minutes</strong>.</p>
|
|
|
|
|
|
<p>如果您没有请求此验证码,请忽略此邮件。</p>
|
|
|
|
|
|
<p>If you did not request this code, please ignore this email.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="footer">
|
|
|
|
|
|
<p>此邮件由系统自动发送,请勿回复。/ This is an automated message, please do not reply.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
2026-04-13 13:59:35 +08:00
|
|
|
|
</html>`
|
|
|
|
|
|
|
|
|
|
|
|
// buildNotifyVerifyEmailBody builds the HTML email body for notify email verification.
|
|
|
|
|
|
func buildNotifyVerifyEmailBody(code, siteName string) string {
|
|
|
|
|
|
return fmt.Sprintf(notifyVerifyEmailTemplate, siteName, code)
|
2026-04-12 02:48:57 +08:00
|
|
|
|
}
|