From 39fdc2029c9f9b205ec5b9f02ce6ffe59e77127e Mon Sep 17 00:00:00 2001 From: Koishi Date: Mon, 9 Feb 2026 00:38:50 +0800 Subject: [PATCH 1/4] fix: fix auth --- .env.example | 4 +- CHANGELOG.md | 50 +++++ backend/app/core/config.py | 19 +- backend/app/main.py | 17 ++ backend/app/services/auth/schemas.py | 2 + backend/app/services/auth/service.py | 2 +- .../app/services/community/schemas/user.py | 4 +- backend/app/services/community/service.py | 10 + docs/development.md | 6 + frontend/.env.example | 8 + frontend/app/auth/login/page.tsx | 53 +++++- frontend/app/auth/register/page.tsx | 85 +++++++-- frontend/app/community/profile/page.tsx | 99 +++++++++- frontend/components/auth/SliderCaptcha.tsx | 177 ++++++++++++++++++ frontend/lib/api/community.ts | 2 + frontend/lib/auth.ts | 1 + scripts/dev-setup.sh | 18 +- 17 files changed, 524 insertions(+), 33 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/components/auth/SliderCaptcha.tsx diff --git a/.env.example b/.env.example index 02444055..29cfc889 100644 --- a/.env.example +++ b/.env.example @@ -23,7 +23,7 @@ DATABASE_URL=sqlite+aiosqlite:///./data/momshell.db MODELSCOPE_KEY=your_modelscope_api_key_here MODELSCOPE_MODEL=Qwen/Qwen2.5-72B-Instruct # For image generation model (optional) -MODELSCOPE_IMAGE_MODEL= +# MODELSCOPE_IMAGE_MODEL=your_image_model_here # MediaPipe Configuration POSE_MODEL_COMPLEXITY=1 @@ -48,7 +48,7 @@ JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 # Web Search (Firecrawl API for reducing AI hallucinations) # Get your API key from: https://www.firecrawl.dev/ -FIRECRAWL_API_KEY= +# FIRECRAWL_API_KEY=your_firecrawl_api_key_here # Initial Admin Account (optional, created on first startup) # Set these to automatically create an admin account when the app starts diff --git a/CHANGELOG.md b/CHANGELOG.md index 980cf20d..47ae986a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ 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). +## [0.5.3] - 2026-02-09 + +### Added + +#### Security Improvements + +- **JWT Secret Auto-Generation**: Automatic random JWT secret key generation for Docker deployments + - If `JWT_SECRET_KEY` is not set, a random 64-char hex key is generated at startup + - Startup warning in production mode when using auto-generated (non-persisted) key + - `dev-setup.sh` now auto-generates a persistent JWT secret in `.env` + +- **Slider CAPTCHA**: Human verification for login and registration + - New `SliderCaptcha` component with drag-to-verify interaction + - Validates position accuracy, timing, and drag trail + - Required before form submission on both login and register pages + +#### User Experience Improvements + +- **Profile Page Enhancements**: + - Username display in account security section (read-only) + - Email display with edit capability + - Backend support for email updates with duplicate checking + +- **Registration Improvements**: + - Role selection during registration (Mom/Dad/Family) + - Backend accepts `role` field in registration request + +- **Password Visibility Toggle**: Show/hide password button on login and register pages + - Eye icon toggle for password fields + - Register page: single toggle controls both password and confirm password fields + +#### Developer Experience + +- **Local Development Setup**: + - New `frontend/.env.example` template with `NEXT_PUBLIC_API_URL` + - `dev-setup.sh` now creates `frontend/.env.local` automatically + - Updated `docs/development.md` with frontend environment setup instructions + +### Changed + +- **Environment Variables**: Unified `.env.example` style + - Required keys use placeholder values (e.g., `MODELSCOPE_KEY=your_key_here`) + - Optional keys are commented out with placeholder values + +### Fixed + +- Backend lint errors in `config.py` (unnecessary f-string) and `main.py` (unused import) + +--- + ## [0.5.2] - 2026-02-06 ### Added diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8b38ded8..e7d798db 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,10 +1,16 @@ """Application configuration.""" +import secrets from functools import lru_cache from pathlib import Path +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +# Security constants +DEFAULT_JWT_SECRET = "your-secret-key-change-in-production" +_generated_jwt_secret: str | None = None + def _find_env_file() -> str | None: """Find .env file, checking multiple locations for Docker compatibility.""" @@ -59,11 +65,22 @@ class Settings(BaseSettings): rest_prompt_interval: int = 300 # seconds # JWT Authentication - jwt_secret_key: str = "your-secret-key-change-in-production" + jwt_secret_key: str = DEFAULT_JWT_SECRET jwt_algorithm: str = "HS256" jwt_access_token_expire_minutes: int = 30 jwt_refresh_token_expire_days: int = 7 + @model_validator(mode="after") + def _auto_generate_jwt_secret(self) -> "Settings": + """Auto-generate JWT secret if using default value.""" + global _generated_jwt_secret + if self.jwt_secret_key == DEFAULT_JWT_SECRET: + if _generated_jwt_secret is None: + _generated_jwt_secret = secrets.token_hex(32) + print("[Config] Auto-generated JWT secret (not persisted)") + object.__setattr__(self, "jwt_secret_key", _generated_jwt_secret) + return self + # Web Search (Firecrawl API for reducing AI hallucinations) firecrawl_api_key: str = "" diff --git a/backend/app/main.py b/backend/app/main.py index 568a2304..79c739cc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,6 +28,22 @@ settings = get_settings() + +def check_security_settings() -> None: + """Check for insecure settings in production mode.""" + from app.core.config import _generated_jwt_secret + + if settings.debug: + return # Skip checks in debug mode + + if _generated_jwt_secret is not None: + print("\033[93m" + "=" * 60) + print("WARNING: JWT secret was auto-generated (not persisted).") + print("User sessions will be invalidated on restart.") + print("Set JWT_SECRET_KEY in environment for persistent sessions.") + print("=" * 60 + "\033[0m") + + # Paths BASE_DIR = Path(__file__).resolve().parent STATIC_DIR = BASE_DIR / "static" @@ -118,6 +134,7 @@ async def ensure_admin() -> None: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan manager.""" # Startup + check_security_settings() ensure_db_directory() await init_db() # Seed guardian task templates diff --git a/backend/app/services/auth/schemas.py b/backend/app/services/auth/schemas.py index a04240e9..318a9fea 100644 --- a/backend/app/services/auth/schemas.py +++ b/backend/app/services/auth/schemas.py @@ -1,6 +1,7 @@ """Schemas for authentication.""" from datetime import datetime +from typing import Literal from pydantic import BaseModel, EmailStr, Field @@ -12,6 +13,7 @@ class RegisterRequest(BaseModel): email: EmailStr password: str = Field(..., min_length=6, max_length=100) nickname: str = Field(..., min_length=1, max_length=50) + role: Literal["mom", "dad", "family"] = "mom" class LoginRequest(BaseModel): diff --git a/backend/app/services/auth/service.py b/backend/app/services/auth/service.py index 2cf1d647..669bccd7 100644 --- a/backend/app/services/auth/service.py +++ b/backend/app/services/auth/service.py @@ -47,7 +47,7 @@ async def register(self, request: RegisterRequest) -> UserResponse: email=request.email, password_hash=get_password_hash(request.password), nickname=request.nickname, - role=UserRole.MOM, + role=UserRole(request.role), is_active=True, is_banned=False, ) diff --git a/backend/app/services/community/schemas/user.py b/backend/app/services/community/schemas/user.py index 25c5a08f..4878a0d8 100644 --- a/backend/app/services/community/schemas/user.py +++ b/backend/app/services/community/schemas/user.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, EmailStr, Field from ..enums import UserRole from .base import TagInfo @@ -16,6 +16,7 @@ class UserProfileUpdate(BaseModel): """Request schema for updating user profile.""" nickname: str | None = Field(None, min_length=1, max_length=50) + email: EmailStr | None = None avatar_url: str | None = Field(None, max_length=500) role: FamilyRoleType | None = Field( None, description="Only family roles allowed: mom, dad, family" @@ -36,6 +37,7 @@ class UserProfile(BaseModel): id: str nickname: str + email: str avatar_url: str | None = None role: UserRole is_certified: bool = False diff --git a/backend/app/services/community/service.py b/backend/app/services/community/service.py index 9b1b84ae..f2e0e99d 100644 --- a/backend/app/services/community/service.py +++ b/backend/app/services/community/service.py @@ -1312,6 +1312,7 @@ async def get_user_profile( return UserProfile( id=user.id, nickname=user.nickname, + email=user.email, avatar_url=user.avatar_url, role=user.role, is_certified=is_certified, @@ -1337,6 +1338,15 @@ async def update_user_profile( if profile_update.nickname is not None: user.nickname = profile_update.nickname + if profile_update.email is not None and profile_update.email != user.email: + # Check if email is already taken + existing = await db.execute( + select(User).where(User.email == profile_update.email) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该邮箱已被使用") + user.email = profile_update.email + if profile_update.avatar_url is not None: user.avatar_url = profile_update.avatar_url diff --git a/docs/development.md b/docs/development.md index f47e03bb..d63c2bce 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,10 +25,16 @@ cd MomShell 1. **Environment variables** ```bash +# Backend environment (API keys, database, etc.) cp .env.example .env # Edit .env and fill in your API keys + +# Frontend environment (required for local development) +cp frontend/.env.example frontend/.env.local ``` +> **Note:** In Docker deployment, the frontend uses relative paths via Nginx. But for local development with separate frontend/backend servers, the frontend needs `NEXT_PUBLIC_API_URL` to point to the backend at `http://localhost:8000`. + 2. **Backend dependencies** ```bash diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..7c300b42 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,8 @@ +# Frontend Environment Variables +# Copy this file to .env.local for local development: +# cp .env.example .env.local + +# Backend API URL +# - Local development: http://localhost:8000 +# - Docker/Production: Leave empty (uses relative path via Nginx) +NEXT_PUBLIC_API_URL=http://localhost:8000 diff --git a/frontend/app/auth/login/page.tsx b/frontend/app/auth/login/page.tsx index 8bb023eb..055cff84 100644 --- a/frontend/app/auth/login/page.tsx +++ b/frontend/app/auth/login/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { useAuth } from '../../../contexts/AuthContext'; import { getErrorMessage } from '../../../lib/apiClient'; +import SliderCaptcha from '../../../components/auth/SliderCaptcha'; export default function LoginPage() { const router = useRouter(); @@ -12,13 +13,21 @@ export default function LoginPage() { const [login_, setLogin] = useState(''); const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const [rememberMe, setRememberMe] = useState(false); + const [captchaToken, setCaptchaToken] = useState(null); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); + + if (!captchaToken) { + setError('请完成滑块验证'); + return; + } + setIsLoading(true); try { @@ -76,17 +85,41 @@ export default function LoginPage() { - setPassword(e.target.value)} - required - className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-pink-500 focus:border-transparent outline-none transition" - placeholder="输入密码" - /> +
+ setPassword(e.target.value)} + required + className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-pink-500 focus:border-transparent outline-none transition" + placeholder="输入密码" + /> + +
+ setCaptchaToken(token)} + onReset={() => setCaptchaToken(null)} + /> +
+
+ +
+ {[ + { value: 'mom', label: '妈妈' }, + { value: 'dad', label: '爸爸' }, + { value: 'family', label: '家属' }, + ].map((option) => ( + + ))} +
+
+
- setPassword(e.target.value)} - required - minLength={6} - className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-pink-500 focus:border-transparent outline-none transition" - placeholder="至少6位" - /> +
+ setPassword(e.target.value)} + required + minLength={6} + className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-pink-500 focus:border-transparent outline-none transition" + placeholder="至少6位" + /> + +
@@ -132,7 +186,7 @@ export default function RegisterPage() { setConfirmPassword(e.target.value)} required @@ -141,9 +195,14 @@ export default function RegisterPage() { />
+ setCaptchaToken(token)} + onReset={() => setCaptchaToken(null)} + /> + + )} + + {isEditingEmail ? ( +
+ setEditEmail(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-stone-200 focus:border-[#e8a4b8] focus:outline-none focus:ring-2 focus:ring-[#e8a4b8]/20 text-stone-700 text-sm" + placeholder="输入新邮箱" + /> +
+ + +
+
+ ) : ( +
{profile?.email}
+ )} + +
-

账号安全

+ 修改密码 {!isChangingPassword && ( )}
diff --git a/frontend/components/auth/SliderCaptcha.tsx b/frontend/components/auth/SliderCaptcha.tsx new file mode 100644 index 00000000..eee3c813 --- /dev/null +++ b/frontend/components/auth/SliderCaptcha.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useState, useRef, useCallback, useEffect } from 'react'; + +interface SliderCaptchaProps { + onSuccess: (token: string) => void; + onReset?: () => void; +} + +export default function SliderCaptcha({ onSuccess, onReset }: SliderCaptchaProps) { + const [isDragging, setIsDragging] = useState(false); + const [position, setPosition] = useState(0); + const [isVerified, setIsVerified] = useState(false); + const [startTime, setStartTime] = useState(0); + const [trail, setTrail] = useState([]); + + const containerRef = useRef(null); + const sliderWidth = 40; + const targetPosition = 85; // percentage + const tolerance = 5; // percentage tolerance + + const generateToken = useCallback(() => { + // Generate a simple token based on verification data + const data = { + t: Date.now(), + d: Date.now() - startTime, + l: trail.length, + p: position, + }; + return btoa(JSON.stringify(data)); + }, [startTime, trail.length, position]); + + const handleStart = useCallback((clientX: number) => { + if (isVerified) return; + setIsDragging(true); + setStartTime(Date.now()); + setTrail([]); + }, [isVerified]); + + const handleMove = useCallback((clientX: number) => { + if (!isDragging || !containerRef.current || isVerified) return; + + const rect = containerRef.current.getBoundingClientRect(); + const maxMove = rect.width - sliderWidth; + const newPosition = Math.max(0, Math.min(clientX - rect.left - sliderWidth / 2, maxMove)); + const percentage = (newPosition / maxMove) * 100; + + setPosition(percentage); + setTrail(prev => [...prev, percentage]); + }, [isDragging, isVerified]); + + const handleEnd = useCallback(() => { + if (!isDragging || isVerified) return; + setIsDragging(false); + + const duration = Date.now() - startTime; + + // Validation checks + const isPositionCorrect = Math.abs(position - targetPosition) <= tolerance; + const isDurationReasonable = duration > 200 && duration < 10000; + const hasTrail = trail.length > 5; + + if (isPositionCorrect && isDurationReasonable && hasTrail) { + setIsVerified(true); + onSuccess(generateToken()); + } else { + // Reset on failure + setPosition(0); + setTrail([]); + } + }, [isDragging, isVerified, position, startTime, trail.length, generateToken, onSuccess]); + + // Mouse events + const handleMouseDown = (e: React.MouseEvent) => handleStart(e.clientX); + const handleMouseMove = useCallback((e: MouseEvent) => handleMove(e.clientX), [handleMove]); + const handleMouseUp = useCallback(() => handleEnd(), [handleEnd]); + + // Touch events + const handleTouchStart = (e: React.TouchEvent) => handleStart(e.touches[0].clientX); + const handleTouchMove = useCallback((e: TouchEvent) => handleMove(e.touches[0].clientX), [handleMove]); + const handleTouchEnd = useCallback(() => handleEnd(), [handleEnd]); + + useEffect(() => { + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('touchmove', handleTouchMove); + document.addEventListener('touchend', handleTouchEnd); + } + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + }; + }, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]); + + const reset = () => { + setPosition(0); + setIsVerified(false); + setTrail([]); + onReset?.(); + }; + + return ( +
+
+ 请拖动滑块完成验证 + {isVerified && ( + + )} +
+
+ {/* Track fill */} +
+ + {/* Target indicator */} + {!isVerified && ( +
+ )} + + {/* Center text */} +
+ + {isVerified ? '验证成功' : '向右拖动滑块'} + +
+ + {/* Slider handle */} +
+ {isVerified ? ( + + + + ) : ( + + + + )} +
+
+
+ ); +} diff --git a/frontend/lib/api/community.ts b/frontend/lib/api/community.ts index 8675ce34..408d2de8 100644 --- a/frontend/lib/api/community.ts +++ b/frontend/lib/api/community.ts @@ -239,6 +239,7 @@ export interface UserStats { export interface UserProfile { id: string; nickname: string; + email: string; avatar_url: string | null; role: string; is_certified: boolean; @@ -249,6 +250,7 @@ export interface UserProfile { export interface UserProfileUpdateParams { nickname?: string; + email?: string; avatar_url?: string; role?: 'mom' | 'dad' | 'family'; } diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts index c6bae1cf..e9cfc53f 100644 --- a/frontend/lib/auth.ts +++ b/frontend/lib/auth.ts @@ -63,6 +63,7 @@ export interface RegisterParams { email: string; password: string; nickname: string; + role?: 'mom' | 'dad' | 'family'; } export interface LoginParams { diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index 5104a5bc..7a51bde0 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -114,18 +114,34 @@ echo "" echo -e "${BLUE}=== Step 2: Setting Up Environment Variables ===${NC}" echo "" +# Backend .env if [ -f ".env" ]; then success ".env file already exists" else if [ -f ".env.example" ]; then cp .env.example .env - success "Created .env from .env.example" + # Generate random JWT secret key + JWT_SECRET=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))") + sed -i "s/your-secret-key-change-in-production/$JWT_SECRET/" .env + success "Created .env from .env.example (JWT secret auto-generated)" warn "Please edit .env and fill in your API keys (MODELSCOPE_KEY, etc.)" else warn ".env.example not found, skipping .env setup" fi fi +# Frontend .env.local (required for local development) +if [ -f "frontend/.env.local" ]; then + success "frontend/.env.local already exists" +else + if [ -f "frontend/.env.example" ]; then + cp frontend/.env.example frontend/.env.local + success "Created frontend/.env.local from frontend/.env.example" + else + warn "frontend/.env.example not found, skipping frontend env setup" + fi +fi + # ============================================ # Step 3: Initialize Git LFS # ============================================ From 8c81b0a1a8142afdc30c7307fc1b4c0ba93c9c91 Mon Sep 17 00:00:00 2001 From: Koishi Date: Mon, 9 Feb 2026 01:12:00 +0800 Subject: [PATCH 2/4] fix: fix filter --- CHANGELOG.md | 13 ++- backend/app/services/auth/service.py | 16 +++- backend/app/services/community/service.py | 82 +++++++++++++++---- frontend/app/auth/register/page.tsx | 37 +++++++-- frontend/app/community/profile/page.tsx | 9 +- .../components/community/CommunityFeed.tsx | 5 +- .../community/QuestionDetailModal.tsx | 29 +++---- frontend/lib/apiClient.ts | 11 ++- 8 files changed, 151 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47ae986a..b92c3561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Validates position accuracy, timing, and drag trail - Required before form submission on both login and register pages +- **Content Moderation Expansion**: Extended sensitive content filtering + - Post titles now moderated (previously only content was checked) + - Comments rejection on moderation failure (previously only marked for review) + - Nickname moderation on registration and profile update + - Post title moderation on edit (previously only content was checked) + #### User Experience Improvements - **Profile Page Enhancements**: @@ -33,8 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Backend accepts `role` field in registration request - **Password Visibility Toggle**: Show/hide password button on login and register pages - - Eye icon toggle for password fields - - Register page: single toggle controls both password and confirm password fields + - Eye icon toggle for all password fields + - Both password fields share the same visibility state #### Developer Experience @@ -52,6 +58,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Backend lint errors in `config.py` (unnecessary f-string) and `main.py` (unused import) +- **Moderation error messages not displayed**: Frontend now correctly extracts and shows detailed error messages from backend moderation responses + - Updated `getErrorMessage()` to handle `{message: ...}` response format + - Applied fix to post creation, comments, profile updates, and all error handlers --- diff --git a/backend/app/services/auth/service.py b/backend/app/services/auth/service.py index 669bccd7..c03e6abc 100644 --- a/backend/app/services/auth/service.py +++ b/backend/app/services/auth/service.py @@ -5,8 +5,9 @@ from sqlalchemy.orm import selectinload from app.core.config import get_settings -from app.services.community.enums import CertificationStatus, UserRole +from app.services.community.enums import CertificationStatus, ModerationResult, UserRole from app.services.community.models import User, UserCertification +from app.services.community.moderation import get_moderation_service from .schemas import ( LoginRequest, @@ -30,6 +31,17 @@ def __init__(self, db: AsyncSession): async def register(self, request: RegisterRequest) -> UserResponse: """Register a new user.""" + from fastapi import HTTPException + + # Moderate nickname + moderation = get_moderation_service() + nickname_decision = await moderation.moderate_text(request.nickname) + if nickname_decision.result == ModerationResult.REJECTED: + raise HTTPException( + status_code=400, + detail=f"昵称包含敏感内容: {nickname_decision.reason}", + ) + # Check if username already exists existing = await self.db.execute( select(User).where( @@ -37,8 +49,6 @@ async def register(self, request: RegisterRequest) -> UserResponse: ) ) if existing.scalar_one_or_none(): - from fastapi import HTTPException - raise HTTPException(status_code=400, detail="用户名或邮箱已存在") # Create new user diff --git a/backend/app/services/community/service.py b/backend/app/services/community/service.py index f2e0e99d..51858fa9 100644 --- a/backend/app/services/community/service.py +++ b/backend/app/services/community/service.py @@ -306,21 +306,38 @@ async def create_question( author: User, ) -> Question: """Create a new question with moderation.""" - # Content moderation - decision = await self._moderation.moderate_text(question_in.content, author.id) + # Content moderation - check both title and content + title_decision = await self._moderation.moderate_text( + question_in.title, author.id + ) + if title_decision.result == ModerationResult.REJECTED: + raise HTTPException( + status_code=400, + detail={ + "message": f"标题审核未通过: {title_decision.reason}", + "categories": [c.value for c in title_decision.categories], + "crisis_intervention": title_decision.crisis_intervention, + }, + ) - if decision.result == ModerationResult.REJECTED: + content_decision = await self._moderation.moderate_text( + question_in.content, author.id + ) + if content_decision.result == ModerationResult.REJECTED: raise HTTPException( status_code=400, detail={ - "message": f"内容审核未通过: {decision.reason}", - "categories": [c.value for c in decision.categories], - "crisis_intervention": decision.crisis_intervention, + "message": f"内容审核未通过: {content_decision.reason}", + "categories": [c.value for c in content_decision.categories], + "crisis_intervention": content_decision.crisis_intervention, }, ) - # Determine initial status - if decision.result == ModerationResult.PASSED: + # Determine initial status (use stricter result) + if ( + title_decision.result == ModerationResult.PASSED + and content_decision.result == ModerationResult.PASSED + ): status = ContentStatus.PUBLISHED published_at = datetime.utcnow() else: # NEED_MANUAL_REVIEW @@ -353,20 +370,20 @@ async def create_question( .values(question_count=Tag.question_count + 1) ) - # Log moderation result + # Log moderation result (use content decision as primary) db.add( ModerationLog( target_type="question", target_id=question.id, moderation_type="auto", - result=decision.result, + result=content_decision.result, sensitive_categories=( - json.dumps([c.value for c in decision.categories]) - if decision.categories + json.dumps([c.value for c in content_decision.categories]) + if content_decision.categories else None ), - confidence_score=decision.confidence, - reason=decision.reason, + confidence_score=content_decision.confidence, + reason=content_decision.reason, ) ) @@ -866,7 +883,18 @@ async def create_comment( reply_to_user_id = parent.author_id # Run moderation - moderation_result = await self._moderation.moderate_text(comment_in.content) + moderation_result = await self._moderation.moderate_text( + comment_in.content, user.id + ) + if moderation_result.result == ModerationResult.REJECTED: + raise HTTPException( + status_code=400, + detail={ + "message": f"评论审核未通过: {moderation_result.reason}", + "categories": [c.value for c in moderation_result.categories], + "crisis_intervention": moderation_result.crisis_intervention, + }, + ) status = ( ContentStatus.PUBLISHED if moderation_result.result == ModerationResult.PASSED @@ -997,7 +1025,22 @@ async def update_question( # Update fields if provided if question_in.title is not None: + # Title moderation + title_decision = await self._moderation.moderate_text( + question_in.title, user.id + ) + if title_decision.result == ModerationResult.REJECTED: + raise HTTPException( + status_code=400, + detail={ + "message": f"标题审核未通过: {title_decision.reason}", + "categories": [c.value for c in title_decision.categories], + }, + ) question.title = question_in.title + if title_decision.result == ModerationResult.NEED_MANUAL_REVIEW: + question.status = ContentStatus.PENDING_REVIEW + if question_in.content is not None: # Content moderation for new content decision = await self._moderation.moderate_text( @@ -1336,6 +1379,15 @@ async def update_user_profile( from .enums import FAMILY_ROLES, PROFESSIONAL_ROLES if profile_update.nickname is not None: + # Moderate nickname + nickname_decision = await self._moderation.moderate_text( + profile_update.nickname + ) + if nickname_decision.result == ModerationResult.REJECTED: + raise HTTPException( + status_code=400, + detail=f"昵称包含敏感内容: {nickname_decision.reason}", + ) user.nickname = profile_update.nickname if profile_update.email is not None and profile_update.email != user.email: diff --git a/frontend/app/auth/register/page.tsx b/frontend/app/auth/register/page.tsx index 65ffcaec..2850ef36 100644 --- a/frontend/app/auth/register/page.tsx +++ b/frontend/app/auth/register/page.tsx @@ -184,15 +184,34 @@ export default function RegisterPage() { - setConfirmPassword(e.target.value)} - required - className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-pink-500 focus:border-transparent outline-none transition" - placeholder="再次输入密码" - /> +
+ setConfirmPassword(e.target.value)} + required + className="w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-pink-500 focus:border-transparent outline-none transition" + placeholder="再次输入密码" + /> + +
setSaveMessage({ show: false, success: true, text: '' }), 3000); - } catch (err: any) { + } catch (err: unknown) { console.error('Failed to change password:', err); - setSaveMessage({ show: true, success: false, text: err.message || '密码修改失败' }); + setSaveMessage({ show: true, success: false, text: getErrorMessage(err) }); setTimeout(() => setSaveMessage({ show: false, success: true, text: '' }), 3000); } finally { setIsUpdatingPassword(false); @@ -200,9 +201,9 @@ export default function ProfilePage() { setIsEditingEmail(false); setSaveMessage({ show: true, success: true, text: '邮箱修改成功' }); setTimeout(() => setSaveMessage({ show: false, success: true, text: '' }), 3000); - } catch (err: any) { + } catch (err: unknown) { console.error('Failed to update email:', err); - setSaveMessage({ show: true, success: false, text: err.response?.data?.detail || '邮箱修改失败' }); + setSaveMessage({ show: true, success: false, text: getErrorMessage(err) }); setTimeout(() => setSaveMessage({ show: false, success: true, text: '' }), 3000); } finally { setIsSavingEmail(false); diff --git a/frontend/components/community/CommunityFeed.tsx b/frontend/components/community/CommunityFeed.tsx index fce6bc4a..c0afb631 100644 --- a/frontend/components/community/CommunityFeed.tsx +++ b/frontend/components/community/CommunityFeed.tsx @@ -20,6 +20,7 @@ import UserMenu from './UserMenu'; import { type ChannelType, type Question } from '../../types/community'; import { SPRING_CONFIGS } from '../../lib/design-tokens'; import { getQuestions, createQuestion as apiCreateQuestion, toggleLike, toggleCollection } from '../../lib/api/community'; +import { getErrorMessage } from '../../lib/apiClient'; import { useAuth } from '../../contexts/AuthContext'; export default function CommunityFeed() { @@ -236,8 +237,8 @@ export default function CommunityFeed() { setTimeout(() => { setSubmitMessage({ show: false, success: true, text: '' }); }, 3000); - } catch (err: any) { - setSubmitMessage({ show: true, success: false, text: err.message || '发布失败,请重试' }); + } catch (err: unknown) { + setSubmitMessage({ show: true, success: false, text: getErrorMessage(err) }); setTimeout(() => { setSubmitMessage({ show: false, success: true, text: '' }); diff --git a/frontend/components/community/QuestionDetailModal.tsx b/frontend/components/community/QuestionDetailModal.tsx index a26fce24..0ca2c3f8 100644 --- a/frontend/components/community/QuestionDetailModal.tsx +++ b/frontend/components/community/QuestionDetailModal.tsx @@ -10,6 +10,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { type Question, type Answer, ROLE_CONFIG } from '../../types/community'; import { getQuestion, getAnswers, createAnswer, toggleLike, deleteQuestion, deleteAnswer, updateQuestion, updateAnswer, getComments, createComment, deleteComment, type Comment } from '../../lib/api/community'; +import { getErrorMessage } from '../../lib/apiClient'; import { useAuth } from '../../contexts/AuthContext'; interface QuestionDetailModalProps { @@ -126,9 +127,9 @@ export default function QuestionDetailModal({ setReplyContent(''); // 通知父组件更新回复数 onAnswerCreated?.(question.id); - } catch (err: any) { + } catch (err: unknown) { console.error('回复失败:', err); - alert(err.message || '回复失败,请重试'); + alert(getErrorMessage(err)); } finally { setIsSubmitting(false); } @@ -159,9 +160,9 @@ export default function QuestionDetailModal({ await deleteQuestion(question.id); onQuestionDeleted?.(question.id); onClose(); - } catch (err: any) { + } catch (err: unknown) { console.error('删除失败:', err); - alert(err.message || '删除失败'); + alert(getErrorMessage(err)); } }; @@ -172,9 +173,9 @@ export default function QuestionDetailModal({ try { await deleteAnswer(answerId); setAnswers((prev) => prev.filter((a) => a.id !== answerId)); - } catch (err: any) { + } catch (err: unknown) { console.error('删除失败:', err); - alert(err.message || '删除失败'); + alert(getErrorMessage(err)); } }; @@ -207,9 +208,9 @@ export default function QuestionDetailModal({ setLocalQuestion(updated); setIsEditingQuestion(false); onQuestionUpdated?.(question.id); - } catch (err: any) { + } catch (err: unknown) { console.error('保存失败:', err); - alert(err.message || '保存失败'); + alert(getErrorMessage(err)); } finally { setIsSavingQuestion(false); } @@ -223,9 +224,9 @@ export default function QuestionDetailModal({ prev.map((a) => (a.id === answerId ? { ...a, content: updated.content } : a)) ); return true; - } catch (err: any) { + } catch (err: unknown) { console.error('保存失败:', err); - alert(err.message || '保存失败'); + alert(getErrorMessage(err)); return false; } }; @@ -683,9 +684,9 @@ function AnswerCard({ setCommentContent(''); setReplyingTo(null); - } catch (err: any) { + } catch (err: unknown) { console.error('评论失败:', err); - alert(err.message || '评论失败'); + alert(getErrorMessage(err)); } finally { setIsSubmittingComment(false); } @@ -706,9 +707,9 @@ function AnswerCard({ replies: c.replies.filter((r) => r.id !== commentId), })) ); - } catch (err: any) { + } catch (err: unknown) { console.error('删除评论失败:', err); - alert(err.message || '删除失败'); + alert(getErrorMessage(err)); } }; diff --git a/frontend/lib/apiClient.ts b/frontend/lib/apiClient.ts index 3862c563..3d3798fe 100644 --- a/frontend/lib/apiClient.ts +++ b/frontend/lib/apiClient.ts @@ -156,8 +156,15 @@ export default apiClient; // Helper function to extract error message export function getErrorMessage(error: unknown): string { if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError<{ detail?: string }>; - return axiosError.response?.data?.detail || axiosError.message || '请求失败'; + const axiosError = error as AxiosError<{ detail?: string | { message?: string } }>; + const detail = axiosError.response?.data?.detail; + if (typeof detail === 'string') { + return detail; + } + if (detail && typeof detail === 'object' && 'message' in detail) { + return detail.message || '请求失败'; + } + return axiosError.message || '请求失败'; } if (error instanceof Error) { return error.message; From 08a34251c1c7c7aaeeea0ef47c9be82255f757e5 Mon Sep 17 00:00:00 2001 From: Koishi Date: Mon, 9 Feb 2026 01:24:28 +0800 Subject: [PATCH 3/4] chore: update ci --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c43dc62c..f2bca3c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,3 +83,6 @@ jobs: - name: TypeScript type check run: npm run typecheck + + - name: Build + run: npm run build From a8d8e6f0d4f4dea661bed3f3dd4b6c717f7c0755 Mon Sep 17 00:00:00 2001 From: Koishi Date: Mon, 9 Feb 2026 01:24:38 +0800 Subject: [PATCH 4/4] docs: update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 47c390c8..d659aef6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/koishi510/MomShell/ci.yml?branch=main&style=flat&label=CI)](https://github.com/koishi510/MomShell/actions/workflows/ci.yml) -[![Version](https://img.shields.io/github/v/tag/koishi510/MomShell?style=flat&label=version)](https://github.com/koishi510/MomShell/tags) +[![Version](https://img.shields.io/github/v/tag/koishi510/MomShell?style=flat&label=Version)](https://github.com/koishi510/MomShell/tags) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL%203.0-blue?style=flat)](LICENSE) [![Python](https://img.shields.io/badge/Python-3.11-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org/) [![Node.js](https://img.shields.io/badge/Node.js-22-339933?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)