| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615 |
- package repository
- import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "sort"
- "strings"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- "github.com/Wei-Shaw/sub2api/ent/apikey"
- "github.com/Wei-Shaw/sub2api/ent/authidentity"
- "github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
- dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
- "github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
- "github.com/Wei-Shaw/sub2api/ent/predicate"
- "github.com/Wei-Shaw/sub2api/ent/schema/mixins"
- dbuser "github.com/Wei-Shaw/sub2api/ent/user"
- "github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
- "github.com/Wei-Shaw/sub2api/ent/usersubscription"
- "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/lib/pq"
- entsql "entgo.io/ent/dialect/sql"
- )
- type userRepository struct {
- client *dbent.Client
- sql sqlExecutor
- }
- var _ service.RedeemUserAdjustmentRepository = (*userRepository)(nil)
- func NewUserRepository(client *dbent.Client, sqlDB *sql.DB) service.UserRepository {
- return newUserRepositoryWithSQL(client, sqlDB)
- }
- func newUserRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *userRepository {
- return &userRepository{client: client, sql: sqlq}
- }
- func (r *userRepository) Create(ctx context.Context, userIn *service.User) error {
- return r.create(ctx, userIn, false, "")
- }
- // CreateWithEmailAliasGuard 见 service.UserRepository:在邮箱唯一性锁内复查收件箱身份,
- // 供注册路径使用。
- func (r *userRepository) CreateWithEmailAliasGuard(ctx context.Context, userIn *service.User) error {
- return r.create(ctx, userIn, true, "")
- }
- // CountUsersByEmailDomain 统计指定可注册主域名及其子域名下的未删除用户。
- func (r *userRepository) CountUsersByEmailDomain(ctx context.Context, domain string) (int, error) {
- return countUsersByEmailDomainWithClient(ctx, clientFromContext(ctx, r.client), domain)
- }
- // CreateWithEmailAliasGuardAndDomainLimit 串行化非白名单域名的注册请求,
- // 并在用户写入的同一事务内复查域名额度。
- func (r *userRepository) CreateWithEmailAliasGuardAndDomainLimit(ctx context.Context, userIn *service.User, domain string) error {
- return r.create(ctx, userIn, true, normalizeEmailDomain(domain))
- }
- func (r *userRepository) create(ctx context.Context, userIn *service.User, guardEmailAlias bool, domainLimit string) error {
- if userIn == nil {
- return nil
- }
- // 统一使用 ent 的事务:保证用户与允许分组的更新原子化,
- // 并避免基于 *sql.Tx 手动构造 ent client 导致的 ExecQuerier 断言错误。
- //
- // 注意:ent 的 Client.Tx 不感知上下文中的事务(只检查 driver 类型),
- // 因此必须显式检查 TxFromContext:当调用方已开启外部事务(如注册时的
- // “建用户 + 占用邀请码”原子事务),直接复用其 client,由调用方统一提交/回滚,
- // 否则用户写入会落入独立事务并自行提交,导致外层事务无法回滚(孤儿用户)。
- var txClient *dbent.Client
- txCtx := ctx
- var ownedTx *dbent.Tx
- if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
- txClient = existingTx.Client()
- } else {
- tx, err := r.client.Tx(ctx)
- switch {
- case errors.Is(err, dbent.ErrTxStarted):
- // r.client 本身已是事务绑定 client(client 注入式事务,如集成测试
- // 夹具 tx.Client()):直接复用,提交/回滚由 client 的持有方负责。
- txClient = r.client
- case err != nil:
- return err
- default:
- ownedTx = tx
- defer func() { _ = ownedTx.Rollback() }()
- txClient = tx.Client()
- txCtx = dbent.NewTxContext(ctx, tx)
- }
- }
- releaseHierarchy, err := guardUserHierarchyCreate(txCtx, txClient, userIn)
- if err != nil {
- return err
- }
- defer releaseHierarchy()
- lockKeys := []string{normalizedEmailUniquenessLockKey(userIn.Email)}
- if guardEmailAlias {
- // 别名变体的字面量不同,唯一索引无法兜底;用收件箱身份锁把同一收件箱的并发注册串行化。
- lockKeys = append(lockKeys, emailAliasUniquenessLockKey(userIn.Email))
- }
- if domainLimit != "" {
- lockKeys = append(lockKeys, registrationEmailDomainLockKey(domainLimit))
- }
- releaseEmailLock, err := lockRepositoryScopedKeys(
- txCtx,
- txClient,
- txAwareSQLExecutor(txCtx, r.sql, r.client),
- lockKeys...,
- )
- if err != nil {
- return err
- }
- defer releaseEmailLock()
- if domainLimit != "" {
- count, err := countUsersByEmailDomainWithClient(txCtx, txClient, domainLimit)
- if err != nil {
- return err
- }
- if count > 0 {
- return service.ErrEmailDomainRegistrationLimit
- }
- }
- if err := ensureNormalizedEmailAvailableWithClient(txCtx, txClient, 0, userIn.Email); err != nil {
- return err
- }
- if guardEmailAlias {
- aliasExists, err := existsByEmailAliasWithClient(txCtx, txClient, userIn.Email)
- if err != nil {
- return err
- }
- if aliasExists {
- return service.ErrEmailExists
- }
- }
- created, err := txClient.User.Create().
- SetEmail(userIn.Email).
- SetUsername(userIn.Username).
- SetNotes(userIn.Notes).
- SetPasswordHash(userIn.PasswordHash).
- SetRole(userIn.Role).
- SetBalance(userIn.Balance).
- SetConcurrency(userIn.Concurrency).
- SetStatus(userIn.Status).
- SetSignupSource(userSignupSourceOrDefault(userIn.SignupSource)).
- SetNillableLastLoginAt(userIn.LastLoginAt).
- SetNillableLastActiveAt(userIn.LastActiveAt).
- SetRpmLimit(userIn.RPMLimit).
- SetRestrictPublicGroups(userIn.RestrictPublicGroups).
- Save(txCtx)
- if err != nil {
- return translatePersistenceError(err, nil, service.ErrEmailExists)
- }
- if err := r.syncUserAllowedGroupsWithClient(txCtx, txClient, created.ID, userIn.AllowedGroups); err != nil {
- return err
- }
- if err := ensureEmailAuthIdentityWithClient(txCtx, txClient, created.ID, created.Email, "user_repo_create"); err != nil {
- return err
- }
- if ownedTx != nil {
- if err := ownedTx.Commit(); err != nil {
- return err
- }
- }
- applyUserEntityToService(userIn, created)
- return nil
- }
- func (r *userRepository) GetByID(ctx context.Context, id int64) (*service.User, error) {
- m, err := r.client.User.Query().Where(dbuser.IDEQ(id)).Only(ctx)
- if err != nil {
- return nil, translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- out := userEntityToService(m)
- groups, err := r.loadAllowedGroups(ctx, []int64{id})
- if err != nil {
- return nil, err
- }
- if v, ok := groups[id]; ok {
- out.AllowedGroups = v
- }
- return out, nil
- }
- func (r *userRepository) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.User, error) {
- ctx = mixins.SkipSoftDelete(ctx)
- m, err := r.client.User.Query().Where(dbuser.IDEQ(id)).Only(ctx)
- if err != nil {
- return nil, translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- out := userEntityToService(m)
- groups, err := r.loadAllowedGroups(ctx, []int64{id})
- if err != nil {
- return nil, err
- }
- if v, ok := groups[id]; ok {
- out.AllowedGroups = v
- }
- return out, nil
- }
- func (r *userRepository) GetByEmail(ctx context.Context, email string) (*service.User, error) {
- matches, err := r.client.User.Query().
- Where(userEmailLookupPredicate(email)).
- Order(dbent.Asc(dbuser.FieldID)).
- All(ctx)
- if err != nil {
- return nil, err
- }
- if len(matches) == 0 {
- return nil, service.ErrUserNotFound
- }
- if len(matches) > 1 {
- return nil, fmt.Errorf("normalized email lookup matched multiple users for %q", strings.TrimSpace(email))
- }
- m := matches[0]
- out := userEntityToService(m)
- groups, err := r.loadAllowedGroups(ctx, []int64{m.ID})
- if err != nil {
- return nil, err
- }
- if v, ok := groups[m.ID]; ok {
- out.AllowedGroups = v
- }
- return out, nil
- }
- func (r *userRepository) Update(ctx context.Context, userIn *service.User, fields service.UserUpdateFields) error {
- if userIn == nil {
- return nil
- }
- // 空掩码代表调用方不改任何列,直接返回,避免产生一次无意义的整行写。
- if fields.IsEmpty() {
- return nil
- }
- // 使用 ent 事务包裹用户更新与 allowed_groups 同步,避免跨层事务不一致。
- var tx *dbent.Tx
- var txClient *dbent.Client
- txCtx := ctx
- if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
- txClient = existingTx.Client()
- } else {
- var err error
- tx, err = r.client.Tx(ctx)
- switch {
- case errors.Is(err, dbent.ErrTxStarted):
- txClient = r.client
- case err != nil:
- return err
- default:
- defer func() { _ = tx.Rollback() }()
- txClient = tx.Client()
- txCtx = dbent.NewTxContext(ctx, tx)
- }
- }
- releaseHierarchy, err := guardUserHierarchyUpdate(txCtx, txClient, userIn, fields)
- if err != nil {
- return err
- }
- defer releaseHierarchy()
- // 邮箱唯一性锁与查重只在本次确实要改邮箱时才做:不改邮箱的更新既不需要
- // 串行化,也不该因为快照里的旧邮箱已被他人占用而报 ErrEmailExists。
- if fields.Email {
- releaseEmailLock, err := lockRepositoryScopedKeys(
- txCtx,
- txClient,
- txAwareSQLExecutor(txCtx, r.sql, r.client),
- normalizedEmailUniquenessLockKey(userIn.Email),
- )
- if err != nil {
- return err
- }
- defer releaseEmailLock()
- if err := ensureNormalizedEmailAvailableWithClient(txCtx, txClient, userIn.ID, userIn.Email); err != nil {
- return err
- }
- }
- existing, err := clientFromContext(txCtx, txClient).User.Get(txCtx, userIn.ID)
- if err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- oldEmail := existing.Email
- updateOp := txClient.User.UpdateOneID(userIn.ID)
- if fields.Email {
- updateOp = updateOp.SetEmail(userIn.Email)
- }
- if fields.Username {
- updateOp = updateOp.SetUsername(userIn.Username)
- }
- if fields.Notes {
- updateOp = updateOp.SetNotes(userIn.Notes)
- }
- if fields.PasswordHash {
- updateOp = updateOp.SetPasswordHash(userIn.PasswordHash)
- }
- if fields.Role {
- updateOp = updateOp.SetRole(userIn.Role)
- }
- if fields.Concurrency {
- updateOp = updateOp.SetConcurrency(userIn.Concurrency)
- }
- if fields.RPMLimit {
- updateOp = updateOp.SetRpmLimit(userIn.RPMLimit)
- }
- if fields.Status {
- updateOp = updateOp.SetStatus(userIn.Status)
- }
- if fields.RestrictPublicGroups {
- updateOp = updateOp.SetRestrictPublicGroups(userIn.RestrictPublicGroups)
- }
- if fields.BalanceNotifySettings {
- updateOp = updateOp.
- SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled).
- SetBalanceNotifyThresholdType(userIn.BalanceNotifyThresholdType).
- SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold)
- if userIn.BalanceNotifyThreshold == nil {
- updateOp = updateOp.ClearBalanceNotifyThreshold()
- }
- }
- if fields.BalanceNotifyExtraEmails {
- updateOp = updateOp.SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails))
- }
- if fields.SignupSource && userIn.SignupSource != "" {
- updateOp = updateOp.SetSignupSource(userIn.SignupSource)
- }
- if fields.LastLoginAt && userIn.LastLoginAt != nil {
- updateOp = updateOp.SetLastLoginAt(*userIn.LastLoginAt)
- }
- if fields.LastActiveAt && userIn.LastActiveAt != nil {
- updateOp = updateOp.SetLastActiveAt(*userIn.LastActiveAt)
- }
- updated, err := updateOp.Save(txCtx)
- if err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, service.ErrEmailExists)
- }
- if fields.AllowedGroups {
- if err := r.syncUserAllowedGroupsWithClient(txCtx, txClient, updated.ID, userIn.AllowedGroups); err != nil {
- return err
- }
- }
- // 始终以库中的邮箱为准补齐 email 身份:未改邮箱时 updated.Email == oldEmail,
- // 这里退化为幂等的身份补写,与改邮箱前的行为一致。
- if err := replaceEmailAuthIdentityWithClient(txCtx, txClient, updated.ID, oldEmail, updated.Email, "user_repo_update"); err != nil {
- return err
- }
- if tx != nil {
- if err := tx.Commit(); err != nil {
- return err
- }
- }
- userIn.UpdatedAt = updated.UpdatedAt
- return nil
- }
- func ensureEmailAuthIdentityWithClient(ctx context.Context, client *dbent.Client, userID int64, email string, source string) error {
- client = clientFromContext(ctx, client)
- if client == nil || userID <= 0 {
- return nil
- }
- subject := normalizeEmailAuthIdentitySubject(email)
- if subject == "" {
- return nil
- }
- if err := client.AuthIdentity.Create().
- SetUserID(userID).
- SetProviderType("email").
- SetProviderKey("email").
- SetProviderSubject(subject).
- SetVerifiedAt(time.Now().UTC()).
- SetMetadata(map[string]any{"source": source}).
- OnConflictColumns(
- authidentity.FieldProviderType,
- authidentity.FieldProviderKey,
- authidentity.FieldProviderSubject,
- ).
- DoNothing().
- Exec(ctx); err != nil {
- if !isSQLNoRowsError(err) {
- return err
- }
- }
- identity, err := client.AuthIdentity.Query().
- Where(
- authidentity.ProviderTypeEQ("email"),
- authidentity.ProviderKeyEQ("email"),
- authidentity.ProviderSubjectEQ(subject),
- ).
- Only(ctx)
- if err != nil {
- if dbent.IsNotFound(err) {
- return nil
- }
- return err
- }
- if identity.UserID != userID {
- return ErrAuthIdentityOwnershipConflict
- }
- return nil
- }
- func replaceEmailAuthIdentityWithClient(ctx context.Context, client *dbent.Client, userID int64, oldEmail, newEmail string, source string) error {
- newSubject := normalizeEmailAuthIdentitySubject(newEmail)
- if err := ensureEmailAuthIdentityWithClient(ctx, client, userID, newEmail, source); err != nil {
- return err
- }
- oldSubject := normalizeEmailAuthIdentitySubject(oldEmail)
- if oldSubject == "" || oldSubject == newSubject {
- return nil
- }
- _, err := clientFromContext(ctx, client).AuthIdentity.Delete().
- Where(
- authidentity.UserIDEQ(userID),
- authidentity.ProviderTypeEQ("email"),
- authidentity.ProviderKeyEQ("email"),
- authidentity.ProviderSubjectEQ(oldSubject),
- ).
- Exec(ctx)
- return err
- }
- func normalizeEmailAuthIdentitySubject(email string) string {
- normalized := strings.ToLower(strings.TrimSpace(email))
- if normalized == "" {
- return ""
- }
- if strings.HasSuffix(normalized, service.LinuxDoConnectSyntheticEmailDomain) ||
- strings.HasSuffix(normalized, service.OIDCConnectSyntheticEmailDomain) ||
- strings.HasSuffix(normalized, service.WeChatConnectSyntheticEmailDomain) ||
- strings.HasSuffix(normalized, service.DingTalkConnectSyntheticEmailDomain) {
- return ""
- }
- return normalized
- }
- func (r *userRepository) Delete(ctx context.Context, id int64) error {
- // 复用 context 中已存在的事务(如 AdminService.DeleteUser 把删 Key 与删 User 包在同一事务中),
- // 由调用方负责提交/回滚,保证两者的原子性。
- if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
- return r.deleteUser(ctx, existingTx.Client(), id)
- }
- tx, err := r.client.Tx(ctx)
- if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- exec := r.client
- if err == nil {
- defer func() { _ = tx.Rollback() }()
- exec = tx.Client()
- }
- // err == dbent.ErrTxStarted 时复用当前事务(exec = r.client)。
- if err := r.deleteUser(ctx, exec, id); err != nil {
- return err
- }
- if tx != nil {
- if err := tx.Commit(); err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- }
- return nil
- }
- // deleteUser 在给定 client(可能是外部事务 client)上删除用户及其身份关联记录,自身不开启/提交事务。
- func (r *userRepository) deleteUser(ctx context.Context, exec *dbent.Client, id int64) error {
- releaseHierarchy, err := guardUserHierarchyDelete(ctx, exec, id)
- if err != nil {
- return err
- }
- defer releaseHierarchy()
- identityIDs, err := exec.AuthIdentity.Query().
- Where(authidentity.UserIDEQ(id)).
- IDs(ctx)
- if err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- if len(identityIDs) > 0 {
- if _, err := exec.IdentityAdoptionDecision.Update().
- Where(identityadoptiondecision.IdentityIDIn(identityIDs...)).
- ClearIdentityID().
- Save(ctx); err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- if _, err := exec.AuthIdentityChannel.Delete().
- Where(authidentitychannel.IdentityIDIn(identityIDs...)).
- Exec(ctx); err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- if _, err := exec.AuthIdentity.Delete().
- Where(authidentity.UserIDEQ(id)).
- Exec(ctx); err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- }
- affected, err := exec.User.Delete().Where(dbuser.IDEQ(id)).Exec(ctx)
- if err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- if affected == 0 {
- return service.ErrUserNotFound
- }
- return nil
- }
- func (r *userRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
- return r.ListWithFilters(ctx, params, service.UserListFilters{})
- }
- func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) {
- // SkipSoftDelete 仅作用于 User 身份解析(下方 Count/All);订阅、分组等关联实体沿用原始 ctx,避免穿透到这些同样带软删除的实体而带出已删除行。
- userCtx := ctx
- if filters.IncludeDeleted {
- userCtx = mixins.SkipSoftDelete(ctx)
- }
- q := r.client.User.Query()
- if filters.Status != "" {
- q = q.Where(dbuser.StatusEQ(filters.Status))
- }
- if filters.Role != "" {
- q = q.Where(dbuser.RoleEQ(filters.Role))
- }
- if filters.Search != "" {
- q = q.Where(
- dbuser.Or(
- dbuser.EmailContainsFold(filters.Search),
- dbuser.UsernameContainsFold(filters.Search),
- dbuser.NotesContainsFold(filters.Search),
- dbuser.HasAPIKeysWith(apikey.KeyContainsFold(filters.Search)),
- ),
- )
- }
- if filters.GroupName != "" {
- q = q.Where(dbuser.HasAllowedGroupsWith(
- dbgroup.NameContainsFold(filters.GroupName),
- ))
- }
- if filters.APIKeyGroupID > 0 {
- // 按"API Key 实际绑定的分组"过滤:用户只要有任意一个未软删除的 API Key
- // 绑定到该分组即命中(EXISTS 语义)。
- // 注意:SoftDeleteMixin 的拦截器不会自动下沉到 HasAPIKeysWith 子查询,
- // 必须显式加 apikey.DeletedAtIsNil(),否则已软删除的 key 会污染过滤结果。
- q = q.Where(dbuser.HasAPIKeysWith(
- apikey.GroupIDEQ(filters.APIKeyGroupID),
- apikey.DeletedAtIsNil(),
- ))
- }
- // If attribute filters are specified, we need to filter by user IDs first
- var allowedUserIDs []int64
- if len(filters.Attributes) > 0 {
- var attrErr error
- allowedUserIDs, attrErr = r.filterUsersByAttributes(ctx, filters.Attributes)
- if attrErr != nil {
- return nil, nil, attrErr
- }
- if len(allowedUserIDs) == 0 {
- // No users match the attribute filters
- return []service.User{}, paginationResultFromTotal(0, params), nil
- }
- q = q.Where(dbuser.IDIn(allowedUserIDs...))
- }
- total, err := q.Clone().Count(userCtx)
- if err != nil {
- return nil, nil, err
- }
- usersQuery := q.
- Offset(params.Offset()).
- Limit(params.Limit())
- for _, order := range userListOrder(params) {
- usersQuery = usersQuery.Order(order)
- }
- users, err := usersQuery.All(userCtx)
- if err != nil {
- return nil, nil, err
- }
- outUsers := make([]service.User, 0, len(users))
- if len(users) == 0 {
- return outUsers, paginationResultFromTotal(int64(total), params), nil
- }
- userIDs := make([]int64, 0, len(users))
- userMap := make(map[int64]*service.User, len(users))
- for i := range users {
- userIDs = append(userIDs, users[i].ID)
- u := userEntityToService(users[i])
- outUsers = append(outUsers, *u)
- userMap[u.ID] = &outUsers[len(outUsers)-1]
- }
- shouldLoadSubscriptions := filters.IncludeSubscriptions == nil || *filters.IncludeSubscriptions
- if shouldLoadSubscriptions {
- // Batch load active subscriptions with groups to avoid N+1.
- subs, err := r.client.UserSubscription.Query().
- Where(
- usersubscription.UserIDIn(userIDs...),
- usersubscription.StatusEQ(service.SubscriptionStatusActive),
- ).
- WithGroup().
- All(ctx)
- if err != nil {
- return nil, nil, err
- }
- for i := range subs {
- if u, ok := userMap[subs[i].UserID]; ok {
- u.Subscriptions = append(u.Subscriptions, *userSubscriptionEntityToService(subs[i]))
- }
- }
- }
- allowedGroupsByUser, err := r.loadAllowedGroups(ctx, userIDs)
- if err != nil {
- return nil, nil, err
- }
- for id, u := range userMap {
- if groups, ok := allowedGroupsByUser[id]; ok {
- u.AllowedGroups = groups
- }
- }
- return outUsers, paginationResultFromTotal(int64(total), params), nil
- }
- func userListOrder(params pagination.PaginationParams) []func(*entsql.Selector) {
- sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
- sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
- if sortBy == "last_used_at" {
- return userLastUsedAtOrder(sortOrder)
- }
- var field string
- defaultField := true
- nullsLastField := false
- switch sortBy {
- case "email":
- field = dbuser.FieldEmail
- defaultField = false
- case "username":
- field = dbuser.FieldUsername
- defaultField = false
- case "role":
- field = dbuser.FieldRole
- defaultField = false
- case "balance":
- field = dbuser.FieldBalance
- defaultField = false
- case "concurrency":
- field = dbuser.FieldConcurrency
- defaultField = false
- case "status":
- field = dbuser.FieldStatus
- defaultField = false
- case "created_at":
- field = dbuser.FieldCreatedAt
- defaultField = false
- case "last_active_at":
- field = dbuser.FieldLastActiveAt
- defaultField = false
- nullsLastField = true
- default:
- field = dbuser.FieldID
- }
- if sortOrder == pagination.SortOrderAsc {
- if defaultField && field == dbuser.FieldID {
- return []func(*entsql.Selector){dbent.Asc(dbuser.FieldID)}
- }
- if nullsLastField {
- return []func(*entsql.Selector){
- entsql.OrderByField(field, entsql.OrderNullsLast()).ToFunc(),
- dbent.Asc(dbuser.FieldID),
- }
- }
- return []func(*entsql.Selector){dbent.Asc(field), dbent.Asc(dbuser.FieldID)}
- }
- if defaultField && field == dbuser.FieldID {
- return []func(*entsql.Selector){dbent.Desc(dbuser.FieldID)}
- }
- if nullsLastField {
- return []func(*entsql.Selector){
- entsql.OrderByField(field, entsql.OrderDesc(), entsql.OrderNullsLast()).ToFunc(),
- dbent.Desc(dbuser.FieldID),
- }
- }
- return []func(*entsql.Selector){dbent.Desc(field), dbent.Desc(dbuser.FieldID)}
- }
- func (r *userRepository) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
- result := make(map[int64]*time.Time, len(userIDs))
- if len(userIDs) == 0 {
- return result, nil
- }
- if r.sql == nil {
- return nil, fmt.Errorf("sql executor is not configured")
- }
- const query = `
- SELECT user_id, MAX(created_at) AS last_used_at
- FROM usage_logs
- WHERE user_id = ANY($1)
- GROUP BY user_id
- `
- rows, err := r.sql.QueryContext(ctx, query, pq.Array(userIDs))
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- for rows.Next() {
- var (
- userID int64
- lastUsedAt time.Time
- )
- if scanErr := rows.Scan(&userID, &lastUsedAt); scanErr != nil {
- return nil, scanErr
- }
- ts := lastUsedAt.UTC()
- result[userID] = &ts
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (r *userRepository) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
- latestByUserID, err := r.GetLatestUsedAtByUserIDs(ctx, []int64{userID})
- if err != nil {
- return nil, err
- }
- return latestByUserID[userID], nil
- }
- func userLastUsedAtOrder(sortOrder string) []func(*entsql.Selector) {
- orderExpr := func(direction, nulls string, tieOrder func(string) string) func(*entsql.Selector) {
- return func(s *entsql.Selector) {
- subquery := fmt.Sprintf("(SELECT MAX(created_at) FROM usage_logs WHERE user_id = %s)", s.C(dbuser.FieldID))
- s.OrderExpr(entsql.Expr(subquery + " " + direction + " NULLS " + nulls))
- s.OrderBy(tieOrder(s.C(dbuser.FieldID)))
- }
- }
- if sortOrder == pagination.SortOrderAsc {
- return []func(*entsql.Selector){
- orderExpr("ASC", "FIRST", entsql.Asc),
- }
- }
- return []func(*entsql.Selector){
- orderExpr("DESC", "LAST", entsql.Desc),
- }
- }
- // filterUsersByAttributes returns user IDs that match ALL the given attribute filters
- func (r *userRepository) filterUsersByAttributes(ctx context.Context, attrs map[int64]string) ([]int64, error) {
- if len(attrs) == 0 {
- return nil, nil
- }
- if r.sql == nil {
- return nil, fmt.Errorf("sql executor is not configured")
- }
- clauses := make([]string, 0, len(attrs))
- args := make([]any, 0, len(attrs)*2+1)
- argIndex := 1
- for attrID, value := range attrs {
- clauses = append(clauses, fmt.Sprintf("(attribute_id = $%d AND value ILIKE $%d)", argIndex, argIndex+1))
- args = append(args, attrID, "%"+value+"%")
- argIndex += 2
- }
- query := fmt.Sprintf(
- `SELECT user_id
- FROM user_attribute_values
- WHERE %s
- GROUP BY user_id
- HAVING COUNT(DISTINCT attribute_id) = $%d`,
- strings.Join(clauses, " OR "),
- argIndex,
- )
- args = append(args, len(attrs))
- rows, err := r.sql.QueryContext(ctx, query, args...)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
- result := make([]int64, 0)
- for rows.Next() {
- var userID int64
- if scanErr := rows.Scan(&userID); scanErr != nil {
- return nil, scanErr
- }
- result = append(result, userID)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return result, nil
- }
- func (r *userRepository) UpdateBalance(ctx context.Context, id int64, amount float64) error {
- client := clientFromContext(ctx, r.client)
- update := client.User.Update().Where(dbuser.IDEQ(id)).AddBalance(amount)
- // Track cumulative recharge amount for percentage-based notifications
- if amount > 0 {
- update = update.AddTotalRecharged(amount)
- }
- n, err := update.Save(ctx)
- if err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- if n == 0 {
- return service.ErrUserNotFound
- }
- return nil
- }
- func (r *userRepository) ApplyRedeemBalanceAdjustment(ctx context.Context, id int64, delta float64) error {
- const updateSQL = `
- UPDATE users
- SET balance = GREATEST(balance + $1, 0), updated_at = NOW()
- WHERE id = $2 AND deleted_at IS NULL
- `
- client := clientFromContext(ctx, r.client)
- result, err := client.ExecContext(ctx, updateSQL, delta, id)
- if err != nil {
- return err
- }
- affected, err := result.RowsAffected()
- if err != nil {
- return err
- }
- if affected == 0 {
- return service.ErrUserNotFound
- }
- return nil
- }
- // DeductBalance 扣除用户余额
- // 透支策略:允许余额变为负数,确保当前请求能够完成
- // 中间件会阻止余额 <= 0 的用户发起后续请求
- func (r *userRepository) DeductBalance(ctx context.Context, id int64, amount float64) error {
- client := clientFromContext(ctx, r.client)
- n, err := client.User.Update().
- Where(dbuser.IDEQ(id), dbuser.BalanceGTE(amount)).
- AddBalance(-amount).
- Save(ctx)
- if err != nil {
- return err
- }
- if n > 0 {
- return nil
- }
- n, err = client.User.Update().
- Where(dbuser.IDEQ(id)).
- AddBalance(-amount).
- Save(ctx)
- if err != nil {
- return err
- }
- if n == 0 {
- return service.ErrUserNotFound
- }
- return nil
- }
- // DeductAvailableBalance atomically deducts min(amount, max(balance, 0)).
- // Unlike DeductBalance, this refund-specific operation never increases an
- // existing deficit or permits a concurrent deduction to cause an overdraft.
- func (r *userRepository) DeductAvailableBalance(ctx context.Context, id int64, amount float64) (deducted float64, err error) {
- if amount < 0 {
- return 0, fmt.Errorf("deduction amount must be nonnegative")
- }
- const updateSQL = `
- WITH target AS (
- SELECT id, balance
- FROM users
- WHERE id = $2 AND deleted_at IS NULL
- FOR UPDATE
- ), updated AS (
- UPDATE users AS u
- SET balance = target.balance - LEAST($1, GREATEST(target.balance, 0)), updated_at = NOW()
- FROM target
- WHERE u.id = target.id AND u.deleted_at IS NULL
- RETURNING target.balance - u.balance AS deducted
- )
- SELECT deducted FROM updated
- `
- rows, err := clientFromContext(ctx, r.client).QueryContext(ctx, updateSQL, amount, id)
- if err != nil {
- return 0, err
- }
- defer func() {
- if closeErr := rows.Close(); closeErr != nil && err == nil {
- err = closeErr
- }
- }()
- if !rows.Next() {
- if rowsErr := rows.Err(); rowsErr != nil {
- return 0, rowsErr
- }
- return 0, service.ErrUserNotFound
- }
- if err := rows.Scan(&deducted); err != nil {
- return 0, err
- }
- return deducted, rows.Err()
- }
- // AdjustBalance 原子地把 delta 累加到余额上,结果为负时整条语句不生效。
- // 相比"读余额 → 算新值 → 整行写回",这里把读与写压进同一条 UPDATE,
- // 并发的计费扣款不会被旧快照覆盖。
- func (r *userRepository) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
- const updateSQL = `
- UPDATE users
- SET balance = balance + $1, updated_at = NOW()
- WHERE id = $2 AND deleted_at IS NULL AND balance + $1 >= 0
- RETURNING balance - $1, balance
- `
- change, ok, err := scanBalanceChange(ctx, clientFromContext(ctx, r.client), updateSQL, delta, id)
- if err != nil {
- return service.BalanceChange{}, err
- }
- if ok {
- return change, nil
- }
- // 0 行既可能是用户不存在,也可能是余额不足以承受这次扣减,需要区分。
- current, err := r.currentBalance(ctx, id)
- if err != nil {
- return service.BalanceChange{}, err
- }
- return service.BalanceChange{Old: current, New: current + delta}, service.ErrBalanceNegative
- }
- // SetBalance 原子地把余额置为 value,并返回变更前后的值。
- func (r *userRepository) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
- if value < 0 {
- // 连同当前余额一起返回,便于上层给出可读的错误信息。
- current, err := r.currentBalance(ctx, id)
- if err != nil {
- return service.BalanceChange{}, err
- }
- return service.BalanceChange{Old: current, New: value}, service.ErrBalanceNegative
- }
- const updateSQL = `
- UPDATE users AS u
- SET balance = $1, updated_at = NOW()
- FROM (SELECT id, balance FROM users WHERE id = $2 AND deleted_at IS NULL) AS prev
- WHERE u.id = prev.id AND u.deleted_at IS NULL
- RETURNING prev.balance, u.balance
- `
- change, ok, err := scanBalanceChange(ctx, clientFromContext(ctx, r.client), updateSQL, value, id)
- if err != nil {
- return service.BalanceChange{}, err
- }
- if !ok {
- return service.BalanceChange{}, service.ErrUserNotFound
- }
- return change, nil
- }
- // currentBalance 读取用户当前余额,用户不存在时返回 ErrUserNotFound。
- func (r *userRepository) currentBalance(ctx context.Context, id int64) (balance float64, err error) {
- rows, err := clientFromContext(ctx, r.client).QueryContext(ctx,
- `SELECT balance FROM users WHERE id = $1 AND deleted_at IS NULL`, id)
- if err != nil {
- return 0, err
- }
- defer func() {
- if closeErr := rows.Close(); closeErr != nil && err == nil {
- err = closeErr
- }
- }()
- if !rows.Next() {
- if rowsErr := rows.Err(); rowsErr != nil {
- return 0, rowsErr
- }
- return 0, service.ErrUserNotFound
- }
- if err := rows.Scan(&balance); err != nil {
- return 0, err
- }
- return balance, rows.Err()
- }
- // scanBalanceChange 执行一条 RETURNING 旧余额、新余额的语句。ok 为 false 表示语句未命中任何行。
- func scanBalanceChange(ctx context.Context, client *dbent.Client, query string, args ...any) (change service.BalanceChange, ok bool, err error) {
- rows, err := client.QueryContext(ctx, query, args...)
- if err != nil {
- return service.BalanceChange{}, false, err
- }
- defer func() {
- if closeErr := rows.Close(); closeErr != nil && err == nil {
- err = closeErr
- }
- }()
- if !rows.Next() {
- if rowsErr := rows.Err(); rowsErr != nil {
- return service.BalanceChange{}, false, rowsErr
- }
- return service.BalanceChange{}, false, nil
- }
- if err := rows.Scan(&change.Old, &change.New); err != nil {
- return service.BalanceChange{}, false, err
- }
- return change, true, rows.Err()
- }
- func (r *userRepository) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
- client := clientFromContext(ctx, r.client)
- n, err := client.User.Update().Where(dbuser.IDEQ(id)).AddConcurrency(amount).Save(ctx)
- if err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- if n == 0 {
- return service.ErrUserNotFound
- }
- return nil
- }
- func (r *userRepository) ApplyRedeemConcurrencyAdjustment(ctx context.Context, id int64, delta int) error {
- const updateSQL = `
- UPDATE users
- SET concurrency = GREATEST(concurrency + $1, 0), updated_at = NOW()
- WHERE id = $2 AND deleted_at IS NULL
- `
- client := clientFromContext(ctx, r.client)
- result, err := client.ExecContext(ctx, updateSQL, delta, id)
- if err != nil {
- return err
- }
- affected, err := result.RowsAffected()
- if err != nil {
- return err
- }
- if affected == 0 {
- return service.ErrUserNotFound
- }
- return nil
- }
- func (r *userRepository) BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) {
- if len(userIDs) == 0 {
- return 0, nil
- }
- if value < 0 {
- value = 0
- }
- res, err := r.sql.ExecContext(ctx,
- "UPDATE users SET concurrency = $1, updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL",
- value, pq.Array(userIDs))
- if err != nil {
- return 0, fmt.Errorf("batch set concurrency: %w", err)
- }
- affected, _ := res.RowsAffected()
- return int(affected), nil
- }
- func (r *userRepository) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) {
- if len(userIDs) == 0 {
- return 0, nil
- }
- res, err := r.sql.ExecContext(ctx,
- "UPDATE users SET concurrency = GREATEST(concurrency + $1, 0), updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL",
- delta, pq.Array(userIDs))
- if err != nil {
- return 0, fmt.Errorf("batch add concurrency: %w", err)
- }
- affected, _ := res.RowsAffected()
- return int(affected), nil
- }
- func (r *userRepository) BatchUpdateLimits(ctx context.Context, userIDs []int64, concurrency, rpmLimit *int) (int, error) {
- if len(userIDs) == 0 || (concurrency == nil && rpmLimit == nil) {
- return 0, nil
- }
- setClauses := make([]string, 0, 3)
- args := make([]any, 0, 3)
- if concurrency != nil {
- value := max(*concurrency, 0)
- args = append(args, value)
- setClauses = append(setClauses, fmt.Sprintf("concurrency = $%d", len(args)))
- }
- if rpmLimit != nil {
- value := max(*rpmLimit, 0)
- args = append(args, value)
- setClauses = append(setClauses, fmt.Sprintf("rpm_limit = $%d", len(args)))
- }
- setClauses = append(setClauses, "updated_at = NOW()")
- args = append(args, pq.Array(userIDs))
- query := fmt.Sprintf(
- "UPDATE users SET %s WHERE id = ANY($%d) AND deleted_at IS NULL",
- strings.Join(setClauses, ", "),
- len(args),
- )
- res, err := r.sql.ExecContext(ctx, query, args...)
- if err != nil {
- return 0, fmt.Errorf("batch update user limits: %w", err)
- }
- affected, _ := res.RowsAffected()
- return int(affected), nil
- }
- func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
- return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx)
- }
- // emailAliasCandidateLimit 限制一次别名查重最多取回的候选行数。探针都以去点后的
- // 本地部分为前缀锚定(见 dotStrippedEmailExpr),正常收件箱的变体只有个位数;
- // 上限只是兜底,避免公开未鉴权的注册/发码端点把大表整张读进内存。
- const emailAliasCandidateLimit = 50
- // ExistsByEmailAlias 见 service.UserRepository。软删除过滤沿用 ExistsByEmail 的默认行为。
- func (r *userRepository) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
- return existsByEmailAliasWithClient(ctx, clientFromContext(ctx, r.client), email)
- }
- func existsByEmailAliasWithClient(ctx context.Context, client *dbent.Client, email string) (bool, error) {
- _, exists, err := emailAliasOwnerIDWithClient(ctx, client, email, 0)
- return exists, err
- }
- func emailAliasOwnerIDWithClient(ctx context.Context, client *dbent.Client, email string, currentUserID int64) (int64, bool, error) {
- if client == nil {
- return 0, false, nil
- }
- probes := service.EmailAliasDedupProbes(email)
- if len(probes) == 0 {
- return 0, false, nil
- }
- preds := make([]predicate.User, 0, 2*len(probes))
- for _, probe := range probes {
- preds = append(preds,
- dotStrippedEmailEQ(probe.Local+"@"+probe.Domain),
- // "+后缀"的内容未知,只能按前缀匹配。
- dotStrippedEmailLike(escapeLikeWildcards(probe.Local)+"+%@"+escapeLikeWildcards(probe.Domain)),
- )
- }
- candidates, err := client.User.Query().
- Where(dbuser.Or(preds...)).
- Limit(emailAliasCandidateLimit).
- Select(dbuser.FieldID, dbuser.FieldEmail).
- All(ctx)
- if err != nil {
- return 0, false, err
- }
- // 探针会有过度匹配(点号只在 Gmail 家族无意义),最终判定必须回到完整归一化规则。
- // 返回“其他用户”优先于当前用户,避免历史重复数据让调用方误判为仅当前用户占用。
- identity := service.NormalizeEmailForAliasDedup(email)
- var selfID int64
- selfExists := false
- for _, candidate := range candidates {
- if service.NormalizeEmailForAliasDedup(candidate.Email) != identity {
- continue
- }
- if candidate.ID != 0 && candidate.ID != currentUserID {
- return candidate.ID, true, nil
- }
- if candidate.ID == currentUserID {
- selfID = candidate.ID
- selfExists = true
- }
- }
- return selfID, selfExists, nil
- }
- // UpdateEmailWithAliasGuard 在调用方事务内更新主邮箱与密码哈希。
- //
- // 邮箱换绑不能只依赖服务层前置查重:两个并发请求可能同时看到同一收件箱未被占用。
- // 这里先按“字面邮箱 + 收件箱身份”加锁,复查是否已被其他用户占用,再执行写入;
- // PostgreSQL 使用事务级 advisory lock 跨实例互斥,测试内存库则由进程内锁兜底。
- func (r *userRepository) UpdateEmailWithAliasGuard(
- ctx context.Context,
- userID int64,
- email string,
- passwordHash string,
- ) error {
- if userID <= 0 {
- return service.ErrUserNotFound
- }
- if strings.TrimSpace(email) == "" || passwordHash == "" {
- return fmt.Errorf("email identity update requires email and password hash")
- }
- tx := dbent.TxFromContext(ctx)
- if tx == nil {
- return fmt.Errorf("email identity update requires a transaction")
- }
- client := tx.Client()
- releaseEmailLock, err := lockRepositoryScopedKeys(
- ctx,
- client,
- txAwareSQLExecutor(ctx, r.sql, r.client),
- normalizedEmailUniquenessLockKey(email),
- emailAliasUniquenessLockKey(email),
- )
- if err != nil {
- return err
- }
- defer releaseEmailLock()
- ownerID, exists, err := emailAliasOwnerIDWithClient(ctx, client, email, userID)
- if err != nil {
- return err
- }
- if exists && ownerID != userID {
- return service.ErrEmailExists
- }
- if _, err := client.User.UpdateOneID(userID).
- SetEmail(email).
- SetPasswordHash(passwordHash).
- Save(ctx); err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, service.ErrEmailExists)
- }
- return nil
- }
- // dotStrippedEmailExpr 渲染下面的表达式:去掉存量邮箱的大小写、首尾空白(与
- // userEmailLookupPredicate 的精确匹配口径一致,历史数据存在带空白的行)以及全部点号。
- //
- // REPLACE(LOWER(TRIM(email)), '.', '')
- //
- // 两侧都去点,因此一个域名探针即可同时覆盖 Gmail 点号变体与 FQDN 根点(user@gmail.com.)。
- // migrations/190 为同一表达式建了索引。
- func dotStrippedEmailExpr(b *entsql.Builder, s *entsql.Selector) *entsql.Builder {
- return b.WriteString("REPLACE(LOWER(TRIM(").
- Ident(s.C(dbuser.FieldEmail)).
- WriteString(")), '.', '')")
- }
- func dotStrippedEmailEQ(value string) predicate.User {
- return predicate.User(func(s *entsql.Selector) {
- s.Where(entsql.P(func(b *entsql.Builder) {
- dotStrippedEmailExpr(b, s).WriteString(" = ").Arg(value)
- }))
- })
- }
- func dotStrippedEmailLike(pattern string) predicate.User {
- return predicate.User(func(s *entsql.Selector) {
- s.Where(entsql.P(func(b *entsql.Builder) {
- dotStrippedEmailExpr(b, s).WriteString(" LIKE ").Arg(pattern).WriteString(` ESCAPE '\'`)
- }))
- })
- }
- // escapeLikeWildcards 转义 LIKE 元字符:本地部分合法可含 % 与 _,不转义会扩大匹配面。
- var likeWildcardEscaper = strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`)
- func escapeLikeWildcards(value string) string {
- return likeWildcardEscaper.Replace(value)
- }
- func ensureNormalizedEmailAvailableWithClient(ctx context.Context, client *dbent.Client, userID int64, email string) error {
- client = clientFromContext(ctx, client)
- if client == nil {
- return nil
- }
- matches, err := client.User.Query().
- Where(userEmailLookupPredicate(email)).
- All(ctx)
- if err != nil {
- return err
- }
- for _, match := range matches {
- if match.ID != userID {
- return service.ErrEmailExists
- }
- }
- return nil
- }
- func userEmailLookupPredicate(email string) predicate.User {
- normalized := normalizeEmailLookupValue(email)
- if normalized == "" {
- return dbuser.EmailEQ(email)
- }
- return predicate.User(func(s *entsql.Selector) {
- s.Where(entsql.P(func(b *entsql.Builder) {
- b.WriteString("LOWER(TRIM(").
- Ident(s.C(dbuser.FieldEmail)).
- WriteString(")) = ").
- Arg(normalized)
- }))
- })
- }
- func normalizeEmailLookupValue(email string) string {
- return strings.ToLower(strings.TrimSpace(email))
- }
- func normalizedEmailUniquenessLockKey(email string) string {
- normalized := normalizeEmailLookupValue(email)
- if normalized == "" {
- return ""
- }
- return "users:normalized-email:" + normalized
- }
- func registrationEmailDomainLockKey(domain string) string {
- domain = normalizeEmailDomain(domain)
- if domain == "" {
- return ""
- }
- return "users:registration-email-domain:" + domain
- }
- func normalizeEmailDomain(domain string) string {
- return service.NormalizeRegistrationEmailDomain(domain)
- }
- func countUsersByEmailDomainWithClient(ctx context.Context, client *dbent.Client, domain string) (int, error) {
- client = clientFromContext(ctx, client)
- domain = normalizeEmailDomain(domain)
- if client == nil || domain == "" {
- return 0, nil
- }
- return client.User.Query().Where(userEmailDomainPredicate(domain)).Count(ctx)
- }
- func userEmailDomainPredicate(domain string) predicate.User {
- domain = normalizeEmailDomain(domain)
- escapedDomain := escapeLikeWildcards(domain)
- exactPattern := "%@" + escapedDomain
- subdomainPattern := "%@%." + escapedDomain
- return predicate.User(func(s *entsql.Selector) {
- s.Where(entsql.P(func(b *entsql.Builder) {
- b.WriteString("(RTRIM(LOWER(TRIM(").
- Ident(s.C(dbuser.FieldEmail)).
- WriteString(")), '.') LIKE ").
- Arg(exactPattern).
- WriteString(` ESCAPE '\' OR RTRIM(LOWER(TRIM(`).
- Ident(s.C(dbuser.FieldEmail)).
- WriteString(")), '.') LIKE ").
- Arg(subdomainPattern).
- WriteString(` ESCAPE '\'`).
- WriteString(")")
- }))
- })
- }
- // emailAliasUniquenessLockKey 按收件箱身份(而非邮箱字面量)加锁,使同一收件箱的不同
- // 别名变体在注册时互斥。
- func emailAliasUniquenessLockKey(email string) string {
- identity := service.NormalizeEmailForAliasDedup(email)
- if identity == "" {
- return ""
- }
- return "users:email-alias-identity:" + identity
- }
- func (r *userRepository) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
- client := clientFromContext(ctx, r.client)
- err := client.UserAllowedGroup.Create().
- SetUserID(userID).
- SetGroupID(groupID).
- OnConflictColumns(userallowedgroup.FieldUserID, userallowedgroup.FieldGroupID).
- DoNothing().
- Exec(ctx)
- if isSQLNoRowsError(err) {
- return nil
- }
- return err
- }
- func (r *userRepository) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) {
- // 仅操作 user_allowed_groups 联接表,legacy users.allowed_groups 列已弃用。
- affected, err := r.client.UserAllowedGroup.Delete().
- Where(userallowedgroup.GroupIDEQ(groupID)).
- Exec(ctx)
- if err != nil {
- return 0, err
- }
- return int64(affected), nil
- }
- // RemoveGroupFromUserAllowedGroups 移除单个用户的指定分组权限
- func (r *userRepository) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
- client := clientFromContext(ctx, r.client)
- _, err := client.UserAllowedGroup.Delete().
- Where(userallowedgroup.UserIDEQ(userID), userallowedgroup.GroupIDEQ(groupID)).
- Exec(ctx)
- return err
- }
- func (r *userRepository) GetFirstAdmin(ctx context.Context) (*service.User, error) {
- m, err := r.client.User.Query().
- Where(
- dbuser.RoleIn(service.RoleAdmin, service.RoleSuperAdmin),
- dbuser.StatusEQ(service.StatusActive),
- ).
- Order(dbent.Desc(dbuser.FieldRole), dbent.Asc(dbuser.FieldID)).
- First(ctx)
- if err != nil {
- return nil, translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- out := userEntityToService(m)
- groups, err := r.loadAllowedGroups(ctx, []int64{m.ID})
- if err != nil {
- return nil, err
- }
- if v, ok := groups[m.ID]; ok {
- out.AllowedGroups = v
- }
- return out, nil
- }
- func (r *userRepository) loadAllowedGroups(ctx context.Context, userIDs []int64) (map[int64][]int64, error) {
- out := make(map[int64][]int64, len(userIDs))
- if len(userIDs) == 0 {
- return out, nil
- }
- rows, err := r.client.UserAllowedGroup.Query().
- Where(userallowedgroup.UserIDIn(userIDs...)).
- All(ctx)
- if err != nil {
- return nil, err
- }
- for i := range rows {
- out[rows[i].UserID] = append(out[rows[i].UserID], rows[i].GroupID)
- }
- for userID := range out {
- sort.Slice(out[userID], func(i, j int) bool { return out[userID][i] < out[userID][j] })
- }
- return out, nil
- }
- // syncUserAllowedGroupsWithClient 在 ent client/事务内同步用户允许分组:
- // 仅操作 user_allowed_groups 联接表,legacy users.allowed_groups 列已弃用。
- func (r *userRepository) syncUserAllowedGroupsWithClient(ctx context.Context, client *dbent.Client, userID int64, groupIDs []int64) error {
- if client == nil {
- return nil
- }
- existingRows, err := client.UserAllowedGroup.Query().
- Where(userallowedgroup.UserIDEQ(userID)).
- All(ctx)
- if err != nil {
- return err
- }
- desired := make(map[int64]struct{}, len(groupIDs))
- for _, id := range groupIDs {
- if id <= 0 {
- continue
- }
- desired[id] = struct{}{}
- }
- existing := make(map[int64]struct{}, len(existingRows))
- removed := make([]int64, 0)
- for _, row := range existingRows {
- existing[row.GroupID] = struct{}{}
- if _, keep := desired[row.GroupID]; !keep {
- removed = append(removed, row.GroupID)
- }
- }
- if len(removed) > 0 {
- if _, err := client.UserAllowedGroup.Delete().
- Where(userallowedgroup.UserIDEQ(userID), userallowedgroup.GroupIDIn(removed...)).
- Exec(ctx); err != nil {
- return err
- }
- }
- creates := make([]*dbent.UserAllowedGroupCreate, 0, len(desired))
- for groupID := range desired {
- if _, present := existing[groupID]; !present {
- creates = append(creates, client.UserAllowedGroup.Create().SetUserID(userID).SetGroupID(groupID))
- }
- }
- if len(creates) > 0 {
- if err := client.UserAllowedGroup.
- CreateBulk(creates...).
- OnConflictColumns(userallowedgroup.FieldUserID, userallowedgroup.FieldGroupID).
- DoNothing().
- Exec(ctx); err != nil {
- if isSQLNoRowsError(err) {
- return nil
- }
- return err
- }
- }
- return nil
- }
- func applyUserEntityToService(dst *service.User, src *dbent.User) {
- if dst == nil || src == nil {
- return
- }
- dst.ID = src.ID
- dst.SignupSource = src.SignupSource
- dst.LastLoginAt = src.LastLoginAt
- dst.LastActiveAt = src.LastActiveAt
- dst.CreatedAt = src.CreatedAt
- dst.UpdatedAt = src.UpdatedAt
- }
- func userSignupSourceOrDefault(signupSource string) string {
- switch strings.TrimSpace(strings.ToLower(signupSource)) {
- case "", "email":
- return "email"
- case "linuxdo", "wechat", "oidc", "dingtalk":
- return strings.TrimSpace(strings.ToLower(signupSource))
- default:
- return "email"
- }
- }
- // marshalExtraEmails serializes notify email entries to JSON for storage.
- func marshalExtraEmails(entries []service.NotifyEmailEntry) string {
- return service.MarshalNotifyEmails(entries)
- }
- // UpdateTotpSecret 更新用户的 TOTP 加密密钥
- func (r *userRepository) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
- client := clientFromContext(ctx, r.client)
- update := client.User.UpdateOneID(userID)
- if encryptedSecret == nil {
- update = update.ClearTotpSecretEncrypted()
- } else {
- update = update.SetTotpSecretEncrypted(*encryptedSecret)
- }
- _, err := update.Save(ctx)
- if err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- return nil
- }
- // EnableTotp 启用用户的 TOTP 双因素认证
- func (r *userRepository) EnableTotp(ctx context.Context, userID int64) error {
- client := clientFromContext(ctx, r.client)
- _, err := client.User.UpdateOneID(userID).
- SetTotpEnabled(true).
- SetTotpEnabledAt(time.Now()).
- Save(ctx)
- if err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- return nil
- }
- // DisableTotp 禁用用户的 TOTP 双因素认证
- func (r *userRepository) DisableTotp(ctx context.Context, userID int64) error {
- client := clientFromContext(ctx, r.client)
- _, err := client.User.UpdateOneID(userID).
- SetTotpEnabled(false).
- ClearTotpEnabledAt().
- ClearTotpSecretEncrypted().
- Save(ctx)
- if err != nil {
- return translatePersistenceError(err, service.ErrUserNotFound, nil)
- }
- return nil
- }
|