From 3bd848ee3638be982a6f14aafa5900690eee0e67 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 1 Jun 2026 14:37:02 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20pgvector=20?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E8=84=9A=E6=9C=AC=E5=92=8C=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=20schema=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/init-db.sh: Docker 首次启动时编译安装 pgvector v0.5.1 - backend/init_schema.py: create_all + stamp head 初始化数据库 - docker-compose.yml: 挂载 init-db.sh 到 /docker-entrypoint-initdb.d/ --- backend/init-db.sh | 29 +++++++++++++++++++++++++++++ backend/init_schema.py | 40 ++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 1 + 3 files changed, 70 insertions(+) create mode 100755 backend/init-db.sh create mode 100644 backend/init_schema.py diff --git a/backend/init-db.sh b/backend/init-db.sh new file mode 100755 index 0000000..63ab761 --- /dev/null +++ b/backend/init-db.sh @@ -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 ===" diff --git a/backend/init_schema.py b/backend/init_schema.py new file mode 100644 index 0000000..ea1755e --- /dev/null +++ b/backend/init_schema.py @@ -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()) diff --git a/docker-compose.yml b/docker-compose.yml index 70de3ff..ab184f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: - "5432: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 From 4f86f2bd62796dbd5970a84240a1da15c3d04cf0 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 1 Jun 2026 20:35:56 +0800 Subject: [PATCH 02/10] chore: Plan 004 - launch readiness sprint (timezone fixes, health check, JWT secret) --- backend/.env.test | 1 + backend/Dockerfile | 2 +- backend/app/models/diagnosis_record.py | 6 +- backend/app/models/payment_order.py | 4 +- backend/app/models/subscription.py | 3 +- backend/app/models/user.py | 8 +- docker-compose.yml | 3 + ...06-01-geo-launch-readiness-requirements.md | 109 +++++++ ...-chore-geo-launch-readiness-sprint-plan.md | 298 ++++++++++++++++++ 9 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 docs/brainstorms/2026-06-01-geo-launch-readiness-requirements.md create mode 100644 docs/plans/2026-06-01-004-chore-geo-launch-readiness-sprint-plan.md diff --git a/backend/.env.test b/backend/.env.test index 84c1de5..2ed98a5 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 3c04f46..11c8b6a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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", "-"] diff --git a/backend/app/models/diagnosis_record.py b/backend/app/models/diagnosis_record.py index e83270a..bb0321f 100644 --- a/backend/app/models/diagnosis_record.py +++ b/backend/app/models/diagnosis_record.py @@ -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"), diff --git a/backend/app/models/payment_order.py b/backend/app/models/payment_order.py index 22e6905..988e80b 100644 --- a/backend/app/models/payment_order.py +++ b/backend/app/models/payment_order.py @@ -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) diff --git a/backend/app/models/subscription.py b/backend/app/models/subscription.py index b83d4cb..9a7906d 100644 --- a/backend/app/models/subscription.py +++ b/backend/app/models/subscription.py @@ -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, ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index af0ede4..00cce23 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index ab184f3..7c14848 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,9 @@ services: - "8000:8000" env_file: - .env + environment: + DATABASE_URL: postgresql+asyncpg://postgres:postgres123@db:5432/geo_platform + REDIS_URL: redis://redis:6379/0 volumes: - ./backend:/app depends_on: diff --git a/docs/brainstorms/2026-06-01-geo-launch-readiness-requirements.md b/docs/brainstorms/2026-06-01-geo-launch-readiness-requirements.md new file mode 100644 index 0000000..d83c453 --- /dev/null +++ b/docs/brainstorms/2026-06-01-geo-launch-readiness-requirements.md @@ -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) diff --git a/docs/plans/2026-06-01-004-chore-geo-launch-readiness-sprint-plan.md b/docs/plans/2026-06-01-004-chore-geo-launch-readiness-sprint-plan.md new file mode 100644 index 0000000..12acc03 --- /dev/null +++ b/docs/plans/2026-06-01-004-chore-geo-launch-readiness-sprint-plan.md @@ -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 客户端等问题 From 4168aca107f9e49e903feb2649d7de43cf97a3ce Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 1 Jun 2026 20:36:10 +0800 Subject: [PATCH 03/10] chore: Plan 005 - tech debt cleanup sprint (timezone, API client, security) --- ...6-01-geo-tech-debt-cleanup-requirements.md | 130 +++++++++ ...01-005-chore-geo-tech-debt-cleanup-plan.md | 270 ++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 docs/brainstorms/2026-06-01-geo-tech-debt-cleanup-requirements.md create mode 100644 docs/plans/2026-06-01-005-chore-geo-tech-debt-cleanup-plan.md diff --git a/docs/brainstorms/2026-06-01-geo-tech-debt-cleanup-requirements.md b/docs/brainstorms/2026-06-01-geo-tech-debt-cleanup-requirements.md new file mode 100644 index 0000000..9c5a47f --- /dev/null +++ b/docs/brainstorms/2026-06-01-geo-tech-debt-cleanup-requirements.md @@ -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 diff --git a/docs/plans/2026-06-01-005-chore-geo-tech-debt-cleanup-plan.md b/docs/plans/2026-06-01-005-chore-geo-tech-debt-cleanup-plan.md new file mode 100644 index 0000000..dbfcd9b --- /dev/null +++ b/docs/plans/2026-06-01-005-chore-geo-tech-debt-cleanup-plan.md @@ -0,0 +1,270 @@ +--- +title: "chore: GEO Tech Debt Cleanup Sprint" +type: chore +status: active +date: "2026-06-01" +origin: docs/brainstorms/2026-06-01-geo-tech-debt-cleanup-requirements.md +--- + +## Summary + +分三批清理 GEO 平台技术债:Batch 1 修复 28 个模型文件中 68 个缺失 `DateTime(timezone=True)` 的 datetime 列(`monitoring_record.py` 是废弃文件不修);Batch 2 统一前端 2 个页面组件的 API 客户端调用并扩展 `fetchWithAuth` 支持非 JSON 响应;Batch 3 完成前端端到端验证和部署安全加固。 + +## Problem Frame + +Plan 004 端到端验证暴露了 asyncpg 严格时区检查的系统性问题。当前仅修复了变现闭环涉及的 4 个核心表,其余 28 个模型文件的 68 个 datetime 列仍是确定的运行时炸弹——任何写入 `datetime.now(UTC)` 到未标记 timezone 的列都会直接报错。同时前端 2 个页面绕过统一 API 客户端,认证 token 不被附加导致 401。Docker 部署从未验证成功,Redis/PostgreSQL 安全配置缺失。这些技术债不清理,系统无法真正上线。 + +--- + +## Requirements + +**时区修复(Batch 1)** + +R1. 28 个模型文件的 68 个 datetime 列添加 `DateTime(timezone=True)`,PostgreSQL 对应列类型改为 TIMESTAMPTZ + +R2. 时区修复按 API 调用路径分三批执行:核心变现路径 → Agent 框架路径 → 辅助路径 + +R3. 每批修复后验证对应 API 路径可正常写入和读取 datetime 数据 + +R4. 生成对应的 Alembic 迁移脚本 + +R5. 删除废弃的 `monitoring_record.py` 文件(无任何代码引用,与 `monitoring.py` 定义同名表但字段不同) + +**前端 API 客户端统一(Batch 2)** + +R6. `reports/page.tsx` 的 CSV 导出改用统一 API 客户端 + +R7. `lifecycle/new/page.tsx` 的项目创建改用统一 API 客户端 + +R8. 统一 API 客户端支持非 JSON 响应(blob/PDF 导出) + +**前端端到端验证(Batch 3)** + +R9. 浏览器中可完成完整变现闭环:注册→登录→创建品牌→诊断→查看健康分→付费墙→支付→解锁 + +R10. 公开健康分页面无需登录即可访问 + +R11. Onboarding 流程在浏览器中可正常走通 + +**部署安全加固(Batch 3)** + +R12. Redis 配置密码保护 + +R13. PostgreSQL 默认弱密码更换为强密码 + +R14. 创建 `.env.production` 模板 + +R15. Docker Compose 部署验证通过 + +--- + +## Key Technical Decisions + +KTD1. **时区修复按 API 调用路径分批,而非一次性全量迁移。** 68 列同时 ALTER TABLE 风险高,按实际调用路径修复风险可控。核心变现路径(brand、query、content 等)最优先,因为这是用户最可能触发的路径。 + +KTD2. **`monitoring_record.py` 是废弃文件,删除而非修复。** 代码引用分析确认所有 import 都指向 `monitoring.py`,`monitoring_record.py` 无任何引用。两个文件定义同名表但字段结构不同,保留 `monitoring.py`(更完整,有 relationship 和 user_id/query_id 外键)。 + +KTD3. **扩展 `fetchWithAuth` 支持非 JSON 响应,而非新增独立函数。** `reports.ts` 的 PDF blob 导出被迫绕过统一客户端,因为 `fetchWithAuth` 只返回 JSON。扩展一个 `responseType` 参数比新增 `fetchWithAuthBlob` 更符合 DRY 原则,且对现有调用方无侵入。 + +KTD4. **Alembic 迁移按 Batch 生成,而非一个大迁移。** 每个 Batch 生成一个迁移文件,便于回滚和增量部署。Batch 1 因模型数量多可能需要 2-3 个迁移文件。 + +--- + +## Implementation Units + +### U1. Batch 1a: 核心变现路径时区修复 + +- **Goal:** 修复核心变现路径涉及的模型文件 datetime 列,确保品牌创建、查询、内容管理、GEO 计划、建议生成等 API 不再触发 asyncpg 时区错误 +- **Requirements:** R1, R2, R3, R4 +- **Dependencies:** none +- **Files:** + - `backend/app/models/brand.py` — 4 列(last_queried_at, next_query_at, created_at, updated_at) + - `backend/app/models/query.py` — 4 列(last_queried_at, next_query_at, created_at, updated_at) + - `backend/app/models/citation_record.py` — 1 列(queried_at) + - `backend/app/models/attribution_record.py` — 4 列(published_at, window_end_at, created_at, updated_at) + - `backend/app/models/content.py` — 4 列(Content: created_at, updated_at; ContentVersion: created_at; ContentReview: created_at) + - `backend/app/models/geo_plan.py` — 5 列(GeoPlan: created_at, updated_at; GeoPlanAction: completed_at, created_at, updated_at) + - `backend/app/models/suggestion.py` — 2 列(generated_at, updated_at) + - `backend/app/models/competitor.py` — 1 列(created_at) + - `backend/app/models/competitor_insight.py` — 2 列(created_at, updated_at) + - `backend/app/models/distribution.py` — 2 列(created_at, updated_at) + - `backend/app/models/brand_knowledge.py` — 3 列(BrandKnowledge: created_at, updated_at; Keyword: created_at) +- **Approach:** 每个文件添加 `DateTime` import(如缺失),将所有 `Mapped[datetime]` 列的 `mapped_column()` 添加 `DateTime(timezone=True)` 参数。对于已有 `DateTime` 但无 `timezone=True` 的列(如 brand.py 的 last_queried_at),改为 `DateTime(timezone=True)`。修复后启动后端服务,通过 curl 验证品牌创建和查询 API 的 datetime 读写正常。 +- **Patterns to follow:** 已修复的 4 个核心表(diagnosis_record.py, payment_order.py, subscription.py, user.py)的修改模式 +- **Test scenarios:** + - 品牌创建 API 返回的 created_at 包含时区信息 + - 查询品牌列表 API 返回的 datetime 字段包含时区信息 + - 创建 GEO 计划后 completed_at 可写入 timezone-aware datetime + - attribution_record 的 published_at 和 window_end_at 可写入 timezone-aware datetime +- **Verification:** 启动后端服务,通过 curl 调用品牌创建、查询、内容管理 API,确认 datetime 读写无 asyncpg 时区错误 + +### U2. Batch 1b: Agent 框架路径时区修复 + +- **Goal:** 修复 Agent 框架和监控相关模型文件的 datetime 列 +- **Requirements:** R1, R2, R3, R4 +- **Dependencies:** U1 +- **Files:** + - `backend/app/models/agent.py` — 9 列(AgentRegistry: last_heartbeat, created_at, updated_at; AgentConfig: updated_at; AgentTask: scheduled_at, started_at, completed_at, created_at; AgentTaskLog: created_at) + - `backend/app/models/detection_task.py` — 4 列(last_run_at, next_run_at, created_at, updated_at) + - `backend/app/models/monitoring.py` — 5 列(MonitoringRecord: last_checked_at, next_check_at, created_at, updated_at; ContentBaseline: recorded_at) + - `backend/app/models/trend_insight.py` — 4 列(period_start, period_end, created_at, updated_at) + - `backend/app/models/query_task.py` — 3 列(scheduled_at, started_at, completed_at) + - `backend/app/models/usage_record.py` — 2 列(timestamp, created_at) + - `backend/app/models/api_key.py` — 3 列(last_verified_at, created_at, updated_at) +- **Approach:** 同 U1 模式。注意 `detection_task.py` 的 `next_run_at` 有 `default=lambda: datetime.now(timezone.utc)`,这已经是 timezone-aware 的,但列类型仍是 `DateTime`(无 timezone),需要改为 `DateTime(timezone=True)`。 +- **Patterns to follow:** U1 的修改模式 +- **Test scenarios:** + - Agent 注册时 last_heartbeat 可写入 timezone-aware datetime + - AgentTask 的 scheduled_at、started_at、completed_at 可写入 timezone-aware datetime + - detection_task 的 next_run_at 默认值写入不触发时区错误 + - monitoring_record 的 last_checked_at、next_check_at 可写入 timezone-aware datetime +- **Verification:** 启动后端服务,通过 curl 调用 Agent 相关 API,确认 datetime 读写无错误 + +### U3. Batch 1c: 辅助路径时区修复 + 废弃文件清理 + +- **Goal:** 修复剩余辅助路径模型文件的 datetime 列,删除废弃的 monitoring_record.py +- **Requirements:** R1, R2, R3, R4, R5 +- **Dependencies:** U2 +- **Files:** + - `backend/app/models/knowledge.py` — 6 列(KnowledgeBase: created_at, updated_at; KnowledgeDocument: created_at, updated_at; KnowledgeChunk: created_at; KnowledgeSearchLog: created_at) + - `backend/app/models/knowledge_graph.py` — 3 列(KnowledgeEntity: created_at, updated_at; KnowledgeRelation: created_at) + - `backend/app/models/organization.py` — 3 列(Organization: created_at, updated_at; OrgMember: joined_at) + - `backend/app/models/lifecycle.py` — 4 列(LifecycleProject: created_at, updated_at; ProjectStage: started_at, completed_at) + - `backend/app/models/alert.py` — 1 列(created_at) + - `backend/app/models/alert_setting.py` — 2 列(created_at, updated_at) + - `backend/app/models/platform_rule.py` — 1 列(updated_at) + - `backend/app/models/platform_rule_version.py` — 1 列(created_at) + - `backend/app/models/schema_suggestion.py` — 2 列(created_at, updated_at) + - `backend/app/models/monitoring_record.py` — 删除整个文件 + - `backend/app/models/__init__.py` — 移除 monitoring_record 的 import(如有) +- **Approach:** 同 U1 模式。删除 `monitoring_record.py` 前确认 `__init__.py` 无引用(已验证无任何代码 import 此文件)。删除后检查 `__init__.py` 是否有相关 import 需清理。 +- **Patterns to follow:** U1 的修改模式 +- **Test scenarios:** + - knowledge 相关 API 的 created_at/updated_at 可写入 timezone-aware datetime + - lifecycle project 的 started_at/completed_at 可写入 timezone-aware datetime + - 删除 monitoring_record.py 后后端服务正常启动,无 import 错误 +- **Verification:** 启动后端服务,确认无 import 错误;调用 knowledge 和 lifecycle API 验证 datetime 读写 + +### U4. Alembic 迁移生成与执行 + +- **Goal:** 为 U1-U3 的所有模型变更生成 Alembic 迁移脚本,并在本地数据库执行 +- **Requirements:** R4 +- **Dependencies:** U1, U2, U3 +- **Files:** + - `backend/alembic/versions/` — 新增迁移文件 +- **Approach:** 运行 `alembic revision --autogenerate` 生成迁移。检查生成的迁移脚本确认所有 ALTER COLUMN 操作正确(`TIMESTAMP → TIMESTAMPTZ`)。执行迁移后验证数据库列类型已更新。如果项目未配置 Alembic,则通过 `init_schema.py` 或手动 SQL 完成数据库更新。 +- **Patterns to follow:** 已有的 Alembic 迁移文件(如存在) +- **Test scenarios:** + - 迁移脚本可成功执行,无错误 + - 迁移后数据库列类型为 TIMESTAMPTZ + - 迁移可回滚(downgrade) +- **Verification:** 执行迁移,检查数据库列类型 + +### U5. 前端 API 客户端统一 + +- **Goal:** 统一前端 2 个页面组件的 API 调用,扩展 fetchWithAuth 支持非 JSON 响应 +- **Requirements:** R6, R7, R8 +- **Dependencies:** none(可与 U1-U4 并行) +- **Files:** + - `frontend/lib/api/client.ts` — 扩展 fetchWithAuth 支持 responseType 参数 + - `frontend/app/(dashboard)/dashboard/reports/page.tsx` — CSV 导出改用 fetchWithAuth + - `frontend/app/(dashboard)/dashboard/lifecycle/new/page.tsx` — 项目创建改用 fetchWithAuth + - `frontend/lib/api/reports.ts` — PDF blob 导出改用 fetchWithAuth(responseType: 'blob') +- **Approach:** 在 `fetchWithAuth` 中添加可选 `responseType` 参数,默认 `'json'`,当为 `'blob'` 时返回 `Response` 对象而非解析 JSON。修改 reports/page.tsx 和 lifecycle/new/page.tsx 使用 `fetchWithAuth` 替代手动 `fetch`。修改 reports.ts 使用 `fetchWithAuth` 的 blob 模式。 +- **Patterns to follow:** `frontend/lib/api/client.ts` 现有的 fetchWithAuth 实现 +- **Test scenarios:** + - fetchWithAuth 默认行为不变(返回 JSON) + - fetchWithAuth responseType='blob' 返回 Response 对象 + - reports 页面 CSV 导出认证 token 正确传递 + - lifecycle/new 页面项目创建认证 token 正确传递 + - PDF 导出通过 fetchWithAuth blob 模式正常工作 +- **Verification:** 前端构建通过,浏览器中访问 reports 和 lifecycle/new 页面无 401 错误 + +### U6. 前端端到端验证 + +- **Goal:** 在浏览器中验证完整变现闭环和关键用户流程 +- **Requirements:** R9, R10, R11 +- **Dependencies:** U4, U5 +- **Files:** + - 无代码修改,纯验证 +- **Approach:** 启动前后端服务,在浏览器中手动走通完整变现闭环。重点验证:注册→登录→Onboarding→创建品牌→诊断→健康分→付费墙→支付→解锁。同时验证公开健康分页面无需登录可访问。 +- **Test scenarios:** + - 完整变现闭环:注册→登录→创建品牌→诊断→健康分→付费墙→支付→解锁 + - 公开健康分页面无需登录可访问并生成报告 + - Onboarding 流程可正常走通 + - reports 页面 CSV 导出正常 + - lifecycle/new 页面项目创建正常 +- **Verification:** 所有流程在浏览器中走通,无 401、无页面报错 + +### U7. 部署安全加固 + +- **Goal:** Redis 密码保护、PostgreSQL 强密码、.env.production 模板、Docker Compose 部署验证 +- **Requirements:** R12, R13, R14, R15 +- **Dependencies:** U4 +- **Files:** + - `docker-compose.yml` — Redis 添加密码配置、PostgreSQL 密码更新 + - `backend/.env` — Redis 密码和 PostgreSQL 密码同步更新 + - `backend/.env.production` — 新建生产环境配置模板 + - `docker-compose.prod.yml` — 更新生产环境配置(如存在) +- **Approach:** 在 docker-compose.yml 中为 Redis 添加 `--requirepass` 命令和环境变量。PostgreSQL 密码从弱密码 `geo123` 改为强密码。创建 `.env.production` 模板包含所有必需配置项。后端代码中 Redis 连接需同步添加密码参数。执行 `docker compose up` 验证 4 个服务正常启动和通信。 +- **Patterns to follow:** docker-compose.yml 现有配置结构 +- **Test scenarios:** + - Redis 无密码连接被拒绝 + - Redis 有密码连接成功 + - PostgreSQL 弱密码连接被拒绝 + - PostgreSQL 强密码连接成功 + - Docker Compose 4 个服务正常启动 + - 后端服务可连接 Redis 和 PostgreSQL +- **Verification:** `docker compose up` 成功,4 个服务健康检查通过,后端 API 可正常响应 + +--- + +## Scope Boundaries + +**In scope:** +- 28 个模型文件的 DateTime(timezone=True) 修复 +- 废弃文件 monitoring_record.py 删除 +- Alembic 迁移生成和执行 +- 前端 2 个页面组件 API 客户端统一 +- 统一 API 客户端非 JSON 响应支持 +- 浏览器端到端验证 +- Docker Compose 部署验证 +- Redis/PostgreSQL 安全配置 +- .env.production 模板 + +**Deferred for later:** +- 真实微信/支付宝 SDK 接入 +- CI/CD 流水线 +- 性能优化和压力测试 +- 生产环境域名和 HTTPS 配置 +- 完整测试覆盖 +- pgvector 镜像优化 +- JWT_SECRET 强密钥生成 +- UI 打磨和视觉优化 + +**Outside this sprint:** +- 新功能开发 +- 代码重构(除时区修复和废弃文件删除外) + +--- + +## Risks & Dependencies + +- **Alembic 未配置或配置不完整。** 如果项目未正确配置 Alembic,自动迁移生成可能失败。回退方案:通过 `init_schema.py` 或手动 SQL 完成 ALTER COLUMN。 +- **Docker Hub 网络问题。** Plan 004 中 Docker 部署因网络问题失败,U7 可能遇到同样问题。回退方案:配置 Docker 镜像源或使用本地构建。 +- **前端构建可能暴露其他问题。** 前端代码从未在浏览器中完整验证,E2E 验证可能发现新的 bug,这些 bug 需要额外修复。 +- **Redis 密码变更影响后端连接。** 后端代码中 Redis 连接配置需同步更新,否则服务启动失败。 + +--- + +## Sources / Research + +- Plan 004 端到端验证记录:时区 bug 在 diagnosis_records 和 payment_orders 上的具体表现 +- `backend/app/models/` — 28 个待修复模型文件,68 个 datetime 列 +- `backend/app/models/monitoring.py` — 实际使用的监控模型(被 4 处代码引用) +- `backend/app/models/monitoring_record.py` — 废弃文件(零引用) +- `frontend/lib/api/client.ts` — 统一 API 客户端 +- `frontend/app/(dashboard)/dashboard/reports/page.tsx` — 绕过统一客户端 +- `frontend/app/(dashboard)/dashboard/lifecycle/new/page.tsx` — 绕过统一客户端 +- `frontend/lib/api/reports.ts` — PDF blob 导出被迫绕过 +- `docker-compose.yml` — Redis 无密码、PostgreSQL 弱密码 From 289f04302f5a527366bb73c8cb7debdf93305ee8 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 1 Jun 2026 20:39:14 +0800 Subject: [PATCH 04/10] fix: add DateTime(timezone=True) to Batch 1a core monetization path models (11 files, 32 columns) --- backend/app/models/attribution_record.py | 8 +++++--- backend/app/models/brand.py | 6 ++++-- backend/app/models/brand_knowledge.py | 5 ++++- backend/app/models/citation_record.py | 3 ++- backend/app/models/competitor.py | 1 + backend/app/models/competitor_insight.py | 4 +++- backend/app/models/content.py | 6 +++++- backend/app/models/distribution.py | 4 +++- backend/app/models/geo_plan.py | 8 ++++++-- backend/app/models/query.py | 8 +++++--- backend/app/models/suggestion.py | 2 ++ 11 files changed, 40 insertions(+), 15 deletions(-) diff --git a/backend/app/models/attribution_record.py b/backend/app/models/attribution_record.py index 5e8f9dc..a288334 100644 --- a/backend/app/models/attribution_record.py +++ b/backend/app/models/attribution_record.py @@ -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, diff --git a/backend/app/models/brand.py b/backend/app/models/brand.py index bd56c30..60e44f8 100644 --- a/backend/app/models/brand.py +++ b/backend/app/models/brand.py @@ -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, diff --git a/backend/app/models/brand_knowledge.py b/backend/app/models/brand_knowledge.py index cca0887..0c3057a 100644 --- a/backend/app/models/brand_knowledge.py +++ b/backend/app/models/brand_knowledge.py @@ -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, ) diff --git a/backend/app/models/citation_record.py b/backend/app/models/citation_record.py index 2bfca19..c691ef5 100644 --- a/backend/app/models/citation_record.py +++ b/backend/app/models/citation_record.py @@ -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, ) diff --git a/backend/app/models/competitor.py b/backend/app/models/competitor.py index 46e9814..edb5a5d 100644 --- a/backend/app/models/competitor.py +++ b/backend/app/models/competitor.py @@ -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, ) diff --git a/backend/app/models/competitor_insight.py b/backend/app/models/competitor_insight.py index b5b1582..3595e61 100644 --- a/backend/app/models/competitor_insight.py +++ b/backend/app/models/competitor_insight.py @@ -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, diff --git a/backend/app/models/content.py b/backend/app/models/content.py index 8075c38..4c0d47e 100644 --- a/backend/app/models/content.py +++ b/backend/app/models/content.py @@ -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, ) diff --git a/backend/app/models/distribution.py b/backend/app/models/distribution.py index 9c1d142..b228f50 100644 --- a/backend/app/models/distribution.py +++ b/backend/app/models/distribution.py @@ -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, diff --git a/backend/app/models/geo_plan.py b/backend/app/models/geo_plan.py index 23c5f04..bfbd84e 100644 --- a/backend/app/models/geo_plan.py +++ b/backend/app/models/geo_plan.py @@ -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, diff --git a/backend/app/models/query.py b/backend/app/models/query.py index d13478a..9ef182a 100644 --- a/backend/app/models/query.py +++ b/backend/app/models/query.py @@ -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, diff --git a/backend/app/models/suggestion.py b/backend/app/models/suggestion.py index 01efaeb..d48bbad 100644 --- a/backend/app/models/suggestion.py +++ b/backend/app/models/suggestion.py @@ -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, From 14cafa66c6bc0e0dbf32ce27c61decf1e4b21c3f Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 1 Jun 2026 20:41:31 +0800 Subject: [PATCH 05/10] fix: add DateTime(timezone=True) to Batch 1b agent framework path models (7 files, 28 columns) --- backend/app/models/agent.py | 15 ++++++++++----- backend/app/models/api_key.py | 4 +++- backend/app/models/detection_task.py | 6 ++++-- backend/app/models/monitoring.py | 9 ++++++--- backend/app/models/query_task.py | 7 ++++--- backend/app/models/trend_insight.py | 8 +++++--- backend/app/models/usage_record.py | 3 ++- 7 files changed, 34 insertions(+), 18 deletions(-) diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index 6cbe199..eb0ea97 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -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, ) diff --git a/backend/app/models/api_key.py b/backend/app/models/api_key.py index efff1fb..bd835c3 100644 --- a/backend/app/models/api_key.py +++ b/backend/app/models/api_key.py @@ -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, diff --git a/backend/app/models/detection_task.py b/backend/app/models/detection_task.py index 957500f..aecd5e3 100644 --- a/backend/app/models/detection_task.py +++ b/backend/app/models/detection_task.py @@ -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, diff --git a/backend/app/models/monitoring.py b/backend/app/models/monitoring.py index 93f193e..912495f 100644 --- a/backend/app/models/monitoring.py +++ b/backend/app/models/monitoring.py @@ -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, ) diff --git a/backend/app/models/query_task.py b/backend/app/models/query_task.py index 2fd2285..6996e24 100644 --- a/backend/app/models/query_task.py +++ b/backend/app/models/query_task.py @@ -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") diff --git a/backend/app/models/trend_insight.py b/backend/app/models/trend_insight.py index 35d3929..67eeffd 100644 --- a/backend/app/models/trend_insight.py +++ b/backend/app/models/trend_insight.py @@ -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, diff --git a/backend/app/models/usage_record.py b/backend/app/models/usage_record.py index 0dac114..a77a80b 100644 --- a/backend/app/models/usage_record.py +++ b/backend/app/models/usage_record.py @@ -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, ) From 792d9ebe533f8dca45f3d79d95a3202a64fa1076 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 1 Jun 2026 20:48:39 +0800 Subject: [PATCH 06/10] fix: add DateTime(timezone=True) to Batch 1c auxiliary path models (9 files, 23 columns) and remove orphan monitoring_record.py --- backend/app/models/alert.py | 3 +- backend/app/models/alert_setting.py | 4 +- backend/app/models/knowledge.py | 8 +- backend/app/models/knowledge_graph.py | 3 + backend/app/models/lifecycle.py | 8 +- backend/app/models/monitoring_record.py | 108 -------------------- backend/app/models/organization.py | 5 +- backend/app/models/platform_rule.py | 3 +- backend/app/models/platform_rule_version.py | 4 +- backend/app/models/schema_suggestion.py | 2 + 10 files changed, 30 insertions(+), 118 deletions(-) delete mode 100644 backend/app/models/monitoring_record.py diff --git a/backend/app/models/alert.py b/backend/app/models/alert.py index 97fea83..fb88470 100644 --- a/backend/app/models/alert.py +++ b/backend/app/models/alert.py @@ -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, ) diff --git a/backend/app/models/alert_setting.py b/backend/app/models/alert_setting.py index c745c49..30ddca7 100644 --- a/backend/app/models/alert_setting.py +++ b/backend/app/models/alert_setting.py @@ -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, diff --git a/backend/app/models/knowledge.py b/backend/app/models/knowledge.py index 950a0b9..31cff5b 100644 --- a/backend/app/models/knowledge.py +++ b/backend/app/models/knowledge.py @@ -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, ) diff --git a/backend/app/models/knowledge_graph.py b/backend/app/models/knowledge_graph.py index fb8550d..885b189 100644 --- a/backend/app/models/knowledge_graph.py +++ b/backend/app/models/knowledge_graph.py @@ -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, ) diff --git a/backend/app/models/lifecycle.py b/backend/app/models/lifecycle.py index f79fbaf..53846c6 100644 --- a/backend/app/models/lifecycle.py +++ b/backend/app/models/lifecycle.py @@ -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) diff --git a/backend/app/models/monitoring_record.py b/backend/app/models/monitoring_record.py deleted file mode 100644 index 8662c57..0000000 --- a/backend/app/models/monitoring_record.py +++ /dev/null @@ -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"), - ) diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py index d472520..16a33fa 100644 --- a/backend/app/models/organization.py +++ b/backend/app/models/organization.py @@ -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, ) diff --git a/backend/app/models/platform_rule.py b/backend/app/models/platform_rule.py index f68bdac..0e777f1 100644 --- a/backend/app/models/platform_rule.py +++ b/backend/app/models/platform_rule.py @@ -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, diff --git a/backend/app/models/platform_rule_version.py b/backend/app/models/platform_rule_version.py index 425cd1d..445e7eb 100644 --- a/backend/app/models/platform_rule_version.py +++ b/backend/app/models/platform_rule_version.py @@ -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"), diff --git a/backend/app/models/schema_suggestion.py b/backend/app/models/schema_suggestion.py index 57bebff..68aad0d 100644 --- a/backend/app/models/schema_suggestion.py +++ b/backend/app/models/schema_suggestion.py @@ -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, From f1a8b69c2aadca58f1e2ea922964cc1403bf4a91 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 1 Jun 2026 20:54:12 +0800 Subject: [PATCH 07/10] fix: unify frontend API client - add blob support to fetchWithAuth, eliminate raw fetch calls - Extend fetchWithAuth with responseType parameter ('json' | 'blob') - reports.ts: PDF/CSV export now uses fetchWithAuth blob mode - reports/page.tsx: remove duplicate API_BASE, use fetchWithAuth for CSV export - lifecycle/new/page.tsx: replace raw fetch with fetchWithAuth for quick-start POST --- .../dashboard/lifecycle/new/page.tsx | 32 ++++--------------- .../(dashboard)/dashboard/reports/page.tsx | 13 ++------ frontend/lib/api/client.ts | 11 +++++-- frontend/lib/api/reports.ts | 10 ++---- 4 files changed, 20 insertions(+), 46 deletions(-) diff --git a/frontend/app/(dashboard)/dashboard/lifecycle/new/page.tsx b/frontend/app/(dashboard)/dashboard/lifecycle/new/page.tsx index 745af14..e126e07 100644 --- a/frontend/app/(dashboard)/dashboard/lifecycle/new/page.tsx +++ b/frontend/app/(dashboard)/dashboard/lifecycle/new/page.tsx @@ -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); diff --git a/frontend/app/(dashboard)/dashboard/reports/page.tsx b/frontend/app/(dashboard)/dashboard/reports/page.tsx index 198d01c..20a96ba 100644 --- a/frontend/app/(dashboard)/dashboard/reports/page.tsx +++ b/frontend/app/(dashboard)/dashboard/reports/page.tsx @@ -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(""); @@ -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); diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index d214045..a82aa50 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -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(); } diff --git a/frontend/lib/api/reports.ts b/frontend/lib/api/reports.ts index ba2dd7d..ea45a98 100644 --- a/frontend/lib/api/reports.ts +++ b/frontend/lib/api/reports.ts @@ -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"); }, }; From eabd23d09382198c1ea5e08866daa71454242553 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 1 Jun 2026 21:08:56 +0800 Subject: [PATCH 08/10] feat: add Alembic migration for timezone-aware datetime columns (95 columns, 38 tables) - Convert TIMESTAMP WITHOUT TIME ZONE to TIMESTAMP WITH TIME ZONE - Uses AT TIME ZONE 'UTC' for safe data conversion - Covers all models: users, brands, queries, citations, agents, etc. - Includes downgrade path back to TIMESTAMP WITHOUT TIME ZONE --- ...78_add_timezone_to_all_datetime_columns.py | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 backend/alembic/versions/h3i4j5k6mn78_add_timezone_to_all_datetime_columns.py diff --git a/backend/alembic/versions/h3i4j5k6mn78_add_timezone_to_all_datetime_columns.py b/backend/alembic/versions/h3i4j5k6mn78_add_timezone_to_all_datetime_columns.py new file mode 100644 index 0000000..84e005f --- /dev/null +++ b/backend/alembic/versions/h3i4j5k6mn78_add_timezone_to_all_datetime_columns.py @@ -0,0 +1,268 @@ +"""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\'') + + +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)) From d5f752f41911264a6a76b3ca2e0fcb592adf10cb Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 1 Jun 2026 21:26:39 +0800 Subject: [PATCH 09/10] fix: add DateTime(timezone=True) to analytics.py (4 columns), update migration and port config - analytics.py: 4 DateTime columns now timezone-aware (99 total, 0 remaining) - Migration script updated with publish_records, content_metrics, optimization_insights - Docker Compose: db port 5433, redis port 6380 (avoid conflicts with fischerx) - .env files: DATABASE_URL and REDIS_URL updated to new ports - alembic.ini: updated to localhost:5433 --- backend/.env.example | 4 ++-- backend/alembic.ini | 2 +- ...3i4j5k6mn78_add_timezone_to_all_datetime_columns.py | 10 ++++++++++ backend/app/models/analytics.py | 10 ++++++---- docker-compose.yml | 4 ++-- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 27e27db..faedbd7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -55,12 +55,12 @@ ENABLE_LLM=true # ============================================================ # 数据库配置 # ============================================================ -DATABASE_URL=postgresql+asyncpg://postgres:postgres123@localhost:5432/geo_platform +DATABASE_URL=postgresql+asyncpg://postgres:postgres123@localhost:5433/geo_platform # ============================================================ # Redis 配置 # ============================================================ -REDIS_URL=redis://localhost:6379/0 +REDIS_URL=redis://localhost:6380/0 # 是否启用Redis缓存 ENABLE_REDIS=true diff --git a/backend/alembic.ini b/backend/alembic.ini index 79339d6..7ab8a55 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -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:postgres123@127.0.0.1:5433/geo_platform [post_write_hooks] diff --git a/backend/alembic/versions/h3i4j5k6mn78_add_timezone_to_all_datetime_columns.py b/backend/alembic/versions/h3i4j5k6mn78_add_timezone_to_all_datetime_columns.py index 84e005f..c03d29f 100644 --- a/backend/alembic/versions/h3i4j5k6mn78_add_timezone_to_all_datetime_columns.py +++ b/backend/alembic/versions/h3i4j5k6mn78_add_timezone_to_all_datetime_columns.py @@ -141,6 +141,11 @@ def upgrade() -> None: 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)) @@ -266,3 +271,8 @@ def downgrade() -> None: 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)) diff --git a/backend/app/models/analytics.py b/backend/app/models/analytics.py index ddcbe38..ccd3897 100644 --- a/backend/app/models/analytics.py +++ b/backend/app/models/analytics.py @@ -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()) + + diff --git a/docker-compose.yml b/docker-compose.yml index 7c14848..05f505a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: POSTGRES_PASSWORD: postgres123 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 @@ -32,7 +32,7 @@ services: container_name: geo_redis restart: unless-stopped ports: - - "6379:6379" + - "6380:6379" volumes: - redis_data:/data healthcheck: From 47719b25ce69c5984585a58e07f52e2614971dec Mon Sep 17 00:00:00 2001 From: chiguyong Date: Mon, 1 Jun 2026 21:34:13 +0800 Subject: [PATCH 10/10] feat: deployment security hardening - Redis: add password authentication (requirepass), update healthcheck - PostgreSQL: replace hardcoded password with env variable - Docker Compose: remove obsolete version field, use env vars for credentials - Add .env.production.example template with strong password placeholders - Update all .env files with new credentials and ports --- .env.production.example | 64 +++++++++++++++++++++++++++++++++++++++++ backend/.env.example | 4 +-- backend/alembic.ini | 2 +- docker-compose.yml | 11 ++++--- 4 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 .env.production.example diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..7fbfbe0 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/backend/.env.example b/backend/.env.example index faedbd7..9fb9471 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -55,12 +55,12 @@ ENABLE_LLM=true # ============================================================ # 数据库配置 # ============================================================ -DATABASE_URL=postgresql+asyncpg://postgres:postgres123@localhost:5433/geo_platform +DATABASE_URL=postgresql+asyncpg://postgres:geo_pg_dev_2026@localhost:5433/geo_platform # ============================================================ # Redis 配置 # ============================================================ -REDIS_URL=redis://localhost:6380/0 +REDIS_URL=redis://:geo_redis_dev_2026@localhost:6380/0 # 是否启用Redis缓存 ENABLE_REDIS=true diff --git a/backend/alembic.ini b/backend/alembic.ini index 7ab8a55..cb920b1 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -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@127.0.0.1:5433/geo_platform +sqlalchemy.url = postgresql+asyncpg://postgres:geo_pg_dev_2026@127.0.0.1:5433/geo_platform [post_write_hooks] diff --git a/docker-compose.yml b/docker-compose.yml index 05f505a..1440981 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.9" - services: db: image: postgres:15-alpine @@ -7,7 +5,7 @@ services: restart: unless-stopped environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres123 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-geo_pg_dev_2026} POSTGRES_DB: geo_platform ports: - "5433:5432" @@ -31,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: - "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 @@ -57,8 +56,8 @@ services: env_file: - .env environment: - DATABASE_URL: postgresql+asyncpg://postgres:postgres123@db:5432/geo_platform - REDIS_URL: redis://redis:6379/0 + 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: