user_service.go 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/sha256"
  6. "crypto/subtle"
  7. "encoding/base64"
  8. "encoding/hex"
  9. "fmt"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  12. "image"
  13. "image/color"
  14. stddraw "image/draw"
  15. _ "image/gif"
  16. "image/jpeg"
  17. _ "image/png"
  18. "log/slog"
  19. "net/url"
  20. "sort"
  21. "strconv"
  22. "strings"
  23. "sync"
  24. "time"
  25. xdraw "golang.org/x/image/draw"
  26. "golang.org/x/sync/singleflight"
  27. )
  28. var (
  29. ErrUserNotFound = infraerrors.NotFound("USER_NOT_FOUND", "user not found")
  30. ErrPasswordIncorrect = infraerrors.BadRequest("PASSWORD_INCORRECT", "current password is incorrect")
  31. ErrBalanceNegative = infraerrors.BadRequest("BALANCE_NEGATIVE", "balance cannot be negative")
  32. ErrInsufficientPerms = infraerrors.Forbidden("INSUFFICIENT_PERMISSIONS", "insufficient permissions")
  33. ErrNotifyCodeUserRateLimit = infraerrors.TooManyRequests("NOTIFY_CODE_USER_RATE_LIMIT", "too many verification codes requested, please try again later")
  34. ErrAvatarInvalid = infraerrors.BadRequest("AVATAR_INVALID", "avatar must be a valid image data URL or http(s) URL")
  35. ErrAvatarTooLarge = infraerrors.BadRequest("AVATAR_TOO_LARGE", "avatar image must be 100KB or smaller")
  36. ErrAvatarNotImage = infraerrors.BadRequest("AVATAR_NOT_IMAGE", "avatar content must be an image")
  37. ErrIdentityProviderInvalid = infraerrors.BadRequest("IDENTITY_PROVIDER_INVALID", "identity provider is invalid")
  38. ErrIdentityRedirectInvalid = infraerrors.BadRequest("IDENTITY_REDIRECT_INVALID", "identity redirect path is invalid")
  39. ErrIdentityUnbindLastMethod = infraerrors.Conflict(
  40. "IDENTITY_UNBIND_LAST_METHOD",
  41. "bind another sign-in method before unbinding this provider",
  42. )
  43. )
  44. const (
  45. maxNotifyEmails = 3 // Maximum number of notification emails per user
  46. maxInlineAvatarBytes = 100 * 1024
  47. targetAvatarBytes = 20 * 1024
  48. // User-level rate limiting for notify email verification codes
  49. notifyCodeUserRateLimit = 5
  50. notifyCodeUserRateWindow = 10 * time.Minute
  51. defaultUserIdentityRedirect = "/settings/profile"
  52. userLastActiveMinTouch = 10 * time.Minute
  53. userLastActiveFailBackoff = 30 * time.Second
  54. )
  55. var (
  56. avatarScaleSteps = []float64{1, 0.92, 0.84, 0.76, 0.68, 0.6, 0.52, 0.44, 0.36}
  57. avatarQualitySteps = []int{88, 80, 72, 64, 56, 48, 40, 32}
  58. )
  59. // UserListFilters contains all filter options for listing users
  60. type UserListFilters struct {
  61. Status string // User status filter
  62. Role string // User role filter
  63. Search string // Search in email, username
  64. GroupName string // Filter by allowed group name (fuzzy match)
  65. // APIKeyGroupID filters users who own at least one non-soft-deleted API key
  66. // bound to this group (api_keys.group_id). 0 = no filter. Covers all three
  67. // group types since it matches the key's group directly, not allowed_groups.
  68. APIKeyGroupID int64
  69. Attributes map[int64]string // Custom attribute filters: attributeID -> value
  70. // IncludeSubscriptions controls whether ListWithFilters should load active subscriptions.
  71. // For large datasets this can be expensive; admin list pages should enable it on demand.
  72. // nil means not specified (default: load subscriptions for backward compatibility).
  73. IncludeSubscriptions *bool
  74. // IncludeDeleted 为 true 时绕过软删除过滤,返回含已删除(deleted_at 非空)的用户。
  75. // 仅供 /admin/usage 的 SearchUsers 端点使用,其他列表调用方不要设置。
  76. IncludeDeleted bool
  77. }
  78. // UserUpdateFields 声明 UserRepository.Update 允许写回的列。
  79. //
  80. // 未声明的列保持数据库当前值,不会被调用方手里的快照覆盖。用户行上有多条
  81. // 不经过 Update 的原子写入路径(DeductBalance/UpdateBalance 扣加余额、
  82. // UpdateConcurrency、BatchUpdateLimits、UpdateUserLastActiveAt 等),
  83. // status/role 也可能被其他流程并发改写。若 Update 无条件整行回写,
  84. // 一次"读-改-写"就会静默回滚这些并发结果(lost update),
  85. // 因此每个调用方必须显式声明它真正要改的列。
  86. //
  87. // 注意这里没有 balance / total_recharged:余额只能经由 AdjustBalance、
  88. // SetBalance、UpdateBalance、DeductBalance 等原子接口修改,Update 永远不碰它们。
  89. type UserUpdateFields struct {
  90. Email bool
  91. Username bool
  92. Notes bool
  93. PasswordHash bool
  94. Role bool
  95. Status bool
  96. Concurrency bool
  97. RPMLimit bool
  98. SignupSource bool
  99. LastLoginAt bool
  100. LastActiveAt bool
  101. // BalanceNotifySettings 覆盖 balance_notify_enabled / _threshold_type / _threshold。
  102. BalanceNotifySettings bool
  103. // BalanceNotifyExtraEmails 与上一项分开,避免"改通知阈值"覆盖并发的"加通知邮箱"。
  104. BalanceNotifyExtraEmails bool
  105. // AllowedGroups 为 true 时才同步 user_allowed_groups 关联表。
  106. AllowedGroups bool
  107. // RestrictPublicGroups 覆盖 restrict_public_groups 列。
  108. RestrictPublicGroups bool
  109. }
  110. // BalanceChange 记录一次余额变更前后的值。
  111. type BalanceChange struct {
  112. Old float64
  113. New float64
  114. }
  115. // IsEmpty 报告该次 Update 是否不写任何列(此时仓储直接返回,不产生写操作)。
  116. func (f UserUpdateFields) IsEmpty() bool {
  117. return f == UserUpdateFields{}
  118. }
  119. type UserRepository interface {
  120. Create(ctx context.Context, user *User) error
  121. // CreateWithEmailAliasGuard 创建用户,并在邮箱唯一性锁内复查"收件箱身份"是否已被占用
  122. // (+别名 / Gmail 点号 / FQDN 根点变体,见 NormalizeEmailForAliasDedup),
  123. // 冲突时返回 ErrEmailExists。仅注册路径使用:同一收件箱的多个别名变体并发注册时,
  124. // 服务层的前置查重会同时通过,必须由这里串行化兜底。管理员建号仍走 Create,不受限制。
  125. CreateWithEmailAliasGuard(ctx context.Context, user *User) error
  126. GetByID(ctx context.Context, id int64) (*User, error)
  127. // GetByIDIncludeDeleted 绕过软删除过滤按 ID 取用户(含已删)。仅供管理员审计/usage 点击使用。
  128. GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error)
  129. GetByEmail(ctx context.Context, email string) (*User, error)
  130. GetFirstAdmin(ctx context.Context) (*User, error)
  131. // Update 只写 fields 中显式声明的列,其余列保持库中当前值。
  132. Update(ctx context.Context, user *User, fields UserUpdateFields) error
  133. Delete(ctx context.Context, id int64) error
  134. GetUserAvatar(ctx context.Context, userID int64) (*UserAvatar, error)
  135. UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error)
  136. DeleteUserAvatar(ctx context.Context, userID int64) error
  137. List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error)
  138. ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error)
  139. GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error)
  140. GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error)
  141. UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error
  142. UpdateBalance(ctx context.Context, id int64, amount float64) error
  143. DeductBalance(ctx context.Context, id int64, amount float64) error
  144. // AdjustBalance 原子地把 delta 累加到余额上,并返回变更前后的值。结果为负时
  145. // 拒绝写入并返回 ErrBalanceNegative。管理员的加/扣款必须走这里而不是
  146. // "读余额→算新值→整行写回",否则并发的计费扣款会被旧快照抹掉。
  147. AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error)
  148. // SetBalance 原子地把余额置为 value(value 必须 >= 0),返回变更前后的值。
  149. SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error)
  150. UpdateConcurrency(ctx context.Context, id int64, amount int) error
  151. BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error)
  152. BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error)
  153. BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error)
  154. ExistsByEmail(ctx context.Context, email string) (bool, error)
  155. // ExistsByEmailAlias 判断是否已有账号与该邮箱指向同一收件箱(+别名 / Gmail 点号 /
  156. // FQDN 根点变体,见 NormalizeEmailForAliasDedup)。用于注册与发送验证码前的查重。
  157. ExistsByEmailAlias(ctx context.Context, email string) (bool, error)
  158. RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
  159. // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略)
  160. AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error
  161. // RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限
  162. RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error
  163. ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error)
  164. UnbindUserAuthProvider(ctx context.Context, userID int64, provider string) error
  165. // TOTP 双因素认证
  166. UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error
  167. EnableTotp(ctx context.Context, userID int64) error
  168. DisableTotp(ctx context.Context, userID int64) error
  169. }
  170. // RegistrationEmailDomainRepository 是生产用户仓储为非白名单域名单账户兜底策略提供的可选能力。
  171. // 它独立于 UserRepository,避免无关测试桩和服务消费者实现注册专用方法。
  172. type RegistrationEmailDomainRepository interface {
  173. CountUsersByEmailDomain(ctx context.Context, domain string) (int, error)
  174. CreateWithEmailAliasGuardAndDomainLimit(ctx context.Context, user *User, domain string) error
  175. }
  176. // RedeemUserAdjustmentRepository provides the atomic, floor-at-zero updates
  177. // used by negative-value redeem codes. It is intentionally narrower than
  178. // UserRepository because normal usage billing is allowed to overdraw.
  179. type RedeemUserAdjustmentRepository interface {
  180. ApplyRedeemBalanceAdjustment(ctx context.Context, id int64, delta float64) error
  181. ApplyRedeemConcurrencyAdjustment(ctx context.Context, id int64, delta int) error
  182. }
  183. type UserAuthIdentityRecord struct {
  184. ProviderType string
  185. ProviderKey string
  186. ProviderSubject string
  187. VerifiedAt *time.Time
  188. Issuer *string
  189. Metadata map[string]any
  190. CreatedAt time.Time
  191. UpdatedAt time.Time
  192. }
  193. type UserIdentitySummary struct {
  194. Provider string `json:"provider"`
  195. Bound bool `json:"bound"`
  196. BoundCount int `json:"bound_count"`
  197. DisplayName string `json:"display_name,omitempty"`
  198. AvatarURL string `json:"-"`
  199. SubjectHint string `json:"subject_hint,omitempty"`
  200. ProviderKey string `json:"provider_key,omitempty"`
  201. VerifiedAt *time.Time `json:"verified_at,omitempty"`
  202. BindStartPath string `json:"bind_start_path,omitempty"`
  203. CanBind bool `json:"can_bind"`
  204. CanUnbind bool `json:"can_unbind"`
  205. NoteKey string `json:"note_key,omitempty"`
  206. Note string `json:"note,omitempty"`
  207. }
  208. type UserIdentitySummarySet struct {
  209. Email UserIdentitySummary `json:"email"`
  210. LinuxDo UserIdentitySummary `json:"linuxdo"`
  211. OIDC UserIdentitySummary `json:"oidc"`
  212. WeChat UserIdentitySummary `json:"wechat"`
  213. DingTalk UserIdentitySummary `json:"dingtalk"`
  214. }
  215. type StartUserIdentityBindingRequest struct {
  216. Provider string
  217. RedirectTo string
  218. }
  219. type StartUserIdentityBindingResult struct {
  220. Provider string `json:"provider"`
  221. AuthorizeURL string `json:"authorize_url"`
  222. Method string `json:"method"`
  223. UseBrowserRedirect bool `json:"use_browser_redirect"`
  224. }
  225. const (
  226. userIdentityNoteEmailManagedFromProfile = "profile.authBindings.notes.emailManagedFromProfile"
  227. userIdentityNoteCanUnbind = "profile.authBindings.notes.canUnbind"
  228. userIdentityNoteBindAnotherBeforeUnbind = "profile.authBindings.notes.bindAnotherBeforeUnbind"
  229. )
  230. // UpdateProfileRequest 更新用户资料请求
  231. type UpdateProfileRequest struct {
  232. Email *string `json:"email"`
  233. Username *string `json:"username"`
  234. AvatarURL *string `json:"avatar_url"`
  235. Concurrency *int `json:"concurrency"`
  236. BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
  237. BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
  238. }
  239. type UserAvatar struct {
  240. StorageProvider string
  241. StorageKey string
  242. URL string
  243. ContentType string
  244. ByteSize int
  245. SHA256 string
  246. }
  247. type UpsertUserAvatarInput struct {
  248. StorageProvider string
  249. StorageKey string
  250. URL string
  251. ContentType string
  252. ByteSize int
  253. SHA256 string
  254. }
  255. type userProfileIdentityTxRunner interface {
  256. WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error
  257. }
  258. // ChangePasswordRequest 修改密码请求
  259. type ChangePasswordRequest struct {
  260. CurrentPassword string `json:"current_password"`
  261. NewPassword string `json:"new_password"`
  262. }
  263. // UserService 用户服务
  264. type UserService struct {
  265. userRepo UserRepository
  266. settingRepo SettingRepository
  267. authCacheInvalidator APIKeyAuthCacheInvalidator
  268. billingCache BillingCache
  269. lastActiveTouchL1 sync.Map
  270. lastActiveTouchSF singleflight.Group
  271. }
  272. // NewUserService 创建用户服务实例
  273. func NewUserService(userRepo UserRepository, settingRepo SettingRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, billingCache BillingCache) *UserService {
  274. return &UserService{
  275. userRepo: userRepo,
  276. settingRepo: settingRepo,
  277. authCacheInvalidator: authCacheInvalidator,
  278. billingCache: billingCache,
  279. }
  280. }
  281. // GetFirstAdmin 获取首个管理员用户(用于 Admin API Key 认证)
  282. func (s *UserService) GetFirstAdmin(ctx context.Context) (*User, error) {
  283. admin, err := s.userRepo.GetFirstAdmin(ctx)
  284. if err != nil {
  285. return nil, fmt.Errorf("get first admin: %w", err)
  286. }
  287. return admin, nil
  288. }
  289. // GetProfile 获取用户资料
  290. func (s *UserService) GetProfile(ctx context.Context, userID int64) (*User, error) {
  291. user, err := s.userRepo.GetByID(ctx, userID)
  292. if err != nil {
  293. return nil, fmt.Errorf("get user: %w", err)
  294. }
  295. normalizeLoadedUserTokenVersion(user)
  296. if err := s.hydrateUserAvatar(ctx, user); err != nil {
  297. return nil, fmt.Errorf("get user avatar: %w", err)
  298. }
  299. return user, nil
  300. }
  301. func (s *UserService) GetProfileIdentitySummaries(ctx context.Context, userID int64, user *User) (UserIdentitySummarySet, error) {
  302. if user == nil {
  303. var err error
  304. user, err = s.userRepo.GetByID(ctx, userID)
  305. if err != nil {
  306. return UserIdentitySummarySet{}, fmt.Errorf("get user: %w", err)
  307. }
  308. }
  309. records, err := s.listUserAuthIdentities(ctx, userID)
  310. if err != nil {
  311. return UserIdentitySummarySet{}, err
  312. }
  313. summaries := UserIdentitySummarySet{
  314. Email: s.buildEmailIdentitySummary(user, records),
  315. LinuxDo: s.buildProviderIdentitySummary("linuxdo", user, records),
  316. OIDC: s.buildProviderIdentitySummary("oidc", user, records),
  317. WeChat: s.buildProviderIdentitySummary("wechat", user, records),
  318. DingTalk: s.buildProviderIdentitySummary("dingtalk", user, records),
  319. }
  320. s.applyExplicitProviderAvailability(ctx, &summaries)
  321. return summaries, nil
  322. }
  323. func (s *UserService) applyExplicitProviderAvailability(ctx context.Context, summaries *UserIdentitySummarySet) {
  324. if s == nil || summaries == nil || s.settingRepo == nil {
  325. return
  326. }
  327. settings, err := s.settingRepo.GetMultiple(ctx, []string{
  328. SettingKeyLinuxDoConnectEnabled,
  329. SettingKeyOIDCConnectEnabled,
  330. SettingKeyWeChatConnectEnabled,
  331. SettingKeyWeChatConnectOpenEnabled,
  332. SettingKeyWeChatConnectMPEnabled,
  333. SettingKeyWeChatConnectMobileEnabled,
  334. SettingKeyWeChatConnectMode,
  335. SettingKeyDingTalkConnectEnabled,
  336. })
  337. if err != nil {
  338. return
  339. }
  340. if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
  341. disableIdentityBindAction(&summaries.LinuxDo)
  342. }
  343. if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
  344. disableIdentityBindAction(&summaries.DingTalk)
  345. }
  346. if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" {
  347. disableIdentityBindAction(&summaries.OIDC)
  348. }
  349. if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok && strings.TrimSpace(raw) != "" {
  350. if raw != "true" {
  351. disableIdentityBindAction(&summaries.WeChat)
  352. return
  353. }
  354. openEnabled, mpEnabled, _ := parseWeChatConnectCapabilitySettings(settings, true, settings[SettingKeyWeChatConnectMode])
  355. if !openEnabled && !mpEnabled {
  356. disableIdentityBindAction(&summaries.WeChat)
  357. }
  358. }
  359. }
  360. func disableIdentityBindAction(summary *UserIdentitySummary) {
  361. if summary == nil || summary.Bound {
  362. return
  363. }
  364. summary.CanBind = false
  365. summary.BindStartPath = ""
  366. }
  367. func (s *UserService) PrepareIdentityBindingStart(_ context.Context, req StartUserIdentityBindingRequest) (*StartUserIdentityBindingResult, error) {
  368. provider := normalizeUserIdentityProvider(req.Provider)
  369. if provider == "" {
  370. return nil, ErrIdentityProviderInvalid
  371. }
  372. authorizeURL, err := buildUserIdentityBindAuthorizeURL(provider, req.RedirectTo)
  373. if err != nil {
  374. return nil, err
  375. }
  376. return &StartUserIdentityBindingResult{
  377. Provider: provider,
  378. AuthorizeURL: authorizeURL,
  379. Method: "GET",
  380. UseBrowserRedirect: true,
  381. }, nil
  382. }
  383. func (s *UserService) UnbindUserAuthProvider(ctx context.Context, userID int64, provider string) (*User, error) {
  384. user, _, err := s.UnbindUserAuthProviderWithResult(ctx, userID, provider)
  385. return user, err
  386. }
  387. func (s *UserService) UnbindUserAuthProviderWithResult(ctx context.Context, userID int64, provider string) (*User, bool, error) {
  388. provider = normalizeUserIdentityProvider(provider)
  389. if provider == "" || provider == "email" {
  390. return nil, false, ErrIdentityProviderInvalid
  391. }
  392. user, err := s.userRepo.GetByID(ctx, userID)
  393. if err != nil {
  394. return nil, false, fmt.Errorf("get user: %w", err)
  395. }
  396. records, err := s.listUserAuthIdentities(ctx, userID)
  397. if err != nil {
  398. return nil, false, err
  399. }
  400. if len(filterUserAuthIdentities(records, provider)) == 0 {
  401. return user, false, nil
  402. }
  403. if !s.canUnbindProvider(provider, user, records) {
  404. return nil, false, ErrIdentityUnbindLastMethod
  405. }
  406. if err := s.userRepo.UnbindUserAuthProvider(ctx, userID, provider); err != nil {
  407. return nil, false, err
  408. }
  409. if s.authCacheInvalidator != nil {
  410. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  411. }
  412. updatedUser, err := s.GetProfile(ctx, userID)
  413. if err != nil {
  414. return nil, false, err
  415. }
  416. return updatedUser, true, nil
  417. }
  418. // UpdateProfile 更新用户资料
  419. func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, error) {
  420. if txRunner, ok := s.userRepo.(userProfileIdentityTxRunner); ok {
  421. var (
  422. updated *User
  423. oldConcurrency int
  424. )
  425. if err := txRunner.WithUserProfileIdentityTx(ctx, func(txCtx context.Context) error {
  426. var err error
  427. updated, oldConcurrency, err = s.updateProfile(txCtx, userID, req)
  428. return err
  429. }); err != nil {
  430. return nil, err
  431. }
  432. if s.authCacheInvalidator != nil && updated != nil && updated.Concurrency != oldConcurrency {
  433. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  434. }
  435. return updated, nil
  436. }
  437. updated, oldConcurrency, err := s.updateProfile(ctx, userID, req)
  438. if err != nil {
  439. return nil, err
  440. }
  441. if s.authCacheInvalidator != nil && updated.Concurrency != oldConcurrency {
  442. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  443. }
  444. return updated, nil
  445. }
  446. func (s *UserService) updateProfile(ctx context.Context, userID int64, req UpdateProfileRequest) (*User, int, error) {
  447. user, err := s.userRepo.GetByID(ctx, userID)
  448. if err != nil {
  449. return nil, 0, fmt.Errorf("get user: %w", err)
  450. }
  451. oldConcurrency := user.Concurrency
  452. // fields 只登记本次请求真正带上的字段。余额、状态等列不由这里回写,
  453. // 否则并发的扣费与状态变更会被这份快照回滚。
  454. var fields UserUpdateFields
  455. // 更新字段
  456. if req.Email != nil {
  457. // 检查新邮箱是否已被使用
  458. exists, err := s.userRepo.ExistsByEmail(ctx, *req.Email)
  459. if err != nil {
  460. return nil, oldConcurrency, fmt.Errorf("check email exists: %w", err)
  461. }
  462. if exists && *req.Email != user.Email {
  463. return nil, oldConcurrency, ErrEmailExists
  464. }
  465. user.Email = *req.Email
  466. fields.Email = true
  467. }
  468. if req.Username != nil {
  469. user.Username = *req.Username
  470. fields.Username = true
  471. }
  472. if req.AvatarURL != nil {
  473. avatar, err := s.SetAvatar(ctx, userID, *req.AvatarURL)
  474. if err != nil {
  475. return nil, oldConcurrency, err
  476. }
  477. applyUserAvatar(user, avatar)
  478. }
  479. if req.Concurrency != nil {
  480. user.Concurrency = *req.Concurrency
  481. fields.Concurrency = true
  482. }
  483. if req.BalanceNotifyEnabled != nil {
  484. user.BalanceNotifyEnabled = *req.BalanceNotifyEnabled
  485. fields.BalanceNotifySettings = true
  486. }
  487. if req.BalanceNotifyThreshold != nil {
  488. if *req.BalanceNotifyThreshold <= 0 {
  489. user.BalanceNotifyThreshold = nil // clear to system default
  490. } else {
  491. user.BalanceNotifyThreshold = req.BalanceNotifyThreshold
  492. }
  493. fields.BalanceNotifySettings = true
  494. }
  495. if err := s.userRepo.Update(ctx, user, fields); err != nil {
  496. return nil, oldConcurrency, fmt.Errorf("update user: %w", err)
  497. }
  498. return user, oldConcurrency, nil
  499. }
  500. func (s *UserService) SetAvatar(ctx context.Context, userID int64, raw string) (*UserAvatar, error) {
  501. avatarValue := strings.TrimSpace(raw)
  502. if avatarValue == "" {
  503. if err := s.userRepo.DeleteUserAvatar(ctx, userID); err != nil {
  504. return nil, fmt.Errorf("delete avatar: %w", err)
  505. }
  506. return nil, nil
  507. }
  508. avatarInput, err := normalizeUserAvatarInput(avatarValue)
  509. if err != nil {
  510. return nil, err
  511. }
  512. avatar, err := s.userRepo.UpsertUserAvatar(ctx, userID, avatarInput)
  513. if err != nil {
  514. return nil, fmt.Errorf("upsert avatar: %w", err)
  515. }
  516. return avatar, nil
  517. }
  518. func applyUserAvatar(user *User, avatar *UserAvatar) {
  519. if user == nil {
  520. return
  521. }
  522. if avatar == nil {
  523. user.AvatarURL = ""
  524. user.AvatarSource = ""
  525. user.AvatarMIME = ""
  526. user.AvatarByteSize = 0
  527. user.AvatarSHA256 = ""
  528. return
  529. }
  530. user.AvatarURL = avatar.URL
  531. user.AvatarSource = avatar.StorageProvider
  532. user.AvatarMIME = avatar.ContentType
  533. user.AvatarByteSize = avatar.ByteSize
  534. user.AvatarSHA256 = avatar.SHA256
  535. }
  536. func normalizeUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
  537. raw = strings.TrimSpace(raw)
  538. if raw == "" {
  539. return UpsertUserAvatarInput{}, ErrAvatarInvalid
  540. }
  541. if strings.HasPrefix(raw, "data:") {
  542. return normalizeInlineUserAvatarInput(raw)
  543. }
  544. parsed, err := url.Parse(raw)
  545. if err != nil || parsed == nil {
  546. return UpsertUserAvatarInput{}, ErrAvatarInvalid
  547. }
  548. if !strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https") {
  549. return UpsertUserAvatarInput{}, ErrAvatarInvalid
  550. }
  551. if strings.TrimSpace(parsed.Host) == "" {
  552. return UpsertUserAvatarInput{}, ErrAvatarInvalid
  553. }
  554. return UpsertUserAvatarInput{
  555. StorageProvider: "remote_url",
  556. URL: raw,
  557. }, nil
  558. }
  559. func ValidateUserAvatar(raw string) error {
  560. _, err := normalizeUserAvatarInput(raw)
  561. return err
  562. }
  563. func normalizeInlineUserAvatarInput(raw string) (UpsertUserAvatarInput, error) {
  564. body := strings.TrimPrefix(raw, "data:")
  565. meta, encoded, ok := strings.Cut(body, ",")
  566. if !ok {
  567. return UpsertUserAvatarInput{}, ErrAvatarInvalid
  568. }
  569. meta = strings.TrimSpace(meta)
  570. encoded = strings.TrimSpace(encoded)
  571. if !strings.HasSuffix(strings.ToLower(meta), ";base64") {
  572. return UpsertUserAvatarInput{}, ErrAvatarInvalid
  573. }
  574. contentType := strings.TrimSpace(meta[:len(meta)-len(";base64")])
  575. if contentType == "" || !strings.HasPrefix(strings.ToLower(contentType), "image/") {
  576. return UpsertUserAvatarInput{}, ErrAvatarNotImage
  577. }
  578. decoded, err := base64.StdEncoding.DecodeString(encoded)
  579. if err != nil {
  580. return UpsertUserAvatarInput{}, ErrAvatarInvalid
  581. }
  582. if len(decoded) > maxInlineAvatarBytes {
  583. return UpsertUserAvatarInput{}, ErrAvatarTooLarge
  584. }
  585. if len(decoded) > targetAvatarBytes {
  586. decoded, contentType, err = compressInlineAvatar(decoded)
  587. if err != nil {
  588. return UpsertUserAvatarInput{}, err
  589. }
  590. raw = "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(decoded)
  591. }
  592. sum := sha256.Sum256(decoded)
  593. return UpsertUserAvatarInput{
  594. StorageProvider: "inline",
  595. URL: raw,
  596. ContentType: contentType,
  597. ByteSize: len(decoded),
  598. SHA256: hex.EncodeToString(sum[:]),
  599. }, nil
  600. }
  601. func compressInlineAvatar(decoded []byte) ([]byte, string, error) {
  602. src, _, err := image.Decode(bytes.NewReader(decoded))
  603. if err != nil {
  604. return nil, "", ErrAvatarInvalid
  605. }
  606. srcBounds := src.Bounds()
  607. if srcBounds.Empty() {
  608. return nil, "", ErrAvatarInvalid
  609. }
  610. for _, scale := range avatarScaleSteps {
  611. width := max(1, int(float64(srcBounds.Dx())*scale))
  612. height := max(1, int(float64(srcBounds.Dy())*scale))
  613. dst := image.NewRGBA(image.Rect(0, 0, width, height))
  614. stddraw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, stddraw.Src)
  615. xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, srcBounds, stddraw.Over, nil)
  616. for _, quality := range avatarQualitySteps {
  617. var buf bytes.Buffer
  618. if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: quality}); err != nil {
  619. return nil, "", ErrAvatarInvalid
  620. }
  621. if buf.Len() <= targetAvatarBytes {
  622. return buf.Bytes(), "image/jpeg", nil
  623. }
  624. }
  625. }
  626. return nil, "", ErrAvatarTooLarge
  627. }
  628. func (s *UserService) buildEmailIdentitySummary(user *User, records []UserAuthIdentityRecord) UserIdentitySummary {
  629. summary := UserIdentitySummary{
  630. Provider: "email",
  631. CanBind: false,
  632. CanUnbind: false,
  633. NoteKey: userIdentityNoteEmailManagedFromProfile,
  634. Note: "Primary account email is managed from the profile form.",
  635. }
  636. if user == nil {
  637. return summary
  638. }
  639. filtered := filterUserAuthIdentities(records, "email")
  640. if len(filtered) > 0 {
  641. primary := selectPrimaryUserAuthIdentity(filtered)
  642. email := strings.TrimSpace(firstStringIdentityValue(primary.Metadata, "email"))
  643. if email == "" {
  644. email = strings.TrimSpace(primary.ProviderSubject)
  645. }
  646. if email == "" || isReservedEmail(email) {
  647. email = strings.TrimSpace(user.Email)
  648. }
  649. if email == "" || isReservedEmail(email) {
  650. email = strings.TrimSpace(primary.ProviderKey)
  651. }
  652. summary.Bound = true
  653. summary.BoundCount = len(filtered)
  654. summary.DisplayName = email
  655. summary.SubjectHint = maskEmailIdentity(email)
  656. summary.ProviderKey = strings.TrimSpace(primary.ProviderKey)
  657. summary.VerifiedAt = primary.VerifiedAt
  658. return summary
  659. }
  660. // Compatibility fallback for legacy normal-email users that predate auth_identities backfill.
  661. email := strings.TrimSpace(user.Email)
  662. if email == "" || isReservedEmail(email) {
  663. return summary
  664. }
  665. summary.Bound = true
  666. summary.BoundCount = 1
  667. summary.DisplayName = email
  668. summary.SubjectHint = maskEmailIdentity(email)
  669. summary.ProviderKey = "email"
  670. return summary
  671. }
  672. func (s *UserService) buildProviderIdentitySummary(provider string, user *User, records []UserAuthIdentityRecord) UserIdentitySummary {
  673. summary := UserIdentitySummary{
  674. Provider: provider,
  675. CanUnbind: false,
  676. }
  677. filtered := filterUserAuthIdentities(records, provider)
  678. if len(filtered) == 0 {
  679. summary.CanBind = true
  680. bindStartPath, err := buildUserIdentityBindAuthorizeURL(provider, "")
  681. if err == nil {
  682. summary.BindStartPath = bindStartPath
  683. }
  684. return summary
  685. }
  686. primary := selectPrimaryUserAuthIdentity(filtered)
  687. summary.Bound = true
  688. summary.BoundCount = len(filtered)
  689. summary.DisplayName = userAuthIdentityDisplayName(primary)
  690. summary.AvatarURL = strings.TrimSpace(firstStringIdentityValue(primary.Metadata, "avatar_url", "suggested_avatar_url", "headimgurl"))
  691. summary.SubjectHint = maskOpaqueIdentity(primary.ProviderSubject)
  692. summary.ProviderKey = strings.TrimSpace(primary.ProviderKey)
  693. summary.VerifiedAt = primary.VerifiedAt
  694. summary.CanUnbind = s.canUnbindProvider(provider, user, records)
  695. if summary.CanUnbind {
  696. summary.NoteKey = userIdentityNoteCanUnbind
  697. summary.Note = "You can unbind this sign-in method."
  698. } else {
  699. summary.NoteKey = userIdentityNoteBindAnotherBeforeUnbind
  700. summary.Note = "Bind another sign-in method before unbinding."
  701. }
  702. return summary
  703. }
  704. func (s *UserService) canUnbindProvider(provider string, user *User, records []UserAuthIdentityRecord) bool {
  705. if provider == "" || provider == "email" || len(filterUserAuthIdentities(records, provider)) == 0 {
  706. return false
  707. }
  708. if s.canUseEmailAsSignInMethod(user, records) {
  709. return true
  710. }
  711. for _, candidate := range []string{"linuxdo", "oidc", "wechat", "dingtalk"} {
  712. if candidate == provider {
  713. continue
  714. }
  715. if len(filterUserAuthIdentities(records, candidate)) > 0 {
  716. return true
  717. }
  718. }
  719. return false
  720. }
  721. func (s *UserService) canUseEmailAsSignInMethod(user *User, records []UserAuthIdentityRecord) bool {
  722. if user == nil {
  723. return false
  724. }
  725. email := strings.ToLower(strings.TrimSpace(user.Email))
  726. if email == "" || isReservedEmail(email) {
  727. return false
  728. }
  729. if emailSignupSourceAllowsLogin(user.SignupSource) {
  730. return true
  731. }
  732. for _, record := range filterUserAuthIdentities(records, "email") {
  733. if emailIdentitySupportsSignIn(record) {
  734. return true
  735. }
  736. }
  737. return false
  738. }
  739. func emailSignupSourceAllowsLogin(signupSource string) bool {
  740. signupSource = strings.ToLower(strings.TrimSpace(signupSource))
  741. return signupSource == "" || signupSource == "email"
  742. }
  743. func emailIdentitySupportsSignIn(record UserAuthIdentityRecord) bool {
  744. source := strings.TrimSpace(firstStringIdentityValue(record.Metadata, "source"))
  745. switch source {
  746. case "auth_service_email_bind", "auth_service_login_backfill", "auth_service_dual_write":
  747. return true
  748. default:
  749. return false
  750. }
  751. }
  752. func (s *UserService) listUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error) {
  753. if userID <= 0 || s == nil || s.userRepo == nil {
  754. return nil, nil
  755. }
  756. return s.userRepo.ListUserAuthIdentities(ctx, userID)
  757. }
  758. func buildUserIdentityBindAuthorizeURL(provider, redirectTo string) (string, error) {
  759. provider = normalizeUserIdentityProvider(provider)
  760. if provider == "" || provider == "email" {
  761. return "", ErrIdentityProviderInvalid
  762. }
  763. redirectTo, err := normalizeUserIdentityRedirect(redirectTo)
  764. if err != nil {
  765. return "", err
  766. }
  767. path := ""
  768. switch provider {
  769. case "linuxdo":
  770. path = "/api/v1/auth/oauth/linuxdo/bind/start"
  771. case "oidc":
  772. path = "/api/v1/auth/oauth/oidc/bind/start"
  773. case "wechat":
  774. path = "/api/v1/auth/oauth/wechat/bind/start"
  775. case "dingtalk":
  776. path = "/api/v1/auth/oauth/dingtalk/bind/start"
  777. default:
  778. return "", ErrIdentityProviderInvalid
  779. }
  780. query := url.Values{}
  781. query.Set("redirect", redirectTo)
  782. query.Set("intent", "bind_current_user")
  783. return path + "?" + query.Encode(), nil
  784. }
  785. func normalizeUserIdentityProvider(provider string) string {
  786. switch strings.ToLower(strings.TrimSpace(provider)) {
  787. case "linuxdo":
  788. return "linuxdo"
  789. case "oidc":
  790. return "oidc"
  791. case "wechat":
  792. return "wechat"
  793. case "dingtalk":
  794. return "dingtalk"
  795. case "email":
  796. return "email"
  797. default:
  798. return ""
  799. }
  800. }
  801. func normalizeUserIdentityRedirect(raw string) (string, error) {
  802. redirect := strings.TrimSpace(raw)
  803. if redirect == "" {
  804. return defaultUserIdentityRedirect, nil
  805. }
  806. if len(redirect) > 2048 || !strings.HasPrefix(redirect, "/") || strings.HasPrefix(redirect, "//") {
  807. return "", ErrIdentityRedirectInvalid
  808. }
  809. return redirect, nil
  810. }
  811. func filterUserAuthIdentities(records []UserAuthIdentityRecord, provider string) []UserAuthIdentityRecord {
  812. if len(records) == 0 {
  813. return nil
  814. }
  815. filtered := make([]UserAuthIdentityRecord, 0, len(records))
  816. for _, record := range records {
  817. if strings.EqualFold(strings.TrimSpace(record.ProviderType), provider) {
  818. filtered = append(filtered, record)
  819. }
  820. }
  821. return filtered
  822. }
  823. func selectPrimaryUserAuthIdentity(records []UserAuthIdentityRecord) UserAuthIdentityRecord {
  824. if len(records) == 0 {
  825. return UserAuthIdentityRecord{}
  826. }
  827. sort.SliceStable(records, func(i, j int) bool {
  828. left := userAuthIdentitySortTime(records[i])
  829. right := userAuthIdentitySortTime(records[j])
  830. if !left.Equal(right) {
  831. return left.After(right)
  832. }
  833. return records[i].ProviderKey < records[j].ProviderKey
  834. })
  835. return records[0]
  836. }
  837. func userAuthIdentitySortTime(record UserAuthIdentityRecord) time.Time {
  838. if record.VerifiedAt != nil && !record.VerifiedAt.IsZero() {
  839. return record.VerifiedAt.UTC()
  840. }
  841. if !record.UpdatedAt.IsZero() {
  842. return record.UpdatedAt.UTC()
  843. }
  844. if !record.CreatedAt.IsZero() {
  845. return record.CreatedAt.UTC()
  846. }
  847. return time.Time{}
  848. }
  849. func userAuthIdentityDisplayName(record UserAuthIdentityRecord) string {
  850. if displayName := firstStringIdentityValue(record.Metadata,
  851. "display_name",
  852. "suggested_display_name",
  853. "username",
  854. "name",
  855. "nickname",
  856. "email",
  857. ); displayName != "" {
  858. return displayName
  859. }
  860. if subject := strings.TrimSpace(record.ProviderSubject); subject != "" {
  861. return subject
  862. }
  863. return strings.TrimSpace(record.ProviderType)
  864. }
  865. func firstStringIdentityValue(values map[string]any, keys ...string) string {
  866. for _, key := range keys {
  867. raw, ok := values[key]
  868. if !ok {
  869. continue
  870. }
  871. switch value := raw.(type) {
  872. case string:
  873. if trimmed := strings.TrimSpace(value); trimmed != "" {
  874. return trimmed
  875. }
  876. case fmt.Stringer:
  877. if trimmed := strings.TrimSpace(value.String()); trimmed != "" {
  878. return trimmed
  879. }
  880. }
  881. }
  882. return ""
  883. }
  884. func maskEmailIdentity(email string) string {
  885. local, domain, ok := strings.Cut(strings.TrimSpace(email), "@")
  886. if !ok || local == "" || domain == "" {
  887. return maskOpaqueIdentity(email)
  888. }
  889. runes := []rune(local)
  890. if len(runes) == 1 {
  891. return string(runes[0]) + "***@" + domain
  892. }
  893. return string(runes[0]) + "***" + string(runes[len(runes)-1]) + "@" + domain
  894. }
  895. func maskOpaqueIdentity(value string) string {
  896. value = strings.TrimSpace(value)
  897. runes := []rune(value)
  898. switch {
  899. case len(runes) == 0:
  900. return ""
  901. case len(runes) <= 4:
  902. return string(runes[0]) + "***"
  903. case len(runes) <= 8:
  904. return string(runes[:2]) + "***" + string(runes[len(runes)-1:])
  905. default:
  906. return string(runes[:3]) + "***" + string(runes[len(runes)-3:])
  907. }
  908. }
  909. // ChangePassword 修改密码
  910. // Security: Increments TokenVersion to invalidate all existing JWT tokens
  911. func (s *UserService) ChangePassword(ctx context.Context, userID int64, req ChangePasswordRequest) error {
  912. user, err := s.userRepo.GetByID(ctx, userID)
  913. if err != nil {
  914. return fmt.Errorf("get user: %w", err)
  915. }
  916. // 验证当前密码
  917. if !user.CheckPassword(req.CurrentPassword) {
  918. return ErrPasswordIncorrect
  919. }
  920. if err := user.SetPassword(req.NewPassword); err != nil {
  921. return fmt.Errorf("set password: %w", err)
  922. }
  923. // Increment TokenVersion to invalidate all existing tokens
  924. // This ensures that any tokens issued before the password change become invalid
  925. user.TokenVersion++
  926. // TokenVersion 没有对应的数据库列(见 resolvedTokenVersion:它由 email+password_hash
  927. // 指纹推导),改密写回 password_hash 即可让旧 token 失效。
  928. if err := s.userRepo.Update(ctx, user, UserUpdateFields{PasswordHash: true}); err != nil {
  929. return fmt.Errorf("update user: %w", err)
  930. }
  931. return nil
  932. }
  933. // GetByID 根据ID获取用户(管理员功能)
  934. func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
  935. user, err := s.userRepo.GetByID(ctx, id)
  936. if err != nil {
  937. return nil, fmt.Errorf("get user: %w", err)
  938. }
  939. normalizeLoadedUserTokenVersion(user)
  940. if err := s.hydrateUserAvatar(ctx, user); err != nil {
  941. return nil, fmt.Errorf("get user avatar: %w", err)
  942. }
  943. return user, nil
  944. }
  945. func normalizeLoadedUserTokenVersion(user *User) {
  946. if user == nil || user.TokenVersionResolved {
  947. return
  948. }
  949. user.TokenVersion = resolvedTokenVersion(user)
  950. user.TokenVersionResolved = true
  951. }
  952. // TouchLastActive 通过防抖更新 users.last_active_at,减少鉴权热路径写放大。
  953. // 该操作为尽力而为,不应中断正常请求。
  954. func (s *UserService) TouchLastActive(ctx context.Context, userID int64) {
  955. if s == nil || s.userRepo == nil || userID <= 0 {
  956. return
  957. }
  958. user, err := s.userRepo.GetByID(ctx, userID)
  959. if err != nil {
  960. slog.Debug("skip touch user last active after load failure", "user_id", userID, "error", err)
  961. return
  962. }
  963. s.TouchLastActiveForUser(ctx, user)
  964. }
  965. // TouchLastActiveForUser 使用已加载的用户信息更新 last_active_at,避免重复读取数据库。
  966. func (s *UserService) TouchLastActiveForUser(ctx context.Context, user *User) {
  967. if s == nil || s.userRepo == nil || user == nil || user.ID <= 0 {
  968. return
  969. }
  970. now := time.Now()
  971. if userLastActiveFresh(user.LastActiveAt, now) {
  972. return
  973. }
  974. if v, ok := s.lastActiveTouchL1.Load(user.ID); ok {
  975. if nextAllowedAt, ok := v.(time.Time); ok && now.Before(nextAllowedAt) {
  976. return
  977. }
  978. }
  979. _, err, _ := s.lastActiveTouchSF.Do(strconv.FormatInt(user.ID, 10), func() (any, error) {
  980. latest := time.Now()
  981. if v, ok := s.lastActiveTouchL1.Load(user.ID); ok {
  982. if nextAllowedAt, ok := v.(time.Time); ok && latest.Before(nextAllowedAt) {
  983. return nil, nil
  984. }
  985. }
  986. if userLastActiveFresh(user.LastActiveAt, latest) {
  987. return nil, nil
  988. }
  989. if err := s.userRepo.UpdateUserLastActiveAt(ctx, user.ID, latest); err != nil {
  990. s.lastActiveTouchL1.Store(user.ID, latest.Add(userLastActiveFailBackoff))
  991. return nil, fmt.Errorf("touch user last active: %w", err)
  992. }
  993. s.lastActiveTouchL1.Store(user.ID, latest.Add(userLastActiveMinTouch))
  994. return nil, nil
  995. })
  996. if err != nil {
  997. slog.Warn("touch user last active failed", "user_id", user.ID, "error", err)
  998. }
  999. }
  1000. func userLastActiveFresh(lastActiveAt *time.Time, now time.Time) bool {
  1001. if lastActiveAt == nil {
  1002. return false
  1003. }
  1004. return now.Before(lastActiveAt.Add(userLastActiveMinTouch))
  1005. }
  1006. func (s *UserService) hydrateUserAvatar(ctx context.Context, user *User) error {
  1007. if s == nil || s.userRepo == nil || user == nil || user.ID == 0 {
  1008. return nil
  1009. }
  1010. avatar, err := s.userRepo.GetUserAvatar(ctx, user.ID)
  1011. if err != nil {
  1012. return err
  1013. }
  1014. applyUserAvatar(user, avatar)
  1015. return nil
  1016. }
  1017. // List 获取用户列表(管理员功能)
  1018. func (s *UserService) List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
  1019. users, pagination, err := s.userRepo.List(ctx, params)
  1020. if err != nil {
  1021. return nil, nil, fmt.Errorf("list users: %w", err)
  1022. }
  1023. return users, pagination, nil
  1024. }
  1025. // UpdateBalance 更新用户余额(管理员功能)
  1026. func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount float64) error {
  1027. if err := s.userRepo.UpdateBalance(ctx, userID, amount); err != nil {
  1028. return fmt.Errorf("update balance: %w", err)
  1029. }
  1030. if s.authCacheInvalidator != nil {
  1031. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  1032. }
  1033. if s.billingCache != nil {
  1034. go func() {
  1035. defer func() {
  1036. if r := recover(); r != nil {
  1037. slog.Error("panic in balance cache invalidation", "user_id", userID, "recover", r)
  1038. }
  1039. }()
  1040. cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  1041. defer cancel()
  1042. if err := s.billingCache.InvalidateUserBalance(cacheCtx, userID); err != nil {
  1043. slog.Error("invalidate user balance cache failed", "user_id", userID, "error", err)
  1044. }
  1045. }()
  1046. }
  1047. return nil
  1048. }
  1049. // UpdateConcurrency 更新用户并发数(管理员功能)
  1050. func (s *UserService) UpdateConcurrency(ctx context.Context, userID int64, concurrency int) error {
  1051. if err := s.userRepo.UpdateConcurrency(ctx, userID, concurrency); err != nil {
  1052. return fmt.Errorf("update concurrency: %w", err)
  1053. }
  1054. if s.authCacheInvalidator != nil {
  1055. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  1056. }
  1057. return nil
  1058. }
  1059. // UpdateStatus 更新用户状态(管理员功能)
  1060. func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status string) error {
  1061. user, err := s.userRepo.GetByID(ctx, userID)
  1062. if err != nil {
  1063. return fmt.Errorf("get user: %w", err)
  1064. }
  1065. user.Status = status
  1066. if err := s.userRepo.Update(ctx, user, UserUpdateFields{Status: true}); err != nil {
  1067. return fmt.Errorf("update user: %w", err)
  1068. }
  1069. if s.authCacheInvalidator != nil {
  1070. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  1071. }
  1072. return nil
  1073. }
  1074. // Delete 删除用户(管理员功能)
  1075. func (s *UserService) Delete(ctx context.Context, userID int64) error {
  1076. if s.authCacheInvalidator != nil {
  1077. s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
  1078. }
  1079. if err := s.userRepo.Delete(ctx, userID); err != nil {
  1080. return fmt.Errorf("delete user: %w", err)
  1081. }
  1082. return nil
  1083. }
  1084. // SendNotifyEmailCode sends a verification code to the extra notification email.
  1085. func (s *UserService) SendNotifyEmailCode(ctx context.Context, userID int64, email string, emailService *EmailService, cache EmailCache, locale ...string) error {
  1086. if err := checkNotifyCodeRateLimit(ctx, cache, userID, email); err != nil {
  1087. return err
  1088. }
  1089. code, err := emailService.GenerateVerifyCode()
  1090. if err != nil {
  1091. return fmt.Errorf("generate code: %w", err)
  1092. }
  1093. // Send email first — if SMTP fails, don't write cache or increment counters,
  1094. // so the user is not locked out by cooldown/rate-limit for a code they never received.
  1095. if err := s.sendNotifyVerifyEmail(ctx, emailService, userID, email, code, firstEmailLocale(locale)); err != nil {
  1096. return err
  1097. }
  1098. if err := saveNotifyVerifyCode(ctx, cache, email, code); err != nil {
  1099. return err
  1100. }
  1101. // Increment user-level counter after successful save
  1102. if _, err := cache.IncrNotifyCodeUserRate(ctx, userID, notifyCodeUserRateWindow); err != nil {
  1103. slog.Error("failed to increment notify code user rate", "user_id", userID, "error", err)
  1104. }
  1105. return nil
  1106. }
  1107. // checkNotifyCodeRateLimit checks both email cooldown and user-level rate limit.
  1108. func checkNotifyCodeRateLimit(ctx context.Context, cache EmailCache, userID int64, email string) error {
  1109. existing, err := cache.GetNotifyVerifyCode(ctx, email)
  1110. if err == nil && existing != nil {
  1111. if time.Since(existing.CreatedAt) < verifyCodeCooldown {
  1112. return ErrVerifyCodeTooFrequent
  1113. }
  1114. }
  1115. count, err := cache.GetNotifyCodeUserRate(ctx, userID)
  1116. if err == nil && count >= notifyCodeUserRateLimit {
  1117. return ErrNotifyCodeUserRateLimit
  1118. }
  1119. return nil
  1120. }
  1121. // saveNotifyVerifyCode saves the verification code to cache.
  1122. func saveNotifyVerifyCode(ctx context.Context, cache EmailCache, email, code string) error {
  1123. data := &VerificationCodeData{
  1124. Code: code,
  1125. Attempts: 0,
  1126. CreatedAt: time.Now(),
  1127. ExpiresAt: time.Now().Add(verifyCodeTTL),
  1128. }
  1129. if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil {
  1130. return fmt.Errorf("save verify code: %w", err)
  1131. }
  1132. return nil
  1133. }
  1134. // sendNotifyVerifyEmail builds and sends the verification email.
  1135. func (s *UserService) sendNotifyVerifyEmail(ctx context.Context, emailService *EmailService, userID int64, email, code, locale string) error {
  1136. siteName := "Sub2API"
  1137. if s.settingRepo != nil {
  1138. if name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName); err == nil && name != "" {
  1139. siteName = name
  1140. }
  1141. }
  1142. if emailService.notificationEmailService != nil {
  1143. if err := emailService.notificationEmailService.Send(ctx, NotificationEmailSendInput{
  1144. Event: NotificationEmailEventNotificationEmailVerifyCode,
  1145. Locale: locale,
  1146. RecipientEmail: email,
  1147. RecipientName: emailRecipientName(email),
  1148. UserID: userID,
  1149. Variables: map[string]string{
  1150. "verification_code": code,
  1151. "expires_in_minutes": strconv.Itoa(int(verifyCodeTTL / time.Minute)),
  1152. },
  1153. }); err == nil {
  1154. return nil
  1155. } else {
  1156. if !shouldFallbackNotificationEmail(err) {
  1157. return err
  1158. }
  1159. slog.Warn("template notification email verification failed; falling back to built-in body", "recipient_hash", notificationEmailHash(email), "err", err.Error())
  1160. }
  1161. }
  1162. subject := fmt.Sprintf("[%s] 通知邮箱验证码 / Notification Email Verification", siteName)
  1163. body := buildNotifyVerifyEmailBody(code, siteName)
  1164. return emailService.SendEmail(ctx, email, subject, body)
  1165. }
  1166. // VerifyAndAddNotifyEmail verifies the code and adds the email to user's extra emails.
  1167. func (s *UserService) VerifyAndAddNotifyEmail(ctx context.Context, userID int64, email, code string, cache EmailCache) error {
  1168. if err := verifyNotifyCode(ctx, cache, email, code); err != nil {
  1169. return err
  1170. }
  1171. _ = cache.DeleteNotifyVerifyCode(ctx, email)
  1172. return s.addOrVerifyNotifyEmail(ctx, userID, email)
  1173. }
  1174. // verifyNotifyCode validates the verification code against the cached data.
  1175. func verifyNotifyCode(ctx context.Context, cache EmailCache, email, code string) error {
  1176. data, err := cache.GetNotifyVerifyCode(ctx, email)
  1177. if err != nil || data == nil {
  1178. return ErrInvalidVerifyCode
  1179. }
  1180. if data.Attempts >= maxVerifyCodeAttempts {
  1181. return ErrVerifyCodeMaxAttempts
  1182. }
  1183. if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 {
  1184. data.Attempts++
  1185. remaining := time.Until(data.ExpiresAt)
  1186. if remaining <= 0 {
  1187. return ErrInvalidVerifyCode
  1188. }
  1189. if err := cache.SetNotifyVerifyCode(ctx, email, data, remaining); err != nil {
  1190. slog.Error("failed to update notify verify code attempts", "email", email, "error", err)
  1191. }
  1192. if data.Attempts >= maxVerifyCodeAttempts {
  1193. return ErrVerifyCodeMaxAttempts
  1194. }
  1195. return ErrInvalidVerifyCode
  1196. }
  1197. return nil
  1198. }
  1199. // addOrVerifyNotifyEmail adds the email to user's extra notification emails or marks it as verified.
  1200. // Note: concurrent calls for the same user could race on the read-modify-write of
  1201. // BalanceNotifyExtraEmails. The window is small (requires two verify flows completing
  1202. // simultaneously), and the worst case is a duplicate entry which is harmless.
  1203. func (s *UserService) addOrVerifyNotifyEmail(ctx context.Context, userID int64, email string) error {
  1204. user, err := s.userRepo.GetByID(ctx, userID)
  1205. if err != nil {
  1206. return err
  1207. }
  1208. for i, e := range user.BalanceNotifyExtraEmails {
  1209. if strings.EqualFold(e.Email, email) {
  1210. if !e.Verified {
  1211. user.BalanceNotifyExtraEmails[i].Verified = true
  1212. return s.userRepo.Update(ctx, user, UserUpdateFields{BalanceNotifyExtraEmails: true})
  1213. }
  1214. return nil // Already verified
  1215. }
  1216. }
  1217. if len(user.BalanceNotifyExtraEmails) >= maxNotifyEmails {
  1218. return infraerrors.BadRequest("TOO_MANY_NOTIFY_EMAILS", fmt.Sprintf("maximum %d notification emails allowed", maxNotifyEmails))
  1219. }
  1220. user.BalanceNotifyExtraEmails = append(user.BalanceNotifyExtraEmails, NotifyEmailEntry{
  1221. Email: email,
  1222. Disabled: false,
  1223. Verified: true,
  1224. })
  1225. return s.userRepo.Update(ctx, user, UserUpdateFields{BalanceNotifyExtraEmails: true})
  1226. }
  1227. // RemoveNotifyEmail removes an email from user's extra notification emails.
  1228. func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email string) error {
  1229. user, err := s.userRepo.GetByID(ctx, userID)
  1230. if err != nil {
  1231. return err
  1232. }
  1233. filtered := make([]NotifyEmailEntry, 0, len(user.BalanceNotifyExtraEmails))
  1234. found := false
  1235. for _, e := range user.BalanceNotifyExtraEmails {
  1236. if strings.EqualFold(e.Email, email) {
  1237. found = true
  1238. } else {
  1239. filtered = append(filtered, e)
  1240. }
  1241. }
  1242. if !found {
  1243. return infraerrors.BadRequest("EMAIL_NOT_FOUND", "notification email not found")
  1244. }
  1245. user.BalanceNotifyExtraEmails = filtered
  1246. return s.userRepo.Update(ctx, user, UserUpdateFields{BalanceNotifyExtraEmails: true})
  1247. }
  1248. // ToggleNotifyEmail toggles the disabled state of a notification email entry.
  1249. func (s *UserService) ToggleNotifyEmail(ctx context.Context, userID int64, email string, disabled bool) error {
  1250. user, err := s.userRepo.GetByID(ctx, userID)
  1251. if err != nil {
  1252. return err
  1253. }
  1254. found := false
  1255. for i, e := range user.BalanceNotifyExtraEmails {
  1256. if strings.EqualFold(e.Email, email) {
  1257. user.BalanceNotifyExtraEmails[i].Disabled = disabled
  1258. found = true
  1259. break
  1260. }
  1261. }
  1262. if !found {
  1263. return infraerrors.BadRequest("EMAIL_NOT_FOUND", "notification email not found")
  1264. }
  1265. return s.userRepo.Update(ctx, user, UserUpdateFields{BalanceNotifyExtraEmails: true})
  1266. }
  1267. // notifyVerifyEmailTemplate is the HTML template for notify email verification.
  1268. // Format args: siteName, code.
  1269. const notifyVerifyEmailTemplate = `<!DOCTYPE html>
  1270. <html>
  1271. <head>
  1272. <meta charset="UTF-8">
  1273. <style>
  1274. body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
  1275. .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); }
  1276. .header { background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); color: white; padding: 30px; text-align: center; }
  1277. .header h1 { margin: 0; font-size: 24px; }
  1278. .content { padding: 40px 30px; text-align: center; }
  1279. .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; }
  1280. .info { color: #666; font-size: 14px; line-height: 1.6; margin-top: 20px; }
  1281. .footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
  1282. </style>
  1283. </head>
  1284. <body>
  1285. <div class="container">
  1286. <div class="header">
  1287. <h1>%s</h1>
  1288. </div>
  1289. <div class="content">
  1290. <p style="font-size: 18px; color: #333;">通知邮箱验证码 / Notification Email Verification</p>
  1291. <div class="code">%s</div>
  1292. <div class="info">
  1293. <p>您正在添加额外的通知邮箱,请输入此验证码完成验证。</p>
  1294. <p>You are adding an extra notification email. Please enter this code to verify.</p>
  1295. <p>此验证码将在 <strong>15 分钟</strong>后失效。</p>
  1296. <p>This code will expire in <strong>15 minutes</strong>.</p>
  1297. <p>如果您没有请求此验证码,请忽略此邮件。</p>
  1298. <p>If you did not request this code, please ignore this email.</p>
  1299. </div>
  1300. </div>
  1301. <div class="footer">
  1302. <p>此邮件由系统自动发送,请勿回复。/ This is an automated message, please do not reply.</p>
  1303. </div>
  1304. </div>
  1305. </body>
  1306. </html>`
  1307. // buildNotifyVerifyEmailBody builds the HTML email body for notify email verification.
  1308. func buildNotifyVerifyEmailBody(code, siteName string) string {
  1309. return fmt.Sprintf(notifyVerifyEmailTemplate, siteName, code)
  1310. }