feat: 添加PostgreSQL支持并更新数据库配置

- 新增PostgreSQL服务支持,使用最新版17
- 更新Docker Compose配置以支持PostgreSQL和Redis
- 修改数据库连接逻辑,支持PostgreSQL和SQLite
- 更新文档以反映新的数据库配置和使用方法
- 优化数据模型,确保时间戳字段支持时区
This commit is contained in:
penghanyuan
2026-01-21 23:21:36 +01:00
parent dbbb924625
commit 0591e9d7c1
7 changed files with 170 additions and 48 deletions

View File

@@ -10,11 +10,12 @@ Life Echo API 是一个智能对话系统,通过 WebSocket 实时连接,使
- **Web 框架**: FastAPI 0.115.0
- **WebSocket**: websockets 14.1
- **AI 框架**: LangChain 0.3.0 + DeepSeek/兼容 OpenAI 的 LLM
- **数据库**: SQLAlchemy 2.0.36 + SQLite (aiosqlite)
- **AI 框架**: LangChain 0.3.7 + DeepSeek/兼容 OpenAI 的 LLM
- **数据库**: PostgreSQL 17 + SQLAlchemy 2.0.36 (asyncpg)
- **缓存/队列**: Redis 7 + Celery 5.3
- **PDF 生成**: ReportLab 4.2.2 + WeasyPrint 62.3
- **ASR/TTS**: OpenAI Whisper API
- **认证**: JWT (python-jose) + bcrypt (passlib)
- **认证**: JWT (python-jose) + bcrypt
- **其他**: Pydantic, python-dotenv
## 项目结构
@@ -73,8 +74,11 @@ LLM_BASE_URL=https://api.your-llm-provider.com # 可选
LLM_MODEL=your-model-name # 可选,默认 deepseek-chat
LLM_TEMPERATURE=0.7 # 可选,默认 0.7
# 数据库配置(可选,默认使用 SQLite
DATABASE_URL=sqlite+aiosqlite:///./life_echo.db
# 数据库配置(PostgreSQL推荐
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo
# Redis 配置
REDIS_URL=redis://localhost:6379/0
# 认证配置
SECRET_KEY=your-secret-key-here # JWT签名密钥建议使用随机字符串
@@ -112,16 +116,20 @@ init_db()
```bash
cd api
# 1. 启动 Redis
# 1. 启动 PostgreSQL + Redis
docker-compose -f docker-compose.dev.yml up -d
# 2. 安装依赖
pip install -r requirements.txt
# 3. 启动 API终端 1
# 3. 配置环境变量
export DATABASE_URL=postgresql://postgres:postgres@localhost:5432/life_echo
export REDIS_URL=redis://localhost:6379/0
# 4. 启动 API终端 1
uvicorn main:app --reload --host 0.0.0.0 --port 8000
# 4. 启动 Celery Worker终端 2
# 5. 启动 Celery Worker终端 2
# macOS 使用 solo 池避免 fork 崩溃问题
celery -A tasks.celery_app worker --loglevel=info --pool=solo
@@ -129,6 +137,16 @@ celery -A tasks.celery_app worker --loglevel=info --pool=solo
# celery -A tasks.celery_app worker --loglevel=info --concurrency=4
```
### 验证服务
```bash
# 检查 PostgreSQL
docker exec life-echo-postgres-dev psql -U postgres -c "SELECT 1"
# 检查 Redis
docker exec life-echo-redis-dev redis-cli ping
```
### 生产部署(一键)
```bash

View File

@@ -1,5 +1,6 @@
"""
数据库连接和初始化
支持 PostgreSQL推荐和 SQLite本地开发
"""
import os
from sqlalchemy import create_engine
@@ -8,31 +9,64 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess
from .models import Base
# 数据库文件路径
# 从环境变量获取,如果格式不正确则使用默认值
raw_database_url = os.getenv("DATABASE_URL", "sqlite:///./life_echo.db")
# 从环境变量获取数据库 URL
raw_database_url = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/life_echo")
# 处理数据库 URL
# 如果已经是异步格式,需要提取同步格式用于同步引擎
if raw_database_url.startswith("sqlite+aiosqlite://"):
# 提取文件路径(移除协议部分)
file_path = raw_database_url.replace("sqlite+aiosqlite://", "")
DATABASE_URL = f"sqlite://{file_path}"
ASYNC_DATABASE_URL = raw_database_url
elif raw_database_url.startswith("sqlite://"):
DATABASE_URL = raw_database_url
ASYNC_DATABASE_URL = raw_database_url.replace("sqlite://", "sqlite+aiosqlite://")
def parse_database_url(url: str) -> tuple[str, str]:
"""
解析数据库 URL返回同步和异步版本
支持格式:
- PostgreSQL: postgresql://user:pass@host:port/db
- PostgreSQL async: postgresql+asyncpg://user:pass@host:port/db
- SQLite: sqlite:///./path/to/db.db
- SQLite async: sqlite+aiosqlite:///./path/to/db.db
"""
# PostgreSQL
if url.startswith("postgresql+asyncpg://"):
async_url = url
sync_url = url.replace("postgresql+asyncpg://", "postgresql://")
elif url.startswith("postgresql://"):
sync_url = url
async_url = url.replace("postgresql://", "postgresql+asyncpg://")
# SQLite
elif url.startswith("sqlite+aiosqlite://"):
async_url = url
sync_url = url.replace("sqlite+aiosqlite://", "sqlite://")
elif url.startswith("sqlite://"):
sync_url = url
async_url = url.replace("sqlite://", "sqlite+aiosqlite://")
else:
# 如果格式不正确,使用默认值并打印警告
print(f"警告: DATABASE_URL 格式不正确 ({raw_database_url}),使用默认")
DATABASE_URL = "sqlite:///./life_echo.db"
ASYNC_DATABASE_URL = "sqlite+aiosqlite:///./life_echo.db"
# 默认使用 PostgreSQL
print(f"警告: DATABASE_URL 格式不正确 ({url}),使用默认 PostgreSQL")
sync_url = "postgresql://postgres:postgres@localhost:5432/life_echo"
async_url = "postgresql+asyncpg://postgres:postgres@localhost:5432/life_echo"
# 创建同步引擎(用于迁移等)
return sync_url, async_url
DATABASE_URL, ASYNC_DATABASE_URL = parse_database_url(raw_database_url)
# 创建同步引擎用于迁移、Celery 任务等)
# SQLite 需要特殊的 connect_args
if DATABASE_URL.startswith("sqlite"):
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
else:
# 使用 psycopg (v3) 驱动
sync_url = DATABASE_URL.replace("postgresql://", "postgresql+psycopg://")
engine = create_engine(sync_url, pool_size=5, max_overflow=10)
# 创建异步引擎(用于实际应用
# 创建异步引擎(用于 FastAPI
if ASYNC_DATABASE_URL.startswith("sqlite"):
async_engine = create_async_engine(ASYNC_DATABASE_URL, echo=False)
else:
async_engine = create_async_engine(
ASYNC_DATABASE_URL,
echo=False,
pool_size=5,
max_overflow=10
)
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

View File

@@ -1,7 +1,7 @@
"""
数据库模型定义
"""
from datetime import datetime
from datetime import datetime, timezone
from typing import Optional, List
from sqlalchemy import Column, String, Integer, DateTime, Boolean, Text, ForeignKey, JSON
from sqlalchemy.ext.declarative import declarative_base
@@ -10,6 +10,11 @@ from sqlalchemy.orm import relationship
Base = declarative_base()
def utc_now():
"""返回当前 UTC 时间(带时区信息)"""
return datetime.now(timezone.utc)
class User(Base):
"""用户表"""
__tablename__ = "users"
@@ -22,7 +27,7 @@ class User(Base):
nickname = Column(String, nullable=False)
avatar_url = Column(String, nullable=True)
subscription_type = Column(String, default="free") # free, premium
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime(timezone=True), default=utc_now)
# Relationships
conversations = relationship("Conversation", back_populates="user")
@@ -38,8 +43,8 @@ class Conversation(Base):
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False)
started_at = Column(DateTime, default=datetime.utcnow)
ended_at = Column(DateTime, nullable=True)
started_at = Column(DateTime(timezone=True), default=utc_now)
ended_at = Column(DateTime(timezone=True), nullable=True)
duration_seconds = Column(Integer, default=0)
summary = Column(Text, nullable=True)
status = Column(String, default="active") # active, ended, processing
@@ -59,7 +64,7 @@ class Segment(Base):
conversation_id = Column(String, ForeignKey("conversations.id"), nullable=False)
audio_url = Column(String, nullable=True)
transcript_text = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime(timezone=True), default=utc_now)
processed = Column(Boolean, default=False)
topic_category = Column(String, nullable=True)
agent_response = Column(Text, nullable=True)
@@ -79,7 +84,7 @@ class Chapter(Base):
order_index = Column(Integer, nullable=False)
status = Column(String, default="draft") # draft, completed
images = Column(JSON, nullable=True) # 图片 URL 列表
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
category = Column(String, nullable=True) # 章节分类
is_new = Column(Boolean, default=True) # 是否为新内容(未读)
source_segments = Column(JSON, nullable=True) # 来源 segment IDs 列表
@@ -98,7 +103,7 @@ class Book(Base):
total_pages = Column(Integer, default=0)
total_words = Column(Integer, default=0)
cover_image_url = Column(String, nullable=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
has_update = Column(Boolean, default=False) # 是否有新内容
last_update_chapter_id = Column(String, nullable=True) # 最近更新的章节 ID
@@ -116,7 +121,7 @@ class MemoirState(Base):
current_stage = Column(String, default="childhood") # 当前阶段
covered_stages = Column(JSON, default=list) # 已完成阶段列表
slots = Column(JSON, nullable=False) # 各阶段 slot 信息
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
# Relationships
user = relationship("User", back_populates="memoir_state")
@@ -129,8 +134,8 @@ class RefreshToken(Base):
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
token = Column(String, unique=True, nullable=False, index=True) # 刷新令牌(唯一)
expires_at = Column(DateTime, nullable=False) # 过期时间30天后
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime(timezone=True), nullable=False) # 过期时间30天后
created_at = Column(DateTime(timezone=True), default=utc_now)
is_revoked = Column(Boolean, default=False) # 是否已撤销
# Relationships

View File

@@ -4,7 +4,26 @@ version: '3.8'
# 使用方法: docker-compose -f docker-compose.dev.yml up -d
services:
# Redis 服务
# PostgreSQL 数据库(使用最新版 17
postgres:
image: postgres:17-alpine
container_name: life-echo-postgres-dev
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: life_echo
volumes:
- postgres_data_dev:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Redis 服务(用于会话存储和 Celery
redis:
image: redis:7-alpine
container_name: life-echo-redis-dev
@@ -25,5 +44,7 @@ networks:
name: life-echo-dev
volumes:
postgres_data_dev:
driver: local
redis_data_dev:
driver: local

View File

@@ -1,6 +1,32 @@
version: '3.8'
services:
# PostgreSQL 数据库(使用最新版 17
postgres:
image: postgres:17-alpine
container_name: life-echo-postgres
ports:
- "5432:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-life_echo}
volumes:
- postgres_data:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- life-echo-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Redis 服务(用于会话存储和 Celery 消息队列)
redis:
image: redis:7-alpine
@@ -36,11 +62,12 @@ services:
env_file:
- .env.prod
environment:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
- REDIS_URL=redis://redis:6379/0
volumes:
- ./life_echo.db:/app/life_echo.db
restart: always
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
@@ -48,7 +75,7 @@ services:
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
start_period: 15s
networks:
- life-echo-network
logging:
@@ -68,11 +95,12 @@ services:
env_file:
- .env.prod
environment:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
- REDIS_URL=redis://redis:6379/0
volumes:
- ./life_echo.db:/app/life_echo.db
restart: always
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
api:
@@ -96,9 +124,12 @@ services:
# env_file:
# - .env.prod
# environment:
# - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/life_echo
# - REDIS_URL=redis://redis:6379/0
# restart: always
# depends_on:
# postgres:
# condition: service_healthy
# redis:
# condition: service_healthy
# networks:
@@ -130,5 +161,7 @@ networks:
driver: bridge
volumes:
postgres_data:
driver: local
redis_data:
driver: local

View File

@@ -61,7 +61,14 @@ logger.info("数据库初始化完成")
# 记录关键配置信息
logger.info("=== 应用配置信息 ===")
logger.info(f"数据库连接: {os.getenv('DATABASE_URL', 'sqlite:///./life_echo.db')}")
db_url = os.getenv('DATABASE_URL', 'postgresql://postgres:postgres@localhost:5432/life_echo')
# 隐藏密码
if '@' in db_url:
parts = db_url.split('@')
masked_url = parts[0].rsplit(':', 1)[0] + ':***@' + parts[1]
else:
masked_url = db_url
logger.info(f"数据库连接: {masked_url}")
logger.info(f"JWT 算法: {os.getenv('ALGORITHM', 'HS256')}")
logger.info(f"访问令牌过期时间: {os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES', '120')} 分钟")

View File

@@ -10,8 +10,12 @@ langchain-openai==0.2.0
# Database
sqlalchemy==2.0.36
aiosqlite==0.20.0
greenlet>=3.3.0
# PostgreSQL drivers
asyncpg>=0.29.0
psycopg[binary]>=3.1.0
# SQLite (optional, for local dev without Docker)
aiosqlite==0.20.0
# Redis for session storage
redis>=5.0.0