feat(init): 完成 Phase 1 基础架构搭建

- 完成 T-001A: 前端项目初始化 (Next.js 14 + TypeScript + Tailwind CSS)
- 完成 T-001B: 后端项目初始化 (FastAPI + SQLAlchemy + asyncpg)
- 完成 T-002: 数据库配置 (KolVideo 模型 + 索引 + 测试)
- 完成 T-003: 基础 UI 框架 (Header/Footer 组件 + 品牌色系)
- 完成 T-004: 环境变量配置 (前后端环境变量)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zfc
2026-01-28 14:26:46 +08:00
co-authored by Claude
commit ac0f086821
75 changed files with 13285 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# 数据库连接
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/yuntu_kol
# CORS 允许的前端地址
CORS_ORIGINS=["http://localhost:3000"]
# 品牌 API 配置
BRAND_API_BASE_URL=https://api.internal.intelligrow.cn
+42
View File
@@ -0,0 +1,42 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+71
View File
@@ -0,0 +1,71 @@
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.config import settings
from app.database import Base
from app.models import KolVideo # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_url():
return settings.DATABASE_URL
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async engine."""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = get_url()
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
View File
View File
View File
+28
View File
@@ -0,0 +1,28 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List
class Settings(BaseSettings):
"""Application settings."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
)
# Database
DATABASE_URL: str = "postgresql+asyncpg://user:password@localhost:5432/yuntu_kol"
# CORS
CORS_ORIGINS: List[str] = ["http://localhost:3000"]
# Brand API
BRAND_API_BASE_URL: str = "https://api.internal.intelligrow.cn"
# API Settings
MAX_QUERY_LIMIT: int = 1000
BRAND_API_TIMEOUT: float = 3.0
BRAND_API_CONCURRENCY: int = 10
settings = Settings()
View File
+34
View File
@@ -0,0 +1,34 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
# 创建异步引擎
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
# 创建异步会话工厂
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
"""Base class for all models."""
pass
async def get_db() -> AsyncSession:
"""Dependency to get database session."""
async with async_session_maker() as session:
try:
yield session
finally:
await session.close()
+31
View File
@@ -0,0 +1,31 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
app = FastAPI(
title="KOL Insight API",
description="KOL 视频数据查询与分析 API",
version="1.0.0",
)
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
async def root():
"""Root endpoint."""
return {"message": "KOL Insight API", "version": "1.0.0"}
@app.get("/health")
async def health():
"""Health check endpoint."""
return {"status": "healthy"}
+3
View File
@@ -0,0 +1,3 @@
from app.models.kol_video import KolVideo
__all__ = ["KolVideo"]
+52
View File
@@ -0,0 +1,52 @@
from sqlalchemy import Column, String, Integer, Float, DateTime, Index
from app.database import Base
class KolVideo(Base):
"""KOL 视频数据模型."""
__tablename__ = "kol_videos"
# 主键
item_id = Column(String, primary_key=True)
# 基础信息
title = Column(String, nullable=True)
viral_type = Column(String, nullable=True)
video_url = Column(String, nullable=True)
star_id = Column(String, nullable=False)
star_unique_id = Column(String, nullable=False)
star_nickname = Column(String, nullable=False)
publish_time = Column(DateTime, nullable=True)
# 曝光指标
natural_play_cnt = Column(Integer, default=0)
heated_play_cnt = Column(Integer, default=0)
total_play_cnt = Column(Integer, default=0)
# 互动指标
total_interact = Column(Integer, default=0)
like_cnt = Column(Integer, default=0)
share_cnt = Column(Integer, default=0)
comment_cnt = Column(Integer, default=0)
# 效果指标
new_a3_rate = Column(Float, nullable=True)
after_view_search_uv = Column(Integer, default=0)
return_search_cnt = Column(Integer, default=0)
# 商业信息
industry_id = Column(String, nullable=True)
industry_name = Column(String, nullable=True)
brand_id = Column(String, nullable=True)
estimated_video_cost = Column(Float, default=0)
# 索引定义
__table_args__ = (
Index("idx_star_id", "star_id"),
Index("idx_star_unique_id", "star_unique_id"),
Index("idx_star_nickname", "star_nickname"),
)
def __repr__(self):
return f"<KolVideo(item_id={self.item_id}, title={self.title})>"
View File
View File
+5
View File
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
addopts = -v --cov=app --cov-report=html --cov-report=term-missing
+27
View File
@@ -0,0 +1,27 @@
# Web Framework
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
# Database
sqlalchemy>=2.0.0
asyncpg>=0.29.0
alembic>=1.12.0
# HTTP Client
httpx>=0.25.0
# Data Validation
pydantic>=2.0.0
pydantic-settings>=2.0.0
# Excel Export
openpyxl>=3.1.0
# Testing
pytest>=7.4.0
pytest-asyncio>=0.21.0
pytest-cov>=4.1.0
httpx>=0.25.0
# Development
python-dotenv>=1.0.0
View File
+58
View File
@@ -0,0 +1,58 @@
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.database import Base
from app.models import KolVideo
@pytest.fixture
def sample_video_data():
"""Sample video data for testing."""
return {
"item_id": "test_item_001",
"title": "测试视频标题",
"viral_type": "爆款",
"video_url": "https://example.com/video/001",
"star_id": "star_001",
"star_unique_id": "unique_001",
"star_nickname": "测试达人",
"natural_play_cnt": 100000,
"heated_play_cnt": 50000,
"total_play_cnt": 150000,
"total_interact": 5000,
"like_cnt": 3000,
"share_cnt": 1000,
"comment_cnt": 1000,
"new_a3_rate": 0.05,
"after_view_search_uv": 500,
"return_search_cnt": 200,
"industry_id": "ind_001",
"industry_name": "美妆",
"brand_id": "brand_001",
"estimated_video_cost": 10000.0,
}
@pytest.fixture
async def test_engine():
"""Create a test database engine using SQLite."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
echo=False,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def test_session(test_engine):
"""Create a test database session."""
async_session = async_sessionmaker(
test_engine,
class_=AsyncSession,
expire_on_commit=False,
)
async with async_session() as session:
yield session
+165
View File
@@ -0,0 +1,165 @@
import pytest
from sqlalchemy import select
from app.models import KolVideo
class TestKolVideoModel:
"""Tests for KolVideo model."""
async def test_create_video(self, test_session, sample_video_data):
"""Test creating a video record."""
video = KolVideo(**sample_video_data)
test_session.add(video)
await test_session.commit()
result = await test_session.execute(
select(KolVideo).where(KolVideo.item_id == sample_video_data["item_id"])
)
saved_video = result.scalar_one()
assert saved_video.item_id == sample_video_data["item_id"]
assert saved_video.title == sample_video_data["title"]
assert saved_video.star_id == sample_video_data["star_id"]
async def test_query_by_star_id(self, test_session, sample_video_data):
"""Test querying videos by star_id."""
video = KolVideo(**sample_video_data)
test_session.add(video)
await test_session.commit()
result = await test_session.execute(
select(KolVideo).where(KolVideo.star_id == sample_video_data["star_id"])
)
videos = result.scalars().all()
assert len(videos) == 1
assert videos[0].star_id == sample_video_data["star_id"]
async def test_query_by_star_unique_id(self, test_session, sample_video_data):
"""Test querying videos by star_unique_id."""
video = KolVideo(**sample_video_data)
test_session.add(video)
await test_session.commit()
result = await test_session.execute(
select(KolVideo).where(
KolVideo.star_unique_id == sample_video_data["star_unique_id"]
)
)
videos = result.scalars().all()
assert len(videos) == 1
assert videos[0].star_unique_id == sample_video_data["star_unique_id"]
async def test_query_by_nickname_like(self, test_session, sample_video_data):
"""Test querying videos by nickname using LIKE."""
video = KolVideo(**sample_video_data)
test_session.add(video)
await test_session.commit()
result = await test_session.execute(
select(KolVideo).where(KolVideo.star_nickname.like("%测试%"))
)
videos = result.scalars().all()
assert len(videos) == 1
assert "测试" in videos[0].star_nickname
async def test_batch_query_by_star_ids(self, test_session, sample_video_data):
"""Test batch querying videos by multiple star_ids."""
video1 = KolVideo(**sample_video_data)
video2_data = sample_video_data.copy()
video2_data["item_id"] = "test_item_002"
video2_data["star_id"] = "star_002"
video2 = KolVideo(**video2_data)
test_session.add_all([video1, video2])
await test_session.commit()
star_ids = ["star_001", "star_002"]
result = await test_session.execute(
select(KolVideo).where(KolVideo.star_id.in_(star_ids))
)
videos = result.scalars().all()
assert len(videos) == 2
async def test_video_default_values(self, test_session):
"""Test that default values are set correctly."""
video = KolVideo(
item_id="test_defaults",
star_id="star_test",
star_unique_id="unique_test",
star_nickname="测试默认值",
)
test_session.add(video)
await test_session.commit()
result = await test_session.execute(
select(KolVideo).where(KolVideo.item_id == "test_defaults")
)
saved_video = result.scalar_one()
assert saved_video.natural_play_cnt == 0
assert saved_video.heated_play_cnt == 0
assert saved_video.total_play_cnt == 0
assert saved_video.estimated_video_cost == 0
async def test_video_nullable_fields(self, test_session):
"""Test that nullable fields can be None."""
video = KolVideo(
item_id="test_nullable",
star_id="star_nullable",
star_unique_id="unique_nullable",
star_nickname="测试可空字段",
)
test_session.add(video)
await test_session.commit()
result = await test_session.execute(
select(KolVideo).where(KolVideo.item_id == "test_nullable")
)
saved_video = result.scalar_one()
assert saved_video.title is None
assert saved_video.video_url is None
assert saved_video.brand_id is None
assert saved_video.new_a3_rate is None
async def test_update_video(self, test_session, sample_video_data):
"""Test updating a video record."""
video = KolVideo(**sample_video_data)
test_session.add(video)
await test_session.commit()
result = await test_session.execute(
select(KolVideo).where(KolVideo.item_id == sample_video_data["item_id"])
)
saved_video = result.scalar_one()
saved_video.title = "更新后的标题"
await test_session.commit()
result = await test_session.execute(
select(KolVideo).where(KolVideo.item_id == sample_video_data["item_id"])
)
updated_video = result.scalar_one()
assert updated_video.title == "更新后的标题"
async def test_delete_video(self, test_session, sample_video_data):
"""Test deleting a video record."""
video = KolVideo(**sample_video_data)
test_session.add(video)
await test_session.commit()
result = await test_session.execute(
select(KolVideo).where(KolVideo.item_id == sample_video_data["item_id"])
)
saved_video = result.scalar_one()
await test_session.delete(saved_video)
await test_session.commit()
result = await test_session.execute(
select(KolVideo).where(KolVideo.item_id == sample_video_data["item_id"])
)
assert result.scalar_one_or_none() is None