Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
1529 lines
45 KiB
Go
1529 lines
45 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
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).
|
||
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 同步,避免跨层事务不一致。
|
||
tx, err := r.client.Tx(ctx)
|
||
if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
|
||
return err
|
||
}
|
||
|
||
var txClient *dbent.Client
|
||
txCtx := ctx
|
||
if err == nil {
|
||
defer func() { _ = tx.Rollback() }()
|
||
txClient = tx.Client()
|
||
txCtx = dbent.NewTxContext(ctx, tx)
|
||
} else {
|
||
// 已处于外部事务中(ErrTxStarted),复用当前事务 client 并由调用方负责提交/回滚。
|
||
if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
|
||
txClient = existingTx.Client()
|
||
} else {
|
||
txClient = r.client
|
||
}
|
||
}
|
||
|
||
// 邮箱唯一性锁与查重只在本次确实要改邮箱时才做:不改邮箱的更新既不需要
|
||
// 串行化,也不该因为快照里的旧邮箱已被他人占用而报 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.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 {
|
||
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) {
|
||
if client == nil {
|
||
return false, nil
|
||
}
|
||
probes := service.EmailAliasDedupProbes(email)
|
||
if len(probes) == 0 {
|
||
return 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.FieldEmail).
|
||
Strings(ctx)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
// 探针会有过度匹配(点号只在 Gmail 家族无意义),最终判定必须回到完整归一化规则。
|
||
identity := service.NormalizeEmailForAliasDedup(email)
|
||
for _, candidate := range candidates {
|
||
if service.NormalizeEmailForAliasDedup(candidate) == identity {
|
||
return true, nil
|
||
}
|
||
}
|
||
return false, 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.RoleEQ(service.RoleAdmin),
|
||
dbuser.StatusEQ(service.StatusActive),
|
||
).
|
||
Order(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
|
||
}
|