diff --git a/.env.example b/.env.example
index 7510d285..683eb5c1 100644
--- a/.env.example
+++ b/.env.example
@@ -22,6 +22,10 @@ JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api-inference.modelscope.cn/v1
OPENAI_MODEL=Qwen/Qwen3-235B-A22B
+IMAGE_MODEL=Tongyi-MAI/Z-Image-Turbo
+
+# ==================== Firecrawl (Web Search) ====================
+FIRECRAWL_API_KEY=
# ==================== Server ====================
PORT=8000
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 341fb0c0..dae3f2f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+
+#### Vue 3 Frontend
+
+- **Background music loop**: Added global alternating playback for `The Shore and You` and `Travelogue`
+- **Profile music control**: Added background music volume slider in the profile settings panel
+
+### Changed
+
+- **Frontend assets**: Reorganized static assets into `frontend/src/assets/images/` and `frontend/src/assets/audio/`
+- **Asset imports**: Updated frontend image and audio references to match the new asset directory structure
+- **Crab speech bubble anchoring**: Reworked the beach scene crab hint bubble to position from the crab sprite's rendered bounds instead of static offsets, keeping the bubble visually closer across viewport sizes.
+---
+
## [1.0.0] - 2026-03-05
### Major Rewrite
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index c466e217..fcfa4151 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -13,6 +13,7 @@ import (
"github.com/momshell/backend/internal/repository"
"github.com/momshell/backend/internal/router"
"github.com/momshell/backend/internal/service"
+ "github.com/momshell/backend/pkg/firecrawl"
"github.com/momshell/backend/pkg/openai"
"github.com/momshell/backend/pkg/password"
)
@@ -46,6 +47,9 @@ func main() {
tagRepo := repository.NewTagRepo(db)
chatRepo := repository.NewChatRepo(db)
echoRepo := repository.NewEchoRepo(db)
+ photoRepo := repository.NewPhotoRepo(db)
+ whisperRepo := repository.NewWhisperRepo(db)
+ taskRepo := repository.NewTaskRepo(db)
// Initialize services
moderationService := service.NewModerationService()
@@ -63,8 +67,28 @@ func main() {
log.Println("[WARN] OPENAI_API_KEY not set, chat service will not work")
chatClient = openai.NewClient("dummy", cfg.OpenAIBaseURL, cfg.OpenAIModel)
}
- chatService := service.NewChatService(chatClient, chatRepo)
+
+ var firecrawlClient *firecrawl.Client
+ if cfg.FirecrawlAPIKey != "" {
+ firecrawlClient = firecrawl.NewClient(cfg.FirecrawlAPIKey)
+ }
+
+ chatService := service.NewChatService(chatClient, chatRepo, firecrawlClient)
echoService := service.NewEchoService(chatClient, echoRepo, userRepo)
+ photoService := service.NewPhotoService(photoRepo, chatClient, cfg.ImageModel)
+ whisperService := service.NewWhisperService(whisperRepo, userRepo, chatClient)
+ taskService := service.NewTaskService(taskRepo, userRepo)
+
+ // Ensure AI user exists for community AI replies
+ aiUserID := ensureAIUser(userRepo)
+ var communityAIService *service.CommunityAIService
+ if aiUserID != "" && cfg.OpenAIAPIKey != "" {
+ communityAIService = service.NewCommunityAIService(
+ chatClient, firecrawlClient,
+ questionRepo, answerRepo, commentRepo,
+ aiUserID,
+ )
+ }
userService := service.NewUserService(
db, userRepo, questionRepo, answerRepo,
@@ -77,15 +101,18 @@ func main() {
// Initialize handlers
authHandler := handler.NewAuthHandler(authService)
- questionHandler := handler.NewQuestionHandler(communityService, authService)
- answerHandler := handler.NewAnswerHandler(communityService, authService)
- commentHandler := handler.NewCommentHandler(communityService, authService)
+ questionHandler := handler.NewQuestionHandler(communityService, authService, communityAIService)
+ answerHandler := handler.NewAnswerHandler(communityService, authService, communityAIService)
+ commentHandler := handler.NewCommentHandler(communityService, authService, communityAIService)
interactionHandler := handler.NewInteractionHandler(communityService)
tagHandler := handler.NewTagHandler(communityService)
chatHandler := handler.NewChatHandler(chatService)
echoHandler := handler.NewEchoHandler(echoService)
userHandler := handler.NewUserHandler(userService)
adminHandler := handler.NewAdminHandler(adminService, authService)
+ photoHandler := handler.NewPhotoHandler(photoService)
+ whisperHandler := handler.NewWhisperHandler(whisperService)
+ taskHandler := handler.NewTaskHandler(taskService)
// Setup Gin
r := gin.New()
@@ -99,6 +126,7 @@ func main() {
authHandler, questionHandler, answerHandler,
commentHandler, interactionHandler, tagHandler,
chatHandler, echoHandler, userHandler, adminHandler,
+ photoHandler, whisperHandler, taskHandler,
)
// Start server
@@ -143,3 +171,33 @@ func createInitialAdmin(cfg *config.Config, userRepo *repository.UserRepo) {
log.Printf("Admin user created: %s", cfg.AdminUsername)
}
+
+func ensureAIUser(userRepo *repository.UserRepo) string {
+ user, err := userRepo.FindByUsernameOrEmail("xiaoshiguang")
+ if err == nil {
+ return user.ID
+ }
+
+ hash, err := password.Hash("ai-user-no-login")
+ if err != nil {
+ log.Printf("Failed to hash AI user password: %v", err)
+ return ""
+ }
+
+ aiUser := &model.User{
+ Username: "xiaoshiguang",
+ Email: "ai@momshell.com",
+ PasswordHash: hash,
+ Nickname: "小石光",
+ Role: model.RoleAIAssistant,
+ IsActive: true,
+ }
+
+ if err := userRepo.Create(aiUser); err != nil {
+ log.Printf("Failed to create AI user: %v", err)
+ return ""
+ }
+
+ log.Printf("AI user created: %s (ID: %s)", aiUser.Username, aiUser.ID)
+ return aiUser.ID
+}
diff --git a/backend/internal/admin/admin.html b/backend/internal/admin/admin.html
index ec510ccb..2db5bdf6 100644
--- a/backend/internal/admin/admin.html
+++ b/backend/internal/admin/admin.html
@@ -475,7 +475,7 @@
确认删除
headers: { 'Authorization': 'Bearer ' + data.access_token }
});
const meData = await meResp.json();
- if (!meResp.ok || meData.role !== 'admin') {
+ if (!meResp.ok || !meData.is_admin) {
throw new Error('需要管理员权限');
}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index fb6a0b66..761fbe40 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -22,6 +22,10 @@ type Config struct {
OpenAIAPIKey string
OpenAIBaseURL string
OpenAIModel string
+ ImageModel string
+
+ // Firecrawl (web search)
+ FirecrawlAPIKey string
// Server
Port string
@@ -46,6 +50,8 @@ func Load() *Config {
OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""),
OpenAIBaseURL: getEnv("OPENAI_BASE_URL", "https://api-inference.modelscope.cn/v1"),
OpenAIModel: getEnv("OPENAI_MODEL", "Qwen/Qwen2.5-72B-Instruct"),
+ FirecrawlAPIKey: getEnv("FIRECRAWL_API_KEY", ""),
+ ImageModel: getEnv("IMAGE_MODEL", ""),
Port: getEnv("PORT", "8000"),
AdminUsername: getEnv("ADMIN_USERNAME", ""),
AdminEmail: getEnv("ADMIN_EMAIL", ""),
diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go
index 78580b4c..e3c248c5 100644
--- a/backend/internal/database/migrate.go
+++ b/backend/internal/database/migrate.go
@@ -20,10 +20,17 @@ func Migrate(db *gorm.DB) error {
&model.ChatMemory{},
&model.IdentityTag{},
&model.Memoir{},
+ &model.Photo{},
+ &model.Whisper{},
+ &model.DailyTask{},
+ &model.UserTask{},
); err != nil {
return err
}
+ // Seed default daily tasks if none exist
+ seedDailyTasks(db)
+
// Migrate legacy role='admin' users to is_admin flag
db.Model(&model.User{}).Where("role = ?", "admin").Updates(map[string]interface{}{
"is_admin": true,
@@ -32,3 +39,42 @@ func Migrate(db *gorm.DB) error {
return nil
}
+
+func seedDailyTasks(db *gorm.DB) {
+ var count int64
+ db.Model(&model.DailyTask{}).Count(&count)
+ if count > 0 {
+ return
+ }
+
+ tasks := []model.DailyTask{
+ // Housework
+ {Title: "做一顿早餐", Description: "为家人准备一份营养早餐", Category: model.TaskCategoryHousework, Difficulty: 2},
+ {Title: "整理卧室", Description: "整理床铺、叠好衣物", Category: model.TaskCategoryHousework, Difficulty: 1},
+ {Title: "打扫客厅", Description: "扫地、拖地、整理杂物", Category: model.TaskCategoryHousework, Difficulty: 2},
+ {Title: "清洁厨房", Description: "洗碗、擦台面、清理灶台", Category: model.TaskCategoryHousework, Difficulty: 2},
+ {Title: "洗一次衣服", Description: "把脏衣服分类洗好、晾晒", Category: model.TaskCategoryHousework, Difficulty: 2},
+ {Title: "倒垃圾", Description: "把垃圾分类打包扔掉", Category: model.TaskCategoryHousework, Difficulty: 1},
+ // Parenting
+ {Title: "学习婴儿急救知识", Description: "看一篇婴儿急救相关的文章或视频", Category: model.TaskCategoryParenting, Difficulty: 3},
+ {Title: "了解宝宝辅食添加", Description: "学习适龄辅食的种类和注意事项", Category: model.TaskCategoryParenting, Difficulty: 2},
+ {Title: "学习安抚哭闹技巧", Description: "学习如何科学安抚宝宝", Category: model.TaskCategoryParenting, Difficulty: 2},
+ {Title: "阅读一篇育儿文章", Description: "选一篇科学育儿知识文章阅读", Category: model.TaskCategoryParenting, Difficulty: 1},
+ {Title: "陪宝宝互动 15 分钟", Description: "放下手机,全身心陪伴宝宝玩耍", Category: model.TaskCategoryParenting, Difficulty: 2},
+ // Health
+ {Title: "陪妈妈散步 30 分钟", Description: "陪伴一起户外走走,呼吸新鲜空气", Category: model.TaskCategoryHealth, Difficulty: 2},
+ {Title: "提醒妈妈按时吃饭", Description: "确保她三餐按时吃、营养均衡", Category: model.TaskCategoryHealth, Difficulty: 1},
+ {Title: "帮妈妈做肩颈按摩", Description: "帮她放松一下肩颈,缓解疲劳", Category: model.TaskCategoryHealth, Difficulty: 2},
+ {Title: "提醒妈妈喝水", Description: "关心她的饮水量,适时提醒", Category: model.TaskCategoryHealth, Difficulty: 1},
+ // Emotional
+ {Title: "给妈妈写一段鼓励的话", Description: "用文字表达你对她的感谢和支持", Category: model.TaskCategoryEmotional, Difficulty: 3},
+ {Title: "主动询问她今天的感受", Description: "认真倾听她的心情,不急着给建议", Category: model.TaskCategoryEmotional, Difficulty: 2},
+ {Title: "准备一个小惊喜", Description: "一杯热饮、一束花、或一句暖心的话", Category: model.TaskCategoryEmotional, Difficulty: 3},
+ {Title: "主动承担一次夜间照顾", Description: "让妈妈好好休息一晚", Category: model.TaskCategoryEmotional, Difficulty: 4},
+ {Title: "表达一次感谢", Description: "告诉她你看到了她的付出和辛苦", Category: model.TaskCategoryEmotional, Difficulty: 1},
+ }
+
+ for i := range tasks {
+ db.Create(&tasks[i])
+ }
+}
diff --git a/backend/internal/dto/photo.go b/backend/internal/dto/photo.go
new file mode 100644
index 00000000..56c38f79
--- /dev/null
+++ b/backend/internal/dto/photo.go
@@ -0,0 +1,46 @@
+package dto
+
+type PhotoResponse struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Tags []string `json:"tags"`
+ ImageURL string `json:"image_url"`
+ IsOnWall bool `json:"is_on_wall"`
+ WallPosition *int `json:"wall_position"`
+ Source string `json:"source"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+type PhotoListResponse struct {
+ Photos []PhotoResponse `json:"photos"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ TotalPages int `json:"total_pages"`
+}
+
+type GeneratePhotoRequest struct {
+ Prompt string `json:"prompt" binding:"required,min=1,max=500"`
+}
+
+type UpdatePhotoRequest struct {
+ Title *string `json:"title" binding:"omitempty,max=200"`
+ Description *string `json:"description" binding:"omitempty,max=2000"`
+ Tags []string `json:"tags" binding:"omitempty,max=10"`
+}
+
+type ToggleWallRequest struct {
+ IsOnWall bool `json:"is_on_wall"`
+ WallPosition *int `json:"wall_position" binding:"omitempty,min=0,max=8"`
+}
+
+type BatchWallUpdateRequest struct {
+ Photos []WallItem `json:"photos" binding:"required,min=1,max=9"`
+}
+
+type WallItem struct {
+ PhotoID string `json:"photo_id" binding:"required"`
+ Position int `json:"position" binding:"min=0,max=8"`
+}
diff --git a/backend/internal/dto/task.go b/backend/internal/dto/task.go
new file mode 100644
index 00000000..08b92a2a
--- /dev/null
+++ b/backend/internal/dto/task.go
@@ -0,0 +1,27 @@
+package dto
+
+import "time"
+
+type UserTaskItem struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Category string `json:"category"`
+ Difficulty int `json:"difficulty"`
+ Status string `json:"status"`
+ Score *int `json:"score"`
+ Comment *string `json:"comment"`
+ CompletedAt *time.Time `json:"completed_at"`
+ ScoredAt *time.Time `json:"scored_at"`
+ Date time.Time `json:"date"`
+}
+
+type TaskScore struct {
+ Score int `json:"score" binding:"required,min=1,max=5"`
+ Comment string `json:"comment" binding:"max=500"`
+}
+
+type TaskStats struct {
+ XP int `json:"xp"`
+ Level int `json:"level"`
+}
diff --git a/backend/internal/dto/whisper.go b/backend/internal/dto/whisper.go
new file mode 100644
index 00000000..3234576d
--- /dev/null
+++ b/backend/internal/dto/whisper.go
@@ -0,0 +1,18 @@
+package dto
+
+import "time"
+
+type WhisperCreate struct {
+ Content string `json:"content" binding:"required,min=1,max=2000"`
+}
+
+type WhisperItem struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type WhisperTips struct {
+ Tips string `json:"tips"`
+ Whispers []WhisperItem `json:"whispers"`
+}
diff --git a/backend/internal/handler/answer.go b/backend/internal/handler/answer.go
index 9239a24a..9b33c554 100644
--- a/backend/internal/handler/answer.go
+++ b/backend/internal/handler/answer.go
@@ -12,10 +12,11 @@ import (
type AnswerHandler struct {
communityService *service.CommunityService
authService *service.AuthService
+ communityAI *service.CommunityAIService
}
-func NewAnswerHandler(communityService *service.CommunityService, authService *service.AuthService) *AnswerHandler {
- return &AnswerHandler{communityService: communityService, authService: authService}
+func NewAnswerHandler(communityService *service.CommunityService, authService *service.AuthService, communityAI *service.CommunityAIService) *AnswerHandler {
+ return &AnswerHandler{communityService: communityService, authService: authService, communityAI: communityAI}
}
// GET /api/v1/community/questions/:id/answers
@@ -67,6 +68,11 @@ func (h *AnswerHandler) Create(c *gin.Context) {
return
}
+ // Trigger AI reply if answer mentions @小石光
+ if h.communityAI != nil && service.ContainsMention(req.Content) {
+ go h.communityAI.HandleNewAnswer(answer.QuestionID, answer.ID)
+ }
+
c.JSON(http.StatusCreated, gin.H{"id": answer.ID, "status": string(answer.Status)})
}
diff --git a/backend/internal/handler/comment.go b/backend/internal/handler/comment.go
index 02dec0cd..3d62b793 100644
--- a/backend/internal/handler/comment.go
+++ b/backend/internal/handler/comment.go
@@ -12,10 +12,11 @@ import (
type CommentHandler struct {
communityService *service.CommunityService
authService *service.AuthService
+ communityAI *service.CommunityAIService
}
-func NewCommentHandler(communityService *service.CommunityService, authService *service.AuthService) *CommentHandler {
- return &CommentHandler{communityService: communityService, authService: authService}
+func NewCommentHandler(communityService *service.CommunityService, authService *service.AuthService, communityAI *service.CommunityAIService) *CommentHandler {
+ return &CommentHandler{communityService: communityService, authService: authService, communityAI: communityAI}
}
// GET /api/v1/community/answers/:id/comments
@@ -59,6 +60,11 @@ func (h *CommentHandler) Create(c *gin.Context) {
return
}
+ // Trigger AI reply if commenting on AI's answer, replying to AI, or @mentioning AI
+ if h.communityAI != nil && h.communityAI.ShouldReplyToComment(req.Content, answerID, req.ParentID) {
+ go h.communityAI.HandleNewComment(answerID, comment.ID)
+ }
+
c.JSON(http.StatusCreated, comment)
}
diff --git a/backend/internal/handler/photo.go b/backend/internal/handler/photo.go
new file mode 100644
index 00000000..fd7b3e7f
--- /dev/null
+++ b/backend/internal/handler/photo.go
@@ -0,0 +1,204 @@
+package handler
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "github.com/momshell/backend/internal/dto"
+ "github.com/momshell/backend/internal/middleware"
+ "github.com/momshell/backend/internal/service"
+)
+
+const maxPhotoSize = 5 << 20 // 5 MB
+
+var allowedPhotoTypes = map[string]bool{
+ "image/jpeg": true,
+ "image/png": true,
+ "image/gif": true,
+ "image/webp": true,
+}
+
+type PhotoHandler struct {
+ photoService *service.PhotoService
+}
+
+func NewPhotoHandler(photoService *service.PhotoService) *PhotoHandler {
+ return &PhotoHandler{photoService: photoService}
+}
+
+// GET /api/v1/photos
+func (h *PhotoHandler) List(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
+
+ result, err := h.photoService.ListPhotos(userID, page, pageSize)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// POST /api/v1/photos/upload
+func (h *PhotoHandler) Upload(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+
+ file, header, err := c.Request.FormFile("photo")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的照片"})
+ return
+ }
+ defer func() { _ = file.Close() }()
+
+ if header.Size > maxPhotoSize {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "照片大小不能超过 5MB"})
+ return
+ }
+
+ contentType := header.Header.Get("Content-Type")
+ if !allowedPhotoTypes[contentType] {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPG、PNG、GIF、WebP 格式"})
+ return
+ }
+
+ ext := ".jpg"
+ switch contentType {
+ case "image/png":
+ ext = ".png"
+ case "image/gif":
+ ext = ".gif"
+ case "image/webp":
+ ext = ".webp"
+ }
+
+ uploadDir := "uploads/photos"
+ if err := os.MkdirAll(uploadDir, 0o755); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "上传失败"})
+ return
+ }
+
+ filename := uuid.New().String() + ext
+ savePath := filepath.Join(uploadDir, filename)
+
+ if err := c.SaveUploadedFile(header, savePath); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "上传失败"})
+ return
+ }
+
+ imageURL := fmt.Sprintf("/uploads/photos/%s", filename)
+ title := c.PostForm("title")
+
+ result, err := h.photoService.CreateFromUpload(userID, title, imageURL)
+ if err != nil {
+ _ = os.Remove(savePath)
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusCreated, result)
+}
+
+// POST /api/v1/photos/generate
+func (h *PhotoHandler) Generate(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+
+ var req dto.GeneratePhotoRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Image generation may need async polling, allow up to 5 minutes
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ result, err := h.photoService.GeneratePhoto(ctx, userID, req)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusCreated, result)
+}
+
+// PUT /api/v1/photos/:id
+func (h *PhotoHandler) Update(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ photoID := c.Param("id")
+
+ var req dto.UpdatePhotoRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ result, err := h.photoService.UpdatePhoto(photoID, userID, req)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// DELETE /api/v1/photos/:id
+func (h *PhotoHandler) Delete(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ photoID := c.Param("id")
+
+ if err := h.photoService.DeletePhoto(photoID, userID); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusNoContent, nil)
+}
+
+// PUT /api/v1/photos/:id/wall
+func (h *PhotoHandler) ToggleWall(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ photoID := c.Param("id")
+
+ var req dto.ToggleWallRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ result, err := h.photoService.ToggleWall(photoID, userID, req)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// PUT /api/v1/photos/wall
+func (h *PhotoHandler) BatchUpdateWall(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+
+ var req dto.BatchWallUpdateRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ result, err := h.photoService.BatchUpdateWall(userID, req)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"photos": result})
+}
diff --git a/backend/internal/handler/question.go b/backend/internal/handler/question.go
index ad396881..bb9a8e59 100644
--- a/backend/internal/handler/question.go
+++ b/backend/internal/handler/question.go
@@ -6,16 +6,18 @@ import (
"github.com/gin-gonic/gin"
"github.com/momshell/backend/internal/dto"
"github.com/momshell/backend/internal/middleware"
+ "github.com/momshell/backend/internal/model"
"github.com/momshell/backend/internal/service"
)
type QuestionHandler struct {
communityService *service.CommunityService
authService *service.AuthService
+ communityAI *service.CommunityAIService
}
-func NewQuestionHandler(communityService *service.CommunityService, authService *service.AuthService) *QuestionHandler {
- return &QuestionHandler{communityService: communityService, authService: authService}
+func NewQuestionHandler(communityService *service.CommunityService, authService *service.AuthService, communityAI *service.CommunityAIService) *QuestionHandler {
+ return &QuestionHandler{communityService: communityService, authService: authService, communityAI: communityAI}
}
// GET /api/v1/community/questions
@@ -122,6 +124,11 @@ func (h *QuestionHandler) Create(c *gin.Context) {
return
}
+ // Trigger AI reply for every new published question
+ if h.communityAI != nil && question.Status == model.StatusPublished {
+ go h.communityAI.HandleNewQuestion(question.ID)
+ }
+
c.JSON(http.StatusCreated, gin.H{"id": question.ID, "status": string(question.Status)})
}
diff --git a/backend/internal/handler/task.go b/backend/internal/handler/task.go
new file mode 100644
index 00000000..4a243d32
--- /dev/null
+++ b/backend/internal/handler/task.go
@@ -0,0 +1,125 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/momshell/backend/internal/dto"
+ "github.com/momshell/backend/internal/middleware"
+ "github.com/momshell/backend/internal/service"
+)
+
+type TaskHandler struct {
+ taskService *service.TaskService
+}
+
+func NewTaskHandler(taskService *service.TaskService) *TaskHandler {
+ return &TaskHandler{taskService: taskService}
+}
+
+// GET /api/v1/tasks/daily
+func (h *TaskHandler) DailyTasks(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ items, err := h.taskService.GetDailyTasks(userID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, items)
+}
+
+// POST /api/v1/tasks/:id/complete
+func (h *TaskHandler) Complete(c *gin.Context) {
+ taskID := c.Param("id")
+ userID := middleware.GetUserID(c)
+
+ item, err := h.taskService.CompleteTask(userID, taskID)
+ if err != nil {
+ status := http.StatusBadRequest
+ if err.Error() == "任务不存在" {
+ status = http.StatusNotFound
+ } else if err.Error() == "无权操作此任务" {
+ status = http.StatusForbidden
+ }
+ c.JSON(status, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, item)
+}
+
+// GET /api/v1/tasks/partner
+func (h *TaskHandler) PartnerTasks(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ items, err := h.taskService.GetPartnerTasks(userID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, items)
+}
+
+// POST /api/v1/tasks/:id/score
+func (h *TaskHandler) Score(c *gin.Context) {
+ taskID := c.Param("id")
+ userID := middleware.GetUserID(c)
+
+ var req dto.TaskScore
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ item, err := h.taskService.ScoreTask(userID, taskID, req)
+ if err != nil {
+ status := http.StatusBadRequest
+ if err.Error() == "任务不存在" {
+ status = http.StatusNotFound
+ } else if err.Error() == "只能验收伴侣的任务" || err.Error() == "只有妈妈角色可以验收任务" {
+ status = http.StatusForbidden
+ }
+ c.JSON(status, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, item)
+}
+
+// POST /api/v1/tasks/:id/reject
+func (h *TaskHandler) Reject(c *gin.Context) {
+ taskID := c.Param("id")
+ userID := middleware.GetUserID(c)
+
+ var req struct {
+ Comment string `json:"comment" binding:"max=500"`
+ }
+ _ = c.ShouldBindJSON(&req)
+
+ item, err := h.taskService.RejectTask(userID, taskID, req.Comment)
+ if err != nil {
+ status := http.StatusBadRequest
+ if err.Error() == "任务不存在" {
+ status = http.StatusNotFound
+ } else if err.Error() == "只能验收伴侣的任务" || err.Error() == "只有妈妈角色可以验收任务" {
+ status = http.StatusForbidden
+ }
+ c.JSON(status, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, item)
+}
+
+// GET /api/v1/tasks/stats
+func (h *TaskHandler) Stats(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ stats, err := h.taskService.GetTaskStats(userID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, stats)
+}
diff --git a/backend/internal/handler/whisper.go b/backend/internal/handler/whisper.go
new file mode 100644
index 00000000..62a51db1
--- /dev/null
+++ b/backend/internal/handler/whisper.go
@@ -0,0 +1,64 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/momshell/backend/internal/dto"
+ "github.com/momshell/backend/internal/middleware"
+ "github.com/momshell/backend/internal/service"
+)
+
+type WhisperHandler struct {
+ whisperService *service.WhisperService
+}
+
+func NewWhisperHandler(whisperService *service.WhisperService) *WhisperHandler {
+ return &WhisperHandler{whisperService: whisperService}
+}
+
+// POST /api/v1/whisper
+func (h *WhisperHandler) Create(c *gin.Context) {
+ var req dto.WhisperCreate
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ userID := middleware.GetUserID(c)
+ item, err := h.whisperService.CreateWhisper(userID, req.Content)
+ if err != nil {
+ status := http.StatusBadRequest
+ if err.Error() == "用户不存在" {
+ status = http.StatusUnauthorized
+ }
+ c.JSON(status, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusCreated, item)
+}
+
+// GET /api/v1/whisper
+func (h *WhisperHandler) List(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ items, err := h.whisperService.GetWhispers(userID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, items)
+}
+
+// GET /api/v1/whisper/tips
+func (h *WhisperHandler) Tips(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ tips, err := h.whisperService.GetWhisperTips(userID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, tips)
+}
diff --git a/backend/internal/model/photo.go b/backend/internal/model/photo.go
new file mode 100644
index 00000000..ceb0aee8
--- /dev/null
+++ b/backend/internal/model/photo.go
@@ -0,0 +1,31 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type Photo struct {
+ ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
+ UserID string `gorm:"type:varchar(36);index;not null" json:"-"`
+ Title string `gorm:"type:varchar(200)" json:"title"`
+ Description string `gorm:"type:text" json:"description"`
+ Tags string `gorm:"type:text" json:"tags"`
+ ImageURL string `gorm:"type:varchar(500);not null" json:"image_url"`
+ IsOnWall bool `gorm:"default:false" json:"is_on_wall"`
+ WallPosition *int `gorm:"type:int" json:"wall_position"`
+ Source string `gorm:"type:varchar(20);not null" json:"source"`
+ CreatedAt time.Time `gorm:"index" json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+
+ User User `gorm:"foreignKey:UserID" json:"-"`
+}
+
+func (p *Photo) BeforeCreate(tx *gorm.DB) error {
+ if p.ID == "" {
+ p.ID = uuid.New().String()
+ }
+ return nil
+}
diff --git a/backend/internal/model/task.go b/backend/internal/model/task.go
new file mode 100644
index 00000000..b7d111d3
--- /dev/null
+++ b/backend/internal/model/task.go
@@ -0,0 +1,76 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// TaskCategory enum
+type TaskCategory string
+
+const (
+ TaskCategoryHousework TaskCategory = "housework"
+ TaskCategoryParenting TaskCategory = "parenting"
+ TaskCategoryHealth TaskCategory = "health"
+ TaskCategoryEmotional TaskCategory = "emotional"
+)
+
+// TaskStatus enum
+type TaskStatus string
+
+const (
+ TaskPending TaskStatus = "pending"
+ TaskCompleted TaskStatus = "completed"
+ TaskVerified TaskStatus = "verified"
+)
+
+// DailyTask is a task template from which user tasks are generated.
+type DailyTask struct {
+ ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
+ Title string `gorm:"type:varchar(200);not null" json:"title"`
+ Description string `gorm:"type:text" json:"description"`
+ Category TaskCategory `gorm:"type:varchar(50);not null;index" json:"category"`
+ Difficulty int `gorm:"default:1" json:"difficulty"` // 1-5
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func (t *DailyTask) BeforeCreate(tx *gorm.DB) error {
+ if t.ID == "" {
+ t.ID = uuid.New().String()
+ }
+ return nil
+}
+
+// UserTask is a concrete task assigned to a user on a given date.
+type UserTask struct {
+ ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
+ UserID string `gorm:"type:varchar(36);not null;index" json:"user_id"`
+ TaskID string `gorm:"type:varchar(36);not null" json:"task_id"`
+
+ Date time.Time `gorm:"type:date;not null;index" json:"date"`
+ Status TaskStatus `gorm:"type:varchar(20);default:'pending'" json:"status"`
+
+ // Verification by partner
+ Score *int `gorm:"type:int" json:"score"`
+ Comment *string `gorm:"type:text" json:"comment"`
+ ScoredByID *string `gorm:"type:varchar(36)" json:"scored_by_id"`
+ ScoredAt *time.Time `json:"scored_at"`
+
+ CompletedAt *time.Time `json:"completed_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+
+ // Relationships
+ User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
+ Task DailyTask `gorm:"foreignKey:TaskID" json:"task,omitempty"`
+ ScoredBy *User `gorm:"foreignKey:ScoredByID" json:"scored_by,omitempty"`
+}
+
+func (ut *UserTask) BeforeCreate(tx *gorm.DB) error {
+ if ut.ID == "" {
+ ut.ID = uuid.New().String()
+ }
+ return nil
+}
diff --git a/backend/internal/model/whisper.go b/backend/internal/model/whisper.go
new file mode 100644
index 00000000..4804ba9e
--- /dev/null
+++ b/backend/internal/model/whisper.go
@@ -0,0 +1,25 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+type Whisper struct {
+ ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
+ AuthorID string `gorm:"type:varchar(36);index;not null" json:"author_id"`
+ Content string `gorm:"type:text;not null" json:"content"`
+ CreatedAt time.Time `gorm:"index" json:"created_at"`
+
+ // Relationships
+ Author User `gorm:"foreignKey:AuthorID" json:"author,omitempty"`
+}
+
+func (w *Whisper) BeforeCreate(tx *gorm.DB) error {
+ if w.ID == "" {
+ w.ID = uuid.New().String()
+ }
+ return nil
+}
diff --git a/backend/internal/repository/answer.go b/backend/internal/repository/answer.go
index 53553acd..a3066e48 100644
--- a/backend/internal/repository/answer.go
+++ b/backend/internal/repository/answer.go
@@ -86,6 +86,16 @@ func (r *AnswerRepo) Delete(id string) error {
return r.db.Where("id = ?", id).Delete(&model.Answer{}).Error
}
+func (r *AnswerRepo) DeleteByQuestionID(questionID string) error {
+ return r.db.Where("question_id = ?", questionID).Delete(&model.Answer{}).Error
+}
+
+func (r *AnswerRepo) FindIDsByQuestionID(questionID string) ([]string, error) {
+ var ids []string
+ err := r.db.Model(&model.Answer{}).Where("question_id = ?", questionID).Pluck("id", &ids).Error
+ return ids, err
+}
+
func (r *AnswerRepo) UpdateLikeCount(id string, delta int) error {
return r.db.Model(&model.Answer{}).Where("id = ?", id).
UpdateColumn("like_count", gorm.Expr("like_count + ?", delta)).Error
diff --git a/backend/internal/repository/comment.go b/backend/internal/repository/comment.go
index e0c50ef4..75398228 100644
--- a/backend/internal/repository/comment.go
+++ b/backend/internal/repository/comment.go
@@ -52,6 +52,10 @@ func (r *CommentRepo) DeleteByParentID(parentID string) (int64, error) {
return result.RowsAffected, result.Error
}
+func (r *CommentRepo) DeleteByAnswerID(answerID string) error {
+ return r.db.Where("answer_id = ?", answerID).Delete(&model.Comment{}).Error
+}
+
func (r *CommentRepo) UpdateLikeCount(id string, delta int) error {
return r.db.Model(&model.Comment{}).Where("id = ?", id).
UpdateColumn("like_count", gorm.Expr("like_count + ?", delta)).Error
diff --git a/backend/internal/repository/photo.go b/backend/internal/repository/photo.go
new file mode 100644
index 00000000..ae1f622f
--- /dev/null
+++ b/backend/internal/repository/photo.go
@@ -0,0 +1,103 @@
+package repository
+
+import (
+ "github.com/momshell/backend/internal/model"
+ "gorm.io/gorm"
+)
+
+type PhotoRepo struct {
+ db *gorm.DB
+}
+
+func NewPhotoRepo(db *gorm.DB) *PhotoRepo {
+ return &PhotoRepo{db: db}
+}
+
+func (r *PhotoRepo) FindByUserID(userID string, limit, offset int) ([]model.Photo, int64, error) {
+ query := r.db.Model(&model.Photo{}).Where("user_id = ?", userID)
+
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ var photos []model.Photo
+ err := query.Order("created_at desc").Offset(offset).Limit(limit).Find(&photos).Error
+ return photos, total, err
+}
+
+func (r *PhotoRepo) FindWallPhotos(userID string) ([]model.Photo, error) {
+ var photos []model.Photo
+ err := r.db.Where("user_id = ? AND is_on_wall = ?", userID, true).
+ Order("wall_position asc").Find(&photos).Error
+ return photos, err
+}
+
+func (r *PhotoRepo) FindByID(id string) (*model.Photo, error) {
+ var photo model.Photo
+ err := r.db.Where("id = ?", id).First(&photo).Error
+ if err != nil {
+ return nil, err
+ }
+ return &photo, nil
+}
+
+func (r *PhotoRepo) FindByIDAndUserID(id, userID string) (*model.Photo, error) {
+ var photo model.Photo
+ err := r.db.Where("id = ? AND user_id = ?", id, userID).First(&photo).Error
+ if err != nil {
+ return nil, err
+ }
+ return &photo, nil
+}
+
+func (r *PhotoRepo) CountByUserID(userID string) (int64, error) {
+ var count int64
+ err := r.db.Model(&model.Photo{}).Where("user_id = ?", userID).Count(&count).Error
+ return count, err
+}
+
+func (r *PhotoRepo) CountWallPhotos(userID string) (int64, error) {
+ var count int64
+ err := r.db.Model(&model.Photo{}).Where("user_id = ? AND is_on_wall = ?", userID, true).Count(&count).Error
+ return count, err
+}
+
+func (r *PhotoRepo) Create(photo *model.Photo) error {
+ return r.db.Create(photo).Error
+}
+
+func (r *PhotoRepo) Update(photo *model.Photo) error {
+ return r.db.Save(photo).Error
+}
+
+func (r *PhotoRepo) Delete(id, userID string) error {
+ return r.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.Photo{}).Error
+}
+
+func (r *PhotoRepo) BatchUpdateWall(userID string, updates []WallUpdate) error {
+ return r.db.Transaction(func(tx *gorm.DB) error {
+ // Clear all wall positions for user first
+ if err := tx.Model(&model.Photo{}).
+ Where("user_id = ? AND is_on_wall = ?", userID, true).
+ Updates(map[string]interface{}{"is_on_wall": false, "wall_position": nil}).Error; err != nil {
+ return err
+ }
+
+ // Set new wall positions
+ for _, u := range updates {
+ pos := u.Position
+ if err := tx.Model(&model.Photo{}).
+ Where("id = ? AND user_id = ?", u.PhotoID, userID).
+ Updates(map[string]interface{}{"is_on_wall": true, "wall_position": pos}).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+type WallUpdate struct {
+ PhotoID string
+ Position int
+}
diff --git a/backend/internal/repository/task.go b/backend/internal/repository/task.go
new file mode 100644
index 00000000..e73d37fe
--- /dev/null
+++ b/backend/internal/repository/task.go
@@ -0,0 +1,88 @@
+package repository
+
+import (
+ "time"
+
+ "github.com/momshell/backend/internal/model"
+ "gorm.io/gorm"
+)
+
+type TaskRepo struct {
+ db *gorm.DB
+}
+
+func NewTaskRepo(db *gorm.DB) *TaskRepo {
+ return &TaskRepo{db: db}
+}
+
+// DailyTask template operations
+
+func (r *TaskRepo) CountTemplates() (int64, error) {
+ var count int64
+ err := r.db.Model(&model.DailyTask{}).Count(&count).Error
+ return count, err
+}
+
+func (r *TaskRepo) CreateTemplate(t *model.DailyTask) error {
+ return r.db.Create(t).Error
+}
+
+func (r *TaskRepo) FindRandomTemplates(count int) ([]model.DailyTask, error) {
+ var tasks []model.DailyTask
+ err := r.db.Order("RANDOM()").Limit(count).Find(&tasks).Error
+ return tasks, err
+}
+
+// UserTask operations
+
+func (r *TaskRepo) CreateUserTask(ut *model.UserTask) error {
+ return r.db.Create(ut).Error
+}
+
+func (r *TaskRepo) FindUserTasksByDate(userID string, date time.Time) ([]model.UserTask, error) {
+ var tasks []model.UserTask
+ err := r.db.Preload("Task").
+ Where("user_id = ? AND date = ?", userID, date.Format("2006-01-02")).
+ Order("created_at asc").
+ Find(&tasks).Error
+ return tasks, err
+}
+
+func (r *TaskRepo) FindUserTaskByID(id string) (*model.UserTask, error) {
+ var task model.UserTask
+ err := r.db.Preload("Task").Preload("User").First(&task, "id = ?", id).Error
+ if err != nil {
+ return nil, err
+ }
+ return &task, nil
+}
+
+func (r *TaskRepo) UpdateUserTask(ut *model.UserTask) error {
+ return r.db.Save(ut).Error
+}
+
+// ResetUserTaskToPending clears completed_at and sets status back to pending.
+func (r *TaskRepo) ResetUserTaskToPending(ut *model.UserTask) error {
+ return r.db.Model(ut).
+ Select("status", "completed_at", "comment").
+ Updates(map[string]interface{}{
+ "status": ut.Status,
+ "completed_at": nil,
+ "comment": ut.Comment,
+ }).Error
+}
+
+func (r *TaskRepo) SumScoreByUserID(userID string) (int, error) {
+ var total *int
+ err := r.db.Model(&model.UserTask{}).
+ Where("user_id = ? AND status = ? AND score IS NOT NULL", userID, model.TaskVerified).
+ Select("COALESCE(SUM(score), 0)").
+ Scan(&total).Error
+ if err != nil {
+ return 0, err
+ }
+ if total == nil {
+ return 0, nil
+ }
+ return *total, nil
+}
diff --git a/backend/internal/repository/whisper.go b/backend/internal/repository/whisper.go
new file mode 100644
index 00000000..40219eee
--- /dev/null
+++ b/backend/internal/repository/whisper.go
@@ -0,0 +1,27 @@
+package repository
+
+import (
+ "github.com/momshell/backend/internal/model"
+ "gorm.io/gorm"
+)
+
+type WhisperRepo struct {
+ db *gorm.DB
+}
+
+func NewWhisperRepo(db *gorm.DB) *WhisperRepo {
+ return &WhisperRepo{db: db}
+}
+
+func (r *WhisperRepo) Create(w *model.Whisper) error {
+ return r.db.Create(w).Error
+}
+
+func (r *WhisperRepo) FindByAuthorID(authorID string, limit int) ([]model.Whisper, error) {
+ var whispers []model.Whisper
+ err := r.db.Where("author_id = ?", authorID).
+ Order("created_at desc").
+ Limit(limit).
+ Find(&whispers).Error
+ return whispers, err
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 07f068d3..58de2fb5 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -20,6 +20,9 @@ func Setup(
echoHandler *handler.EchoHandler,
userHandler *handler.UserHandler,
adminHandler *handler.AdminHandler,
+ photoHandler *handler.PhotoHandler,
+ whisperHandler *handler.WhisperHandler,
+ taskHandler *handler.TaskHandler,
) {
// Health check
r.GET("/health", func(c *gin.Context) {
@@ -146,6 +149,37 @@ func Setup(
echo.POST("/memoirs/:id/rate", echoHandler.RateMemoir)
}
+ // ==================== Photos ====================
+ photos := api.Group("/photos", middleware.AuthRequired(cfg))
+ {
+ photos.GET("", photoHandler.List)
+ photos.POST("/upload", photoHandler.Upload)
+ photos.POST("/generate", photoHandler.Generate)
+ photos.PUT("/wall", photoHandler.BatchUpdateWall)
+ photos.PUT("/:id", photoHandler.Update)
+ photos.DELETE("/:id", photoHandler.Delete)
+ photos.PUT("/:id/wall", photoHandler.ToggleWall)
+ }
+
+ // ==================== Whisper (Heart Words) ====================
+ whisper := api.Group("/whisper", middleware.AuthRequired(cfg))
+ {
+ whisper.POST("", whisperHandler.Create)
+ whisper.GET("", whisperHandler.List)
+ whisper.GET("/tips", whisperHandler.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)
+ }
+
// ==================== Admin ====================
adminAPI := api.Group("/admin", middleware.AdminRequired(cfg))
{
diff --git a/backend/internal/service/chat.go b/backend/internal/service/chat.go
index 58402dac..94f99746 100644
--- a/backend/internal/service/chat.go
+++ b/backend/internal/service/chat.go
@@ -6,16 +6,18 @@ import (
"fmt"
"log"
"regexp"
+ "strings"
"sync"
"github.com/google/uuid"
"github.com/momshell/backend/internal/dto"
"github.com/momshell/backend/internal/model"
"github.com/momshell/backend/internal/repository"
+ "github.com/momshell/backend/pkg/firecrawl"
"github.com/momshell/backend/pkg/openai"
)
-const companionSystemPrompt = `你是「贝壳姐姐」,一位「曾走过这段路的朋友」,专为产后恢复期女性设计的情感陪伴者。
+const companionSystemPrompt = `你是「小石光」,一位「曾走过这段路的朋友」,专为产后恢复期女性设计的情感陪伴者。
## 角色定位:Independent Woman Supporter
@@ -54,18 +56,20 @@ const companionSystemPrompt = `你是「贝壳姐姐」,一位「曾走过这
记住:你的存在不是为了「解决她的问题」,而是让她感到——在这一刻,她并不孤单。`
type ChatService struct {
- client *openai.Client
- chatRepo *repository.ChatRepo
+ client *openai.Client
+ chatRepo *repository.ChatRepo
+ firecrawl *firecrawl.Client
// In-memory storage for guest sessions
mu sync.RWMutex
guestMemory map[string][]map[string]interface{}
guestProfiles map[string]map[string]interface{}
}
-func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo) *ChatService {
+func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo, fc *firecrawl.Client) *ChatService {
return &ChatService{
client: client,
chatRepo: chatRepo,
+ firecrawl: fc,
guestMemory: make(map[string][]map[string]interface{}),
guestProfiles: make(map[string]map[string]interface{}),
}
@@ -87,6 +91,11 @@ func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage
formatTurns(turns),
)
+ webResults := s.searchWebForChat(ctx, msg.Content)
+ if webResults != "" {
+ systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n如有引用搜索内容,请自然融入回答,标注来源。不确定的信息请标明。"
+ }
+
messages := []openai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: msg.Content},
@@ -140,6 +149,11 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto.
formatTurns(turns),
)
+ webResults := s.searchWebForChat(ctx, msg.Content)
+ if webResults != "" {
+ systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n如有引用搜索内容,请自然融入回答,标注来源。不确定的信息请标明。"
+ }
+
messages := []openai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: msg.Content},
@@ -169,6 +183,32 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto.
return buildVisualResponse(parsed, memoryUpdated), nil
}
+func (s *ChatService) searchWebForChat(ctx context.Context, userMessage string) string {
+ if s.firecrawl == nil {
+ return ""
+ }
+ results, err := s.firecrawl.Search(ctx, userMessage, 3)
+ if err != nil {
+ log.Printf("[ChatService] web search failed: %v", err)
+ return ""
+ }
+ if len(results) == 0 {
+ return ""
+ }
+ var sb strings.Builder
+ for i, r := range results {
+ content := r.Markdown
+ if content == "" {
+ content = r.Description
+ }
+ if len([]rune(content)) > 300 {
+ content = string([]rune(content)[:300]) + "..."
+ }
+ fmt.Fprintf(&sb, "[来源%d] %s (%s): %s\n", i+1, r.Title, r.URL, content)
+ }
+ return sb.String()
+}
+
func (s *ChatService) GetProfile(userID string) (*dto.ChatProfile, error) {
mem, err := s.chatRepo.FindByUserID(userID)
if err != nil {
diff --git a/backend/internal/service/community.go b/backend/internal/service/community.go
index 5528b6e1..b2a6b764 100644
--- a/backend/internal/service/community.go
+++ b/backend/internal/service/community.go
@@ -372,6 +372,12 @@ func (s *CommunityService) DeleteQuestion(questionID string, user *model.User) e
}
// Clean up related data
+ answerIDs, _ := s.answerRepo.FindIDsByQuestionID(questionID)
+ for _, aid := range answerIDs {
+ _ = s.commentRepo.DeleteByAnswerID(aid)
+ _ = s.interactionRepo.DeleteLikesByTarget("answer", aid)
+ }
+ _ = s.answerRepo.DeleteByQuestionID(questionID)
_ = s.interactionRepo.DeleteLikesByTarget("question", questionID)
_ = s.interactionRepo.DeleteCollectionsByQuestion(questionID)
_ = s.tagRepo.DeleteQuestionTags(questionID)
@@ -528,6 +534,7 @@ func (s *CommunityService) DeleteAnswer(answerID string, user *model.User) error
return errors.New("无权删除此回答")
}
+ _ = s.commentRepo.DeleteByAnswerID(answerID)
_ = s.interactionRepo.DeleteLikesByTarget("answer", answerID)
_ = s.questionRepo.DecrementAnswerCount(a.QuestionID)
diff --git a/backend/internal/service/community_ai.go b/backend/internal/service/community_ai.go
new file mode 100644
index 00000000..815f7f15
--- /dev/null
+++ b/backend/internal/service/community_ai.go
@@ -0,0 +1,374 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/momshell/backend/internal/model"
+ "github.com/momshell/backend/internal/repository"
+ "github.com/momshell/backend/pkg/firecrawl"
+ "github.com/momshell/backend/pkg/openai"
+)
+
+var mentionPattern = regexp.MustCompile(`@小石光`)
+
+type CommunityAIService struct {
+ client *openai.Client
+ firecrawl *firecrawl.Client
+ questionRepo *repository.QuestionRepo
+ answerRepo *repository.AnswerRepo
+ commentRepo *repository.CommentRepo
+ aiUserID string
+}
+
+func NewCommunityAIService(
+ client *openai.Client,
+ fc *firecrawl.Client,
+ questionRepo *repository.QuestionRepo,
+ answerRepo *repository.AnswerRepo,
+ commentRepo *repository.CommentRepo,
+ aiUserID string,
+) *CommunityAIService {
+ return &CommunityAIService{
+ client: client,
+ firecrawl: fc,
+ questionRepo: questionRepo,
+ answerRepo: answerRepo,
+ commentRepo: commentRepo,
+ aiUserID: aiUserID,
+ }
+}
+
+// ContainsMention checks if content mentions @小石光.
+func ContainsMention(content string) bool {
+ return mentionPattern.MatchString(content)
+}
+
+// ShouldReplyToComment returns true if AI should reply to this comment:
+// - comment mentions @小石光, OR
+// - comment is on an AI-authored answer, OR
+// - comment is a reply to an AI-authored comment.
+func (s *CommunityAIService) ShouldReplyToComment(content, answerID string, parentID *string) bool {
+ if ContainsMention(content) {
+ return true
+ }
+ answer, err := s.answerRepo.FindByID(answerID)
+ if err == nil && answer.AuthorID == s.aiUserID {
+ return true
+ }
+ if parentID != nil {
+ parent, err := s.commentRepo.FindByID(*parentID)
+ if err == nil && parent.AuthorID == s.aiUserID {
+ return true
+ }
+ }
+ return false
+}
+
+// HandleNewQuestion generates an AI answer for a question that mentions @小石光.
+func (s *CommunityAIService) HandleNewQuestion(questionID string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ q, err := s.questionRepo.FindByID(questionID)
+ if err != nil {
+ log.Printf("[CommunityAI] failed to find question %s: %v", questionID, err)
+ return
+ }
+
+ threadCtx := fmt.Sprintf("帖子标题:%s\n帖子内容:%s", q.Title, q.Content)
+ searchCtx, sources := s.searchWeb(ctx, q.Title)
+
+ reply, err := s.generateReply(ctx, threadCtx, searchCtx, sources)
+ if err != nil {
+ log.Printf("[CommunityAI] failed to generate reply for question %s: %v", questionID, err)
+ return
+ }
+
+ answer := &model.Answer{
+ QuestionID: questionID,
+ AuthorID: s.aiUserID,
+ Content: reply,
+ AuthorRole: model.RoleAIAssistant,
+ IsProfessional: false,
+ Status: model.StatusPublished,
+ }
+
+ if err := s.answerRepo.Create(answer); err != nil {
+ log.Printf("[CommunityAI] failed to create answer for question %s: %v", questionID, err)
+ return
+ }
+
+ _ = s.questionRepo.IncrementAnswerCount(questionID)
+ log.Printf("[CommunityAI] AI answered question %s", questionID)
+}
+
+// HandleNewAnswer generates an AI comment on an answer that mentions @小石光.
+func (s *CommunityAIService) HandleNewAnswer(questionID, answerID string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ q, err := s.questionRepo.FindByID(questionID)
+ if err != nil {
+ log.Printf("[CommunityAI] failed to find question %s: %v", questionID, err)
+ return
+ }
+
+ answer, err := s.answerRepo.FindByID(answerID)
+ if err != nil {
+ log.Printf("[CommunityAI] failed to find answer %s: %v", answerID, err)
+ return
+ }
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "帖子标题:%s\n帖子内容:%s\n\n", q.Title, q.Content)
+ fmt.Fprintf(&sb, "用户回答:%s", answer.Content)
+
+ searchCtx, sources := s.searchWeb(ctx, q.Title)
+
+ reply, err := s.generateReply(ctx, sb.String(), searchCtx, sources)
+ if err != nil {
+ log.Printf("[CommunityAI] failed to generate reply for answer %s: %v", answerID, err)
+ return
+ }
+
+ comment := &model.Comment{
+ AnswerID: answerID,
+ AuthorID: s.aiUserID,
+ ReplyToUserID: &answer.AuthorID,
+ Content: reply,
+ Status: model.StatusPublished,
+ }
+
+ if err := s.commentRepo.Create(comment); err != nil {
+ log.Printf("[CommunityAI] failed to create comment on answer %s: %v", answerID, err)
+ return
+ }
+
+ _ = s.answerRepo.IncrementCommentCount(answerID)
+ log.Printf("[CommunityAI] AI commented on answer %s", answerID)
+}
+
+// HandleNewComment generates an AI comment reply when a comment mentions @小石光.
+func (s *CommunityAIService) HandleNewComment(answerID, commentID string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ answer, err := s.answerRepo.FindByID(answerID)
+ if err != nil {
+ log.Printf("[CommunityAI] failed to find answer %s: %v", answerID, err)
+ return
+ }
+
+ q, err := s.questionRepo.FindByID(answer.QuestionID)
+ if err != nil {
+ log.Printf("[CommunityAI] failed to find question %s: %v", answer.QuestionID, err)
+ return
+ }
+
+ comments, err := s.commentRepo.FindByAnswerID(answerID)
+ if err != nil {
+ log.Printf("[CommunityAI] failed to find comments for answer %s: %v", answerID, err)
+ return
+ }
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "帖子标题:%s\n帖子内容:%s\n\n", q.Title, q.Content)
+ fmt.Fprintf(&sb, "当前回答:%s\n\n评论区对话:\n", answer.Content)
+ for _, c := range comments {
+ role := "用户"
+ if c.AuthorID == s.aiUserID {
+ role = "小石光"
+ }
+ fmt.Fprintf(&sb, "%s:%s\n", role, c.Content)
+ }
+
+ searchCtx, sources := s.searchWeb(ctx, q.Title)
+
+ reply, err := s.generateReply(ctx, sb.String(), searchCtx, sources)
+ if err != nil {
+ log.Printf("[CommunityAI] failed to generate comment reply: %v", err)
+ return
+ }
+
+ triggerComment, err := s.commentRepo.FindByID(commentID)
+ if err != nil {
+ log.Printf("[CommunityAI] failed to find trigger comment %s: %v", commentID, err)
+ return
+ }
+
+ comment := &model.Comment{
+ AnswerID: answerID,
+ AuthorID: s.aiUserID,
+ ParentID: &commentID,
+ ReplyToUserID: &triggerComment.AuthorID,
+ Content: reply,
+ Status: model.StatusPublished,
+ }
+
+ if err := s.commentRepo.Create(comment); err != nil {
+ log.Printf("[CommunityAI] failed to create comment reply: %v", err)
+ return
+ }
+
+ _ = s.answerRepo.IncrementCommentCount(answerID)
+ log.Printf("[CommunityAI] AI replied to comment %s on answer %s", commentID, answerID)
+}
+
+type sourceRef struct {
+ index int
+ title string
+ url string
+}
+
+func (s *CommunityAIService) searchWeb(ctx context.Context, query string) (string, []sourceRef) {
+ if s.firecrawl == nil {
+ return "", nil
+ }
+
+ results, err := s.firecrawl.Search(ctx, query, 3)
+ if err != nil {
+ log.Printf("[CommunityAI] web search failed: %v", err)
+ return "", nil
+ }
+ if len(results) == 0 {
+ return "", nil
+ }
+
+ var sources []sourceRef
+ var sb strings.Builder
+ for i, r := range results {
+ content := r.Markdown
+ if content == "" {
+ content = r.Description
+ }
+ if len([]rune(content)) > 500 {
+ content = string([]rune(content)[:500]) + "..."
+ }
+ fmt.Fprintf(&sb, "[来源%d] %s\n链接:%s\n内容:%s\n\n", i+1, r.Title, r.URL, content)
+ sources = append(sources, sourceRef{index: i + 1, title: r.Title, url: r.URL})
+ }
+ return sb.String(), sources
+}
+
+const communityAISystemPrompt = `你是「小石光」,一位温柔且专业的产后恢复陪伴者,正在社区帖子中回复用户的提问。
+
+## 角色定位
+你面对的每一位用户,首先是一个完整的「独立女性」,其次才是新手妈妈。你用温暖、真诚、专业的语气回应她们的问题和困惑。
+
+## 回复原则
+1. **认可与共情优先**:先看见和认可她的感受,再提供建议
+2. **拒绝说教**:用「我了解到...」「有研究表明...」「或许可以试试...」的方式分享
+3. **保护主体性**:提醒她有权利为自己发声,可以寻求帮助
+4. **避免有毒正能量**:承认困难的真实性
+
+## 帖子上下文
+%s
+
+## 联网搜索结果
+%s
+
+## 防幻觉规则(严格遵守)
+1. 只基于上述帖子上下文和搜索结果回答
+2. 仅在提供事实性信息时引用来源(如医学知识、研究数据、具体机构推荐),日常共情和鼓励不需要引用
+3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业医生/心理咨询师」
+4. 绝不编造医疗数据、药物剂量、具体治疗方案
+5. 涉及医疗问题时,始终建议咨询专业医生
+
+## 回复要求
+- 用纯文本回复,不要使用 JSON 格式
+- 语气温暖自然,像朋友聊天
+- 1-3段话即可,不要过长
+- 不要重复用户说过的话
+- 移除@提及,直接回复内容`
+
+func (s *CommunityAIService) generateReply(ctx context.Context, threadContext, searchContext string, sources []sourceRef) (string, error) {
+ if searchContext == "" {
+ searchContext = "(无搜索结果)"
+ }
+
+ systemPrompt := fmt.Sprintf(communityAISystemPrompt, threadContext, searchContext)
+
+ messages := []openai.Message{
+ {Role: "system", Content: systemPrompt},
+ {Role: "user", Content: "请根据帖子上下文,给出一个温暖、有帮助的回复。"},
+ }
+
+ reply, err := s.client.Chat(ctx, messages)
+ if err != nil {
+ return "", fmt.Errorf("AI 服务调用失败: %w", err)
+ }
+
+ reply = mentionPattern.ReplaceAllString(reply, "")
+ reply = strings.TrimSpace(reply)
+
+ if reply == "" {
+ reply = "谢谢你的分享。如果需要更多帮助,随时可以找我聊聊。"
+ }
+
+ // Append footnotes for any cited sources
+ reply = appendSourceFootnotes(reply, sources)
+
+ return reply, nil
+}
+
+var sourceRefPattern = regexp.MustCompile(`\[来源(\d+)\]`)
+
+func appendSourceFootnotes(reply string, sources []sourceRef) string {
+ matches := sourceRefPattern.FindAllStringSubmatch(reply, -1)
+ if len(matches) == 0 || len(sources) == 0 {
+ return reply
+ }
+
+ // Collect cited indices in order of first appearance
+ seen := make(map[int]bool)
+ var citedOrder []int
+ for _, m := range matches {
+ var idx int
+ if _, err := fmt.Sscanf(m[1], "%d", &idx); err == nil && !seen[idx] {
+ seen[idx] = true
+ citedOrder = append(citedOrder, idx)
+ }
+ }
+
+ // Build renumber map: old index -> new sequential index
+ renumber := make(map[int]int)
+ for newIdx, oldIdx := range citedOrder {
+ renumber[oldIdx] = newIdx + 1
+ }
+
+ // Replace [来源N] with renumbered [来源M] in reply text
+ replaced := sourceRefPattern.ReplaceAllStringFunc(reply, func(match string) string {
+ sub := sourceRefPattern.FindStringSubmatch(match)
+ var oldIdx int
+ if _, err := fmt.Sscanf(sub[1], "%d", &oldIdx); err == nil {
+ if newIdx, ok := renumber[oldIdx]; ok {
+ return fmt.Sprintf("[来源%d]", newIdx)
+ }
+ }
+ return match
+ })
+
+ // Build source index map for quick lookup
+ sourceMap := make(map[int]sourceRef)
+ for _, src := range sources {
+ sourceMap[src.index] = src
+ }
+
+ // Append footnotes with renumbered indices
+ var footnotes strings.Builder
+ for _, oldIdx := range citedOrder {
+ if src, ok := sourceMap[oldIdx]; ok {
+ fmt.Fprintf(&footnotes, "\n[来源%d] %s %s", renumber[oldIdx], src.title, src.url)
+ }
+ }
+
+ if footnotes.Len() > 0 {
+ replaced += "\n" + footnotes.String()
+ }
+ return replaced
+}
diff --git a/backend/internal/service/echo.go b/backend/internal/service/echo.go
index 33e9d0f7..074ab35e 100644
--- a/backend/internal/service/echo.go
+++ b/backend/internal/service/echo.go
@@ -217,28 +217,117 @@ func buildMemoirSystemPrompt(tags []model.IdentityTag, theme *string) string {
func parseMemoirLLMResponse(content string) map[string]interface{} {
var result map[string]interface{}
+ // Strip Qwen3's ... blocks
+ thinkRe := regexp.MustCompile(`(?s).*?`)
+ content = strings.TrimSpace(thinkRe.ReplaceAllString(content, ""))
+
+ // Try direct JSON parse
if err := json.Unmarshal([]byte(content), &result); err == nil {
- return result
+ return cleanParsedMemoir(result)
}
+ // Try extracting from ```json ... ``` code block
re := regexp.MustCompile("(?s)```json\\s*(.*?)\\s*```")
if matches := re.FindStringSubmatch(content); len(matches) > 1 {
if err := json.Unmarshal([]byte(matches[1]), &result); err == nil {
- return result
+ return cleanParsedMemoir(result)
}
}
+ // Try extracting any JSON object (greedy)
re2 := regexp.MustCompile(`(?s)\{.*\}`)
if match := re2.FindString(content); match != "" {
if err := json.Unmarshal([]byte(match), &result); err == nil {
- return result
+ return cleanParsedMemoir(result)
+ }
+ }
+
+ // Handle truncated ```json block (no closing ```) - extract everything after ```json
+ re3 := regexp.MustCompile("(?s)```json\\s*(.*)")
+ if matches := re3.FindStringSubmatch(content); len(matches) > 1 {
+ inner := strings.TrimSpace(matches[1])
+ inner = strings.TrimSuffix(inner, "```")
+ if err := json.Unmarshal([]byte(inner), &result); err == nil {
+ return cleanParsedMemoir(result)
+ }
+ // Try to find JSON object inside
+ if match := re2.FindString(inner); match != "" {
+ if err := json.Unmarshal([]byte(match), &result); err == nil {
+ return cleanParsedMemoir(result)
+ }
+ }
+ }
+
+ // Last resort: try to extract title and content with regex from malformed JSON
+ titleRe := regexp.MustCompile(`"title"\s*:\s*"((?:[^"\\]|\\.)*)`)
+ contentRe := regexp.MustCompile(`"content"\s*:\s*"((?:[^"\\]|\\.)*)`)
+ titleMatch := titleRe.FindStringSubmatch(content)
+ contentMatch := contentRe.FindStringSubmatch(content)
+ if titleMatch != nil || contentMatch != nil {
+ title := "一段温柔的回响"
+ body := ""
+ if titleMatch != nil {
+ title = titleMatch[1]
+ }
+ if contentMatch != nil {
+ body = contentMatch[1]
+ // Unescape common JSON escapes
+ body = strings.ReplaceAll(body, `\n`, "\n")
+ body = strings.ReplaceAll(body, `\"`, `"`)
+ body = strings.ReplaceAll(body, `\\`, `\`)
+ }
+ return map[string]interface{}{
+ "title": cleanMemoirText(title),
+ "content": cleanMemoirText(body),
}
}
return map[string]interface{}{
"title": "一段温柔的回响",
- "content": strings.TrimSpace(content),
+ "content": cleanMemoirText(strings.TrimSpace(content)),
+ }
+}
+
+// cleanMemoirText strips leftover JSON/markdown artifacts from LLM output.
+func cleanMemoirText(s string) string {
+ s = strings.TrimSpace(s)
+ // Remove trailing markdown code fences and JSON braces
+ s = strings.TrimRight(s, " \t\n\r")
+ for {
+ trimmed := s
+ trimmed = strings.TrimSuffix(trimmed, "```")
+ trimmed = strings.TrimSuffix(trimmed, "}")
+ trimmed = strings.TrimSuffix(trimmed, `"`)
+ trimmed = strings.TrimRight(trimmed, " \t\n\r")
+ if trimmed == s {
+ break
+ }
+ s = trimmed
+ }
+ // Remove leading markdown code fences
+ for {
+ trimmed := s
+ trimmed = strings.TrimPrefix(trimmed, "```json")
+ trimmed = strings.TrimPrefix(trimmed, "```")
+ trimmed = strings.TrimPrefix(trimmed, "{")
+ trimmed = strings.TrimLeft(trimmed, " \t\n\r")
+ if trimmed == s {
+ break
+ }
+ s = trimmed
+ }
+ return strings.TrimSpace(s)
+}
+
+// cleanParsedMemoir cleans title and content fields in a successfully parsed JSON map.
+func cleanParsedMemoir(m map[string]interface{}) map[string]interface{} {
+ if t, ok := m["title"].(string); ok {
+ m["title"] = cleanMemoirText(t)
+ }
+ if c, ok := m["content"].(string); ok {
+ m["content"] = cleanMemoirText(c)
}
+ return m
}
func generateMemoirCoverDataURI(title string, theme *string) string {
diff --git a/backend/internal/service/photo.go b/backend/internal/service/photo.go
new file mode 100644
index 00000000..f3b995e1
--- /dev/null
+++ b/backend/internal/service/photo.go
@@ -0,0 +1,335 @@
+package service
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/momshell/backend/internal/dto"
+ "github.com/momshell/backend/internal/model"
+ "github.com/momshell/backend/internal/repository"
+ "github.com/momshell/backend/pkg/openai"
+)
+
+const (
+ maxPhotosPerUser = 50
+ maxWallPhotos = 9
+)
+
+type PhotoService struct {
+ photoRepo *repository.PhotoRepo
+ aiClient *openai.Client
+ imageModel string
+}
+
+func NewPhotoService(photoRepo *repository.PhotoRepo, aiClient *openai.Client, imageModel string) *PhotoService {
+ return &PhotoService{
+ photoRepo: photoRepo,
+ aiClient: aiClient,
+ imageModel: imageModel,
+ }
+}
+
+func (s *PhotoService) ListPhotos(userID string, page, pageSize int) (*dto.PhotoListResponse, error) {
+ if page < 1 {
+ page = 1
+ }
+ if pageSize < 1 || pageSize > 50 {
+ pageSize = 20
+ }
+ offset := (page - 1) * pageSize
+
+ photos, total, err := s.photoRepo.FindByUserID(userID, pageSize, offset)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list photos: %w", err)
+ }
+
+ totalPages := int(math.Ceil(float64(total) / float64(pageSize)))
+
+ items := make([]dto.PhotoResponse, 0, len(photos))
+ for _, p := range photos {
+ items = append(items, toPhotoResponse(p))
+ }
+
+ return &dto.PhotoListResponse{
+ Photos: items,
+ Total: total,
+ Page: page,
+ PageSize: pageSize,
+ TotalPages: totalPages,
+ }, nil
+}
+
+func (s *PhotoService) GetPhoto(id, userID string) (*dto.PhotoResponse, error) {
+ photo, err := s.photoRepo.FindByIDAndUserID(id, userID)
+ if err != nil {
+ return nil, fmt.Errorf("photo not found")
+ }
+ resp := toPhotoResponse(*photo)
+ return &resp, nil
+}
+
+func (s *PhotoService) CreateFromUpload(userID, title, imageURL string) (*dto.PhotoResponse, error) {
+ count, err := s.photoRepo.CountByUserID(userID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to check photo count: %w", err)
+ }
+ if count >= maxPhotosPerUser {
+ return nil, fmt.Errorf("photo limit reached (max %d)", maxPhotosPerUser)
+ }
+
+ photo := &model.Photo{
+ UserID: userID,
+ Title: title,
+ ImageURL: imageURL,
+ Source: "upload",
+ }
+
+ if err := s.photoRepo.Create(photo); err != nil {
+ return nil, fmt.Errorf("failed to create photo: %w", err)
+ }
+
+ resp := toPhotoResponse(*photo)
+ return &resp, nil
+}
+
+func (s *PhotoService) GeneratePhoto(ctx context.Context, userID string, req dto.GeneratePhotoRequest) (*dto.PhotoResponse, error) {
+ if s.imageModel == "" {
+ return nil, fmt.Errorf("image generation is not configured")
+ }
+
+ count, err := s.photoRepo.CountByUserID(userID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to check photo count: %w", err)
+ }
+ if count >= maxPhotosPerUser {
+ return nil, fmt.Errorf("photo limit reached (max %d)", maxPhotosPerUser)
+ }
+
+ imgResp, err := s.aiClient.GenerateImage(ctx, s.imageModel, req.Prompt)
+ if err != nil {
+ return nil, fmt.Errorf("image generation failed: %w", err)
+ }
+
+ imageURL, err := s.downloadGeneratedImage(imgResp)
+ if err != nil {
+ return nil, fmt.Errorf("failed to save generated image: %w", err)
+ }
+
+ photo := &model.Photo{
+ UserID: userID,
+ Title: truncate(req.Prompt, 200),
+ Description: req.Prompt,
+ ImageURL: imageURL,
+ Source: "ai_generated",
+ }
+
+ if err := s.photoRepo.Create(photo); err != nil {
+ return nil, fmt.Errorf("failed to create photo: %w", err)
+ }
+
+ resp := toPhotoResponse(*photo)
+ return &resp, nil
+}
+
+func (s *PhotoService) UpdatePhoto(id, userID string, req dto.UpdatePhotoRequest) (*dto.PhotoResponse, error) {
+ photo, err := s.photoRepo.FindByIDAndUserID(id, userID)
+ if err != nil {
+ return nil, fmt.Errorf("photo not found")
+ }
+
+ if req.Title != nil {
+ photo.Title = *req.Title
+ }
+ if req.Description != nil {
+ photo.Description = *req.Description
+ }
+ if req.Tags != nil {
+ tagsJSON, jsonErr := json.Marshal(req.Tags)
+ if jsonErr != nil {
+ return nil, fmt.Errorf("failed to encode tags: %w", jsonErr)
+ }
+ photo.Tags = string(tagsJSON)
+ }
+
+ if err := s.photoRepo.Update(photo); err != nil {
+ return nil, fmt.Errorf("failed to update photo: %w", err)
+ }
+
+ resp := toPhotoResponse(*photo)
+ return &resp, nil
+}
+
+func (s *PhotoService) DeletePhoto(id, userID string) error {
+ photo, err := s.photoRepo.FindByIDAndUserID(id, userID)
+ if err != nil {
+ return fmt.Errorf("photo not found")
+ }
+
+ // Remove file from disk if it's a local upload
+ if photo.ImageURL != "" {
+ localPath := "." + photo.ImageURL
+ _ = os.Remove(localPath)
+ }
+
+ return s.photoRepo.Delete(id, userID)
+}
+
+func (s *PhotoService) ToggleWall(id, userID string, req dto.ToggleWallRequest) (*dto.PhotoResponse, error) {
+ photo, err := s.photoRepo.FindByIDAndUserID(id, userID)
+ if err != nil {
+ return nil, fmt.Errorf("photo not found")
+ }
+
+ if req.IsOnWall && !photo.IsOnWall {
+ wallCount, countErr := s.photoRepo.CountWallPhotos(userID)
+ if countErr != nil {
+ return nil, fmt.Errorf("failed to check wall count: %w", countErr)
+ }
+ if wallCount >= maxWallPhotos {
+ return nil, fmt.Errorf("wall is full (max %d photos)", maxWallPhotos)
+ }
+ }
+
+ photo.IsOnWall = req.IsOnWall
+ photo.WallPosition = req.WallPosition
+ if !req.IsOnWall {
+ photo.WallPosition = nil
+ }
+
+ if err := s.photoRepo.Update(photo); err != nil {
+ return nil, fmt.Errorf("failed to update photo: %w", err)
+ }
+
+ resp := toPhotoResponse(*photo)
+ return &resp, nil
+}
+
+func (s *PhotoService) BatchUpdateWall(userID string, req dto.BatchWallUpdateRequest) ([]dto.PhotoResponse, error) {
+ if len(req.Photos) > maxWallPhotos {
+ return nil, fmt.Errorf("too many wall photos (max %d)", maxWallPhotos)
+ }
+
+ updates := make([]repository.WallUpdate, 0, len(req.Photos))
+ for _, item := range req.Photos {
+ updates = append(updates, repository.WallUpdate{
+ PhotoID: item.PhotoID,
+ Position: item.Position,
+ })
+ }
+
+ if err := s.photoRepo.BatchUpdateWall(userID, updates); err != nil {
+ return nil, fmt.Errorf("failed to update wall: %w", err)
+ }
+
+ wallPhotos, err := s.photoRepo.FindWallPhotos(userID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch wall photos: %w", err)
+ }
+
+ results := make([]dto.PhotoResponse, 0, len(wallPhotos))
+ for _, p := range wallPhotos {
+ results = append(results, toPhotoResponse(p))
+ }
+ return results, nil
+}
+
+func (s *PhotoService) downloadGeneratedImage(imgResp *openai.ImageResponse) (string, error) {
+ uploadDir := "uploads/photos"
+ if err := os.MkdirAll(uploadDir, 0o755); err != nil {
+ return "", fmt.Errorf("failed to create upload dir: %w", err)
+ }
+
+ filename := fmt.Sprintf("%s_%d.png", uuid.New().String(), time.Now().Unix())
+ savePath := filepath.Join(uploadDir, filename)
+
+ data := imgResp.Data[0]
+
+ if data.URL != "" {
+ return s.downloadFromURL(data.URL, savePath)
+ }
+
+ if data.B64JSON != "" {
+ decoded, err := base64.StdEncoding.DecodeString(data.B64JSON)
+ if err != nil {
+ return "", fmt.Errorf("failed to decode base64 image: %w", err)
+ }
+ if err := os.WriteFile(savePath, decoded, 0o644); err != nil {
+ return "", fmt.Errorf("failed to write image file: %w", err)
+ }
+ return "/uploads/photos/" + filename, nil
+ }
+
+ return "", fmt.Errorf("no image data in response")
+}
+
+func (s *PhotoService) downloadFromURL(imageURL, savePath string) (string, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create download request: %w", err)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to download image: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("download failed with status: %d", resp.StatusCode)
+ }
+
+ out, err := os.Create(savePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to create file: %w", err)
+ }
+ defer func() { _ = out.Close() }()
+
+ if _, err := io.Copy(out, resp.Body); err != nil {
+ return "", fmt.Errorf("failed to write file: %w", err)
+ }
+
+ return "/uploads/photos/" + filepath.Base(savePath), nil
+}
+
+func toPhotoResponse(p model.Photo) dto.PhotoResponse {
+ var tags []string
+ if p.Tags != "" {
+ _ = json.Unmarshal([]byte(p.Tags), &tags)
+ }
+ if tags == nil {
+ tags = []string{}
+ }
+
+ return dto.PhotoResponse{
+ ID: p.ID,
+ Title: p.Title,
+ Description: p.Description,
+ Tags: tags,
+ ImageURL: p.ImageURL,
+ IsOnWall: p.IsOnWall,
+ WallPosition: p.WallPosition,
+ Source: p.Source,
+ CreatedAt: p.CreatedAt.Format(time.RFC3339),
+ UpdatedAt: p.UpdatedAt.Format(time.RFC3339),
+ }
+}
+
+func truncate(s string, maxLen int) string {
+ runes := []rune(s)
+ if len(runes) > maxLen {
+ return string(runes[:maxLen])
+ }
+ return s
+}
diff --git a/backend/internal/service/task.go b/backend/internal/service/task.go
new file mode 100644
index 00000000..46c84273
--- /dev/null
+++ b/backend/internal/service/task.go
@@ -0,0 +1,270 @@
+package service
+
+import (
+ "fmt"
+ "math"
+ "time"
+
+ "github.com/momshell/backend/internal/dto"
+ "github.com/momshell/backend/internal/model"
+ "github.com/momshell/backend/internal/repository"
+)
+
+type TaskService struct {
+ taskRepo *repository.TaskRepo
+ userRepo *repository.UserRepo
+}
+
+func NewTaskService(taskRepo *repository.TaskRepo, userRepo *repository.UserRepo) *TaskService {
+ return &TaskService{taskRepo: taskRepo, userRepo: userRepo}
+}
+
+func today() time.Time {
+ now := time.Now()
+ return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+}
+
+// GetDailyTasks returns today's tasks for a Dad user, lazily creating them if needed.
+func (s *TaskService) GetDailyTasks(userID string) ([]dto.UserTaskItem, error) {
+ user, err := s.userRepo.FindByID(userID)
+ if err != nil {
+ return nil, fmt.Errorf("用户不存在")
+ }
+ if user.Role != model.RoleDad {
+ return nil, fmt.Errorf("只有爸爸角色可以查看任务")
+ }
+ if user.PartnerID == nil {
+ return nil, fmt.Errorf("请先完成伴侣绑定")
+ }
+
+ date := today()
+ tasks, err := s.taskRepo.FindUserTasksByDate(userID, date)
+ if err != nil {
+ return nil, err
+ }
+
+ // Lazy initialization: assign tasks for today
+ if len(tasks) == 0 {
+ templates, err := s.taskRepo.FindRandomTemplates(4)
+ if err != nil || len(templates) == 0 {
+ return []dto.UserTaskItem{}, nil
+ }
+ for _, tmpl := range templates {
+ ut := &model.UserTask{
+ UserID: userID,
+ TaskID: tmpl.ID,
+ Date: date,
+ Status: model.TaskPending,
+ }
+ if err := s.taskRepo.CreateUserTask(ut); err != nil {
+ continue
+ }
+ }
+ tasks, err = s.taskRepo.FindUserTasksByDate(userID, date)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return toTaskItems(tasks), nil
+}
+
+// CompleteTask marks a task as completed by the Dad user.
+func (s *TaskService) CompleteTask(userID, taskID string) (*dto.UserTaskItem, error) {
+ ut, err := s.taskRepo.FindUserTaskByID(taskID)
+ if err != nil {
+ return nil, fmt.Errorf("任务不存在")
+ }
+ if ut.UserID != userID {
+ return nil, fmt.Errorf("无权操作此任务")
+ }
+ if ut.Status != model.TaskPending {
+ return nil, fmt.Errorf("任务已完成或已验收")
+ }
+
+ now := time.Now()
+ ut.Status = model.TaskCompleted
+ ut.CompletedAt = &now
+
+ if err := s.taskRepo.UpdateUserTask(ut); err != nil {
+ return nil, err
+ }
+
+ item := toTaskItem(*ut)
+ return &item, nil
+}
+
+// GetPartnerTasks returns the partner (Dad)'s tasks for Mom to review.
+func (s *TaskService) GetPartnerTasks(callerID string) ([]dto.UserTaskItem, error) {
+ user, err := s.userRepo.FindByID(callerID)
+ if err != nil {
+ return nil, fmt.Errorf("用户不存在")
+ }
+ if user.Role != model.RoleMom {
+ return nil, fmt.Errorf("只有妈妈角色可以查看伴侣任务")
+ }
+ if user.PartnerID == nil {
+ return nil, fmt.Errorf("请先完成伴侣绑定")
+ }
+
+ date := today()
+ tasks, err := s.taskRepo.FindUserTasksByDate(*user.PartnerID, date)
+ if err != nil {
+ return nil, err
+ }
+
+ // Lazily assign tasks for partner if none exist today
+ if len(tasks) == 0 {
+ templates, err := s.taskRepo.FindRandomTemplates(4)
+ if err != nil || len(templates) == 0 {
+ return []dto.UserTaskItem{}, nil
+ }
+ for _, tmpl := range templates {
+ ut := &model.UserTask{
+ UserID: *user.PartnerID,
+ TaskID: tmpl.ID,
+ Date: date,
+ Status: model.TaskPending,
+ }
+ if err := s.taskRepo.CreateUserTask(ut); err != nil {
+ continue
+ }
+ }
+ tasks, err = s.taskRepo.FindUserTasksByDate(*user.PartnerID, date)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return toTaskItems(tasks), nil
+}
+
+// ScoreTask allows Mom to verify and score a completed task.
+func (s *TaskService) ScoreTask(callerID, taskID string, req dto.TaskScore) (*dto.UserTaskItem, error) {
+ caller, err := s.userRepo.FindByID(callerID)
+ if err != nil {
+ return nil, fmt.Errorf("用户不存在")
+ }
+ if caller.Role != model.RoleMom {
+ return nil, fmt.Errorf("只有妈妈角色可以验收任务")
+ }
+ if caller.PartnerID == nil {
+ return nil, fmt.Errorf("请先完成伴侣绑定")
+ }
+
+ ut, err := s.taskRepo.FindUserTaskByID(taskID)
+ if err != nil {
+ return nil, fmt.Errorf("任务不存在")
+ }
+ if ut.UserID != *caller.PartnerID {
+ return nil, fmt.Errorf("只能验收伴侣的任务")
+ }
+ if ut.Status != model.TaskCompleted {
+ return nil, fmt.Errorf("只能验收已完成的任务")
+ }
+
+ now := time.Now()
+ ut.Status = model.TaskVerified
+ ut.Score = &req.Score
+ if req.Comment != "" {
+ ut.Comment = &req.Comment
+ }
+ ut.ScoredByID = &callerID
+ ut.ScoredAt = &now
+
+ if err := s.taskRepo.UpdateUserTask(ut); err != nil {
+ return nil, err
+ }
+
+ item := toTaskItem(*ut)
+ return &item, nil
+}
+
+// RejectTask allows Mom to reject a completed task back to pending.
+func (s *TaskService) RejectTask(callerID, taskID string, comment string) (*dto.UserTaskItem, error) {
+ caller, err := s.userRepo.FindByID(callerID)
+ if err != nil {
+ return nil, fmt.Errorf("用户不存在")
+ }
+ if caller.Role != model.RoleMom {
+ return nil, fmt.Errorf("只有妈妈角色可以验收任务")
+ }
+ if caller.PartnerID == nil {
+ return nil, fmt.Errorf("请先完成伴侣绑定")
+ }
+
+ ut, err := s.taskRepo.FindUserTaskByID(taskID)
+ if err != nil {
+ return nil, fmt.Errorf("任务不存在")
+ }
+ if ut.UserID != *caller.PartnerID {
+ return nil, fmt.Errorf("只能验收伴侣的任务")
+ }
+ if ut.Status != model.TaskCompleted {
+ return nil, fmt.Errorf("只能驳回已完成的任务")
+ }
+
+ ut.Status = model.TaskPending
+ ut.CompletedAt = nil
+ if comment != "" {
+ ut.Comment = &comment
+ }
+
+ if err := s.taskRepo.ResetUserTaskToPending(ut); err != nil {
+ return nil, err
+ }
+
+ item := toTaskItem(*ut)
+ return &item, nil
+}
+
+// GetTaskStats returns XP and level for a user (Dad).
+func (s *TaskService) GetTaskStats(userID string) (*dto.TaskStats, error) {
+ user, err := s.userRepo.FindByID(userID)
+ if err != nil {
+ return nil, fmt.Errorf("用户不存在")
+ }
+ if user.PartnerID == nil {
+ return &dto.TaskStats{XP: 0, Level: 1}, nil
+ }
+
+ // For Mom, show partner's stats
+ targetID := userID
+ if user.Role == model.RoleMom {
+ targetID = *user.PartnerID
+ }
+
+ xp, err := s.taskRepo.SumScoreByUserID(targetID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Level formula: level = floor(sqrt(xp / 10)) + 1
+ level := int(math.Floor(math.Sqrt(float64(xp)/10))) + 1
+
+ return &dto.TaskStats{XP: xp, Level: level}, nil
+}
+
+func toTaskItem(ut model.UserTask) dto.UserTaskItem {
+ return dto.UserTaskItem{
+ ID: ut.ID,
+ Title: ut.Task.Title,
+ Description: ut.Task.Description,
+ Category: string(ut.Task.Category),
+ Difficulty: ut.Task.Difficulty,
+ Status: string(ut.Status),
+ Score: ut.Score,
+ Comment: ut.Comment,
+ CompletedAt: ut.CompletedAt,
+ ScoredAt: ut.ScoredAt,
+ Date: ut.Date,
+ }
+}
+
+func toTaskItems(tasks []model.UserTask) []dto.UserTaskItem {
+ items := make([]dto.UserTaskItem, len(tasks))
+ for i, t := range tasks {
+ items[i] = toTaskItem(t)
+ }
+ return items
+}
diff --git a/backend/internal/service/whisper.go b/backend/internal/service/whisper.go
new file mode 100644
index 00000000..a055bd87
--- /dev/null
+++ b/backend/internal/service/whisper.go
@@ -0,0 +1,170 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/momshell/backend/internal/dto"
+ "github.com/momshell/backend/internal/model"
+ "github.com/momshell/backend/internal/repository"
+ "github.com/momshell/backend/pkg/openai"
+)
+
+type WhisperService struct {
+ whisperRepo *repository.WhisperRepo
+ userRepo *repository.UserRepo
+ aiClient *openai.Client
+}
+
+func NewWhisperService(
+ whisperRepo *repository.WhisperRepo,
+ userRepo *repository.UserRepo,
+ aiClient *openai.Client,
+) *WhisperService {
+ return &WhisperService{
+ whisperRepo: whisperRepo,
+ userRepo: userRepo,
+ aiClient: aiClient,
+ }
+}
+
+// CreateWhisper creates a new whisper from a mom user.
+func (s *WhisperService) CreateWhisper(authorID, content string) (*dto.WhisperItem, error) {
+ user, err := s.userRepo.FindByID(authorID)
+ if err != nil {
+ return nil, fmt.Errorf("用户不存在")
+ }
+ if user.Role != model.RoleMom {
+ return nil, fmt.Errorf("只有妈妈角色可以写心语")
+ }
+ if user.PartnerID == nil {
+ return nil, fmt.Errorf("请先完成伴侣绑定")
+ }
+
+ w := &model.Whisper{
+ AuthorID: authorID,
+ Content: content,
+ }
+ if err := s.whisperRepo.Create(w); err != nil {
+ return nil, err
+ }
+
+ return &dto.WhisperItem{
+ ID: w.ID,
+ Content: w.Content,
+ CreatedAt: w.CreatedAt,
+ }, nil
+}
+
+// GetWhispers returns whispers for the partner to read.
+// If the caller is Dad, returns Mom's whispers.
+func (s *WhisperService) GetWhispers(callerID string) ([]dto.WhisperItem, error) {
+ user, err := s.userRepo.FindByID(callerID)
+ if err != nil {
+ return nil, fmt.Errorf("用户不存在")
+ }
+ if user.PartnerID == nil {
+ return nil, fmt.Errorf("请先完成伴侣绑定")
+ }
+
+ // Determine whose whispers to fetch
+ var targetID string
+ if user.Role == model.RoleDad {
+ targetID = *user.PartnerID // Dad reads Mom's whispers
+ } else {
+ targetID = user.ID // Mom reads her own whispers
+ }
+
+ whispers, err := s.whisperRepo.FindByAuthorID(targetID, 20)
+ if err != nil {
+ return nil, err
+ }
+
+ items := make([]dto.WhisperItem, len(whispers))
+ for i, w := range whispers {
+ items[i] = dto.WhisperItem{
+ ID: w.ID,
+ Content: w.Content,
+ CreatedAt: w.CreatedAt,
+ }
+ }
+ return items, nil
+}
+
+const whisperTipsPrompt = `你是「小石光」,一位温柔的家庭关系顾问。
+以下是一位妈妈最近写下的心语(心情、感受或愿望):
+
+%s
+
+请根据这些心语,给她的伴侣(爸爸)一些温暖、实用的提示和建议:
+1. 先简要分析妈妈目前的情绪状态
+2. 给出 2-3 条具体的行动建议(比如可以做什么、说什么)
+3. 语气温暖鼓励,像朋友之间的建议
+
+用纯文本回复,不要使用 JSON 格式,1-3 段话即可。`
+
+// GetWhisperTips generates AI tips for Dad based on Mom's whispers.
+func (s *WhisperService) GetWhisperTips(callerID string) (*dto.WhisperTips, error) {
+ user, err := s.userRepo.FindByID(callerID)
+ if err != nil {
+ return nil, fmt.Errorf("用户不存在")
+ }
+ if user.Role != model.RoleDad {
+ return nil, fmt.Errorf("只有爸爸角色可以获取提示")
+ }
+ if user.PartnerID == nil {
+ return nil, fmt.Errorf("请先完成伴侣绑定")
+ }
+
+ whispers, err := s.whisperRepo.FindByAuthorID(*user.PartnerID, 10)
+ if err != nil {
+ return nil, err
+ }
+
+ items := make([]dto.WhisperItem, len(whispers))
+ for i, w := range whispers {
+ items[i] = dto.WhisperItem{
+ ID: w.ID,
+ Content: w.Content,
+ CreatedAt: w.CreatedAt,
+ }
+ }
+
+ if len(whispers) == 0 {
+ return &dto.WhisperTips{
+ Tips: "她还没有写下心语,也许你可以主动关心一下她今天过得怎么样。",
+ Whispers: items,
+ }, nil
+ }
+
+ // Build whisper context
+ var sb strings.Builder
+ for _, w := range whispers {
+ fmt.Fprintf(&sb, "- %s(%s)\n", w.Content, w.CreatedAt.Format("01-02 15:04"))
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ messages := []openai.Message{
+ {Role: "system", Content: fmt.Sprintf(whisperTipsPrompt, sb.String())},
+ {Role: "user", Content: "请给出建议。"},
+ }
+
+ tips, err := s.aiClient.Chat(ctx, messages)
+ if err != nil {
+ log.Printf("[WhisperService] AI tips generation failed: %v", err)
+ return &dto.WhisperTips{
+ Tips: "暂时无法生成建议,请稍后再试。",
+ Whispers: items,
+ }, nil
+ }
+
+ return &dto.WhisperTips{
+ Tips: strings.TrimSpace(tips),
+ Whispers: items,
+ }, nil
+}
diff --git a/backend/pkg/firecrawl/client.go b/backend/pkg/firecrawl/client.go
new file mode 100644
index 00000000..defe97ae
--- /dev/null
+++ b/backend/pkg/firecrawl/client.go
@@ -0,0 +1,79 @@
+package firecrawl
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+type Client struct {
+ apiKey string
+ http *http.Client
+}
+
+func NewClient(apiKey string) *Client {
+ return &Client{
+ apiKey: apiKey,
+ http: &http.Client{},
+ }
+}
+
+type SearchResult struct {
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Markdown string `json:"markdown"`
+}
+
+type searchRequest struct {
+ Query string `json:"query"`
+ Limit int `json:"limit"`
+}
+
+type searchResponse struct {
+ Success bool `json:"success"`
+ Data []SearchResult `json:"data"`
+}
+
+func (c *Client) Search(ctx context.Context, query string, limit int) ([]SearchResult, error) {
+ if c.apiKey == "" {
+ return nil, nil
+ }
+
+ body, err := json.Marshal(searchRequest{Query: query, Limit: limit})
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", "https://api.firecrawl.dev/v1/search", bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("firecrawl search failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("firecrawl search failed: status %d, body: %s", resp.StatusCode, string(respBody))
+ }
+
+ var searchResp searchResponse
+ if err := json.Unmarshal(respBody, &searchResp); err != nil {
+ return nil, fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ return searchResp.Data, nil
+}
diff --git a/backend/pkg/openai/client.go b/backend/pkg/openai/client.go
index 8dccdac1..da1da289 100644
--- a/backend/pkg/openai/client.go
+++ b/backend/pkg/openai/client.go
@@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"io"
+ "log"
"net/http"
+ "time"
)
type Client struct {
@@ -57,7 +59,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) {
Model: c.model,
Messages: messages,
Temperature: 0.7,
- MaxTokens: 1024,
+ MaxTokens: 4096,
EnableThinking: false,
}
@@ -102,5 +104,217 @@ func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) {
return "", fmt.Errorf("openai returned no choices")
}
- return chatResp.Choices[0].Message.Content, nil
+ content := chatResp.Choices[0].Message.Content
+ log.Printf("[Chat] raw response (first 500 chars): %.500s", content)
+ return content, nil
+}
+
+type imageRequest struct {
+ Model string `json:"model"`
+ Prompt string `json:"prompt"`
+ N int `json:"n,omitempty"`
+ Size string `json:"size,omitempty"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ NumInferenceSteps int `json:"num_inference_steps,omitempty"`
+ GuidanceScale float64 `json:"guidance_scale"`
+}
+
+type ImageResponse struct {
+ Data []struct {
+ URL string `json:"url,omitempty"`
+ B64JSON string `json:"b64_json,omitempty"`
+ } `json:"data"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error,omitempty"`
+}
+
+func (c *Client) GenerateImage(ctx context.Context, model, prompt string) (*ImageResponse, error) {
+ reqBody := imageRequest{
+ Model: model,
+ Prompt: prompt,
+ Width: 1024,
+ Height: 1024,
+ NumInferenceSteps: 9,
+ GuidanceScale: 0.0,
+ }
+
+ body, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal image request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/images/generations", bytes.NewReader(body))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create image request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ req.Header.Set("X-ModelScope-Async-Mode", "true")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("image generation request failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read image response: %w", err)
+ }
+
+ log.Printf("[ImageGen] model=%s POST status=%d body_len=%d body_preview=%.500s", model, resp.StatusCode, len(respBody), string(respBody))
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
+ return nil, fmt.Errorf("image generation failed: status code: %d, message: %s",
+ resp.StatusCode, string(respBody))
+ }
+
+ // Try parsing as an async task response (ModelScope returns task_id for polling)
+ var taskResp taskStatusResponse
+ if err := json.Unmarshal(respBody, &taskResp); err == nil {
+ // If already SUCCEED with images, return directly
+ if imgResp := extractImagesFromTaskStatus(&taskResp); imgResp != nil {
+ return imgResp, nil
+ }
+ // Has task_id - poll with it (ModelScope uses task_id, NOT request_id)
+ if taskResp.TaskID != "" {
+ log.Printf("[ImageGen] async task, task_id=%s, status=%s, polling...", taskResp.TaskID, taskResp.TaskStatus)
+ return c.pollImageTask(ctx, taskResp.TaskID)
+ }
+ // Fallback: try request_id if no task_id
+ if taskResp.RequestID != "" {
+ log.Printf("[ImageGen] async task (fallback), request_id=%s, status=%s, polling...", taskResp.RequestID, taskResp.TaskStatus)
+ return c.pollImageTask(ctx, taskResp.RequestID)
+ }
+ }
+
+ // Synchronous response (standard OpenAI format)
+ var imgResp ImageResponse
+ if err := json.Unmarshal(respBody, &imgResp); err != nil {
+ return nil, fmt.Errorf("failed to parse image response: %w", err)
+ }
+
+ if imgResp.Error != nil {
+ return nil, fmt.Errorf("openai image error: %s", imgResp.Error.Message)
+ }
+
+ if len(imgResp.Data) == 0 {
+ return nil, fmt.Errorf("image generation returned no results")
+ }
+
+ return &imgResp, nil
+}
+
+func extractImagesFromTaskStatus(status *taskStatusResponse) *ImageResponse {
+ if status.TaskStatus != "SUCCEED" && status.TaskStatus != "SUCCEEDED" {
+ return nil
+ }
+
+ imgResp := &ImageResponse{}
+
+ if len(status.OutputImages) > 0 {
+ imgResp.Data = make([]struct {
+ URL string `json:"url,omitempty"`
+ B64JSON string `json:"b64_json,omitempty"`
+ }, len(status.OutputImages))
+ for i, u := range status.OutputImages {
+ imgResp.Data[i].URL = u
+ }
+ return imgResp
+ }
+
+ if status.Output != nil && len(status.Output.Results) > 0 {
+ imgResp.Data = make([]struct {
+ URL string `json:"url,omitempty"`
+ B64JSON string `json:"b64_json,omitempty"`
+ }, len(status.Output.Results))
+ for i, r := range status.Output.Results {
+ imgResp.Data[i].URL = r.URL
+ imgResp.Data[i].B64JSON = r.B64JSON
+ }
+ return imgResp
+ }
+
+ return nil // Status is SUCCEED but no images found
+}
+
+type taskStatusResponse struct {
+ RequestID string `json:"request_id"`
+ TaskID string `json:"task_id"`
+ TaskStatus string `json:"task_status"`
+ OutputImages []string `json:"output_images,omitempty"`
+ Output *struct {
+ TaskID string `json:"task_id"`
+ Results []struct {
+ URL string `json:"url,omitempty"`
+ B64JSON string `json:"b64_json,omitempty"`
+ } `json:"results"`
+ } `json:"output,omitempty"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+func (c *Client) pollImageTask(ctx context.Context, taskID string) (*ImageResponse, error) {
+ pollURL := c.baseURL + "/tasks/" + taskID
+
+ for i := 0; i < 40; i++ {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(10 * time.Second):
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "GET", pollURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create poll request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ req.Header.Set("X-ModelScope-Task-Type", "image_generation")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ log.Printf("[ImageGen] poll error: %v", err)
+ continue
+ }
+
+ respBody, err := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if err != nil {
+ continue
+ }
+
+ log.Printf("[ImageGen] poll #%d status=%d body_len=%d body_preview=%.500s", i+1, resp.StatusCode, len(respBody), string(respBody))
+
+ var status taskStatusResponse
+ if err := json.Unmarshal(respBody, &status); err != nil {
+ log.Printf("[ImageGen] poll parse error: %v", err)
+ continue
+ }
+
+ if status.Error != nil {
+ return nil, fmt.Errorf("image task failed: %s", status.Error.Message)
+ }
+
+ if imgResp := extractImagesFromTaskStatus(&status); imgResp != nil {
+ return imgResp, nil
+ }
+
+ switch status.TaskStatus {
+ case "SUCCEED", "SUCCEEDED", "succeeded":
+ return nil, fmt.Errorf("image task succeeded but no results in response")
+ case "FAILED", "failed":
+ msg := status.Message
+ if msg == "" {
+ msg = "image generation task failed"
+ }
+ return nil, fmt.Errorf("%s", msg)
+ }
+ // PENDING, RUNNING - continue polling
+ }
+
+ return nil, fmt.Errorf("image generation timed out after polling")
}
diff --git a/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg b/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg
deleted file mode 100644
index 97475c83..00000000
--- a/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1f62ade99c320ef2cc6b03c1267b3e84e571c7d4b2e576accb438604913b8e5a
-size 953068
diff --git a/backend/uploads/avatars/7489176a-d8b7-4cee-a3a3-76c4d2759292.png b/backend/uploads/avatars/7489176a-d8b7-4cee-a3a3-76c4d2759292.png
deleted file mode 100644
index df568498..00000000
--- a/backend/uploads/avatars/7489176a-d8b7-4cee-a3a3-76c4d2759292.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:527e644d661085f0201e58e9b502dae8d9803281358da68d6950a43e89cc8e5f
-size 414120
diff --git a/frontend.new/.omc/state/last-tool-error.json b/frontend.new/.omc/state/last-tool-error.json
deleted file mode 100644
index 50c8bb76..00000000
--- a/frontend.new/.omc/state/last-tool-error.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "tool_name": "Bash",
- "tool_input_preview": "{\"command\":\"cd /home/koishi/MomShell && git add frontend.new/src/components/scene/BeachScene.vue frontend.new/src/constants/layers.ts frontend.new/vite.config.ts && git rebase --continue\",\"description...",
- "error": "Exit code 1\n[detached HEAD cc7ec54] refactor: restructure beach scene into Vue 3 + Vite + TypeScript\n 5 files changed, 2174 insertions(+), 1221 deletions(-)\n rename frontend.new/assets/{render-sand.js => render-sand.ts} (87%)\nRebasing (16/58)\rAuto-merging frontend.new/src/components/scene/BeachScene.vue\nCONFLICT (content): Merge conflict in frontend.new/src/components/scene/BeachScene.vue\nAuto-merging frontend.new/src/components/scene/SpritesLayer.vue\nCONFLICT (add/add): Merge conflict in fronte...",
- "timestamp": "2026-03-07T06:54:53.196Z",
- "retry_count": 1
-}
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 05e93e56..b72df70d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "beach-scene",
+ "name": "momshell-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "beach-scene",
+ "name": "momshell-frontend",
"version": "1.0.0",
"dependencies": {
"axios": "^1.13.6",
@@ -1204,6 +1204,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -1606,6 +1607,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2111,7 +2113,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"dev": true,
- "license": "BSD-3-Clause"
+ "license": "BSD-3-Clause",
+ "peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -2314,6 +2317,7 @@
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -3920,6 +3924,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -3960,6 +3965,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4022,6 +4028,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -4103,6 +4110,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 0d0a36fd..b47ed11f 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -3,30 +3,35 @@
-
+
+
-
-
diff --git a/frontend/src/components/overlay/TaskPanel.vue b/frontend/src/components/overlay/TaskPanel.vue
new file mode 100644
index 00000000..95efe153
--- /dev/null
+++ b/frontend/src/components/overlay/TaskPanel.vue
@@ -0,0 +1,530 @@
+
+
+
+
+
+ 今日任务
+ 完成任务,一起成长
+
+
+
+ Lv.{{ stats.level }}
+ {{ stats.xp }} XP
+
+
+ 加载中...
+
+ 今天没有任务
+
+
+
+
+
{{ t.title }}
+
{{ t.description }}
+
+
+
+
+
+
+
+
+ 伴侣任务
+ 查看他的完成情况
+
+
+
+ Lv.{{ stats.level }}
+ {{ stats.xp }} XP
+
+
+ 加载中...
+
+ 今天还没有任务
+
+
+
+
+
{{ t.title }}
+
{{ t.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ error }}
+
+
+
+
+
+
+
diff --git a/frontend/src/components/overlay/WhisperPanel.vue b/frontend/src/components/overlay/WhisperPanel.vue
new file mode 100644
index 00000000..345284e8
--- /dev/null
+++ b/frontend/src/components/overlay/WhisperPanel.vue
@@ -0,0 +1,332 @@
+
+
+
+
+
+ 写下此刻的心声
+ 你的感受、心情或小小的愿望
+
+
+
+
+
+
+ {{ error }}
+ {{ success }}
+
+
+
我的心语
+
+
{{ w.content }}
+
{{ formatTime(w.created_at) }}
+
+
+
+
+
+
+ 她的心声
+ 了解她此刻的感受
+
+ 加载中...
+
+
+
+
+
+
+
+
+
+
+
她的心语
+
+
{{ w.content }}
+
{{ formatTime(w.created_at) }}
+
+
+ 她还没有写下心语
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/scene/SandLayer.vue b/frontend/src/components/scene/SandLayer.vue
index 33bbce3a..960dd408 100644
--- a/frontend/src/components/scene/SandLayer.vue
+++ b/frontend/src/components/scene/SandLayer.vue
@@ -16,7 +16,7 @@ import { ref, onMounted, inject } from 'vue'
import { PARALLAX_KEY } from '@/composables/useParallax'
import { LAYERS } from '@/constants/layers'
import { generateWaveParticles } from '@/composables/useProceduralElements'
-import sandTexture from '@/assets/sand.png'
+import sandTexture from '@/assets/images/sand.png'
const layerEl = ref(null)
const ctx = inject(PARALLAX_KEY)!
diff --git a/frontend/src/components/scene/SpritesLayer.vue b/frontend/src/components/scene/SpritesLayer.vue
index 1746a6b1..13b774ef 100644
--- a/frontend/src/components/scene/SpritesLayer.vue
+++ b/frontend/src/components/scene/SpritesLayer.vue
@@ -3,6 +3,7 @@
+
+
-
- 去吧台买点饮料,谈天说地吧
+
+ {{ currentHint }}
@@ -86,6 +164,15 @@ onMounted(() => {
height: calc(58% + 2px);
}
+.sprite-bubble-layer {
+ width: 400vw;
+ height: 100%;
+ top: 0;
+ z-index: 120;
+ pointer-events: none;
+ overflow: visible;
+}
+
.sprite {
position: absolute;
height: auto;
@@ -108,34 +195,45 @@ onMounted(() => {
.speech-bubble {
position: absolute;
- background: rgba(255, 255, 255, 0.92);
+ background: rgba(255, 252, 246, 0.96);
color: #5a3e2b;
- font-size: 1rem;
- padding: 0.6em 1em;
- border-radius: 1em;
- white-space: nowrap;
+ font-size: 0.95rem;
+ line-height: 1.55;
+ padding: 0.8em 1em;
+ border-radius: 1.1em;
+ white-space: normal;
+ width: 18rem;
+ max-width: none;
pointer-events: none;
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
- z-index: 50;
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
+ border: 1px solid rgba(255, 214, 170, 0.7);
+ z-index: 1;
+}
+
+.crab-bubble {
+ --bubble-shift-x: -10%;
+ --bubble-shift-y: calc(-100% - 0.5rem);
+ transform: translate(var(--bubble-shift-x), var(--bubble-shift-y));
}
.speech-bubble::after {
content: '';
position: absolute;
bottom: -8px;
- left: 2em;
+ left: 2.2em;
border-width: 8px 6px 0;
border-style: solid;
- border-color: rgba(255, 255, 255, 0.92) transparent transparent;
+ border-color: rgba(255, 252, 246, 0.96) transparent transparent;
}
.bubble-fade-enter-active,
.bubble-fade-leave-active {
- transition: opacity 0.35s ease;
+ transition: opacity 0.35s ease, transform 0.35s ease;
}
.bubble-fade-enter-from,
.bubble-fade-leave-to {
opacity: 0;
+ transform: translate(var(--bubble-shift-x, 0), calc(var(--bubble-shift-y, 0px) + 6px));
}
diff --git a/frontend/src/composables/useBackgroundMusicLoop.ts b/frontend/src/composables/useBackgroundMusicLoop.ts
new file mode 100644
index 00000000..25803577
--- /dev/null
+++ b/frontend/src/composables/useBackgroundMusicLoop.ts
@@ -0,0 +1,182 @@
+import { onMounted, onUnmounted, readonly, ref } from 'vue'
+import shoreAndYou from '@/assets/audio/The Shore and You.mp3'
+import travelogue from '@/assets/audio/Travelogue.mp3'
+
+const TRACKS = [shoreAndYou, travelogue]
+const UNLOCK_EVENTS: Array = ['pointerdown', 'keydown', 'touchstart']
+const DEFAULT_VOLUME = 0.45
+
+const currentTrackIndex = ref(0)
+const isBackgroundMusicPlaying = ref(false)
+const backgroundMusicVolume = ref(DEFAULT_VOLUME)
+
+let activeConsumers = 0
+let isInitialized = false
+let audioPlayers: HTMLAudioElement[] = []
+let endedHandlers: Array<() => void> = []
+
+function clampVolume(volume: number) {
+ return Math.min(1, Math.max(0, volume))
+}
+
+function applyVolume() {
+ audioPlayers.forEach((audio) => {
+ audio.volume = backgroundMusicVolume.value
+ })
+}
+
+function removeUnlockListeners() {
+ UNLOCK_EVENTS.forEach((eventName) => {
+ window.removeEventListener(eventName, unlockPlayback)
+ })
+}
+
+function addUnlockListeners() {
+ removeUnlockListeners()
+ UNLOCK_EVENTS.forEach((eventName) => {
+ window.addEventListener(eventName, unlockPlayback, { passive: true })
+ })
+}
+
+function stopOtherTracks(activeIndex: number) {
+ audioPlayers.forEach((audio, audioIndex) => {
+ if (audioIndex !== activeIndex) {
+ audio.pause()
+ audio.currentTime = 0
+ }
+ })
+}
+
+async function playTrack(index: number, restart = false) {
+ if (!audioPlayers.length) {
+ return
+ }
+
+ currentTrackIndex.value = index
+ stopOtherTracks(index)
+
+ const currentAudio = audioPlayers[index]
+ if (restart) {
+ currentAudio.currentTime = 0
+ }
+
+ try {
+ await currentAudio.play()
+ isBackgroundMusicPlaying.value = true
+ removeUnlockListeners()
+ } catch {
+ isBackgroundMusicPlaying.value = false
+ addUnlockListeners()
+ }
+}
+
+function playNextTrack() {
+ const nextTrackIndex = (currentTrackIndex.value + 1) % TRACKS.length
+ void playTrack(nextTrackIndex, true)
+}
+
+function unlockPlayback() {
+ removeUnlockListeners()
+ void playTrack(currentTrackIndex.value)
+}
+
+function ensureAudioPlayers() {
+ if (isInitialized) {
+ return
+ }
+
+ endedHandlers = TRACKS.map((_, index) => () => {
+ if (index === currentTrackIndex.value && isBackgroundMusicPlaying.value) {
+ playNextTrack()
+ }
+ })
+
+ audioPlayers = TRACKS.map((src, index) => {
+ const audio = new Audio(src)
+ audio.preload = 'auto'
+ audio.loop = false
+ audio.volume = backgroundMusicVolume.value
+ audio.addEventListener('ended', endedHandlers[index])
+ return audio
+ })
+
+ isInitialized = true
+}
+
+function destroyAudioPlayers() {
+ removeUnlockListeners()
+
+ audioPlayers.forEach((audio, index) => {
+ audio.pause()
+ audio.currentTime = 0
+ audio.removeEventListener('ended', endedHandlers[index])
+ audio.src = ''
+ audio.load()
+ })
+
+ audioPlayers = []
+ endedHandlers = []
+ currentTrackIndex.value = 0
+ isBackgroundMusicPlaying.value = false
+ isInitialized = false
+}
+
+export function playBackgroundMusic() {
+ void playTrack(currentTrackIndex.value)
+}
+
+export function pauseBackgroundMusic() {
+ removeUnlockListeners()
+ audioPlayers.forEach((audio, index) => {
+ audio.pause()
+ if (index !== currentTrackIndex.value) {
+ audio.currentTime = 0
+ }
+ })
+ isBackgroundMusicPlaying.value = false
+}
+
+export function toggleBackgroundMusic() {
+ if (isBackgroundMusicPlaying.value) {
+ pauseBackgroundMusic()
+ return
+ }
+
+ playBackgroundMusic()
+}
+
+export function setBackgroundMusicVolume(volume: number) {
+ backgroundMusicVolume.value = clampVolume(volume)
+ applyVolume()
+}
+
+export function useBackgroundMusicControls() {
+ return {
+ backgroundMusicVolume: readonly(backgroundMusicVolume),
+ isBackgroundMusicPlaying: readonly(isBackgroundMusicPlaying),
+ playBackgroundMusic,
+ pauseBackgroundMusic,
+ toggleBackgroundMusic,
+ setBackgroundMusicVolume,
+ }
+}
+
+export function useBackgroundMusicLoop() {
+ onMounted(() => {
+ activeConsumers += 1
+ ensureAudioPlayers()
+ applyVolume()
+ void playTrack(currentTrackIndex.value, currentTrackIndex.value === 0)
+ })
+
+ onUnmounted(() => {
+ activeConsumers -= 1
+
+ if (activeConsumers <= 0) {
+ activeConsumers = 0
+ destroyAudioPlayers()
+ }
+ })
+
+ return useBackgroundMusicControls()
+}
diff --git a/frontend/src/constants/sprites.ts b/frontend/src/constants/sprites.ts
index 46653c43..b27c4372 100644
--- a/frontend/src/constants/sprites.ts
+++ b/frontend/src/constants/sprites.ts
@@ -9,12 +9,13 @@ export interface SpriteData {
scaleY?: number
}
-import carImg from '@/assets/car.png'
-import barImg from '@/assets/bar.png'
-import stoneImg from '@/assets/stone.png'
-import crabImg from '@/assets/crab.png'
-import shellImg from '@/assets/shell.png'
-import communityImg from '@/assets/community.png'
+import carImg from '@/assets/images/car.png'
+import barImg from '@/assets/images/bar.png'
+import stoneImg from '@/assets/images/stone.png'
+import crabImg from '@/assets/images/crab.png'
+import shellImg from '@/assets/images/shell.png'
+import starImg from '@/assets/images/star.png'
+import conqueImg from '@/assets/images/conque.png'
export const SPRITES: SpriteData[] = [
@@ -24,12 +25,18 @@ export const SPRITES: SpriteData[] = [
{ id: 'stone', src: stoneImg, left: '68%', top: '-10%', width: '30vw' },
- { id: 'crab', src: crabImg, left: '60%', top: '12%', width: '6vw' },
+ {
+ id: 'crab',
+ src: crabImg,
+ left: '60%',
+ top: '12%',
+ width: '6vw',
+ },
- { id: 'shell1', src: shellImg, left: '48%', top: '45%', width: '6vw', rotate: 15 },
- { id: 'shell3', src: shellImg, left: '50%', top: '75%', width: '5vw', rotate: 45, scaleY: 1 },
- { id: 'shell4', src: shellImg, left: '52.5%', top: '48%', width: '5.2vw', rotate: 30, scaleX: -1, scaleY: -1 },
+ { id: 'shell', src: shellImg, left: '48%', top: '45%', width: '5vw', rotate: 0 },
- { id: 'community', src: communityImg, left: '54%', top: '15%', width: '45vw' },
+ { id: 'star', src: starImg, left: '50%', top: '75%', width: '5vw', rotate: 0 },
+
+ { id: 'conque', src: conqueImg, left: '52.5%', top: '48%', width: '5vw', rotate: 0 },
]
diff --git a/frontend/src/lib/api/photo.ts b/frontend/src/lib/api/photo.ts
new file mode 100644
index 00000000..cee4b3af
--- /dev/null
+++ b/frontend/src/lib/api/photo.ts
@@ -0,0 +1,69 @@
+import apiClient from '@/lib/apiClient'
+
+export interface Photo {
+ id: string
+ title: string
+ description: string
+ tags: string[]
+ image_url: string
+ is_on_wall: boolean
+ wall_position: number | null
+ source: 'upload' | 'ai_generated'
+ created_at: string
+ updated_at: string
+}
+
+export interface PhotoListResponse {
+ photos: Photo[]
+ total: number
+ page: number
+ page_size: number
+ total_pages: number
+}
+
+export function getPhotos(page = 1, pageSize = 50): Promise {
+ return apiClient
+ .get('/api/v1/photos', { params: { page, page_size: pageSize } })
+ .then((r) => r.data)
+}
+
+export function uploadPhoto(file: File, title?: string): Promise {
+ const formData = new FormData()
+ formData.append('photo', file)
+ if (title) formData.append('title', title)
+ return apiClient.post('/api/v1/photos/upload', formData).then((r) => r.data)
+}
+
+export function generatePhoto(prompt: string): Promise {
+ return apiClient.post('/api/v1/photos/generate', { prompt }).then((r) => r.data)
+}
+
+export function updatePhoto(
+ id: string,
+ data: { title?: string; description?: string; tags?: string[] },
+): Promise {
+ return apiClient.put(`/api/v1/photos/${id}`, data).then((r) => r.data)
+}
+
+export function deletePhoto(id: string): Promise {
+ return apiClient.delete(`/api/v1/photos/${id}`)
+}
+
+export function togglePhotoWall(
+ id: string,
+ isOnWall: boolean,
+ wallPosition?: number,
+): Promise {
+ return apiClient
+ .put(`/api/v1/photos/${id}/wall`, {
+ is_on_wall: isOnWall,
+ wall_position: wallPosition ?? null,
+ })
+ .then((r) => r.data)
+}
+
+export function batchUpdateWall(
+ photos: Array<{ photo_id: string; position: number }>,
+): Promise<{ photos: Photo[] }> {
+ return apiClient.put('/api/v1/photos/wall', { photos }).then((r) => r.data)
+}
diff --git a/frontend/src/lib/api/task.ts b/frontend/src/lib/api/task.ts
new file mode 100644
index 00000000..6c5c7e0f
--- /dev/null
+++ b/frontend/src/lib/api/task.ts
@@ -0,0 +1,52 @@
+import apiClient from "@/lib/apiClient";
+
+export interface UserTaskItem {
+ id: string;
+ title: string;
+ description: string;
+ category: string;
+ difficulty: number;
+ status: string;
+ score: number | null;
+ comment: string | null;
+ completed_at: string | null;
+ scored_at: string | null;
+ date: string;
+}
+
+export interface TaskStats {
+ xp: number;
+ level: number;
+}
+
+export function getDailyTasks(): Promise {
+ return apiClient.get("/api/v1/tasks/daily").then((r) => r.data);
+}
+
+export function completeTask(id: string): Promise {
+ return apiClient.post(`/api/v1/tasks/${id}/complete`).then((r) => r.data);
+}
+
+export function getPartnerTasks(): Promise {
+ return apiClient.get("/api/v1/tasks/partner").then((r) => r.data);
+}
+
+export function scoreTask(
+ id: string,
+ data: { score: number; comment?: string },
+): Promise {
+ return apiClient.post(`/api/v1/tasks/${id}/score`, data).then((r) => r.data);
+}
+
+export function rejectTask(
+ id: string,
+ data?: { comment?: string },
+): Promise {
+ return apiClient
+ .post(`/api/v1/tasks/${id}/reject`, data ?? {})
+ .then((r) => r.data);
+}
+
+export function getTaskStats(): Promise {
+ return apiClient.get("/api/v1/tasks/stats").then((r) => r.data);
+}
diff --git a/frontend/src/lib/api/user.ts b/frontend/src/lib/api/user.ts
index 742b105f..46b303b7 100644
--- a/frontend/src/lib/api/user.ts
+++ b/frontend/src/lib/api/user.ts
@@ -1,129 +1,129 @@
-import apiClient from '@/lib/apiClient'
-import type { PaginatedResponse } from './community'
+import apiClient from "@/lib/apiClient";
+import type { PaginatedResponse } from "./community";
export interface PartnerInfo {
- id: string
- nickname: string
- avatar_url: string | null
- role: string
+ id: string;
+ nickname: string;
+ avatar_url: string | null;
+ role: string;
}
export interface UserProfile {
- id: string
- username: string
- nickname: string
- email: string
- avatar_url: string | null
- role: string
- is_admin: boolean
- shell_code: string | null
- partner: PartnerInfo | null
- is_certified: boolean
- certification_title: string | null
- stats: UserStats
- created_at: string
+ id: string;
+ username: string;
+ nickname: string;
+ email: string;
+ avatar_url: string | null;
+ role: string;
+ is_admin: boolean;
+ shell_code: string | null;
+ partner: PartnerInfo | null;
+ is_certified: boolean;
+ certification_title: string | null;
+ stats: UserStats;
+ created_at: string;
}
export interface UserStats {
- question_count: number
- answer_count: number
- like_received_count: number
- collection_count: number
+ question_count: number;
+ answer_count: number;
+ like_received_count: number;
+ collection_count: number;
}
export interface MyQuestionListItem {
- id: string
- title: string
- content_preview: string
- channel: string
- tags: Array<{ id: string; name: string; slug: string }>
- view_count: number
- answer_count: number
- like_count: number
- collection_count: number
- status: string
- has_accepted_answer: boolean
- is_liked: boolean
- is_collected: boolean
- created_at: string
+ id: string;
+ title: string;
+ content_preview: string;
+ channel: string;
+ tags: Array<{ id: string; name: string; slug: string }>;
+ view_count: number;
+ answer_count: number;
+ like_count: number;
+ collection_count: number;
+ status: string;
+ has_accepted_answer: boolean;
+ is_liked: boolean;
+ is_collected: boolean;
+ created_at: string;
}
export interface MyAnswerListItem {
- id: string
- content_preview: string
- question: { id: string; title: string; channel: string }
- is_professional: boolean
- is_accepted: boolean
- like_count: number
- comment_count: number
- status: string
- is_liked: boolean
- created_at: string
+ id: string;
+ content_preview: string;
+ question: { id: string; title: string; channel: string };
+ is_professional: boolean;
+ is_accepted: boolean;
+ like_count: number;
+ comment_count: number;
+ status: string;
+ is_liked: boolean;
+ created_at: string;
}
export function getUserProfile(): Promise {
- return apiClient.get('/api/v1/community/users/me').then((r) => r.data)
+ return apiClient.get("/api/v1/community/users/me").then((r) => r.data);
}
export function updateUserProfile(data: {
- username?: string
- nickname?: string
- email?: string
- avatar_url?: string
- role?: string
+ username?: string;
+ nickname?: string;
+ email?: string;
+ avatar_url?: string;
+ role?: string;
}): Promise {
- return apiClient.put('/api/v1/community/users/me', data).then((r) => r.data)
+ return apiClient.put("/api/v1/community/users/me", data).then((r) => r.data);
}
export function getMyQuestions(params?: {
- page?: number
- page_size?: number
+ page?: number;
+ page_size?: number;
}): Promise> {
return apiClient
- .get('/api/v1/community/users/me/questions', { params })
- .then((r) => r.data)
+ .get("/api/v1/community/users/me/questions", { params })
+ .then((r) => r.data);
}
export function getMyAnswers(params?: {
- page?: number
- page_size?: number
+ page?: number;
+ page_size?: number;
}): Promise> {
return apiClient
- .get('/api/v1/community/users/me/answers', { params })
- .then((r) => r.data)
+ .get("/api/v1/community/users/me/answers", { params })
+ .then((r) => r.data);
}
export function changePassword(data: {
- old_password: string
- new_password: string
+ old_password: string;
+ new_password: string;
}): Promise<{ message: string }> {
return apiClient
- .post('/api/v1/auth/change-password', data)
- .then((r) => r.data)
+ .post("/api/v1/auth/change-password", data)
+ .then((r) => r.data);
}
export function uploadAvatar(file: File): Promise {
- const form = new FormData()
- form.append('avatar', file)
+ const form = new FormData();
+ form.append("avatar", file);
return apiClient
- .post('/api/v1/community/users/me/avatar', form)
- .then((r) => r.data)
+ .post("/api/v1/community/users/me/avatar", form)
+ .then((r) => r.data);
}
export function generateShellCode(): Promise {
return apiClient
- .post('/api/v1/community/users/me/shell-code')
- .then((r) => r.data)
+ .post("/api/v1/community/users/me/shell-code")
+ .then((r) => r.data);
}
export function bindPartner(shellCode: string): Promise {
return apiClient
- .post('/api/v1/community/users/me/bind', { shell_code: shellCode })
- .then((r) => r.data)
+ .post("/api/v1/community/users/me/bind", { shell_code: shellCode })
+ .then((r) => r.data);
}
export function unbindPartner(): Promise {
return apiClient
- .delete('/api/v1/community/users/me/bind')
- .then((r) => r.data)
+ .delete("/api/v1/community/users/me/bind")
+ .then((r) => r.data);
}
diff --git a/frontend/src/lib/api/whisper.ts b/frontend/src/lib/api/whisper.ts
new file mode 100644
index 00000000..7e0f2369
--- /dev/null
+++ b/frontend/src/lib/api/whisper.ts
@@ -0,0 +1,24 @@
+import apiClient from "@/lib/apiClient";
+
+export interface WhisperItem {
+ id: string;
+ content: string;
+ created_at: string;
+}
+
+export interface WhisperTips {
+ tips: string;
+ whispers: WhisperItem[];
+}
+
+export function createWhisper(content: string): Promise {
+ return apiClient.post("/api/v1/whisper", { content }).then((r) => r.data);
+}
+
+export function getWhispers(): Promise {
+ return apiClient.get("/api/v1/whisper").then((r) => r.data);
+}
+
+export function getWhisperTips(): Promise {
+ return apiClient.get("/api/v1/whisper/tips").then((r) => r.data);
+}
diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts
index 3aa2bea0..8879b85e 100644
--- a/frontend/src/stores/ui.ts
+++ b/frontend/src/stores/ui.ts
@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useAuthStore } from './auth'
-export type PanelName = 'auth' | 'role' | 'profile' | 'memory' | 'community' | 'chat' | 'car' | null
+export type PanelName = 'auth' | 'role' | 'memory' | 'community' | 'chat' | 'car' | 'whisper' | 'task' | null
export type AuthMode = 'login' | 'register' | 'guest'
export const useUiStore = defineStore('ui', () => {
@@ -28,7 +28,7 @@ export const useUiStore = defineStore('ui', () => {
}
/** Open a feature panel, but intercept if guest → force auth */
- function openFeature(panel: 'car' | 'profile' | 'memory' | 'community' | 'chat') {
+ function openFeature(panel: 'car' | 'memory' | 'community' | 'chat' | 'whisper' | 'task') {
const auth = useAuthStore()
if (!auth.isAuthenticated && !auth.isGuest) {
openAuth()
diff --git a/scripts/dev-setup.ps1 b/scripts/dev-setup.ps1
new file mode 100644
index 00000000..69f639f6
--- /dev/null
+++ b/scripts/dev-setup.ps1
@@ -0,0 +1,493 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+ MomShell Development Environment Setup (Windows)
+.DESCRIPTION
+ Sets up PostgreSQL, environment variables, Go backend, and Vue frontend.
+.USAGE
+ powershell -ExecutionPolicy Bypass -File scripts\dev-setup.ps1
+#>
+
+$ErrorActionPreference = "Stop"
+
+# ── Helpers ──────────────────────────────────────────────────────────
+
+function Write-Ok { param($msg) Write-Host "[ OK ] $msg" -ForegroundColor Green }
+function Write-Info { param($msg) Write-Host "[INFO] $msg" -ForegroundColor Cyan }
+function Write-Warn { param($msg) Write-Host "[WARN] $msg" -ForegroundColor Yellow }
+function Write-Fail { param($msg) Write-Host "[FAIL] $msg" -ForegroundColor Red }
+
+function Test-Command { param($cmd) $null -ne (Get-Command $cmd -ErrorAction SilentlyContinue) }
+
+function Read-Default {
+ param([string]$Prompt, [string]$Default)
+ if ($Default) {
+ $input = Read-Host " $Prompt [$Default]"
+ } else {
+ $input = Read-Host " $Prompt"
+ }
+ if ([string]::IsNullOrWhiteSpace($input)) { return $Default } else { return $input }
+}
+
+# Detect if winget or choco is available
+$PM = $null
+if (Test-Command "winget") { $PM = "winget" }
+elseif (Test-Command "choco") { $PM = "choco" }
+
+function Try-Install {
+ param([string]$Cmd, [string]$WingetId, [string]$ChocoId, [string]$DisplayName, [string]$ManualUrl)
+ if (-not $PM) {
+ Write-Warn "No package manager (winget/choco) found. Install $DisplayName manually: $ManualUrl"
+ return $false
+ }
+ $answer = Read-Host " Install $DisplayName via ${PM}? [Y/n]"
+ if ($answer -match '^[nN]') { return $false }
+ Write-Info "Installing $DisplayName..."
+ if ($PM -eq "winget") {
+ winget install --id $WingetId -e --accept-package-agreements --accept-source-agreements
+ } else {
+ choco install $ChocoId -y
+ }
+ # Refresh PATH so newly installed commands are found
+ $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" +
+ [System.Environment]::GetEnvironmentVariable("Path", "User")
+ if (Test-Command $Cmd) {
+ Write-Ok "$DisplayName installed"
+ return $true
+ } else {
+ Write-Fail "Installation may require restarting the terminal"
+ return $false
+ }
+}
+
+# ── Resolve project root ─────────────────────────────────────────────
+
+$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$ProjectRoot = Split-Path -Parent $ScriptDir
+Set-Location $ProjectRoot
+
+# ── Banner ────────────────────────────────────────────────────────────
+
+Write-Host ""
+Write-Host "+==============================================+" -ForegroundColor Cyan
+Write-Host "| MomShell Development Setup (Windows) |" -ForegroundColor Cyan
+Write-Host "+==============================================+" -ForegroundColor Cyan
+Write-Host ""
+
+# ============================================
+# 1. Check dependencies
+# ============================================
+Write-Host "=== 1. Check System Dependencies ===" -ForegroundColor Cyan
+
+$Failed = $false
+
+# --- Go ---
+if (Test-Command "go") {
+ $goVer = (go version) -replace 'go version ',''
+ Write-Ok "Go $goVer"
+} else {
+ Write-Fail "Go not installed"
+ if (-not (Try-Install "go" "GoLang.Go" "golang" "Go" "https://go.dev/dl/")) { $Failed = $true }
+}
+
+# --- Node ---
+if (Test-Command "node") {
+ Write-Ok "Node $(node -v)"
+} else {
+ Write-Fail "Node not installed"
+ if (-not (Try-Install "node" "OpenJS.NodeJS.LTS" "nodejs-lts" "Node.js" "https://nodejs.org/")) { $Failed = $true }
+}
+
+# --- npm ---
+if (Test-Command "npm") {
+ Write-Ok "npm $(npm -v)"
+} else {
+ Write-Fail "npm not installed (should come with Node)"
+ $Failed = $true
+}
+
+# --- git ---
+if (Test-Command "git") {
+ $gitVer = (git --version) -replace 'git version ',''
+ Write-Ok "git $gitVer"
+} else {
+ Write-Fail "git not installed"
+ if (-not (Try-Install "git" "Git.Git" "git" "Git" "https://git-scm.com/")) { $Failed = $true }
+}
+
+# --- PostgreSQL (psql) ---
+if (Test-Command "psql") {
+ Write-Ok "PostgreSQL client installed"
+} else {
+ Write-Fail "psql not installed"
+ if (-not (Try-Install "psql" "PostgreSQL.PostgreSQL" "postgresql" "PostgreSQL" "https://www.postgresql.org/download/")) { $Failed = $true }
+}
+
+if ($Failed) {
+ Write-Host ""
+ Write-Fail "Some required dependencies are missing. Install them and re-run this script."
+ exit 1
+}
+
+# ============================================
+# 2. PostgreSQL
+# ============================================
+Write-Host ""
+Write-Host "=== 2. Setup PostgreSQL ===" -ForegroundColor Cyan
+
+$DB_NAME = "momshell"
+$DB_USER = "momshell"
+$DB_PASS = "momshell"
+
+# Check if PostgreSQL is running
+$pgReady = $false
+try {
+ $null = pg_isready 2>$null
+ if ($LASTEXITCODE -eq 0) { $pgReady = $true }
+} catch {}
+
+if ($pgReady) {
+ Write-Ok "PostgreSQL is running"
+} else {
+ Write-Warn "PostgreSQL is not running, attempting to start..."
+ # Try starting via Windows service
+ $pgService = Get-Service -Name "postgresql*" -ErrorAction SilentlyContinue | Select-Object -First 1
+ if ($pgService) {
+ try {
+ Start-Service $pgService.Name -ErrorAction Stop
+ Start-Sleep -Seconds 2
+ Write-Ok "PostgreSQL service '$($pgService.Name)' started"
+ } catch {
+ Write-Fail "Could not start PostgreSQL service. Run this script as Administrator, or start PostgreSQL manually."
+ Write-Host " Try: Start-Service $($pgService.Name)" -ForegroundColor Yellow
+ exit 1
+ }
+ } else {
+ # Try pg_ctl with default data directory
+ $pgData = $null
+ if ($env:PGDATA) { $pgData = $env:PGDATA }
+ elseif (Test-Path "$env:PROGRAMFILES\PostgreSQL") {
+ $pgDir = Get-ChildItem "$env:PROGRAMFILES\PostgreSQL" -Directory | Sort-Object Name -Descending | Select-Object -First 1
+ if ($pgDir -and (Test-Path "$($pgDir.FullName)\data")) {
+ $pgData = "$($pgDir.FullName)\data"
+ }
+ }
+ if ($pgData -and (Test-Command "pg_ctl")) {
+ Write-Info "Starting PostgreSQL with pg_ctl..."
+ pg_ctl start -D $pgData -l "$pgData\server.log"
+ Start-Sleep -Seconds 3
+ } else {
+ Write-Fail "Could not find or start PostgreSQL. Please start it manually."
+ Write-Host " Common locations: C:\Program Files\PostgreSQL\\data" -ForegroundColor Yellow
+ exit 1
+ }
+ }
+
+ # Verify it's now running
+ try {
+ $null = pg_isready 2>$null
+ if ($LASTEXITCODE -ne 0) { throw "not ready" }
+ Write-Ok "PostgreSQL is running"
+ } catch {
+ Write-Fail "PostgreSQL still not responding. Please start it manually and re-run."
+ exit 1
+ }
+}
+
+# On Windows, PostgreSQL installer creates a 'postgres' superuser by default.
+# Connect as postgres to create the app user/database.
+$env:PGPASSWORD = ""
+
+# Helper to run psql as the postgres superuser
+function Invoke-PgSuperuser {
+ param([string]$Sql)
+ # Try connecting as postgres user (default installer superuser)
+ $env:PGPASSWORD = "postgres"
+ $result = psql -h localhost -U postgres -tAc $Sql 2>$null
+ if ($LASTEXITCODE -ne 0) {
+ # Try without password (peer/trust auth)
+ $env:PGPASSWORD = ""
+ $result = psql -h localhost -U postgres -tAc $Sql 2>$null
+ }
+ return $result
+}
+
+function Invoke-PgSuperuserCmd {
+ param([string]$Sql)
+ $env:PGPASSWORD = "postgres"
+ psql -h localhost -U postgres -c $Sql 2>$null
+ if ($LASTEXITCODE -ne 0) {
+ $env:PGPASSWORD = ""
+ psql -h localhost -U postgres -c $Sql 2>$null
+ }
+}
+
+# Create user
+$userExists = Invoke-PgSuperuser "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'"
+if ($userExists -match "1") {
+ Write-Ok "Database user '$DB_USER' exists"
+} else {
+ Write-Info "Creating database user '$DB_USER'..."
+ Invoke-PgSuperuserCmd "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
+ if ($LASTEXITCODE -eq 0) {
+ Write-Ok "Database user '$DB_USER' created"
+ } else {
+ Write-Warn "Could not create user. You may need to set the postgres superuser password."
+ Write-Host " Try: psql -h localhost -U postgres -c `"CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';`"" -ForegroundColor Yellow
+ }
+}
+
+# Create database
+$dbExists = Invoke-PgSuperuser "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'"
+if ($dbExists -match "1") {
+ Write-Ok "Database '$DB_NAME' exists"
+} else {
+ Write-Info "Creating database '$DB_NAME'..."
+ Invoke-PgSuperuserCmd "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
+ if ($LASTEXITCODE -eq 0) {
+ Write-Ok "Database '$DB_NAME' created"
+ } else {
+ Write-Warn "Could not create database. Create it manually."
+ Write-Host " Try: psql -h localhost -U postgres -c `"CREATE DATABASE $DB_NAME OWNER $DB_USER;`"" -ForegroundColor Yellow
+ }
+}
+
+# Test connection
+$env:PGPASSWORD = $DB_PASS
+try {
+ $null = psql -h localhost -U $DB_USER -d $DB_NAME -c "SELECT 1" 2>$null
+ if ($LASTEXITCODE -eq 0) {
+ Write-Ok "Database connection verified"
+ } else {
+ throw "connection failed"
+ }
+} catch {
+ Write-Warn "Could not connect via localhost. Check pg_hba.conf allows password auth."
+ Write-Host " Add to pg_hba.conf: host all all 127.0.0.1/32 md5" -ForegroundColor Yellow
+}
+$env:PGPASSWORD = ""
+
+# ============================================
+# 3. Environment variables
+# ============================================
+Write-Host ""
+Write-Host "=== 3. Configure Environment Variables ===" -ForegroundColor Cyan
+
+$envFile = Join-Path $ProjectRoot ".env"
+
+if (Test-Path $envFile) {
+ Write-Ok ".env already exists, skipping interactive config"
+} else {
+ Write-Info "Creating .env -- press Enter to accept defaults, leave blank to skip"
+ Write-Host ""
+
+ # Database
+ Write-Host " -- Database --" -ForegroundColor Cyan
+ $defaultDbUrl = "postgres://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}?sslmode=disable"
+ $DATABASE_URL = Read-Default "DATABASE_URL" $defaultDbUrl
+
+ # JWT
+ Write-Host " -- JWT --" -ForegroundColor Cyan
+ # Generate random hex string for JWT secret
+ $jwtBytes = New-Object byte[] 32
+ [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($jwtBytes)
+ $defaultJwt = ($jwtBytes | ForEach-Object { $_.ToString("x2") }) -join ''
+ $JWT_SECRET_KEY = Read-Default "JWT_SECRET_KEY" $defaultJwt
+ $JWT_ACCESS_TOKEN_EXPIRE_MINUTES = Read-Default "JWT_ACCESS_TOKEN_EXPIRE_MINUTES" "30"
+ $JWT_REFRESH_TOKEN_EXPIRE_DAYS = Read-Default "JWT_REFRESH_TOKEN_EXPIRE_DAYS" "7"
+
+ # OpenAI
+ Write-Host " -- OpenAI Compatible API --" -ForegroundColor Cyan
+ $OPENAI_API_KEY = Read-Default "OPENAI_API_KEY" ""
+ $OPENAI_BASE_URL = Read-Default "OPENAI_BASE_URL" "https://api-inference.modelscope.cn/v1"
+ $OPENAI_MODEL = Read-Default "OPENAI_MODEL" "Qwen/Qwen3-235B-A22B"
+
+ # Server
+ Write-Host " -- Server --" -ForegroundColor Cyan
+ $PORT = Read-Default "PORT" "8000"
+
+ # Frontend
+ Write-Host " -- Frontend --" -ForegroundColor Cyan
+ $VITE_API_BASE_URL = Read-Default "VITE_API_BASE_URL" "http://localhost:8000"
+
+ # Admin
+ Write-Host " -- Initial Admin (optional) --" -ForegroundColor Cyan
+ $ADMIN_USERNAME = Read-Default "ADMIN_USERNAME" ""
+ $ADMIN_EMAIL = Read-Default "ADMIN_EMAIL" ""
+ $ADMIN_PASSWORD = Read-Default "ADMIN_PASSWORD" ""
+
+ # Write .env
+ @"
+# MomShell Environment Configuration
+
+# ==================== Database ====================
+DATABASE_URL=$DATABASE_URL
+
+# ==================== JWT ====================
+JWT_SECRET_KEY=$JWT_SECRET_KEY
+JWT_ACCESS_TOKEN_EXPIRE_MINUTES=$JWT_ACCESS_TOKEN_EXPIRE_MINUTES
+JWT_REFRESH_TOKEN_EXPIRE_DAYS=$JWT_REFRESH_TOKEN_EXPIRE_DAYS
+
+# ==================== OpenAI Compatible API ====================
+OPENAI_API_KEY=$OPENAI_API_KEY
+OPENAI_BASE_URL=$OPENAI_BASE_URL
+OPENAI_MODEL=$OPENAI_MODEL
+
+# ==================== Server ====================
+PORT=$PORT
+
+# ==================== Frontend ====================
+VITE_API_BASE_URL=$VITE_API_BASE_URL
+
+# ==================== Initial Admin (optional, created on first startup) ====================
+ADMIN_USERNAME=$ADMIN_USERNAME
+ADMIN_EMAIL=$ADMIN_EMAIL
+ADMIN_PASSWORD=$ADMIN_PASSWORD
+"@ | Set-Content -Path $envFile -Encoding UTF8
+
+ Write-Host ""
+ Write-Ok ".env created"
+}
+
+# ============================================
+# 4. Backend (Go)
+# ============================================
+Write-Host ""
+Write-Host "=== 4. Initialize Backend (Go) ===" -ForegroundColor Cyan
+
+# Set Go proxy to Chinese mirror if default proxy is unreachable
+$currentProxy = (go env GOPROXY) 2>$null
+if ($currentProxy -match "proxy\.golang\.org") {
+ Write-Info "Testing Go module proxy connectivity..."
+ try {
+ $null = Invoke-WebRequest -Uri "https://proxy.golang.org/" -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop
+ } catch {
+ Write-Warn "proxy.golang.org unreachable, switching to goproxy.cn"
+ go env -w GOPROXY=https://goproxy.cn,direct
+ Write-Ok "GOPROXY set to https://goproxy.cn,direct"
+ }
+}
+
+Write-Info "Downloading Go dependencies..."
+Push-Location (Join-Path $ProjectRoot "backend")
+go mod download
+if ($LASTEXITCODE -ne 0) {
+ Write-Fail "Failed to download Go dependencies"
+ Pop-Location
+ exit 1
+}
+Pop-Location
+Write-Ok "Backend dependencies installed"
+
+# ============================================
+# 5. Frontend (Vue)
+# ============================================
+Write-Host ""
+Write-Host "=== 5. Initialize Frontend (Vue) ===" -ForegroundColor Cyan
+
+# Set npm registry to Chinese mirror if default registry is unreachable
+$currentNpmRegistry = (npm config get registry) 2>$null
+if ($currentNpmRegistry -match "registry\.npmjs\.org") {
+ Write-Info "Testing npm registry connectivity..."
+ try {
+ $null = Invoke-WebRequest -Uri "https://registry.npmjs.org/" -TimeoutSec 5 -UseBasicParsing -ErrorAction Stop
+ } catch {
+ Write-Warn "registry.npmjs.org unreachable, switching to npmmirror.com"
+ npm config set registry https://registry.npmmirror.com
+ Write-Ok "npm registry set to https://registry.npmmirror.com"
+ }
+}
+
+Write-Info "Installing npm dependencies..."
+$frontendDir = Join-Path $ProjectRoot "frontend"
+$nodeModules = Join-Path $frontendDir "node_modules"
+if (Test-Path $nodeModules) {
+ Write-Info "Cleaning stale node_modules..."
+ Remove-Item $nodeModules -Recurse -Force
+}
+# Use Chinese mirror for Puppeteer's Chromium download if needed
+if (-not $env:PUPPETEER_DOWNLOAD_BASE_URL) {
+ $env:PUPPETEER_DOWNLOAD_BASE_URL = "https://registry.npmmirror.com/mirrors/chrome-for-testing"
+}
+Push-Location $frontendDir
+npm install
+if ($LASTEXITCODE -ne 0) {
+ Write-Fail "npm install failed"
+ Pop-Location
+ exit 1
+}
+Pop-Location
+Write-Ok "Frontend dependencies installed"
+
+# ============================================
+# 6. Pre-commit hooks
+# ============================================
+Write-Host ""
+Write-Host "=== 6. Install Pre-commit Hooks ===" -ForegroundColor Cyan
+
+if (Test-Command "pre-commit") {
+ Set-Location $ProjectRoot
+ pre-commit install
+ Write-Ok "Pre-commit hooks installed"
+} else {
+ Write-Warn "pre-commit not installed"
+ if (Test-Command "pip") {
+ $answer = Read-Host " Install pre-commit via pip? [Y/n]"
+ if ($answer -notmatch '^[nN]') {
+ Write-Info "Installing pre-commit..."
+ pip install pre-commit 2>$null
+ if (Test-Command "pre-commit") {
+ Set-Location $ProjectRoot
+ pre-commit install
+ Write-Ok "Pre-commit hooks installed"
+ } else {
+ Write-Warn "Installation failed, skipping hook setup"
+ }
+ } else {
+ Write-Warn "Skipping hook setup"
+ }
+ } else {
+ Write-Warn "pip not found, skipping hook setup"
+ Write-Host " Install: pip install pre-commit; pre-commit install" -ForegroundColor Yellow
+ }
+}
+
+# ============================================
+# 7. Verify
+# ============================================
+Write-Host ""
+Write-Host "=== 7. Verify ===" -ForegroundColor Cyan
+
+Push-Location (Join-Path $ProjectRoot "backend")
+$buildResult = go build ./... 2>&1
+if ($LASTEXITCODE -eq 0) {
+ Write-Ok "Backend build passed"
+} else {
+ Write-Warn "Backend build failed (database config may be missing)"
+}
+Pop-Location
+
+if (Test-Path (Join-Path $ProjectRoot "frontend\node_modules")) {
+ Write-Ok "Frontend node_modules installed"
+} else {
+ Write-Warn "Frontend node_modules not found"
+}
+
+# ============================================
+# Done
+# ============================================
+Write-Host ""
+Write-Host "+==============================================+" -ForegroundColor Green
+Write-Host "| Setup Complete! |" -ForegroundColor Green
+Write-Host "+==============================================+" -ForegroundColor Green
+Write-Host ""
+Write-Host "Next steps:"
+Write-Host ""
+Write-Host " 1. Start development servers:"
+Write-Host " make dev-backend # Backend http://localhost:8000"
+Write-Host " make dev-frontend # Frontend http://localhost:5173"
+Write-Host ""
+Write-Host " 2. Admin panel: http://localhost:8000/admin"
+Write-Host ""
+Write-Host " 3. Edit .env anytime to update configuration"
+Write-Host ""
diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh
index ab59ebc5..4d01845c 100755
--- a/scripts/dev-setup.sh
+++ b/scripts/dev-setup.sh
@@ -174,31 +174,49 @@ if pg_isready -q 2>/dev/null; then
success "PostgreSQL is running"
else
warn "PostgreSQL is not running, attempting to start..."
- if sudo systemctl start postgresql 2>/dev/null; then
+ if [[ "$OSTYPE" == darwin* ]]; then
+ brew services start postgresql 2>/dev/null || brew services start postgresql@* 2>/dev/null
+ else
+ sudo systemctl start postgresql 2>/dev/null
+ fi
+ if pg_isready -q 2>/dev/null; then
success "PostgreSQL started"
else
fail "Could not start PostgreSQL"
- echo " Try: sudo systemctl start postgresql"
- echo " Arch first-time: sudo -u postgres initdb -D /var/lib/postgres/data"
+ if [[ "$OSTYPE" == darwin* ]]; then
+ echo " Try: brew services start postgresql"
+ else
+ echo " Try: sudo systemctl start postgresql"
+ echo " Arch first-time: sudo -u postgres initdb -D /var/lib/postgres/data"
+ fi
exit 1
fi
fi
+# Determine how to run psql as superuser
+# macOS Homebrew: psql runs as the current user who is already a superuser
+# Linux: need sudo -u postgres
+if [[ "$OSTYPE" == darwin* ]]; then
+ PG_SUDO="psql -d postgres"
+else
+ PG_SUDO="sudo -u postgres psql"
+fi
+
# Create database user if not exists
-if sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" 2>/dev/null | grep -q 1; then
+if $PG_SUDO -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" 2>/dev/null | grep -q 1; then
success "Database user '$DB_USER' exists"
else
info "Creating database user '$DB_USER'..."
- sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';" 2>/dev/null
+ $PG_SUDO -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';" 2>/dev/null
success "Database user '$DB_USER' created"
fi
# Create database if not exists
-if sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" 2>/dev/null | grep -q 1; then
+if $PG_SUDO -tAc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" 2>/dev/null | grep -q 1; then
success "Database '$DB_NAME' exists"
else
info "Creating database '$DB_NAME'..."
- sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;" 2>/dev/null
+ $PG_SUDO -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;" 2>/dev/null
success "Database '$DB_NAME' created"
fi
@@ -264,6 +282,14 @@ else
ask "OPENAI_MODEL" "Qwen/Qwen3-235B-A22B"
OPENAI_MODEL="$REPLY"
+ ask "IMAGE_MODEL (image generation)" "Tongyi-MAI/Z-Image-Turbo"
+ IMAGE_MODEL="$REPLY"
+
+ # Firecrawl
+ echo -e " ${BLUE}-- Firecrawl (Web Search) --${NC}"
+ ask "FIRECRAWL_API_KEY" ""
+ FIRECRAWL_API_KEY="$REPLY"
+
# Server
echo -e " ${BLUE}-- Server --${NC}"
ask "PORT" "8000"
@@ -301,6 +327,10 @@ JWT_REFRESH_TOKEN_EXPIRE_DAYS=$JWT_REFRESH_TOKEN_EXPIRE_DAYS
OPENAI_API_KEY=$OPENAI_API_KEY
OPENAI_BASE_URL=$OPENAI_BASE_URL
OPENAI_MODEL=$OPENAI_MODEL
+IMAGE_MODEL=$IMAGE_MODEL
+
+# ==================== Firecrawl (Web Search) ====================
+FIRECRAWL_API_KEY=$FIRECRAWL_API_KEY
# ==================== Server ====================
PORT=$PORT
@@ -324,8 +354,19 @@ fi
echo ""
echo -e "${BLUE}=== 4. Initialize Backend (Go) ===${NC}"
+# Set Go proxy to Chinese mirror if default proxy is unreachable
+CURRENT_GOPROXY=$(go env GOPROXY)
+if [[ "$CURRENT_GOPROXY" == *"proxy.golang.org"* ]]; then
+ info "Testing Go module proxy connectivity..."
+ if ! curl -sf --connect-timeout 5 "https://proxy.golang.org/" >/dev/null 2>&1; then
+ warn "proxy.golang.org unreachable, switching to goproxy.cn"
+ go env -w GOPROXY=https://goproxy.cn,direct
+ success "GOPROXY set to https://goproxy.cn,direct"
+ fi
+fi
+
info "Downloading Go dependencies..."
-cd backend && go mod download && cd "$PROJECT_ROOT"
+(cd "$PROJECT_ROOT/backend" && go mod download)
success "Backend dependencies installed"
# ============================================
@@ -334,8 +375,25 @@ success "Backend dependencies installed"
echo ""
echo -e "${BLUE}=== 5. Initialize Frontend (Vue) ===${NC}"
+# Set npm registry to Chinese mirror if default registry is unreachable
+CURRENT_NPM_REGISTRY=$(npm config get registry 2>/dev/null)
+if [[ "$CURRENT_NPM_REGISTRY" == *"registry.npmjs.org"* ]]; then
+ info "Testing npm registry connectivity..."
+ if ! curl -sf --connect-timeout 5 "https://registry.npmjs.org/" >/dev/null 2>&1; then
+ warn "registry.npmjs.org unreachable, switching to npmmirror.com"
+ npm config set registry https://registry.npmmirror.com
+ success "npm registry set to https://registry.npmmirror.com"
+ fi
+fi
+
info "Installing npm dependencies..."
-cd frontend && npm install && cd "$PROJECT_ROOT"
+if [ -d "$PROJECT_ROOT/frontend/node_modules" ]; then
+ info "Cleaning stale node_modules..."
+ rm -rf "$PROJECT_ROOT/frontend/node_modules"
+fi
+# Use Chinese mirror for Puppeteer's Chromium download if needed
+export PUPPETEER_DOWNLOAD_BASE_URL="${PUPPETEER_DOWNLOAD_BASE_URL:-https://registry.npmmirror.com/mirrors/chrome-for-testing}"
+(cd "$PROJECT_ROOT/frontend" && npm install)
success "Frontend dependencies installed"
# ============================================
@@ -377,14 +435,13 @@ fi
echo ""
echo -e "${BLUE}=== 7. Verify ===${NC}"
-if cd backend && go build ./... 2>/dev/null; then
+if (cd "$PROJECT_ROOT/backend" && go build ./... 2>/dev/null); then
success "Backend build passed"
else
warn "Backend build failed (database config may be missing)"
fi
-cd "$PROJECT_ROOT"
-if [ -d frontend/node_modules ]; then
+if [ -d "$PROJECT_ROOT/frontend/node_modules" ]; then
success "Frontend node_modules installed"
else
warn "Frontend node_modules not found"