| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463 |
- package service
- import (
- "bytes"
- "context"
- "crypto/sha256"
- "crypto/subtle"
- "encoding/base64"
- "encoding/hex"
- "fmt"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
- "image"
- "image/color"
- stddraw "image/draw"
- _ "image/gif"
- "image/jpeg"
- _ "image/png"
- "log/slog"
- "net/url"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
- xdraw "golang.org/x/image/draw"
- "golang.org/x/sync/singleflight"
- )
- var (
- ErrUserNotFound = infraerrors.NotFound("USER_NOT_FOUND", "user not found")
- ErrPasswordIncorrect = infraerrors.BadRequest("PASSWORD_INCORRECT", "current password is incorrect")
- ErrBalanceNegative = infraerrors.BadRequest("BALANCE_NEGATIVE", "balance cannot be negative")
- ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
- ErrNotifyCodeUserRateLimit = infraerrors.TooManyRequests("NOTIFY_CODE_USER_RATE_LIMIT", "too many verification codes requested, please try again later")
- 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")
- ErrIdentityProviderInvalid = infraerrors.BadRequest("IDENTITY_PROVIDER_INVALID", "identity provider is invalid")
- ErrIdentityRedirectInvalid = infraerrors.BadRequest("IDENTITY_REDIRECT_INVALID", "identity redirect path is invalid")
- ErrIdentityUnbindLastMethod = infraerrors.Conflict(
- "IDENTITY_UNBIND_LAST_METHOD",
- "bind another sign-in method before unbinding this provider",
- )
- )
- const (
- maxNotifyEmails = 3 // Maximum number of notification emails per user
- maxInlineAvatarBytes = 100 * 1024
- targetAvatarBytes = 20 * 1024
- // User-level rate limiting for notify email verification codes
- notifyCodeUserRateLimit = 5
- notifyCodeUserRateWindow = 10 * time.Minute
- defaultUserIdentityRedirect = "/settings/profile"
- userLastActiveMinTouch = 10 * time.Minute
- userLastActiveFailBackoff = 30 * time.Second
- )
- var (
- avatarScaleSteps = []float64{1, 0.92, 0.84, 0.76, 0.68, 0.6, 0.52, 0.44, 0.36}
- avatarQualitySteps = []int{88, 80, 72, 64, 56, 48, 40, 32}
- )
- // UserListFilters contains all filter options for listing users
- type UserListFilters struct {
- Status string // User status filter
- Role string // User role filter
- Search string // Search in email, username
- GroupName string // Filter by allowed group name (fuzzy match)
- // APIKeyGroupID filters users who own at least one non-soft-deleted API key
- // bound to this group (api_keys.group_id). 0 = no filter. Covers all three
- // group types since it matches the key's group directly, not allowed_groups.
- APIKeyGroupID int64
- Attributes map[int64]string // Custom attribute filters: attributeID -> value
- // IncludeSubscriptions controls whether ListWithFilters should load active subscriptions.
- // For large datasets this can be expensive; admin list pages should enable it on demand.
- // nil means not specified (default: load subscriptions for backward compatibility).
- IncludeSubscriptions *bool
- // IncludeDeleted 为 true 时绕过软删除过滤,返回含已删除(deleted_at 非空)的用户。
- // 仅供 /admin/usage 的 SearchUsers 端点使用,其他列表调用方不要设置。
- IncludeDeleted bool
- }
- // UserUpdateFields 声明 UserRepository.Update 允许写回的列。
- //
- // 未声明的列保持数据库当前值,不会被调用方手里的快照覆盖。用户行上有多条
- // 不经过 Update 的原子写入路径(DeductBalance/UpdateBalance 扣加余额、
- // UpdateConcurrency、BatchUpdateLimits、UpdateUserLastActiveAt 等),
- // status/role 也可能被其他流程并发改写。若 Update 无条件整行回写,
- // 一次"读-改-写"就会静默回滚这些并发结果(lost update),
- // 因此每个调用方必须显式声明它真正要改的列。
- //
- // 注意这里没有 balance / total_recharged:余额只能经由 AdjustBalance、
- // SetBalance、UpdateBalance、DeductBalance 等原子接口修改,Update 永远不碰它们。
- type UserUpdateFields struct {
- Email bool
- Username bool
- Notes bool
- PasswordHash bool
- Role bool
- Status bool
- Concurrency bool
- RPMLimit bool
- SignupSource bool
- LastLoginAt bool
- LastActiveAt bool
- // BalanceNotifySettings 覆盖 balance_notify_enabled / _threshold_type / _threshold。
- BalanceNotifySettings bool
- // BalanceNotifyExtraEmails 与上一项分开,避免"改通知阈值"覆盖并发的"加通知邮箱"。
- BalanceNotifyExtraEmails bool
- // AllowedGroups 为 true 时才同步 user_allowed_groups 关联表。
- AllowedGroups bool
- // RestrictPublicGroups 覆盖 restrict_public_groups 列。
- RestrictPublicGroups bool
- }
- // BalanceChange 记录一次余额变更前后的值。
- type BalanceChange struct {
- Old float64
- New float64
- }
- // IsEmpty 报告该次 Update 是否不写任何列(此时仓储直接返回,不产生写操作)。
- func (f UserUpdateFields) IsEmpty() bool {
- return f == UserUpdateFields{}
- }
- type UserRepository interface {
- Create(ctx context.Context, user *User) error
- // CreateWithEmailAliasGuard 创建用户,并在邮箱唯一性锁内复查"收件箱身份"是否已被占用
- // (+别名 / Gmail 点号 / FQDN 根点变体,见 NormalizeEmailForAliasDedup),
- // 冲突时返回 ErrEmailExists。仅注册路径使用:同一收件箱的多个别名变体并发注册时,
- // 服务层的前置查重会同时通过,必须由这里串行化兜底。管理员建号仍走 Create,不受限制。
- CreateWithEmailAliasGuard(ctx context.Context, user *User) error
- GetByID(ctx context.Context, id int64) (*User, error)
- // GetByIDIncludeDeleted 绕过软删除过滤按 ID 取用户(含已删)。仅供管理员审计/usage 点击使用。
- GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error)
- GetByEmail(ctx context.Context, email string) (*User, error)
- GetFirstAdmin(ctx context.Context) (*User, error)
- // Update 只写 fields 中显式声明的列,其余列保持库中当前值。
- Update(ctx context.Context, user *User, fields UserUpdateFields) error
- Delete(ctx context.Context, id int64) error
- 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
- List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error)
- ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error)
- GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error)
- GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error)
- UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error
- UpdateBalance(ctx context.Context, id int64, amount float64) error
- DeductBalance(ctx context.Context, id int64, amount float64) error
- // AdjustBalance 原子地把 delta 累加到余额上,并返回变更前后的值。结果为负时
- // 拒绝写入并返回 ErrBalanceNegative。管理员的加/扣款必须走这里而不是
- // "读余额→算新值→整行写回",否则并发的计费扣款会被旧快照抹掉。
- AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error)
- // SetBalance 原子地把余额置为 value(value 必须 >= 0),返回变更前后的值。
- SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error)
- UpdateConcurrency(ctx context.Context, id int64, amount int) error
- BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error)
- BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error)
- BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error)
- ExistsByEmail(ctx context.Context, email string) (bool, error)
- // ExistsByEmailAlias 判断是否已有账号与该邮箱指向同一收件箱(+别名 / Gmail 点号 /
- // FQDN 根点变体,见 NormalizeEmailForAliasDedup)。用于注册与发送验证码前的查重。
- ExistsByEmailAlias(ctx context.Context, email string) (bool, error)
- RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
- // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略)
- AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error
- // RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限
- RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error
- ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error)
- UnbindUserAuthProvider(ctx context.Context, userID int64, provider string) error
- // TOTP 双因素认证
- UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error
- EnableTotp(ctx context.Context, userID int64) error
- DisableTotp(ctx context.Context, userID int64) error
- }
- // RegistrationEmailDomainRepository 是生产用户仓储为非白名单域名单账户兜底策略提供的可选能力。
- // 它独立于 UserRepository,避免无关测试桩和服务消费者实现注册专用方法。
- type RegistrationEmailDomainRepository interface {
- CountUsersByEmailDomain(ctx context.Context, domain string) (int, error)
- CreateWithEmailAliasGuardAndDomainLimit(ctx context.Context, user *User, domain string) error
- }
- // RedeemUserAdjustmentRepository provides the atomic, floor-at-zero updates
- // used by negative-value redeem codes. It is intentionally narrower than
- // UserRepository because normal usage billing is allowed to overdraw.
- type RedeemUserAdjustmentRepository interface {
- ApplyRedeemBalanceAdjustment(ctx context.Context, id int64, delta float64) error
- ApplyRedeemConcurrencyAdjustment(ctx context.Context, id int64, delta int) error
- }
- 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"`
- AvatarURL string `json:"-"`
- 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"`
- NoteKey string `json:"note_key,omitempty"`
- Note string `json:"note,omitempty"`
- }
- type UserIdentitySummarySet struct {
- Email UserIdentitySummary `json:"email"`
- LinuxDo UserIdentitySummary `json:"linuxdo"`
- OIDC UserIdentitySummary `json:"oidc"`
- WeChat UserIdentitySummary `json:"wechat"`
- DingTalk UserIdentitySummary `json:"dingtalk"`
- }
- 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"`
- }
- const (
- userIdentityNoteEmailManagedFromProfile = "profile.authBindings.notes.emailManagedFromProfile"
- userIdentityNoteCanUnbind = "profile.authBindings.notes.canUnbind"
- userIdentityNoteBindAnotherBeforeUnbind = "profile.authBindings.notes.bindAnotherBeforeUnbind"
- )
- // UpdateProfileRequest 更新用户资料请求
- type UpdateProfileRequest struct {
- Email *string `json:"email"`
- Username *string `json:"username"`
- AvatarURL *string `json:"avatar_url"`
- Concurrency *int `json:"concurrency"`
- BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
- BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
- }
- 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
- }
- type userProfileIdentityTxRunner interface {
- WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error
- }
- // ChangePasswordRequest 修改密码请求
- type ChangePasswordRequest struct {
- CurrentPassword string `json:"current_password"`
- NewPassword string `json:"new_password"`
- }
- // UserService 用户服务
- type UserService struct {
- userRepo UserRepository
- settingRepo SettingRepository
- authCacheInvalidator APIKeyAuthCacheInvalidator
- billingCache BillingCache
- lastActiveTouchL1 sync.Map
- lastActiveTouchSF singleflight.Group
- }
- // NewUserService 创建用户服务实例
- func NewUserService(userRepo UserRepository, settingRepo SettingRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCache BillingCache) *UserService {
- return &UserService{
- userRepo: userRepo,
- settingRepo: settingRepo,
- authCacheInvalidator: authCacheInvalidator,
- billingCache: billingCache,
- }
- }
- // GetFirstAdmin 获取首个管理员用户(用于 Admin API Key 认证)
- func (s *UserService) GetFirstAdmin(ctx context.Context) (*User, error) {
- admin, err := s.userRepo.GetFirstAdmin(ctx)
- if err != nil {
- return nil, fmt.Errorf("get first admin: %w", err)
- }
- return admin, nil
- }
- // GetProfile 获取用户资料
- func (s *UserService) GetProfile(ctx context.Context, userID int64) (*User, error) {
- user, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- return nil, fmt.Errorf("get user: %w", err)
- }
- normalizeLoadedUserTokenVersion(user)
- if err := s.hydrateUserAvatar(ctx, user); err != nil {
- return nil, fmt.Errorf("get user avatar: %w", err)
- }
- return user, nil
- }
- 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
- }
- summaries := UserIdentitySummarySet{
- Email: s.buildEmailIdentitySummary(user, records),
- LinuxDo: s.buildProviderIdentitySummary("linuxdo", user, records),
- OIDC: s.buildProviderIdentitySummary("oidc", user, records),
- WeChat: s.buildProviderIdentitySummary("wechat", user, records),
- DingTalk: s.buildProviderIdentitySummary("dingtalk", user, records),
- }
- s.applyExplicitProviderAvailability(ctx, &summaries)
- return summaries, nil
- }
- func (s *UserService) applyExplicitProviderAvailability(ctx context.Context, summaries *UserIdentitySummarySet) {
- if s == nil || summaries == nil || s.settingRepo == nil {
- return
- }
- settings, err := s.settingRepo.GetMultiple(ctx, []string{
- SettingKeyLinuxDoConnectEnabled,
- SettingKeyOIDCConnectEnabled,
- SettingKeyWeChatConnectEnabled,
- SettingKeyWeChatConnectOpenEnabled,
- SettingKeyWeChatConnectMPEnabled,
- SettingKeyWeChatConnectMobileEnabled,
- SettingKeyWeChatConnectMode,
- SettingKeyDingTalkConnectEnabled,
- })
- if err != nil {
- return
- }
- if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
- disableIdentityBindAction(&summaries.LinuxDo)
- }
- if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
- disableIdentityBindAction(&summaries.DingTalk)
- }
- if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
- disableIdentityBindAction(&summaries.OIDC)
- }
- if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok && strings.TrimSpace(raw) != "" {
- if raw != "true" {
- disableIdentityBindAction(&summaries.WeChat)
- return
- }
- openEnabled, mpEnabled, _ := parseWeChatConnectCapabilitySettings(settings, true, settings[SettingKeyWeChatConnectMode])
- if !openEnabled && !mpEnabled {
- disableIdentityBindAction(&summaries.WeChat)
- }
- }
- }
- func disableIdentityBindAction(summary *UserIdentitySummary) {
- if summary == nil || summary.Bound {
- return
- }
- summary.CanBind = false
- summary.BindStartPath = ""
- }
- 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
- }
- func (s *UserService) UnbindUserAuthProvider(ctx context.Context, userID int64, provider string) (*User, error) {
- user, _, err := s.UnbindUserAuthProviderWithResult(ctx, userID, provider)
- return user, err
- }
- func (s *UserService) UnbindUserAuthProviderWithResult(ctx context.Context, userID int64, provider string) (*User, bool, error) {
- provider = normalizeUserIdentityProvider(provider)
- if provider == "" || provider == "email" {
- return nil, false, ErrIdentityProviderInvalid
- }
- user, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- return nil, false, fmt.Errorf("get user: %w", err)
- }
- records, err := s.listUserAuthIdentities(ctx, userID)
- if err != nil {
- return nil, false, err
- }
- if len(filterUserAuthIdentities(records, provider)) == 0 {
- return user, false, nil
- }
- if !s.canUnbindProvider(provider, user, records) {
- return nil, false, ErrIdentityUnbindLastMethod
- }
- if err := s.userRepo.UnbindUserAuthProvider(ctx, userID, provider); err != nil {
- return nil, false, err
- }
- if s.authCacheInvalidator != nil {
- s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
- }
- updatedUser, err := s.GetProfile(ctx, userID)
- if err != nil {
- return nil, false, err
- }
- return updatedUser, true, nil
- }
- // UpdateProfile 更新用户资料
- func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, error) {
- if txRunner, ok := s.userRepo.(userProfileIdentityTxRunner); ok {
- var (
- updated *User
- oldConcurrency int
- )
- if err := txRunner.WithUserProfileIdentityTx(ctx, func(txCtx context.Context) error {
- var err error
- updated, oldConcurrency, err = s.updateProfile(txCtx, userID, req)
- return err
- }); err != nil {
- return nil, err
- }
- if s.authCacheInvalidator != nil && updated != nil && updated.Concurrency != oldConcurrency {
- s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
- }
- return updated, nil
- }
- updated, oldConcurrency, err := s.updateProfile(ctx, userID, req)
- if err != nil {
- return nil, err
- }
- if s.authCacheInvalidator != nil && updated.Concurrency != oldConcurrency {
- s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
- }
- return updated, nil
- }
- func (s *UserService) updateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, int, error) {
- user, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- return nil, 0, fmt.Errorf("get user: %w", err)
- }
- oldConcurrency := user.Concurrency
- // fields 只登记本次请求真正带上的字段。余额、状态等列不由这里回写,
- // 否则并发的扣费与状态变更会被这份快照回滚。
- var fields UserUpdateFields
- // 更新字段
- if req.Email != nil {
- // 检查新邮箱是否已被使用
- exists, err := s.userRepo.ExistsByEmail(ctx, *req.Email)
- if err != nil {
- return nil, oldConcurrency, fmt.Errorf("check email exists: %w", err)
- }
- if exists && *req.Email != user.Email {
- return nil, oldConcurrency, ErrEmailExists
- }
- user.Email = *req.Email
- fields.Email = true
- }
- if req.Username != nil {
- user.Username = *req.Username
- fields.Username = true
- }
- if req.AvatarURL != nil {
- avatar, err := s.SetAvatar(ctx, userID, *req.AvatarURL)
- if err != nil {
- return nil, oldConcurrency, err
- }
- applyUserAvatar(user, avatar)
- }
- if req.Concurrency != nil {
- user.Concurrency = *req.Concurrency
- fields.Concurrency = true
- }
- if req.BalanceNotifyEnabled != nil {
- user.BalanceNotifyEnabled = *req.BalanceNotifyEnabled
- fields.BalanceNotifySettings = true
- }
- if req.BalanceNotifyThreshold != nil {
- if *req.BalanceNotifyThreshold <= 0 {
- user.BalanceNotifyThreshold = nil // clear to system default
- } else {
- user.BalanceNotifyThreshold = req.BalanceNotifyThreshold
- }
- fields.BalanceNotifySettings = true
- }
- if err := s.userRepo.Update(ctx, user, fields); err != nil {
- return nil, oldConcurrency, fmt.Errorf("update user: %w", err)
- }
- return user, oldConcurrency, nil
- }
- func (s *UserService) SetAvatar(ctx context.Context, userID int64, raw string) (*UserAvatar, error) {
- avatarValue := strings.TrimSpace(raw)
- if avatarValue == "" {
- if err := s.userRepo.DeleteUserAvatar(ctx, userID); err != nil {
- return nil, fmt.Errorf("delete avatar: %w", err)
- }
- return nil, nil
- }
- 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)
- }
- return avatar, nil
- }
- 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 ValidateUserAvatar(raw string) error {
- _, err := normalizeUserAvatarInput(raw)
- return err
- }
- 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
- }
- if len(decoded) > targetAvatarBytes {
- decoded, contentType, err = compressInlineAvatar(decoded)
- if err != nil {
- return UpsertUserAvatarInput{}, err
- }
- raw = "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(decoded)
- }
- sum := sha256.Sum256(decoded)
- return UpsertUserAvatarInput{
- StorageProvider: "inline",
- URL: raw,
- ContentType: contentType,
- ByteSize: len(decoded),
- SHA256: hex.EncodeToString(sum[:]),
- }, nil
- }
- func compressInlineAvatar(decoded []byte) ([]byte, string, error) {
- src, _, err := image.Decode(bytes.NewReader(decoded))
- if err != nil {
- return nil, "", ErrAvatarInvalid
- }
- srcBounds := src.Bounds()
- if srcBounds.Empty() {
- return nil, "", ErrAvatarInvalid
- }
- for _, scale := range avatarScaleSteps {
- width := max(1, int(float64(srcBounds.Dx())*scale))
- height := max(1, int(float64(srcBounds.Dy())*scale))
- dst := image.NewRGBA(image.Rect(0, 0, width, height))
- stddraw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, stddraw.Src)
- xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, srcBounds, stddraw.Over, nil)
- for _, quality := range avatarQualitySteps {
- var buf bytes.Buffer
- if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: quality}); err != nil {
- return nil, "", ErrAvatarInvalid
- }
- if buf.Len() <= targetAvatarBytes {
- return buf.Bytes(), "image/jpeg", nil
- }
- }
- }
- return nil, "", ErrAvatarTooLarge
- }
- func (s *UserService) buildEmailIdentitySummary(user *User, records []UserAuthIdentityRecord) UserIdentitySummary {
- summary := UserIdentitySummary{
- Provider: "email",
- CanBind: false,
- CanUnbind: false,
- NoteKey: userIdentityNoteEmailManagedFromProfile,
- Note: "Primary account email is managed from the profile form.",
- }
- if user == nil {
- return summary
- }
- filtered := filterUserAuthIdentities(records, "email")
- if len(filtered) > 0 {
- primary := selectPrimaryUserAuthIdentity(filtered)
- email := strings.TrimSpace(firstStringIdentityValue(primary.Metadata, "email"))
- if email == "" {
- email = strings.TrimSpace(primary.ProviderSubject)
- }
- if email == "" || isReservedEmail(email) {
- email = strings.TrimSpace(user.Email)
- }
- if email == "" || isReservedEmail(email) {
- email = strings.TrimSpace(primary.ProviderKey)
- }
- summary.Bound = true
- summary.BoundCount = len(filtered)
- summary.DisplayName = email
- summary.SubjectHint = maskEmailIdentity(email)
- summary.ProviderKey = strings.TrimSpace(primary.ProviderKey)
- summary.VerifiedAt = primary.VerifiedAt
- return summary
- }
- // Compatibility fallback for legacy normal-email users that predate auth_identities backfill.
- 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, user *User, 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.AvatarURL = strings.TrimSpace(firstStringIdentityValue(primary.Metadata, "avatar_url", "suggested_avatar_url", "headimgurl"))
- summary.SubjectHint = maskOpaqueIdentity(primary.ProviderSubject)
- summary.ProviderKey = strings.TrimSpace(primary.ProviderKey)
- summary.VerifiedAt = primary.VerifiedAt
- summary.CanUnbind = s.canUnbindProvider(provider, user, records)
- if summary.CanUnbind {
- summary.NoteKey = userIdentityNoteCanUnbind
- summary.Note = "You can unbind this sign-in method."
- } else {
- summary.NoteKey = userIdentityNoteBindAnotherBeforeUnbind
- summary.Note = "Bind another sign-in method before unbinding."
- }
- return summary
- }
- func (s *UserService) canUnbindProvider(provider string, user *User, records []UserAuthIdentityRecord) bool {
- if provider == "" || provider == "email" || len(filterUserAuthIdentities(records, provider)) == 0 {
- return false
- }
- if s.canUseEmailAsSignInMethod(user, records) {
- return true
- }
- for _, candidate := range []string{"linuxdo", "oidc", "wechat", "dingtalk"} {
- if candidate == provider {
- continue
- }
- if len(filterUserAuthIdentities(records, candidate)) > 0 {
- return true
- }
- }
- return false
- }
- func (s *UserService) canUseEmailAsSignInMethod(user *User, records []UserAuthIdentityRecord) bool {
- if user == nil {
- return false
- }
- email := strings.ToLower(strings.TrimSpace(user.Email))
- if email == "" || isReservedEmail(email) {
- return false
- }
- if emailSignupSourceAllowsLogin(user.SignupSource) {
- return true
- }
- for _, record := range filterUserAuthIdentities(records, "email") {
- if emailIdentitySupportsSignIn(record) {
- return true
- }
- }
- return false
- }
- func emailSignupSourceAllowsLogin(signupSource string) bool {
- signupSource = strings.ToLower(strings.TrimSpace(signupSource))
- return signupSource == "" || signupSource == "email"
- }
- func emailIdentitySupportsSignIn(record UserAuthIdentityRecord) bool {
- source := strings.TrimSpace(firstStringIdentityValue(record.Metadata, "source"))
- switch source {
- case "auth_service_email_bind", "auth_service_login_backfill", "auth_service_dual_write":
- return true
- default:
- return false
- }
- }
- 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/bind/start"
- case "oidc":
- path = "/api/v1/auth/oauth/oidc/bind/start"
- case "wechat":
- path = "/api/v1/auth/oauth/wechat/bind/start"
- case "dingtalk":
- path = "/api/v1/auth/oauth/dingtalk/bind/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 "dingtalk":
- return "dingtalk"
- 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:])
- }
- }
- // ChangePassword 修改密码
- // Security: Increments TokenVersion to invalidate all existing JWT tokens
- 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)
- }
- // 验证当前密码
- if !user.CheckPassword(req.CurrentPassword) {
- return ErrPasswordIncorrect
- }
- if err := user.SetPassword(req.NewPassword); err != nil {
- return fmt.Errorf("set password: %w", err)
- }
- // Increment TokenVersion to invalidate all existing tokens
- // This ensures that any tokens issued before the password change become invalid
- user.TokenVersion++
- // TokenVersion 没有对应的数据库列(见 resolvedTokenVersion:它由 email+password_hash
- // 指纹推导),改密写回 password_hash 即可让旧 token 失效。
- if err := s.userRepo.Update(ctx, user, UserUpdateFields{PasswordHash: true}); err != nil {
- return fmt.Errorf("update user: %w", err)
- }
- return nil
- }
- // GetByID 根据ID获取用户(管理员功能)
- func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
- user, err := s.userRepo.GetByID(ctx, id)
- if err != nil {
- return nil, fmt.Errorf("get user: %w", err)
- }
- normalizeLoadedUserTokenVersion(user)
- if err := s.hydrateUserAvatar(ctx, user); err != nil {
- return nil, fmt.Errorf("get user avatar: %w", err)
- }
- return user, nil
- }
- func normalizeLoadedUserTokenVersion(user *User) {
- if user == nil || user.TokenVersionResolved {
- return
- }
- user.TokenVersion = resolvedTokenVersion(user)
- user.TokenVersionResolved = true
- }
- // TouchLastActive 通过防抖更新 users.last_active_at,减少鉴权热路径写放大。
- // 该操作为尽力而为,不应中断正常请求。
- func (s *UserService) TouchLastActive(ctx context.Context, userID int64) {
- if s == nil || s.userRepo == nil || userID <= 0 {
- return
- }
- user, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- slog.Debug("skip touch user last active after load failure", "user_id", userID, "error", err)
- return
- }
- s.TouchLastActiveForUser(ctx, user)
- }
- // TouchLastActiveForUser 使用已加载的用户信息更新 last_active_at,避免重复读取数据库。
- func (s *UserService) TouchLastActiveForUser(ctx context.Context, user *User) {
- if s == nil || s.userRepo == nil || user == nil || user.ID <= 0 {
- return
- }
- now := time.Now()
- if userLastActiveFresh(user.LastActiveAt, now) {
- return
- }
- if v, ok := s.lastActiveTouchL1.Load(user.ID); ok {
- if nextAllowedAt, ok := v.(time.Time); ok && now.Before(nextAllowedAt) {
- return
- }
- }
- _, err, _ := s.lastActiveTouchSF.Do(strconv.FormatInt(user.ID, 10), func() (any, error) {
- latest := time.Now()
- if v, ok := s.lastActiveTouchL1.Load(user.ID); ok {
- if nextAllowedAt, ok := v.(time.Time); ok && latest.Before(nextAllowedAt) {
- return nil, nil
- }
- }
- if userLastActiveFresh(user.LastActiveAt, latest) {
- return nil, nil
- }
- if err := s.userRepo.UpdateUserLastActiveAt(ctx, user.ID, latest); err != nil {
- s.lastActiveTouchL1.Store(user.ID, latest.Add(userLastActiveFailBackoff))
- return nil, fmt.Errorf("touch user last active: %w", err)
- }
- s.lastActiveTouchL1.Store(user.ID, latest.Add(userLastActiveMinTouch))
- return nil, nil
- })
- if err != nil {
- slog.Warn("touch user last active failed", "user_id", user.ID, "error", err)
- }
- }
- func userLastActiveFresh(lastActiveAt *time.Time, now time.Time) bool {
- if lastActiveAt == nil {
- return false
- }
- return now.Before(lastActiveAt.Add(userLastActiveMinTouch))
- }
- 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
- }
- // List 获取用户列表(管理员功能)
- func (s *UserService) List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
- 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)
- }
- if s.authCacheInvalidator != nil {
- s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
- }
- if s.billingCache != nil {
- go func() {
- defer func() {
- if r := recover(); r != nil {
- slog.Error("panic in balance cache invalidation", "user_id", userID, "recover", r)
- }
- }()
- cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- if err := s.billingCache.InvalidateUserBalance(cacheCtx, userID); err != nil {
- slog.Error("invalidate user balance cache failed", "user_id", userID, "error", err)
- }
- }()
- }
- return nil
- }
- // 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)
- }
- if s.authCacheInvalidator != nil {
- s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
- }
- return nil
- }
- // 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, UserUpdateFields{Status: true}); err != nil {
- return fmt.Errorf("update user: %w", err)
- }
- if s.authCacheInvalidator != nil {
- s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
- }
- return nil
- }
- // Delete 删除用户(管理员功能)
- func (s *UserService) Delete(ctx context.Context, userID int64) error {
- if s.authCacheInvalidator != nil {
- s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
- }
- if err := s.userRepo.Delete(ctx, userID); err != nil {
- return fmt.Errorf("delete user: %w", err)
- }
- return nil
- }
- // 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, locale ...string) error {
- 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)
- }
- // 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, userID, email, code, firstEmailLocale(locale)); err != nil {
- return err
- }
- 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)
- }
- return nil
- }
- // checkNotifyCodeRateLimit checks both email cooldown and user-level rate limit.
- func checkNotifyCodeRateLimit(ctx context.Context, cache EmailCache, userID int64, email string) error {
- existing, err := cache.GetNotifyVerifyCode(ctx, email)
- if err == nil && existing != nil {
- if time.Since(existing.CreatedAt) < verifyCodeCooldown {
- return ErrVerifyCodeTooFrequent
- }
- }
- count, err := cache.GetNotifyCodeUserRate(ctx, userID)
- if err == nil && count >= notifyCodeUserRateLimit {
- return ErrNotifyCodeUserRateLimit
- }
- return nil
- }
- // saveNotifyVerifyCode saves the verification code to cache.
- func saveNotifyVerifyCode(ctx context.Context, cache EmailCache, email, code string) error {
- data := &VerificationCodeData{
- Code: code,
- Attempts: 0,
- CreatedAt: time.Now(),
- ExpiresAt: time.Now().Add(verifyCodeTTL),
- }
- if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil {
- return fmt.Errorf("save verify code: %w", err)
- }
- return nil
- }
- // sendNotifyVerifyEmail builds and sends the verification email.
- func (s *UserService) sendNotifyVerifyEmail(ctx context.Context, emailService *EmailService, userID int64, email, code, locale string) error {
- siteName := "Sub2API"
- if s.settingRepo != nil {
- if name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && name != "" {
- siteName = name
- }
- }
- if emailService.notificationEmailService != nil {
- if err := emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
- Event: NotificationEmailEventNotificationEmailVerifyCode,
- Locale: locale,
- RecipientEmail: email,
- RecipientName: emailRecipientName(email),
- UserID: userID,
- Variables: map[string]string{
- "verification_code": code,
- "expires_in_minutes": strconv.Itoa(int(verifyCodeTTL / time.Minute)),
- },
- }); err == nil {
- return nil
- } else {
- if !shouldFallbackNotificationEmail(err) {
- return err
- }
- slog.Warn("template notification email verification failed; falling back to built-in body", "recipient_hash", notificationEmailHash(email), "err", err.Error())
- }
- }
- 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 {
- 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 {
- 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++
- remaining := time.Until(data.ExpiresAt)
- if remaining <= 0 {
- return ErrInvalidVerifyCode
- }
- if err := cache.SetNotifyVerifyCode(ctx, email, data, remaining); err != nil {
- slog.Error("failed to update notify verify code attempts", "email", email, "error", err)
- }
- if data.Attempts >= maxVerifyCodeAttempts {
- return ErrVerifyCodeMaxAttempts
- }
- return ErrInvalidVerifyCode
- }
- return nil
- }
- // 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 {
- user, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- return err
- }
- for i, e := range user.BalanceNotifyExtraEmails {
- if strings.EqualFold(e.Email, email) {
- if !e.Verified {
- user.BalanceNotifyExtraEmails[i].Verified = true
- return s.userRepo.Update(ctx, user, UserUpdateFields{BalanceNotifyExtraEmails: true})
- }
- return nil // Already verified
- }
- }
- if len(user.BalanceNotifyExtraEmails) >= maxNotifyEmails {
- return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d notification emails allowed", maxNotifyEmails))
- }
- user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, NotifyEmailEntry{
- Email: email,
- Disabled: false,
- Verified: true,
- })
- return s.userRepo.Update(ctx, user, UserUpdateFields{BalanceNotifyExtraEmails: true})
- }
- // 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
- }
- filtered := make([]NotifyEmailEntry, 0, len(user.BalanceNotifyExtraEmails))
- found := false
- for _, e := range user.BalanceNotifyExtraEmails {
- if strings.EqualFold(e.Email, email) {
- found = true
- } else {
- filtered = append(filtered, e)
- }
- }
- if !found {
- return infraerrors.BadRequest("EMAIL_NOT_FOUND", "notification email not found")
- }
- user.BalanceNotifyExtraEmails = filtered
- return s.userRepo.Update(ctx, user, UserUpdateFields{BalanceNotifyExtraEmails: true})
- }
- // 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, UserUpdateFields{BalanceNotifyExtraEmails: true})
- }
- // notifyVerifyEmailTemplate is the HTML template for notify email verification.
- // Format args: siteName, code.
- const notifyVerifyEmailTemplate = `<!DOCTYPE html>
- <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>
- </html>`
- // buildNotifyVerifyEmailBody builds the HTML email body for notify email verification.
- func buildNotifyVerifyEmailBody(code, siteName string) string {
- return fmt.Sprintf(notifyVerifyEmailTemplate, siteName, code)
- }
|