This commit is contained in:
Winnie
2026-08-05 17:46:05 +08:00
101 changed files with 8797 additions and 3323 deletions
+3 -2
View File
@@ -65,5 +65,6 @@ python -m migrations.data.migrate_legacy_workspaces --source "<path>/server.py"
## 第 11、12 小步数据处理决定
根据实施确认,第 11、12 小步不迁移旧版资源、脚本或稳定版本数据。新实现直接
使用 MySQL、Workspace 文件目录和 RustFS,从空的 `data_resources``scripts`
`versions` 表开始运行。功能验收产生的临时对象和数据库记录均已清理。
使用 MySQL、Workspace 文件目录和对象存储(s3 模式连 S3-兼容服务,local 模式
`/data/storage` 共享卷),从空的 `data_resources``scripts`
`versions` 表开始运行。功能验收产生的临时对象和数据库记录均已清理。
@@ -6,7 +6,6 @@ import asyncio
import hashlib
import json
import os
from collections import defaultdict
from pathlib import Path
from typing import Any
from urllib.parse import quote
@@ -17,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from common.db import create_database_engine, create_session_factory
from common.db.models import (
AuditLogs,
Roles,
Users,
WorkspaceMembers,
@@ -95,7 +93,6 @@ def new_stats() -> dict[str, int]:
"workspaces_updated": 0,
"workspace_members_inserted": 0,
"workspace_members_updated": 0,
"audit_logs_workspace_backfilled": 0,
}
@@ -266,44 +263,6 @@ async def migrate_members(
stats["workspace_members_updated"] += 1
async def backfill_audit_workspaces(
session: AsyncSession,
source: list[LegacyWorkspace],
workspace_ids: dict[str, str],
users: dict[str, Users],
stats: dict[str, int],
) -> None:
workspace_codes_by_username: dict[str, set[str]] = defaultdict(set)
for workspace in source:
for username in workspace.userIds:
workspace_codes_by_username[username].add(workspace.id)
workspace_by_user_id = {
users[username].user_id: workspace_ids[next(iter(codes))]
for username, codes in workspace_codes_by_username.items()
if len(codes) == 1
}
audit_logs = (
await session.scalars(
select(AuditLogs).where(AuditLogs.workspace_id.is_(None))
)
).all()
for audit_log in audit_logs:
if (
not isinstance(audit_log.detail_json, dict)
or audit_log.detail_json.get("migration_source")
!= "platform_data/system.json"
):
continue
workspace_id = workspace_by_user_id.get(
audit_log.actor_user_id
)
if workspace_id is not None:
audit_log.workspace_id = workspace_id
stats["audit_logs_workspace_backfilled"] += 1
async def run_migration(
database_url: str,
source: list[LegacyWorkspace],
@@ -339,13 +298,6 @@ async def run_migration(
users,
stats,
)
await backfill_audit_workspaces(
session,
source,
workspace_ids,
users,
stats,
)
if apply_changes:
await session.commit()
+11 -108
View File
@@ -5,18 +5,16 @@ import asyncio
import hashlib
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import quote
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from common.db import create_database_engine, create_session_factory
from common.db.models import (
AuditLogs,
Permissions,
RolePermissions,
Roles,
@@ -39,19 +37,6 @@ PERMISSION_NAMES = {
"resource.public.manage": "管理公共资源",
"system.view": "查看系统管理",
"system.manage": "管理系统配置",
"audit.view": "查看审计日志",
}
ACTION_CATALOG = {
"保存调度配置": ("schedule.save", "schedule"),
"删除脚本对象": ("script.delete", "script"),
"删除实验记录": ("experiment.delete", "experiment"),
"删除数据资源": ("data_resource.delete", "data_resource"),
"上传数据资源": ("data_resource.upload", "data_resource"),
"新建脚本对象": ("script.create", "script"),
"修改用户角色": ("user.role.update", "user"),
"运行 Python": ("script.run_python", "script"),
"运行调度": ("schedule.run", "schedule"),
}
@@ -74,26 +59,18 @@ class LegacyRole(BaseModel):
permissions: list[str]
class LegacyAuditLog(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
actorId: str
actorName: str
role: str
action: str
target: str
detail: str
status: str
createdAt: str
class LegacySystem(BaseModel):
model_config = ConfigDict(extra="forbid")
"""Legacy platform_data/system.json shape.
``extra='ignore'`` so legacy files that include other top-level
keys (e.g. historical audit-log payloads) can still be parsed —
only the fields this script actually consumes are listed below.
"""
model_config = ConfigDict(extra="ignore")
users: list[LegacyUser]
roles: list[LegacyRole]
audit_logs: list[LegacyAuditLog] = Field(alias="auditLogs")
def deterministic_legacy_ulid(entity_type: str, legacy_key: str) -> str:
@@ -120,10 +97,8 @@ def require_unique(values: list[str], label: str) -> None:
def validate_source(source: LegacySystem) -> None:
role_codes = [role.key for role in source.roles]
user_codes = [user.id for user in source.users]
audit_ids = [item.id for item in source.audit_logs]
require_unique(role_codes, "role keys")
require_unique(user_codes, "user ids")
require_unique(audit_ids, "audit ids")
role_code_set = set(role_codes)
unknown_roles = sorted(
@@ -134,15 +109,6 @@ def validate_source(source: LegacySystem) -> None:
if unknown_roles:
raise ValueError(f"users reference unknown roles: {unknown_roles}")
user_code_set = set(user_codes)
unknown_actors = sorted(
item.actorId
for item in source.audit_logs
if item.actorId not in user_code_set
)
if unknown_actors:
raise ValueError(f"audit logs reference unknown users: {unknown_actors}")
for role in source.roles:
require_unique(role.permissions, f"permissions of role {role.key}")
for permission_code in role.permissions:
@@ -177,8 +143,6 @@ def new_stats() -> dict[str, int]:
"role_permissions_inserted": 0,
"users_inserted": 0,
"users_updated": 0,
"audit_logs_inserted": 0,
"audit_logs_skipped": 0,
}
@@ -343,63 +307,6 @@ async def migrate_users(
return user_ids
def audit_catalog(action: str) -> tuple[str, str]:
known = ACTION_CATALOG.get(action)
if known is not None:
return known
digest = hashlib.sha256(action.encode("utf-8")).hexdigest()[:16]
return (f"legacy.action.{digest}", "legacy")
async def migrate_audit_logs(
session: AsyncSession,
source: LegacySystem,
user_ids: dict[str, str],
stats: dict[str, int],
) -> None:
existing_payloads = (
await session.scalars(select(AuditLogs.detail_json))
).all()
existing_legacy_ids = {
payload.get("legacy_id")
for payload in existing_payloads
if isinstance(payload, dict) and payload.get("legacy_id")
}
for legacy in sorted(source.audit_logs, key=lambda item: item.createdAt):
if legacy.id in existing_legacy_ids:
stats["audit_logs_skipped"] += 1
continue
action_code, target_type = audit_catalog(legacy.action)
session.add(
AuditLogs(
actor_user_id=user_ids[legacy.actorId],
action_code=action_code,
target_type=target_type,
target_id=legacy.target[:128] or None,
operation_status=(
"success" if legacy.status == "成功" else "failed"
),
created_at=datetime.strptime(
legacy.createdAt, "%Y-%m-%d %H:%M:%S"
),
detail_json={
"legacy_id": legacy.id,
"legacy_actor_name": legacy.actorName,
"legacy_role": legacy.role,
"legacy_action": legacy.action,
"legacy_target": legacy.target,
"legacy_detail": legacy.detail,
"legacy_status": legacy.status,
"migration_source": "platform_data/system.json",
},
)
)
existing_legacy_ids.add(legacy.id)
stats["audit_logs_inserted"] += 1
async def run_migration(
database_url: str,
source: LegacySystem,
@@ -424,12 +331,9 @@ async def run_migration(
permission_ids,
stats,
)
user_ids = await migrate_users(
await migrate_users(
session, source, role_ids, stats
)
await migrate_audit_logs(
session, source, user_ids, stats
)
if apply_changes:
await session.commit()
@@ -489,7 +393,6 @@ async def async_main() -> None:
len(role.permissions) for role in source.roles
),
"users": len(source.users),
"audit_logs": len(source.audit_logs),
},
"changes": stats,
}
@@ -501,4 +404,4 @@ def main() -> None:
if __name__ == "__main__":
main()
main()
@@ -1,57 +0,0 @@
"""enable password login for the seeded development users
Revision ID: 9a1b2c3d4e5f
Revises: b71c4f2a9d10
Create Date: 2026-08-03 16:00:00
"""
from collections.abc import Sequence
import os
from alembic import op
import sqlalchemy as sa
from common.auth.passwords import hash_password
revision: str = "9a1b2c3d4e5f"
down_revision: str | Sequence[str] | None = "b71c4f2a9d10"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
SEEDED_USER_IDS = (
"0000000000RF6FG1SDBXG59S13",
"0000000000H2QYCGPCWQM1JSGS",
"0000000000RWG40ESZPGJT629J",
"00000000004CQV7WASJA6N6FW4",
)
DISABLED_PASSWORD = "demo-login-disabled"
def upgrade() -> None:
password = os.environ.get("INITIAL_ADMIN_PASSWORD", "admin12345")
users = sa.table(
"users",
sa.column("user_id", sa.String),
sa.column("password_hash", sa.String),
)
op.execute(
users.update()
.where(users.c.user_id.in_(SEEDED_USER_IDS))
.where(users.c.password_hash == DISABLED_PASSWORD)
.values(password_hash=hash_password(password))
)
def downgrade() -> None:
users = sa.table(
"users",
sa.column("user_id", sa.String),
sa.column("password_hash", sa.String),
)
op.execute(
users.update()
.where(users.c.user_id.in_(SEEDED_USER_IDS))
.values(password_hash=DISABLED_PASSWORD)
)
@@ -1,33 +0,0 @@
"""add the RustFS trash object key
Revision ID: a2b3c4d5e6f7
Revises: 9a1b2c3d4e5f
Create Date: 2026-08-03 16:30:00
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "a2b3c4d5e6f7"
down_revision: str | Sequence[str] | None = "9a1b2c3d4e5f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"storage_objects",
sa.Column(
"trash_key",
sa.String(length=1100),
nullable=True,
comment="Path inside the trash bucket where soft-deleted bytes are stored",
),
)
def downgrade() -> None:
op.drop_column("storage_objects", "trash_key")
@@ -1,168 +0,0 @@
"""seed the self-hosted demo users and workspaces
Revision ID: b71c4f2a9d10
Revises: 8d86e2f82860
Create Date: 2026-07-31 16:00:00
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "b71c4f2a9d10"
down_revision: str | Sequence[str] | None = "8d86e2f82860"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
ADMIN_ROLE_ID = "0000000000000000000000000A"
DEVELOPER_ROLE_ID = "0000000000000000000000000B"
USERS = (
("0000000000RF6FG1SDBXG59S13", "admin-zhang", "张三", ADMIN_ROLE_ID),
("0000000000H2QYCGPCWQM1JSGS", "admin-li", "李四", ADMIN_ROLE_ID),
("0000000000RWG40ESZPGJT629J", "dev-wang", "王五", DEVELOPER_ROLE_ID),
("00000000004CQV7WASJA6N6FW4", "dev-zhao", "赵六", DEVELOPER_ROLE_ID),
)
WORKSPACES = (
("00000000000BM630VT9ARVFZPC", "model-development", "模型开发 Workspace"),
("0000000000AE0NC0V5T424KK86", "risk-validation", "风险验证 Workspace"),
)
def upgrade() -> None:
roles = sa.table(
"roles",
sa.column("role_id", sa.String),
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("role_scope", sa.String),
sa.column("is_builtin", sa.Integer),
sa.column("description", sa.String),
)
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),
)
workspaces = sa.table(
"workspaces",
sa.column("workspace_id", sa.String),
sa.column("workspace_code", sa.String),
sa.column("workspace_name", sa.String),
sa.column("active_root_uri", sa.String),
sa.column("status", sa.String),
sa.column("created_by", sa.String),
sa.column("description", sa.String),
)
members = sa.table(
"workspace_members",
sa.column("workspace_id", sa.String),
sa.column("user_id", sa.String),
sa.column("role_id", sa.String),
sa.column("member_status", sa.String),
)
op.bulk_insert(
roles,
[
{
"role_id": ADMIN_ROLE_ID,
"role_code": "admin",
"role_name": "管理员",
"role_scope": "workspace",
"is_builtin": 1,
"description": "Self-hosted workspace administrator",
},
{
"role_id": DEVELOPER_ROLE_ID,
"role_code": "developer",
"role_name": "开发人员",
"role_scope": "workspace",
"is_builtin": 1,
"description": "Self-hosted workspace developer",
},
],
)
op.bulk_insert(
users,
[
{
"user_id": user_id,
"username": username,
"display_name": display_name,
"password_hash": "demo-login-disabled",
"status": "active",
"email": f"{username}@model-platform.local",
"platform_role_id": role_id,
}
for user_id, username, display_name, role_id in USERS
],
)
op.bulk_insert(
workspaces,
[
{
"workspace_id": workspace_id,
"workspace_code": workspace_code,
"workspace_name": workspace_name,
"active_root_uri": f"s3://workspaces/{workspace_id}/",
"status": "active",
"created_by": USERS[0][0],
"description": "Self-hosted demo workspace",
}
for workspace_id, workspace_code, workspace_name in WORKSPACES
],
)
op.bulk_insert(
members,
[
{
"workspace_id": workspace_id,
"user_id": user_id,
"role_id": role_id,
"member_status": "active",
}
for workspace_id, _, _ in WORKSPACES
for user_id, _, _, role_id in USERS
],
)
def downgrade() -> None:
connection = op.get_bind()
workspace_ids = [workspace_id for workspace_id, _, _ in WORKSPACES]
user_ids = [user_id for user_id, _, _, _ in USERS]
connection.execute(
sa.text(
"DELETE FROM workspace_members "
"WHERE workspace_id IN :workspace_ids AND user_id IN :user_ids"
).bindparams(
sa.bindparam("workspace_ids", expanding=True),
sa.bindparam("user_ids", expanding=True),
),
{"workspace_ids": workspace_ids, "user_ids": user_ids},
)
connection.execute(
sa.text("DELETE FROM workspaces WHERE workspace_id IN :workspace_ids")
.bindparams(sa.bindparam("workspace_ids", expanding=True)),
{"workspace_ids": workspace_ids},
)
connection.execute(
sa.text("DELETE FROM users WHERE user_id IN :user_ids")
.bindparams(sa.bindparam("user_ids", expanding=True)),
{"user_ids": user_ids},
)
connection.execute(
sa.text("DELETE FROM roles WHERE role_id IN :role_ids")
.bindparams(sa.bindparam("role_ids", expanding=True)),
{"role_ids": [ADMIN_ROLE_ID, DEVELOPER_ROLE_ID]},
)
@@ -1,27 +0,0 @@
"""reconcile the legacy remote database revision
Revision ID: d4e5f6a7b8c9
Revises: a2b3c4d5e6f7
Create Date: 2026-08-05 15:30:00
The shared development database was stamped with this revision by an older
migration history. The corresponding file was lost while branches were
merged. Keeping the marker in the active chain lets Alembic safely continue
without rewriting the existing database or replaying the baseline migration.
"""
from collections.abc import Sequence
revision: str = "d4e5f6a7b8c9"
down_revision: str | Sequence[str] | None = "a2b3c4d5e6f7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Preserve the already-applied legacy revision marker."""
def downgrade() -> None:
"""The compatibility marker has no schema operation to reverse."""
@@ -1,46 +1,39 @@
"""initial baseline (20 active tables)
"""squashed baseline — full schema + seed data in one migration
Revision ID: 8d86e2f82860
Revises:
Create Date: 2026-07-31 13:17:52.047720
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
Revises: (none)
Create Date: 2026-08-05
"""
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 identifiers, used by Alembic.
revision: str = '8d86e2f82860'
revision: str = "d4e5f6a7b8c9"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_logs',
sa.Column('audit_id', mysql.BIGINT(), nullable=False),
sa.Column('action_code', sa.String(length=128), nullable=False),
sa.Column('target_type', sa.String(length=64), nullable=False),
sa.Column('operation_status', sa.String(length=16), server_default=sa.text("'success'"), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=True),
sa.Column('actor_user_id', mysql.CHAR(length=26), nullable=True),
sa.Column('target_id', sa.String(length=128), nullable=True),
sa.Column('client_ip', sa.String(length=45), nullable=True),
sa.Column('user_agent', sa.String(length=1000), nullable=True),
sa.Column('detail_json', sa.JSON(), 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('audit_id'),
comment='操作审计日志'
)
op.create_index('idx_audit_action_time', 'audit_logs', ['action_code', 'created_at'], unique=False)
op.create_index('idx_audit_actor_time', 'audit_logs', ['actor_user_id', 'created_at'], unique=False)
op.create_index('idx_audit_workspace_time', 'audit_logs', ['workspace_id', 'created_at'], unique=False)
"""Full schema from scratch (all 20 tables, current model state)."""
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),
@@ -287,7 +280,7 @@ def upgrade() -> None:
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('object_type', sa.String(length=16), nullable=False, comment='file/directory'),
sa.Column('usage_type', sa.String(length=32), nullable=False, comment='working_copy/public_script/data_resource/version_artifact/snapshot/run_log/run_result'),
sa.Column('storage_backend', sa.String(length=16), nullable=False, comment='rustfs'),
sa.Column('storage_backend', sa.String(length=16), nullable=False, comment='s3'),
sa.Column('storage_uri', sa.String(length=1500), nullable=False),
sa.Column('file_name', sa.String(length=255), nullable=False),
sa.Column('size_bytes', mysql.BIGINT(), server_default=sa.text('0'), nullable=False),
@@ -308,10 +301,11 @@ def upgrade() -> None:
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.PrimaryKeyConstraint('storage_object_id'),
comment='Workspace 文件和 RustFS 对象的统一元数据'
comment='Workspace 文件和 S3 对象的统一元数据'
)
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)
@@ -338,10 +332,15 @@ 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('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='RustFS 预签名上传会话;URL 本身不持久化'
comment='S3 上传会话;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)
@@ -375,7 +374,7 @@ def upgrade() -> None:
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('script_id', mysql.CHAR(length=26), nullable=False),
sa.Column('source_object_id', mysql.CHAR(length=26), nullable=False, comment='发布时的源对象'),
sa.Column('artifact_object_id', mysql.CHAR(length=26), nullable=False, comment='RustFS 不可变版本制品'),
sa.Column('artifact_object_id', mysql.CHAR(length=26), nullable=False, comment='S3 不可变版本制品'),
sa.Column('version_no', mysql.INTEGER(), nullable=False),
sa.Column('version_label', sa.String(length=32), nullable=False, comment='例如 v1.0'),
sa.Column('source_path', sa.String(length=1024), nullable=False, comment='发布时路径快照'),
@@ -412,29 +411,6 @@ def upgrade() -> None:
)
op.create_index('idx_workspace_members_role', 'workspace_members', ['role_id'], unique=False)
op.create_index('idx_workspace_members_user', 'workspace_members', ['user_id', 'member_status'], unique=False)
op.create_table('workspace_operations',
sa.Column('operation_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('operation_type', sa.String(length=24), nullable=False, comment='open/close/mount/unmount/start/stop/restart/recycle'),
sa.Column('operation_status', sa.String(length=24), server_default=sa.text("'pending'"), nullable=False, comment='pending/running/succeeded/failed/cancelled'),
sa.Column('state_version', mysql.INTEGER(), server_default=sa.text('0'), nullable=False, comment='乐观锁版本'),
sa.Column('requested_by', mysql.CHAR(length=26), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('runtime_id', mysql.CHAR(length=26), nullable=True),
sa.Column('request_id', sa.String(length=128), nullable=True),
sa.Column('started_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('finished_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('error_code', sa.String(length=64), nullable=True),
sa.Column('error_message', sa.Text(), 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('operation_id'),
comment='无状态 Backend 的 Workspace/Jupyter 异步操作记录'
)
op.create_index('fk_workspace_operations_user', 'workspace_operations', ['requested_by'], unique=False)
op.create_index('idx_workspace_operations_runtime', 'workspace_operations', ['runtime_id', 'created_at'], unique=False)
op.create_index('idx_workspace_operations_workspace', 'workspace_operations', ['workspace_id', 'operation_status', 'created_at'], unique=False)
op.create_index('uk_workspace_operations_request', 'workspace_operations', ['request_id'], unique=True)
op.create_table('workspaces',
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workspace_code', sa.String(length=64), nullable=False),
@@ -447,8 +423,8 @@ def upgrade() -> None:
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('description', sa.String(length=1000), nullable=True),
sa.Column('artifact_bucket', sa.String(length=128), nullable=True, comment='RustFS bucket'),
sa.Column('artifact_prefix', sa.String(length=512), nullable=True, comment='RustFS object key prefix'),
sa.Column('artifact_bucket', sa.String(length=128), nullable=True, comment='S3 bucket'),
sa.Column('artifact_prefix', sa.String(length=512), nullable=True, comment='S3 object key prefix'),
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('workspace_id'),
@@ -457,21 +433,150 @@ 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"
USERS = (
("0000000000RF6FG1SDBXG59S13", "admin-zhang", "张三", ADMIN_ROLE_ID),
("0000000000H2QYCGPCWQM1JSGS", "admin-li", "李四", ADMIN_ROLE_ID),
("0000000000RWG40ESZPGJT629J", "dev-wang", "王五", DEVELOPER_ROLE_ID),
("00000000004CQV7WASJA6N6FW4", "dev-zhao", "赵六", DEVELOPER_ROLE_ID),
)
WORKSPACES = (
("00000000000BM630VT9ARVFZPC", "model-development", "模型开发 Workspace"),
("0000000000AE0NC0V5T424KK86", "risk-validation", "风险验证 Workspace"),
)
roles = sa.table(
"roles",
sa.column("role_id", sa.String),
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("role_scope", sa.String),
sa.column("is_builtin", sa.Integer),
sa.column("description", sa.String),
)
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),
)
workspaces = sa.table(
"workspaces",
sa.column("workspace_id", sa.String),
sa.column("workspace_code", sa.String),
sa.column("workspace_name", sa.String),
sa.column("active_root_uri", sa.String),
sa.column("status", sa.String),
sa.column("created_by", sa.String),
sa.column("description", sa.String),
)
members = sa.table(
"workspace_members",
sa.column("workspace_id", sa.String),
sa.column("user_id", sa.String),
sa.column("role_id", sa.String),
sa.column("member_status", sa.String),
)
op.bulk_insert(
roles,
[
{
"role_id": ADMIN_ROLE_ID,
"role_code": "admin",
"role_name": "管理员",
"role_scope": "workspace",
"is_builtin": 1,
"description": "Self-hosted workspace administrator",
},
{
"role_id": DEVELOPER_ROLE_ID,
"role_code": "developer",
"role_name": "开发人员",
"role_scope": "workspace",
"is_builtin": 1,
"description": "Self-hosted workspace developer",
},
],
)
op.bulk_insert(
users,
[
{
"user_id": user_id,
"username": username,
"display_name": display_name,
"password_hash": "demo-login-disabled",
"status": "active",
"email": f"{username}@model-platform.local",
"platform_role_id": role_id,
}
for user_id, username, display_name, role_id in USERS
],
)
op.bulk_insert(
workspaces,
[
{
"workspace_id": workspace_id,
"workspace_code": workspace_code,
"workspace_name": workspace_name,
"active_root_uri": f"s3://workspaces/{workspace_id}/",
"status": "active",
"created_by": USERS[0][0],
"description": "Self-hosted demo workspace",
}
for workspace_id, workspace_code, workspace_name in WORKSPACES
],
)
op.bulk_insert(
members,
[
{
"workspace_id": workspace_id,
"user_id": user_id,
"role_id": role_id,
"member_status": "active",
}
for workspace_id, _, _ in WORKSPACES
for user_id, _, _, role_id in USERS
],
)
# ── enable demo password login (from 9a1b2c3d4e5f) ─────────────
password = os.environ.get("INITIAL_ADMIN_PASSWORD", "admin12345")
seeded_user_ids = (
"0000000000RF6FG1SDBXG59S13",
"0000000000H2QYCGPCWQM1JSGS",
"0000000000RWG40ESZPGJT629J",
"00000000004CQV7WASJA6N6FW4",
)
users_update = sa.table(
"users",
sa.column("user_id", sa.String),
sa.column("password_hash", sa.String),
)
op.execute(
users_update.update()
.where(users_update.c.user_id.in_(seeded_user_ids))
.where(users_update.c.password_hash == "demo-login-disabled")
.values(password_hash=hash_password(password))
)
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
"""Drop everything (reverse of upgrade)."""
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')
op.drop_table('workspaces')
op.drop_index('uk_workspace_operations_request', table_name='workspace_operations')
op.drop_index('idx_workspace_operations_workspace', table_name='workspace_operations')
op.drop_index('idx_workspace_operations_runtime', table_name='workspace_operations')
op.drop_index('fk_workspace_operations_user', table_name='workspace_operations')
op.drop_table('workspace_operations')
op.drop_index('idx_workspace_members_user', table_name='workspace_members')
op.drop_index('idx_workspace_members_role', table_name='workspace_members')
op.drop_table('workspace_members')
@@ -550,9 +655,4 @@ def downgrade() -> None:
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')
op.drop_index('idx_audit_workspace_time', table_name='audit_logs')
op.drop_index('idx_audit_actor_time', table_name='audit_logs')
op.drop_index('idx_audit_action_time', table_name='audit_logs')
op.drop_table('audit_logs')
# ### end Alembic commands ###
op.drop_table('consumer_inbox')