fix:文明
This commit is contained in:
40
server/alembic/README.md
Normal file
40
server/alembic/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Alembic(数据库迁移)
|
||||
|
||||
## 1. 前置
|
||||
|
||||
- 在 `server/` 下准备 `.env.dev`(或系统环境变量),至少包含:
|
||||
- `DATABASE_URL=mysql+aiomysql://...`
|
||||
|
||||
> 注意:本仓库推荐使用 Python 虚拟环境(venv)。示例以 `server/.venv` 为准。
|
||||
|
||||
---
|
||||
|
||||
## 2. 安装依赖(一次性)
|
||||
|
||||
在仓库根目录:
|
||||
|
||||
```bash
|
||||
python3 -m venv server/.venv
|
||||
source server/.venv/bin/activate
|
||||
pip install -r server/requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 常用命令
|
||||
|
||||
在 `server/` 目录运行:
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
alembic -c alembic.ini history
|
||||
alembic -c alembic.ini upgrade head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 说明
|
||||
|
||||
- 连接串由 `alembic/env.py` 从环境变量 `DATABASE_URL`(或 `app/core/config.py`)读取。
|
||||
- 初始迁移版本为:`0001_init_content_tables`(创建推荐系统最小内容表与画像表)。
|
||||
|
||||
BIN
server/alembic/__pycache__/env.cpython-313.pyc
Normal file
BIN
server/alembic/__pycache__/env.cpython-313.pyc
Normal file
Binary file not shown.
137
server/alembic/env.py
Normal file
137
server/alembic/env.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
# 让 alembic 在 `server/` 下运行时也能 import app.*
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1] # .../server/alembic -> .../server
|
||||
sys.path.append(str(SERVER_DIR))
|
||||
|
||||
from app.db.base import Base # noqa: E402
|
||||
import app.db.models # noqa: F401,E402 # 确保模型被导入,metadata 完整
|
||||
|
||||
# Alembic Config 对象
|
||||
config = context.config
|
||||
|
||||
# 配置日志
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# 目标 metadata(autogenerate 依赖)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _read_env_kv(env_path: Path) -> dict[str, str]:
|
||||
"""
|
||||
读取 .env 文件中的 KEY=VALUE。
|
||||
|
||||
说明:
|
||||
- 迁移阶段只需要 DATABASE_URL,不应因为 Redis/Celery 等配置缺失而失败
|
||||
- 这里不依赖 pydantic-settings 的 Settings 校验,避免“缺字段导致迁移不可用”
|
||||
"""
|
||||
|
||||
data: dict[str, str] = {}
|
||||
if not env_path.exists():
|
||||
return data
|
||||
for raw in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if k:
|
||||
data[k] = v
|
||||
return data
|
||||
|
||||
|
||||
def _get_database_url() -> str:
|
||||
"""
|
||||
获取数据库连接串。
|
||||
|
||||
约定:
|
||||
- 优先读取环境变量 `DATABASE_URL`
|
||||
- 若未设置,则按 `APP_ENV`(默认 dev)读取 `server/.env.dev` 或 `server/.env.prod`
|
||||
|
||||
注意:迁移阶段仅依赖 DATABASE_URL;不应强制要求 REDIS_URL / CELERY_BROKER_URL 等配置存在。
|
||||
"""
|
||||
|
||||
# 允许在 alembic 命令时临时覆盖
|
||||
env_url = os.getenv("DATABASE_URL")
|
||||
if env_url:
|
||||
return env_url
|
||||
|
||||
app_env = (os.getenv("APP_ENV") or "dev").strip() or "dev"
|
||||
env_file = SERVER_DIR / (".env.prod" if app_env == "prod" else ".env.dev")
|
||||
kv = _read_env_kv(env_file)
|
||||
url = kv.get("DATABASE_URL")
|
||||
if url:
|
||||
return url
|
||||
|
||||
raise RuntimeError(
|
||||
"缺少 DATABASE_URL:请设置环境变量 DATABASE_URL,或在 server/.env.dev(或 .env.prod)中配置 DATABASE_URL。"
|
||||
)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""离线模式:生成 SQL 脚本,不连接数据库。"""
|
||||
|
||||
url = _get_database_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""在线模式:在已有连接上执行迁移。"""
|
||||
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_migrations_online() -> None:
|
||||
"""在线模式:使用异步引擎执行迁移。"""
|
||||
|
||||
url = _get_database_url()
|
||||
config.set_main_option("sqlalchemy.url", url)
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section) or {},
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
|
||||
27
server/alembic/script.py.mako
Normal file
27
server/alembic/script.py.mako
Normal file
@@ -0,0 +1,27 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
||||
147
server/alembic/versions/0001_init_content_tables.py
Normal file
147
server/alembic/versions/0001_init_content_tables.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""init content tables
|
||||
|
||||
Revision ID: 0001_init_content_tables
|
||||
Revises:
|
||||
Create Date: 2026-02-01
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0001_init_content_tables"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"contents",
|
||||
sa.Column(
|
||||
"content_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
comment="文案唯一 ID(自增;文案微调时保持不变)",
|
||||
),
|
||||
sa.Column("text_en", sa.Text(), nullable=True, comment="英文文案(可空;若为空则必须提供 text_tc)"),
|
||||
sa.Column("text_tc", sa.Text(), nullable=True, comment="繁体中文文案(可空;若为空则必须提供 text_en)"),
|
||||
sa.Column("author_id", sa.String(length=255), nullable=True, comment="作者/来源 ID(可空;用于多样性与频控)"),
|
||||
sa.Column("template_id", sa.String(length=255), nullable=True, comment="模板 ID(可空;用于多样性与频控)"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="更新时间"),
|
||||
sa.CheckConstraint(
|
||||
"(text_en IS NOT NULL) OR (text_tc IS NOT NULL)",
|
||||
name="chk_contents_text_present",
|
||||
),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
op.create_index("idx_contents_author_id", "contents", ["author_id"], unique=False)
|
||||
op.create_index("idx_contents_template_id", "contents", ["template_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"content_profiles",
|
||||
sa.Column(
|
||||
"content_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
sa.ForeignKey("contents.content_id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
comment="FK -> contents.content_id",
|
||||
),
|
||||
sa.Column(
|
||||
"stage",
|
||||
sa.Enum("general", "expecting", "parenting", "unknown", name="content_stage"),
|
||||
server_default="general",
|
||||
nullable=False,
|
||||
comment="母职阶段定位(general/expecting/parenting/unknown)",
|
||||
),
|
||||
sa.Column("emotion_score", sa.Numeric(3, 2), nullable=True, comment="情绪调性 0~1;NULL 表示 general"),
|
||||
sa.Column(
|
||||
"context_suitability_json",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
comment="各 context 的适配度(JSON:0/0.5/1;必须包含 5 个 key)",
|
||||
),
|
||||
sa.Column(
|
||||
"need_suitability_json",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
comment="各 need 的适配度(JSON:0/0.5/1;必须包含 5 个 key)",
|
||||
),
|
||||
sa.Column(
|
||||
"personalization_power",
|
||||
sa.SmallInteger(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="个性化力度(约定只允许 0/5/10,分别映射 0/0.5/1)",
|
||||
),
|
||||
sa.Column(
|
||||
"review_confidence",
|
||||
sa.Numeric(3, 2),
|
||||
nullable=True,
|
||||
comment="标注置信度 0~1;NULL 表示由推荐侧按 0.7 兜底",
|
||||
),
|
||||
sa.Column(
|
||||
"is_safe_pool",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("0"),
|
||||
nullable=False,
|
||||
comment="是否属于通用安全池(L3 兜底)",
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="画像更新时间"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
op.create_index("idx_profiles_is_safe_pool", "content_profiles", ["is_safe_pool"], unique=False)
|
||||
op.create_index("idx_profiles_personalization_power", "content_profiles", ["personalization_power"], unique=False)
|
||||
op.create_index("idx_profiles_stage", "content_profiles", ["stage"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"content_risk_flags",
|
||||
sa.Column(
|
||||
"id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
comment="主键",
|
||||
),
|
||||
sa.Column(
|
||||
"content_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
sa.ForeignKey("contents.content_id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="FK -> contents.content_id",
|
||||
),
|
||||
sa.Column(
|
||||
"flag",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="风险标记(unsafe_for_* / block_* / soft_*)",
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"),
|
||||
sa.UniqueConstraint("content_id", "flag", name="uniq_content_flag"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
op.create_index("idx_content_id", "content_risk_flags", ["content_id"], unique=False)
|
||||
op.create_index("idx_flag", "content_risk_flags", ["flag"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_flag", table_name="content_risk_flags")
|
||||
op.drop_index("idx_content_id", table_name="content_risk_flags")
|
||||
op.drop_table("content_risk_flags")
|
||||
|
||||
op.drop_index("idx_profiles_stage", table_name="content_profiles")
|
||||
op.drop_index("idx_profiles_personalization_power", table_name="content_profiles")
|
||||
op.drop_index("idx_profiles_is_safe_pool", table_name="content_profiles")
|
||||
op.drop_table("content_profiles")
|
||||
|
||||
op.drop_index("idx_contents_template_id", table_name="contents")
|
||||
op.drop_index("idx_contents_author_id", table_name="contents")
|
||||
op.drop_table("contents")
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user