From 28127949dbd977a4ef6cbc7281d824947103c90d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Thu, 10 Sep 2026 10:00:17 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E4=B8=8E=E9=80=9A=E7=94=A8=20DTO=EF=BC=88WP-?= =?UTF-8?q?01=20=E7=AC=AC=203=E3=80=814=20=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通用 DTO:Money(最小货币单位整数)、CursorPage 泛型、9 个平台 ID Annotated 类型(前缀+UUIDv7/v4 正则,与 openapi 契约对齐) - db 基础:naming_convention Base(pk_/uq_/ix_/fk_,AGENTS 8.6)+ async engine + 事务作用域 session - 首批 6 张表 typed mapping:user/personal_domain/session/service_account/audit_event/outbox_event;Session ID 为 CSPRNG opaque;审计与 Outbox 不可变 - Alembic async 迁移(含表注释);docker 起 hunjing-postgres 并 upgrade 验证:7 表、约束/索引命名全部符合约定 - 按 database-design skill 清单交叉检查:外键索引全覆盖(复合唯一最左前缀)、审计聚合时间线与 Outbox 投递扫描均为专用复合索引 - 集成测试 8 项(唯一约束、FK restrict/cascade、检查约束、同事务提交)连 PG 测试库运行;pytest 15 项、ruff、mypy strict 全绿 --- backend/alembic.ini | 149 ++++++++++++++++++ backend/alembic/README | 1 + backend/alembic/env.py | 94 +++++++++++ backend/alembic/script.py.mako | 28 ++++ ...始基线_身份_会话_服务账号_审计与_outbox.py | 125 +++++++++++++++ backend/app/core/dto.py | 43 +++++ backend/app/db/__init__.py | 1 + backend/app/db/base.py | 51 ++++++ backend/app/models.py | 140 ++++++++++++++++ backend/pyproject.toml | 5 + backend/tests/conftest.py | 26 +++ backend/tests/test_models.py | 143 +++++++++++++++++ backend/uv.lock | 14 ++ docker/docker-compose.yml | 3 +- 14 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/e16a40566b45_初始基线_身份_会话_服务账号_审计与_outbox.py create mode 100644 backend/app/core/dto.py create mode 100644 backend/app/db/__init__.py create mode 100644 backend/app/db/base.py create mode 100644 backend/app/models.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_models.py diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..df80d65 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# 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 = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[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/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..0ef113c --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,94 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 模型元数据(autogenerate 真源);连接串来自环境(统一 PostgreSQL,ADR-024) +import os + +from app import models # noqa: F401 确保模型注册到 metadata +from app.core.config import get_settings +from app.db.base import Base + +target_metadata = Base.metadata +db_url = os.environ.get("DATABASE_URL", get_settings().database_url) +config.set_main_option("sqlalchemy.url", db_url) + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + 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: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/e16a40566b45_初始基线_身份_会话_服务账号_审计与_outbox.py b/backend/alembic/versions/e16a40566b45_初始基线_身份_会话_服务账号_审计与_outbox.py new file mode 100644 index 0000000..5a2580a --- /dev/null +++ b/backend/alembic/versions/e16a40566b45_初始基线_身份_会话_服务账号_审计与_outbox.py @@ -0,0 +1,125 @@ +"""初始基线:身份、会话、服务账号、审计与 Outbox + +Revision ID: e16a40566b45 +Revises: +Create Date: 2026-09-10 09:55:35.167917 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'e16a40566b45' +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('audit_event', + sa.Column('id', sa.String(), nullable=False), + sa.Column('occurred_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('actor_type', sa.String(), nullable=False), + sa.Column('actor_id', sa.String(), nullable=False), + sa.Column('action', sa.String(), nullable=False), + sa.Column('aggregate_type', sa.String(), nullable=False), + sa.Column('aggregate_id', sa.String(), nullable=False), + sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('trace_id', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_event')) + ) + op.create_index('ix_audit_event_aggregate', 'audit_event', ['aggregate_type', 'aggregate_id', 'occurred_at'], unique=False) + op.create_index('ix_audit_event_occurred_at', 'audit_event', ['occurred_at'], unique=False) + op.create_table('outbox_event', + sa.Column('id', sa.String(), nullable=False), + sa.Column('occurred_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('aggregate_type', sa.String(), nullable=False), + sa.Column('aggregate_id', sa.String(), nullable=False), + sa.Column('event_type', sa.String(), nullable=False), + sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('status', sa.String(), nullable=False), + sa.Column('attempts', sa.Integer(), nullable=False), + sa.Column('delivered_at', sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("status IN ('pending', 'delivered', 'failed')", name=op.f('ck_outbox_event_status_values')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_outbox_event')) + ) + op.create_index('ix_outbox_event_aggregate', 'outbox_event', ['aggregate_type', 'aggregate_id'], unique=False) + op.create_index('ix_outbox_event_status_occurred', 'outbox_event', ['status', 'occurred_at'], unique=False) + op.create_table('user', + sa.Column('id', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('display_name', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_user')), + sa.UniqueConstraint('email', name=op.f('uq_user_email')) + ) + op.create_table('personal_domain', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('data_region', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_personal_domain_user'), ondelete='restrict'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_personal_domain')) + ) + op.create_index(op.f('ix_personal_domain_user_id'), 'personal_domain', ['user_id'], unique=False) + op.create_table('session', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_session_user'), ondelete='cascade'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_session')) + ) + op.create_index(op.f('ix_session_expires_at'), 'session', ['expires_at'], unique=False) + op.create_index(op.f('ix_session_user_id'), 'session', ['user_id'], unique=False) + op.create_table('service_account', + sa.Column('id', sa.String(), nullable=False), + sa.Column('domain_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('key_prefix', sa.String(), nullable=False), + sa.Column('key_hash', sa.String(), nullable=False), + sa.Column('scopes', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['domain_id'], ['personal_domain.id'], name=op.f('fk_service_account_personal_domain'), ondelete='cascade'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_service_account')) + ) + op.create_index(op.f('ix_service_account_key_prefix'), 'service_account', ['key_prefix'], unique=False) + op.create_index('uq_service_account_domain_name', 'service_account', ['domain_id', 'name'], unique=True) + # ### end Alembic commands ### + op.execute("COMMENT ON TABLE \"user\" IS '用户(WP-01):多凭证与账户根'") + op.execute("COMMENT ON TABLE personal_domain IS '个人数据域:数据归属与授权边界,绑定固定数据区域'") + op.execute("COMMENT ON TABLE session IS 'Web 会话:ID 为 CSPRNG opaque,仅存校验值'") + op.execute("COMMENT ON TABLE service_account IS '服务账号:API key 明文只显示一次,只存哈希与前缀'") + op.execute("COMMENT ON TABLE audit_event IS '不可变审计链:与权威状态、Outbox 同事务提交'") + op.execute("COMMENT ON TABLE outbox_event IS 'Transactional Outbox:已提交领域事实的可靠投递队列'") + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('uq_service_account_domain_name', table_name='service_account') + op.drop_index(op.f('ix_service_account_key_prefix'), table_name='service_account') + op.drop_table('service_account') + op.drop_index(op.f('ix_session_user_id'), table_name='session') + op.drop_index(op.f('ix_session_expires_at'), table_name='session') + op.drop_table('session') + op.drop_index(op.f('ix_personal_domain_user_id'), table_name='personal_domain') + op.drop_table('personal_domain') + op.drop_table('user') + op.drop_index('ix_outbox_event_status_occurred', table_name='outbox_event') + op.drop_index('ix_outbox_event_aggregate', table_name='outbox_event') + op.drop_table('outbox_event') + op.drop_index('ix_audit_event_occurred_at', table_name='audit_event') + op.drop_index('ix_audit_event_aggregate', table_name='audit_event') + op.drop_table('audit_event') + # ### end Alembic commands ### diff --git a/backend/app/core/dto.py b/backend/app/core/dto.py new file mode 100644 index 0000000..ba50236 --- /dev/null +++ b/backend/app/core/dto.py @@ -0,0 +1,43 @@ +"""通用 DTO:与 openapi/openapi.yaml 契约对齐的 Pydantic 类型。 + +对齐由 tests/test_dto_contract.py 用 bundled 契约校验关键字段; +禁止在此复制会漂移的完整值集(以契约为准)。 +""" + +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field + + +def _id_pattern(prefix: str, version: int) -> str: + ver = "7" if version == 7 else "4" + tail = r"[0-9a-f]{12}-[0-9a-f]{4}-" + ver + r"[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + return f"^{prefix}" + tail + + +UserId = Annotated[str, Field(pattern=_id_pattern("user_", 7))] +PersonalDomainId = Annotated[str, Field(pattern=_id_pattern("domain_", 7))] +ConversationId = Annotated[str, Field(pattern=_id_pattern("conversation_", 7))] +BranchId = Annotated[str, Field(pattern=_id_pattern("branch_", 7))] +MessageId = Annotated[str, Field(pattern=_id_pattern("message_", 7))] +ContentBlockId = Annotated[str, Field(pattern=_id_pattern("block_", 7))] +RunId = Annotated[str, Field(pattern=_id_pattern("run_", 7))] +TurnId = Annotated[str, Field(pattern=_id_pattern("turn_", 7))] +RequestId = Annotated[str, Field(pattern=_id_pattern("request_", 4))] + + +class Money(BaseModel): + """金额:最小货币单位整数 + ISO 4217 币种;禁止浮点(契约第 4 节)。""" + + model_config = ConfigDict(extra="forbid") + + amount_minor: int = Field(ge=0) + currency: str = Field(pattern=r"^[A-Z]{3}$") + + +class CursorPage[T](BaseModel): + """opaque cursor 分页(契约第 4.1 节);客户端不得解析 cursor。""" + + data: list[T] + next_cursor: str | None + has_more: bool diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..1d81515 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1 @@ +"""数据库层:基础、引擎与命名约定。""" diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..297d7db --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,51 @@ +"""数据库基础:命名约定、异步引擎与会话。 + +索引/约束命名按 AGENTS.md 8.6(pk_/uk_/idx_/fk_ + 表名); +主库统一 PostgreSQL(ADR-024),不提供 SQLite 档。 +""" + +from collections.abc import AsyncIterator + +from sqlalchemy import MetaData +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.core.config import get_settings + +naming_convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=naming_convention) + + +def create_engine() -> AsyncEngine: + return create_async_engine(get_settings().database_url, pool_pre_ping=True) + + +def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(engine, expire_on_commit=False) + + +async def session_scope( + factory: async_sessionmaker[AsyncSession], +) -> AsyncIterator[AsyncSession]: + """事务作用域:成功提交、异常回滚(权威状态与 Outbox 同事务,契约第 4.4 节)。""" + async with factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..e586599 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,140 @@ +"""首批领域模型:身份、会话、服务账号、审计与 Outbox(WP-01)。 + +- 主键为带前缀的 UUIDv7 文本(ADR-015);Session ID 为 CSPRNG opaque(秘密不用 UUID)。 +- 命名约定见 app/db/base.py(pk_/uq_/ix_/fk_,AGENTS.md 8.6)。 +- AuditEvent 与 OutboxEvent 不可变:无 updated_at,修正写新记录(契约第 4.4 节)。 +""" + +from datetime import datetime +from typing import Any + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.ids import new_id +from app.db.base import Base + + +class User(Base): + __tablename__ = "user" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("user_")) + email: Mapped[str] = mapped_column(String, nullable=False, unique=True) + display_name: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + +class PersonalDomain(Base): + __tablename__ = "personal_domain" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: new_id("domain_") + ) + user_id: Mapped[str] = mapped_column( + ForeignKey("user.id", ondelete="restrict"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String, nullable=False) + # 区域驻留(ADR-024 之前的数据驻留决策):默认区域,迁移走正式协议 + data_region: Mapped[str] = mapped_column(String, nullable=False, default="default") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + +class Session(Base): + """Web 会话:ID 为 CSPRNG opaque value,数据库只存校验值(ADR-015)。""" + + __tablename__ = "session" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column( + ForeignKey("user.id", ondelete="cascade"), nullable=False, index=True + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class ServiceAccount(Base): + """自动化账号:API key 明文只显示一次,只保存哈希、前缀、scope 与撤销状态。""" + + __tablename__ = "service_account" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: new_id("service_account_") + ) + domain_id: Mapped[str] = mapped_column( + ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False + ) + name: Mapped[str] = mapped_column(String, nullable=False) + key_prefix: Mapped[str] = mapped_column(String, nullable=False, index=True) + key_hash: Mapped[str] = mapped_column(String, nullable=False) + scopes: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + __table_args__ = (Index("uq_service_account_domain_name", "domain_id", "name", unique=True),) + + +class AuditEvent(Base): + """不可变审计链:状态与 Outbox 在同一事务提交(契约第 4.4 节)。""" + + __tablename__ = "audit_event" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("audit_")) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + actor_type: Mapped[str] = mapped_column(String, nullable=False) + actor_id: Mapped[str] = mapped_column(String, nullable=False) + action: Mapped[str] = mapped_column(String, nullable=False) + aggregate_type: Mapped[str] = mapped_column(String, nullable=False) + aggregate_id: Mapped[str] = mapped_column(String, nullable=False) + payload: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) + trace_id: Mapped[str | None] = mapped_column(String, nullable=True) + + __table_args__ = ( + # 聚合时间线是审计的主要查询路径(最左前缀覆盖聚合定位) + Index("ix_audit_event_aggregate", "aggregate_type", "aggregate_id", "occurred_at"), + Index("ix_audit_event_occurred_at", "occurred_at"), + ) + + +class OutboxEvent(Base): + """Transactional Outbox:已提交领域事实的可靠投递队列。""" + + __tablename__ = "outbox_event" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("event_")) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + aggregate_type: Mapped[str] = mapped_column(String, nullable=False) + aggregate_id: Mapped[str] = mapped_column(String, nullable=False) + event_type: Mapped[str] = mapped_column(String, nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False, default="pending") + attempts: Mapped[int] = mapped_column(nullable=False, default=0) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + __table_args__ = ( + CheckConstraint( + "status IN ('pending', 'delivered', 'failed')", + name="status_values", + ), + # 投递循环按状态+时间扫描待处理事件 + Index("ix_outbox_event_status_occurred", "status", "occurred_at"), + Index("ix_outbox_event_aggregate", "aggregate_type", "aggregate_id"), + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1f53d1e..1cc2229 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "alembic>=1.14", "asyncpg>=0.30", "uuid-utils>=0.10", + "greenlet>=3.5.5", ] [dependency-groups] @@ -37,6 +38,10 @@ target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "ASYNC"] +[tool.ruff.lint.per-file-ignores] +"alembic/*" = ["E402"] +"alembic/versions/*" = ["E501"] + [tool.mypy] python_version = "3.12" strict = true diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..130cd6a --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,26 @@ +"""pytest 夹具:统一连接 PostgreSQL 测试库(ADR-024,不用 SQLite 替代)。""" + +import os + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app import models # noqa: F401 注册模型 +from app.db.base import Base + +TEST_DATABASE_URL = os.environ.get( + "TEST_DATABASE_URL", + "postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing_test", +) + + +@pytest.fixture +async def db() -> async_sessionmaker[AsyncSession]: + """每个测试重建 schema:隔离且可重复。""" + engine = create_async_engine(TEST_DATABASE_URL) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + yield factory + await engine.dispose() diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..ce31fa7 --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,143 @@ +"""首批模型的集成测试:CRUD、约束与外键行为(运行于 PostgreSQL)。""" + +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.ids import new_id +from app.models import AuditEvent, OutboxEvent, PersonalDomain, ServiceAccount, Session, User + + +async def _make_user(db: async_sessionmaker[AsyncSession], email: str = "a@example.com") -> User: + async with db() as session: + user = User(email=email, display_name="测试用户") + session.add(user) + await session.commit() + await session.refresh(user) + return user + + +async def test_user_crud_and_id_prefix(db: async_sessionmaker[AsyncSession]) -> None: + user = await _make_user(db) + assert user.id.startswith("user_") + async with db() as session: + loaded = await session.get(User, user.id) + assert loaded is not None + assert loaded.email == "a@example.com" + assert loaded.created_at.tzinfo is not None # aware UTC + + +async def test_duplicate_email_rejected_by_unique(db: async_sessionmaker[AsyncSession]) -> None: + await _make_user(db) + async with db() as session: + session.add(User(email="a@example.com", display_name="重复")) + with pytest.raises(IntegrityError): + await session.commit() + + +async def test_personal_domain_requires_user(db: async_sessionmaker[AsyncSession]) -> None: + async with db() as session: + session.add(PersonalDomain(user_id=new_id("user_"), name="镜域")) + with pytest.raises(IntegrityError): + await session.commit() + + +async def test_delete_user_restricted_by_domain(db: async_sessionmaker[AsyncSession]) -> None: + user = await _make_user(db) + async with db() as session: + session.add(PersonalDomain(user_id=user.id, name="镜域")) + await session.commit() + async with db() as session: + loaded = await session.get(User, user.id) + assert loaded is not None + await session.delete(loaded) + with pytest.raises(IntegrityError): + await session.commit() + + +async def test_session_cascades_with_user(db: async_sessionmaker[AsyncSession]) -> None: + user = await _make_user(db) + async with db() as session: + session.add( + Session( + id="opaque-session-token", + user_id=user.id, + expires_at=datetime.now(UTC) + timedelta(days=7), + ) + ) + await session.commit() + async with db() as session: + loaded = await session.get(User, user.id) + assert loaded is not None + await session.delete(loaded) + await session.commit() + async with db() as session: + assert (await session.scalars(select(Session))).all() == [] + + +async def test_service_account_unique_domain_name(db: async_sessionmaker[AsyncSession]) -> None: + user = await _make_user(db) + async with db() as session: + domain = PersonalDomain(user_id=user.id, name="镜域") + session.add(domain) + await session.commit() + await session.refresh(domain) + async with db() as session: + session.add( + ServiceAccount( + domain_id=domain.id, name="ci", key_prefix="hjk_ci", key_hash="sha256:..." + ) + ) + await session.commit() + session.add( + ServiceAccount( + domain_id=domain.id, name="ci", key_prefix="hjk_ci2", key_hash="sha256:..." + ) + ) + with pytest.raises(IntegrityError): + await session.commit() + + +async def test_outbox_status_check_constraint(db: async_sessionmaker[AsyncSession]) -> None: + async with db() as session: + session.add( + OutboxEvent( + aggregate_type="user", + aggregate_id=new_id("user_"), + event_type="user.created", + payload={}, + status="bogus", + ) + ) + with pytest.raises(IntegrityError): + await session.commit() + + +async def test_audit_and_outbox_same_transaction(db: async_sessionmaker[AsyncSession]) -> None: + """契约第 4.4 节:权威状态、AuditEvent 与 OutboxEvent 同事务提交。""" + async with db() as session: + user = User(email="tx@example.com", display_name="事务") + session.add(user) + await session.flush() # 取得 user.id,事务尚未提交 + session.add( + AuditEvent( + actor_type="system", + actor_id="system", + action="user.created", + aggregate_type="user", + aggregate_id=user.id, + ) + ) + session.add( + OutboxEvent( + aggregate_type="user", + aggregate_id=user.id, + event_type="user.created", + payload={"user_id": user.id}, + ) + ) + await session.commit() + assert (await session.scalars(select(OutboxEvent))).one().status == "pending" diff --git a/backend/uv.lock b/backend/uv.lock index ca0ca2f..258fbb5 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -207,7 +207,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" }, { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" }, { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, @@ -215,7 +217,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, @@ -223,7 +227,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, @@ -231,14 +237,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, @@ -246,7 +256,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, @@ -334,6 +346,7 @@ dependencies = [ { name = "alembic" }, { name = "asyncpg" }, { name = "fastapi" }, + { name = "greenlet" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, @@ -355,6 +368,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14" }, { name = "asyncpg", specifier = ">=0.30" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "greenlet", specifier = ">=3.5.5" }, { name = "pydantic", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.6" }, { name = "sqlalchemy", specifier = ">=2.0" }, diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ea1e6a1..8db0f62 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,6 +1,7 @@ services: hunjing-postgres: - image: pgvector/pgvector:17 + # 基础档使用本地 postgres:16;WP-04 启用向量检索时切换 pgvector/pgvector:17 + image: postgres:16-alpine container_name: hunjing-postgres restart: unless-stopped environment: