Merge branch 'chore/geo-tech-debt-cleanup' — Plan 005: tech debt cleanup sprint
This commit is contained in:
commit
9fb4dad215
|
|
@ -0,0 +1,64 @@
|
|||
# ============================================================
|
||||
# GEO Platform Production Environment Configuration
|
||||
# ============================================================
|
||||
# IMPORTANT: Replace ALL placeholder values before deploying!
|
||||
# Passwords must be at least 16 characters with mixed case, numbers, and symbols.
|
||||
|
||||
# ============================================================
|
||||
# Database (MUST use strong password in production)
|
||||
# ============================================================
|
||||
POSTGRES_PASSWORD=CHANGE_ME_strong_pg_password_32chars!
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:CHANGE_ME_strong_pg_password_32chars!@db:5432/geo_platform
|
||||
|
||||
# ============================================================
|
||||
# Redis (MUST use strong password in production)
|
||||
# ============================================================
|
||||
REDIS_PASSWORD=CHANGE_ME_strong_redis_password_32chars!
|
||||
REDIS_URL=redis://:CHANGE_ME_strong_redis_password_32chars!@redis:6379/0
|
||||
|
||||
# ============================================================
|
||||
# JWT (MUST be unique and at least 32 characters)
|
||||
# ============================================================
|
||||
JWT_SECRET=CHANGE_ME_unique_jwt_secret_at_least_32_chars
|
||||
JWT_EXPIRE_HOURS=24
|
||||
SECRET_KEY=CHANGE_ME_unique_nextauth_secret_at_least_32_chars
|
||||
|
||||
# ============================================================
|
||||
# API Configuration
|
||||
# ============================================================
|
||||
NEXT_PUBLIC_API_URL=https://your-domain.com
|
||||
CORS_ORIGINS=https://your-domain.com
|
||||
|
||||
# ============================================================
|
||||
# LLM Provider (fill in at least one)
|
||||
# ============================================================
|
||||
ENABLE_LLM=true
|
||||
DEFAULT_LLM_PROVIDER=deepseek
|
||||
DEFAULT_LLM_MODEL=deepseek-chat
|
||||
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
DEEPSEEK_MAX_CONTEXT=64000
|
||||
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=
|
||||
OPENAI_BASE_URL=
|
||||
|
||||
MOONSHOT_API_KEY=
|
||||
BAIDU_QIANFAN_API_KEY=
|
||||
BAIDU_QIANFAN_SECRET_KEY=
|
||||
DOUBAO_API_KEY=
|
||||
DOUBAO_ENDPOINT_ID=
|
||||
|
||||
# ============================================================
|
||||
# Rate Limiting
|
||||
# ============================================================
|
||||
API_RATE_LIMIT_RPM=10
|
||||
|
||||
# ============================================================
|
||||
# Payment / Distribution / Email (set to real mode in production)
|
||||
# ============================================================
|
||||
PAYMENT_MODE=mock
|
||||
DISTRIBUTION_MODE=mock
|
||||
EMAIL_MODE=mock
|
||||
|
|
@ -55,12 +55,12 @@ ENABLE_LLM=true
|
|||
# ============================================================
|
||||
# 数据库配置
|
||||
# ============================================================
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres123@localhost:5432/geo_platform
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:geo_pg_dev_2026@localhost:5433/geo_platform
|
||||
|
||||
# ============================================================
|
||||
# Redis 配置
|
||||
# ============================================================
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_URL=redis://:geo_redis_dev_2026@localhost:6380/0
|
||||
|
||||
# 是否启用Redis缓存
|
||||
ENABLE_REDIS=true
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ DATABASE_URL=sqlite+aiosqlite:///./test.db
|
|||
REDIS_URL=redis://localhost:6379
|
||||
ENVIRONMENT=testing
|
||||
LOG_LEVEL=info
|
||||
JWT_SECRET=test-jwt-secret-for-testing-at-least-32-characters-long
|
||||
SECRET_KEY=test-secret-key-for-testing-only
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ EXPOSE 8000
|
|||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/api/health || exit 1
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", \
|
||||
"--bind", "0.0.0.0:8000", "--timeout", "120", "--access-logfile", "-"]
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ path_separator = os
|
|||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = postgresql+asyncpg://postgres:postgres123@db:5432/geo_platform
|
||||
sqlalchemy.url = postgresql+asyncpg://postgres:geo_pg_dev_2026@127.0.0.1:5433/geo_platform
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
"""Add timezone to all datetime columns
|
||||
|
||||
Revision ID: h3i4j5k6mn78
|
||||
Revises: f063b3da67b6
|
||||
Create Date: 2026-06-01
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'h3i4j5k6mn78'
|
||||
down_revision: Union[str, Sequence[str], None] = 'f063b3da67b6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column('users', 'lastLoginAt', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='lastLoginAt AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('users', 'createdAt', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='createdAt AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('users', 'updatedAt', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updatedAt AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('users', 'lockedUntil', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='lockedUntil AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('brands', 'last_queried_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='last_queried_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('brands', 'next_query_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='next_query_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('brands', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('brands', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('queries', 'last_queried_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='last_queried_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('queries', 'next_query_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='next_query_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('queries', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('queries', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('citation_records', 'queried_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='queried_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('attribution_records', 'published_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='published_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('attribution_records', 'window_end_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='window_end_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('attribution_records', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('attribution_records', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('contents', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('contents', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('content_versions', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('content_reviews', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('geo_plans', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('geo_plans', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('geo_plan_actions', 'completed_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='completed_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('geo_plan_actions', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('geo_plan_actions', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('suggestions', 'generated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='generated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('suggestions', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('competitors', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('competitor_insights', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('competitor_insights', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('distribution_schedules', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('distribution_schedules', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('brand_knowledge', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('brand_knowledge', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('keywords', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('agent_registry', 'last_heartbeat', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='last_heartbeat AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('agent_registry', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('agent_registry', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('agent_configs', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('agent_tasks', 'scheduled_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='scheduled_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('agent_tasks', 'started_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='started_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('agent_tasks', 'completed_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='completed_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('agent_tasks', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('agent_task_logs', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('detection_tasks', 'last_run_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='last_run_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('detection_tasks', 'next_run_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='next_run_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('detection_tasks', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('detection_tasks', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('monitoring_records', 'last_checked_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='last_checked_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('monitoring_records', 'next_check_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='next_check_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('monitoring_records', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('monitoring_records', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('content_baselines', 'recorded_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='recorded_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('trend_insights', 'period_start', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='period_start AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('trend_insights', 'period_end', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='period_end AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('trend_insights', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('trend_insights', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('query_tasks', 'scheduled_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='scheduled_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('query_tasks', 'started_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='started_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('query_tasks', 'completed_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='completed_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('usage_records', 'timestamp', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='timestamp AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('usage_records', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('api_keys', 'last_verified_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='last_verified_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('api_keys', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('api_keys', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('knowledge_bases', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('knowledge_bases', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('knowledge_documents', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('knowledge_documents', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('knowledge_chunks', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('knowledge_search_logs', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('knowledge_entities', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('knowledge_entities', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('knowledge_relations', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('organizations', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('organizations', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('org_members', 'joined_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='joined_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('lifecycle_projects', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('lifecycle_projects', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('project_stages', 'started_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='started_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('project_stages', 'completed_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='completed_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('alerts', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('alert_settings', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('alert_settings', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('platform_rules', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('platform_rule_versions', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('schema_suggestions', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('schema_suggestions', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('subscriptions', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('payment_orders', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('payment_orders', 'updated_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='updated_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('payment_orders', 'paid_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='paid_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('diagnosis_records', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('diagnosis_records', 'completed_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='completed_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
op.alter_column('publish_records', 'published_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='published_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('publish_records', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('content_metrics', 'recorded_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='recorded_at AT TIME ZONE \'UTC\'')
|
||||
op.alter_column('optimization_insights', 'created_at', type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), postgresql_using='created_at AT TIME ZONE \'UTC\'')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column('users', 'lastLoginAt', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('users', 'createdAt', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('users', 'updatedAt', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('users', 'lockedUntil', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('brands', 'last_queried_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('brands', 'next_query_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('brands', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('brands', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('queries', 'last_queried_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('queries', 'next_query_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('queries', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('queries', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('citation_records', 'queried_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('attribution_records', 'published_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('attribution_records', 'window_end_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('attribution_records', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('attribution_records', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('contents', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('contents', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('content_versions', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('content_reviews', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('geo_plans', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('geo_plans', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('geo_plan_actions', 'completed_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('geo_plan_actions', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('geo_plan_actions', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('suggestions', 'generated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('suggestions', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('competitors', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('competitor_insights', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('competitor_insights', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('distribution_schedules', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('distribution_schedules', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('brand_knowledge', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('brand_knowledge', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('keywords', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('agent_registry', 'last_heartbeat', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('agent_registry', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('agent_registry', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('agent_configs', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('agent_tasks', 'scheduled_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('agent_tasks', 'started_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('agent_tasks', 'completed_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('agent_tasks', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('agent_task_logs', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('detection_tasks', 'last_run_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('detection_tasks', 'next_run_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('detection_tasks', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('detection_tasks', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('monitoring_records', 'last_checked_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('monitoring_records', 'next_check_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('monitoring_records', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('monitoring_records', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('content_baselines', 'recorded_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('trend_insights', 'period_start', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('trend_insights', 'period_end', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('trend_insights', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('trend_insights', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('query_tasks', 'scheduled_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('query_tasks', 'started_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('query_tasks', 'completed_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('usage_records', 'timestamp', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('usage_records', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('api_keys', 'last_verified_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('api_keys', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('api_keys', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('knowledge_bases', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('knowledge_bases', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('knowledge_documents', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('knowledge_documents', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('knowledge_chunks', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('knowledge_search_logs', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('knowledge_entities', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('knowledge_entities', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('knowledge_relations', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('organizations', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('organizations', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('org_members', 'joined_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('lifecycle_projects', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('lifecycle_projects', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('project_stages', 'started_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('project_stages', 'completed_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('alerts', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('alert_settings', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('alert_settings', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('platform_rules', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('platform_rule_versions', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('schema_suggestions', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('schema_suggestions', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('subscriptions', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('payment_orders', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('payment_orders', 'updated_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('payment_orders', 'paid_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('diagnosis_records', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('diagnosis_records', 'completed_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
||||
op.alter_column('publish_records', 'published_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('publish_records', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('content_metrics', 'recorded_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
op.alter_column('optimization_insights', 'created_at', type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True))
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -24,12 +24,14 @@ class AgentRegistry(Base):
|
|||
endpoint: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), server_default="offline", nullable=False)
|
||||
capabilities: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||
last_heartbeat: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
last_heartbeat: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -70,6 +72,7 @@ class AgentConfig(Base):
|
|||
config_value: Mapped[dict] = mapped_column(JSONType, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -128,10 +131,11 @@ class AgentTask(Base):
|
|||
ForeignKey("lifecycle_projects.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
scheduled_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
@ -185,6 +189,7 @@ class AgentTaskLog(Base):
|
|||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
extra_metadata: Mapped[dict | None] = mapped_column("metadata", JSONType, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Boolean, Float, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Boolean, Float, ForeignKey, Index, func, Text, DateTime
|
||||
from sqlalchemy import Uuid, JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
|
@ -58,6 +58,7 @@ class Alert(Base):
|
|||
Boolean, default=False, nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Boolean, Float, ForeignKey, Index, func
|
||||
from sqlalchemy import String, Boolean, Float, ForeignKey, Index, func, DateTime
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -39,10 +39,12 @@ class AlertSetting(Base):
|
|||
comment="阈值(如评分下降超过5分触发)",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ class PublishRecord(Base):
|
|||
platform: Mapped[str] = mapped_column(String(50)) # wechat/zhihu/xiaohongshu...
|
||||
published_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft") # draft/published/archived
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
metrics: Mapped[list["ContentMetrics"]] = relationship(back_populates="publish_record")
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ class ContentMetrics(Base):
|
|||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
publish_record_id: Mapped[str] = mapped_column(String(36), ForeignKey("publish_records.id"))
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 互动指标
|
||||
views: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
|
@ -60,4 +60,6 @@ class OptimizationInsight(Base):
|
|||
recommendation: Mapped[str] = mapped_column(Text)
|
||||
severity: Mapped[str] = mapped_column(String(20), default="info") # info/warning/success
|
||||
applied: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,12 +27,14 @@ class APIKey(Base):
|
|||
key_source: Mapped[str] = mapped_column(String(10), default="user")
|
||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||
priority: Mapped[int] = mapped_column(default=0)
|
||||
last_verified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Float, Integer, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Float, Integer, DateTime, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -37,18 +37,20 @@ class AttributionRecord(Base):
|
|||
attribution_window_days: Mapped[int] = mapped_column(
|
||||
Integer, server_default="28", nullable=False,
|
||||
)
|
||||
published_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
window_end_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
window_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), server_default="tracking", nullable=False,
|
||||
)
|
||||
attributed_dimensions: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||
roi_percentage: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -40,13 +40,15 @@ class Brand(Base):
|
|||
platforms: Mapped[list] = mapped_column(JSONType, default=list, nullable=False)
|
||||
frequency: Mapped[str] = mapped_column(String(20), default="weekly", nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
|
||||
last_queried_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
next_query_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_queried_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
next_query_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Boolean, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -32,10 +32,12 @@ class BrandKnowledge(Base):
|
|||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -81,6 +83,7 @@ class Keyword(Base):
|
|||
competition_level: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), server_default="active", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Boolean, Integer, Float, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Boolean, Integer, Float, DateTime, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import Uuid, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -65,6 +65,7 @@ class CitationRecord(Base):
|
|||
comment="AI回答原始文本(去掉data_source标记后的纯文本)",
|
||||
)
|
||||
queried_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class Competitor(Base):
|
|||
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
aliases: Mapped[list] = mapped_column(JSONType, default=list, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Float, Integer, ForeignKey, Index, func
|
||||
from sqlalchemy import String, Float, Integer, DateTime, ForeignKey, Index, func
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
|
@ -49,10 +49,12 @@ class CompetitorInsight(Base):
|
|||
confidence: Mapped[str] = mapped_column(String(20), default="medium", nullable=False)
|
||||
period_days: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -40,10 +40,12 @@ class Content(Base):
|
|||
)
|
||||
current_version: Mapped[int] = mapped_column(Integer, server_default="1", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -98,6 +100,7 @@ class ContentVersion(Base):
|
|||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
@ -137,6 +140,7 @@ class ContentReview(Base):
|
|||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
comments: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -39,15 +39,17 @@ class DetectionTask(Base):
|
|||
queries: Mapped[list] = mapped_column(JSONType, default=list, nullable=False)
|
||||
competitor_names: Mapped[list | None] = mapped_column(JSONType, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
next_run_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, default=lambda: datetime.now(timezone.utc)
|
||||
DateTime(timezone=True), nullable=True, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Uuid, JSON, Float, Text, ForeignKey, Index, func
|
||||
from sqlalchemy import String, Uuid, JSON, Float, Text, DateTime, ForeignKey, Index, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
|
@ -28,9 +28,9 @@ class DiagnosisRecord(Base):
|
|||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
collection_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
server_default=func.now(), nullable=False
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_diagnosis_records_brand_id", "brand_id"),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Integer, DateTime, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -38,10 +38,12 @@ class DistributionSchedule(Base):
|
|||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Text, ForeignKey, Index, func
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Index, func
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -41,10 +41,12 @@ class GeoPlan(Base):
|
|||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -92,12 +94,14 @@ class GeoPlanAction(Base):
|
|||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, server_default="0", nullable=False,
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Integer, ForeignKey, Index, func, Text, DateTime
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -42,10 +42,12 @@ class KnowledgeBase(Base):
|
|||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -93,10 +95,12 @@ class KnowledgeDocument(Base):
|
|||
# mapped_column("metadata") to avoid SQLAlchemy reserved keyword conflict
|
||||
extra_metadata: Mapped[dict | None] = mapped_column("metadata", JSONType, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -153,6 +157,7 @@ class KnowledgeChunk(Base):
|
|||
# mapped_column("metadata") to avoid SQLAlchemy reserved keyword conflict
|
||||
extra_metadata: Mapped[dict | None] = mapped_column("metadata", JSONType, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
@ -192,6 +197,7 @@ class KnowledgeSearchLog(Base):
|
|||
results_count: Mapped[int] = mapped_column(Integer, server_default="0", nullable=False)
|
||||
latency_ms: Mapped[int] = mapped_column(Integer, server_default="0", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -83,10 +83,12 @@ class KnowledgeEntity(Base):
|
|||
|
||||
# 元数据
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -151,6 +153,7 @@ class KnowledgeRelation(Base):
|
|||
|
||||
# 元数据
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Integer, ForeignKey, Index, func, Text, DateTime
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -31,10 +31,12 @@ class LifecycleProject(Base):
|
|||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -73,8 +75,8 @@ class ProjectStage(Base):
|
|||
)
|
||||
stage_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), server_default="pending", nullable=False)
|
||||
started_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
metrics: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Float, ForeignKey, Index, func
|
||||
from sqlalchemy import String, Integer, Float, DateTime, ForeignKey, Index, func
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -39,16 +39,18 @@ class MonitoringRecord(Base):
|
|||
check_interval_hours: Mapped[int] = mapped_column(
|
||||
Integer, server_default="24", nullable=False,
|
||||
)
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
next_check_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
next_check_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), server_default="active", nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -87,6 +89,7 @@ class ContentBaseline(Base):
|
|||
rank_position: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
snapshot_data: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Float, ForeignKey, Index, func
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base, JSONType
|
||||
|
||||
|
||||
class MonitoringRecord(Base):
|
||||
__tablename__ = "monitoring_records"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
brand_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("brands.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
content_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("contents.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
query_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("queries.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
task_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), server_default="pending", nullable=False,
|
||||
)
|
||||
baseline_data: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||
current_data: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||
change_report: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||
interval_hours: Mapped[int] = mapped_column(
|
||||
Integer, server_default="24", nullable=False,
|
||||
)
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
next_check_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
brand: Mapped["Brand"] = relationship("Brand")
|
||||
user: Mapped["User"] = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_monitoring_records_user_id", "user_id"),
|
||||
Index("idx_monitoring_records_brand_id", "brand_id"),
|
||||
Index("idx_monitoring_records_status", "status"),
|
||||
Index("idx_monitoring_records_next_check_at", "next_check_at"),
|
||||
)
|
||||
|
||||
|
||||
class ContentBaseline(Base):
|
||||
__tablename__ = "content_baselines"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
brand_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("brands.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
content_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid(as_uuid=True),
|
||||
ForeignKey("contents.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
citation_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
positive_ratio: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
avg_rank: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
platform_data: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
brand: Mapped["Brand"] = relationship("Brand")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_content_baselines_brand_id", "brand_id"),
|
||||
Index("idx_content_baselines_content_id", "content_id"),
|
||||
)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Integer, ForeignKey, Index, func, Text, DateTime
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
|
@ -24,10 +24,12 @@ class Organization(Base):
|
|||
plan: Mapped[str] = mapped_column(String(20), server_default="free", nullable=False)
|
||||
max_members: Mapped[int] = mapped_column(Integer, server_default="5", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
@ -84,6 +86,7 @@ class OrgMember(Base):
|
|||
)
|
||||
role: Mapped[str] = mapped_column(String(20), server_default="viewer", nullable=False)
|
||||
joined_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,12 +29,14 @@ class PaymentOrder(Base):
|
|||
pay_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
callback_data: Mapped[dict | None] = mapped_column(JSONType, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Boolean, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Boolean, ForeignKey, Index, func, Text, DateTime
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -24,6 +24,7 @@ class PlatformRule(Base):
|
|||
severity: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, server_default="true", nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Index, func
|
||||
from sqlalchemy import String, Integer, Index, func, DateTime
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ class PlatformRuleVersion(Base):
|
|||
rule_data: Mapped[dict] = mapped_column(JSONType, nullable=False)
|
||||
change_summary: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_rule_versions_rule_id", "rule_id"),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, ForeignKey, Index, func
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Index, func
|
||||
from sqlalchemy import Uuid, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -27,13 +27,15 @@ class Query(Base):
|
|||
platforms: Mapped[list] = mapped_column(JSON, nullable=False, default=lambda: ["wenxin", "kimi"])
|
||||
frequency: Mapped[str] = mapped_column(String(20), default="weekly")
|
||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||
last_queried_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
next_query_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
last_queried_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
next_query_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -25,11 +25,12 @@ class QueryTask(Base):
|
|||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
scheduled_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
query: Mapped["Query"] = relationship("Query", back_populates="query_tasks")
|
||||
|
||||
|
|
|
|||
|
|
@ -52,10 +52,12 @@ class SchemaSuggestion(Base):
|
|||
JSONType, nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime, date
|
||||
|
||||
from sqlalchemy import String, ForeignKey, Numeric, func
|
||||
from sqlalchemy import String, ForeignKey, Numeric, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ class Subscription(Base):
|
|||
payment_method: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
payment_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,10 +49,12 @@ class Suggestion(Base):
|
|||
comment="状态: pending/in_progress/completed/dismissed",
|
||||
)
|
||||
generated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Float, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import String, Integer, Float, DateTime, ForeignKey, Index, func, Text
|
||||
from sqlalchemy import Uuid, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
|
|
@ -24,8 +24,8 @@ class TrendInsight(Base):
|
|||
trend_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
keyword: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
platform: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
period_start: Mapped[datetime] = mapped_column(nullable=False)
|
||||
period_end: Mapped[datetime] = mapped_column(nullable=False)
|
||||
period_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
period_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
data_points: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
change_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
absolute_change: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
|
@ -37,10 +37,12 @@ class TrendInsight(Base):
|
|||
String(20), nullable=False, server_default="info",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
|
|
|
|||
|
|
@ -51,11 +51,12 @@ class UsageRecord(Base):
|
|||
default=dict,
|
||||
)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
DateTime(timezone=True),
|
||||
default=func.now(),
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ class User(Base):
|
|||
isActive: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
emailVerified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
phoneVerified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
lastLoginAt: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
createdAt: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updatedAt: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
|
||||
lastLoginAt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
createdAt: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updatedAt: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), onupdate=func.now(), nullable=False)
|
||||
mfaSecret: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
mfaEnabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
loginAttempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
lockedUntil: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
lockedUntil: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
organization_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("organizations.id", ondelete="SET NULL"), nullable=True)
|
||||
role: Mapped[str] = mapped_column(String(20), server_default="owner", nullable=False)
|
||||
plan: Mapped[str] = mapped_column(String(20), server_default="free", nullable=False)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Installing pgvector extension for PostgreSQL ==="
|
||||
|
||||
echo "[1/5] Installing build dependencies..."
|
||||
apk add --no-cache build-base git clang llvm-dev postgresql-dev
|
||||
|
||||
echo "[2/5] Cloning pgvector v0.5.1..."
|
||||
cd /tmp
|
||||
rm -rf pgvector
|
||||
git clone --branch v0.5.1 --depth 1 https://github.com/pgvector/pgvector.git
|
||||
|
||||
echo "[3/5] Compiling and installing pgvector..."
|
||||
cd /tmp/pgvector
|
||||
make
|
||||
make install
|
||||
|
||||
echo "[4/5] Cleaning up build dependencies..."
|
||||
cd /
|
||||
rm -rf /tmp/pgvector
|
||||
apk del build-base git clang llvm-dev postgresql-dev
|
||||
|
||||
echo "[5/5] Creating vector extension in database..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
EOSQL
|
||||
|
||||
echo "=== pgvector installation complete ==="
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Initialize database schema: create all tables and stamp alembic version."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base, engine
|
||||
from app.models import * # noqa: F401, F403
|
||||
|
||||
|
||||
async def create_tables():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
print("All tables created successfully.")
|
||||
|
||||
|
||||
def stamp_alembic():
|
||||
result = subprocess.run(
|
||||
["alembic", "stamp", "head"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"alembic stamp failed:\n{result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("Alembic version stamped to head.")
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"Using DATABASE_URL: {settings.DATABASE_URL.split('@')[-1]}")
|
||||
await create_tables()
|
||||
stamp_alembic()
|
||||
await engine.dispose()
|
||||
print("Schema initialization complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
|
|
@ -7,12 +5,13 @@ services:
|
|||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres123
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-geo_pg_dev_2026}
|
||||
POSTGRES_DB: geo_platform
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./backend/init-db.sh:/docker-entrypoint-initdb.d/01-install-pgvector.sh
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d geo_platform"]
|
||||
interval: 5s
|
||||
|
|
@ -30,12 +29,13 @@ services:
|
|||
image: redis:7-alpine
|
||||
container_name: geo_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD:-geo_redis_dev_2026} --appendonly yes
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "6380:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-geo_redis_dev_2026}", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
|
@ -55,6 +55,9 @@ services:
|
|||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-geo_pg_dev_2026}@db:5432/geo_platform
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD:-geo_redis_dev_2026}@redis:6379/0
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
date: "2026-06-01"
|
||||
topic: geo-launch-readiness
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
GEO 平台变现闭环代码已全部落地(Plan 003),但从未端到端运行过。本需求定义一个"部署+验证"冲刺:先将系统部署到类生产环境,再在部署环境中验证完整变现闭环(注册→诊断→健康分→付费墙→支付→解锁),直到新用户可完成全链路操作。
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Plan 003 完成了 9 个实施单元的代码编写——诊断数据采集、免费健康分页面、Onboarding 重设计、支付集成、AI 内容生成、效果归因、邮件集成、契约测试、E2E 烟雾测试。但这些代码从未作为完整系统运行过。后端服务能否启动、前端能否构建、数据库迁移能否执行、前后端联调是否通畅——全部未知。在代码从未跑过的状态下,任何单点修复都是盲目的;只有让系统先跑起来,才能发现真正的阻塞问题。
|
||||
|
||||
## Key Decisions
|
||||
|
||||
**部署优先于功能验证。** 本地跑通不等于可上线。部署问题(数据库迁移、环境变量、CORS、静态资源)往往是上线前最大的意外。先解决部署,再在部署环境中验证流程,一步到位。
|
||||
|
||||
**支付用 mock 模式验证。** 真实微信/支付宝 SDK 接入涉及商户审核、证书配置,是独立的长期工作。本次验证用 mock 支付确认付费墙逻辑正确即可,真实支付接入作为后续计划。
|
||||
|
||||
**阻塞驱动,不追求完美。** 遇到问题修到能继续往下走即可,不做全面重构或优化。目标是跑通闭环,不是打磨每个细节。
|
||||
|
||||
## Requirements
|
||||
|
||||
**部署就绪**
|
||||
|
||||
R1. 后端服务可在 Docker Compose 环境中启动,所有 API 端点可访问
|
||||
|
||||
R2. 前端构建产物可被部署并提供页面访问
|
||||
|
||||
R3. 数据库迁移可从空库执行到最新版本,所有表和索引正确创建
|
||||
|
||||
R4. 环境变量配置完整,第三方服务密钥缺失时优雅降级为 mock 模式
|
||||
|
||||
**核心流程可跑通**
|
||||
|
||||
R5. 新用户可完成注册并登录,获得有效 JWT
|
||||
|
||||
R6. 登录用户可创建品牌,触发诊断,获得非零健康分
|
||||
|
||||
R7. 免费用户访问付费功能时触发付费墙,显示升级提示
|
||||
|
||||
R8. 用户可发起支付(mock 模式),支付完成后配额刷新、付费功能解锁
|
||||
|
||||
R9. 公开健康分页面无需注册即可访问,输入品牌名可生成报告
|
||||
|
||||
**端到端验证**
|
||||
|
||||
R10. 完整变现闭环可在部署环境中走通:注册→诊断→健康分→付费墙→支付→解锁
|
||||
|
||||
**代码质量(2026-06-01 复盘新增)**
|
||||
|
||||
R11. Dockerfile 健康检查端点与实际 API 端点一致(当前 `/api/health` 不存在,应为 `/health`)
|
||||
|
||||
R12. 测试环境配置完整,`.env.test` 包含必需的 JWT_SECRET
|
||||
|
||||
R13. 前端页面统一使用 API 客户端,认证 token 正确传递(reports、lifecycle/new 页面绕过了统一客户端)
|
||||
|
||||
## Key Flows
|
||||
|
||||
- F1. 完整变现闭环验证
|
||||
- **Trigger:** 新用户访问平台
|
||||
- **Steps:** 注册账号 → 登录 → 创建品牌 → 触发诊断 → 查看非零健康分 → 尝试付费功能 → 触发付费墙 → 发起 mock 支付 → 支付完成 → 付费功能解锁
|
||||
- **Outcome:** 用户完成从获客到付费的完整链路
|
||||
- **Covers:** R5, R6, R7, R8, R10
|
||||
|
||||
- F2. 公开健康分获客路径
|
||||
- **Trigger:** 未注册用户访问公开健康分页面
|
||||
- **Steps:** 输入品牌名 → 系统生成 GEO 健康分报告 → 显示关键指标和问题 → 引导注册查看完整报告
|
||||
- **Outcome:** 用户被引导进入注册流程
|
||||
- **Covers:** R9, R10
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- Docker Compose 部署配置
|
||||
- 数据库迁移执行
|
||||
- 核心流程端到端验证
|
||||
- 阻塞问题修复
|
||||
|
||||
**Deferred for later:**
|
||||
- 真实微信/支付宝 SDK 接入
|
||||
- CI/CD 流水线
|
||||
- 性能优化和压力测试
|
||||
- 生产环境域名和 HTTPS
|
||||
- 完整测试覆盖
|
||||
|
||||
**Outside this sprint:**
|
||||
- UI 打磨和视觉优化
|
||||
- 新功能开发
|
||||
- 代码重构
|
||||
|
||||
## Dependencies / Assumptions
|
||||
|
||||
- PostgreSQL 15 + pgvector 扩展可用(Docker 容器已配置)
|
||||
- Redis 7 可用(会话和缓存)
|
||||
- 第三方 API 密钥(DeepSeek、OpenAI 等)可能未配置,系统需支持 mock 降级
|
||||
- LLM API 调用成本可控(诊断和内容生成会消耗 token)
|
||||
|
||||
## Outstanding Questions
|
||||
|
||||
**Resolved:**
|
||||
- Docker Compose 配置已完整,包含 db (PostgreSQL 15) + redis (Redis 7) + backend (FastAPI) + frontend (Next.js) 四个服务,开发和生产两套配置均就绪
|
||||
- 数据库迁移策略:使用 `create_all` + `stamp head`(KTD2)
|
||||
- 前端部署方式:独立容器,开发模式用 `npm run dev`,生产模式用 standalone `node server.js`
|
||||
- pgvector 扩展安装:通过 init-db.sh 从源码编译安装(KTD3)
|
||||
- 验证方式:本地裸跑优先,Docker 部署后续(KTD6)
|
||||
|
||||
**Open:**
|
||||
- 前端 reports 和 lifecycle/new 页面绕过统一 API 客户端,是否在本次修复?(当前标记为 deferred)
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
date: "2026-06-01"
|
||||
topic: geo-tech-debt-cleanup
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
GEO 平台技术债清理冲刺,分三批交付:第一批修复 29 个模型文件中 73 个缺失 `DateTime(timezone=True)` 的 datetime 列(按 API 调用路径优先级排序);第二批统一前端 2 个页面组件的 API 客户端调用;第三批完成前端端到端验证和部署安全加固。每批有明确交付物和验证门。
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Plan 004 的端到端验证暴露了一个系统性问题:asyncpg 驱动严格检查 timezone-aware datetime 与 `TIMESTAMP WITHOUT TIME ZONE` 列的兼容性,任何写入 `datetime.now(UTC)` 到未标记 timezone 的列都会直接报运行时错误。当前仅修复了变现闭环涉及的 4 个核心表(diagnosis_records、payment_orders、subscriptions、users),其余 29 个模型文件的 73 个列仍是确定的运行时炸弹。与此同时,前端 2 个页面组件绕过统一 API 客户端直接调用 `fetch`,认证 token 不被附加,用户访问会 401。Docker 部署从未验证成功,Redis/PostgreSQL 安全配置缺失。这些技术债不清理,系统无法真正上线。
|
||||
|
||||
## Key Decisions
|
||||
|
||||
**时区修复按 API 调用路径优先级排序,而非一次性全量迁移。** 全量迁移风险高(73 列同时 ALTER TABLE),且部分 API 路径尚未被触发。按实际调用路径修复,每批修完验证对应 API 可用,风险可控。
|
||||
|
||||
**前端 API 客户端统一排在时区修复之后。** 401 错误是确定的问题但影响范围有限(2 个页面),时区 bug 影响所有涉及 datetime 写入的 API。先修高影响再修低影响。
|
||||
|
||||
**部署安全加固排在最后。** 安全配置(Redis 密码、PostgreSQL 弱密码、.env.production)在本地开发环境不影响功能,但上线前必须完成。作为第三批交付。
|
||||
|
||||
**前端 E2E 验证与部署安全加固合并为第三批。** E2E 验证需要浏览器环境,与部署配置调整可以并行进行。
|
||||
|
||||
## Requirements
|
||||
|
||||
**时区修复(Batch 1)**
|
||||
|
||||
R1. 所有 29 个模型文件的 73 个 datetime 列添加 `DateTime(timezone=True)`,PostgreSQL 对应列类型改为 TIMESTAMPTZ
|
||||
|
||||
R2. 时区修复按 API 调用路径分三批执行:核心变现路径(brand、query、citation_record、attribution_record、content、geo_plan、suggestion)→ Agent 框架路径(agent、detection_task、monitoring、monitoring_record、competitor_insight、trend_insight)→ 辅助路径(knowledge、knowledge_graph、organization、lifecycle、distribution、alert、alert_setting、platform_rule、platform_rule_version、usage_record、api_key、query_task、brand_knowledge、competitor)
|
||||
|
||||
R3. 每批修复后验证对应 API 路径可正常写入和读取 datetime 数据
|
||||
|
||||
R4. 生成对应的 Alembic 迁移脚本,迁移可从当前数据库状态执行
|
||||
|
||||
**前端 API 客户端统一(Batch 2)**
|
||||
|
||||
R5. `reports/page.tsx` 的 CSV 导出改用统一 API 客户端,认证 token 正确传递
|
||||
|
||||
R6. `lifecycle/new/page.tsx` 的项目创建改用统一 API 客户端,认证 token 正确传递
|
||||
|
||||
R7. 统一 API 客户端支持非 JSON 响应(如 blob/PDF 导出),避免 `reports.ts` 等模块被迫绕过
|
||||
|
||||
**前端端到端验证(Batch 3)**
|
||||
|
||||
R8. 浏览器中可完成完整变现闭环:注册→登录→创建品牌→诊断→查看健康分→付费墙→支付→解锁
|
||||
|
||||
R9. 公开健康分页面无需登录即可访问并生成报告
|
||||
|
||||
R10. Onboarding 流程在浏览器中可正常走通
|
||||
|
||||
**部署安全加固(Batch 3)**
|
||||
|
||||
R11. Redis 配置密码保护,非本地环境禁止无密码连接
|
||||
|
||||
R12. PostgreSQL 默认弱密码更换为强密码
|
||||
|
||||
R13. 创建 `.env.production` 模板,包含所有生产环境必需配置项
|
||||
|
||||
R14. Docker Compose 部署验证通过,4 个服务均可正常启动和通信
|
||||
|
||||
## Key Flows
|
||||
|
||||
- F1. 时区修复验证流程
|
||||
- **Trigger:** 某批模型文件完成 DateTime(timezone=True) 修改
|
||||
- **Steps:** 修改模型 → 生成 Alembic 迁移 → 执行迁移 → 调用对应 API 写入数据 → 验证写入成功 → 读取数据验证时区正确
|
||||
- **Outcome:** 该批 API 路径的 datetime 读写不再触发 asyncpg 时区错误
|
||||
- **Covers:** R1, R2, R3, R4
|
||||
|
||||
- F2. 前端变现闭环验证流程
|
||||
- **Trigger:** 浏览器打开 GEO 平台
|
||||
- **Steps:** 注册→登录→创建品牌→触发诊断→查看健康分→尝试付费功能→触发付费墙→Mock支付→功能解锁→验证 reports 和 lifecycle 页面正常
|
||||
- **Outcome:** 用户在浏览器中完成从获客到付费的完整链路,所有页面认证正常
|
||||
- **Covers:** R5, R6, R8, R9, R10
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- 29 个模型文件的 DateTime(timezone=True) 修复
|
||||
- 对应 Alembic 迁移生成和执行
|
||||
- 前端 2 个页面组件 API 客户端统一
|
||||
- 统一 API 客户端非 JSON 响应支持
|
||||
- 浏览器端到端验证
|
||||
- Docker Compose 部署验证
|
||||
- Redis/PostgreSQL 安全配置
|
||||
- .env.production 模板
|
||||
|
||||
**Deferred for later:**
|
||||
- 真实微信/支付宝 SDK 接入
|
||||
- CI/CD 流水线
|
||||
- 性能优化和压力测试
|
||||
- 生产环境域名和 HTTPS 配置
|
||||
- 完整测试覆盖
|
||||
- pgvector 镜像优化(改用 pgvector/pgvector:pg15)
|
||||
- JWT_SECRET 强密钥生成
|
||||
|
||||
**Outside this sprint:**
|
||||
- UI 打磨和视觉优化
|
||||
- 新功能开发
|
||||
- 代码重构(除时区修复外)
|
||||
|
||||
## Dependencies / Assumptions
|
||||
|
||||
- PostgreSQL 15 + pgvector 扩展可用(Plan 004 已验证)
|
||||
- Redis 7 可用(Plan 004 已验证)
|
||||
- asyncpg 驱动严格时区检查行为不变(这是正确行为,不是 bug)
|
||||
- 已修复的 4 个核心表(diagnosis_records、payment_orders、subscriptions、users)不需要再次修改
|
||||
- `monitoring.py` 和 `monitoring_record.py` 可能存在重复模型定义,需确认哪个是实际使用的
|
||||
- Docker Hub 网络问题可能影响 Docker 部署验证(Plan 004 中曾因此失败)
|
||||
|
||||
## Outstanding Questions
|
||||
|
||||
**Resolve Before Planning:**
|
||||
- `monitoring.py` 和 `monitoring_record.py` 都定义了 MonitoringRecord 和 ContentBaseline,需确认哪个是实际使用的,避免重复修复
|
||||
|
||||
**Deferred to Planning:**
|
||||
- Alembic 迁移策略:是生成一个大的迁移脚本还是按批次生成多个
|
||||
- 统一 API 客户端非 JSON 响应的具体实现方式(扩展 fetchWithAuth 还是新增 fetchWithAuthBlob)
|
||||
- Docker 部署验证是否需要解决 Docker Hub 网络问题(镜像源配置等)
|
||||
|
||||
## Sources / Research
|
||||
|
||||
- Plan 004 端到端验证记录:时区 bug 在 diagnosis_records 和 payment_orders 上的具体表现
|
||||
- `backend/app/models/` — 29 个待修复模型文件,73 个 datetime 列
|
||||
- `frontend/lib/api/client.ts` — 统一 API 客户端,导出 fetchWithAuth 和 API_BASE
|
||||
- `frontend/app/(dashboard)/dashboard/reports/page.tsx` — 绕过统一客户端,手动 fetch + Authorization header
|
||||
- `frontend/app/(dashboard)/dashboard/lifecycle/new/page.tsx` — 绕过统一客户端,手动 fetch + Authorization header
|
||||
- `frontend/lib/api/reports.ts` — PDF blob 导出被迫绕过统一客户端(fetchWithAuth 只返回 JSON)
|
||||
- `docker-compose.yml` — Redis 无密码、PostgreSQL 弱密码(geo:geo123)
|
||||
- `docker-compose.prod.yml` — 依赖不存在的 .env.production
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
---
|
||||
title: "chore: GEO Platform Launch Readiness Sprint"
|
||||
type: chore
|
||||
status: active
|
||||
date: "2026-06-01"
|
||||
origin: docs/brainstorms/2026-06-01-geo-launch-readiness-requirements.md
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
修复部署阻塞问题,启动服务,端到端验证完整变现闭环(注册→诊断→健康分→付费墙→支付→解锁),使 GEO 平台达到可上线状态。
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Plan 003 完成了 9 个实施单元的代码编写,但这些代码从未作为完整系统运行过。研究发现 4 个 P0 阻塞问题导致服务无法启动:JWT_SECRET 长度不足、DATABASE_URL 驱动不匹配、根目录 .env 缺失、pgvector 扩展未安装。在代码从未跑过的状态下,任何单点修复都是盲目的;只有让系统先跑起来,才能发现真正的集成问题。
|
||||
|
||||
## Requirements
|
||||
|
||||
**部署就绪**
|
||||
|
||||
R1. 后端服务可在 Docker Compose 环境中启动,所有 API 端点可访问
|
||||
|
||||
R2. 前端构建产物可被部署并提供页面访问
|
||||
|
||||
R3. 数据库迁移可从空库执行到最新版本,所有表和索引正确创建
|
||||
|
||||
R4. 环境变量配置完整,第三方服务密钥缺失时优雅降级为 mock 模式
|
||||
|
||||
**核心流程可跑通**
|
||||
|
||||
R5. 新用户可完成注册并登录,获得有效 JWT
|
||||
|
||||
R6. 登录用户可创建品牌,触发诊断,获得非零健康分
|
||||
|
||||
R7. 免费用户访问付费功能时触发付费墙,显示升级提示
|
||||
|
||||
R8. 用户可发起支付(mock 模式),支付完成后配额刷新、付费功能解锁
|
||||
|
||||
R9. 公开健康分页面无需注册即可访问,输入品牌名可生成报告
|
||||
|
||||
**端到端验证**
|
||||
|
||||
R10. 完整变现闭环可在部署环境中走通:注册→诊断→健康分→付费墙→支付→解锁
|
||||
|
||||
**代码质量**
|
||||
|
||||
R11. Dockerfile 健康检查端点与实际 API 端点一致
|
||||
|
||||
R12. 测试环境配置完整,测试可正常启动
|
||||
|
||||
R13. 前端页面统一使用 API 客户端,认证 token 正确传递
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
KTD1. **DATABASE_URL 使用 asyncpg 驱动。** `database.py` 使用 `create_async_engine`,必须用 `postgresql+asyncpg://` 而非 `postgresql+psycopg://`。当前 `backend/.env` 中的值使用了错误的同步驱动。
|
||||
|
||||
KTD2. **数据库初始化使用 create_all + stamp head。** Alembic 迁移链存在顺序问题(alerts 表引用 brands 但 brands 在更晚的迁移中创建),直接 `alembic upgrade head` 会失败。先用 `Base.metadata.create_all()` 创建所有表,再用 `alembic stamp head` 标记版本。这与前序会话中验证过的策略一致。
|
||||
|
||||
KTD3. **pgvector 通过 PostgreSQL 初始化脚本安装。** 在 Docker Compose 中为 db 服务添加初始化脚本,从源码编译安装 pgvector 扩展。避免构建自定义 PostgreSQL 镜像的复杂性。
|
||||
|
||||
KTD4. **支付/分发/邮件保持 mock 模式。** 本次验证目标是确认付费墙逻辑正确,真实 SDK 接入是后续工作。`PAYMENT_MODE=mock`、`DISTRIBUTION_MODE=mock`、`EMAIL_MODE=mock`。
|
||||
|
||||
KTD5. **开发环境优先于生产环境。** 先用 `docker-compose.yml`(开发模式)验证,确认跑通后再配置 `docker-compose.prod.yml`。开发模式挂载源码目录便于调试。
|
||||
|
||||
KTD6. **本地裸跑优先于 Docker 部署。** Docker Hub 网络问题导致镜像构建失败,先用本地直接运行后端/前端验证核心流程,Docker 部署作为后续工作。
|
||||
|
||||
KTD7. **验证驱动修复。** 手动走完注册→诊断→支付全链路,每步发现问题就修,不做预防性重构。
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Fix environment configuration blockers ✅
|
||||
|
||||
**Goal:** 修复所有 P0 阻塞问题,使服务可以启动
|
||||
|
||||
**Requirements:** R4
|
||||
|
||||
**Status:** Completed (previous session)
|
||||
|
||||
**What was done:**
|
||||
- `backend/.env` DATABASE_URL 改为 `postgresql+asyncpg://`
|
||||
- `JWT_SECRET` 改为 43 字符
|
||||
- 从 `.env.example` 创建根目录 `.env`
|
||||
- `.gitignore` 确认排除 `.env`
|
||||
|
||||
---
|
||||
|
||||
### U2. Database setup with pgvector and migrations ✅
|
||||
|
||||
**Goal:** PostgreSQL 容器安装 pgvector 扩展,数据库表结构完整创建
|
||||
|
||||
**Requirements:** R3
|
||||
|
||||
**Status:** Completed (previous session)
|
||||
|
||||
**What was done:**
|
||||
- 创建 `backend/init-db.sh` pgvector 初始化脚本
|
||||
- `docker-compose.yml` 挂载初始化脚本
|
||||
- 创建 `backend/init_schema.py` 使用 create_all + stamp head
|
||||
- 数据库表和 pgvector 扩展已验证存在
|
||||
|
||||
---
|
||||
|
||||
### U3. Service startup and health verification 🔄
|
||||
|
||||
**Goal:** 后端和前端服务均可启动并通过健康检查
|
||||
|
||||
**Requirements:** R1, R2
|
||||
|
||||
**Status:** Partially completed (previous session)
|
||||
|
||||
**What was done:**
|
||||
- 后端本地启动成功:`uvicorn app.main:app --host 0.0.0.0 --port 8000`
|
||||
- 后端健康检查通过:`/health` → healthy, `/ready` → database:ok, redis:ok
|
||||
- 前端本地启动成功:`npm run dev` on port 3001
|
||||
|
||||
**Remaining:**
|
||||
- 验证前端页面可访问(`curl http://localhost:3001`)
|
||||
- 验证前端可调用后端 API(无 CORS 错误)
|
||||
|
||||
---
|
||||
|
||||
### U3.5. Fix audit-discovered P0 issues
|
||||
|
||||
**Goal:** 修复复盘发现的 P0 阻塞问题
|
||||
|
||||
**Requirements:** R11, R12
|
||||
|
||||
**Dependencies:** none (可并行)
|
||||
|
||||
**Files:**
|
||||
- `backend/Dockerfile` — 修复健康检查端点 `/api/health` → `/health`
|
||||
- `backend/.env.test` — 添加 JWT_SECRET
|
||||
|
||||
**Approach:**
|
||||
1. 修改 Dockerfile HEALTHCHECK 端点从 `/api/health` 改为 `/health`
|
||||
2. 在 `.env.test` 中添加 `JWT_SECRET=test-jwt-secret-for-testing-at-least-32-characters-long`
|
||||
|
||||
**Test scenarios:**
|
||||
- Dockerfile 健康检查端点与 FastAPI 注册的 `/health` 一致
|
||||
- `pytest` 可正常启动(不因 JWT_SECRET 缺失而 sys.exit)
|
||||
|
||||
**Verification:** Dockerfile HEALTHCHECK CMD 正确,测试可运行
|
||||
|
||||
---
|
||||
|
||||
### U4. Authentication flow verification
|
||||
|
||||
**Goal:** 新用户可完成注册、登录、获取 JWT
|
||||
|
||||
**Requirements:** R5
|
||||
|
||||
**Dependencies:** U3
|
||||
|
||||
**Files:**
|
||||
- `backend/app/api/auth.py` — 认证 API
|
||||
- `backend/app/services/auth.py` — 认证服务
|
||||
- `backend/app/models/user.py` — User 模型
|
||||
- `frontend/app/(auth)/` — 前端认证页面
|
||||
|
||||
**Known issues:**
|
||||
- 前端部分页面绕过统一 API 客户端(reports、lifecycle/new),认证 token 可能不被附加
|
||||
|
||||
**Approach:**
|
||||
1. 启动后端和前端服务
|
||||
2. 通过 API 注册新用户:`POST /api/v1/auth/register`
|
||||
3. 验证用户数据正确写入数据库(plan=free, max_queries=5)
|
||||
4. 登录获取 JWT token:`POST /api/v1/auth/login`
|
||||
5. 用 JWT token 调用受保护 API:`GET /api/v1/brands`
|
||||
6. 修复认证流程中的任何错误
|
||||
|
||||
**Test scenarios:**
|
||||
- `POST /api/v1/auth/register` 创建用户成功,返回 201
|
||||
- `POST /api/v1/auth/login` 返回 JWT token
|
||||
- `GET /api/v1/brands` 携带 JWT 返回 200(非 401)
|
||||
- 新用户 plan 字段为 "free",max_queries 为 5
|
||||
|
||||
**Verification:** 新用户可完成注册→登录→访问受保护资源
|
||||
|
||||
---
|
||||
|
||||
### U5. Diagnosis and health score verification
|
||||
|
||||
**Goal:** 用户可创建品牌、触发诊断、获得非零健康分;公开健康分页面可访问
|
||||
|
||||
**Requirements:** R6, R9
|
||||
|
||||
**Dependencies:** U4
|
||||
|
||||
**Files:**
|
||||
- `backend/app/api/diagnosis.py` — 诊断 API
|
||||
- `backend/app/api/health_score.py` — 公开健康分 API
|
||||
- `backend/app/services/diagnosis/` — 诊断服务
|
||||
- `frontend/app/(public)/health-score/` — 公开健康分页面
|
||||
- `frontend/app/(dashboard)/onboarding/` — Onboarding 页面
|
||||
|
||||
**Known risks:**
|
||||
- 诊断依赖 DeepSeek API,如果 API Key 无效或额度耗尽,诊断会返回空结果
|
||||
- 诊断是异步流程,可能需要轮询等待结果
|
||||
|
||||
**Approach:**
|
||||
1. 登录用户创建品牌
|
||||
2. 触发诊断,验证数据采集流程(AI 平台查询 + CitationRecord 分析)
|
||||
3. 查看诊断结果,确认健康分为非零值
|
||||
4. 访问公开健康分页面,输入品牌名生成报告
|
||||
5. 修复诊断流程中的任何错误(LLM API 调用失败、数据采集空结果等)
|
||||
|
||||
**Test scenarios:**
|
||||
- `POST /api/v1/brands` 创建品牌成功
|
||||
- `POST /api/v1/diagnosis` 触发诊断,返回诊断任务 ID
|
||||
- `GET /api/v1/diagnosis/{id}` 返回非零健康分
|
||||
- `GET /api/v1/public/health-score?brand={name}` 返回公开健康分报告
|
||||
- 公开健康分页面无需登录即可访问
|
||||
|
||||
**Verification:** 用户可获得非零 GEO 健康分,公开页面可生成报告
|
||||
|
||||
---
|
||||
|
||||
### U6. Monetization closed loop verification
|
||||
|
||||
**Goal:** 完整变现闭环可走通——免费用户触发付费墙、mock 支付、功能解锁
|
||||
|
||||
**Requirements:** R7, R8, R10
|
||||
|
||||
**Dependencies:** U5
|
||||
|
||||
**Files:**
|
||||
- `backend/app/middleware/subscription_enforcement.py` — 订阅限制中间件
|
||||
- `backend/app/api/payments.py` — 支付 API
|
||||
- `backend/app/services/payment/` — 支付服务
|
||||
- `frontend/components/subscription/` — 订阅 UI 组件
|
||||
- `frontend/app/(dashboard)/dashboard/` — Dashboard 页面
|
||||
|
||||
**Approach:**
|
||||
1. 免费用户尝试访问付费功能(如 AI 内容生成、高级诊断),验证付费墙触发
|
||||
2. 验证升级提示正确显示
|
||||
3. 发起 mock 支付,确认支付流程完成
|
||||
4. 验证支付后用户 plan 升级、配额刷新
|
||||
5. 验证付费功能解锁
|
||||
6. 修复变现闭环中的任何错误
|
||||
|
||||
**Test scenarios:**
|
||||
- 免费用户访问付费 API 返回 403 + 升级提示
|
||||
- `POST /api/v1/payments/create` 创建支付订单
|
||||
- Mock 支付回调后用户 plan 从 "free" 变为 "pro"
|
||||
- 付费功能解锁,用户可正常使用
|
||||
|
||||
**Verification:** 完整变现闭环走通——注册→诊断→付费墙→支付→解锁
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- 修复部署阻塞问题
|
||||
- 数据库初始化和迁移
|
||||
- 服务启动验证
|
||||
- 核心流程端到端验证
|
||||
- 验证过程中发现的阻塞 bug 修复
|
||||
- 审计发现的 P0 问题修复
|
||||
|
||||
**Deferred for later:**
|
||||
- 真实微信/支付宝 SDK 接入
|
||||
- CI/CD 流水线
|
||||
- 性能优化和压力测试
|
||||
- 生产环境域名和 HTTPS 配置
|
||||
- `.env.production` 文件创建
|
||||
- Redis 密码保护
|
||||
- PostgreSQL 弱密码更换
|
||||
- pgvector 镜像优化(改用 pgvector/pgvector:pg15)
|
||||
- 前端统一 API 客户端修复(reports、lifecycle/new 页面)
|
||||
- JWT_SECRET 强密钥生成
|
||||
|
||||
**Outside this sprint:**
|
||||
- UI 打磨和视觉优化
|
||||
- 新功能开发
|
||||
- 代码重构
|
||||
- 完整测试覆盖
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **LLM API 可用性**:诊断和内容生成依赖 DeepSeek API,如果 API Key 无效或额度耗尽,诊断会返回空结果。需确认 API Key 有效。
|
||||
- **pgvector 编译时间**:从源码编译 pgvector 需要安装 build-essential 和 git,首次启动数据库容器可能需要 2-3 分钟。
|
||||
- **迁移链顺序问题**:Alembic 迁移链可能存在未发现的顺序依赖,create_all + stamp head 策略可绕过此问题。
|
||||
- **前端构建问题**:ESLint 警告已降级为 warn,但可能存在运行时错误仅在浏览器中暴露。
|
||||
- **前端 API 客户端不一致**:部分页面绕过统一 API 客户端,认证 token 可能不被附加,导致 401 错误。
|
||||
- **Docker Hub 网络问题**:国内环境拉取 Docker 镜像可能超时,影响 Docker Compose 部署。
|
||||
|
||||
## Sources & Research
|
||||
|
||||
- `backend/.env` — 当前环境变量配置,DATABASE_URL 驱动和 JWT_SECRET 已修复
|
||||
- `backend/app/config.py` — JWT_SECRET >= 32 字符校验逻辑
|
||||
- `backend/app/database.py` — async engine 配置,确认需要 asyncpg 驱动
|
||||
- `backend/app/main.py` — FastAPI 入口,28+ API 路由注册,6 层中间件栈
|
||||
- `backend/Dockerfile` — 健康检查端点错误(/api/health → /health)
|
||||
- `docker-compose.yml` — 开发环境 4 服务配置,backend environment 覆盖
|
||||
- `docker-compose.prod.yml` — 生产环境配置,依赖 `.env.production`
|
||||
- 前序会话 pgvector 安装经验:从源码编译 v0.5.1
|
||||
- 2026-06-01 全面复盘审计:发现 Dockerfile 健康检查、.env.test、前端 API 客户端等问题
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
---
|
||||
title: "chore: GEO Tech Debt Cleanup Sprint"
|
||||
type: chore
|
||||
status: active
|
||||
date: "2026-06-01"
|
||||
origin: docs/brainstorms/2026-06-01-geo-tech-debt-cleanup-requirements.md
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
分三批清理 GEO 平台技术债:Batch 1 修复 28 个模型文件中 68 个缺失 `DateTime(timezone=True)` 的 datetime 列(`monitoring_record.py` 是废弃文件不修);Batch 2 统一前端 2 个页面组件的 API 客户端调用并扩展 `fetchWithAuth` 支持非 JSON 响应;Batch 3 完成前端端到端验证和部署安全加固。
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Plan 004 端到端验证暴露了 asyncpg 严格时区检查的系统性问题。当前仅修复了变现闭环涉及的 4 个核心表,其余 28 个模型文件的 68 个 datetime 列仍是确定的运行时炸弹——任何写入 `datetime.now(UTC)` 到未标记 timezone 的列都会直接报错。同时前端 2 个页面绕过统一 API 客户端,认证 token 不被附加导致 401。Docker 部署从未验证成功,Redis/PostgreSQL 安全配置缺失。这些技术债不清理,系统无法真正上线。
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
**时区修复(Batch 1)**
|
||||
|
||||
R1. 28 个模型文件的 68 个 datetime 列添加 `DateTime(timezone=True)`,PostgreSQL 对应列类型改为 TIMESTAMPTZ
|
||||
|
||||
R2. 时区修复按 API 调用路径分三批执行:核心变现路径 → Agent 框架路径 → 辅助路径
|
||||
|
||||
R3. 每批修复后验证对应 API 路径可正常写入和读取 datetime 数据
|
||||
|
||||
R4. 生成对应的 Alembic 迁移脚本
|
||||
|
||||
R5. 删除废弃的 `monitoring_record.py` 文件(无任何代码引用,与 `monitoring.py` 定义同名表但字段不同)
|
||||
|
||||
**前端 API 客户端统一(Batch 2)**
|
||||
|
||||
R6. `reports/page.tsx` 的 CSV 导出改用统一 API 客户端
|
||||
|
||||
R7. `lifecycle/new/page.tsx` 的项目创建改用统一 API 客户端
|
||||
|
||||
R8. 统一 API 客户端支持非 JSON 响应(blob/PDF 导出)
|
||||
|
||||
**前端端到端验证(Batch 3)**
|
||||
|
||||
R9. 浏览器中可完成完整变现闭环:注册→登录→创建品牌→诊断→查看健康分→付费墙→支付→解锁
|
||||
|
||||
R10. 公开健康分页面无需登录即可访问
|
||||
|
||||
R11. Onboarding 流程在浏览器中可正常走通
|
||||
|
||||
**部署安全加固(Batch 3)**
|
||||
|
||||
R12. Redis 配置密码保护
|
||||
|
||||
R13. PostgreSQL 默认弱密码更换为强密码
|
||||
|
||||
R14. 创建 `.env.production` 模板
|
||||
|
||||
R15. Docker Compose 部署验证通过
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
KTD1. **时区修复按 API 调用路径分批,而非一次性全量迁移。** 68 列同时 ALTER TABLE 风险高,按实际调用路径修复风险可控。核心变现路径(brand、query、content 等)最优先,因为这是用户最可能触发的路径。
|
||||
|
||||
KTD2. **`monitoring_record.py` 是废弃文件,删除而非修复。** 代码引用分析确认所有 import 都指向 `monitoring.py`,`monitoring_record.py` 无任何引用。两个文件定义同名表但字段结构不同,保留 `monitoring.py`(更完整,有 relationship 和 user_id/query_id 外键)。
|
||||
|
||||
KTD3. **扩展 `fetchWithAuth` 支持非 JSON 响应,而非新增独立函数。** `reports.ts` 的 PDF blob 导出被迫绕过统一客户端,因为 `fetchWithAuth` 只返回 JSON。扩展一个 `responseType` 参数比新增 `fetchWithAuthBlob` 更符合 DRY 原则,且对现有调用方无侵入。
|
||||
|
||||
KTD4. **Alembic 迁移按 Batch 生成,而非一个大迁移。** 每个 Batch 生成一个迁移文件,便于回滚和增量部署。Batch 1 因模型数量多可能需要 2-3 个迁移文件。
|
||||
|
||||
---
|
||||
|
||||
## Implementation Units
|
||||
|
||||
### U1. Batch 1a: 核心变现路径时区修复
|
||||
|
||||
- **Goal:** 修复核心变现路径涉及的模型文件 datetime 列,确保品牌创建、查询、内容管理、GEO 计划、建议生成等 API 不再触发 asyncpg 时区错误
|
||||
- **Requirements:** R1, R2, R3, R4
|
||||
- **Dependencies:** none
|
||||
- **Files:**
|
||||
- `backend/app/models/brand.py` — 4 列(last_queried_at, next_query_at, created_at, updated_at)
|
||||
- `backend/app/models/query.py` — 4 列(last_queried_at, next_query_at, created_at, updated_at)
|
||||
- `backend/app/models/citation_record.py` — 1 列(queried_at)
|
||||
- `backend/app/models/attribution_record.py` — 4 列(published_at, window_end_at, created_at, updated_at)
|
||||
- `backend/app/models/content.py` — 4 列(Content: created_at, updated_at; ContentVersion: created_at; ContentReview: created_at)
|
||||
- `backend/app/models/geo_plan.py` — 5 列(GeoPlan: created_at, updated_at; GeoPlanAction: completed_at, created_at, updated_at)
|
||||
- `backend/app/models/suggestion.py` — 2 列(generated_at, updated_at)
|
||||
- `backend/app/models/competitor.py` — 1 列(created_at)
|
||||
- `backend/app/models/competitor_insight.py` — 2 列(created_at, updated_at)
|
||||
- `backend/app/models/distribution.py` — 2 列(created_at, updated_at)
|
||||
- `backend/app/models/brand_knowledge.py` — 3 列(BrandKnowledge: created_at, updated_at; Keyword: created_at)
|
||||
- **Approach:** 每个文件添加 `DateTime` import(如缺失),将所有 `Mapped[datetime]` 列的 `mapped_column()` 添加 `DateTime(timezone=True)` 参数。对于已有 `DateTime` 但无 `timezone=True` 的列(如 brand.py 的 last_queried_at),改为 `DateTime(timezone=True)`。修复后启动后端服务,通过 curl 验证品牌创建和查询 API 的 datetime 读写正常。
|
||||
- **Patterns to follow:** 已修复的 4 个核心表(diagnosis_record.py, payment_order.py, subscription.py, user.py)的修改模式
|
||||
- **Test scenarios:**
|
||||
- 品牌创建 API 返回的 created_at 包含时区信息
|
||||
- 查询品牌列表 API 返回的 datetime 字段包含时区信息
|
||||
- 创建 GEO 计划后 completed_at 可写入 timezone-aware datetime
|
||||
- attribution_record 的 published_at 和 window_end_at 可写入 timezone-aware datetime
|
||||
- **Verification:** 启动后端服务,通过 curl 调用品牌创建、查询、内容管理 API,确认 datetime 读写无 asyncpg 时区错误
|
||||
|
||||
### U2. Batch 1b: Agent 框架路径时区修复
|
||||
|
||||
- **Goal:** 修复 Agent 框架和监控相关模型文件的 datetime 列
|
||||
- **Requirements:** R1, R2, R3, R4
|
||||
- **Dependencies:** U1
|
||||
- **Files:**
|
||||
- `backend/app/models/agent.py` — 9 列(AgentRegistry: last_heartbeat, created_at, updated_at; AgentConfig: updated_at; AgentTask: scheduled_at, started_at, completed_at, created_at; AgentTaskLog: created_at)
|
||||
- `backend/app/models/detection_task.py` — 4 列(last_run_at, next_run_at, created_at, updated_at)
|
||||
- `backend/app/models/monitoring.py` — 5 列(MonitoringRecord: last_checked_at, next_check_at, created_at, updated_at; ContentBaseline: recorded_at)
|
||||
- `backend/app/models/trend_insight.py` — 4 列(period_start, period_end, created_at, updated_at)
|
||||
- `backend/app/models/query_task.py` — 3 列(scheduled_at, started_at, completed_at)
|
||||
- `backend/app/models/usage_record.py` — 2 列(timestamp, created_at)
|
||||
- `backend/app/models/api_key.py` — 3 列(last_verified_at, created_at, updated_at)
|
||||
- **Approach:** 同 U1 模式。注意 `detection_task.py` 的 `next_run_at` 有 `default=lambda: datetime.now(timezone.utc)`,这已经是 timezone-aware 的,但列类型仍是 `DateTime`(无 timezone),需要改为 `DateTime(timezone=True)`。
|
||||
- **Patterns to follow:** U1 的修改模式
|
||||
- **Test scenarios:**
|
||||
- Agent 注册时 last_heartbeat 可写入 timezone-aware datetime
|
||||
- AgentTask 的 scheduled_at、started_at、completed_at 可写入 timezone-aware datetime
|
||||
- detection_task 的 next_run_at 默认值写入不触发时区错误
|
||||
- monitoring_record 的 last_checked_at、next_check_at 可写入 timezone-aware datetime
|
||||
- **Verification:** 启动后端服务,通过 curl 调用 Agent 相关 API,确认 datetime 读写无错误
|
||||
|
||||
### U3. Batch 1c: 辅助路径时区修复 + 废弃文件清理
|
||||
|
||||
- **Goal:** 修复剩余辅助路径模型文件的 datetime 列,删除废弃的 monitoring_record.py
|
||||
- **Requirements:** R1, R2, R3, R4, R5
|
||||
- **Dependencies:** U2
|
||||
- **Files:**
|
||||
- `backend/app/models/knowledge.py` — 6 列(KnowledgeBase: created_at, updated_at; KnowledgeDocument: created_at, updated_at; KnowledgeChunk: created_at; KnowledgeSearchLog: created_at)
|
||||
- `backend/app/models/knowledge_graph.py` — 3 列(KnowledgeEntity: created_at, updated_at; KnowledgeRelation: created_at)
|
||||
- `backend/app/models/organization.py` — 3 列(Organization: created_at, updated_at; OrgMember: joined_at)
|
||||
- `backend/app/models/lifecycle.py` — 4 列(LifecycleProject: created_at, updated_at; ProjectStage: started_at, completed_at)
|
||||
- `backend/app/models/alert.py` — 1 列(created_at)
|
||||
- `backend/app/models/alert_setting.py` — 2 列(created_at, updated_at)
|
||||
- `backend/app/models/platform_rule.py` — 1 列(updated_at)
|
||||
- `backend/app/models/platform_rule_version.py` — 1 列(created_at)
|
||||
- `backend/app/models/schema_suggestion.py` — 2 列(created_at, updated_at)
|
||||
- `backend/app/models/monitoring_record.py` — 删除整个文件
|
||||
- `backend/app/models/__init__.py` — 移除 monitoring_record 的 import(如有)
|
||||
- **Approach:** 同 U1 模式。删除 `monitoring_record.py` 前确认 `__init__.py` 无引用(已验证无任何代码 import 此文件)。删除后检查 `__init__.py` 是否有相关 import 需清理。
|
||||
- **Patterns to follow:** U1 的修改模式
|
||||
- **Test scenarios:**
|
||||
- knowledge 相关 API 的 created_at/updated_at 可写入 timezone-aware datetime
|
||||
- lifecycle project 的 started_at/completed_at 可写入 timezone-aware datetime
|
||||
- 删除 monitoring_record.py 后后端服务正常启动,无 import 错误
|
||||
- **Verification:** 启动后端服务,确认无 import 错误;调用 knowledge 和 lifecycle API 验证 datetime 读写
|
||||
|
||||
### U4. Alembic 迁移生成与执行
|
||||
|
||||
- **Goal:** 为 U1-U3 的所有模型变更生成 Alembic 迁移脚本,并在本地数据库执行
|
||||
- **Requirements:** R4
|
||||
- **Dependencies:** U1, U2, U3
|
||||
- **Files:**
|
||||
- `backend/alembic/versions/` — 新增迁移文件
|
||||
- **Approach:** 运行 `alembic revision --autogenerate` 生成迁移。检查生成的迁移脚本确认所有 ALTER COLUMN 操作正确(`TIMESTAMP → TIMESTAMPTZ`)。执行迁移后验证数据库列类型已更新。如果项目未配置 Alembic,则通过 `init_schema.py` 或手动 SQL 完成数据库更新。
|
||||
- **Patterns to follow:** 已有的 Alembic 迁移文件(如存在)
|
||||
- **Test scenarios:**
|
||||
- 迁移脚本可成功执行,无错误
|
||||
- 迁移后数据库列类型为 TIMESTAMPTZ
|
||||
- 迁移可回滚(downgrade)
|
||||
- **Verification:** 执行迁移,检查数据库列类型
|
||||
|
||||
### U5. 前端 API 客户端统一
|
||||
|
||||
- **Goal:** 统一前端 2 个页面组件的 API 调用,扩展 fetchWithAuth 支持非 JSON 响应
|
||||
- **Requirements:** R6, R7, R8
|
||||
- **Dependencies:** none(可与 U1-U4 并行)
|
||||
- **Files:**
|
||||
- `frontend/lib/api/client.ts` — 扩展 fetchWithAuth 支持 responseType 参数
|
||||
- `frontend/app/(dashboard)/dashboard/reports/page.tsx` — CSV 导出改用 fetchWithAuth
|
||||
- `frontend/app/(dashboard)/dashboard/lifecycle/new/page.tsx` — 项目创建改用 fetchWithAuth
|
||||
- `frontend/lib/api/reports.ts` — PDF blob 导出改用 fetchWithAuth(responseType: 'blob')
|
||||
- **Approach:** 在 `fetchWithAuth` 中添加可选 `responseType` 参数,默认 `'json'`,当为 `'blob'` 时返回 `Response` 对象而非解析 JSON。修改 reports/page.tsx 和 lifecycle/new/page.tsx 使用 `fetchWithAuth` 替代手动 `fetch`。修改 reports.ts 使用 `fetchWithAuth` 的 blob 模式。
|
||||
- **Patterns to follow:** `frontend/lib/api/client.ts` 现有的 fetchWithAuth 实现
|
||||
- **Test scenarios:**
|
||||
- fetchWithAuth 默认行为不变(返回 JSON)
|
||||
- fetchWithAuth responseType='blob' 返回 Response 对象
|
||||
- reports 页面 CSV 导出认证 token 正确传递
|
||||
- lifecycle/new 页面项目创建认证 token 正确传递
|
||||
- PDF 导出通过 fetchWithAuth blob 模式正常工作
|
||||
- **Verification:** 前端构建通过,浏览器中访问 reports 和 lifecycle/new 页面无 401 错误
|
||||
|
||||
### U6. 前端端到端验证
|
||||
|
||||
- **Goal:** 在浏览器中验证完整变现闭环和关键用户流程
|
||||
- **Requirements:** R9, R10, R11
|
||||
- **Dependencies:** U4, U5
|
||||
- **Files:**
|
||||
- 无代码修改,纯验证
|
||||
- **Approach:** 启动前后端服务,在浏览器中手动走通完整变现闭环。重点验证:注册→登录→Onboarding→创建品牌→诊断→健康分→付费墙→支付→解锁。同时验证公开健康分页面无需登录可访问。
|
||||
- **Test scenarios:**
|
||||
- 完整变现闭环:注册→登录→创建品牌→诊断→健康分→付费墙→支付→解锁
|
||||
- 公开健康分页面无需登录可访问并生成报告
|
||||
- Onboarding 流程可正常走通
|
||||
- reports 页面 CSV 导出正常
|
||||
- lifecycle/new 页面项目创建正常
|
||||
- **Verification:** 所有流程在浏览器中走通,无 401、无页面报错
|
||||
|
||||
### U7. 部署安全加固
|
||||
|
||||
- **Goal:** Redis 密码保护、PostgreSQL 强密码、.env.production 模板、Docker Compose 部署验证
|
||||
- **Requirements:** R12, R13, R14, R15
|
||||
- **Dependencies:** U4
|
||||
- **Files:**
|
||||
- `docker-compose.yml` — Redis 添加密码配置、PostgreSQL 密码更新
|
||||
- `backend/.env` — Redis 密码和 PostgreSQL 密码同步更新
|
||||
- `backend/.env.production` — 新建生产环境配置模板
|
||||
- `docker-compose.prod.yml` — 更新生产环境配置(如存在)
|
||||
- **Approach:** 在 docker-compose.yml 中为 Redis 添加 `--requirepass` 命令和环境变量。PostgreSQL 密码从弱密码 `geo123` 改为强密码。创建 `.env.production` 模板包含所有必需配置项。后端代码中 Redis 连接需同步添加密码参数。执行 `docker compose up` 验证 4 个服务正常启动和通信。
|
||||
- **Patterns to follow:** docker-compose.yml 现有配置结构
|
||||
- **Test scenarios:**
|
||||
- Redis 无密码连接被拒绝
|
||||
- Redis 有密码连接成功
|
||||
- PostgreSQL 弱密码连接被拒绝
|
||||
- PostgreSQL 强密码连接成功
|
||||
- Docker Compose 4 个服务正常启动
|
||||
- 后端服务可连接 Redis 和 PostgreSQL
|
||||
- **Verification:** `docker compose up` 成功,4 个服务健康检查通过,后端 API 可正常响应
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- 28 个模型文件的 DateTime(timezone=True) 修复
|
||||
- 废弃文件 monitoring_record.py 删除
|
||||
- Alembic 迁移生成和执行
|
||||
- 前端 2 个页面组件 API 客户端统一
|
||||
- 统一 API 客户端非 JSON 响应支持
|
||||
- 浏览器端到端验证
|
||||
- Docker Compose 部署验证
|
||||
- Redis/PostgreSQL 安全配置
|
||||
- .env.production 模板
|
||||
|
||||
**Deferred for later:**
|
||||
- 真实微信/支付宝 SDK 接入
|
||||
- CI/CD 流水线
|
||||
- 性能优化和压力测试
|
||||
- 生产环境域名和 HTTPS 配置
|
||||
- 完整测试覆盖
|
||||
- pgvector 镜像优化
|
||||
- JWT_SECRET 强密钥生成
|
||||
- UI 打磨和视觉优化
|
||||
|
||||
**Outside this sprint:**
|
||||
- 新功能开发
|
||||
- 代码重构(除时区修复和废弃文件删除外)
|
||||
|
||||
---
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
- **Alembic 未配置或配置不完整。** 如果项目未正确配置 Alembic,自动迁移生成可能失败。回退方案:通过 `init_schema.py` 或手动 SQL 完成 ALTER COLUMN。
|
||||
- **Docker Hub 网络问题。** Plan 004 中 Docker 部署因网络问题失败,U7 可能遇到同样问题。回退方案:配置 Docker 镜像源或使用本地构建。
|
||||
- **前端构建可能暴露其他问题。** 前端代码从未在浏览器中完整验证,E2E 验证可能发现新的 bug,这些 bug 需要额外修复。
|
||||
- **Redis 密码变更影响后端连接。** 后端代码中 Redis 连接配置需同步更新,否则服务启动失败。
|
||||
|
||||
---
|
||||
|
||||
## Sources / Research
|
||||
|
||||
- Plan 004 端到端验证记录:时区 bug 在 diagnosis_records 和 payment_orders 上的具体表现
|
||||
- `backend/app/models/` — 28 个待修复模型文件,68 个 datetime 列
|
||||
- `backend/app/models/monitoring.py` — 实际使用的监控模型(被 4 处代码引用)
|
||||
- `backend/app/models/monitoring_record.py` — 废弃文件(零引用)
|
||||
- `frontend/lib/api/client.ts` — 统一 API 客户端
|
||||
- `frontend/app/(dashboard)/dashboard/reports/page.tsx` — 绕过统一客户端
|
||||
- `frontend/app/(dashboard)/dashboard/lifecycle/new/page.tsx` — 绕过统一客户端
|
||||
- `frontend/lib/api/reports.ts` — PDF blob 导出被迫绕过
|
||||
- `docker-compose.yml` — Redis 无密码、PostgreSQL 弱密码
|
||||
|
|
@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AlertCard } from "@/components/business/alert-card";
|
||||
import { fetchWithAuth } from "@/lib/api/client";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
interface ProjectResponse {
|
||||
|
|
@ -57,39 +58,20 @@ export default function NewProjectPage() {
|
|||
const token = session?.accessToken as string | undefined;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/v1/lifecycle/projects/quick-start`,
|
||||
const data = await fetchWithAuth(
|
||||
"/api/v1/lifecycle/projects/quick-start",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token || ""}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
brand_name: brandName.trim(),
|
||||
brand_url: brandUrl.trim(),
|
||||
description: description.trim() || undefined,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "请求失败,请稍后重试";
|
||||
try {
|
||||
const errData = await response.json();
|
||||
errorMessage = errData.detail || errData.message || errorMessage;
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _data = (await response.json()) as {
|
||||
project: ProjectResponse;
|
||||
message: string;
|
||||
};
|
||||
},
|
||||
token
|
||||
) as { project: ProjectResponse; message: string };
|
||||
|
||||
void data;
|
||||
animateSteps();
|
||||
} catch (err) {
|
||||
setSubmitting(false);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { reportsApi } from "@/lib/api/reports";
|
||||
import { fetchWithAuth } from "@/lib/api/client";
|
||||
import type { QueryListResponse } from "@/lib/api/queries";
|
||||
import type { CitationListResponse, CitationStats } from "@/lib/api/citations";
|
||||
import { useApi } from "@/lib/hooks/use-api";
|
||||
|
|
@ -39,8 +40,6 @@ import {
|
|||
BarChart3,
|
||||
} from "lucide-react";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { data: session } = useSession();
|
||||
const [selectedQuery, setSelectedQuery] = useState<string>("");
|
||||
|
|
@ -87,15 +86,7 @@ export default function ReportsPage() {
|
|||
|
||||
if (format === "csv") {
|
||||
const query = `?query_id=${queryId}`;
|
||||
const url = `${API_BASE}/api/v1/reports/export/csv${query}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ detail: "导出失败" }));
|
||||
throw new Error(errorData.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
blob = await res.blob();
|
||||
blob = await fetchWithAuth(`/api/v1/reports/export/csv${query}`, {}, session.accessToken, "blob") as unknown as Blob;
|
||||
filename = `report_${queryId}_${new Date().toISOString().split("T")[0]}.csv`;
|
||||
} else {
|
||||
blob = await reportsApi.exportPDF(session.accessToken, queryId);
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ export function getApiUrl(path: string): string {
|
|||
return `${API_BASE}${path}`;
|
||||
}
|
||||
|
||||
export type ResponseType = "json" | "blob";
|
||||
|
||||
export async function fetchWithAuth(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
token?: string
|
||||
token?: string,
|
||||
responseType: ResponseType = "json"
|
||||
) {
|
||||
// 如果没有显式传入 token,尝试从 NextAuth session 获取
|
||||
let authToken = token;
|
||||
if (!authToken && typeof window !== "undefined") {
|
||||
try {
|
||||
|
|
@ -33,7 +35,6 @@ export async function fetchWithAuth(
|
|||
const res = await fetch(`${API_BASE}${url}`, { ...options, headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
// 不要自动跳转登录页,让页面组件/layout自行处理认证状态
|
||||
throw new Error("登录已过期,请重新登录");
|
||||
}
|
||||
|
||||
|
|
@ -52,5 +53,9 @@ export async function fetchWithAuth(
|
|||
return null;
|
||||
}
|
||||
|
||||
if (responseType === "blob") {
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
import { API_BASE, fetchWithAuth } from "./client";
|
||||
import { fetchWithAuth } from "./client";
|
||||
|
||||
export const reportsApi = {
|
||||
exportCSV: (token: string, queryId?: string) => {
|
||||
const query = queryId ? `?query_id=${queryId}` : "";
|
||||
return fetchWithAuth(`/api/v1/reports/export/csv${query}`, {}, token);
|
||||
return fetchWithAuth(`/api/v1/reports/export/csv${query}`, {}, token, "blob");
|
||||
},
|
||||
exportPDF: async (token: string, queryId?: string) => {
|
||||
const query = queryId ? `?query_id=${queryId}` : "";
|
||||
const res = await fetch(`${API_BASE}/api/v1/reports/export/pdf${query}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error("导出失败");
|
||||
return res.blob();
|
||||
return fetchWithAuth(`/api/v1/reports/export/pdf${query}`, {}, token, "blob");
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue