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,40 @@
package database
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"path/filepath"
"testing"
"yingce/backend/internal/model"
)
func TestLoginLogMigrationUpgradesExistingSchema(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "migration.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { _ = sqlDB.Close() })
if err := MigrateSchema(db); err != nil {
t.Fatal(err)
}
if err := db.Migrator().DropTable(&model.LoginLog{}); err != nil {
t.Fatal(err)
}
if err := db.Exec("DELETE FROM schema_migrations WHERE version = 53").Error; err != nil {
t.Fatal(err)
}
if err := MigrateSchema(db); err != nil {
t.Fatal(err)
}
if !db.Migrator().HasTable(&model.LoginLog{}) {
t.Fatal("existing schema lacks login logs")
}
if err := MigrateSchema(db); err != nil {
t.Fatal("migration is not repeatable", err)
}
status, err := ReadSchemaStatus(db)
if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
t.Fatalf("schema status: %#v %v", status, err)
}
}

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
)
const CurrentSchemaVersion int64 = 50
const CurrentSchemaVersion int64 = 53
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -166,6 +166,18 @@ var schemaMigrations = []migration{
{version: 50, name: "canvas_mutation_receipts", checksum: "sha256:canvas-mutation-receipts-v50-20261011", apply: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.CanvasMutationReceipt{})
}},
{version: 51, name: "personal_profile_security", checksum: "sha256:personal-profile-security-v51-20261012", apply: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.OAuthState{}, &model.AuthVerification{}, &model.UserAvatar{})
}},
{version: 52, name: "user_avatar_presets", checksum: "sha256:user-avatar-presets-v52-20261012", apply: func(tx *gorm.DB) error {
if tx.Migrator().HasColumn(&model.User{}, "AvatarPreset") {
return nil
}
return tx.Migrator().AddColumn(&model.User{}, "AvatarPreset")
}},
{version: 53, name: "user_login_logs", checksum: "sha256:user-login-logs-v53", apply: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.LoginLog{})
}},
}
func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error {

View File

@@ -0,0 +1,141 @@
package database
import (
"bytes"
"testing"
"yingce/backend/internal/model"
)
func TestProfileMigrationUpgradesLegacyIdentityRows(t *testing.T) {
db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"})
if err != nil {
t.Fatal(err)
}
for _, sql := range []string{
`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, email TEXT, password_hash TEXT, status TEXT)`,
`INSERT INTO users VALUES ('legacy','legacy','legacy@example.com','preserved-hash','active')`,
`CREATE TABLE auth_sessions (id TEXT PRIMARY KEY,user_id TEXT,token_hash TEXT,expires_at DATETIME)`,
`INSERT INTO auth_sessions VALUES ('legacy-session','legacy','preserved-token','2099-01-01')`,
`CREATE TABLE o_auth_states (id TEXT PRIMARY KEY,provider TEXT,state_hash TEXT)`,
`INSERT INTO o_auth_states VALUES ('legacy-state','linuxdo','preserved-state')`,
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
var migration *migration
for i := range schemaMigrations {
if schemaMigrations[i].version == 51 {
migration = &schemaMigrations[i]
}
}
if migration == nil {
t.Fatal("profile migration absent")
}
for i := 0; i < 2; i++ {
if err := db.Transaction(migration.apply); err != nil {
t.Fatal(err)
}
}
for _, field := range []string{"AvatarMode", "AvatarVersion"} {
if !db.Migrator().HasColumn(&model.User{}, field) {
t.Fatalf("missing %s", field)
}
}
if !db.Migrator().HasColumn(&model.AuthSession{}, "ReauthenticatedAt") || !db.Migrator().HasColumn(&model.OAuthState{}, "ReauthUserID") || !db.Migrator().HasColumn(&model.OAuthState{}, "ReauthSessionID") || !db.Migrator().HasColumn(&model.AuthVerification{}, "SessionID") || !db.Migrator().HasColumn(&model.AuthVerification{}, "CredentialHash") || !db.Migrator().HasTable(&model.UserAvatar{}) {
t.Fatal("profile schema incomplete")
}
var user model.User
var session model.AuthSession
var state model.OAuthState
if err := db.First(&user, "id = ?", "legacy").Error; err != nil {
t.Fatal(err)
}
if err := db.First(&session, "id = ?", "legacy-session").Error; err != nil {
t.Fatal(err)
}
if err := db.First(&state, "id = ?", "legacy-state").Error; err != nil {
t.Fatal(err)
}
if user.PasswordHash != "preserved-hash" || user.Email != "legacy@example.com" || session.TokenHash != "preserved-token" || session.ReauthenticatedAt != nil || state.StateHash != "preserved-state" || state.ReauthUserID != "" || state.ReauthSessionID != "" {
t.Fatal("legacy credentials or verification changed")
}
for _, m := range Models() {
if _, ok := m.(*model.UserAvatar); ok {
return
}
}
t.Fatal("avatar missing from Models")
}
func TestAvatarPresetMigrationUpgradesV51WithoutChangingUsersOrBlobs(t *testing.T) {
db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := MigrateSchema(db); err != nil {
t.Fatal(err)
}
// Reconstruct the already applied v51 shape and migration ledger. The new
// migration must add one column without replaying or changing v51 metadata.
if err := db.Migrator().DropColumn(&model.User{}, "AvatarPreset"); err != nil {
t.Fatal(err)
}
if err := db.Delete(&schemaMigration{}, "version = ?", 52).Error; err != nil {
t.Fatal(err)
}
var v51Before schemaMigration
if err := db.First(&v51Before, "version = ?", 51).Error; err != nil {
t.Fatal(err)
}
if v51Before.Name != "personal_profile_security" || v51Before.Checksum != "sha256:personal-profile-security-v51-20261012" {
t.Fatal("existing v51 identity changed")
}
user := model.User{ID: "preset-migration-user", Username: "preset-user", Email: "preset@example.com", DisplayName: "Creator", PasswordHash: "preserved-hash", Status: model.UserStatusActive, AvatarMode: "custom", AvatarVersion: "preserved-avatar-version"}
if err := db.Omit("AvatarPreset").Create(&user).Error; err != nil {
t.Fatal(err)
}
payload := []byte("preserved-avatar-payload")
if err := db.Create(&model.UserAvatar{UserID: user.ID, Data: payload}).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := MigrateSchema(db); err != nil {
t.Fatal(err)
}
}
if !db.Migrator().HasColumn(&model.User{}, "AvatarPreset") {
t.Fatal("missing avatar preset column")
}
var migrated model.User
var avatar model.UserAvatar
var v51After schemaMigration
var v52 schemaMigration
if err := db.First(&migrated, "id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&avatar, "user_id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&v51After, "version = ?", 51).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&v52, "version = ?", 52).Error; err != nil {
t.Fatal(err)
}
if migrated.PasswordHash != user.PasswordHash || migrated.Email != user.Email || migrated.DisplayName != user.DisplayName || migrated.AvatarMode != "custom" || migrated.AvatarVersion != user.AvatarVersion || migrated.AvatarPreset != "" || !bytes.Equal(avatar.Data, payload) {
t.Fatal("v52 migration changed existing profile or custom avatar")
}
if v51After != v51Before || v52.Name != "user_avatar_presets" || v52.Checksum == v51Before.Checksum {
t.Fatal("migration identity was rewritten or new checksum was reused")
}
status, err := ReadSchemaStatus(db)
if err != nil || !status.Ready || status.Current != CurrentSchemaVersion {
t.Fatalf("current schema status: %#v, %v", status, err)
}
}

View File

@@ -28,7 +28,9 @@ func Models() []any {
&model.AgentLesson{},
&model.AgentMemorySetting{},
&model.User{},
&model.UserAvatar{},
&model.AuthSession{},
&model.LoginLog{},
&model.UserIdentity{},
&model.OAuthState{},
&model.EmailVerificationCode{},