feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)
Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
40
backend/internal/database/login_logs_migration_test.go
Normal file
40
backend/internal/database/login_logs_migration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
141
backend/internal/database/profile_migration_test.go
Normal file
141
backend/internal/database/profile_migration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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{},
|
||||
|
||||
Reference in New Issue
Block a user