Merge branch 'chore/geo-tech-debt-cleanup' — Plan 005: tech debt cleanup sprint

This commit is contained in:
chiguyong 2026-06-01 21:44:44 +08:00
commit 9fb4dad215
50 changed files with 1374 additions and 220 deletions

64
.env.production.example Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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", "-"]

View File

@ -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]

View File

@ -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))

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,

View File

@ -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())

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,

View File

@ -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,
)

View File

@ -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,

View File

@ -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"),

View File

@ -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,

View File

@ -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,

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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)

View File

@ -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,
)

View File

@ -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"),
)

View File

@ -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,
)

View File

@ -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)

View File

@ -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,

View File

@ -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"),

View File

@ -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,

View File

@ -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")

View File

@ -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,

View File

@ -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,
)

View File

@ -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,

View File

@ -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,

View File

@ -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,
)

View File

@ -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)

29
backend/init-db.sh Executable file
View File

@ -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 ==="

40
backend/init_schema.py Normal file
View File

@ -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())

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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 客户端等问题

View File

@ -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 导出改用 fetchWithAuthresponseType: '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 弱密码

View File

@ -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);

View File

@ -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);

View File

@ -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();
}

View File

@ -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");
},
};