fix: file upload error
This commit is contained in:
@@ -1,38 +0,0 @@
|
||||
"""Add workspace tree relative_path index.
|
||||
|
||||
The ``list_workspace_directories`` endpoint filters by
|
||||
``relative_path`` prefixes inside a workspace. A composite index on
|
||||
``(workspace_id, relative_path(255))`` avoids scanning all rows for a
|
||||
workspace when listing a subdirectory.
|
||||
|
||||
Revision ID: 3ba4d8489f36
|
||||
Revises: f6a7b8c9d0e1
|
||||
Create Date: 2026-08-12
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3ba4d8489f36"
|
||||
down_revision: str | Sequence[str] | None = "f6a7b8c9d0e1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"idx_storage_workspace_relative_path",
|
||||
"storage_objects",
|
||||
[sa.text("`workspace_id`"), sa.text("`relative_path`(255)")],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"idx_storage_workspace_relative_path",
|
||||
table_name="storage_objects",
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Make the scripts workspace/name/type unique index soft-delete aware.
|
||||
|
||||
The old unique index ``uk_scripts_workspace_name`` on
|
||||
``(workspace_id, script_name, script_type)`` blocked re-uploading a
|
||||
script after it had been soft-deleted, because the deleted row was still
|
||||
part of the index.
|
||||
|
||||
Replace it with ``uk_scripts_workspace_name_active`` on
|
||||
``(workspace_id, script_name, script_type, deleted_at)``. In MySQL a
|
||||
unique index treats ``NULL`` values as distinct, so a new active row
|
||||
(``deleted_at IS NULL``) no longer conflicts with a previously deleted
|
||||
row (``deleted_at IS NOT NULL``), while two active rows with the same
|
||||
name still conflict as expected.
|
||||
|
||||
Revision ID: 47a76cd261fd
|
||||
Revises: 3ba4d8489f36
|
||||
Create Date: 2026-08-14
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "47a76cd261fd"
|
||||
down_revision: str | Sequence[str] | None = "3ba4d8489f36"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index(
|
||||
"uk_scripts_workspace_name",
|
||||
table_name="scripts",
|
||||
)
|
||||
op.create_index(
|
||||
"uk_scripts_workspace_name_active",
|
||||
"scripts",
|
||||
["workspace_id", "script_name", "script_type", "deleted_at"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"uk_scripts_workspace_name_active",
|
||||
table_name="scripts",
|
||||
)
|
||||
op.create_index(
|
||||
"uk_scripts_workspace_name",
|
||||
"scripts",
|
||||
["workspace_id", "script_name", "script_type"],
|
||||
unique=True,
|
||||
)
|
||||
@@ -5,3 +5,17 @@
|
||||
- 已发布迁移不得直接修改。
|
||||
- 新迁移必须同时提供可执行的 `upgrade()` 和 `downgrade()`。
|
||||
- 自动生成后必须检查数据类型、约束、索引和执行顺序。
|
||||
# Migration Revisions
|
||||
|
||||
该目录只保存经过评审和验证的 Alembic 迁移版本。
|
||||
|
||||
## Baseline
|
||||
|
||||
- `e1f2a3b4c5d6_rebuild_baseline.py` — 2026-08-14 重建的基线迁移,包含完整 schema + seed。
|
||||
- 之前的 8 个迁移文件已合并并删除;新环境从此基线开始。
|
||||
|
||||
## Rules
|
||||
|
||||
- 已发布迁移不得直接修改。
|
||||
- 新迁移必须同时提供可执行的 `upgrade()` 和 `downgrade()`。
|
||||
- 自动生成后必须检查数据类型、约束、索引和执行顺序。
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Drop unique indexes on Scripts and DataResources.
|
||||
|
||||
StorageObjects now carries the physical uniqueness guarantees:
|
||||
- ``uk_storage_bucket_key``
|
||||
- ``uk_storage_workspace_path``
|
||||
|
||||
Scripts and DataResources therefore no longer need their own unique
|
||||
indexes on ``current_object_id`` / ``storage_object_id``, and the
|
||||
soft-delete-aware ``uk_scripts_workspace_name_active`` index is also
|
||||
removed. Re-uploading a previously soft-deleted script or resource is
|
||||
allowed because the StorageObjects layer enforces path uniqueness only
|
||||
for active objects.
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 47a76cd261fd
|
||||
Create Date: 2026-08-14
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a1b2c3d4e5f6"
|
||||
down_revision: str | Sequence[str] | None = "47a76cd261fd"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_index(
|
||||
"uk_scripts_workspace_name_active",
|
||||
table_name="scripts",
|
||||
)
|
||||
op.drop_index(
|
||||
"uk_scripts_current_object",
|
||||
table_name="scripts",
|
||||
)
|
||||
op.drop_index(
|
||||
"uk_data_resources_object",
|
||||
table_name="data_resources",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.create_index(
|
||||
"uk_scripts_workspace_name_active",
|
||||
"scripts",
|
||||
["workspace_id", "script_name", "script_type", "deleted_at"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"uk_scripts_current_object",
|
||||
"scripts",
|
||||
["current_object_id"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"uk_data_resources_object",
|
||||
"data_resources",
|
||||
["storage_object_id"],
|
||||
unique=True,
|
||||
)
|
||||
+138
-42
@@ -1,22 +1,12 @@
|
||||
"""squashed baseline — full schema + seed data in one migration
|
||||
"""rebuild baseline — full schema + seed data in one migration
|
||||
|
||||
Single baseline migration combining the previous 5-step chain:
|
||||
|
||||
8d86e2f82860 initial baseline (20 tables)
|
||||
b71c4f2a9d10 seed demo users / workspaces / roles / members
|
||||
9a1b2c3d4e5f enable password login for seeded users
|
||||
a2b3c4d5e6f7 add storage_objects.trash_key
|
||||
c3d4e5f6a7b8 add upload_sessions object-metadata columns
|
||||
|
||||
The column additions from the later migrations are folded directly into
|
||||
the CREATE TABLE statements, so this file is a from-scratch schema.
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revision ID: e1f2a3b4c5d6
|
||||
Revises: (none)
|
||||
Create Date: 2026-08-05
|
||||
Create Date: 2026-08-14
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
@@ -26,14 +16,70 @@ from sqlalchemy.dialects import mysql
|
||||
from common.auth.passwords import hash_password
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d4e5f6a7b8c9"
|
||||
revision: str = "e1f2a3b4c5d6"
|
||||
down_revision: str | Sequence[str] | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# ── seed constants ───────────────────────────────────────────────
|
||||
DISABLED_PASSWORD = "demo-login-disabled"
|
||||
SEEDED_USERS = (
|
||||
(
|
||||
"00000000000000000000000001",
|
||||
"admin",
|
||||
"Admin",
|
||||
"0000000000000000000000000A",
|
||||
),
|
||||
)
|
||||
|
||||
ADMIN_ROLE_ID = "0000000000000000000000000A"
|
||||
DEVELOPER_ROLE_ID = "0000000000000000000000000B"
|
||||
|
||||
PERMISSIONS: list[tuple[str, str, str]] = [
|
||||
# (permission_code, permission_name, module_code)
|
||||
("dashboard.view", "查看工作台", "dashboard"),
|
||||
("script.build", "构建脚本", "script"),
|
||||
("script.public.manage", "管理公共脚本", "script"),
|
||||
("schedule.own", "管理本人调度", "schedule"),
|
||||
("schedule.all", "管理全部调度", "schedule"),
|
||||
("experiment.own", "管理本人实验", "experiment"),
|
||||
("experiment.all", "管理全部实验", "experiment"),
|
||||
("resource.personal", "管理个人资源", "resource"),
|
||||
("resource.public.upload", "上传公共资源", "resource"),
|
||||
("resource.public.manage", "管理公共资源", "resource"),
|
||||
("system.view", "查看系统管理", "system"),
|
||||
("system.manage", "管理系统配置", "system"),
|
||||
]
|
||||
|
||||
# developer gets *.own + personal resource only; no system.*, no *.all.
|
||||
DEVELOPER_PERMISSION_CODES: list[str] = [
|
||||
"dashboard.view",
|
||||
"script.build",
|
||||
"script.public.manage",
|
||||
"schedule.own",
|
||||
"experiment.own",
|
||||
"resource.personal",
|
||||
]
|
||||
|
||||
ADMIN_PERMISSION_CODES: list[str] = [code for code, _, _ in PERMISSIONS]
|
||||
|
||||
CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
def _deterministic_permission_id(code: str) -> str:
|
||||
"""Stable 26-char ULID-shaped id derived from permission_code."""
|
||||
digest = hashlib.sha256(
|
||||
f"model-platform-permission-v1:{code}".encode("utf-8")
|
||||
).digest()
|
||||
value = int.from_bytes(b"\x00" * 6 + digest[:10], byteorder="big")
|
||||
encoded = ["0"] * 26
|
||||
for index in range(25, -1, -1):
|
||||
encoded[index] = CROCKFORD_BASE32[value & 31]
|
||||
value >>= 5
|
||||
return "".join(encoded)
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Full schema from scratch (all 20 tables, current model state)."""
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('consumer_inbox',
|
||||
sa.Column('consumer_name', sa.String(length=128), nullable=False),
|
||||
sa.Column('event_id', mysql.CHAR(length=26), nullable=False),
|
||||
@@ -67,7 +113,6 @@ def upgrade() -> None:
|
||||
)
|
||||
op.create_index('idx_data_resources_owner', 'data_resources', ['owner_user_id', 'status'], unique=False)
|
||||
op.create_index('idx_data_resources_workspace', 'data_resources', ['workspace_id', 'visibility', 'status'], unique=False)
|
||||
op.create_index('uk_data_resources_object', 'data_resources', ['storage_object_id'], unique=True)
|
||||
op.create_table('outbox_events',
|
||||
sa.Column('event_id', mysql.CHAR(length=26), nullable=False),
|
||||
sa.Column('aggregate_type', sa.String(length=64), nullable=False),
|
||||
@@ -179,7 +224,7 @@ def upgrade() -> None:
|
||||
sa.Column('node_key', sa.String(length=64), nullable=False, comment='画布内稳定标识'),
|
||||
sa.Column('node_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('versions_id', mysql.CHAR(length=26), nullable=False),
|
||||
sa.Column('python_version', sa.String(length=8), nullable=False, server_default=sa.text("'3.12'"), comment='节点执行 Python 版本(3.8/3.10/3.12)'),
|
||||
sa.Column('python_version', sa.String(length=8), server_default=sa.text("'3.12'"), nullable=False, comment='节点执行 Python 版本(3.8/3.10/3.12)'),
|
||||
sa.Column('timeout_seconds', mysql.INTEGER(), server_default=sa.text('600'), nullable=False),
|
||||
sa.Column('retry_count', mysql.INTEGER(), server_default=sa.text('0'), nullable=False),
|
||||
sa.Column('retry_interval_sec', mysql.INTEGER(), server_default=sa.text('5'), nullable=False),
|
||||
@@ -274,8 +319,6 @@ def upgrade() -> None:
|
||||
)
|
||||
op.create_index('idx_scripts_owner', 'scripts', ['owner_user_id', 'status'], unique=False)
|
||||
op.create_index('idx_scripts_workspace', 'scripts', ['workspace_id', 'script_type', 'visibility', 'status'], unique=False)
|
||||
op.create_index('uk_scripts_current_object', 'scripts', ['current_object_id'], unique=True)
|
||||
op.create_index('uk_scripts_workspace_name', 'scripts', ['workspace_id', 'script_name', 'script_type'], unique=True)
|
||||
op.create_table('storage_objects',
|
||||
sa.Column('storage_object_id', mysql.CHAR(length=26), nullable=False),
|
||||
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
|
||||
@@ -298,23 +341,25 @@ def upgrade() -> None:
|
||||
sa.Column('bucket_name', sa.String(length=128), nullable=True),
|
||||
sa.Column('object_key', sa.String(length=1024), nullable=True),
|
||||
sa.Column('object_key_hash', sa.BINARY(length=32), nullable=True, comment='SHA-256(object_key),由应用写入'),
|
||||
sa.Column('object_key_hash_active', sa.BINARY(length=32), sa.Computed("CASE WHEN object_status = 'available' THEN object_key_hash ELSE NULL END", persisted=False), nullable=True, comment='VIRTUAL generated column used by uk_storage_bucket_key_active'),
|
||||
sa.Column('file_extension', sa.String(length=32), nullable=True),
|
||||
sa.Column('mime_type', sa.String(length=255), nullable=True),
|
||||
sa.Column('content_hash', mysql.CHAR(length=64), nullable=True, comment='SHA-256 hex'),
|
||||
sa.Column('object_etag', sa.String(length=255), nullable=True),
|
||||
sa.Column('trash_key', sa.String(length=1100), nullable=True, comment='Path inside the trash bucket where soft-deleted bytes are stored'),
|
||||
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
|
||||
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
|
||||
sa.Column('trash_key', sa.String(length=1100), nullable=True, comment="Path inside the trash bucket where the soft-deleted bytes live. Format: '{source_bucket}/{object_key}' so a restore is a same-key copy back to the source bucket. NULL while the row is still available."),
|
||||
sa.PrimaryKeyConstraint('storage_object_id'),
|
||||
comment='Workspace 文件和 S3 对象的统一元数据'
|
||||
comment='Workspace 文件和 RustFS 对象的统一元数据'
|
||||
)
|
||||
op.create_index('fk_storage_created_by', 'storage_objects', ['created_by'], unique=False)
|
||||
op.create_index('idx_storage_content_hash', 'storage_objects', ['content_hash'], unique=False)
|
||||
op.create_index('idx_storage_owner', 'storage_objects', ['owner_user_id', 'object_status'], unique=False)
|
||||
op.create_index('idx_storage_parent', 'storage_objects', ['parent_object_id'], unique=False)
|
||||
op.create_index('idx_storage_workspace_path', 'storage_objects', ['workspace_id', 'storage_backend', 'path_hash'], unique=False)
|
||||
op.create_index('idx_storage_workspace_usage', 'storage_objects', ['workspace_id', 'usage_type', 'object_status'], unique=False)
|
||||
op.create_index('uk_storage_bucket_key', 'storage_objects', ['storage_backend', 'bucket_name', 'object_key_hash'], unique=True)
|
||||
op.create_index('uk_storage_workspace_path', 'storage_objects', ['workspace_id', 'storage_backend', 'path_hash'], unique=True)
|
||||
op.create_index('uk_storage_bucket_key_active', 'storage_objects', ['storage_backend', 'bucket_name', 'object_key_hash_active'], unique=True)
|
||||
op.create_index('idx_storage_workspace_relative_path', 'storage_objects', [sa.text('`workspace_id`'), sa.text('`relative_path`(255)')], unique=False)
|
||||
op.create_table('upload_sessions',
|
||||
sa.Column('upload_id', mysql.CHAR(length=26), nullable=False),
|
||||
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
|
||||
@@ -333,15 +378,14 @@ def upgrade() -> None:
|
||||
sa.Column('content_type', sa.String(length=255), nullable=True),
|
||||
sa.Column('storage_object_id', mysql.CHAR(length=26), nullable=True),
|
||||
sa.Column('completed_at', mysql.DATETIME(fsp=3), nullable=True),
|
||||
# Object-metadata columns added by c3d4e5f6a7b8 (server-proxied upload).
|
||||
sa.Column('file_name', sa.String(length=255), nullable=False, server_default=''),
|
||||
sa.Column('usage_type', sa.String(length=32), nullable=False, server_default='working_copy', comment='data_resource/version_artifact/snapshot/run_log/run_result/working_copy/public_script'),
|
||||
sa.Column('visibility', sa.String(length=16), nullable=False, server_default='private', comment='private/workspace/public'),
|
||||
sa.Column('is_immutable', mysql.TINYINT(display_width=1), nullable=False, server_default='0'),
|
||||
sa.Column('file_name', sa.String(length=255), server_default='', nullable=False),
|
||||
sa.Column('usage_type', sa.String(length=32), server_default=sa.text("'working_copy'"), nullable=False, comment='data_resource/version_artifact/snapshot/run_log/run_result/working_copy/public_script'),
|
||||
sa.Column('visibility', sa.String(length=16), server_default=sa.text("'private'"), nullable=False, comment='private/workspace/public'),
|
||||
sa.Column('is_immutable', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
|
||||
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
|
||||
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
|
||||
sa.PrimaryKeyConstraint('upload_id'),
|
||||
comment='S3 上传会话;URL 本身不持久化'
|
||||
comment='RustFS 预签名上传会话;URL 本身不持久化'
|
||||
)
|
||||
op.create_index('fk_upload_sessions_storage_object', 'upload_sessions', ['storage_object_id'], unique=False)
|
||||
op.create_index('fk_upload_sessions_user', 'upload_sessions', ['user_id'], unique=False)
|
||||
@@ -434,10 +478,9 @@ def upgrade() -> None:
|
||||
op.create_index('fk_workspaces_created_by', 'workspaces', ['created_by'], unique=False)
|
||||
op.create_index('idx_workspaces_status', 'workspaces', ['status'], unique=False)
|
||||
op.create_index('uk_workspaces_code', 'workspaces', ['workspace_code'], unique=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
# ── seed data (from b71c4f2a9d10) ──────────────────────────────
|
||||
ADMIN_ROLE_ID = "0000000000000000000000000A"
|
||||
DEVELOPER_ROLE_ID = "0000000000000000000000000B"
|
||||
# ── seed data ────────────────────────────────────────────────────
|
||||
ADMIN_USER_ID = "00000000000000000000000001"
|
||||
DEFAULT_WORKSPACE_ID = "00000000000000000000000002"
|
||||
USERS = (
|
||||
@@ -491,7 +534,7 @@ def upgrade() -> None:
|
||||
"role_id": ADMIN_ROLE_ID,
|
||||
"role_code": "admin",
|
||||
"role_name": "管理员",
|
||||
"role_scope": "workspace",
|
||||
"role_scope": "platform",
|
||||
"is_builtin": 1,
|
||||
"description": "Self-hosted workspace administrator",
|
||||
},
|
||||
@@ -499,7 +542,7 @@ def upgrade() -> None:
|
||||
"role_id": DEVELOPER_ROLE_ID,
|
||||
"role_code": "developer",
|
||||
"role_name": "开发人员",
|
||||
"role_scope": "workspace",
|
||||
"role_scope": "platform",
|
||||
"is_builtin": 1,
|
||||
"description": "Self-hosted workspace developer",
|
||||
},
|
||||
@@ -549,7 +592,7 @@ def upgrade() -> None:
|
||||
],
|
||||
)
|
||||
|
||||
# ── enable demo password login (from 9a1b2c3d4e5f) ─────────────
|
||||
# ── enable demo password login (from e5f6a7b8c9d0) ──────────────
|
||||
password = os.environ.get("INITIAL_ADMIN_PASSWORD", "admin12345")
|
||||
seeded_user_ids = (ADMIN_USER_ID,)
|
||||
users_update = sa.table(
|
||||
@@ -564,9 +607,63 @@ def upgrade() -> None:
|
||||
.values(password_hash=hash_password(password))
|
||||
)
|
||||
|
||||
# ── seed platform permissions (from f6a7b8c9d0e1) ───────────────
|
||||
op.execute(
|
||||
"UPDATE roles SET role_scope = 'platform' "
|
||||
"WHERE role_code IN ('admin', 'developer') AND is_deleted = 0"
|
||||
)
|
||||
|
||||
permissions_table = sa.table(
|
||||
"permissions",
|
||||
sa.column("permission_id", sa.CHAR(26)),
|
||||
sa.column("permission_code", sa.String(128)),
|
||||
sa.column("permission_name", sa.String(100)),
|
||||
sa.column("module_code", sa.String(64)),
|
||||
sa.column("description", sa.String(500)),
|
||||
)
|
||||
perm_id_by_code: dict[str, str] = {}
|
||||
rows: list[dict[str, str]] = []
|
||||
for code, name, module in PERMISSIONS:
|
||||
pid = _deterministic_permission_id(code)
|
||||
perm_id_by_code[code] = pid
|
||||
rows.append(
|
||||
{
|
||||
"permission_id": pid,
|
||||
"permission_code": code,
|
||||
"permission_name": name,
|
||||
"module_code": module,
|
||||
"description": f"platform 菜单权限:{name}",
|
||||
}
|
||||
)
|
||||
op.bulk_insert(permissions_table, rows)
|
||||
|
||||
role_permissions_table = sa.table(
|
||||
"role_permissions",
|
||||
sa.column("role_id", sa.CHAR(26)),
|
||||
sa.column("permission_id", sa.CHAR(26)),
|
||||
)
|
||||
rp_rows: list[dict[str, str]] = []
|
||||
for code in ADMIN_PERMISSION_CODES:
|
||||
rp_rows.append(
|
||||
{
|
||||
"role_id": ADMIN_ROLE_ID,
|
||||
"permission_id": perm_id_by_code[code],
|
||||
}
|
||||
)
|
||||
for code in DEVELOPER_PERMISSION_CODES:
|
||||
rp_rows.append(
|
||||
{
|
||||
"role_id": DEVELOPER_ROLE_ID,
|
||||
"permission_id": perm_id_by_code[code],
|
||||
}
|
||||
)
|
||||
op.bulk_insert(role_permissions_table, rp_rows)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop everything (reverse of upgrade)."""
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('uk_workspaces_code', table_name='workspaces')
|
||||
op.drop_index('idx_workspaces_status', table_name='workspaces')
|
||||
op.drop_index('fk_workspaces_created_by', table_name='workspaces')
|
||||
@@ -593,16 +690,15 @@ def downgrade() -> None:
|
||||
op.drop_index('fk_upload_sessions_user', table_name='upload_sessions')
|
||||
op.drop_index('fk_upload_sessions_storage_object', table_name='upload_sessions')
|
||||
op.drop_table('upload_sessions')
|
||||
op.drop_index('uk_storage_workspace_path', table_name='storage_objects')
|
||||
op.drop_index('uk_storage_bucket_key', table_name='storage_objects')
|
||||
op.drop_index('uk_storage_bucket_key_active', table_name='storage_objects')
|
||||
op.drop_index('idx_storage_workspace_usage', table_name='storage_objects')
|
||||
op.drop_index('idx_storage_workspace_relative_path', table_name='storage_objects')
|
||||
op.drop_index('idx_storage_workspace_path', table_name='storage_objects')
|
||||
op.drop_index('idx_storage_parent', table_name='storage_objects')
|
||||
op.drop_index('idx_storage_owner', table_name='storage_objects')
|
||||
op.drop_index('idx_storage_content_hash', table_name='storage_objects')
|
||||
op.drop_index('fk_storage_created_by', table_name='storage_objects')
|
||||
op.drop_table('storage_objects')
|
||||
op.drop_index('uk_scripts_workspace_name', table_name='scripts')
|
||||
op.drop_index('uk_scripts_current_object', table_name='scripts')
|
||||
op.drop_index('idx_scripts_workspace', table_name='scripts')
|
||||
op.drop_index('idx_scripts_owner', table_name='scripts')
|
||||
op.drop_table('scripts')
|
||||
@@ -644,9 +740,9 @@ def downgrade() -> None:
|
||||
op.drop_index('idx_outbox_idempotency', table_name='outbox_events')
|
||||
op.drop_index('idx_outbox_aggregate', table_name='outbox_events')
|
||||
op.drop_table('outbox_events')
|
||||
op.drop_index('uk_data_resources_object', table_name='data_resources')
|
||||
op.drop_index('idx_data_resources_workspace', table_name='data_resources')
|
||||
op.drop_index('idx_data_resources_owner', table_name='data_resources')
|
||||
op.drop_table('data_resources')
|
||||
op.drop_index('idx_consumer_inbox_status', table_name='consumer_inbox')
|
||||
op.drop_table('consumer_inbox')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,140 +0,0 @@
|
||||
"""ensure the self-hosted demo login remains available
|
||||
|
||||
Revision ID: e5f6a7b8c9d0
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-08-05 15:31:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
import os
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from common.auth.passwords import hash_password
|
||||
|
||||
|
||||
revision: str = "e5f6a7b8c9d0"
|
||||
down_revision: str | Sequence[str] | None = "d4e5f6a7b8c9"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
DISABLED_PASSWORD = "demo-login-disabled"
|
||||
SEEDED_USERS = (
|
||||
(
|
||||
"00000000000000000000000001",
|
||||
"admin",
|
||||
"Admin",
|
||||
"0000000000000000000000000A",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _create_users_table() -> None:
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("user_id", mysql.CHAR(length=26), nullable=False),
|
||||
sa.Column("username", sa.String(length=64), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=16),
|
||||
server_default=sa.text("'active'"),
|
||||
nullable=False,
|
||||
comment="active/disabled/locked",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
mysql.DATETIME(fsp=3),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP(3)"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
mysql.DATETIME(fsp=3),
|
||||
server_default=sa.text(
|
||||
"CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("email", sa.String(length=255), nullable=True),
|
||||
sa.Column("platform_role_id", mysql.CHAR(length=26), nullable=True),
|
||||
sa.Column("avatar_uri", sa.String(length=1000), nullable=True),
|
||||
sa.Column("last_login_at", mysql.DATETIME(fsp=3), nullable=True),
|
||||
sa.Column(
|
||||
"is_deleted",
|
||||
mysql.TINYINT(display_width=1),
|
||||
server_default=sa.text("0"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("deleted_at", mysql.DATETIME(fsp=3), nullable=True),
|
||||
sa.PrimaryKeyConstraint("user_id"),
|
||||
comment="平台用户",
|
||||
)
|
||||
op.create_index("fk_users_platform_role", "users", ["platform_role_id"])
|
||||
op.create_index("idx_users_status", "users", ["status"])
|
||||
op.create_index("uk_users_email", "users", ["email"], unique=True)
|
||||
op.create_index("uk_users_username", "users", ["username"], unique=True)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
if not sa.inspect(connection).has_table("users"):
|
||||
_create_users_table()
|
||||
|
||||
users = sa.table(
|
||||
"users",
|
||||
sa.column("user_id", sa.String),
|
||||
sa.column("username", sa.String),
|
||||
sa.column("display_name", sa.String),
|
||||
sa.column("password_hash", sa.String),
|
||||
sa.column("status", sa.String),
|
||||
sa.column("email", sa.String),
|
||||
sa.column("platform_role_id", sa.String),
|
||||
)
|
||||
existing = {
|
||||
row.username: row.password_hash
|
||||
for row in connection.execute(
|
||||
sa.select(users.c.username, users.c.password_hash).where(
|
||||
users.c.username.in_([user[1] for user in SEEDED_USERS])
|
||||
)
|
||||
)
|
||||
}
|
||||
password_hash = hash_password(
|
||||
os.environ.get("INITIAL_ADMIN_PASSWORD", "admin12345")
|
||||
)
|
||||
|
||||
for user_id, username, display_name, role_id in SEEDED_USERS:
|
||||
if username not in existing:
|
||||
connection.execute(
|
||||
users.insert().values(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
password_hash=password_hash,
|
||||
status="active",
|
||||
email=f"{username}@model-platform.local",
|
||||
platform_role_id=role_id,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if existing[username] in {None, "", DISABLED_PASSWORD}:
|
||||
connection.execute(
|
||||
users.update()
|
||||
.where(users.c.username == username)
|
||||
.values(password_hash=password_hash)
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
users.update()
|
||||
.where(users.c.username == "admin")
|
||||
.values(status="active")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Do not remove or disable accounts that may contain user data."""
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Seed platform permissions + role_permissions, fix admin/developer role_scope.
|
||||
|
||||
The squashed baseline (d4e5f6a7b8c9) ships the Permissions and
|
||||
RolePermissions tables empty, and seeds admin/developer with
|
||||
role_scope='workspace' (an early mistake; the codebase elsewhere treats
|
||||
both as platform-scoped — see backend/platform.py::system_admin_context
|
||||
and common/auth/membership.py::resolve_is_system_admin). This migration:
|
||||
|
||||
1. UPDATE roles SET role_scope='platform' for admin/developer rows.
|
||||
2. INSERT 12 permission rows covering the menu groups the frontend
|
||||
consumes (dashboard / script / schedule / experiment / resource /
|
||||
system).
|
||||
3. INSERT role_permissions join rows: admin gets all 12, developer
|
||||
gets the 6 `*.own` / personal-resource codes.
|
||||
|
||||
Caveats (read before re-running):
|
||||
|
||||
* ``permission_id`` is derived from a sha256 of the code with the salt
|
||||
prefix ``model-platform-permission-v1:``. The legacy
|
||||
``migrations/data/migrate_system_json.py`` script uses a different
|
||||
salt (``model-platform-v1:permission:``), so the same
|
||||
``permission_code`` maps to a DIFFERENT ``permission_id`` between the
|
||||
two paths. The legacy script's ``existing.get(permission_code)`` check
|
||||
keeps the row count correct (it reuses the live row by code), so this
|
||||
is not a crash; the IDs only matter if a downstream system ever
|
||||
cross-references by deterministic ID, which nothing does today.
|
||||
|
||||
* ``downgrade()`` is a SOFT delete (``is_deleted=1``). Running
|
||||
``alembic downgrade`` followed by ``alembic upgrade`` will collide on
|
||||
the ``permission_id`` PRIMARY KEY — downgrade is a one-way trip on
|
||||
any environment that has run this migration. The role_scope fix is
|
||||
not reverted on downgrade (app code already keys off `platform`).
|
||||
|
||||
* If the legacy one-off ``migrations/data/migrate_system_json.py`` is
|
||||
ever run AFTER this migration on the same database, its
|
||||
``existing.get(permission_code)`` check will keep counts correct but
|
||||
reuses our rows; running it BEFORE this migration would cause
|
||||
``uk_permissions_code`` collisions on upgrade. Run this migration
|
||||
first on a fresh database.
|
||||
|
||||
Revision ID: f6a7b8c9d0e1
|
||||
Revises: e5f6a7b8c9d0 (ensure_demo_login)
|
||||
Create Date: 2026-08-07
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
# Hardcoded to match d4e5f6a7b8c9_squashed_baseline.py seed values, so the
|
||||
# role_permissions join rows below resolve against the right role rows.
|
||||
ADMIN_ROLE_ID = "0000000000000000000000000A"
|
||||
DEVELOPER_ROLE_ID = "0000000000000000000000000B"
|
||||
|
||||
PERMISSIONS: list[tuple[str, str, str]] = [
|
||||
# (permission_code, permission_name, module_code)
|
||||
("dashboard.view", "查看工作台", "dashboard"),
|
||||
("script.build", "构建脚本", "script"),
|
||||
("script.public.manage", "管理公共脚本", "script"),
|
||||
("schedule.own", "管理本人调度", "schedule"),
|
||||
("schedule.all", "管理全部调度", "schedule"),
|
||||
("experiment.own", "管理本人实验", "experiment"),
|
||||
("experiment.all", "管理全部实验", "experiment"),
|
||||
("resource.personal", "管理个人资源", "resource"),
|
||||
("resource.public.upload", "上传公共资源", "resource"),
|
||||
("resource.public.manage", "管理公共资源", "resource"),
|
||||
("system.view", "查看系统管理", "system"),
|
||||
("system.manage", "管理系统配置", "system"),
|
||||
]
|
||||
|
||||
# developer gets *.own + personal resource only; no system.*, no *.all.
|
||||
DEVELOPER_PERMISSION_CODES: list[str] = [
|
||||
"dashboard.view",
|
||||
"script.build",
|
||||
"script.public.manage",
|
||||
"schedule.own",
|
||||
"experiment.own",
|
||||
"resource.personal",
|
||||
]
|
||||
|
||||
ADMIN_PERMISSION_CODES: list[str] = [code for code, _, _ in PERMISSIONS]
|
||||
|
||||
|
||||
def _deterministic_permission_id(code: str) -> str:
|
||||
"""Stable 26-char ULID-shaped id derived from permission_code.
|
||||
|
||||
Mirrors ``migrations/data/migrate_system_json.py::deterministic_legacy_ulid``
|
||||
so re-running this migration (or running it after the legacy data
|
||||
migrator) keeps identical IDs for the same code.
|
||||
"""
|
||||
digest = hashlib.sha256(
|
||||
f"model-platform-permission-v1:{code}".encode("utf-8")
|
||||
).digest()
|
||||
value = int.from_bytes(b"\x00" * 6 + digest[:10], byteorder="big")
|
||||
encoded = ["0"] * 26
|
||||
for index in range(25, -1, -1):
|
||||
encoded[index] = CROCKFORD_BASE32[value & 31]
|
||||
value >>= 5
|
||||
return "".join(encoded)
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f6a7b8c9d0e1"
|
||||
down_revision: str | Sequence[str] | None = "e5f6a7b8c9d0"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# (1) role_scope fix: admin/developer were seeded as 'workspace' but
|
||||
# platform.py::system_admin_context treats them as platform-scoped.
|
||||
op.execute(
|
||||
"UPDATE roles SET role_scope = 'platform' "
|
||||
"WHERE role_code IN ('admin', 'developer') AND is_deleted = 0"
|
||||
)
|
||||
|
||||
# (2) Permissions rows.
|
||||
permissions_table = sa.table(
|
||||
"permissions",
|
||||
sa.column("permission_id", sa.CHAR(26)),
|
||||
sa.column("permission_code", sa.String(128)),
|
||||
sa.column("permission_name", sa.String(100)),
|
||||
sa.column("module_code", sa.String(64)),
|
||||
sa.column("description", sa.String(500)),
|
||||
)
|
||||
perm_id_by_code: dict[str, str] = {}
|
||||
rows: list[dict[str, str]] = []
|
||||
for code, name, module in PERMISSIONS:
|
||||
pid = _deterministic_permission_id(code)
|
||||
perm_id_by_code[code] = pid
|
||||
rows.append(
|
||||
{
|
||||
"permission_id": pid,
|
||||
"permission_code": code,
|
||||
"permission_name": name,
|
||||
"module_code": module,
|
||||
"description": f"platform 菜单权限:{name}",
|
||||
}
|
||||
)
|
||||
op.bulk_insert(permissions_table, rows)
|
||||
|
||||
# (3) role_permissions join rows.
|
||||
role_permissions_table = sa.table(
|
||||
"role_permissions",
|
||||
sa.column("role_id", sa.CHAR(26)),
|
||||
sa.column("permission_id", sa.CHAR(26)),
|
||||
)
|
||||
rp_rows: list[dict[str, str]] = []
|
||||
for code in ADMIN_PERMISSION_CODES:
|
||||
rp_rows.append(
|
||||
{
|
||||
"role_id": ADMIN_ROLE_ID,
|
||||
"permission_id": perm_id_by_code[code],
|
||||
}
|
||||
)
|
||||
for code in DEVELOPER_PERMISSION_CODES:
|
||||
rp_rows.append(
|
||||
{
|
||||
"role_id": DEVELOPER_ROLE_ID,
|
||||
"permission_id": perm_id_by_code[code],
|
||||
}
|
||||
)
|
||||
op.bulk_insert(role_permissions_table, rp_rows)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Soft-delete what we inserted. The role_scope fix is intentionally
|
||||
# NOT reverted — the only safe direction is platform, since
|
||||
# application code already keys off it.
|
||||
code_list_sql = "(" + ",".join(f"'{c}'" for c in ADMIN_PERMISSION_CODES) + ")"
|
||||
op.execute(
|
||||
"UPDATE role_permissions "
|
||||
"SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP(3) "
|
||||
f"WHERE permission_id IN (SELECT permission_id FROM permissions "
|
||||
f"WHERE is_deleted = 0 AND permission_code IN {code_list_sql})"
|
||||
)
|
||||
op.execute(
|
||||
"UPDATE permissions SET is_deleted = 1, deleted_at = CURRENT_TIMESTAMP(3) "
|
||||
f"WHERE is_deleted = 0 AND permission_code IN {code_list_sql}"
|
||||
)
|
||||
Reference in New Issue
Block a user