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:
3
backend/app/tasks/__init__.py
Normal file
3
backend/app/tasks/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Celery tasks package."""
|
||||
|
||||
from . import achievements, memory, push_notifications # noqa: F401
|
||||
82
backend/app/tasks/achievements.py
Normal file
82
backend/app/tasks/achievements.py
Normal 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,
|
||||
)
|
||||
29
backend/app/tasks/memory.py
Normal file
29
backend/app/tasks/memory.py
Normal 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
|
||||
108
backend/app/tasks/push_notifications.py
Normal file
108
backend/app/tasks/push_notifications.py
Normal 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()
|
||||
Reference in New Issue
Block a user