diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..30951e9 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,46 @@ +from logging.config import fileConfig + +from alembic import context + +from app.config import settings +from app.database import Base +from app.models import * # noqa: F401,F403 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline(): + url = settings.database_url + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations(): + from app.database import engine + + async with engine.begin() as conn: + await conn.run_sync(do_run_migrations) + + +def run_migrations_online(): + import asyncio + + 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/ab0c56ae4242_create_users_and_audit_logs_tables.py b/backend/alembic/versions/ab0c56ae4242_create_users_and_audit_logs_tables.py new file mode 100644 index 0000000..b973ffd --- /dev/null +++ b/backend/alembic/versions/ab0c56ae4242_create_users_and_audit_logs_tables.py @@ -0,0 +1,58 @@ +"""create users and audit_logs tables + +Revision ID: ab0c56ae4242 +Revises: +Create Date: 2026-07-22 16:25:22.698619 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ab0c56ae4242' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'users', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('username', sa.String(50), unique=True, nullable=False, index=True), + sa.Column('email', sa.String(255), unique=True, nullable=False), + sa.Column('hashed_password', sa.String(255), nullable=False), + sa.Column( + 'role', + sa.Enum('owner', 'operator', 'viewer', name='userrole'), + nullable=False, + server_default='viewer', + ), + sa.Column('is_active', sa.Boolean, nullable=False, server_default=sa.true()), + sa.Column('is_approved', sa.Boolean, nullable=False, server_default=sa.false()), + sa.Column('login_attempts', sa.Integer, nullable=False, server_default='0'), + sa.Column('locked_until', sa.DateTime, nullable=True), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + ) + + op.create_table( + 'audit_logs', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('user_id', sa.String(36), nullable=False, index=True), + sa.Column('username', sa.String(50), nullable=False), + sa.Column('action', sa.String(100), nullable=False, index=True), + sa.Column('target', sa.String(255), nullable=False), + sa.Column('detail', sa.Text, nullable=True), + sa.Column('ip_address', sa.String(45), nullable=True), + sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table('audit_logs') + op.drop_table('users') + op.execute('DROP TYPE IF EXISTS userrole') diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/models/audit.py b/backend/app/models/audit.py new file mode 100644 index 0000000..9bb03ad --- /dev/null +++ b/backend/app/models/audit.py @@ -0,0 +1,20 @@ +"""审计日志 ORM 模型。""" +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + username: Mapped[str] = mapped_column(String(50), nullable=False) + action: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + target: Mapped[str] = mapped_column(String(255), nullable=False) + detail: Mapped[str | None] = mapped_column(Text, nullable=True) + ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..80af843 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,30 @@ +"""用户 ORM 模型。""" +import uuid +from datetime import datetime +from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +import enum + +from app.database import Base + + +class UserRole(str, enum.Enum): + OWNER = "owner" + OPERATOR = "operator" + VIEWER = "viewer" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.VIEWER, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_approved: Mapped[bool] = mapped_column(Boolean, default=False) + login_attempts: Mapped[int] = mapped_column(default=0) + locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)