diff --git a/alembic.ini b/alembic.ini new file mode 100755 index 0000000..0effa0f --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/migrations/README.md b/migrations/README.md new file mode 100755 index 0000000..dd4caa9 --- /dev/null +++ b/migrations/README.md @@ -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。 diff --git a/migrations/env.py b/migrations/env.py new file mode 100755 index 0000000..21b4f24 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100755 index 0000000..6d92c27 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/0001_initial_foundation.py b/migrations/versions/0001_initial_foundation.py new file mode 100755 index 0000000..fe3e7c7 --- /dev/null +++ b/migrations/versions/0001_initial_foundation.py @@ -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.