feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)

Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
ddcat666
2026-10-13 05:31:30 +08:00
committed by GitHub
parent e70fe1b9c9
commit 4c1d2b4012
83 changed files with 5836 additions and 328 deletions

View File

@@ -0,0 +1,174 @@
package repository
import (
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"yingce/backend/internal/model"
)
var ErrProfileAuthorization = errors.New("账号或身份验证状态已变化,请重新登录或验证")
const ProfileReauthenticationTTL = 5 * time.Minute
func ProfileCredentialHash(passwordHash string) string {
v := sha256.Sum256([]byte(passwordHash))
return hex.EncodeToString(v[:])
}
func lockProfileUser(tx *gorm.DB, userID string) error {
if tx.Dialector.Name() == "sqlite" {
return tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("updated_at", gorm.Expr("updated_at")).Error
}
return nil
}
// Serialize session issuance with credential changes so a login validated
// before revocation cannot insert an authenticated session afterwards.
func (r *Repository) CreateAuthSessionForUser(expected *model.User, session *model.AuthSession, events ...*model.LoginLog) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if session.UserID != expected.ID {
return ErrProfileAuthorization
}
if err := lockProfileUser(tx, expected.ID); err != nil {
return err
}
var current model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, "id = ?", expected.ID).Error; err != nil {
return err
}
if current.Status != model.UserStatusActive || current.PasswordHash != expected.PasswordHash || current.Email != expected.Email {
return ErrProfileAuthorization
}
if err := tx.Create(session).Error; err != nil {
return err
}
for _, event := range events {
if event.UserID == nil || *event.UserID != expected.ID || event.Event != "login" || event.Result != "success" {
return ErrProfileAuthorization
}
if err := tx.Create(event).Error; err != nil {
return err
}
}
return nil
})
}
func profileAuthorization(tx *gorm.DB, expected *model.User, session *model.AuthSession, now time.Time, requireReauth bool) error {
var current model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, "id = ?", expected.ID).Error; err != nil {
return err
}
if current.Status != model.UserStatusActive || current.PasswordHash != expected.PasswordHash || current.Email != expected.Email {
return ErrProfileAuthorization
}
var stored model.AuthSession
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&stored, "id = ? AND user_id = ?", session.ID, expected.ID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrProfileAuthorization
}
return err
}
// A transaction may wait for another account write; expiry is checked after
// taking the locks rather than against the request's earlier timestamp.
now = time.Now()
if stored.TokenHash != session.TokenHash || !stored.ExpiresAt.After(now) {
return ErrProfileAuthorization
}
if requireReauth && (stored.ReauthenticatedAt == nil || stored.ReauthenticatedAt.After(now) || !stored.ReauthenticatedAt.Add(ProfileReauthenticationTTL).After(now)) {
return ErrProfileAuthorization
}
return nil
}
func (r *Repository) ChangeProfilePassword(expected *model.User, session *model.AuthSession, newHash string, now time.Time) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := lockProfileUser(tx, expected.ID); err != nil {
return err
}
if err := profileAuthorization(tx, expected, session, now, expected.PasswordHash == ""); err != nil {
return err
}
result := tx.Model(&model.User{}).Where("id = ? AND password_hash = ? AND status = ?", expected.ID, expected.PasswordHash, model.UserStatusActive).Updates(map[string]any{"password_hash": newHash, "updated_at": now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrProfileAuthorization
}
return tx.Where("user_id = ?", expected.ID).Delete(&model.AuthSession{}).Error
})
}
func (r *Repository) ChangeProfileEmail(expected *model.User, session *model.AuthSession, v *model.AuthVerification, now time.Time) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := lockRegistrationEmail(tx, v.Email); err != nil {
return err
}
if err := profileAuthorization(tx, expected, session, now, expected.PasswordHash == ""); err != nil {
return err
}
now = time.Now()
if err := New(tx).CheckEmailAvailable(v.Email, expected.ID); err != nil {
return err
}
result := tx.Model(&model.AuthVerification{}).Where("id = ? AND user_id = ? AND session_id = ? AND purpose = ? AND credential_hash = ? AND email = ? AND ready = ? AND used_at IS NULL AND expires_at > ? AND attempts <= 5", v.ID, expected.ID, session.ID, "profile_email", ProfileCredentialHash(expected.PasswordHash), v.Email, true, now).Update("used_at", now)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrVerificationInvalid
}
result = tx.Model(&model.User{}).Where("id = ? AND password_hash = ? AND status = ?", expected.ID, expected.PasswordHash, model.UserStatusActive).Updates(map[string]any{"email": v.Email, "email_verified_at": now, "updated_at": now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrProfileAuthorization
}
return tx.Where("user_id = ?", expected.ID).Delete(&model.AuthSession{}).Error
})
}
func (r *Repository) ReauthenticateLinuxDOSession(expected *model.User, session *model.AuthSession, subject string, now time.Time) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := lockProfileUser(tx, expected.ID); err != nil {
return err
}
if err := profileAuthorization(tx, expected, session, now, false); err != nil {
return err
}
var count int64
if err := tx.Model(&model.UserIdentity{}).Where("provider = ? AND subject = ? AND user_id = ?", "linuxdo", subject, expected.ID).Count(&count).Error; err != nil {
return err
}
if count != 1 {
return ErrProfileAuthorization
}
result := tx.Model(&model.AuthSession{}).Where("id = ? AND user_id = ? AND token_hash = ?", session.ID, expected.ID, session.TokenHash).Updates(map[string]any{"reauthenticated_at": now, "updated_at": now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrProfileAuthorization
}
return nil
})
}
func (r *Repository) LinuxDOReauthenticationState(stateHash string) (bool, error) {
var state model.OAuthState
err := r.db.First(&state, "provider = ? AND state_hash = ?", "linuxdo", stateHash).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return state.ReauthUserID != "" && state.ReauthSessionID != "", nil
}

View File

@@ -0,0 +1,243 @@
package repository
import (
"errors"
"path/filepath"
"sync"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"yingce/backend/internal/model"
)
func profileRepositoryDB(t *testing.T) (*gorm.DB, string) {
t.Helper()
dsn := filepath.Join(t.TempDir(), "profile.db") + "?_busy_timeout=5000&_journal_mode=WAL"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AuthVerification{}, &model.UserIdentity{}); err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
return db, dsn
}
func TestProfilePasswordConcurrentChangeUsesCredentialCompare(t *testing.T) {
db, dsn := profileRepositoryDB(t)
now := time.Now()
u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive, PasswordHash: "old-hash"}
session := &model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token-hash", ExpiresAt: now.Add(time.Hour)}
if err := db.Create(u).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(session).Error; err != nil {
t.Fatal(err)
}
start := make(chan struct{})
results := make(chan error, 2)
var wg sync.WaitGroup
for _, newHash := range []string{"new-one", "new-two"} {
peer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := peer.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
wg.Add(1)
go func() { defer wg.Done(); <-start; results <- New(peer).ChangeProfilePassword(u, session, newHash, now) }()
}
close(start)
wg.Wait()
close(results)
success := 0
for err := range results {
if err == nil {
success++
} else if !errors.Is(err, ErrProfileAuthorization) {
t.Fatal(err)
}
}
if success != 1 {
t.Fatalf("successful changes=%d", success)
}
var count int64
db.Model(&model.AuthSession{}).Count(&count)
if count != 0 {
t.Fatal("session remained")
}
}
func TestLinuxDOReauthenticationPersistsAcrossSessionTimezones(t *testing.T) {
for _, offset := range []int{-12, 14} {
t.Run(time.FixedZone("session", offset*3600).String(), func(t *testing.T) {
db, _ := profileRepositoryDB(t)
now := time.Now().UTC()
u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive}
session := &model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token", ExpiresAt: now.Add(time.Hour).In(time.FixedZone("session", offset*3600))}
identity := &model.UserIdentity{ID: "identity", UserID: u.ID, Provider: "linuxdo", Subject: "subject"}
for _, row := range []any{u, session, identity} {
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
if err := New(db).ReauthenticateLinuxDOSession(u, session, identity.Subject, now); err != nil {
t.Fatal(err)
}
var stored model.AuthSession
if err := db.First(&stored, "id = ?", session.ID).Error; err != nil {
t.Fatal(err)
}
if stored.ReauthenticatedAt == nil || !stored.ReauthenticatedAt.Equal(now) {
t.Fatal("successful reauthentication did not persist its timestamp")
}
})
}
}
func TestProfileEmailConcurrentCanonicalCollisionDoesNotConsumeLoser(t *testing.T) {
db, dsn := profileRepositoryDB(t)
now := time.Now()
start := make(chan struct{})
results := make(chan error, 2)
var wg sync.WaitGroup
for i, id := range []string{"one", "two"} {
u := &model.User{ID: id, Username: id, Status: model.UserStatusActive, PasswordHash: "hash"}
session := &model.AuthSession{ID: id, UserID: id, TokenHash: "token", ExpiresAt: now.Add(time.Hour)}
v := &model.AuthVerification{ID: id, UserID: id, SessionID: id, CredentialHash: ProfileCredentialHash("hash"), Purpose: "profile_email", Email: []string{"a.b+first@gmail.com", "ab+second@googlemail.com"}[i], Ready: true, ExpiresAt: now.Add(time.Hour)}
for _, row := range []any{u, session, v} {
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
peer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := peer.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
wg.Add(1)
go func() { defer wg.Done(); <-start; results <- New(peer).ChangeProfileEmail(u, session, v, now) }()
}
close(start)
wg.Wait()
close(results)
success := 0
for err := range results {
if err == nil {
success++
} else if err.Error() != "邮箱已被注册" {
t.Fatal(err)
}
}
if success != 1 {
t.Fatalf("successful changes=%d", success)
}
var count int64
db.Model(&model.AuthVerification{}).Where("used_at IS NOT NULL").Count(&count)
if count != 1 {
t.Fatalf("consumed=%d", count)
}
db.Model(&model.AuthSession{}).Count(&count)
if count != 1 {
t.Fatalf("remaining sessions=%d", count)
}
}
func TestProfileEmailRollbackWhenSessionRevocationFails(t *testing.T) {
db, _ := profileRepositoryDB(t)
now := time.Now()
u := &model.User{ID: "one", Username: "one", Email: "old@example.com", PasswordHash: "hash", Status: model.UserStatusActive}
session := &model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token", ExpiresAt: now.Add(time.Hour)}
v := &model.AuthVerification{ID: "ticket", Purpose: "profile_email", UserID: u.ID, SessionID: session.ID, Email: "new@example.com", CredentialHash: ProfileCredentialHash("hash"), Ready: true, ExpiresAt: now.Add(time.Hour)}
for _, row := range []any{u, session, v} {
if err := db.Create(row).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec(`CREATE TRIGGER fail_revoke BEFORE DELETE ON auth_sessions BEGIN SELECT RAISE(ABORT, 'revocation failed'); END`).Error; err != nil {
t.Fatal(err)
}
if err := New(db).ChangeProfileEmail(u, session, v, now); err == nil {
t.Fatal("revocation failure ignored")
}
var stored model.User
if err := db.First(&stored, "id = ?", u.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Email != u.Email || stored.EmailVerifiedAt != nil {
t.Fatal("email update not rolled back")
}
var ticket model.AuthVerification
if err := db.First(&ticket, "id = ?", v.ID).Error; err != nil {
t.Fatal(err)
}
if ticket.UsedAt != nil {
t.Fatal("ticket consumption not rolled back")
}
}
func TestProfileCredentialChangeRejectsInFlightLoginSession(t *testing.T) {
db, _ := profileRepositoryDB(t)
u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive, PasswordHash: "old-hash", Email: "old@example.com"}
if err := db.Create(u).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.User{}).Where("id = ?", u.ID).Update("password_hash", "new-hash").Error; err != nil {
t.Fatal(err)
}
session := &model.AuthSession{ID: "new-session", UserID: u.ID, TokenHash: "token", ExpiresAt: time.Now().Add(time.Hour)}
if err := New(db).CreateAuthSessionForUser(u, session); !errors.Is(err, ErrProfileAuthorization) {
t.Fatalf("stale login accepted: %v", err)
}
var count int64
db.Model(&model.AuthSession{}).Count(&count)
if count != 0 {
t.Fatal("stale login inserted session")
}
}
func TestProfilePasswordRevocationSerializesWithConcurrentSessionIssuance(t *testing.T) {
db, dsn := profileRepositoryDB(t)
now := time.Now()
u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive, PasswordHash: "old-hash"}
oldSession := &model.AuthSession{ID: "old-session", UserID: u.ID, TokenHash: "old-token", ExpiresAt: now.Add(time.Hour)}
if err := db.Create(u).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(oldSession).Error; err != nil {
t.Fatal(err)
}
peer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := peer.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
start := make(chan struct{})
issued := make(chan error, 1)
changed := make(chan error, 1)
go func() {
<-start
issued <- New(peer).CreateAuthSessionForUser(u, &model.AuthSession{ID: "new-session", UserID: u.ID, TokenHash: "new-token", ExpiresAt: now.Add(time.Hour)})
}()
go func() { <-start; changed <- New(db).ChangeProfilePassword(u, oldSession, "new-hash", now) }()
close(start)
if err := <-changed; err != nil {
t.Fatal(err)
}
if err := <-issued; err != nil && !errors.Is(err, ErrProfileAuthorization) {
t.Fatal(err)
}
var count int64
if err := db.Model(&model.AuthSession{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("sessions surviving concurrent revocation=%d", count)
}
}

View File

@@ -0,0 +1,108 @@
package repository
import (
"errors"
"time"
"yingce/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type LoginLogFilter struct {
Keyword string
Event string
Method string
Result string
IP string
From *time.Time
To *time.Time
}
func (r *Repository) AppendLoginLog(event *model.LoginLog) error { return r.db.Create(event).Error }
func (r *Repository) LoginLogs(filter LoginLogFilter, limit, offset int) ([]model.LoginLog, int64, error) {
query := r.db.Model(&model.LoginLog{})
if filter.Keyword != "" {
term := "%" + filter.Keyword + "%"
query = query.Where("lower(username) LIKE lower(?) OR lower(display_name) LIKE lower(?) OR lower(account) LIKE lower(?) OR user_id = ?", term, term, term, filter.Keyword)
}
if filter.Event != "" {
query = query.Where("event = ?", filter.Event)
}
if filter.Method != "" {
query = query.Where("method = ?", filter.Method)
}
if filter.Result != "" {
query = query.Where("result = ?", filter.Result)
}
if filter.IP != "" {
query = query.Where("ip = ?", filter.IP)
}
if filter.From != nil {
query = query.Where("created_at >= ?", filter.From.UTC())
}
if filter.To != nil {
query = query.Where("created_at <= ?", filter.To.UTC())
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
logs := make([]model.LoginLog, 0)
err := query.Order("created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&logs).Error
return logs, total, err
}
func (r *Repository) LoginLog(id string) (*model.LoginLog, error) {
var event model.LoginLog
if err := r.db.First(&event, "id = ?", id).Error; err != nil {
return nil, err
}
return &event, nil
}
// 凭据校验、撤销和日志提交在同一事务中;只有真正撤销的有效会话写成功事件。
func (r *Repository) RevokeAuthSessionWithLog(sessionID, tokenHash string, event *model.LoginLog) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// SQLite 先取得写锁,避免并发退出在读快照后升级写锁时互相冲突。
if tx.Dialector.Name() == "sqlite" {
if err := tx.Model(&model.AuthSession{}).Where("id = ? AND token_hash = ?", sessionID, tokenHash).UpdateColumn("updated_at", gorm.Expr("updated_at")).Error; err != nil {
return err
}
}
var session model.AuthSession
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&session, "id = ? AND token_hash = ?", sessionID, tokenHash).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
// 既有 SQLite 会话可能保存不同时区的时间字符串,不能按 SQL 字符串比较有效期。
if !session.ExpiresAt.After(time.Now()) {
return nil
}
var user model.User
if err := tx.First(&user, "id = ?", session.UserID).Error; err != nil {
return err
}
event.UserID = &user.ID
event.Username, event.DisplayName, event.Account = user.Username, user.DisplayName, user.Username
var login model.LoginLog
err = tx.Where("session_ref = ? AND event = ? AND result = ?", event.SessionRef, "login", "success").First(&login).Error
if err == nil {
event.Method = login.Method
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
deleted := tx.Where("id = ? AND token_hash = ? AND expires_at = ?", session.ID, tokenHash, session.ExpiresAt).Delete(&model.AuthSession{})
if deleted.Error != nil {
return deleted.Error
}
if deleted.RowsAffected == 0 {
return nil
}
return tx.Create(event).Error
})
}

View File

@@ -0,0 +1,144 @@
package repository
import (
"fmt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"path/filepath"
"sync"
"testing"
"time"
"yingce/backend/internal/model"
)
func TestRevokeAuthSessionConcurrentWritesSingleLogout(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "logout.db")+"?_busy_timeout=5000&_journal_mode=WAL"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.LoginLog{}); err != nil {
t.Fatal(err)
}
u := model.User{ID: "concurrent-user", Username: "concurrent-user", DisplayName: "Creator"}
now := time.Now()
if err := db.Create(&u).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token-hash", ExpiresAt: now.Add(time.Hour), UpdatedAt: now}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.LoginLog{ID: "login", UserID: &u.ID, Event: "login", Result: "success", Method: "email", SessionRef: "session-ref", CreatedAt: now}).Error; err != nil {
t.Fatal(err)
}
r := New(db)
start := make(chan struct{})
errs := make(chan error, 4)
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
errs <- r.RevokeAuthSessionWithLog("session", "token-hash", &model.LoginLog{ID: fmt.Sprintf("logout-%d", i), Event: "logout", Result: "success", Method: "unknown", Source: "logout", SessionRef: "session-ref", CreatedAt: time.Now()})
}(i)
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal("concurrent logout failed", err)
}
}
var events []model.LoginLog
if err := db.Where("event = ?", "logout").Find(&events).Error; err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Method != "email" || events[0].Username != u.Username {
t.Fatalf("concurrent logout logs: %#v", events)
}
}
func TestLoginLogFiltersKeepHistoricalSnapshots(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.LoginLog{}); err != nil {
t.Fatal(err)
}
at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
id := "historical-user"
for _, event := range []model.LoginLog{
{ID: "first", UserID: &id, Username: "Creator", DisplayName: "历史昵称", Event: "login", Method: "password", Result: "success", IP: "192.0.2.5", CreatedAt: at},
{ID: "second", Username: "Another", Event: "login", Method: "email", Result: "failed", IP: "192.0.2.6", CreatedAt: at.Add(time.Hour)},
} {
if err := db.Create(&event).Error; err != nil {
t.Fatal(err)
}
}
r := New(db)
filter := LoginLogFilter{Keyword: "creator", Event: "login", Method: "password", Result: "success", IP: "192.0.2.5", From: &at, To: &at}
logs, total, err := r.LoginLogs(filter, 20, 0)
if err != nil || total != 1 || len(logs) != 1 || logs[0].DisplayName != "历史昵称" {
t.Fatalf("boundary filter: %#v %d %v", logs, total, err)
}
offsetBound := at.In(time.FixedZone("UTC+8", 8*60*60))
filter.From, filter.To = &offsetBound, &offsetBound
logs, total, err = r.LoginLogs(filter, 20, 0)
if err != nil || total != 1 || len(logs) != 1 {
t.Fatalf("same instant in another timezone: %#v %d %v", logs, total, err)
}
logs, total, err = r.LoginLogs(LoginLogFilter{}, 1, 0)
if err != nil || total != 2 || len(logs) != 1 || logs[0].ID != "second" {
t.Fatalf("descending page: %#v %d %v", logs, total, err)
}
logs, total, err = r.LoginLogs(LoginLogFilter{Keyword: "missing"}, 20, 0)
if err != nil || total != 0 || logs == nil || len(logs) != 0 {
t.Fatalf("empty array: %#v %d %v", logs, total, err)
}
}
func TestLogoutExpirationUsesInstantAcrossTimezones(t *testing.T) {
for _, valid := range []bool{true, false} {
t.Run(fmt.Sprintf("valid=%t", valid), func(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.LoginLog{}); err != nil {
t.Fatal(err)
}
u := model.User{ID: "timezone-user", Username: "timezone-user"}
if err := db.Create(&u).Error; err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
expires, eventTime := now.Add(-time.Hour).In(time.FixedZone("UTC+8", 8*60*60)), now
if valid {
expires = now.Add(time.Hour)
eventTime = now.In(time.FixedZone("UTC+8", 8*60*60))
}
if err := db.Create(&model.AuthSession{ID: "timezone-session", UserID: u.ID, TokenHash: "token", ExpiresAt: expires}).Error; err != nil {
t.Fatal(err)
}
event := &model.LoginLog{ID: "timezone-log", Event: "logout", Result: "success", Method: "unknown", CreatedAt: eventTime}
if err := New(db).RevokeAuthSessionWithLog("timezone-session", "token", event); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&model.LoginLog{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if (count == 1) != valid {
t.Fatalf("expiration is compared by wall clock rather than instant: logs=%d, valid=%t", count, valid)
}
})
}
}

View File

@@ -0,0 +1,53 @@
package repository
import (
"errors"
"time"
"yingce/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var ErrProfileAvatarUserUnavailable = errors.New("avatar owner is not active")
func (r *Repository) UserAvatar(userID string) (*model.UserAvatar, error) {
var avatar model.UserAvatar
err := r.db.First(&avatar, "user_id = ?", userID).Error
return &avatar, err
}
// UpdateUserAvatar serializes avatar writes on the user row so the payload and
// its cache version always belong to the same committed selection.
func (r *Repository) UpdateUserAvatar(userID string, data []byte, version string, preset string, now time.Time) (*model.User, error) {
var user model.User
err := r.db.Transaction(func(tx *gorm.DB) error {
mode := "default"
if len(data) > 0 {
mode = "custom"
}
updates := map[string]any{"avatar_mode": mode, "avatar_version": version, "updated_at": now}
if mode == "default" {
updates["avatar_preset"] = preset
}
result := tx.Model(&model.User{}).Where("id = ? AND status = ?", userID, model.UserStatusActive).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrProfileAvatarUserUnavailable
}
if mode == "custom" {
avatar := model.UserAvatar{UserID: userID, Data: data, UpdatedAt: now}
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"data", "updated_at"})}).Create(&avatar).Error; err != nil {
return err
}
} else if err := tx.Delete(&model.UserAvatar{}, "user_id = ?", userID).Error; err != nil {
return err
}
return tx.First(&user, "id = ?", userID).Error
})
return &user, err
}