From 346cf0e4a6610a601b90d0c7eac9443cda8d631d Mon Sep 17 00:00:00 2001 From: Chaos Date: Mon, 15 Jun 2026 07:36:13 +0800 Subject: [PATCH] fix: restore soft-deleted users on login --- controller/discord.go | 5 ++ controller/github.go | 9 +-- controller/linuxdo.go | 8 +-- controller/oauth.go | 10 ++- controller/oidc.go | 5 ++ controller/telegram.go | 5 ++ controller/user.go | 2 +- controller/wechat.go | 8 +-- dev.ps1 | 132 ++++++++++++++++++++++++++++++++++ model/log.go | 27 +++++++ model/user.go | 51 +++++++++---- model/user_oauth_binding.go | 8 +-- web/default/rsbuild.config.ts | 1 + 13 files changed, 234 insertions(+), 37 deletions(-) create mode 100644 dev.ps1 diff --git a/controller/discord.go b/controller/discord.go index a0865de5..ad1154c9 100644 --- a/controller/discord.go +++ b/controller/discord.go @@ -139,6 +139,11 @@ func DiscordOAuth(c *gin.Context) { }) return } + if err := user.RestoreIfDeleted("discord", c.ClientIP()); err != nil { + common.SysError(fmt.Sprintf("failed to restore user %d: %v", user.Id, err)) + common.ApiError(c, err) + return + } } else { if common.RegisterEnabled { if discordUser.ID != "" { diff --git a/controller/github.go b/controller/github.go index 5d906136..f5f99b62 100644 --- a/controller/github.go +++ b/controller/github.go @@ -122,12 +122,9 @@ func GitHubOAuth(c *gin.Context) { }) return } - // if user.Id == 0 , user has been deleted - if user.Id == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "用户已注销", - }) + if err := user.RestoreIfDeleted("github", c.ClientIP()); err != nil { + common.SysError(fmt.Sprintf("failed to restore user %d: %v", user.Id, err)) + common.ApiError(c, err) return } } else { diff --git a/controller/linuxdo.go b/controller/linuxdo.go index 5457c9a4..b4a91c5e 100644 --- a/controller/linuxdo.go +++ b/controller/linuxdo.go @@ -212,11 +212,9 @@ func LinuxdoOAuth(c *gin.Context) { }) return } - if user.Id == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "用户已注销", - }) + if err := user.RestoreIfDeleted("linuxdo", c.ClientIP()); err != nil { + common.SysError(fmt.Sprintf("failed to restore user %d: %v", user.Id, err)) + common.ApiError(c, err) return } } else { diff --git a/controller/oauth.go b/controller/oauth.go index 147e0047..25682b38 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -213,9 +213,9 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o if err != nil { return nil, err } - // Check if user has been deleted - if user.Id == 0 { - return nil, &OAuthUserDeletedError{} + if err := user.RestoreIfDeleted(provider.GetName(), c.ClientIP()); err != nil { + common.SysError(fmt.Sprintf("[OAuth] Failed to restore user %d: %s", user.Id, err.Error())) + return nil, err } return user, nil } @@ -227,6 +227,10 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o if err != nil { return nil, err } + if err := user.RestoreIfDeleted(provider.GetName(), c.ClientIP()); err != nil { + common.SysError(fmt.Sprintf("[OAuth] Failed to restore user %d: %s", user.Id, err.Error())) + return nil, err + } if user.Id != 0 { // Found user with legacy ID, migrate to new ID common.SysLog(fmt.Sprintf("[OAuth] Migrating user %d from legacy_id=%s to new_id=%s", diff --git a/controller/oidc.go b/controller/oidc.go index ac49f84e..e3c9ddc7 100644 --- a/controller/oidc.go +++ b/controller/oidc.go @@ -141,6 +141,11 @@ func OidcAuth(c *gin.Context) { }) return } + if err := user.RestoreIfDeleted("oidc", c.ClientIP()); err != nil { + common.SysError(fmt.Sprintf("failed to restore user %d: %v", user.Id, err)) + common.ApiError(c, err) + return + } } else { if common.RegisterEnabled { user.Email = oidcUser.Email diff --git a/controller/telegram.go b/controller/telegram.go index b5918d8e..9b464fc2 100644 --- a/controller/telegram.go +++ b/controller/telegram.go @@ -95,6 +95,11 @@ func TelegramLogin(c *gin.Context) { }) return } + if err := user.RestoreIfDeleted("telegram", c.ClientIP()); err != nil { + common.SysError("failed to restore user: " + err.Error()) + common.ApiError(c, err) + return + } setupLogin(&user, c) } diff --git a/controller/user.go b/controller/user.go index afebc6d4..7e3f9ce1 100644 --- a/controller/user.go +++ b/controller/user.go @@ -51,7 +51,7 @@ func Login(c *gin.Context) { Username: username, Password: password, } - err = user.ValidateAndFill() + err = user.ValidateAndFill(c.ClientIP()) if err != nil { switch { case errors.Is(err, model.ErrDatabase): diff --git a/controller/wechat.go b/controller/wechat.go index 8889daca..b5975434 100644 --- a/controller/wechat.go +++ b/controller/wechat.go @@ -82,11 +82,9 @@ func WeChatAuth(c *gin.Context) { }) return } - if user.Id == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "用户已注销", - }) + if err := user.RestoreIfDeleted("wechat", c.ClientIP()); err != nil { + common.SysError(fmt.Sprintf("failed to restore user %d: %v", user.Id, err)) + common.ApiError(c, err) return } } else { diff --git a/dev.ps1 b/dev.ps1 new file mode 100644 index 00000000..5153dbed --- /dev/null +++ b/dev.ps1 @@ -0,0 +1,132 @@ +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSCommandPath +$backendPort = 3000 +$frontendPort = 5173 + +function Test-TcpPort { + param( + [Parameter(Mandatory = $true)] + [int]$Port + ) + + $client = [System.Net.Sockets.TcpClient]::new() + try { + $task = $client.ConnectAsync("127.0.0.1", $Port) + if (-not $task.Wait(500)) { + return $false + } + return $client.Connected + } + catch { + return $false + } + finally { + $client.Dispose() + } +} + +function Assert-PortFree { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [int]$Port + ) + + if (Test-TcpPort -Port $Port) { + Write-Host "[error] $Name 端口 $Port 已被占用,请先停止占用该端口的进程" -ForegroundColor Red + exit 1 + } +} + +function Wait-Port { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [int]$Port, + [Parameter(Mandatory = $true)] + [System.Diagnostics.Process]$Process, + [int]$TimeoutSeconds = 90 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if ($Process.HasExited) { + Write-Host "[error] $Name 进程已退出,退出码 $($Process.ExitCode),请查看对应窗口日志" -ForegroundColor Red + exit 1 + } + if (Test-TcpPort -Port $Port) { + Write-Host "[$Name] 已就绪 (port $Port)" -ForegroundColor Green + return + } + Start-Sleep -Milliseconds 500 + } + + Write-Host "[error] $Name 未在 $TimeoutSeconds 秒内监听端口 $Port,请查看对应窗口日志" -ForegroundColor Red + exit 1 +} + +# 0. 初始化 PATH +$env:Path = "C:\Program Files\Go\bin;C:\Users\Chaos\.bun\bin;$env:Path" + +# 1. 检查 .env +if (-not (Test-Path "$root\.env")) { + Write-Host "[setup] 复制 .env.example -> .env" -ForegroundColor Yellow + Copy-Item "$root\.env.example" "$root\.env" +} + +# 2. 检查前端依赖 +if (-not (Test-Path "$root\web\default\node_modules")) { + Write-Host "[setup] 安装前端依赖..." -ForegroundColor Yellow + Set-Location "$root\web\default" + bun install + Set-Location $root + if ($LASTEXITCODE -ne 0) { + Write-Host "[error] bun install 失败" -ForegroundColor Red + exit 1 + } +} + + +# 3. 创建 go:embed 所需目录 +$embedDirs = @("web\default\dist", "web\classic\dist", "web\image-gen\dist") +foreach ($dir in $embedDirs) { + $fullPath = Join-Path $root $dir + if (-not (Test-Path $fullPath)) { + New-Item -ItemType Directory -Force -Path $fullPath | Out-Null + } + $indexFile = Join-Path $fullPath "index.html" + if (-not (Test-Path $indexFile)) { + Set-Content -Path $indexFile -Value "" + } +} + +# 4. 初始化 PATH +$goPath = "C:\Program Files\Go\bin" +$bunPath = "C:\Users\Chaos\.bun\bin" +$initPath = "`$env:Path = '$goPath;$bunPath;' + `$env:Path;" + +# 5. 检查端口占用 +Assert-PortFree -Name "Backend" -Port $backendPort +Assert-PortFree -Name "Frontend" -Port $frontendPort + +# 6. 启动后端 +Write-Host "[backend] 启动 API 服务 (port $backendPort)..." -ForegroundColor Green +$backendJob = Start-Process -FilePath "powershell" -ArgumentList "-NoExit", "-Command", "$initPath Set-Location '$root'; Write-Host '=== Backend :$backendPort ===' -ForegroundColor Cyan; go run main.go" -PassThru + +# 7. 启动前端 +Write-Host "[frontend] 启动前端开发服务 (port $frontendPort)..." -ForegroundColor Green +$frontendJob = Start-Process -FilePath "powershell" -ArgumentList "-NoExit", "-Command", "$initPath Set-Location '$root\web\default'; Write-Host '=== Frontend :$frontendPort ===' -ForegroundColor Magenta; bun run dev" -PassThru + +# 8. 等待服务就绪 +Wait-Port -Name "Backend" -Port $backendPort -Process $backendJob -TimeoutSeconds 90 +Wait-Port -Name "Frontend" -Port $frontendPort -Process $frontendJob -TimeoutSeconds 60 + +Write-Host "" +Write-Host "==============================" -ForegroundColor White +Write-Host " Backend : http://localhost:$backendPort" -ForegroundColor Cyan +Write-Host " Frontend : http://localhost:$frontendPort" -ForegroundColor Magenta +Write-Host "==============================" -ForegroundColor White +Write-Host "" +Write-Host "关闭窗口即可停止服务" -ForegroundColor Gray diff --git a/model/log.go b/model/log.go index 95bde1d2..d8cb7131 100644 --- a/model/log.go +++ b/model/log.go @@ -88,6 +88,33 @@ func GetLogByTokenId(tokenId int) (logs []*Log, err error) { return logs, err } +// RecordUserRestoreLog writes an audit-log entry whenever a soft-deleted user +// is automatically restored (e.g. by logging in again via password or OAuth). +// `source` describes the trigger, e.g. "github", "linuxdo", "telegram", "password". +// `callerIp` may be empty when the call originates from the model layer. +func RecordUserRestoreLog(userId int, source string, callerIp string) { + username, _ := GetUsernameById(userId, false) + other := map[string]interface{}{} + if source != "" { + other["restore_source"] = source + } + if callerIp != "" { + other["caller_ip"] = callerIp + } + log := &Log{ + UserId: userId, + Username: username, + CreatedAt: common.GetTimestamp(), + Type: LogTypeSystem, + Content: fmt.Sprintf("软删除用户被自动恢复,来源 %s", source), + Ip: callerIp, + Other: common.MapToJsonStr(other), + } + if err := LOG_DB.Create(log).Error; err != nil { + common.SysLog("failed to record user restore log: " + err.Error()) + } +} + func RecordLog(userId int, logType int, content string) { if logType == LogTypeConsume && !common.LogConsumeEnabled { return diff --git a/model/user.go b/model/user.go index e40ac3d2..397081bc 100644 --- a/model/user.go +++ b/model/user.go @@ -589,18 +589,40 @@ func (user *User) HardDelete() error { return err } +func (user *User) Restore() error { + if user.Id == 0 { + return errors.New("id 为空!") + } + err := DB.Unscoped().Model(user).Update("deleted_at", nil).Error + if err != nil { + return err + } + if err := invalidateUserCache(user.Id); err != nil { + return err + } + user.DeletedAt = gorm.DeletedAt{} + return nil +} + +func (user *User) RestoreIfDeleted(source string, callerIp string) error { + if !user.DeletedAt.Valid { + return nil + } + if err := user.Restore(); err != nil { + return err + } + RecordUserRestoreLog(user.Id, source, callerIp) + return nil +} + // ValidateAndFill check password & user status -func (user *User) ValidateAndFill() (err error) { - // When querying with struct, GORM will only query with non-zero fields, - // that means if your field's value is 0, '', false or other zero values, - // it won't be used to build query conditions +func (user *User) ValidateAndFill(callerIp string) (err error) { password := user.Password username := strings.TrimSpace(user.Username) if username == "" || password == "" { return ErrUserEmptyCredentials } - // find by username or email - err = DB.Where("username = ? OR email = ?", username, username).First(user).Error + err = DB.Unscoped().Where("username = ? OR email = ?", username, username).First(user).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrInvalidCredentials @@ -611,6 +633,9 @@ func (user *User) ValidateAndFill() (err error) { if !okay || user.Status != common.UserStatusEnabled { return ErrInvalidCredentials } + if err := user.RestoreIfDeleted("password", callerIp); err != nil { + return fmt.Errorf("%w: %v", ErrDatabase, err) + } return nil } @@ -634,7 +659,7 @@ func (user *User) FillUserByGitHubId() error { if user.GitHubId == "" { return errors.New("GitHub id 为空!") } - DB.Where(User{GitHubId: user.GitHubId}).First(user) + DB.Unscoped().Where(User{GitHubId: user.GitHubId}).First(user) return nil } @@ -650,7 +675,7 @@ func (user *User) FillUserByDiscordId() error { if user.DiscordId == "" { return errors.New("discord id 为空!") } - DB.Where(User{DiscordId: user.DiscordId}).First(user) + DB.Unscoped().Where(User{DiscordId: user.DiscordId}).First(user) return nil } @@ -658,7 +683,7 @@ func (user *User) FillUserByOidcId() error { if user.OidcId == "" { return errors.New("oidc id 为空!") } - DB.Where(User{OidcId: user.OidcId}).First(user) + DB.Unscoped().Where(User{OidcId: user.OidcId}).First(user) return nil } @@ -666,7 +691,7 @@ func (user *User) FillUserByWeChatId() error { if user.WeChatId == "" { return errors.New("WeChat id 为空!") } - DB.Where(User{WeChatId: user.WeChatId}).First(user) + DB.Unscoped().Where(User{WeChatId: user.WeChatId}).First(user) return nil } @@ -674,7 +699,7 @@ func (user *User) FillUserByTelegramId() error { if user.TelegramId == "" { return errors.New("Telegram id 为空!") } - err := DB.Where(User{TelegramId: user.TelegramId}).First(user).Error + err := DB.Unscoped().Where(User{TelegramId: user.TelegramId}).First(user).Error if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("该 Telegram 账户未绑定") } @@ -698,7 +723,7 @@ func IsDiscordIdAlreadyTaken(discordId string) bool { } func IsOidcIdAlreadyTaken(oidcId string) bool { - return DB.Where("oidc_id = ?", oidcId).Find(&User{}).RowsAffected == 1 + return DB.Unscoped().Where("oidc_id = ?", oidcId).Find(&User{}).RowsAffected == 1 } func IsTelegramIdAlreadyTaken(telegramId string) bool { @@ -1057,7 +1082,7 @@ func (user *User) FillUserByLinuxDOId() error { if user.LinuxDOId == "" { return errors.New("linux do id is empty") } - err := DB.Where("linux_do_id = ?", user.LinuxDOId).First(user).Error + err := DB.Unscoped().Where("linux_do_id = ?", user.LinuxDOId).First(user).Error return err } diff --git a/model/user_oauth_binding.go b/model/user_oauth_binding.go index 49216625..70ed9300 100644 --- a/model/user_oauth_binding.go +++ b/model/user_oauth_binding.go @@ -10,9 +10,9 @@ import ( // UserOAuthBinding stores the binding relationship between users and custom OAuth providers type UserOAuthBinding struct { Id int `json:"id" gorm:"primaryKey"` - UserId int `json:"user_id" gorm:"not null;uniqueIndex:ux_user_provider"` // User ID - one binding per user per provider - ProviderId int `json:"provider_id" gorm:"not null;uniqueIndex:ux_user_provider;uniqueIndex:ux_provider_userid"` // Custom OAuth provider ID - ProviderUserId string `json:"provider_user_id" gorm:"type:varchar(256);not null;uniqueIndex:ux_provider_userid"` // User ID from OAuth provider - one OAuth account per provider + UserId int `json:"user_id" gorm:"not null;uniqueIndex:ux_user_provider"` // User ID - one binding per user per provider + ProviderId int `json:"provider_id" gorm:"not null;uniqueIndex:ux_user_provider;uniqueIndex:ux_provider_userid"` // Custom OAuth provider ID + ProviderUserId string `json:"provider_user_id" gorm:"type:varchar(256);not null;uniqueIndex:ux_provider_userid"` // User ID from OAuth provider - one OAuth account per provider CreatedAt time.Time `json:"created_at"` } @@ -46,7 +46,7 @@ func GetUserByOAuthBinding(providerId int, providerUserId string) (*User, error) } var user User - err = DB.First(&user, binding.UserId).Error + err = DB.Unscoped().First(&user, binding.UserId).Error if err != nil { return nil, err } diff --git a/web/default/rsbuild.config.ts b/web/default/rsbuild.config.ts index be1d9774..72746a96 100644 --- a/web/default/rsbuild.config.ts +++ b/web/default/rsbuild.config.ts @@ -65,6 +65,7 @@ export default defineConfig(({ envMode }) => { }, server: { host: '0.0.0.0', + port: 5173, strictPort: true, proxy: devProxy, },