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

@@ -0,0 +1,3 @@
"""Celery tasks package."""
from . import achievements, memory, push_notifications # noqa: F401

View File

@@ -0,0 +1,82 @@
"""Celery tasks for achievements."""
import asyncio
from datetime import datetime
from sqlalchemy import select
from app.core.celery_app import celery_app
from app.core.logging import get_logger
from app.db.database import _get_session_factory
from app.db.models import Story, StoryUniverse
from app.services.achievement_extractor import extract_achievements
logger = get_logger(__name__)
@celery_app.task
def extract_story_achievements(story_id: int, universe_id: str) -> None:
"""Extract achievements and update universe."""
asyncio.run(_extract_story_achievements(story_id, universe_id))
async def _extract_story_achievements(story_id: int, universe_id: str) -> None:
session_factory = _get_session_factory()
async with session_factory() as session:
result = await session.execute(select(Story).where(Story.id == story_id))
story = result.scalar_one_or_none()
if not story:
logger.warning("achievement_task_story_missing", story_id=story_id)
return
result = await session.execute(
select(StoryUniverse).where(StoryUniverse.id == universe_id)
)
universe = result.scalar_one_or_none()
if not universe:
logger.warning("achievement_task_universe_missing", universe_id=universe_id)
return
text_content = story.story_text
if not text_content and story.pages:
# 如果是绘本,拼接每页文本
text_content = "\n".join([str(p.get("text", "")) for p in story.pages])
if not text_content:
logger.warning("achievement_task_empty_content", story_id=story_id)
return
achievements = await extract_achievements(text_content)
if not achievements:
logger.info("achievement_task_no_new", story_id=story_id)
return
existing = {
(str(item.get("type", "")).strip(), str(item.get("description", "")).strip())
for item in (universe.achievements or [])
if isinstance(item, dict)
}
merged = list(universe.achievements or [])
added_count = 0
for item in achievements:
key = (item.get("type", "").strip(), item.get("description", "").strip())
if key in existing:
continue
merged.append({
"type": key[0],
"description": key[1],
"obtained_at": datetime.now().isoformat(),
"source_story_id": story_id,
})
existing.add(key)
added_count += 1
universe.achievements = merged
await session.commit()
logger.info(
"achievement_task_success",
story_id=story_id,
universe_id=universe_id,
added=added_count,
)

View File

@@ -0,0 +1,29 @@
import asyncio
from app.core.celery_app import celery_app
from app.core.logging import get_logger
from app.db.database import _get_session_factory
from app.services.memory_service import prune_expired_memories
logger = get_logger(__name__)
@celery_app.task
def prune_memories_task():
"""Daily task to prune expired memories."""
logger.info("prune_memories_task_started")
async def _run():
# Ensure engine is initialized in this process
session_factory = _get_session_factory()
async with session_factory() as session:
return await prune_expired_memories(session)
try:
# Create a new event loop for this task execution
count = asyncio.run(_run())
logger.info("prune_memories_task_completed", deleted_count=count)
return f"Deleted {count} expired memories"
except Exception as exc:
logger.error("prune_memories_task_failed", error=str(exc))
raise

View File

@@ -0,0 +1,108 @@
"""Celery tasks for push notifications."""
import asyncio
from datetime import datetime, time
from zoneinfo import ZoneInfo
from sqlalchemy import select
from app.core.celery_app import celery_app
from app.core.logging import get_logger
from app.db.database import _get_session_factory
from app.db.models import PushConfig, PushEvent
logger = get_logger(__name__)
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
QUIET_HOURS_START = time(21, 0)
QUIET_HOURS_END = time(9, 0)
TRIGGER_WINDOW_MINUTES = 30
@celery_app.task
def check_push_notifications() -> None:
"""Check push configs and create push events."""
asyncio.run(_check_push_notifications())
def _is_quiet_hours(current: time) -> bool:
if QUIET_HOURS_START < QUIET_HOURS_END:
return QUIET_HOURS_START <= current < QUIET_HOURS_END
return current >= QUIET_HOURS_START or current < QUIET_HOURS_END
def _within_window(current: time, target: time) -> bool:
current_minutes = current.hour * 60 + current.minute
target_minutes = target.hour * 60 + target.minute
return 0 <= current_minutes - target_minutes < TRIGGER_WINDOW_MINUTES
async def _already_sent_today(
session,
child_profile_id: str,
now: datetime,
) -> bool:
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
end = now.replace(hour=23, minute=59, second=59, microsecond=999999)
result = await session.execute(
select(PushEvent.id).where(
PushEvent.child_profile_id == child_profile_id,
PushEvent.status == "sent",
PushEvent.sent_at >= start,
PushEvent.sent_at <= end,
)
)
return result.scalar_one_or_none() is not None
async def _check_push_notifications() -> None:
session_factory = _get_session_factory()
now = datetime.now(LOCAL_TZ)
current_day = now.weekday()
current_time = now.time()
async with session_factory() as session:
result = await session.execute(
select(PushConfig).where(PushConfig.enabled.is_(True))
)
configs = result.scalars().all()
for config in configs:
if not config.push_time:
continue
if config.push_days and current_day not in config.push_days:
continue
if not _within_window(current_time, config.push_time):
continue
if _is_quiet_hours(current_time):
session.add(
PushEvent(
user_id=config.user_id,
child_profile_id=config.child_profile_id,
trigger_type="time",
status="suppressed",
reason="quiet_hours",
sent_at=now,
)
)
continue
if await _already_sent_today(session, config.child_profile_id, now):
continue
session.add(
PushEvent(
user_id=config.user_id,
child_profile_id=config.child_profile_id,
trigger_type="time",
status="sent",
reason=None,
sent_at=now,
)
)
logger.info(
"push_event_sent",
child_profile_id=config.child_profile_id,
user_id=config.user_id,
)
await session.commit()