feat(db): initial migration with pgvector and the eight foundation tables
Creates the vector extension plus id_user, id_user_identity, id_consent, bl_quota_rule, bl_quota, bl_ledger, tk_task and ob_ai_call using the domain prefixes of 架构文档 §6.1, and seeds the quota rules. Reversible: verified by an upgrade/downgrade/upgrade round-trip, which CI now runs on every push.
This commit is contained in:
Executable
+40
@@ -0,0 +1,40 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
# The URL is read from DATABASE_URL via packages.shared.config in migrations/env.py.
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
# `migrations/` — Alembic
|
||||
|
||||
异步引擎(asyncpg)+ pgvector。`Base.metadata` 来自 `packages/shared/models.py`。
|
||||
|
||||
```bash
|
||||
make migrate # upgrade head
|
||||
make makemigration m="add kb tables" # autogenerate
|
||||
uv run alembic downgrade -1 # 回滚一步
|
||||
```
|
||||
|
||||
## 规矩
|
||||
|
||||
1. **迁移必须向后兼容**(架构 §14.3):加列不删列、先双写后切读。发布流程是
|
||||
「先迁移,后发布」,所以新迁移必须能和**旧版应用代码**共存,否则回滚时数据
|
||||
不兼容。
|
||||
2. **必须可逆,或在文件头明确标注不可逆并说明原因**(§16.2 迁移检查清单)。
|
||||
CI 会跑 `upgrade → downgrade base → upgrade`。
|
||||
3. **表名必须带域前缀**:`id_` / `pf_` / `rp_` / `kb_` / `ag_` / `tr_` / `bl_` /
|
||||
`tk_` / `cp_` / `ob_`。单库多域,前缀就是将来拆库的边界(§6.1)。
|
||||
4. **主键用雪花 ID(bigint)**,不用自增(枚举攻击 + 分库冲突),不用 UUID
|
||||
作主键(索引局部性差)。
|
||||
5. **不要存人脸原图,也不要存可反推生物特征的数据**(TD-20 / CR-02)。诊断结果
|
||||
只存分类标签与色值等派生量。
|
||||
6. 需要过滤的字段一律提升为列;JSONB 只用于「结构会演进且不需要索引全部字段」
|
||||
的场景(§6.1 原则 6)。
|
||||
7. 配置类数据(配额规则、色板、路由)用迁移种子进表,不要硬编码进代码
|
||||
(NFR-MAINT-05)。
|
||||
|
||||
## 已有迁移
|
||||
|
||||
| 版本 | 内容 |
|
||||
| --- | --- |
|
||||
| `0001` | pgvector 扩展;`id_user` `id_user_identity` `id_consent`;`bl_quota_rule` `bl_quota` `bl_ledger`;`tk_task`;`ob_ai_call`;配额规则种子数据 |
|
||||
|
||||
§6.2 里其余的表(`pf_` 画像与记忆、`rp_` 报告、`kb_` 知识库、`ag_` 会话、
|
||||
`tr_` 换装、`cp_` 合规)尚未创建,属于 M2~M4。
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
"""Alembic environment — async engine + pgvector.
|
||||
|
||||
NFR-MAINT-06 / 架构文档 §14.3: 迁移必须向后兼容(加列不删列、先双写后切读),
|
||||
并且必须可逆或明确标注不可逆。CI runs upgrade → downgrade → upgrade.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from packages.shared.config import get_settings
|
||||
from packages.shared.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _include_object(
|
||||
obj: object, name: str | None, type_: str, reflected: bool, compare_to: object
|
||||
) -> bool:
|
||||
# pgvector installs its own tables/types; never autogenerate against them.
|
||||
return not (type_ == "table" and name in {"vector", "spatial_ref_sys"})
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
include_object=_include_object,
|
||||
)
|
||||
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,
|
||||
include_object=_include_object,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
Reversible: yes # 若不可逆必须改为 no 并在此说明原因 (架构文档 §16.2 迁移检查)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
"""initial foundation: pgvector + identity / billing / task / cost tables
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-08-13
|
||||
|
||||
Reversible: yes
|
||||
|
||||
Covers 架构文档 §6.2 for the subset the engineering foundation needs:
|
||||
identity id_user, id_user_identity, id_consent
|
||||
billing bl_quota_rule, bl_quota, bl_ledger
|
||||
task tk_task
|
||||
观测与成本 ob_ai_call
|
||||
|
||||
The pgvector extension is created here (not with the kb_ tables) so that every
|
||||
environment — including CI — proves it is available before any embedding work
|
||||
starts. Missing extensions are the classic "works on my laptop" failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
|
||||
# ---------------- identity ----------------
|
||||
op.create_table(
|
||||
"id_user",
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="active"),
|
||||
sa.Column("nickname", sa.String(length=64), nullable=True),
|
||||
sa.Column("avatar_key", sa.String(length=255), nullable=True),
|
||||
sa.Column(
|
||||
"register_source", sa.String(length=32), nullable=False, server_default="unknown"
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("last_active_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
)
|
||||
op.create_index("ix_id_user_status", "id_user", ["status"])
|
||||
|
||||
op.create_table(
|
||||
"id_user_identity",
|
||||
sa.Column("identity_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("provider", sa.String(length=32), nullable=False),
|
||||
sa.Column("union_id", sa.String(length=128), nullable=False),
|
||||
sa.Column("open_id", sa.String(length=128), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.PrimaryKeyConstraint("identity_id"),
|
||||
sa.UniqueConstraint("provider", "union_id", name="uq_id_user_identity_provider_union"),
|
||||
)
|
||||
op.create_index("ix_id_user_identity_user", "id_user_identity", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"id_consent",
|
||||
sa.Column("consent_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("consent_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("doc_version", sa.String(length=32), nullable=False),
|
||||
sa.Column(
|
||||
"granted_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("client_ip", sa.String(length=64), nullable=True),
|
||||
sa.Column("ua", sa.String(length=512), nullable=True),
|
||||
sa.PrimaryKeyConstraint("consent_id"),
|
||||
)
|
||||
op.create_index("ix_id_consent_user_type", "id_consent", ["user_id", "consent_type"])
|
||||
|
||||
# ---------------- billing ----------------
|
||||
op.create_table(
|
||||
"bl_quota_rule",
|
||||
sa.Column("quota_key", sa.String(length=64), nullable=False),
|
||||
sa.Column("period_type", sa.String(length=16), nullable=False),
|
||||
sa.Column("base_limit", sa.Integer(), nullable=False),
|
||||
sa.Column("task_bonus_max", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("hard_cap", sa.Integer(), nullable=False),
|
||||
sa.Column("description", sa.String(length=255), nullable=True),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.PrimaryKeyConstraint("quota_key"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"bl_quota",
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("quota_key", sa.String(length=64), nullable=False),
|
||||
sa.Column("period_key", sa.String(length=32), nullable=False),
|
||||
sa.Column("used", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("limit_value", sa.Integer(), nullable=False),
|
||||
sa.Column("bonus_value", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.PrimaryKeyConstraint("user_id", "quota_key", "period_key", name="pk_bl_quota"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"bl_ledger",
|
||||
sa.Column("tx_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("delta", sa.Integer(), nullable=False),
|
||||
sa.Column("tx_type", sa.String(length=24), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False, server_default="committed"),
|
||||
sa.Column("reason", sa.String(length=128), nullable=True),
|
||||
sa.Column("ref_type", sa.String(length=32), nullable=True),
|
||||
sa.Column("ref_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("quota_key", sa.String(length=64), nullable=True),
|
||||
sa.Column("period_key", sa.String(length=32), nullable=True),
|
||||
sa.Column("balance_after", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("idempotency_key", sa.String(length=128), nullable=False),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.PrimaryKeyConstraint("tx_id"),
|
||||
sa.UniqueConstraint("idempotency_key", name="uq_bl_ledger_idempotency_key"),
|
||||
)
|
||||
op.create_index("ix_bl_ledger_user_created", "bl_ledger", ["user_id", "created_at"])
|
||||
|
||||
# ---------------- task ----------------
|
||||
op.create_table(
|
||||
"tk_task",
|
||||
sa.Column("task_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("task_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("status", sa.String(length=16), nullable=False),
|
||||
sa.Column("progress", sa.SmallInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("stage_text", sa.String(length=128), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=128), nullable=False),
|
||||
sa.Column("payload", postgresql.JSONB(), nullable=False, server_default="{}"),
|
||||
sa.Column("result", postgresql.JSONB(), nullable=True),
|
||||
sa.Column("error_code", sa.Integer(), nullable=True),
|
||||
sa.Column("error_message", sa.String(length=512), nullable=True),
|
||||
sa.Column("retry_count", sa.SmallInteger(), nullable=False, server_default="0"),
|
||||
sa.Column("points_reserved", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("reserve_tx_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column(
|
||||
"submitted_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("eta_seconds", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("task_id"),
|
||||
sa.UniqueConstraint("idempotency_key", name="uq_tk_task_idempotency_key"),
|
||||
)
|
||||
op.create_index("ix_tk_task_user_status", "tk_task", ["user_id", "status"])
|
||||
|
||||
# ---------------- 观测与成本 ----------------
|
||||
op.create_table(
|
||||
"ob_ai_call",
|
||||
sa.Column("call_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("scene", sa.String(length=48), nullable=False),
|
||||
sa.Column("provider", sa.String(length=32), nullable=False),
|
||||
sa.Column("model", sa.String(length=64), nullable=False),
|
||||
sa.Column("prompt_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("prompt_version", sa.String(length=16), nullable=True),
|
||||
sa.Column("tokens_in", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("tokens_out", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("tokens_cached", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("latency_ms", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("cost_cents", sa.Numeric(14, 6), nullable=False, server_default="0"),
|
||||
sa.Column("success", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("error_code", sa.String(length=64), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("ref_type", sa.String(length=32), nullable=True),
|
||||
sa.Column("ref_id", sa.String(length=64), nullable=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
sa.PrimaryKeyConstraint("call_id"),
|
||||
)
|
||||
op.create_index("ix_ob_ai_call_created", "ob_ai_call", ["created_at"])
|
||||
op.create_index("ix_ob_ai_call_scene_created", "ob_ai_call", ["scene", "created_at"])
|
||||
op.create_index("ix_ob_ai_call_user_created", "ob_ai_call", ["user_id", "created_at"])
|
||||
|
||||
# FR-BILL-04: 配额规则是数据不是代码,改配置不发版。
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"bl_quota_rule",
|
||||
sa.column("quota_key", sa.String),
|
||||
sa.column("period_type", sa.String),
|
||||
sa.column("base_limit", sa.Integer),
|
||||
sa.column("task_bonus_max", sa.Integer),
|
||||
sa.column("hard_cap", sa.Integer),
|
||||
sa.column("description", sa.String),
|
||||
),
|
||||
[
|
||||
{
|
||||
"quota_key": "agent_chat",
|
||||
"period_type": "day",
|
||||
"base_limit": 20,
|
||||
"task_bonus_max": 10,
|
||||
"hard_cap": 50,
|
||||
"description": "穿搭 Agent 每日对话轮次",
|
||||
},
|
||||
{
|
||||
"quota_key": "report_basic",
|
||||
"period_type": "month",
|
||||
"base_limit": 1,
|
||||
"task_bonus_max": 1,
|
||||
"hard_cap": 3,
|
||||
"description": "初级美学报告生成次数",
|
||||
},
|
||||
{
|
||||
"quota_key": "tryon",
|
||||
"period_type": "day",
|
||||
"base_limit": 3,
|
||||
"task_bonus_max": 3,
|
||||
"hard_cap": 10,
|
||||
"description": "智能试穿出图次数",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ob_ai_call_user_created", table_name="ob_ai_call")
|
||||
op.drop_index("ix_ob_ai_call_scene_created", table_name="ob_ai_call")
|
||||
op.drop_index("ix_ob_ai_call_created", table_name="ob_ai_call")
|
||||
op.drop_table("ob_ai_call")
|
||||
|
||||
op.drop_index("ix_tk_task_user_status", table_name="tk_task")
|
||||
op.drop_table("tk_task")
|
||||
|
||||
op.drop_index("ix_bl_ledger_user_created", table_name="bl_ledger")
|
||||
op.drop_table("bl_ledger")
|
||||
op.drop_table("bl_quota")
|
||||
op.drop_table("bl_quota_rule")
|
||||
|
||||
op.drop_index("ix_id_consent_user_type", table_name="id_consent")
|
||||
op.drop_table("id_consent")
|
||||
op.drop_index("ix_id_user_identity_user", table_name="id_user_identity")
|
||||
op.drop_table("id_user_identity")
|
||||
op.drop_index("ix_id_user_status", table_name="id_user")
|
||||
op.drop_table("id_user")
|
||||
|
||||
# The extension is left installed on purpose: dropping it would break any
|
||||
# other schema in the same database that depends on it.
|
||||
Reference in New Issue
Block a user