chore: fork from IRIS track 供 LICA 部门独立运行

- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update)
- 组织隔离目标: LICA
- 端口规划: 前端 8030 / 后端 8031 / 数据库 8032
- 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本)
- 已排除工作区未提交改动,取干净的 192c8ee 状态
This commit is contained in:
2026-09-21 15:56:52 +08:00
commit 3286a11bc7
212 changed files with 44060 additions and 0 deletions

1
backend/alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

59
backend/alembic/env.py Normal file
View File

@ -0,0 +1,59 @@
"""Alembic 迁移环境配置 — 异步引擎 + 自动加载模型"""
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings
from app.models import Base # 自动发现所有 SQLAlchemy 模型
# Alembic Config 对象
config = context.config
# 日志配置
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 将 DATABASE_URL 同步到 alembic 配置中(覆盖 alembic.ini 中的占位值)
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# 自动生成迁移时需要的 models 元数据
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""离线模式:生成 SQL 脚本而非直接执行"""
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):
"""在线模式:通过数据库连接执行迁移"""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
"""在线模式:异步引擎"""
connectable = create_async_engine(
config.get_main_option("sqlalchemy.url"),
echo=settings.DEBUG,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@ -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"}

View File

@ -0,0 +1,85 @@
"""init: production_orders, products, tasks, task_logs
Revision ID: 80f8cb64544a
Revises:
Create Date: 2026-08-04 10:08:19.670158
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '80f8cb64544a'
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:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('production_orders',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('order_no', sa.String(length=64), nullable=False, comment='订单编号'),
sa.Column('customer_info', sa.String(length=500), nullable=True, comment='客户信息'),
sa.Column('status', sa.String(length=50), nullable=False, comment='订单状态'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_production_orders_order_no'), 'production_orders', ['order_no'], unique=True)
op.create_table('products',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('serial_number', sa.String(length=16), nullable=False, comment='产品序列号(16位)'),
sa.Column('order_id', sa.UUID(), nullable=False, comment='所属订单ID'),
sa.Column('material_id', sa.String(length=64), nullable=True, comment='物料ID(逻辑外键→老系统)'),
sa.Column('parent_product_id', sa.UUID(), nullable=True, comment='父产品ID'),
sa.Column('status', sa.String(length=50), nullable=False, comment='产品状态'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.ForeignKeyConstraint(['order_id'], ['production_orders.id'], ),
sa.ForeignKeyConstraint(['parent_product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_products_serial_number'), 'products', ['serial_number'], unique=True)
op.create_table('tasks',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('product_id', sa.UUID(), nullable=False, comment='所属产品ID'),
sa.Column('parent_task_id', sa.UUID(), nullable=True, comment='父任务ID'),
sa.Column('task_name', sa.String(length=200), nullable=False, comment='任务名称'),
sa.Column('assignee_id', sa.String(length=64), nullable=True, comment='负责人ID(逻辑外键→老系统)'),
sa.Column('status', sa.String(length=50), nullable=False, comment='任务状态'),
sa.Column('notify_parent_on_complete', sa.Boolean(), nullable=False, comment='完成后是否通知父任务'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.ForeignKeyConstraint(['parent_task_id'], ['tasks.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tasks_parent_task_id'), 'tasks', ['parent_task_id'], unique=False)
op.create_table('task_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('task_id', sa.UUID(), nullable=False, comment='所属任务ID'),
sa.Column('operator_id', sa.String(length=64), nullable=True, comment='操作人ID(逻辑外键→老系统)'),
sa.Column('action_type', sa.String(length=50), nullable=False, comment='操作类型'),
sa.Column('remark', sa.Text(), nullable=True, comment='备注'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'),
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_task_logs_task_id'), 'task_logs', ['task_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_task_logs_task_id'), table_name='task_logs')
op.drop_table('task_logs')
op.drop_index(op.f('ix_tasks_parent_task_id'), table_name='tasks')
op.drop_table('tasks')
op.drop_index(op.f('ix_products_serial_number'), table_name='products')
op.drop_table('products')
op.drop_index(op.f('ix_production_orders_order_no'), table_name='production_orders')
op.drop_table('production_orders')
# ### end Alembic commands ###

View File

@ -0,0 +1,102 @@
"""add task rework fields and product current_location
Revision ID: a1b2c3d4e5f6
Revises: 80f8cb64544a
Create Date: 2026-08-04 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, Sequence[str], None] = '80f8cb64544a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# === 数据迁移:将已有任务状态从小写转为大写 ===
op.execute("""
UPDATE tasks SET status = 'PENDING' WHERE lower(status) = 'pending';
""")
op.execute("""
UPDATE tasks SET status = 'WIP' WHERE lower(status) = 'wip';
""")
op.execute("""
UPDATE tasks SET status = 'COMPLETED' WHERE lower(status) = 'completed';
""")
op.execute("""
UPDATE tasks SET status = 'REJECTED' WHERE lower(status) = 'rejected';
""")
op.execute("""
UPDATE tasks SET status = 'ARCHIVED' WHERE lower(status) = 'archived';
""")
# === tasks 表新增字段 ===
op.add_column('tasks', sa.Column(
'received_at',
sa.DateTime(timezone=True),
nullable=True,
comment='操作员确认接收时间',
))
op.add_column('tasks', sa.Column(
'completed_at',
sa.DateTime(timezone=True),
nullable=True,
comment='任务完工转交时间',
))
op.add_column('tasks', sa.Column(
'reject_reason',
sa.String(length=500),
nullable=True,
comment='驳回原因',
))
op.add_column('tasks', sa.Column(
'is_rework',
sa.Boolean(),
nullable=False,
server_default=sa.text('false'),
comment='是否为返工任务',
))
# === products 表新增字段 ===
op.add_column('products', sa.Column(
'current_location_id',
sa.String(length=64),
nullable=True,
comment="当前持有者ID 或 'virtual_warehouse'(仓库)",
))
def downgrade() -> None:
"""Downgrade schema."""
# === products 表移除字段 ===
op.drop_column('products', 'current_location_id')
# === tasks 表移除字段 ===
op.drop_column('tasks', 'is_rework')
op.drop_column('tasks', 'reject_reason')
op.drop_column('tasks', 'completed_at')
op.drop_column('tasks', 'received_at')
# === 数据回迁:将任务状态从大写转回小写 ===
op.execute("""
UPDATE tasks SET status = 'pending' WHERE status = 'PENDING';
""")
op.execute("""
UPDATE tasks SET status = 'wip' WHERE status = 'WIP';
""")
op.execute("""
UPDATE tasks SET status = 'completed' WHERE status = 'COMPLETED';
""")
op.execute("""
UPDATE tasks SET status = 'rejected' WHERE status = 'REJECTED';
""")
op.execute("""
UPDATE tasks SET status = 'archived' WHERE status = 'ARCHIVED';
""")

View File

@ -0,0 +1,24 @@
"""add_task_type
Revision ID: a7b8c9d0e1f2
Revises: f6a7b8c9d0e1
Create Date: 2026-08-06
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "a7b8c9d0e1f2"
down_revision: Union[str, None] = "f6a7b8c9d0e1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("tasks", sa.Column("task_type", sa.String(20), nullable=True, comment="派生类型: TRANSFER/SPAWN/RECOVERY"))
# 刷老数据:parent_task_id 非空的默认为 TRANSFER
op.execute("UPDATE tasks SET task_type = 'TRANSFER' WHERE parent_task_id IS NOT NULL AND task_type IS NULL")
def downgrade() -> None:
op.drop_column("tasks", "task_type")

View File

@ -0,0 +1,44 @@
"""product hex counter + material fields + optional order
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-08-04 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'b2c3d4e5f6a7'
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# === 16进制计数器 Sequence ===
op.execute("CREATE SEQUENCE IF NOT EXISTS product_hex_counter START 1;")
# === products 表: order_id → 可选 ===
op.alter_column('products', 'order_id',
existing_type=sa.UUID(),
nullable=True,
existing_comment='所属订单ID(可选)')
# === products 表: 新增 external_serial ===
op.add_column('products', sa.Column(
'external_serial',
sa.String(length=64),
nullable=True,
comment='用户自定义产品序列号(可选)',
))
def downgrade() -> None:
op.drop_column('products', 'external_serial')
op.alter_column('products', 'order_id',
existing_type=sa.UUID(),
nullable=False)
op.execute("DROP SEQUENCE IF EXISTS product_hex_counter;")

View File

@ -0,0 +1,32 @@
"""add_notifications
Revision ID: b8c9d0e1f2a3
Revises: a7b8c9d0e1f2
Create Date: 2026-08-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "b8c9d0e1f2a3"
down_revision: Union[str, None] = "a7b8c9d0e1f2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", sa.UUID(), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("user_id", sa.String(64), nullable=False, index=True, comment="接收人ID(逻辑外键→老系统)"),
sa.Column("title", sa.String(200), nullable=False, comment="通知标题"),
sa.Column("content", sa.Text(), nullable=False, comment="通知内容详情"),
sa.Column("type", sa.String(20), nullable=False, comment="通知类型: TRANSFER(转交派发) | REJECT(驳回)"),
sa.Column("task_id", sa.UUID(), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, comment="关联任务ID"),
sa.Column("is_read", sa.Boolean(), server_default=sa.text("false"), comment="是否已读"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), comment="创建时间"),
)
def downgrade() -> None:
op.drop_table("notifications")

View File

@ -0,0 +1,35 @@
"""add product material snapshot fields
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-08-05 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'c3d4e5f6a7b8'
down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('products', sa.Column(
'material_name', sa.String(length=255), nullable=True, comment='物料名称快照'))
op.add_column('products', sa.Column(
'spec_model', sa.String(length=255), nullable=True, comment='规格型号快照'))
op.add_column('products', sa.Column(
'category', sa.String(length=255), nullable=True, comment='物料分类快照'))
op.add_column('products', sa.Column(
'material_type', sa.String(length=100), nullable=True, comment='物料类型快照'))
def downgrade() -> None:
op.drop_column('products', 'material_type')
op.drop_column('products', 'category')
op.drop_column('products', 'spec_model')
op.drop_column('products', 'material_name')

View File

@ -0,0 +1,36 @@
"""add_app_versions
Revision ID: c9d0e1f2a3b4
Revises: b8c9d0e1f2a3
Create Date: 2026-08-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c9d0e1f2a3b4"
down_revision: Union[str, None] = "b8c9d0e1f2a3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"app_versions",
sa.Column("id", sa.UUID(), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("version", sa.String(20), nullable=False, unique=True, comment="版本号"),
sa.Column("version_code", sa.Integer(), nullable=False, server_default="100", comment="数字版本号"),
sa.Column("wgt_url", sa.String(500), nullable=False, comment="WGT下载地址"),
sa.Column("description", sa.Text(), nullable=True, comment="更新说明"),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), comment="是否启用"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# 插入初始版本记录
op.execute(
"INSERT INTO app_versions (version, version_code, wgt_url, description, is_active) "
"VALUES ('T1.0.1', 101, '', '初始版本', true)"
)
def downgrade() -> None:
op.drop_table("app_versions")

View File

@ -0,0 +1,30 @@
"""add product overall_status
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-08-05 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, Sequence[str], None] = 'c3d4e5f6a7b8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('products', sa.Column(
'overall_status',
sa.String(length=20),
nullable=True,
comment='宏观状态: 备货/生产/测试/维修/在库',
))
def downgrade() -> None:
op.drop_column('products', 'overall_status')

View File

@ -0,0 +1,35 @@
"""add task_records table
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
Create Date: 2026-08-06 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision: str = 'e5f6a7b8c9d0'
down_revision: Union[str, Sequence[str], None] = 'd4e5f6a7b8c9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table('task_records',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('task_id', UUID(as_uuid=True), sa.ForeignKey('tasks.id'), nullable=False, comment='所属任务ID'),
sa.Column('remark', sa.String(length=2000), nullable=True, comment='备注文本'),
sa.Column('images', sa.String(length=4000), nullable=True, comment='图片URL列表(JSON字符串)'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='记录时间'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_task_records_task_id'), 'task_records', ['task_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_task_records_task_id'), table_name='task_records')
op.drop_table('task_records')

View File

@ -0,0 +1,23 @@
"""add_task_remark
Revision ID: f6a7b8c9d0e1
Revises: e5f6a7b8c9d0
Create Date: 2026-08-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "f6a7b8c9d0e1"
down_revision: Union[str, None] = "e5f6a7b8c9d0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("tasks", sa.Column("remark", sa.String(2000), nullable=True, comment="任务初始描述/交接备注"))
def downgrade() -> None:
op.drop_column("tasks", "remark")

View File

@ -0,0 +1,33 @@
"""add_product_messages
Revision ID: g1h2i3j4k5l6
Revises: c9d0e1f2a3b4
Create Date: 2026-08-10
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "g1h2i3j4k5l6"
down_revision: Union[str, None] = "c9d0e1f2a3b4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"product_messages",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("product_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("products.id", ondelete="CASCADE"),
index=True, nullable=False),
sa.Column("operator_id", sa.String(50), nullable=False),
sa.Column("content", sa.Text, nullable=False),
sa.Column("created_at", sa.DateTime, nullable=True),
)
def downgrade() -> None:
op.drop_table("product_messages")

View File

@ -0,0 +1,32 @@
"""add holidays table
Revision ID: h1h2h3h4h5h6
Revises: g1h2i3j4k5l6
Create Date: 2026-08-28 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'h1h2h3h4h5h6'
down_revision: Union[str, Sequence[str], None] = 'g1h2i3j4k5l6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'holidays',
sa.Column('id', sa.Integer(), autoincrement=True, primary_key=True),
sa.Column('day', sa.Date(), nullable=False, comment='放假日期'),
sa.Column('name', sa.String(length=100), nullable=True, comment='放假说明(如国庆节)'),
)
op.create_index('ix_holidays_day', 'holidays', ['day'], unique=True)
def downgrade() -> None:
op.drop_index('ix_holidays_day', table_name='holidays')
op.drop_table('holidays')

View File

@ -0,0 +1,45 @@
"""add_product_lifecycle_phase
Revision ID: i1j2k3l4m5n6
Revises: h1h2h3h4h5h6
Create Date: 2026-09-14
产品生命周期阶段(lifecycle_phase)
--------------------------------
用于区分「生产阶段的测试(发货测试)」与「出库后再次返厂的售后维修」。
存量数据一律回填 'PRODUCTION'(历史产品视为从未出库回流);
新数据由 SQLAlchemy 端 default 提供,双保险。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "i1j2k3l4m5n6"
down_revision: Union[str, None] = "h1h2h3h4h5h6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"products",
sa.Column(
"lifecycle_phase",
sa.String(20),
nullable=False,
server_default="PRODUCTION",
comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)",
),
)
# 存量数据回填:历史产品从未出库回流,统一视为生产阶段
op.execute("UPDATE products SET lifecycle_phase = 'PRODUCTION' WHERE lifecycle_phase IS NULL")
# 加索引:售后设备排查(按阶段筛选)会高频用到
op.create_index(
"ix_products_lifecycle_phase", "products", ["lifecycle_phase"], unique=False,
)
def downgrade() -> None:
op.drop_index("ix_products_lifecycle_phase", table_name="products")
op.drop_column("products", "lifecycle_phase")

View File

@ -0,0 +1,81 @@
"""add_audit_logs
Revision ID: j1k2l3m4n5o6
Revises: i1j2k3l4m5n6
Create Date: 2026-09-21
操作审计日志表(audit_logs)
--------------------------
新增一张独立的审计表,用于记录 task_logs 覆盖不到的操作:
登录、导出、产品增删改、收口、权限/配置变更等与单个任务无关的动作。
为什么另起一张表而不复用 task_logs:
task_logs.task_id 是 NOT NULL 外键,只能挂在任务上,无法表达「张三导出了
产品清单」这类动作;且缺少来源 IP / UA / 结果状态等审计必需字段。
存量数据无需回填(本表从上线时刻开始记录)。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "j1k2l3m4n5o6"
down_revision: Union[str, None] = "i1j2k3l4m5n6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"audit_logs",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
# 操作人
sa.Column("user_id", sa.String(64), nullable=True, comment="操作人账号(逻辑外键→MOM)"),
sa.Column("display_name", sa.String(100), nullable=True, comment="操作人显示名"),
sa.Column("role", sa.String(50), nullable=True, comment="操作时角色快照"),
# 业务语义
sa.Column("action", sa.String(50), nullable=False, comment="动作"),
sa.Column("module", sa.String(50), nullable=False, comment="业务模块"),
sa.Column("target_type", sa.String(50), nullable=True),
sa.Column("target_id", sa.String(100), nullable=True),
sa.Column("target_name", sa.String(200), nullable=True),
sa.Column("details", postgresql.JSONB(), nullable=True, comment="变更详情"),
# 请求上下文
sa.Column("ip_address", sa.String(50), nullable=True),
sa.Column("user_agent", sa.String(500), nullable=True),
sa.Column("method", sa.String(10), nullable=True),
sa.Column("url", sa.String(500), nullable=True),
sa.Column("status_code", sa.Integer(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
# 与结构化日志对账
sa.Column("request_id", sa.String(64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
# 索引:按审计页最常用的检索维度建
op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"])
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
op.create_index("ix_audit_logs_module", "audit_logs", ["module"])
op.create_index("ix_audit_logs_action", "audit_logs", ["action"])
op.create_index("ix_audit_logs_target_id", "audit_logs", ["target_id"])
op.create_index("ix_audit_logs_request_id", "audit_logs", ["request_id"])
# 组合索引:审计页默认「按时间倒序 + 按模块/动作过滤」
op.create_index("ix_audit_logs_module_created", "audit_logs", ["module", "created_at"])
def downgrade() -> None:
op.drop_index("ix_audit_logs_module_created", table_name="audit_logs")
op.drop_index("ix_audit_logs_request_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_target_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_action", table_name="audit_logs")
op.drop_index("ix_audit_logs_module", table_name="audit_logs")
op.drop_index("ix_audit_logs_user_id", table_name="audit_logs")
op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
op.drop_table("audit_logs")

View File

@ -0,0 +1,53 @@
"""add_user_daily_seen
Revision ID: k1l2m3n4o5p6
Revises: j1k2l3m4n5o6
Create Date: 2026-09-21
每日用户活动表(user_daily_seen)
--------------------------------
一天一人一行,记录当天首次 / 末次活动时刻,供日活报表计算
「上线时间 / 下线时间」。
为什么不复用 audit_logs:
· 上线/下线时间不能取登录时间 —— Refresh Token 有效期 7 天,用户不必每天
重新登录,「登录次数 0 却操作 35 次」的报表没有意义。
· 也不能只取写操作时间 —— 审计中间件只记写操作,普通 GET 不入账,
当天只翻看的人会被漏掉。
· 更不能把活动写进审计表 —— 「末次活动」是需要不断 UPDATE 的状态,
而审计流水必须只增不改;能改的审计记录等于没有审计价值。
存量数据无需回填:本表从上线时刻开始记录;日活接口对更早的日期会自动
回退到审计表的写操作时间去推算。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "k1l2m3n4o5p6"
down_revision: Union[str, None] = "j1k2l3m4n5o6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_daily_seen",
sa.Column("user_id", sa.String(64), primary_key=True,
comment="操作人账号(逻辑外键→MOM)"),
sa.Column("day", sa.Date(), primary_key=True,
comment="北京时间自然日"),
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False,
comment="当天首次活动时刻"),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False,
comment="当天末次活动时刻"),
)
# 日活查询按日期区间扫,给 day 单独建索引。
# (主键是 (user_id, day),前缀是 user_id,按 day 过滤用不上,故需补一条)
op.create_index("ix_user_daily_seen_day", "user_daily_seen", ["day"])
def downgrade() -> None:
op.drop_index("ix_user_daily_seen_day", table_name="user_daily_seen")
op.drop_table("user_daily_seen")