fix: restore soft-deleted users on login

This commit is contained in:
2026-06-15 07:36:13 +08:00
parent 04d30f9dd1
commit 346cf0e4a6
13 changed files with 234 additions and 37 deletions
+5
View File
@@ -139,6 +139,11 @@ func DiscordOAuth(c *gin.Context) {
}) })
return 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 { } else {
if common.RegisterEnabled { if common.RegisterEnabled {
if discordUser.ID != "" { if discordUser.ID != "" {
+3 -6
View File
@@ -122,12 +122,9 @@ func GitHubOAuth(c *gin.Context) {
}) })
return return
} }
// if user.Id == 0 , user has been deleted if err := user.RestoreIfDeleted("github", c.ClientIP()); err != nil {
if user.Id == 0 { common.SysError(fmt.Sprintf("failed to restore user %d: %v", user.Id, err))
c.JSON(http.StatusOK, gin.H{ common.ApiError(c, err)
"success": false,
"message": "用户已注销",
})
return return
} }
} else { } else {
+3 -5
View File
@@ -212,11 +212,9 @@ func LinuxdoOAuth(c *gin.Context) {
}) })
return return
} }
if user.Id == 0 { if err := user.RestoreIfDeleted("linuxdo", c.ClientIP()); err != nil {
c.JSON(http.StatusOK, gin.H{ common.SysError(fmt.Sprintf("failed to restore user %d: %v", user.Id, err))
"success": false, common.ApiError(c, err)
"message": "用户已注销",
})
return return
} }
} else { } else {
+7 -3
View File
@@ -213,9 +213,9 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Check if user has been deleted if err := user.RestoreIfDeleted(provider.GetName(), c.ClientIP()); err != nil {
if user.Id == 0 { common.SysError(fmt.Sprintf("[OAuth] Failed to restore user %d: %s", user.Id, err.Error()))
return nil, &OAuthUserDeletedError{} return nil, err
} }
return user, nil return user, nil
} }
@@ -227,6 +227,10 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
if err != nil { if err != nil {
return nil, err 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 { if user.Id != 0 {
// Found user with legacy ID, migrate to new ID // 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", common.SysLog(fmt.Sprintf("[OAuth] Migrating user %d from legacy_id=%s to new_id=%s",
+5
View File
@@ -141,6 +141,11 @@ func OidcAuth(c *gin.Context) {
}) })
return 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 { } else {
if common.RegisterEnabled { if common.RegisterEnabled {
user.Email = oidcUser.Email user.Email = oidcUser.Email
+5
View File
@@ -95,6 +95,11 @@ func TelegramLogin(c *gin.Context) {
}) })
return 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) setupLogin(&user, c)
} }
+1 -1
View File
@@ -51,7 +51,7 @@ func Login(c *gin.Context) {
Username: username, Username: username,
Password: password, Password: password,
} }
err = user.ValidateAndFill() err = user.ValidateAndFill(c.ClientIP())
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, model.ErrDatabase): case errors.Is(err, model.ErrDatabase):
+3 -5
View File
@@ -82,11 +82,9 @@ func WeChatAuth(c *gin.Context) {
}) })
return return
} }
if user.Id == 0 { if err := user.RestoreIfDeleted("wechat", c.ClientIP()); err != nil {
c.JSON(http.StatusOK, gin.H{ common.SysError(fmt.Sprintf("failed to restore user %d: %v", user.Id, err))
"success": false, common.ApiError(c, err)
"message": "用户已注销",
})
return return
} }
} else { } else {
+132
View File
@@ -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 "<!DOCTYPE html><html></html>"
}
}
# 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
+27
View File
@@ -88,6 +88,33 @@ func GetLogByTokenId(tokenId int) (logs []*Log, err error) {
return logs, err 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) { func RecordLog(userId int, logType int, content string) {
if logType == LogTypeConsume && !common.LogConsumeEnabled { if logType == LogTypeConsume && !common.LogConsumeEnabled {
return return
+38 -13
View File
@@ -589,18 +589,40 @@ func (user *User) HardDelete() error {
return err 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 // ValidateAndFill check password & user status
func (user *User) ValidateAndFill() (err error) { func (user *User) ValidateAndFill(callerIp string) (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
password := user.Password password := user.Password
username := strings.TrimSpace(user.Username) username := strings.TrimSpace(user.Username)
if username == "" || password == "" { if username == "" || password == "" {
return ErrUserEmptyCredentials return ErrUserEmptyCredentials
} }
// find by username or email err = DB.Unscoped().Where("username = ? OR email = ?", username, username).First(user).Error
err = DB.Where("username = ? OR email = ?", username, username).First(user).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrInvalidCredentials return ErrInvalidCredentials
@@ -611,6 +633,9 @@ func (user *User) ValidateAndFill() (err error) {
if !okay || user.Status != common.UserStatusEnabled { if !okay || user.Status != common.UserStatusEnabled {
return ErrInvalidCredentials return ErrInvalidCredentials
} }
if err := user.RestoreIfDeleted("password", callerIp); err != nil {
return fmt.Errorf("%w: %v", ErrDatabase, err)
}
return nil return nil
} }
@@ -634,7 +659,7 @@ func (user *User) FillUserByGitHubId() error {
if user.GitHubId == "" { if user.GitHubId == "" {
return errors.New("GitHub id 为空!") return errors.New("GitHub id 为空!")
} }
DB.Where(User{GitHubId: user.GitHubId}).First(user) DB.Unscoped().Where(User{GitHubId: user.GitHubId}).First(user)
return nil return nil
} }
@@ -650,7 +675,7 @@ func (user *User) FillUserByDiscordId() error {
if user.DiscordId == "" { if user.DiscordId == "" {
return errors.New("discord id 为空!") return errors.New("discord id 为空!")
} }
DB.Where(User{DiscordId: user.DiscordId}).First(user) DB.Unscoped().Where(User{DiscordId: user.DiscordId}).First(user)
return nil return nil
} }
@@ -658,7 +683,7 @@ func (user *User) FillUserByOidcId() error {
if user.OidcId == "" { if user.OidcId == "" {
return errors.New("oidc id 为空!") return errors.New("oidc id 为空!")
} }
DB.Where(User{OidcId: user.OidcId}).First(user) DB.Unscoped().Where(User{OidcId: user.OidcId}).First(user)
return nil return nil
} }
@@ -666,7 +691,7 @@ func (user *User) FillUserByWeChatId() error {
if user.WeChatId == "" { if user.WeChatId == "" {
return errors.New("WeChat id 为空!") return errors.New("WeChat id 为空!")
} }
DB.Where(User{WeChatId: user.WeChatId}).First(user) DB.Unscoped().Where(User{WeChatId: user.WeChatId}).First(user)
return nil return nil
} }
@@ -674,7 +699,7 @@ func (user *User) FillUserByTelegramId() error {
if user.TelegramId == "" { if user.TelegramId == "" {
return errors.New("Telegram id 为空!") 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("该 Telegram 账户未绑定") return errors.New("该 Telegram 账户未绑定")
} }
@@ -698,7 +723,7 @@ func IsDiscordIdAlreadyTaken(discordId string) bool {
} }
func IsOidcIdAlreadyTaken(oidcId 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 { func IsTelegramIdAlreadyTaken(telegramId string) bool {
@@ -1057,7 +1082,7 @@ func (user *User) FillUserByLinuxDOId() error {
if user.LinuxDOId == "" { if user.LinuxDOId == "" {
return errors.New("linux do id is empty") 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 return err
} }
+1 -1
View File
@@ -46,7 +46,7 @@ func GetUserByOAuthBinding(providerId int, providerUserId string) (*User, error)
} }
var user User var user User
err = DB.First(&user, binding.UserId).Error err = DB.Unscoped().First(&user, binding.UserId).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
+1
View File
@@ -65,6 +65,7 @@ export default defineConfig(({ envMode }) => {
}, },
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173,
strictPort: true, strictPort: true,
proxy: devProxy, proxy: devProxy,
}, },