diff --git a/.env.example b/.env.example
index 692eca54..7510d285 100644
--- a/.env.example
+++ b/.env.example
@@ -21,7 +21,7 @@ JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
# ==================== OpenAI Compatible API ====================
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api-inference.modelscope.cn/v1
-OPENAI_MODEL=Qwen/Qwen2.5-72B-Instruct
+OPENAI_MODEL=Qwen/Qwen3-235B-A22B
# ==================== Server ====================
PORT=8000
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index cf89cf80..1da07a50 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -16,29 +16,25 @@
### Self-Check Checklist
-> Run `make check` to execute all checks at once.
-
**Backend (Go)**:
- [ ] `go build ./...` passes
- [ ] `go vet ./...` passes
- [ ] `gofmt` produces no diff
-- [ ] `golangci-lint run` passes (or no new issues)
**Frontend (Vue)**:
- [ ] `npm run lint` passes
- [ ] `npm run typecheck` passes
-- [ ] `npm run build` succeeds
**General**:
- [ ] Removed all temporary debug output
- [ ] No sensitive data in the code
-- [ ] CI checks pass
### Test Steps
1. Pull branch and install dependencies:
```bash
- make install
+ cd backend && go mod download
+ cd ../frontend && npm install
```
2. Start the application:
```bash
diff --git a/.gitignore b/.gitignore
index 76ebaa94..cef5d8dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
# =============================================================================
backend/bin/
backend/.cache/
+uploads/
# =============================================================================
# Node.js / Vue Frontend
@@ -49,6 +50,15 @@ coverage/
# =============================================================================
archive-legacy-*.tar.gz
+# =============================================================================
+# Claude Code / OMC
+# =============================================================================
+.claude
+.omc/
+CLAUDE.md
+PROGRESS.md
+SKILL.md
+
# =============================================================================
# Misc
# =============================================================================
@@ -56,4 +66,3 @@ archive-legacy-*.tar.gz
*.temp
tmp/
temp/
-.claude
diff --git a/Dockerfile b/Dockerfile
index 4554d0fa..af18972c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# ---- Stage 1: Build frontend ----
-FROM node:25-alpine AS frontend-builder
+FROM node:24-alpine AS frontend-builder
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
@@ -9,7 +9,7 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build
# ---- Stage 2: Build backend ----
-FROM golang:1.26-alpine AS backend-builder
+FROM golang:1.23-alpine AS backend-builder
WORKDIR /app
COPY backend/go.mod backend/go.sum ./
RUN go mod download
diff --git a/Makefile b/Makefile
index 5ebdfd06..0e98499b 100644
--- a/Makefile
+++ b/Makefile
@@ -4,6 +4,11 @@
docker-up docker-down docker-logs docker-build \
postgres-up postgres-down postgres-logs db-reset deps-lock deps-update clean clean-all help
+# Database config (must match dev-setup.sh / .env)
+DB_USER ?= momshell
+DB_PASS ?= momshell
+DB_NAME ?= momshell
+
# Colors for terminal output
CYAN := \033[36m
GREEN := \033[32m
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 163c99a0..c466e217 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -64,7 +64,7 @@ func main() {
chatClient = openai.NewClient("dummy", cfg.OpenAIBaseURL, cfg.OpenAIModel)
}
chatService := service.NewChatService(chatClient, chatRepo)
- echoService := service.NewEchoService(chatClient, echoRepo)
+ echoService := service.NewEchoService(chatClient, echoRepo, userRepo)
userService := service.NewUserService(
db, userRepo, questionRepo, answerRepo,
@@ -131,7 +131,8 @@ func createInitialAdmin(cfg *config.Config, userRepo *repository.UserRepo) {
Email: cfg.AdminEmail,
PasswordHash: hash,
Nickname: "Admin",
- Role: model.RoleAdmin,
+ Role: model.RoleMom,
+ IsAdmin: true,
IsActive: true,
}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 4d1dfadd..fb6a0b66 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -33,9 +33,9 @@ type Config struct {
}
func Load() *Config {
- // Load .env file, overriding any existing env vars
- // (ensures local .env is the source of truth for dev)
+ // Load .env file from project root and backend dir (project root takes precedence)
_ = godotenv.Overload()
+ _ = godotenv.Overload("../.env")
cfg := &Config{
DatabaseURL: getEnv("DATABASE_URL", "postgres://user:password@localhost:5432/momshell?sslmode=disable"),
diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go
index 7f27b6cc..78580b4c 100644
--- a/backend/internal/database/migrate.go
+++ b/backend/internal/database/migrate.go
@@ -6,7 +6,7 @@ import (
)
func Migrate(db *gorm.DB) error {
- return db.AutoMigrate(
+ if err := db.AutoMigrate(
&model.User{},
&model.UserCertification{},
&model.Tag{},
@@ -20,5 +20,15 @@ func Migrate(db *gorm.DB) error {
&model.ChatMemory{},
&model.IdentityTag{},
&model.Memoir{},
- )
+ ); err != nil {
+ return err
+ }
+
+ // Migrate legacy role='admin' users to is_admin flag
+ db.Model(&model.User{}).Where("role = ?", "admin").Updates(map[string]interface{}{
+ "is_admin": true,
+ "role": "mom",
+ })
+
+ return nil
}
diff --git a/backend/internal/dto/admin.go b/backend/internal/dto/admin.go
index 61187d8c..37c9d03c 100644
--- a/backend/internal/dto/admin.go
+++ b/backend/internal/dto/admin.go
@@ -17,6 +17,7 @@ type AdminUserListItem struct {
Email string `json:"email"`
Nickname string `json:"nickname"`
Role string `json:"role"`
+ IsAdmin bool `json:"is_admin"`
IsActive bool `json:"is_active"`
IsBanned bool `json:"is_banned"`
IsGuest bool `json:"is_guest"`
@@ -31,6 +32,7 @@ type AdminUserDetail struct {
Nickname string `json:"nickname"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
+ IsAdmin bool `json:"is_admin"`
ShellCode *string `json:"shell_code"`
IsGuest bool `json:"is_guest"`
IsActive bool `json:"is_active"`
@@ -58,6 +60,7 @@ type AdminCreateUser struct {
// AdminUserUpdate is the request body for updating a user via admin
type AdminUserUpdate struct {
Role *string `json:"role"`
+ IsAdmin *bool `json:"is_admin"`
IsActive *bool `json:"is_active"`
IsBanned *bool `json:"is_banned"`
Nickname *string `json:"nickname"`
diff --git a/backend/internal/dto/auth.go b/backend/internal/dto/auth.go
index 6f7d870b..2b2e3504 100644
--- a/backend/internal/dto/auth.go
+++ b/backend/internal/dto/auth.go
@@ -37,6 +37,7 @@ type UserResponse struct {
Nickname string `json:"nickname"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
+ IsAdmin bool `json:"is_admin"`
IsCertified bool `json:"is_certified"`
CertificationTitle *string `json:"certification_title"`
BabyBirthDate *time.Time `json:"baby_birth_date"`
@@ -63,5 +64,5 @@ type ResetPasswordRequest struct {
// UpdateRoleRequest is the request body for updating user role
type UpdateRoleRequest struct {
- Role string `json:"role" binding:"required,oneof=mom dad family"`
+ Role string `json:"role" binding:"required,oneof=mom dad"`
}
diff --git a/backend/internal/dto/common.go b/backend/internal/dto/common.go
index 9ac04c49..16cd5069 100644
--- a/backend/internal/dto/common.go
+++ b/backend/internal/dto/common.go
@@ -52,6 +52,7 @@ type AuthorInfo struct {
Nickname string `json:"nickname"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
+ DisplayTag string `json:"display_tag"`
IsCertified bool `json:"is_certified"`
CertificationTitle *string `json:"certification_title"`
}
diff --git a/backend/internal/dto/user.go b/backend/internal/dto/user.go
index 154546e8..216f2941 100644
--- a/backend/internal/dto/user.go
+++ b/backend/internal/dto/user.go
@@ -4,15 +4,27 @@ import "time"
// UserProfile is the response for user profile
type UserProfile struct {
- ID string `json:"id"`
- Nickname string `json:"nickname"`
- Email string `json:"email"`
- AvatarURL *string `json:"avatar_url"`
- Role string `json:"role"`
- IsCertified bool `json:"is_certified"`
- CertificationTitle *string `json:"certification_title"`
- Stats UserStats `json:"stats"`
- CreatedAt time.Time `json:"created_at"`
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Nickname string `json:"nickname"`
+ Email string `json:"email"`
+ AvatarURL *string `json:"avatar_url"`
+ Role string `json:"role"`
+ IsAdmin bool `json:"is_admin"`
+ ShellCode *string `json:"shell_code"`
+ Partner *PartnerInfo `json:"partner"`
+ IsCertified bool `json:"is_certified"`
+ CertificationTitle *string `json:"certification_title"`
+ Stats UserStats `json:"stats"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// PartnerInfo is the partner summary shown in profile
+type PartnerInfo struct {
+ ID string `json:"id"`
+ Nickname string `json:"nickname"`
+ AvatarURL *string `json:"avatar_url"`
+ Role string `json:"role"`
}
// UserStats holds user statistics
@@ -25,8 +37,14 @@ type UserStats struct {
// UserProfileUpdate is the request body for updating user profile
type UserProfileUpdate struct {
+ Username *string `json:"username" binding:"omitempty,min=3,max=50"`
Nickname *string `json:"nickname" binding:"omitempty,min=1,max=50"`
Email *string `json:"email" binding:"omitempty,email"`
AvatarURL *string `json:"avatar_url"`
- Role *string `json:"role" binding:"omitempty,oneof=mom dad family"`
+ Role *string `json:"role" binding:"omitempty,oneof=mom dad"`
+}
+
+// BindPartnerRequest is the request body for binding a partner via shell code
+type BindPartnerRequest struct {
+ ShellCode string `json:"shell_code" binding:"required"`
}
diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go
index 23960be0..33bc6222 100644
--- a/backend/internal/handler/admin.go
+++ b/backend/internal/handler/admin.go
@@ -7,7 +7,6 @@ import (
"github.com/momshell/backend/internal/admin"
"github.com/momshell/backend/internal/dto"
"github.com/momshell/backend/internal/middleware"
- "github.com/momshell/backend/internal/model"
"github.com/momshell/backend/internal/service"
)
@@ -37,7 +36,7 @@ func (h *AdminHandler) requireAdmin(c *gin.Context) (string, bool) {
return "", false
}
- if user.Role != model.RoleAdmin {
+ if !user.IsAdmin {
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
return "", false
}
diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go
index 1108af8a..cc155791 100644
--- a/backend/internal/handler/user.go
+++ b/backend/internal/handler/user.go
@@ -1,7 +1,10 @@
package handler
import (
+ "fmt"
"net/http"
+ "os"
+ "path/filepath"
"github.com/gin-gonic/gin"
"github.com/momshell/backend/internal/dto"
@@ -9,6 +12,15 @@ import (
"github.com/momshell/backend/internal/service"
)
+const maxAvatarSize = 2 << 20 // 2 MB
+
+var allowedImageTypes = map[string]bool{
+ "image/jpeg": true,
+ "image/png": true,
+ "image/gif": true,
+ "image/webp": true,
+}
+
type UserHandler struct {
userService *service.UserService
}
@@ -48,6 +60,114 @@ func (h *UserHandler) UpdateMe(c *gin.Context) {
c.JSON(http.StatusOK, profile)
}
+// POST /api/v1/community/users/me/avatar
+func (h *UserHandler) UploadAvatar(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+
+ file, header, err := c.Request.FormFile("avatar")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的图片"})
+ return
+ }
+ defer func() { _ = file.Close() }()
+
+ if header.Size > maxAvatarSize {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "图片大小不能超过 2MB"})
+ return
+ }
+
+ contentType := header.Header.Get("Content-Type")
+ if !allowedImageTypes[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/avatars"
+ if err := os.MkdirAll(uploadDir, 0o755); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "上传失败"})
+ return
+ }
+
+ filename := userID + ext
+ savePath := filepath.Join(uploadDir, filename)
+
+ // Remove old avatar files with different extensions
+ for _, e := range []string{".jpg", ".png", ".gif", ".webp"} {
+ if e != ext {
+ _ = os.Remove(filepath.Join(uploadDir, userID+e))
+ }
+ }
+
+ if err := c.SaveUploadedFile(header, savePath); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "上传失败"})
+ return
+ }
+
+ avatarURL := fmt.Sprintf("/uploads/avatars/%s", filename)
+ // Add cache-busting query param so browser refreshes the image
+ avatarURLWithBust := fmt.Sprintf("%s?v=%d", avatarURL, header.Size)
+
+ profile, err := h.userService.UpdateProfile(userID, dto.UserProfileUpdate{
+ AvatarURL: &avatarURLWithBust,
+ })
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, profile)
+}
+
+// POST /api/v1/community/users/me/shell-code
+func (h *UserHandler) GenerateShellCode(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ profile, err := h.userService.GenerateShellCode(userID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, profile)
+}
+
+// POST /api/v1/community/users/me/bind
+func (h *UserHandler) BindPartner(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+
+ var req dto.BindPartnerRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ profile, err := h.userService.BindPartner(userID, req.ShellCode)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, profile)
+}
+
+// DELETE /api/v1/community/users/me/bind
+func (h *UserHandler) UnbindPartner(c *gin.Context) {
+ userID := middleware.GetUserID(c)
+ profile, err := h.userService.UnbindPartner(userID)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, profile)
+}
+
// GET /api/v1/community/users/me/questions
func (h *UserHandler) GetMyQuestions(c *gin.Context) {
userID := middleware.GetUserID(c)
diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go
index c9595944..30421659 100644
--- a/backend/internal/model/user.go
+++ b/backend/internal/model/user.go
@@ -14,11 +14,9 @@ const (
RoleGuest UserRole = "guest"
RoleMom UserRole = "mom"
RoleDad UserRole = "dad"
- RoleFamily UserRole = "family"
RoleCertifiedDoctor UserRole = "certified_doctor"
RoleCertifiedTherapist UserRole = "certified_therapist"
RoleCertifiedNurse UserRole = "certified_nurse"
- RoleAdmin UserRole = "admin"
RoleAIAssistant UserRole = "ai_assistant"
)
@@ -29,9 +27,8 @@ var ProfessionalRoles = map[UserRole]bool{
}
var FamilyRoles = map[UserRole]bool{
- RoleMom: true,
- RoleDad: true,
- RoleFamily: true,
+ RoleMom: true,
+ RoleDad: true,
}
// CertificationStatus enum
@@ -55,6 +52,7 @@ type User struct {
Role UserRole `gorm:"type:varchar(30);default:'mom'" json:"role"`
ShellCode *string `gorm:"type:varchar(8);uniqueIndex" json:"shell_code"`
IsGuest bool `gorm:"default:false" json:"is_guest"`
+ IsAdmin bool `gorm:"default:false" json:"is_admin"`
PartnerID *string `gorm:"type:varchar(36)" json:"partner_id"`
// Postpartum info
diff --git a/backend/internal/repository/echo.go b/backend/internal/repository/echo.go
index fd722dde..ffa4ad1b 100644
--- a/backend/internal/repository/echo.go
+++ b/backend/internal/repository/echo.go
@@ -49,6 +49,19 @@ func (r *EchoRepo) FindMemoirsByUserID(userID string, limit, offset int) ([]mode
return memoirs, total, err
}
+func (r *EchoRepo) FindMemoirsByUserIDs(userIDs []string, limit, offset int) ([]model.Memoir, int64, error) {
+ query := r.db.Model(&model.Memoir{}).Where("user_id IN ?", userIDs)
+
+ var total int64
+ if err := query.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ var memoirs []model.Memoir
+ err := query.Order("created_at desc").Offset(offset).Limit(limit).Find(&memoirs).Error
+ return memoirs, total, err
+}
+
func (r *EchoRepo) CreateMemoir(memoir *model.Memoir) error {
return r.db.Create(memoir).Error
}
diff --git a/backend/internal/repository/user.go b/backend/internal/repository/user.go
index fcc2e203..3b74453b 100644
--- a/backend/internal/repository/user.go
+++ b/backend/internal/repository/user.go
@@ -50,6 +50,27 @@ func (r *UserRepo) ExistsByUsernameOrEmail(username, email string) (bool, error)
return count > 0, err
}
+func (r *UserRepo) ExistsByUsername(username, excludeUserID string) (bool, error) {
+ var count int64
+ err := r.db.Model(&model.User{}).Where("username = ? AND id != ?", username, excludeUserID).Count(&count).Error
+ return count > 0, err
+}
+
+func (r *UserRepo) ExistsByEmail(email, excludeUserID string) (bool, error) {
+ var count int64
+ err := r.db.Model(&model.User{}).Where("email = ? AND id != ?", email, excludeUserID).Count(&count).Error
+ return count > 0, err
+}
+
+func (r *UserRepo) FindByShellCode(code string) (*model.User, error) {
+ var user model.User
+ err := r.db.Where("shell_code = ?", code).First(&user).Error
+ if err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
func (r *UserRepo) Create(user *model.User) error {
return r.db.Create(user).Error
}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index e4f3ecc8..07f068d3 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -26,6 +26,9 @@ func Setup(
c.JSON(200, gin.H{"status": "ok"})
})
+ // Serve uploaded files
+ r.Static("/uploads", "./uploads")
+
// Admin panel (HTML page, no auth required for serving the page)
r.GET("/admin", adminHandler.ServeAdminPage)
@@ -116,6 +119,10 @@ func Setup(
{
users.GET("/me", userHandler.GetMe)
users.PUT("/me", userHandler.UpdateMe)
+ users.POST("/me/avatar", userHandler.UploadAvatar)
+ users.POST("/me/shell-code", userHandler.GenerateShellCode)
+ users.POST("/me/bind", userHandler.BindPartner)
+ users.DELETE("/me/bind", userHandler.UnbindPartner)
users.GET("/me/questions", userHandler.GetMyQuestions)
users.GET("/me/answers", userHandler.GetMyAnswers)
}
diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go
index cf75fd14..84e3b12f 100644
--- a/backend/internal/service/admin.go
+++ b/backend/internal/service/admin.go
@@ -87,6 +87,7 @@ func (s *AdminService) ListUsers(params dto.AdminUserListParams) (*dto.Paginated
Email: u.Email,
Nickname: u.Nickname,
Role: string(u.Role),
+ IsAdmin: u.IsAdmin,
IsActive: u.IsActive,
IsBanned: u.IsBanned,
IsGuest: u.IsGuest,
@@ -112,6 +113,7 @@ func (s *AdminService) GetUser(id string) (*dto.AdminUserDetail, error) {
Nickname: user.Nickname,
AvatarURL: user.AvatarURL,
Role: string(user.Role),
+ IsAdmin: user.IsAdmin,
ShellCode: user.ShellCode,
IsGuest: user.IsGuest,
IsActive: user.IsActive,
@@ -169,8 +171,8 @@ func (s *AdminService) CreateUser(req dto.AdminCreateUser) (*dto.AdminUserDetail
func (s *AdminService) UpdateUser(id, adminID string, req dto.AdminUserUpdate) (*dto.AdminUserDetail, error) {
if id == adminID {
// Prevent self-demotion / self-ban
- if req.Role != nil && *req.Role != string(model.RoleAdmin) {
- return nil, errors.New("不能降低自己的管理员角色")
+ if req.IsAdmin != nil && !*req.IsAdmin {
+ return nil, errors.New("不能取消自己的管理员权限")
}
if req.IsActive != nil && !*req.IsActive {
return nil, errors.New("不能停用自己的账号")
@@ -188,6 +190,9 @@ func (s *AdminService) UpdateUser(id, adminID string, req dto.AdminUserUpdate) (
if req.Role != nil {
user.Role = model.UserRole(*req.Role)
}
+ if req.IsAdmin != nil {
+ user.IsAdmin = *req.IsAdmin
+ }
if req.IsActive != nil {
user.IsActive = *req.IsActive
}
diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go
index b1e1222e..1c215b4b 100644
--- a/backend/internal/service/auth.go
+++ b/backend/internal/service/auth.go
@@ -159,17 +159,17 @@ func (s *AuthService) UpdateRole(userID, role string) (*dto.UserResponse, error)
return nil, errors.New("用户不存在")
}
- // Check if user is a professional or admin
+ // Check if user is a professional
if model.ProfessionalRoles[user.Role] {
return nil, errors.New("认证专业人员不能修改角色")
}
- if user.Role == model.RoleAdmin {
- return nil, errors.New("管理员不能通过此接口修改角色")
+ if user.PartnerID != nil {
+ return nil, errors.New("已绑定伴侣,无法更改身份")
}
newRole := model.UserRole(role)
if !model.FamilyRoles[newRole] {
- return nil, errors.New("角色只能是: mom, dad, family")
+ return nil, errors.New("角色只能是: mom, dad")
}
user.Role = newRole
@@ -210,6 +210,7 @@ func (s *AuthService) buildUserResponse(user *model.User) *dto.UserResponse {
Nickname: user.Nickname,
AvatarURL: user.AvatarURL,
Role: string(user.Role),
+ IsAdmin: user.IsAdmin,
BabyBirthDate: user.BabyBirthDate,
PostpartumWeeks: user.PostpartumWeeks,
CreatedAt: user.CreatedAt,
diff --git a/backend/internal/service/community.go b/backend/internal/service/community.go
index 2ff15b85..5528b6e1 100644
--- a/backend/internal/service/community.go
+++ b/backend/internal/service/community.go
@@ -75,9 +75,38 @@ func (s *CommunityService) BuildAuthorInfo(user *model.User) dto.AuthorInfo {
}
}
+ // Compute display tag: additional tag takes priority over base tag
+ info.DisplayTag = displayTag(user)
+
return info
}
+// displayTag returns the tag to show in community.
+// Additional tags (admin, certified, ai_assistant) override the base tag (溯源者/守护者).
+func displayTag(user *model.User) string {
+ if user.Certification != nil && user.Certification.Status == model.CertApproved {
+ switch user.Certification.CertificationType {
+ case model.RoleCertifiedDoctor:
+ return "认证医师"
+ case model.RoleCertifiedTherapist:
+ return "认证心理师"
+ case model.RoleCertifiedNurse:
+ return "认证护士"
+ }
+ }
+ if user.IsAdmin {
+ return "管理员"
+ }
+ if user.Role == model.RoleAIAssistant {
+ return "AI 助手"
+ }
+ // Base tag
+ if user.Role == model.RoleDad {
+ return "守护者"
+ }
+ return "溯源者"
+}
+
func buildTagInfo(tag model.Tag) dto.TagInfo {
return dto.TagInfo{ID: tag.ID, Name: tag.Name, Slug: tag.Slug}
}
@@ -285,7 +314,7 @@ func (s *CommunityService) UpdateQuestion(questionID string, req dto.QuestionUpd
return nil, err
}
- if q.AuthorID != user.ID && user.Role != model.RoleAdmin {
+ if q.AuthorID != user.ID && !user.IsAdmin {
return nil, errors.New("无权修改此问题")
}
@@ -338,7 +367,7 @@ func (s *CommunityService) DeleteQuestion(questionID string, user *model.User) e
return err
}
- if q.AuthorID != user.ID && user.Role != model.RoleAdmin {
+ if q.AuthorID != user.ID && !user.IsAdmin {
return errors.New("无权删除此问题")
}
@@ -408,7 +437,7 @@ func (s *CommunityService) CreateAnswer(questionID string, req dto.AnswerCreate,
// Check permission for professional channel
if q.Channel == model.ChannelProfessional {
isAuthor := q.AuthorID == author.ID
- if !s.IsCertifiedProfessional(author) && author.Role != model.RoleAdmin && !isAuthor {
+ if !s.IsCertifiedProfessional(author) && !author.IsAdmin && !isAuthor {
return nil, errors.New("专业频道仅限认证专业人士回答")
}
}
@@ -459,7 +488,7 @@ func (s *CommunityService) UpdateAnswer(answerID string, req dto.AnswerUpdate, u
return nil, err
}
- if a.AuthorID != user.ID && user.Role != model.RoleAdmin {
+ if a.AuthorID != user.ID && !user.IsAdmin {
return nil, errors.New("无权修改此回答")
}
@@ -493,7 +522,7 @@ func (s *CommunityService) DeleteAnswer(answerID string, user *model.User) error
isAnswerAuthor := a.AuthorID == user.ID
isQuestionAuthor := a.Question.AuthorID == user.ID
- isAdmin := user.Role == model.RoleAdmin
+ isAdmin := user.IsAdmin
if !isAnswerAuthor && !isQuestionAuthor && !isAdmin {
return errors.New("无权删除此回答")
@@ -523,69 +552,26 @@ func (s *CommunityService) GetComments(answerID, currentUserID string) ([]dto.Co
likedIDs, _ = s.interactionRepo.FindLikedTargetIDs(currentUserID, "comment", commentIDs)
}
- // Build map for nested structure
- commentMap := make(map[string]*model.Comment)
- for i := range comments {
- commentMap[comments[i].ID] = &comments[i]
- }
-
- // Find root ancestor
- var findRootID func(string) string
- findRootID = func(id string) string {
- c, ok := commentMap[id]
- if !ok || c.ParentID == nil {
- return id
- }
- return findRootID(*c.ParentID)
- }
-
- rootItems := make(map[string]*dto.CommentListItem)
+ // Build flat list in chronological order, with reply_to_user resolved
var result []dto.CommentListItem
-
- // First pass: root comments
- for _, c := range comments {
- if c.ParentID == nil {
- item := dto.CommentListItem{
- ID: c.ID,
- AnswerID: c.AnswerID,
- Author: s.BuildAuthorInfo(&c.Author),
- Content: c.Content,
- ParentID: nil,
- LikeCount: c.LikeCount,
- IsLiked: likedIDs[c.ID],
- CreatedAt: c.CreatedAt,
- Replies: []dto.CommentListItem{},
- }
- result = append(result, item)
- rootItems[c.ID] = &result[len(result)-1]
- }
- }
-
- // Second pass: replies
for _, c := range comments {
- if c.ParentID != nil {
- rootID := findRootID(c.ID)
- if root, ok := rootItems[rootID]; ok {
- var replyToUser *dto.AuthorInfo
- if c.ReplyToUser != nil {
- info := s.BuildAuthorInfo(c.ReplyToUser)
- replyToUser = &info
- }
- reply := dto.CommentListItem{
- ID: c.ID,
- AnswerID: c.AnswerID,
- Author: s.BuildAuthorInfo(&c.Author),
- Content: c.Content,
- ParentID: &rootID,
- ReplyToUser: replyToUser,
- LikeCount: c.LikeCount,
- IsLiked: likedIDs[c.ID],
- CreatedAt: c.CreatedAt,
- Replies: []dto.CommentListItem{},
- }
- root.Replies = append(root.Replies, reply)
- }
- }
+ var replyToUser *dto.AuthorInfo
+ if c.ReplyToUser != nil {
+ info := s.BuildAuthorInfo(c.ReplyToUser)
+ replyToUser = &info
+ }
+ result = append(result, dto.CommentListItem{
+ ID: c.ID,
+ AnswerID: c.AnswerID,
+ Author: s.BuildAuthorInfo(&c.Author),
+ Content: c.Content,
+ ParentID: c.ParentID,
+ ReplyToUser: replyToUser,
+ LikeCount: c.LikeCount,
+ IsLiked: likedIDs[c.ID],
+ CreatedAt: c.CreatedAt,
+ Replies: []dto.CommentListItem{},
+ })
}
if result == nil {
@@ -659,7 +645,7 @@ func (s *CommunityService) DeleteComment(commentID string, user *model.User) err
return err
}
- if comment.AuthorID != user.ID && user.Role != model.RoleAdmin {
+ if comment.AuthorID != user.ID && !user.IsAdmin {
return errors.New("无权删除此评论")
}
diff --git a/backend/internal/service/echo.go b/backend/internal/service/echo.go
index 0c827a45..190dfd24 100644
--- a/backend/internal/service/echo.go
+++ b/backend/internal/service/echo.go
@@ -17,12 +17,14 @@ import (
type EchoService struct {
client *openai.Client
echoRepo *repository.EchoRepo
+ userRepo *repository.UserRepo
}
-func NewEchoService(client *openai.Client, echoRepo *repository.EchoRepo) *EchoService {
+func NewEchoService(client *openai.Client, echoRepo *repository.EchoRepo, userRepo *repository.UserRepo) *EchoService {
return &EchoService{
client: client,
echoRepo: echoRepo,
+ userRepo: userRepo,
}
}
@@ -86,7 +88,14 @@ func (s *EchoService) DeleteIdentityTag(userID, tagID string) error {
}
func (s *EchoService) GetMemoirs(userID string, limit, offset int) (*dto.MemoirListResponse, error) {
- memoirs, total, err := s.echoRepo.FindMemoirsByUserID(userID, limit, offset)
+ // Collect user IDs: self + partner
+ userIDs := []string{userID}
+ user, err := s.userRepo.FindByID(userID)
+ if err == nil && user.PartnerID != nil {
+ userIDs = append(userIDs, *user.PartnerID)
+ }
+
+ memoirs, total, err := s.echoRepo.FindMemoirsByUserIDs(userIDs, limit, offset)
if err != nil {
return nil, err
}
@@ -219,7 +228,7 @@ func parseMemoirLLMResponse(content string) map[string]interface{} {
}
}
- re2 := regexp.MustCompile(`(?s)\{.*\}`)
+ re2 := regexp.MustCompile("(?s)\\{.*\\}")
if match := re2.FindString(content); match != "" {
if err := json.Unmarshal([]byte(match), &result); err == nil {
return result
diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go
index 6d4878ad..6d2b8489 100644
--- a/backend/internal/service/user.go
+++ b/backend/internal/service/user.go
@@ -1,7 +1,9 @@
package service
import (
+ "crypto/rand"
"errors"
+ "math/big"
"github.com/momshell/backend/internal/dto"
"github.com/momshell/backend/internal/model"
@@ -52,17 +54,34 @@ func (s *UserService) GetProfile(userID string) (*dto.UserProfile, error) {
info := s.community.BuildAuthorInfo(user)
- return &dto.UserProfile{
+ profile := &dto.UserProfile{
ID: user.ID,
+ Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
AvatarURL: user.AvatarURL,
Role: string(user.Role),
+ IsAdmin: user.IsAdmin,
+ ShellCode: user.ShellCode,
IsCertified: info.IsCertified,
CertificationTitle: info.CertificationTitle,
Stats: *stats,
CreatedAt: user.CreatedAt,
- }, nil
+ }
+
+ if user.PartnerID != nil {
+ partner, err := s.userRepo.FindByID(*user.PartnerID)
+ if err == nil {
+ profile.Partner = &dto.PartnerInfo{
+ ID: partner.ID,
+ Nickname: partner.Nickname,
+ AvatarURL: partner.AvatarURL,
+ Role: string(partner.Role),
+ }
+ }
+ }
+
+ return profile, nil
}
func (s *UserService) UpdateProfile(userID string, req dto.UserProfileUpdate) (*dto.UserProfile, error) {
@@ -71,12 +90,20 @@ func (s *UserService) UpdateProfile(userID string, req dto.UserProfileUpdate) (*
return nil, errors.New("用户不存在")
}
+ if req.Username != nil && *req.Username != user.Username {
+ exists, _ := s.userRepo.ExistsByUsername(*req.Username, userID)
+ if exists {
+ return nil, errors.New("该用户名已被使用")
+ }
+ user.Username = *req.Username
+ }
+
if req.Nickname != nil {
user.Nickname = *req.Nickname
}
if req.Email != nil && *req.Email != user.Email {
- exists, _ := s.userRepo.ExistsByUsernameOrEmail("", *req.Email)
+ exists, _ := s.userRepo.ExistsByEmail(*req.Email, userID)
if exists {
return nil, errors.New("该邮箱已被使用")
}
@@ -90,13 +117,13 @@ func (s *UserService) UpdateProfile(userID string, req dto.UserProfileUpdate) (*
if req.Role != nil {
newRole := model.UserRole(*req.Role)
if !model.FamilyRoles[newRole] {
- return nil, errors.New("角色只能是: mom, dad, family")
+ return nil, errors.New("角色只能是: mom, dad")
}
if model.ProfessionalRoles[user.Role] {
return nil, errors.New("认证专业人员不能修改角色")
}
- if user.Role == model.RoleAdmin {
- return nil, errors.New("管理员不能通过此接口修改角色")
+ if user.PartnerID != nil {
+ return nil, errors.New("已绑定伴侣,无法更改身份")
}
user.Role = newRole
}
@@ -108,6 +135,134 @@ func (s *UserService) UpdateProfile(userID string, req dto.UserProfileUpdate) (*
return s.GetProfile(userID)
}
+// GenerateShellCode generates a shell code for 溯源者 (mom) to share with their partner.
+func (s *UserService) GenerateShellCode(userID string) (*dto.UserProfile, error) {
+ user, err := s.userRepo.FindByID(userID)
+ if err != nil {
+ return nil, errors.New("用户不存在")
+ }
+
+ if user.Role != model.RoleMom {
+ return nil, errors.New("只有溯源者可以生成贝壳码")
+ }
+
+ if user.PartnerID != nil {
+ return nil, errors.New("已绑定伴侣,无法重新生成贝壳码")
+ }
+
+ // Generate if not already present
+ if user.ShellCode == nil {
+ code, err := generateRandomCode(8)
+ if err != nil {
+ return nil, errors.New("生成贝壳码失败")
+ }
+ user.ShellCode = &code
+ if err := s.userRepo.Update(user); err != nil {
+ return nil, err
+ }
+ }
+
+ return s.GetProfile(userID)
+}
+
+// BindPartner binds a 守护者 (dad) to a 溯源者 (mom) via shell code.
+func (s *UserService) BindPartner(userID string, shellCode string) (*dto.UserProfile, error) {
+ user, err := s.userRepo.FindByID(userID)
+ if err != nil {
+ return nil, errors.New("用户不存在")
+ }
+
+ if user.Role != model.RoleDad {
+ return nil, errors.New("只有守护者可以填写贝壳码")
+ }
+
+ if user.PartnerID != nil {
+ return nil, errors.New("您已绑定伴侣")
+ }
+
+ partner, err := s.userRepo.FindByShellCode(shellCode)
+ if err != nil {
+ return nil, errors.New("贝壳码无效")
+ }
+
+ if partner.Role != model.RoleMom {
+ return nil, errors.New("贝壳码无效")
+ }
+
+ if partner.PartnerID != nil {
+ return nil, errors.New("对方已绑定其他伴侣")
+ }
+
+ if partner.ID == userID {
+ return nil, errors.New("不能绑定自己")
+ }
+
+ // Bind both sides in a transaction
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("partner_id", partner.ID).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&model.User{}).Where("id = ?", partner.ID).Update("partner_id", user.ID).Error; err != nil {
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, errors.New("绑定失败")
+ }
+
+ return s.GetProfile(userID)
+}
+
+// UnbindPartner removes the partner relationship for the current user.
+func (s *UserService) UnbindPartner(userID string) (*dto.UserProfile, error) {
+ user, err := s.userRepo.FindByID(userID)
+ if err != nil {
+ return nil, errors.New("用户不存在")
+ }
+
+ if user.PartnerID == nil {
+ return nil, errors.New("您尚未绑定伴侣")
+ }
+
+ partnerID := *user.PartnerID
+
+ // Unbind both sides, clear shell code
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
+ "partner_id": nil,
+ "shell_code": nil,
+ }).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&model.User{}).Where("id = ?", partnerID).Updates(map[string]interface{}{
+ "partner_id": nil,
+ "shell_code": nil,
+ }).Error; err != nil {
+ return err
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, errors.New("解绑失败")
+ }
+
+ return s.GetProfile(userID)
+}
+
+func generateRandomCode(length int) (string, error) {
+ const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+ code := make([]byte, length)
+ for i := range code {
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
+ if err != nil {
+ return "", err
+ }
+ code[i] = charset[n.Int64()]
+ }
+ return string(code), nil
+}
+
func (s *UserService) GetUserQuestions(userID string, page, pageSize int) (*dto.PaginatedResponse, error) {
offset := (page - 1) * pageSize
questions, total, err := s.questionRepo.FindByAuthorID(userID, offset, pageSize)
diff --git a/backend/pkg/openai/client.go b/backend/pkg/openai/client.go
index 8dccdac1..a69be8b5 100644
--- a/backend/pkg/openai/client.go
+++ b/backend/pkg/openai/client.go
@@ -77,7 +77,7 @@ func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) {
if err != nil {
return "", fmt.Errorf("openai chat request failed: %w", err)
}
- defer func() { _ = resp.Body.Close() }()
+ defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
diff --git a/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg b/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg
new file mode 100644
index 00000000..97475c83
--- /dev/null
+++ b/backend/uploads/avatars/0ea70a2d-f9ba-4bd9-8615-fbe6d5a8f421.jpg
@@ -0,0 +1,3 @@
+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
new file mode 100644
index 00000000..df568498
--- /dev/null
+++ b/backend/uploads/avatars/7489176a-d8b7-4cee-a3a3-76c4d2759292.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:527e644d661085f0201e58e9b502dae8d9803281358da68d6950a43e89cc8e5f
+size 414120
diff --git a/docs/configuration.md b/docs/configuration.md
index 5f0661bf..012713e0 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -26,7 +26,7 @@ The setup script auto-generates `JWT_SECRET_KEY`. Change it manually for product
|----------|-------------|----------|---------|
| `OPENAI_API_KEY` | API key for LLM service | **Yes** | — |
| `OPENAI_BASE_URL` | API base URL | No | `https://api-inference.modelscope.cn/v1` |
-| `OPENAI_MODEL` | Model name | No | `Qwen/Qwen2.5-72B-Instruct` |
+| `OPENAI_MODEL` | Model name | No | `Qwen/Qwen3-235B-A22B` |
Any OpenAI-compatible API is supported (ModelScope, OpenAI, local Ollama, etc.).
diff --git a/frontend.new/.omc/state/last-tool-error.json b/frontend.new/.omc/state/last-tool-error.json
new file mode 100644
index 00000000..50c8bb76
--- /dev/null
+++ b/frontend.new/.omc/state/last-tool-error.json
@@ -0,0 +1,7 @@
+{
+ "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 be25ba62..05e93e56 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -3411,6 +3411,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 28638e87..0d0a36fd 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -4,6 +4,7 @@
+
+
+
+
+
+ {{ overflowPhotos.length }}
+