diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 20d1105..3d98fb8 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -427,6 +427,7 @@ type Ticket struct { Status string `gorm:"size:20;default:open" json:"status"` // open, processing, resolved, closed Priority string `gorm:"size:20;default:normal" json:"priority"` // low, normal, high, urgent AssignedTo *uint `json:"assigned_to"` // 分配给的应用开发者ID或平台管理员ID + ClosedAt *time.Time `json:"closed_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` diff --git a/backend/internal/router/app/app.go b/backend/internal/router/app/app.go index 58ad70e..9774743 100644 --- a/backend/internal/router/app/app.go +++ b/backend/internal/router/app/app.go @@ -8,6 +8,8 @@ func SetupRoutes(r *gin.RouterGroup) { SetupPaymentRoutes(r) SetupAccountRoutes(r) SetupDynamicRoutes(r) + SetupUserRoutes(r) + SetupTicketRoutes(r) } func SetupAuthRoutes(r *gin.RouterGroup) { diff --git a/backend/internal/router/app/ticket.go b/backend/internal/router/app/ticket.go new file mode 100644 index 0000000..c3380b2 --- /dev/null +++ b/backend/internal/router/app/ticket.go @@ -0,0 +1,282 @@ +package app + +import ( + "fmt" + "log" + "strconv" + "time" + + "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" +) + +func SetupTicketRoutes(r *gin.RouterGroup) { + r.GET("/tickets", handleAppGetTickets) + r.GET("/tickets/:id", handleAppGetTicket) + r.POST("/tickets", handleAppCreateTicket) + r.POST("/tickets/:id/reply", handleAppReplyTicket) + r.PUT("/tickets/:id/close", handleAppCloseTicket) +} + +func handleAppGetTickets(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 + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + statusFilter := c.Query("status") + + query := database.DB.Model(&model.Ticket{}).Where("user_id = ? AND application_id = ?", userID, app.ID) + + if statusFilter != "" { + query = query.Where("status = ?", statusFilter) + } + + var total int64 + query.Count(&total) + + offset := (page - 1) * pageSize + + var tickets []model.Ticket + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&tickets).Error; err != nil { + response.Error(c, 500, "获取工单列表失败") + return + } + + response.Success(c, gin.H{ + "tickets": tickets, + "total": total, + "page": page, + "page_size": pageSize, + "total_page": (total + int64(pageSize) - 1) / int64(pageSize), + }) +} + +func handleAppGetTicket(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 + } + + ticketID := c.Param("id") + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ? AND application_id = ?", ticketID, userID, app.ID). + Preload("Replies"). + First(&ticket).Error; err != nil { + response.Error(c, 404, "工单不存在") + return + } + + response.Success(c, gin.H{ + "ticket": ticket, + }) +} + +func handleAppCreateTicket(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 { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Type string `json:"type"` + Priority string `json:"priority"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + if req.Type == "" { + req.Type = "other" + } + if req.Priority == "" { + req.Priority = "normal" + } + + ticket := model.Ticket{ + UserID: userID.(uint), + ApplicationID: &app.ID, + Title: req.Title, + Content: req.Content, + Type: req.Type, + Category: "other", + Priority: req.Priority, + Status: "open", + AssignedTo: &app.UserID, + } + + if err := database.DB.Create(&ticket).Error; err != nil { + log.Printf("[ERROR] Failed to create ticket: %v", err) + response.Error(c, 500, "创建工单失败") + return + } + + service.LogOperation(c, "create", "ticket", &ticket.ID, fmt.Sprintf("用户创建工单: %s", ticket.Title), nil) + + response.Success(c, gin.H{ + "id": ticket.ID, + "message": "工单创建成功", + }) +} + +func handleAppReplyTicket(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 + } + + ticketID := c.Param("id") + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ? AND application_id = ?", ticketID, userID, app.ID).First(&ticket).Error; err != nil { + response.Error(c, 404, "工单不存在") + return + } + + if ticket.Status == "closed" { + response.Error(c, 400, "工单已关闭,无法回复") + return + } + + var req struct { + Content string `json:"content" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, "参数错误") + return + } + + reply := model.TicketReply{ + TicketID: parseUint(ticketID), + UserID: userID.(uint), + Content: req.Content, + } + + if err := database.DB.Create(&reply).Error; err != nil { + response.Error(c, 500, "回复失败") + return + } + + if ticket.Status == "resolved" || ticket.Status == "processing" { + ticket.Status = "processing" + database.DB.Save(&ticket) + } + + response.Success(c, gin.H{ + "id": reply.ID, + "message": "回复成功", + }) +} + +func handleAppCloseTicket(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 + } + + ticketID := c.Param("id") + var ticket model.Ticket + if err := database.DB.Where("id = ? AND user_id = ? AND application_id = ?", ticketID, userID, app.ID).First(&ticket).Error; err != nil { + response.Error(c, 404, "工单不存在") + return + } + + if ticket.Status == "closed" { + response.Error(c, 400, "工单已关闭") + return + } + + now := time.Now() + ticket.Status = "closed" + ticket.ClosedAt = &now + + if err := database.DB.Save(&ticket).Error; err != nil { + response.Error(c, 500, "关闭工单失败") + return + } + + service.LogOperation(c, "close", "ticket", &ticket.ID, fmt.Sprintf("用户关闭工单: %s", ticket.Title), nil) + + response.Success(c, gin.H{ + "message": "工单已关闭", + }) +} + +func parseUint(s string) uint { + var val uint + fmt.Sscanf(s, "%d", &val) + return val +} diff --git a/backend/internal/router/app/user.go b/backend/internal/router/app/user.go new file mode 100644 index 0000000..509973e --- /dev/null +++ b/backend/internal/router/app/user.go @@ -0,0 +1,219 @@ +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 + } + + if req.DeviceID != "" { + var device model.UserDevice + if err := database.DB.Where("user_id = ? AND application_id = ? AND device_id = ?", userID, app.ID, req.DeviceID).First(&device).Error; err == nil { + if req.InstanceID != "" { + database.DB.Where("device_id = ? AND instance_id = ?", device.ID, req.InstanceID).Delete(&model.DeviceSession{}) + log.Printf("[INFO] Logout: deleted session for device %s, instance %s", req.DeviceID, req.InstanceID) + } else { + database.DB.Where("device_id = ?", device.ID).Delete(&model.DeviceSession{}) + log.Printf("[INFO] Logout: deleted all sessions for device %s", req.DeviceID) + } + } + } + + service.LogVerification(c, &app.ID, uintPtr(userID.(uint)), "logout", "用户登出", req.DeviceID, nil) + + response.Success(c, gin.H{ + "message": "登出成功", + }) +} + +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 +}