fix: file upload error

This commit is contained in:
tao.chen
2026-08-14 18:22:46 +08:00
parent c65d6dc684
commit 5d49ff5e34
14 changed files with 872 additions and 604 deletions
@@ -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,
)
+14
View File
@@ -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,
)
@@ -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}"
)