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

20
backend/alembic/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Alembic 使用说明
1. 安装依赖(在后端虚拟环境内)
```
pip install alembic
```
2. 设置环境变量,确保 `DATABASE_URL` 指向目标数据库。
3. 运行迁移:
```
alembic upgrade head
```
4. 生成新迁移(如有模型变更):
```
alembic revision -m "message" --autogenerate
```
说明:`alembic/env.py` 会从 `app.core.config` 读取数据库 URL并包含 admin/provider 模型。

68
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,68 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import settings
from app.db import models, admin_models # ensure models are imported
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
fileConfig(config.config_file_name)
# override sqlalchemy.url from settings
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = models.Base.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
"""Run migrations in 'online' mode."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
connect_args={"statement_cache_size": 0},
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@@ -0,0 +1,45 @@
"""init providers and story mode"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0001_init_providers"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"providers",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("type", sa.String(length=50), nullable=False),
sa.Column("adapter", sa.String(length=100), nullable=False),
sa.Column("model", sa.String(length=200), nullable=True),
sa.Column("api_base", sa.String(length=300), nullable=True),
sa.Column("timeout_ms", sa.Integer(), server_default="60000", nullable=False),
sa.Column("max_retries", sa.Integer(), server_default="1", nullable=False),
sa.Column("weight", sa.Integer(), server_default="1", nullable=False),
sa.Column("priority", sa.Integer(), server_default="0", nullable=False),
sa.Column("enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column("config_ref", sa.String(length=100), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_by", sa.String(length=100), nullable=True),
)
with op.batch_alter_table("stories", schema=None) as batch_op:
batch_op.add_column(
sa.Column("mode", sa.String(length=20), server_default="generated", nullable=False)
)
batch_op.alter_column("mode", server_default=None)
def downgrade() -> None:
with op.batch_alter_table("stories", schema=None) as batch_op:
batch_op.drop_column("mode")
op.drop_table("providers")

View File

@@ -0,0 +1,29 @@
"""add api_key to providers
Revision ID: 0002_add_api_key_to_providers
Revises: 0001_init_providers_and_story_mode
Create Date: 2025-01-01
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0002_add_api_key"
down_revision = "0001_init_providers"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 添加 api_key 列,可为空,优先于 config_ref 使用
with op.batch_alter_table("providers", schema=None) as batch_op:
batch_op.add_column(
sa.Column("api_key", sa.String(length=500), nullable=True)
)
def downgrade() -> None:
with op.batch_alter_table("providers", schema=None) as batch_op:
batch_op.drop_column("api_key")

View File

@@ -0,0 +1,100 @@
"""add provider monitoring tables
Revision ID: 0003_add_monitoring
Revises: 0002_add_api_key
Create Date: 2025-01-01
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0003_add_monitoring"
down_revision = "0002_add_api_key"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 创建 provider_metrics 表
op.create_table(
"provider_metrics",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("provider_id", sa.String(length=36), nullable=False),
sa.Column(
"timestamp",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.Column("success", sa.Boolean(), nullable=False),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("cost_usd", sa.Numeric(precision=10, scale=6), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("request_id", sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(
["provider_id"],
["providers.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_provider_metrics_provider_id",
"provider_metrics",
["provider_id"],
unique=False,
)
op.create_index(
"ix_provider_metrics_timestamp",
"provider_metrics",
["timestamp"],
unique=False,
)
# 创建 provider_health 表
op.create_table(
"provider_health",
sa.Column("provider_id", sa.String(length=36), nullable=False),
sa.Column("is_healthy", sa.Boolean(), server_default=sa.text("true"), nullable=True),
sa.Column("last_check", sa.DateTime(timezone=True), nullable=True),
sa.Column("consecutive_failures", sa.Integer(), server_default=sa.text("0"), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(
["provider_id"],
["providers.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("provider_id"),
)
# 创建 provider_secrets 表
op.create_table(
"provider_secrets",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("encrypted_value", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=True,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
def downgrade() -> None:
op.drop_table("provider_secrets")
op.drop_table("provider_health")
op.drop_index("ix_provider_metrics_timestamp", table_name="provider_metrics")
op.drop_index("ix_provider_metrics_provider_id", table_name="provider_metrics")
op.drop_table("provider_metrics")

View File

@@ -0,0 +1,42 @@
"""add child profiles
Revision ID: 0004_add_child_profiles
Revises: 0003_add_monitoring
Create Date: 2025-12-22
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0004_add_child_profiles"
down_revision = "0003_add_monitoring"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"child_profiles",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(255), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(50), nullable=False),
sa.Column("avatar_url", sa.String(500)),
sa.Column("birth_date", sa.Date()),
sa.Column("gender", sa.String(10)),
sa.Column("interests", sa.JSON(), server_default="[]", nullable=False),
sa.Column("growth_themes", sa.JSON(), server_default="[]", nullable=False),
sa.Column("reading_preferences", sa.JSON(), server_default="{}", nullable=False),
sa.Column("stories_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("total_reading_time", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "name", name="uq_child_profile_user_name"),
)
op.create_index("idx_child_profiles_user_id", "child_profiles", ["user_id"])
def downgrade():
op.drop_index("idx_child_profiles_user_id", table_name="child_profiles")
op.drop_table("child_profiles")

View File

@@ -0,0 +1,67 @@
"""add story universes and story links
Revision ID: 0005_add_story_universes_and_story_links
Revises: 0004_add_child_profiles
Create Date: 2025-12-22
"""
from alembic import op
import sqlalchemy as sa
revision = "0005_add_story_universes_and_story_links"
down_revision = "0004_add_child_profiles"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"story_universes",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"child_profile_id",
sa.String(36),
sa.ForeignKey("child_profiles.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("protagonist", sa.JSON(), nullable=False),
sa.Column("recurring_characters", sa.JSON(), server_default="[]", nullable=False),
sa.Column("world_settings", sa.JSON(), server_default="{}", nullable=False),
sa.Column("achievements", sa.JSON(), server_default="[]", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("idx_story_universes_child_id", "story_universes", ["child_profile_id"])
op.create_index("idx_story_universes_updated_at", "story_universes", ["updated_at"])
op.add_column("stories", sa.Column("child_profile_id", sa.String(36), nullable=True))
op.add_column("stories", sa.Column("universe_id", sa.String(36), nullable=True))
op.create_foreign_key(
"fk_stories_child_profile",
"stories",
"child_profiles",
["child_profile_id"],
["id"],
ondelete="SET NULL",
)
op.create_foreign_key(
"fk_stories_universe",
"stories",
"story_universes",
["universe_id"],
["id"],
ondelete="SET NULL",
)
def downgrade():
op.drop_constraint("fk_stories_universe", "stories", type_="foreignkey")
op.drop_constraint("fk_stories_child_profile", "stories", type_="foreignkey")
op.drop_column("stories", "universe_id")
op.drop_column("stories", "child_profile_id")
op.drop_index("idx_story_universes_updated_at", table_name="story_universes")
op.drop_index("idx_story_universes_child_id", table_name="story_universes")
op.drop_table("story_universes")

View File

@@ -0,0 +1,78 @@
"""add reading events and memory items
Revision ID: 0006_add_reading_events_and_memory_items
Revises: 0005_add_story_universes_and_story_links
Create Date: 2025-12-22
"""
from alembic import op
import sqlalchemy as sa
revision = "0006_add_reading_events_and_memory_items"
down_revision = "0005_add_story_universes_and_story_links"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"reading_events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"child_profile_id",
sa.String(36),
sa.ForeignKey("child_profiles.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"story_id",
sa.Integer(),
sa.ForeignKey("stories.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("event_type", sa.String(20), nullable=False),
sa.Column("reading_time", sa.Integer(), server_default="0", nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index("idx_reading_events_profile", "reading_events", ["child_profile_id"])
op.create_index("idx_reading_events_story", "reading_events", ["story_id"])
op.create_index("idx_reading_events_created", "reading_events", ["created_at"])
op.create_table(
"memory_items",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"child_profile_id",
sa.String(36),
sa.ForeignKey("child_profiles.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"universe_id",
sa.String(36),
sa.ForeignKey("story_universes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("type", sa.String(50), nullable=False),
sa.Column("value", sa.JSON(), nullable=False),
sa.Column("base_weight", sa.Float(), server_default="1.0", nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("ttl_days", sa.Integer()),
)
op.create_index("idx_memory_items_profile", "memory_items", ["child_profile_id"])
op.create_index("idx_memory_items_universe", "memory_items", ["universe_id"])
op.create_index("idx_memory_items_last_used", "memory_items", ["last_used_at"])
def downgrade():
op.drop_index("idx_memory_items_last_used", table_name="memory_items")
op.drop_index("idx_memory_items_universe", table_name="memory_items")
op.drop_index("idx_memory_items_profile", table_name="memory_items")
op.drop_table("memory_items")
op.drop_index("idx_reading_events_created", table_name="reading_events")
op.drop_index("idx_reading_events_story", table_name="reading_events")
op.drop_index("idx_reading_events_profile", table_name="reading_events")
op.drop_table("reading_events")

View File

@@ -0,0 +1,68 @@
"""Add push configs and events.
Revision ID: 0007_add_push_configs_and_events
Revises: 0006_add_reading_events_and_memory_items
Create Date: 2025-12-24 16:40:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "0007_add_push_configs_and_events"
down_revision = "0006_add_reading_events_and_memory_items"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"push_configs",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("user_id", sa.String(length=255), nullable=False),
sa.Column("child_profile_id", sa.String(length=36), nullable=False),
sa.Column("push_time", sa.Time(), nullable=True),
sa.Column("push_days", sa.JSON(), nullable=False, server_default="[]"),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["child_profile_id"], ["child_profiles.id"], ondelete="CASCADE"),
sa.UniqueConstraint("child_profile_id", name="uq_push_config_child"),
)
op.create_index("ix_push_configs_user_id", "push_configs", ["user_id"])
op.create_index("ix_push_configs_child_profile_id", "push_configs", ["child_profile_id"])
op.create_table(
"push_events",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("user_id", sa.String(length=255), nullable=False),
sa.Column("child_profile_id", sa.String(length=36), nullable=False),
sa.Column("trigger_type", sa.String(length=20), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("sent_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["child_profile_id"], ["child_profiles.id"], ondelete="CASCADE"),
)
op.create_index("ix_push_events_user_id", "push_events", ["user_id"])
op.create_index("ix_push_events_child_profile_id", "push_events", ["child_profile_id"])
op.create_index("ix_push_events_sent_at", "push_events", ["sent_at"])
def downgrade() -> None:
op.drop_index("ix_push_events_sent_at", table_name="push_events")
op.drop_index("ix_push_events_child_profile_id", table_name="push_events")
op.drop_index("ix_push_events_user_id", table_name="push_events")
op.drop_table("push_events")
op.drop_index("ix_push_configs_child_profile_id", table_name="push_configs")
op.drop_index("ix_push_configs_user_id", table_name="push_configs")
op.drop_table("push_configs")

View File

@@ -0,0 +1,25 @@
"""add pages column to stories
Revision ID: 0008_add_pages_to_stories
Revises: 0007_add_push_configs_and_events
Create Date: 2026-01-20
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0008_add_pages_to_stories'
down_revision = '0007_add_push_configs_and_events'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('stories', sa.Column('pages', postgresql.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('stories', 'pages')