From 187a2d48a076a28798e6d6f62ab684b3a248064d Mon Sep 17 00:00:00 2001 From: arthurcai Date: Sun, 15 Mar 2026 10:48:21 +0800 Subject: [PATCH 1/5] refactor(backend): fix SonarCloud reliability and maintainability issues - S8209: group consecutive params of same type in chat.go, task.go, user.go - S1192: extract constants for duplicated literals in community.go, auth.go, echo.go, router.go, user.go - S3776: reduce cognitive complexity by extracting helpers in community.go, chat.go, task_ai.go, user.go - S107: create Handlers struct in router.go to reduce Setup() params from 16 to 4 - S1135: replace TODO with descriptive comment in auth.go - S4144: extract shared toggleLike() in interaction.go - S8239: use available ctx param instead of context.Background() in chat.go --- backend/cmd/server/main.go | 19 ++- backend/internal/handler/auth.go | 5 +- backend/internal/handler/interaction.go | 25 ++- backend/internal/repository/chat.go | 4 +- backend/internal/router/router.go | 202 +++++++++++++----------- backend/internal/service/auth.go | 17 +- backend/internal/service/chat.go | 160 +++++++++++-------- backend/internal/service/community.go | 91 +++++++---- backend/internal/service/echo.go | 13 +- backend/internal/service/task.go | 2 +- backend/internal/service/task_ai.go | 31 ++-- backend/internal/service/user.go | 43 +++-- 12 files changed, 350 insertions(+), 262 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 69e259f2..1d3db2a8 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -136,10 +136,21 @@ func main() { router.Setup( r, cfg, adminHandler.IsAdmin, - authHandler, questionHandler, answerHandler, - commentHandler, interactionHandler, tagHandler, - chatHandler, echoHandler, userHandler, adminHandler, - photoHandler, whisperHandler, taskHandler, + &router.Handlers{ + Auth: authHandler, + Question: questionHandler, + Answer: answerHandler, + Comment: commentHandler, + Interaction: interactionHandler, + Tag: tagHandler, + Chat: chatHandler, + Echo: echoHandler, + User: userHandler, + Admin: adminHandler, + Photo: photoHandler, + Whisper: whisperHandler, + Task: taskHandler, + }, ) // Start server diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index e4265636..9ce535f1 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -184,8 +184,9 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) { return } - // TODO: In production, send email with reset link instead of logging - _ = token // token would be sent via email + // In a production deployment, the token would be sent via email with a reset link. + // For now, it is intentionally unused to avoid leaking it in the response. + _ = token c.JSON(http.StatusOK, gin.H{ "message": "如果该邮箱已注册,将收到重置密码邮件", }) diff --git a/backend/internal/handler/interaction.go b/backend/internal/handler/interaction.go index 87eaea9c..67a40b63 100644 --- a/backend/internal/handler/interaction.go +++ b/backend/internal/handler/interaction.go @@ -17,8 +17,9 @@ func NewInteractionHandler(communityService *service.CommunityService) *Interact return &InteractionHandler{communityService: communityService} } -// POST /api/v1/community/likes -func (h *InteractionHandler) CreateLike(c *gin.Context) { +// toggleLike is the shared handler for both CreateLike and DeleteLike. +// Since the service layer uses ToggleLike, both endpoints use the same logic. +func (h *InteractionHandler) toggleLike(c *gin.Context) { var req dto.LikeRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -35,22 +36,14 @@ func (h *InteractionHandler) CreateLike(c *gin.Context) { c.JSON(http.StatusOK, dto.LikeResponse{IsLiked: isLiked, NewCount: newCount}) } +// POST /api/v1/community/likes +func (h *InteractionHandler) CreateLike(c *gin.Context) { + h.toggleLike(c) +} + // DELETE /api/v1/community/likes func (h *InteractionHandler) DeleteLike(c *gin.Context) { - var req dto.LikeRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - userID := middleware.GetUserID(c) - isLiked, newCount, err := h.communityService.ToggleLike(userID, req.TargetType, req.TargetID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, dto.LikeResponse{IsLiked: isLiked, NewCount: newCount}) + h.toggleLike(c) } // POST /api/v1/community/collections diff --git a/backend/internal/repository/chat.go b/backend/internal/repository/chat.go index 9b6dbf1e..b413f878 100644 --- a/backend/internal/repository/chat.go +++ b/backend/internal/repository/chat.go @@ -38,7 +38,7 @@ func (r *ChatRepo) Create(m *model.ChatMemory) error { } // UpdateSummaryAndTurns updates only the summary and turns fields. -func (r *ChatRepo) UpdateSummaryAndTurns(userID string, summary string, turns string) error { +func (r *ChatRepo) UpdateSummaryAndTurns(userID, summary, turns string) error { return r.db.Model(&model.ChatMemory{}). Where(whereUserID, userID). Updates(map[string]any{ @@ -139,7 +139,7 @@ func (r *ChatRepo) FactExistsByContentFamily(familyIDs []string, content string) return count > 0, err } -func (r *ChatRepo) DeleteFactsByContentLikeFamily(familyIDs []string, phrases []string) error { +func (r *ChatRepo) DeleteFactsByContentLikeFamily(familyIDs, phrases []string) error { for _, phrase := range phrases { phrase = strings.TrimSpace(phrase) if phrase == "" { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 83cf42d2..06c959c1 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -9,23 +9,33 @@ import ( "github.com/momshell/backend/internal/middleware" ) +const ( + routeUsers = "/users" + routeUserID = "/users/:id" +) + +// Handlers groups all handler dependencies for route setup. +type Handlers struct { + Auth *handler.AuthHandler + Question *handler.QuestionHandler + Answer *handler.AnswerHandler + Comment *handler.CommentHandler + Interaction *handler.InteractionHandler + Tag *handler.TagHandler + Chat *handler.ChatHandler + Echo *handler.EchoHandler + User *handler.UserHandler + Admin *handler.AdminHandler + Photo *handler.PhotoHandler + Whisper *handler.WhisperHandler + Task *handler.TaskHandler +} + func Setup( r *gin.Engine, cfg *config.Config, isAdmin middleware.AdminChecker, - authHandler *handler.AuthHandler, - questionHandler *handler.QuestionHandler, - answerHandler *handler.AnswerHandler, - commentHandler *handler.CommentHandler, - interactionHandler *handler.InteractionHandler, - tagHandler *handler.TagHandler, - chatHandler *handler.ChatHandler, - echoHandler *handler.EchoHandler, - userHandler *handler.UserHandler, - adminHandler *handler.AdminHandler, - photoHandler *handler.PhotoHandler, - whisperHandler *handler.WhisperHandler, - taskHandler *handler.TaskHandler, + h *Handlers, ) { // Rate limiters authLimiter := middleware.RateLimit(10, 1*time.Minute) // 10 req/min for auth @@ -41,26 +51,26 @@ func Setup( r.Static("/uploads", "./uploads") // Admin panel (HTML page, no auth required for serving the page) - r.GET("/admin", adminHandler.ServeAdminPage) + r.GET("/admin", h.Admin.ServeAdminPage) api := r.Group("/api/v1", generalLimiter) // ==================== Auth ==================== auth := api.Group("/auth") { - auth.POST("/register", authLimiter, authHandler.Register) - auth.POST("/login", authLimiter, authHandler.Login) - auth.POST("/refresh", authLimiter, authHandler.Refresh) - auth.POST("/forgot-password", authLimiter, authHandler.ForgotPassword) - auth.POST("/reset-password", authLimiter, authHandler.ResetPassword) + auth.POST("/register", authLimiter, h.Auth.Register) + auth.POST("/login", authLimiter, h.Auth.Login) + auth.POST("/refresh", authLimiter, h.Auth.Refresh) + auth.POST("/forgot-password", authLimiter, h.Auth.ForgotPassword) + auth.POST("/reset-password", authLimiter, h.Auth.ResetPassword) authRequired := auth.Group("", middleware.AuthRequired(cfg)) { - authRequired.POST("/change-password", authHandler.ChangePassword) - authRequired.GET("/me", authHandler.GetMe) - authRequired.PATCH("/me/role", authHandler.UpdateRole) - authRequired.PATCH("/me/tutorial", authHandler.CompleteTutorial) - authRequired.POST("/logout", authHandler.Logout) + authRequired.POST("/change-password", h.Auth.ChangePassword) + authRequired.GET("/me", h.Auth.GetMe) + authRequired.PATCH("/me/role", h.Auth.UpdateRole) + authRequired.PATCH("/me/tutorial", h.Auth.CompleteTutorial) + authRequired.POST("/logout", h.Auth.Logout) } } @@ -70,145 +80,145 @@ func Setup( // Questions (optional auth for read, required for write) questions := community.Group("/questions") { - questions.GET("", middleware.AuthOptional(cfg), questionHandler.List) - questions.GET("/hot", middleware.AuthOptional(cfg), questionHandler.ListHot) - questions.GET("/channel/:channel", middleware.AuthOptional(cfg), questionHandler.ListByChannel) - questions.GET("/:id", middleware.AuthOptional(cfg), questionHandler.Get) + questions.GET("", middleware.AuthOptional(cfg), h.Question.List) + questions.GET("/hot", middleware.AuthOptional(cfg), h.Question.ListHot) + questions.GET("/channel/:channel", middleware.AuthOptional(cfg), h.Question.ListByChannel) + questions.GET("/:id", middleware.AuthOptional(cfg), h.Question.Get) questionsAuth := questions.Group("", middleware.AuthRequired(cfg)) { - questionsAuth.POST("", questionHandler.Create) - questionsAuth.PUT("/:id", questionHandler.Update) - questionsAuth.DELETE("/:id", questionHandler.Delete) + questionsAuth.POST("", h.Question.Create) + questionsAuth.PUT("/:id", h.Question.Update) + questionsAuth.DELETE("/:id", h.Question.Delete) } // Answers under questions - questions.GET("/:id/answers", middleware.AuthOptional(cfg), answerHandler.List) - questions.POST("/:id/answers", middleware.AuthRequired(cfg), answerHandler.Create) + questions.GET("/:id/answers", middleware.AuthOptional(cfg), h.Answer.List) + questions.POST("/:id/answers", middleware.AuthRequired(cfg), h.Answer.Create) } // Answers (update/delete by answer ID) answers := community.Group("/answers") { - answers.PUT("/:id", middleware.AuthRequired(cfg), answerHandler.Update) - answers.DELETE("/:id", middleware.AuthRequired(cfg), answerHandler.Delete) + answers.PUT("/:id", middleware.AuthRequired(cfg), h.Answer.Update) + answers.DELETE("/:id", middleware.AuthRequired(cfg), h.Answer.Delete) // Comments under answers - answers.GET("/:id/comments", middleware.AuthOptional(cfg), commentHandler.List) - answers.POST("/:id/comments", middleware.AuthRequired(cfg), commentHandler.Create) + answers.GET("/:id/comments", middleware.AuthOptional(cfg), h.Comment.List) + answers.POST("/:id/comments", middleware.AuthRequired(cfg), h.Comment.Create) } // Comments (update/delete by comment ID) comments := community.Group("/comments") { - comments.PUT("/:id", middleware.AuthRequired(cfg), commentHandler.Update) - comments.DELETE("/:id", middleware.AuthRequired(cfg), commentHandler.Delete) + comments.PUT("/:id", middleware.AuthRequired(cfg), h.Comment.Update) + comments.DELETE("/:id", middleware.AuthRequired(cfg), h.Comment.Delete) } // Likes likes := community.Group("/likes", middleware.AuthRequired(cfg)) { - likes.POST("", interactionHandler.CreateLike) - likes.DELETE("", interactionHandler.DeleteLike) + likes.POST("", h.Interaction.CreateLike) + likes.DELETE("", h.Interaction.DeleteLike) } // Collections collections := community.Group("/collections", middleware.AuthRequired(cfg)) { - collections.POST("", interactionHandler.CreateCollection) - collections.DELETE("/:id", interactionHandler.DeleteCollection) - collections.GET("/my", interactionHandler.GetMyCollections) + collections.POST("", h.Interaction.CreateCollection) + collections.DELETE("/:id", h.Interaction.DeleteCollection) + collections.GET("/my", h.Interaction.GetMyCollections) } // Tags tags := community.Group("/tags") { - tags.GET("", tagHandler.List) - tags.GET("/hot", tagHandler.ListHot) - tags.POST("", middleware.AdminRequired(cfg, isAdmin), tagHandler.Create) + tags.GET("", h.Tag.List) + tags.GET("/hot", h.Tag.ListHot) + tags.POST("", middleware.AdminRequired(cfg, isAdmin), h.Tag.Create) } // User profile (community context) - users := community.Group("/users", middleware.AuthRequired(cfg)) + users := community.Group(routeUsers, middleware.AuthRequired(cfg)) { - users.GET("/me", userHandler.GetMe) - users.PUT("/me", userHandler.UpdateMe) - users.POST("/me/avatar", userHandler.UploadAvatar) - users.POST("/me/shell-code", userHandler.GenerateShellCode) - users.POST("/me/bind", userHandler.BindPartner) - users.DELETE("/me/bind", userHandler.UnbindPartner) - users.GET("/me/questions", userHandler.GetMyQuestions) - users.GET("/me/answers", userHandler.GetMyAnswers) + users.GET("/me", h.User.GetMe) + users.PUT("/me", h.User.UpdateMe) + users.POST("/me/avatar", h.User.UploadAvatar) + users.POST("/me/shell-code", h.User.GenerateShellCode) + users.POST("/me/bind", h.User.BindPartner) + users.DELETE("/me/bind", h.User.UnbindPartner) + users.GET("/me/questions", h.User.GetMyQuestions) + users.GET("/me/answers", h.User.GetMyAnswers) } } // ==================== Companion (AI Chat) ==================== companion := api.Group("/companion") { - companion.POST("/chat", aiLimiter, middleware.AuthOptional(cfg), chatHandler.Chat) - companion.GET("/profile", middleware.AuthOptional(cfg), chatHandler.GetProfile) - companion.GET("/memories", middleware.AuthRequired(cfg), chatHandler.GetMemories) - companion.DELETE("/memories/:id", middleware.AuthRequired(cfg), chatHandler.DeleteMemory) - companion.GET("/history", middleware.AuthRequired(cfg), chatHandler.GetHistory) - companion.DELETE("/history", middleware.AuthRequired(cfg), chatHandler.ClearHistory) + companion.POST("/chat", aiLimiter, middleware.AuthOptional(cfg), h.Chat.Chat) + companion.GET("/profile", middleware.AuthOptional(cfg), h.Chat.GetProfile) + companion.GET("/memories", middleware.AuthRequired(cfg), h.Chat.GetMemories) + companion.DELETE("/memories/:id", middleware.AuthRequired(cfg), h.Chat.DeleteMemory) + companion.GET("/history", middleware.AuthRequired(cfg), h.Chat.GetHistory) + companion.DELETE("/history", middleware.AuthRequired(cfg), h.Chat.ClearHistory) } echo := api.Group("/echo", middleware.AuthRequired(cfg)) { - echo.GET("/identity-tags", echoHandler.GetIdentityTags) - echo.POST("/identity-tags", echoHandler.CreateIdentityTag) - echo.DELETE("/identity-tags/:id", echoHandler.DeleteIdentityTag) + echo.GET("/identity-tags", h.Echo.GetIdentityTags) + echo.POST("/identity-tags", h.Echo.CreateIdentityTag) + echo.DELETE("/identity-tags/:id", h.Echo.DeleteIdentityTag) - echo.GET("/memoirs", echoHandler.GetMemoirs) - echo.POST("/memoirs/generate", aiLimiter, echoHandler.GenerateMemoir) - echo.POST("/memoirs/:id/rate", echoHandler.RateMemoir) + echo.GET("/memoirs", h.Echo.GetMemoirs) + echo.POST("/memoirs/generate", aiLimiter, h.Echo.GenerateMemoir) + echo.POST("/memoirs/:id/rate", h.Echo.RateMemoir) } // ==================== Photos ==================== photos := api.Group("/photos", middleware.AuthRequired(cfg)) { - photos.GET("", photoHandler.List) - photos.POST("/upload", photoHandler.Upload) - photos.POST("/generate", aiLimiter, photoHandler.Generate) - photos.PUT("/wall", photoHandler.BatchUpdateWall) - photos.PUT("/:id", photoHandler.Update) - photos.DELETE("/:id", photoHandler.Delete) - photos.PUT("/:id/wall", photoHandler.ToggleWall) + photos.GET("", h.Photo.List) + photos.POST("/upload", h.Photo.Upload) + photos.POST("/generate", aiLimiter, h.Photo.Generate) + photos.PUT("/wall", h.Photo.BatchUpdateWall) + photos.PUT("/:id", h.Photo.Update) + photos.DELETE("/:id", h.Photo.Delete) + photos.PUT("/:id/wall", h.Photo.ToggleWall) } // ==================== Whisper (Heart Words) ==================== whisper := api.Group("/whisper", middleware.AuthRequired(cfg)) { - whisper.POST("", whisperHandler.Create) - whisper.GET("", whisperHandler.List) - whisper.GET("/tips", aiLimiter, whisperHandler.Tips) + whisper.POST("", h.Whisper.Create) + whisper.GET("", h.Whisper.List) + whisper.GET("/tips", aiLimiter, h.Whisper.Tips) } // ==================== Tasks ==================== tasks := api.Group("/tasks", middleware.AuthRequired(cfg)) { - tasks.GET("/daily", taskHandler.DailyTasks) - tasks.POST("/:id/complete", taskHandler.Complete) - tasks.GET("/partner", taskHandler.PartnerTasks) - tasks.POST("/:id/score", taskHandler.Score) - tasks.POST("/:id/reject", taskHandler.Reject) - tasks.GET("/stats", taskHandler.Stats) - tasks.GET("/baby-age", taskHandler.GetBabyAge) - tasks.PUT("/baby-age", taskHandler.SetBabyAge) + tasks.GET("/daily", h.Task.DailyTasks) + tasks.POST("/:id/complete", h.Task.Complete) + tasks.GET("/partner", h.Task.PartnerTasks) + tasks.POST("/:id/score", h.Task.Score) + tasks.POST("/:id/reject", h.Task.Reject) + tasks.GET("/stats", h.Task.Stats) + tasks.GET("/baby-age", h.Task.GetBabyAge) + tasks.PUT("/baby-age", h.Task.SetBabyAge) } // ==================== Admin ==================== adminAPI := api.Group("/admin", middleware.AdminRequired(cfg, isAdmin)) { - adminAPI.GET("/stats", adminHandler.GetStats) - adminAPI.GET("/users", adminHandler.ListUsers) - adminAPI.GET("/users/:id", adminHandler.GetUser) - adminAPI.POST("/users", adminHandler.CreateUser) - adminAPI.PATCH("/users/:id", adminHandler.UpdateUser) - adminAPI.DELETE("/users/:id", adminHandler.DeleteUser) - adminAPI.GET("/config", adminHandler.GetConfig) - adminAPI.PATCH("/config", adminHandler.UpdateConfig) - adminAPI.GET("/photos", adminHandler.ListPhotos) - adminAPI.DELETE("/photos/:id", adminHandler.DeletePhoto) + adminAPI.GET("/stats", h.Admin.GetStats) + adminAPI.GET(routeUsers, h.Admin.ListUsers) + adminAPI.GET(routeUserID, h.Admin.GetUser) + adminAPI.POST(routeUsers, h.Admin.CreateUser) + adminAPI.PATCH(routeUserID, h.Admin.UpdateUser) + adminAPI.DELETE(routeUserID, h.Admin.DeleteUser) + adminAPI.GET("/config", h.Admin.GetConfig) + adminAPI.PATCH("/config", h.Admin.UpdateConfig) + adminAPI.GET("/photos", h.Admin.ListPhotos) + adminAPI.DELETE("/photos/:id", h.Admin.DeletePhoto) } } diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index fd4870f2..b618332d 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -13,6 +13,11 @@ import ( "gorm.io/gorm" ) +const ( + errPasswordHashFailed = "密码加密失败: %w" + errInvalidRefreshToken = "无效的刷新令牌" +) + type AuthService struct { cfg *config.Config userRepo *repository.UserRepo @@ -35,7 +40,7 @@ func (s *AuthService) Register(req dto.RegisterRequest) (*dto.UserResponse, erro // Hash password hash, err := password.Hash(req.Password) if err != nil { - return nil, fmt.Errorf("密码加密失败: %w", err) + return nil, fmt.Errorf(errPasswordHashFailed, err) } user := &model.User{ @@ -83,15 +88,15 @@ func (s *AuthService) Login(req dto.LoginRequest) (*dto.TokenResponse, error) { func (s *AuthService) RefreshToken(refreshToken string) (*dto.TokenResponse, error) { claims, err := pkgjwt.ParseToken(refreshToken, s.cfg.JWTSecretKey) if err != nil { - return nil, errors.New("无效的刷新令牌") + return nil, errors.New(errInvalidRefreshToken) } if claims.Type != "refresh" { - return nil, errors.New("无效的刷新令牌") + return nil, errors.New(errInvalidRefreshToken) } user, err := s.userRepo.FindByID(claims.Subject) if err != nil || !user.IsActive || user.IsBanned { - return nil, errors.New("无效的刷新令牌") + return nil, errors.New(errInvalidRefreshToken) } return s.generateTokens(user.ID) @@ -117,7 +122,7 @@ func (s *AuthService) ChangePassword(userID, oldPassword, newPassword string) er hash, err := password.Hash(newPassword) if err != nil { - return fmt.Errorf("密码加密失败: %w", err) + return fmt.Errorf(errPasswordHashFailed, err) } return s.userRepo.UpdatePassword(userID, hash) @@ -147,7 +152,7 @@ func (s *AuthService) ResetPassword(token, newPassword string) error { hash, err := password.Hash(newPassword) if err != nil { - return fmt.Errorf("密码加密失败: %w", err) + return fmt.Errorf(errPasswordHashFailed, err) } return s.userRepo.UpdatePassword(userID, hash) diff --git a/backend/internal/service/chat.go b/backend/internal/service/chat.go index 81496ae0..528385e7 100644 --- a/backend/internal/service/chat.go +++ b/backend/internal/service/chat.go @@ -265,49 +265,62 @@ func (s *ChatService) Chat(ctx context.Context, msg dto.UserMessage, userID stri } // appendWebSearchResults appends web search results to the system prompt if available. -func appendWebSearchResults(systemPrompt string, webResults string) string { +func appendWebSearchResults(systemPrompt, webResults string) string { if webResults == "" { return systemPrompt } return systemPrompt + "\n\n## 联网搜索参考\n" + webResults + "\n日常聊天不需要引用来源。仅在提供专业性建议时才引用,引用时直接写出具体来源名称(如「根据XX的一篇文章...」),不要使用[来源1]这样的标注。不确定的信息请标明。" } -func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage, userID string) (*dto.VisualResponse, error) { - // Look up user role and partner info - role := model.RoleMom - isAdmin := false - var partnerID string - var partnerRole model.UserRole - if user, err := s.userRepo.FindByID(userID); err == nil { - role = user.Role - isAdmin = user.IsAdmin - if user.PartnerID != nil && *user.PartnerID != "" { - partnerID = *user.PartnerID - if partner, err := s.userRepo.FindByID(partnerID); err == nil { - partnerRole = partner.Role - } +// chatUserContext holds resolved user context for authenticated chat. +type chatUserContext struct { + role model.UserRole + isAdmin bool + partnerID string + partnerRole model.UserRole +} + +// resolveChatUserContext looks up the user and partner information for chat. +func (s *ChatService) resolveChatUserContext(userID string) chatUserContext { + ctx := chatUserContext{role: model.RoleMom} + user, err := s.userRepo.FindByID(userID) + if err != nil { + return ctx + } + ctx.role = user.Role + ctx.isAdmin = user.IsAdmin + if user.PartnerID != nil && *user.PartnerID != "" { + ctx.partnerID = *user.PartnerID + if partner, pErr := s.userRepo.FindByID(ctx.partnerID); pErr == nil { + ctx.partnerRole = partner.Role } } - pronoun := pronounFor(role) + return ctx +} + +func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage, userID string) (*dto.VisualResponse, error) { + // Look up user role and partner info + uc := s.resolveChatUserContext(userID) + pronoun := pronounFor(uc.role) familyIDs := []string{userID} - if partnerID != "" { - familyIDs = append(familyIDs, partnerID) + if uc.partnerID != "" { + familyIDs = append(familyIDs, uc.partnerID) } // Load memory from DB (per-user, not shared) profile, turns, summary := s.loadUserMemory(userID) // Load structured facts for prompt (family-scoped) - factsText, deletedFactsText := s.loadFactsForPrompt(userID, familyIDs, role, partnerRole) + factsText, deletedFactsText := s.loadFactsForPrompt(userID, familyIDs, uc.role, uc.partnerRole) - systemPrompt := fmt.Sprintf(getCompanionPrompt(role, isAdmin), + systemPrompt := fmt.Sprintf(getCompanionPrompt(uc.role, uc.isAdmin), formatProfile(profile, pronoun, factsText), formatTurns(turns, summary, pronoun), ) // Update memory section header for family mode - if partnerID != "" { + if uc.partnerID != "" { for _, old := range []string{"你记得关于她的重要信息", "你记得关于他的重要信息", "你记得关于对方的重要信息"} { systemPrompt = strings.Replace(systemPrompt, old, "你记得关于这个家庭的重要信息", 1) } @@ -425,7 +438,7 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. {Role: "user", Content: msg.Content}, } - rawContent, err := s.client.Chat(context.Background(), messages) + rawContent, err := s.client.Chat(ctx, messages) if err != nil { return nil, fmt.Errorf("AI 服务调用失败: %w", err) } @@ -499,6 +512,45 @@ func (s *ChatService) generateAndSaveSummary(userID string, existingSummary stri // --- Phase 3: Structured Memory Facts --- +// parseFactItem extracts content and category from a single fact item. +func parseFactItem(v interface{}) (content, aiCategory string) { + switch item := v.(type) { + case map[string]interface{}: + c, _ := item["content"].(string) + content = strings.TrimSpace(c) + cat, _ := item["category"].(string) + aiCategory = strings.TrimSpace(cat) + case string: + content = strings.TrimSpace(item) + } + return content, aiCategory +} + +// saveSingleFact creates a fact if it does not already exist. Returns true if saved. +func (s *ChatService) saveSingleFact(userID, content, aiCategory string) bool { + exists, err := s.chatRepo.FactExistsByContent(userID, content) + if err != nil { + log.Printf("[ChatService] failed to check fact existence: %v", err) + return false + } + if exists { + return false + } + + category := resolveFactCategory(aiCategory, content) + fact := &model.ChatMemoryFact{ + UserID: userID, + OwnerUserID: userID, + Content: content, + Category: category, + } + if err := s.chatRepo.CreateFact(fact); err != nil { + log.Printf("[ChatService] failed to save fact for user %s: %v", userID, err) + return false + } + return true +} + func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) bool { if extract == nil { return false @@ -514,47 +566,11 @@ func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) b saved := false for _, v := range facts { - var content string - var aiCategory string - - switch item := v.(type) { - case map[string]interface{}: - // New structured format: {"content": "...", "category": "..."} - c, _ := item["content"].(string) - content = strings.TrimSpace(c) - cat, _ := item["category"].(string) - aiCategory = strings.TrimSpace(cat) - case string: - // Legacy string format - content = strings.TrimSpace(item) - default: - continue - } - + content, aiCategory := parseFactItem(v) if content == "" { continue } - - // Skip if identical fact already exists for this user (including soft-deleted) - exists, err := s.chatRepo.FactExistsByContent(userID, content) - if err != nil { - log.Printf("[ChatService] failed to check fact existence: %v", err) - continue - } - if exists { - continue - } - - category := resolveFactCategory(aiCategory, content) - fact := &model.ChatMemoryFact{ - UserID: userID, - OwnerUserID: userID, - Content: content, - Category: category, - } - if err := s.chatRepo.CreateFact(fact); err != nil { - log.Printf("[ChatService] failed to save fact for user %s: %v", userID, err) - } else { + if s.saveSingleFact(userID, content, aiCategory) { saved = true } } @@ -626,7 +642,7 @@ func (s *ChatService) processMemoryCorrections(familyIDs []string, extract inter } // resolveFactCategory uses the AI-provided category if valid, otherwise falls back to keyword detection. -func resolveFactCategory(aiCategory string, content string) model.FactCategory { +func resolveFactCategory(aiCategory, content string) model.FactCategory { switch model.FactCategory(aiCategory) { case model.FactCategoryPersonalInfo, model.FactCategoryFamily, model.FactCategoryInterest, model.FactCategoryConcern, @@ -677,7 +693,18 @@ func categorizeFactContent(content string) model.FactCategory { return model.FactCategoryOther } -func (s *ChatService) loadFactsForPrompt(userID string, familyIDs []string, userRole model.UserRole, partnerRole model.UserRole) (string, string) { +// factLabel returns the display label for a fact in family mode. +func factLabel(f model.ChatMemoryFact, userID string, userRole, partnerRole model.UserRole) string { + if f.Category == model.FactCategoryFamily { + return "家庭" + } + if f.OwnerUserID == userID { + return pronounFor(userRole) + } + return pronounFor(partnerRole) +} + +func (s *ChatService) loadFactsForPrompt(userID string, familyIDs []string, userRole, partnerRole model.UserRole) (string, string) { hasPartner := len(familyIDs) > 1 facts, err := s.chatRepo.FindFactsByFamilyIDs(familyIDs) @@ -690,14 +717,7 @@ func (s *ChatService) loadFactsForPrompt(userID string, familyIDs []string, user var sb strings.Builder for _, f := range facts { if hasPartner { - var label string - if f.Category == model.FactCategoryFamily { - label = "家庭" - } else if f.OwnerUserID == userID { - label = pronounFor(userRole) - } else { - label = pronounFor(partnerRole) - } + label := factLabel(f, userID, userRole, partnerRole) fmt.Fprintf(&sb, " · [%s] %s\n", label, f.Content) } else { fmt.Fprintf(&sb, " · %s\n", f.Content) diff --git a/backend/internal/service/community.go b/backend/internal/service/community.go index 4c329e14..0b45a40c 100644 --- a/backend/internal/service/community.go +++ b/backend/internal/service/community.go @@ -12,6 +12,11 @@ import ( "gorm.io/gorm" ) +const ( + errExpertPostSourceRequired = "专家帖必须标注来源依据" + errContentModerationFailed = "内容审核未通过: %s" +) + type CommunityService struct { questionRepo *repository.QuestionRepo answerRepo *repository.AnswerRepo @@ -259,7 +264,7 @@ func (s *CommunityService) CreateQuestion(req dto.QuestionCreate, author *model. contentDecision := s.moderation.ModerateText(req.Content) if contentDecision.Result == model.ModerationRejected { - return nil, fmt.Errorf("内容审核未通过: %s", derefStr(contentDecision.Reason)) + return nil, fmt.Errorf(errContentModerationFailed, derefStr(contentDecision.Reason)) } // Determine status @@ -456,14 +461,14 @@ func (s *CommunityService) CreateAnswer(questionID string, req dto.AnswerCreate, return nil, errors.New("仅认证专业人士可发布专家帖") } if req.Sources == "" { - return nil, errors.New("专家帖必须标注来源依据") + return nil, errors.New(errExpertPostSourceRequired) } } // Content moderation decision := s.moderation.ModerateText(req.Content) if decision.Result == model.ModerationRejected { - return nil, fmt.Errorf("内容审核未通过: %s", derefStr(decision.Reason)) + return nil, fmt.Errorf(errContentModerationFailed, derefStr(decision.Reason)) } status := model.StatusPublished @@ -504,44 +509,43 @@ func (s *CommunityService) CreateAnswer(questionID string, req dto.AnswerCreate, return answer, nil } -func (s *CommunityService) UpdateAnswer(answerID string, req dto.AnswerUpdate, user *model.User) (*model.Answer, error) { - a, err := s.answerRepo.FindByID(answerID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("回答不存在") - } - return nil, err +// updateAnswerContent moderates and applies content update to an answer. +func (s *CommunityService) updateAnswerContent(a *model.Answer, content *string) error { + if content == nil { + return nil } - - if a.AuthorID != user.ID && !user.IsAdmin { - return nil, errors.New("无权修改此回答") + decision := s.moderation.ModerateText(*content) + if decision.Result == model.ModerationRejected { + return fmt.Errorf(errContentModerationFailed, derefStr(decision.Reason)) } + a.Content = *content + if decision.Result == model.ModerationNeedManualReview { + a.Status = model.StatusPendingReview + } + return nil +} - if req.Content != nil { - decision := s.moderation.ModerateText(*req.Content) - if decision.Result == model.ModerationRejected { - return nil, fmt.Errorf("内容审核未通过: %s", derefStr(decision.Reason)) - } - a.Content = *req.Content - if decision.Result == model.ModerationNeedManualReview { - a.Status = model.StatusPendingReview - } +// resolveExpertPostSources determines the sources value for an expert post update. +func resolveExpertPostSources(reqSources *string, existingSources *string) string { + if reqSources != nil { + return *reqSources } + if existingSources != nil { + return *existingSources + } + return "" +} - // Update expert post fields +// updateExpertPostFields handles the expert post flag and sources fields on answer update. +func (s *CommunityService) updateExpertPostFields(a *model.Answer, req dto.AnswerUpdate, user *model.User) error { if req.IsExpertPost != nil { if *req.IsExpertPost { if !s.IsCertifiedProfessional(user) && !user.IsAdmin { - return nil, errors.New("仅认证专业人士可发布专家帖") - } - sources := "" - if req.Sources != nil { - sources = *req.Sources - } else if a.Sources != nil { - sources = *a.Sources + return errors.New("仅认证专业人士可发布专家帖") } + sources := resolveExpertPostSources(req.Sources, a.Sources) if sources == "" { - return nil, errors.New("专家帖必须标注来源依据") + return errors.New(errExpertPostSourceRequired) } a.IsExpertPost = true a.Sources = &sources @@ -551,10 +555,33 @@ func (s *CommunityService) UpdateAnswer(answerID string, req dto.AnswerUpdate, u } if req.Sources != nil && (req.IsExpertPost == nil || !*req.IsExpertPost) { if a.IsExpertPost && *req.Sources == "" { - return nil, errors.New("专家帖必须标注来源依据") + return errors.New(errExpertPostSourceRequired) } a.Sources = req.Sources } + return nil +} + +func (s *CommunityService) UpdateAnswer(answerID string, req dto.AnswerUpdate, user *model.User) (*model.Answer, error) { + a, err := s.answerRepo.FindByID(answerID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("回答不存在") + } + return nil, err + } + + if a.AuthorID != user.ID && !user.IsAdmin { + return nil, errors.New("无权修改此回答") + } + + if err := s.updateAnswerContent(a, req.Content); err != nil { + return nil, err + } + + if err := s.updateExpertPostFields(a, req, user); err != nil { + return nil, err + } a.UpdatedAt = time.Now() if err := s.answerRepo.Update(a); err != nil { diff --git a/backend/internal/service/echo.go b/backend/internal/service/echo.go index 58b7b9a5..2f7b0cff 100644 --- a/backend/internal/service/echo.go +++ b/backend/internal/service/echo.go @@ -15,9 +15,10 @@ import ( ) const ( - memoirDefaultTitle = "一段温柔的回响" - memoirKeyTitle = "title" - memoirKeyContent = "content" + memoirDefaultTitle = "一段温柔的回响" + memoirKeyTitle = "title" + memoirKeyContent = "content" + trimWhitespaceChars = " \t\n\r" ) type EchoService struct { @@ -319,13 +320,13 @@ func parseMemoirLLMResponse(content string) map[string]interface{} { func cleanMemoirText(s string) string { s = strings.TrimSpace(s) // Remove trailing markdown code fences and JSON braces - s = strings.TrimRight(s, " \t\n\r") + s = strings.TrimRight(s, trimWhitespaceChars) for { trimmed := s trimmed = strings.TrimSuffix(trimmed, "```") trimmed = strings.TrimSuffix(trimmed, "}") trimmed = strings.TrimSuffix(trimmed, `"`) - trimmed = strings.TrimRight(trimmed, " \t\n\r") + trimmed = strings.TrimRight(trimmed, trimWhitespaceChars) if trimmed == s { break } @@ -337,7 +338,7 @@ func cleanMemoirText(s string) string { trimmed = strings.TrimPrefix(trimmed, "```json") trimmed = strings.TrimPrefix(trimmed, "```") trimmed = strings.TrimPrefix(trimmed, "{") - trimmed = strings.TrimLeft(trimmed, " \t\n\r") + trimmed = strings.TrimLeft(trimmed, trimWhitespaceChars) if trimmed == s { break } diff --git a/backend/internal/service/task.go b/backend/internal/service/task.go index 5dede137..aae0646b 100644 --- a/backend/internal/service/task.go +++ b/backend/internal/service/task.go @@ -334,7 +334,7 @@ func (s *TaskService) createTasksForUser(user *model.User, userID string, date t } // SetBabyAge sets the baby age stage for the user and immediately regenerates tasks. -func (s *TaskService) SetBabyAge(userID string, ageStage string) error { +func (s *TaskService) SetBabyAge(userID, ageStage string) error { user, err := s.userRepo.FindByID(userID) if err != nil { return errors.New(errUserNotFound) diff --git a/backend/internal/service/task_ai.go b/backend/internal/service/task_ai.go index 11bce66c..fcdfdf88 100644 --- a/backend/internal/service/task_ai.go +++ b/backend/internal/service/task_ai.go @@ -41,6 +41,24 @@ func coupleKey(a, b string) string { return b + "-" + a } +// resolveAgeStageFromChatMemory tries to infer the age stage from chat memory facts. +func resolveAgeStageFromChatMemory(chatRepo *repository.ChatRepo, familyIDs []string) (string, bool) { + facts, err := chatRepo.FindFactsByFamilyIDs(familyIDs) + if err != nil { + return "", false + } + for _, f := range facts { + lower := strings.ToLower(f.Content) + if strings.Contains(lower, "宝宝") || strings.Contains(lower, "baby") || strings.Contains(lower, "孩子") { + stage := inferAgeStageFromMemory(f.Content) + if stage != "" { + return stage, true + } + } + } + return "", false +} + // resolveAgeStage returns the baby age stage for the couple, checking: // 1. The user's own BabyAgeStage // 2. The partner's BabyAgeStage @@ -69,17 +87,8 @@ func resolveAgeStage( if user.PartnerID != nil { familyIDs = append(familyIDs, *user.PartnerID) } - facts, err := chatRepo.FindFactsByFamilyIDs(familyIDs) - if err == nil { - for _, f := range facts { - lower := strings.ToLower(f.Content) - if strings.Contains(lower, "宝宝") || strings.Contains(lower, "baby") || strings.Contains(lower, "孩子") { - stage := inferAgeStageFromMemory(f.Content) - if stage != "" { - return stage, "memory" - } - } - } + if s, found := resolveAgeStageFromChatMemory(chatRepo, familyIDs); found { + return s, "memory" } } diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index 6979e1f0..ceb2e561 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -11,7 +11,10 @@ import ( "gorm.io/gorm" ) -const errUserServiceUserNotFound = "用户不存在" +const ( + errUserServiceUserNotFound = "用户不存在" + whereIDEquals = "id = ?" +) type UserService struct { db *gorm.DB @@ -86,6 +89,21 @@ func (s *UserService) GetProfile(userID string) (*dto.UserProfile, error) { return profile, nil } +// validateRoleChange checks if a role update is allowed for the user. +func validateRoleChange(user *model.User, newRoleStr string) error { + newRole := model.UserRole(newRoleStr) + if !model.FamilyRoles[newRole] { + return errors.New("角色只能是: mom, dad") + } + if model.ProfessionalRoles[user.Role] { + return errors.New("认证专业人员不能修改角色") + } + if user.PartnerID != nil { + return errors.New("已绑定伴侣,无法更改身份") + } + return nil +} + // applyProfileFieldUpdates applies the individual field updates from the request to the user model. // Returns an error if any validation fails (e.g. duplicate username/email, invalid role). func (s *UserService) applyProfileFieldUpdates(user *model.User, req dto.UserProfileUpdate) error { @@ -114,17 +132,10 @@ func (s *UserService) applyProfileFieldUpdates(user *model.User, req dto.UserPro } if req.Role != nil { - newRole := model.UserRole(*req.Role) - if !model.FamilyRoles[newRole] { - return errors.New("角色只能是: mom, dad") - } - if model.ProfessionalRoles[user.Role] { - return errors.New("认证专业人员不能修改角色") - } - if user.PartnerID != nil { - return errors.New("已绑定伴侣,无法更改身份") + if err := validateRoleChange(user, *req.Role); err != nil { + return err } - user.Role = newRole + user.Role = model.UserRole(*req.Role) } return nil @@ -178,7 +189,7 @@ func (s *UserService) GenerateShellCode(userID string) (*dto.UserProfile, error) } // BindPartner binds a 守护者 (dad) to a 溯源者 (mom) via shell code. -func (s *UserService) BindPartner(userID string, shellCode string) (*dto.UserProfile, error) { +func (s *UserService) BindPartner(userID, shellCode string) (*dto.UserProfile, error) { user, err := s.userRepo.FindByID(userID) if err != nil { return nil, errors.New(errUserServiceUserNotFound) @@ -211,10 +222,10 @@ func (s *UserService) BindPartner(userID string, shellCode string) (*dto.UserPro // Bind both sides in a transaction err = s.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("partner_id", partner.ID).Error; err != nil { + if err := tx.Model(&model.User{}).Where(whereIDEquals, user.ID).Update("partner_id", partner.ID).Error; err != nil { return err } - if err := tx.Model(&model.User{}).Where("id = ?", partner.ID).Update("partner_id", user.ID).Error; err != nil { + if err := tx.Model(&model.User{}).Where(whereIDEquals, partner.ID).Update("partner_id", user.ID).Error; err != nil { return err } return nil @@ -241,13 +252,13 @@ func (s *UserService) UnbindPartner(userID string) (*dto.UserProfile, error) { // Unbind both sides, clear shell code err = s.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + if err := tx.Model(&model.User{}).Where(whereIDEquals, userID).Updates(map[string]interface{}{ "partner_id": nil, "shell_code": nil, }).Error; err != nil { return err } - if err := tx.Model(&model.User{}).Where("id = ?", partnerID).Updates(map[string]interface{}{ + if err := tx.Model(&model.User{}).Where(whereIDEquals, partnerID).Updates(map[string]interface{}{ "partner_id": nil, "shell_code": nil, }).Error; err != nil { From 7ee3e035ab8096dbb16c01c87c65c8435a1fffc3 Mon Sep 17 00:00:00 2001 From: arthurcai Date: Sun, 15 Mar 2026 10:52:39 +0800 Subject: [PATCH 2/5] refactor(frontend): fix SonarCloud reliability and maintainability issues - S7764: prefer globalThis over window across composables and PearlShell - S7767: use Math.trunc instead of bitwise |0 in random.ts, BarPage, PearlShell - S7748: remove zero fractions in seagulls.ts, reflections.ts, PearlShell - S3863: merge duplicate imports in App.vue, ChatPanel.vue - S3358: replace nested ternaries with if/else in CarPage, SpritesLayer, useParallax - S7773: use Number.parseFloat/Number.isNaN in spriteOffset.ts - S7746: prefer throw over Promise.reject in apiClient.ts - S7786: use TypeError for type checks in auth.ts - S4325: remove unnecessary type assertions in useTutorial, apiClient - S7769: use Math.hypot in PearlShell - S7762: use childNode.remove() in PearlShell - S6759: mark props readonly in PearlShell - S7778: combine multiple push calls in CarPage - S7758: use codePointAt over charCodeAt in BarPage - S3735: remove void operator in NavBar - S7735: flip negated condition in useTutorial - S7785: use top-level await in main.ts - S2004/S3776: reduce nesting and complexity in PearlShell - css:S7924: improve text contrast across 8+ overlay components - Web:ImgWithoutAltCheck: add alt attributes to images - Web:S6851: remove redundant alt words in CarPage - Web:S6853: add form label accessibility in CarPage --- frontend/src/App.vue | 3 +- .../src/components/overlay/AiMemoryPanel.vue | 20 +- frontend/src/components/overlay/AuthPanel.vue | 2 +- frontend/src/components/overlay/BarPage.vue | 88 ++-- frontend/src/components/overlay/CarPage.vue | 418 +++++++++--------- frontend/src/components/overlay/ChatPanel.vue | 4 +- .../src/components/overlay/CommunityPanel.vue | 16 +- .../src/components/overlay/MemoryPanel.vue | 2 +- frontend/src/components/overlay/TaskPanel.vue | 20 +- .../src/components/overlay/WhisperPanel.vue | 4 +- frontend/src/components/react/PearlShell.tsx | 136 +++--- frontend/src/components/scene/NavBar.vue | 2 +- .../src/components/scene/SpritesLayer.vue | 7 +- .../src/composables/useBackgroundMusicLoop.ts | 176 ++++---- frontend/src/composables/useIsMobile.ts | 15 +- frontend/src/composables/useParallax.ts | 30 +- frontend/src/composables/useTutorial.ts | 8 +- frontend/src/constants/reflections.ts | 60 ++- frontend/src/constants/seagulls.ts | 20 +- frontend/src/lib/apiClient.ts | 20 +- frontend/src/lib/auth.ts | 6 +- frontend/src/main.ts | 6 +- frontend/src/styles/variables.css | 2 +- frontend/src/utils/random.ts | 4 +- frontend/src/utils/spriteOffset.ts | 24 +- 25 files changed, 581 insertions(+), 512 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5e7d3317..18362a62 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -15,7 +15,7 @@