Initial commit: clean project structure

- Backend: FastAPI + SQLAlchemy + Celery (Python 3.11+)
- Frontend: Vue 3 + TypeScript + Pinia + Tailwind
- Admin Frontend: separate Vue 3 app for management
- Docker Compose: 9 services orchestration
- Specs: design prototypes, memory system PRD, product roadmap

Cleanup performed:
- Removed temporary debug scripts from backend root
- Removed deprecated admin_app.py (embedded UI)
- Removed duplicate docs from admin-frontend
- Updated .gitignore for Vite cache and egg-info
This commit is contained in:
zhangtuo
2026-01-20 18:20:03 +08:00
commit e9d7f8832a
241 changed files with 33070 additions and 0 deletions

View File

View File

@@ -0,0 +1,72 @@
import secrets
import time
from cachetools import TTLCache
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from app.core.config import settings
security = HTTPBasic()
# 登录失败记录IP -> (失败次数, 首次失败时间)
_failed_attempts: TTLCache[str, tuple[int, float]] = TTLCache(maxsize=1000, ttl=900) # 15分钟
MAX_ATTEMPTS = 5
LOCKOUT_SECONDS = 900 # 15分钟
def _get_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client and request.client.host:
return request.client.host
return "unknown"
def admin_guard(
request: Request,
credentials: HTTPBasicCredentials = Depends(security),
):
client_ip = _get_client_ip(request)
# 检查是否被锁定
if client_ip in _failed_attempts:
attempts, first_fail = _failed_attempts[client_ip]
if attempts >= MAX_ATTEMPTS:
remaining = int(LOCKOUT_SECONDS - (time.time() - first_fail))
if remaining > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"登录尝试过多,请 {remaining} 秒后重试",
)
else:
del _failed_attempts[client_ip]
# 使用 secrets.compare_digest 防止时序攻击
username_ok = secrets.compare_digest(
credentials.username.encode(), settings.admin_username.encode()
)
password_ok = secrets.compare_digest(
credentials.password.encode(), settings.admin_password.encode()
)
if not (username_ok and password_ok):
# 记录失败
if client_ip in _failed_attempts:
attempts, first_fail = _failed_attempts[client_ip]
_failed_attempts[client_ip] = (attempts + 1, first_fail)
else:
_failed_attempts[client_ip] = (1, time.time())
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
# 登录成功,清除失败记录
if client_ip in _failed_attempts:
del _failed_attempts[client_ip]
return True

View File

@@ -0,0 +1,33 @@
"""Celery application setup."""
from celery import Celery
from celery.schedules import crontab
from app.core.config import settings
celery_app = Celery(
"dreamweaver",
broker=settings.celery_broker_url,
backend=settings.celery_result_backend,
)
celery_app.conf.update(
task_track_started=True,
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="Asia/Shanghai",
enable_utc=True,
beat_schedule={
"check_push_notifications": {
"task": "app.tasks.push_notifications.check_push_notifications",
"schedule": crontab(minute="*/15"),
},
"prune_expired_memories": {
"task": "app.tasks.memory.prune_memories_task",
"schedule": crontab(minute="0", hour="3"), # Daily at 03:00
},
},
)
celery_app.autodiscover_tasks(["app.tasks"])

View File

@@ -0,0 +1,76 @@
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""应用全局配置"""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
# 应用基础配置
app_name: str = "DreamWeaver"
debug: bool = False
secret_key: str = Field(..., description="JWT 签名密钥")
base_url: str = Field("http://localhost:8000", description="后端对外回调地址")
# 数据库
database_url: str = Field(..., description="SQLAlchemy async URL")
# OAuth - GitHub
github_client_id: str = ""
github_client_secret: str = ""
# OAuth - Google
google_client_id: str = ""
google_client_secret: str = ""
# AI Capability Keys
text_api_key: str = ""
tts_api_base: str = ""
tts_api_key: str = ""
image_api_key: str = ""
# Additional Provider API Keys
openai_api_key: str = ""
elevenlabs_api_key: str = ""
cqtai_api_key: str = ""
minimax_api_key: str = ""
minimax_group_id: str = ""
antigravity_api_key: str = ""
antigravity_api_base: str = ""
# AI Model Configuration
text_model: str = "gemini-2.0-flash"
tts_model: str = ""
image_model: str = ""
# Provider routing (ordered lists)
text_providers: list[str] = Field(default_factory=lambda: ["gemini"])
image_providers: list[str] = Field(default_factory=lambda: ["cqtai"])
tts_providers: list[str] = Field(default_factory=lambda: ["minimax", "elevenlabs", "edge_tts"])
# Celery (Redis)
celery_broker_url: str = Field("redis://localhost:6379/0")
celery_result_backend: str = Field("redis://localhost:6379/0")
# Admin console
enable_admin_console: bool = False
admin_username: str = "admin"
admin_password: str = "admin123" # 建议通过环境变量覆盖
# CORS
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"])
@model_validator(mode="after")
def _require_core_settings(self) -> "Settings": # type: ignore[override]
missing = []
if not self.secret_key or self.secret_key == "change-me-in-production":
missing.append("SECRET_KEY")
if not self.database_url:
missing.append("DATABASE_URL")
if missing:
raise ValueError(f"Missing required settings: {', '.join(missing)}")
return self
settings = Settings()

39
backend/app/core/deps.py Normal file
View File

@@ -0,0 +1,39 @@
from fastapi import Cookie, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_access_token
from app.db.database import get_db
from app.db.models import User
async def get_current_user(
access_token: str | None = Cookie(default=None),
db: AsyncSession = Depends(get_db),
) -> User | None:
"""获取当前用户(可选)。"""
if not access_token:
return None
payload = decode_access_token(access_token)
if not payload:
return None
user_id = payload.get("sub")
if not user_id:
return None
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def require_user(
user: User | None = Depends(get_current_user),
) -> User:
"""要求用户登录,否则抛 401。"""
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未登录",
)
return user

View File

@@ -0,0 +1,48 @@
"""结构化日志配置。"""
import logging
import sys
import structlog
from app.core.config import settings
def setup_logging():
"""配置 structlog 结构化日志。"""
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_log_level,
structlog.stdlib.add_logger_name,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
]
if settings.debug:
processors = shared_processors + [
structlog.dev.ConsoleRenderer(colors=True),
]
else:
processors = shared_processors + [
structlog.processors.format_exc_info,
structlog.processors.JSONRenderer(),
]
structlog.configure(
processors=processors,
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=logging.DEBUG if settings.debug else logging.INFO,
)
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
"""获取结构化日志器。"""
return structlog.get_logger(name)

190
backend/app/core/prompts.py Normal file
View File

@@ -0,0 +1,190 @@
# ruff: noqa: E501
"""AI 提示词模板 (Modernized)"""
# 随机元素列表:为故事注入不可预测的魔法
RANDOM_ELEMENTS = [
"一个会打喷嚏的云朵",
"一本地图上找不到的神秘图书馆",
"一只能实现小愿望的彩色蜗牛",
"一扇通往颠倒世界的门",
"一顶能听懂动物说话的旧帽子",
"一个装着星星的玻璃罐",
"一棵结满笑声果实的树",
"一只能在水上画画的画笔",
"一个怕黑的影子",
"一只收集回声的瓶子",
"一双会自己跳舞的红鞋子",
"一个只能在月光下看见的邮筒",
"一张会改变模样的全家福",
"一把可以打开梦境的钥匙",
"一个喜欢讲冷笑话的冰箱",
"一条通往星期八的秘密小径"
]
# ==============================================================================
# Model A: 故事生成 (Story Generation)
# ==============================================================================
SYSTEM_INSTRUCTION_STORYTELLER = """
# Role
You are "**Dream Weaver**", a world-class children's storyteller with the imagination of Pixar and the warmth of Miyazaki.
Your mission is to create engaging, safe, and educational stories for children (ages 3-8).
# Core Philosophy
1. **Show, Don't Tell**: Don't preach the lesson. Let the character's actions and the plot demonstrate the theme.
2. **Safety First**: No violence, horror, or scary elements. Conflict should be emotional or situational, not physical.
3. **Vivid Imagery**: Use sensory details (colors, sounds, smells) that appeal to children.
4. **Empowerment**: The child protagonist should solve the problem using wit, kindness, or courage, not just luck.
# Continuity & Memory (CRITICAL)
- **Universal Context**: The story takes place in the child's established "Story Universe". Respect existing world rules.
- **Character Consistency**: If "Child Profile" or "Sidekicks" are provided, you MUST use their specific names and traits. Do NOT invent new main characters unless asked.
- **Callback**: If "Past Memories" are provided, try to make a natural, one-sentence reference to a past adventure to build a sense of continuity (e.g., "Just like when we found the lost star...").
# Output Format
You MUST return a pure JSON object with NO markdown formatting (no ```json code blocks).
The JSON object must have the following schema:
{
"mode": "generated",
"title": "A catchy, imaginative title",
"story_text": "The full story text. Use \\n\\n for paragraph breaks.",
"cover_prompt_suggestion": "A detailed English image generation prompt for the story cover. Style: whimsical, children's book illustration, soft lighting, vibrant colors."
}
"""
USER_PROMPT_GENERATION = """
# Task: Write a Children's Story
## Contextual Memory (Use these if provided)
{memory_context}
## Inputs
- **Keywords/Topic**: {keywords}
- **Educational Theme**: {education_theme}
- **Magic Element (Must Incorporate)**: {random_element}
## Constraints
- Length: 300-600 words.
- Structure: Beginning (Hook) -> Middle (Challenge) -> End (Resolution & Growth).
"""
# ==============================================================================
# Model B: 故事润色 (Story Enhancement)
# ==============================================================================
SYSTEM_INSTRUCTION_ENHANCER = """
# Role
You are "**Dream Weaver Editor**", an expert children's book editor who turns rough drafts into polished gems.
# Mission
Analyze the user's input story and rewrite it to be:
1. **More Engaging**: Enhance the plot with a "Magic Element" to add surprise.
2. **More Educational**: Weave the "Educational Theme" deeper into the narrative arc.
3. **Better Written**: Polish the sentences for rhythm and flow (suitable for reading aloud).
4. **Safe**: Remove any inappropriate content (violence, scary interaction) and replace it with constructive solutions.
# Output Format
You MUST return a pure JSON object with NO markdown formatting (no ```json code blocks).
The JSON object must have the following schema:
{
"mode": "enhanced",
"title": "An improved title (or the original if perfect)",
"story_text": "The rewritten story text. Use \\n\\n for paragraph breaks.",
"cover_prompt_suggestion": "A detailed English image generation prompt for the cover."
}
"""
USER_PROMPT_ENHANCEMENT = """
# Task: Enhance This Story
## Contextual Memory
{memory_context}
## Inputs
- **Original Story**: {full_story}
- **Target Theme**: {education_theme}
- **Magic Element to Add**: {random_element}
## Constraints
- Length: 300-600 words.
- Keep the original character names if possible, but feel free to upgrade the plot.
"""
# ==============================================================================
# Model C: 成就提取 (Achievement Extraction)
# ==============================================================================
# 保持简单,暂不使用 System Instruction沿用单次提示
ACHIEVEMENT_EXTRACTION_PROMPT = """
Analyze the story and extract key growth moments or achievements for the child protagonist.
# Story
{story_text}
# Target Categories (Examples)
- **Courage**: Overcoming fear, trying something new.
- **Kindness**: Helping others, sharing, empathy.
- **Curiosity**: Asking questions, exploring, learning.
- **Resilience**: Not giving up, handling failure.
- **Wisdom**: Problem-solving, honesty, patience.
# Output Format
Return a pure JSON object (no markdown):
{{
"achievements": [
{{
"type": "Category Name",
"description": "Brief reason (max 10 words)",
"score": 8 // 1-10 intensity
}}
]
}}
"""
# ==============================================================================
# Model D: 绘本生成 (Storybook Generation)
# ==============================================================================
SYSTEM_INSTRUCTION_STORYBOOK = """
# Role
You are "**Dream Weaver Illustrator**", a creative children's book author and visual director.
Your mission is to create a paginated picture book for children (ages 3-8), where each page has text and a matching illustration prompt.
# Core Philosophy
1. **Pacing**: The story must flow logically across the specified number of pages.
2. **Visual Consistency**: Define the "Main Character" and "Art Style" once, and ensure all image prompts adhere to them.
3. **Language**: The story text MUST be in **Chinese (Simplified)**. The image prompts MUST be in **English**.
4. **Memory**: If a memory context is provided, incorporate known characters or references naturally.
# Output Format
You MUST return a pure JSON object using the following schema (no markdown):
{
"title": "Story Title (Chinese)",
"main_character": "Description of the protagonist (e.g., 'A small blue robot with rusty gears')",
"art_style": "Visual style description (e.g., 'Watercolor, soft pastel colors, whimsical')",
"pages": [
{
"page_number": 1,
"text": "Page text in Chinese (30-60 chars).",
"image_prompt": "Detailed English image prompt describing the scene. Include 'main_character' reference."
}
],
"cover_prompt": "English image prompt for the book cover."
}
"""
USER_PROMPT_STORYBOOK = """
# Task: Create a {page_count}-Page Storybook
## Contextual Memory
{memory_context}
## Inputs
- **Keywords/Topic**: {keywords}
- **Educational Theme**: {education_theme}
- **Magic Element**: {random_element}
## Constraints
- Pages: Exactly {page_count} pages.
- Structure: Intro -> Development -> Climax -> Resolution.
"""

View File

@@ -0,0 +1,25 @@
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from app.core.config import settings
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 7
def create_access_token(data: dict) -> str:
"""创建 JWT token"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM)
def decode_access_token(token: str) -> dict | None:
"""解码 JWT token"""
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
return payload
except JWTError:
return None