Files
verify/backend/internal/router/app/user.go
T
admin 008e66f84e fix: require instance_id when device_id is provided on logout
- Only device_id without instance_id returns error
- Three valid combinations:
  - device_id + instance_id: clear specific instance
  - only instance_id: clear instance across devices
  - no params: clear all instances

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:20:33 +08:00

263 lines
6.5 KiB
Go

package app
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"verification-platform-backend/internal/database"
"verification-platform-backend/internal/model"
"verification-platform-backend/internal/service"
"verification-platform-backend/pkg/response"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
func SetupUserRoutes(r *gin.RouterGroup) {
r.POST("/logout", handleAppLogout)
r.GET("/user-info", handleAppGetUserInfo)
r.POST("/avatar", handleAppUploadAvatar)
}
func handleAppLogout(c *gin.Context) {
appKey := c.Param("appKey")
var app model.Application
if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil {
response.Error(c, 404, "应用不存在")
return
}
if service.GetApplicationDisabledStatus(app.ID) {
response.Error(c, 403, "该应用已被禁用")
return
}
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未授权")
return
}
var req struct {
DeviceID string `json:"device_id"`
InstanceID string `json:"instance_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误")
return
}
// 只有 device_id 没有 instance_id 时提示错误
if req.DeviceID != "" && req.InstanceID == "" {
response.Error(c, 400, "缺少 instance_id 参数")
return
}
// 获取用户在该应用下的所有设备
var devices []model.UserDevice
if err := database.DB.Where("user_id = ? AND application_id = ?", userID, app.ID).Find(&devices).Error; err != nil {
response.Error(c, 500, "获取设备失败")
return
}
if len(devices) == 0 {
response.Success(c, gin.H{"message": "登出成功"})
return
}
deviceIDs := make([]uint, len(devices))
for i, d := range devices {
deviceIDs[i] = d.ID
}
var deletedCount int64
if req.InstanceID != "" && req.DeviceID != "" {
// 清除指定设备的指定实例
var targetDeviceID uint
for _, d := range devices {
if d.DeviceID == req.DeviceID {
targetDeviceID = d.ID
break
}
}
if targetDeviceID == 0 {
response.Error(c, 404, "设备不存在")
return
}
result := database.DB.Where("device_id = ? AND instance_id = ?", targetDeviceID, req.InstanceID).Delete(&model.DeviceSession{})
deletedCount = result.RowsAffected
log.Printf("[INFO] Logout: deleted session for device %s, instance %s, count %d", req.DeviceID, req.InstanceID, deletedCount)
} else if req.InstanceID != "" {
// 清除指定实例(跨设备)
result := database.DB.Where("device_id IN ? AND instance_id = ?", deviceIDs, req.InstanceID).Delete(&model.DeviceSession{})
deletedCount = result.RowsAffected
log.Printf("[INFO] Logout: deleted instance %s across all devices, count %d", req.InstanceID, deletedCount)
} else {
// 清除该用户所有实例
result := database.DB.Where("device_id IN ?", deviceIDs).Delete(&model.DeviceSession{})
deletedCount = result.RowsAffected
log.Printf("[INFO] Logout: deleted all sessions for user %d, count %d", userID, deletedCount)
}
service.LogVerification(c, &app.ID, uintPtr(userID.(uint)), "logout", fmt.Sprintf("用户登出,清除 %d 个实例", deletedCount), req.DeviceID, nil)
response.Success(c, gin.H{
"message": "登出成功",
"deleted_count": deletedCount,
})
}
func handleAppGetUserInfo(c *gin.Context) {
appKey := c.Param("appKey")
var app model.Application
if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil {
response.Error(c, 404, "应用不存在")
return
}
if service.GetApplicationDisabledStatus(app.ID) {
response.Error(c, 403, "该应用已被禁用")
return
}
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未授权")
return
}
var user model.AppUser
if err := database.DB.First(&user, userID).Error; err != nil {
response.Error(c, 404, "用户不存在")
return
}
result := gin.H{
"user_id": user.ID,
"username": user.Username,
"email": user.Email,
"avatar": user.Avatar,
"status": user.Status,
}
if user.Balance == -1 {
result["is_permanent"] = true
} else if app.BillingType == "subscription" {
if user.ExpiryAt != nil {
result["expiry_at"] = user.ExpiryAt.Format("2006-01-02 15:04:05")
result["expiry_timestamp"] = user.ExpiryAt.Unix()
}
} else if app.BillingType != "free" {
result["balance"] = user.Balance
}
response.Success(c, result)
}
func handleAppUploadAvatar(c *gin.Context) {
appKey := c.Param("appKey")
var app model.Application
if err := database.DB.Where("app_key = ?", appKey).First(&app).Error; err != nil {
response.Error(c, 404, "应用不存在")
return
}
if service.GetApplicationDisabledStatus(app.ID) {
response.Error(c, 403, "该应用已被禁用")
return
}
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未授权")
return
}
file, header, err := c.Request.FormFile("avatar")
if err != nil {
response.Error(c, 400, "请上传头像文件")
return
}
defer file.Close()
if !isValidImageFile(header.Filename) {
response.Error(c, 400, "只支持JPG、PNG、GIF格式的图片")
return
}
const maxSize = 2 << 20
if header.Size > maxSize {
response.Error(c, 400, "头像文件大小不能超过2MB")
return
}
avatarDir := filepath.Join("uploads", "avatars")
if err := os.MkdirAll(avatarDir, 0755); err != nil {
response.Error(c, 500, "创建目录失败")
return
}
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("avatar_%d_%s%s", userID.(uint), uuid.New().String()[:8], ext)
dst := filepath.Join(avatarDir, filename)
dstFile, err := os.Create(dst)
if err != nil {
response.Error(c, 500, "创建文件失败")
return
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, file); err != nil {
response.Error(c, 500, "保存文件失败")
return
}
avatarURL := fmt.Sprintf("/uploads/avatars/%s", filename)
var user model.AppUser
if err := database.DB.First(&user, userID).Error; err != nil {
response.Error(c, 404, "用户不存在")
return
}
oldAvatar := user.Avatar
user.Avatar = avatarURL
if err := database.DB.Save(&user).Error; err != nil {
response.Error(c, 500, "保存头像失败")
return
}
if oldAvatar != "" && strings.HasPrefix(oldAvatar, "/uploads/avatars/") {
oldPath := filepath.Join(".", oldAvatar)
os.Remove(oldPath)
}
response.Success(c, gin.H{
"avatar_url": avatarURL,
})
}
func isValidImageFile(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
validExts := map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
}
return validExts[ext]
}
func uintPtr(v uint) *uint {
return &v
}