fix: restore soft-deleted users on login
This commit is contained in:
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+7
-3
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+38
-13
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -65,6 +65,7 @@ export default defineConfig(({ envMode }) => {
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: devProxy,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user