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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.models.kol_video import KolVideo
|
||||
|
||||
__all__ = ["KolVideo"]
|
||||
@@ -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})>"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user