feat: 初始化考培练系统项目

- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
111
2026-01-24 19:33:28 +08:00
commit 998211c483
1197 changed files with 228429 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
"""add_position_courses_table
Revision ID: 0487635b5e95
Revises: 5448c81e7afd
Create Date: 2025-09-22 08:27:52.507507
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0487635b5e95'
down_revision: Union[str, None] = '5448c81e7afd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('position_courses',
sa.Column('position_id', sa.Integer(), nullable=False, comment='岗位ID'),
sa.Column('course_id', sa.Integer(), nullable=False, comment='课程ID'),
sa.Column('course_type', sa.String(length=20), nullable=False, comment='课程类型'),
sa.Column('priority', sa.Integer(), nullable=True, comment='优先级/排序'),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('is_deleted', sa.Boolean(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ),
sa.ForeignKeyConstraint(['position_id'], ['positions.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('position_id', 'course_id', 'is_deleted', name='uix_position_course')
)
op.create_index(op.f('ix_position_courses_id'), 'position_courses', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_position_courses_id'), table_name='position_courses')
op.drop_table('position_courses')
# ### end Alembic commands ###

View File

@@ -0,0 +1,157 @@
"""
Align database schema to the unified design spec.
Changes:
- users: id INT, set defaults, drop extra columns not in design
- user_teams/exams: user_id INT
- teams: drop created_by/updated_by
- training_*: fix defaults; set created_by/updated_by to BIGINT where required
- training_reports: add unique(session_id)
- create or replace view v_user_course_progress
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20250921_align_schema_to_design"
down_revision = "9245f8845fe1"
branch_labels = None
depends_on = None
def upgrade():
connection = op.get_bind()
# users: enforce INT PK, defaults, and drop extra columns
op.execute("""
ALTER TABLE `users`
MODIFY COLUMN `id` INT AUTO_INCREMENT,
MODIFY COLUMN `username` VARCHAR(50) NOT NULL,
MODIFY COLUMN `email` VARCHAR(100) NOT NULL,
MODIFY COLUMN `password_hash` VARCHAR(200) NOT NULL,
MODIFY COLUMN `role` VARCHAR(20) DEFAULT 'trainee',
MODIFY COLUMN `is_active` TINYINT(1) DEFAULT 1,
MODIFY COLUMN `is_verified` TINYINT(1) DEFAULT 0,
MODIFY COLUMN `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
MODIFY COLUMN `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
""")
# Drop extra columns that are not in the design (ignore errors if absent)
for col in [
"is_superuser",
"department",
"position",
"last_login",
"login_count",
"failed_login_count",
"locked_until",
"created_by",
"updated_by",
"is_deleted",
"deleted_at",
]:
try:
op.execute(f"ALTER TABLE `users` DROP COLUMN `{col}`")
except Exception:
pass
# user_teams: user_id INT
op.execute("""
ALTER TABLE `user_teams`
MODIFY COLUMN `user_id` INT NOT NULL
""")
# exams: user_id INT
op.execute("""
ALTER TABLE `exams`
MODIFY COLUMN `user_id` INT NOT NULL
""")
# teams: drop created_by/updated_by to match design
for col in ["created_by", "updated_by"]:
try:
op.execute(f"ALTER TABLE `teams` DROP COLUMN `{col}`")
except Exception:
pass
# training_scenes: set defaults and BIGINT audit fields
op.execute("""
ALTER TABLE `training_scenes`
MODIFY COLUMN `status` ENUM('DRAFT','ACTIVE','INACTIVE') NOT NULL DEFAULT 'DRAFT',
MODIFY COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1,
MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
MODIFY COLUMN `created_by` BIGINT NULL,
MODIFY COLUMN `updated_by` BIGINT NULL
""")
# training_sessions: defaults and BIGINT audit fields
op.execute("""
ALTER TABLE `training_sessions`
MODIFY COLUMN `start_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY COLUMN `status` ENUM('CREATED','IN_PROGRESS','COMPLETED','CANCELLED','ERROR') NOT NULL DEFAULT 'CREATED',
MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
MODIFY COLUMN `created_by` BIGINT NULL,
MODIFY COLUMN `updated_by` BIGINT NULL
""")
# training_messages: timestamps defaults
op.execute("""
ALTER TABLE `training_messages`
MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
""")
# training_reports: timestamps defaults and BIGINT audit fields
op.execute("""
ALTER TABLE `training_reports`
MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
MODIFY COLUMN `created_by` BIGINT NULL,
MODIFY COLUMN `updated_by` BIGINT NULL
""")
# Add unique constraint for training_reports.session_id per design
try:
op.create_unique_constraint(
"uq_training_reports_session_id", "training_reports", ["session_id"]
)
except Exception:
pass
# Create or replace view v_user_course_progress
op.execute("""
CREATE OR REPLACE VIEW v_user_course_progress AS
SELECT
u.id AS user_id,
u.username,
c.id AS course_id,
c.name AS course_name,
COUNT(DISTINCT e.id) AS exam_count,
AVG(e.score) AS avg_score,
MAX(e.score) AS best_score
FROM users u
CROSS JOIN courses c
LEFT JOIN exams e
ON e.user_id = u.id
AND e.course_id = c.id
AND e.status = 'submitted'
GROUP BY u.id, c.id
""")
def downgrade():
# Drop the view to rollback
try:
op.execute("DROP VIEW IF EXISTS v_user_course_progress")
except Exception:
pass
# Note: Full downgrade to previous heterogeneous state is not implemented to avoid data loss.
# Keep this as a no-op for column-level reversions.
pass

View File

@@ -0,0 +1,55 @@
"""
add positions table
Revision ID: 20250922_add_positions
Revises: 20250921_align_schema_to_design
Create Date: 2025-09-22
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = "20250922_add_positions"
down_revision = "20250921_align_schema_to_design"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = inspect(bind)
if not inspector.has_table("positions"):
op.create_table(
"positions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("code", sa.String(length=100), nullable=False, unique=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("parent_id", sa.Integer(), sa.ForeignKey("positions.id", ondelete="SET NULL"), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("updated_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
# 创建索引(若不存在)
try:
indexes = [ix.get("name") for ix in inspector.get_indexes("positions")] # type: ignore
except Exception:
indexes = []
if "ix_positions_name" not in indexes:
op.create_index("ix_positions_name", "positions", ["name"], unique=False)
def downgrade() -> None:
op.drop_index("ix_positions_name", table_name="positions")
op.drop_table("positions")

View File

@@ -0,0 +1,26 @@
"""merge_multiple_heads
Revision ID: 3d5b88fe1875
Revises: 20250922_add_positions, add_users_soft_delete
Create Date: 2025-09-22 08:09:11.966673
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3d5b88fe1875'
down_revision: Union[str, None] = ('20250922_add_positions', 'add_users_soft_delete')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,46 @@
"""add_position_members_table
Revision ID: 5448c81e7afd
Revises: 3d5b88fe1875
Create Date: 2025-09-22 08:13:54.755269
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '5448c81e7afd'
down_revision: Union[str, None] = '3d5b88fe1875'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('position_members',
sa.Column('position_id', sa.Integer(), nullable=False, comment='岗位ID'),
sa.Column('user_id', sa.Integer(), nullable=False, comment='用户ID'),
sa.Column('role', sa.String(length=50), nullable=True, comment='成员角色(预留字段)'),
sa.Column('joined_at', sa.DateTime(), nullable=True, comment='加入时间'),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('is_deleted', sa.Boolean(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['position_id'], ['positions.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('position_id', 'user_id', 'is_deleted', name='uix_position_user')
)
op.create_index(op.f('ix_position_members_id'), 'position_members', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_position_members_id'), table_name='position_members')
op.drop_table('position_members')
# ### end Alembic commands ###

View File

@@ -0,0 +1,708 @@
"""add training models
Revision ID: 9245f8845fe1
Revises: 001
Create Date: 2025-09-21 22:11:03.319902
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '9245f8845fe1'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('training_scenes',
sa.Column('name', sa.String(length=100), nullable=False, comment='场景名称'),
sa.Column('description', sa.Text(), nullable=True, comment='场景描述'),
sa.Column('category', sa.String(length=50), nullable=False, comment='场景分类'),
sa.Column('ai_config', sa.JSON(), nullable=True, comment='AI配置如Coze Bot ID等'),
sa.Column('prompt_template', sa.Text(), nullable=True, comment='提示词模板'),
sa.Column('evaluation_criteria', sa.JSON(), nullable=True, comment='评估标准'),
sa.Column('status', sa.Enum('DRAFT', 'ACTIVE', 'INACTIVE', name='trainingscenestatus'), nullable=False, comment='场景状态'),
sa.Column('is_public', sa.Boolean(), nullable=False, comment='是否公开'),
sa.Column('required_level', sa.Integer(), nullable=True, comment='所需用户等级'),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('is_deleted', sa.Boolean(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('updated_by', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_training_scenes_id'), 'training_scenes', ['id'], unique=False)
op.create_table('training_sessions',
sa.Column('user_id', sa.Integer(), nullable=False, comment='用户ID'),
sa.Column('scene_id', sa.Integer(), nullable=False, comment='场景ID'),
sa.Column('coze_conversation_id', sa.String(length=100), nullable=True, comment='Coze会话ID'),
sa.Column('start_time', sa.DateTime(), nullable=False, comment='开始时间'),
sa.Column('end_time', sa.DateTime(), nullable=True, comment='结束时间'),
sa.Column('duration_seconds', sa.Integer(), nullable=True, comment='持续时长(秒)'),
sa.Column('status', sa.Enum('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR', name='trainingsessionstatus'), nullable=False, comment='会话状态'),
sa.Column('session_config', sa.JSON(), nullable=True, comment='会话配置'),
sa.Column('total_score', sa.Float(), nullable=True, comment='总分'),
sa.Column('evaluation_result', sa.JSON(), nullable=True, comment='评估结果详情'),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('updated_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['scene_id'], ['training_scenes.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_training_sessions_id'), 'training_sessions', ['id'], unique=False)
op.create_index(op.f('ix_training_sessions_user_id'), 'training_sessions', ['user_id'], unique=False)
op.create_table('training_messages',
sa.Column('session_id', sa.Integer(), nullable=False, comment='会话ID'),
sa.Column('role', sa.Enum('USER', 'ASSISTANT', 'SYSTEM', name='messagerole'), nullable=False, comment='消息角色'),
sa.Column('type', sa.Enum('TEXT', 'VOICE', 'SYSTEM', name='messagetype'), nullable=False, comment='消息类型'),
sa.Column('content', sa.Text(), nullable=False, comment='消息内容'),
sa.Column('voice_url', sa.String(length=500), nullable=True, comment='语音文件URL'),
sa.Column('voice_duration', sa.Float(), nullable=True, comment='语音时长(秒)'),
sa.Column('message_metadata', sa.JSON(), nullable=True, comment='消息元数据'),
sa.Column('coze_message_id', sa.String(length=100), nullable=True, comment='Coze消息ID'),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['session_id'], ['training_sessions.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_training_messages_id'), 'training_messages', ['id'], unique=False)
op.create_table('training_reports',
sa.Column('session_id', sa.Integer(), nullable=False, comment='会话ID'),
sa.Column('user_id', sa.Integer(), nullable=False, comment='用户ID'),
sa.Column('overall_score', sa.Float(), nullable=False, comment='总体得分'),
sa.Column('dimension_scores', sa.JSON(), nullable=False, comment='各维度得分'),
sa.Column('strengths', sa.JSON(), nullable=False, comment='优势点'),
sa.Column('weaknesses', sa.JSON(), nullable=False, comment='待改进点'),
sa.Column('suggestions', sa.JSON(), nullable=False, comment='改进建议'),
sa.Column('detailed_analysis', sa.Text(), nullable=True, comment='详细分析'),
sa.Column('transcript', sa.Text(), nullable=True, comment='对话文本记录'),
sa.Column('statistics', sa.JSON(), nullable=True, comment='统计数据'),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('updated_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['session_id'], ['training_sessions.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('session_id')
)
op.create_index(op.f('ix_training_reports_id'), 'training_reports', ['id'], unique=False)
op.create_index(op.f('ix_training_reports_user_id'), 'training_reports', ['user_id'], unique=False)
op.create_table('user_teams',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('team_id', sa.Integer(), nullable=False),
sa.Column('role', sa.String(length=50), nullable=False),
sa.Column('joined_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('user_id', 'team_id'),
sa.UniqueConstraint('user_id', 'team_id', name='uq_user_team')
)
op.alter_column('course_materials', 'course_id',
existing_type=mysql.INTEGER(),
comment='课程ID',
existing_comment='课ç¨ID',
existing_nullable=False)
op.alter_column('course_materials', 'name',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200),
comment='资料名称',
existing_comment='资料å\x90\x8dç§°',
existing_nullable=False)
op.alter_column('course_materials', 'description',
existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'),
comment='资料描述',
existing_comment='资料æ\x8f\x8fè¿°',
existing_nullable=True)
op.alter_column('course_materials', 'file_url',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500),
comment='文件URL',
existing_comment='æ‡ä»¶URL',
existing_nullable=False)
op.alter_column('course_materials', 'file_type',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=50),
comment='文件类型',
existing_comment='文件类型',
existing_nullable=False)
op.alter_column('course_materials', 'file_size',
existing_type=mysql.INTEGER(),
comment='文件大小(字节)',
existing_comment='文件大å°\x8f\xad—节)',
existing_nullable=False)
op.alter_column('course_materials', 'sort_order',
existing_type=mysql.INTEGER(),
comment='排序顺序',
existing_comment='排åº\x8f顺åº\x8f',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
op.drop_index('idx_course_materials_course_id', table_name='course_materials')
op.drop_index('idx_course_materials_is_deleted', table_name='course_materials')
op.create_index(op.f('ix_course_materials_id'), 'course_materials', ['id'], unique=False)
op.drop_table_comment(
'course_materials',
existing_comment='课程资料表',
schema=None
)
op.alter_column('courses', 'name',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200),
comment='课程名称',
existing_comment='课程å\x90\x8dç§°',
existing_nullable=False)
op.alter_column('courses', 'description',
existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'),
comment='课程描述',
existing_comment='课程æ\x8f\x8fè¿°',
existing_nullable=True)
op.alter_column('courses', 'category',
existing_type=mysql.ENUM('technology', 'management', 'business', 'general', collation='utf8mb4_unicode_ci'),
comment='课程分类',
existing_comment='课程分类',
existing_nullable=False,
existing_server_default=sa.text("'general'"))
op.alter_column('courses', 'status',
existing_type=mysql.ENUM('draft', 'published', 'archived', collation='utf8mb4_unicode_ci'),
comment='课程状态',
existing_comment='课程状æ€\x81',
existing_nullable=False,
existing_server_default=sa.text("'draft'"))
op.alter_column('courses', 'cover_image',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500),
comment='封面图片URL',
existing_comment='å°\x81é\x9d¢å¾ç‰‡URL',
existing_nullable=True)
op.alter_column('courses', 'duration_hours',
existing_type=mysql.FLOAT(),
comment='课程时长(小时)',
existing_comment='课程时长(å°\x8fæ—¶)',
existing_nullable=True)
op.alter_column('courses', 'difficulty_level',
existing_type=mysql.INTEGER(),
comment='难度等级(1-5)',
existing_comment='难度ç\xad‰çº§(1-5)',
existing_nullable=True)
op.alter_column('courses', 'tags',
existing_type=mysql.JSON(),
comment='标签列表',
existing_comment='æ\xa0‡ç\xad¾åˆ—表',
existing_nullable=True)
op.alter_column('courses', 'published_at',
existing_type=mysql.DATETIME(),
comment='发布时间',
existing_comment='å\x8f‘布时间',
existing_nullable=True)
op.alter_column('courses', 'publisher_id',
existing_type=mysql.INTEGER(),
comment='发布人ID',
existing_comment='å\x8f布人ID',
existing_nullable=True)
op.alter_column('courses', 'sort_order',
existing_type=mysql.INTEGER(),
comment='排序顺序',
existing_comment='排åº\x8f顺åº\x8f',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
op.alter_column('courses', 'is_featured',
existing_type=mysql.TINYINT(display_width=1),
comment='是否推荐',
existing_comment='是å\x90¦æŽ¨è\x8d\x90',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
op.drop_index('idx_courses_category', table_name='courses')
op.drop_index('idx_courses_is_deleted', table_name='courses')
op.drop_index('idx_courses_status', table_name='courses')
op.create_index(op.f('ix_courses_id'), 'courses', ['id'], unique=False)
op.drop_table_comment(
'courses',
existing_comment='课程表',
schema=None
)
op.alter_column('growth_paths', 'name',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200),
comment='路径名称',
existing_comment='路径å\x90\x8dç§°',
existing_nullable=False)
op.alter_column('growth_paths', 'description',
existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'),
comment='路径描述',
existing_comment='路径æ\x8f\x8fè¿°',
existing_nullable=True)
op.alter_column('growth_paths', 'target_role',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=100),
comment='目标角色',
existing_comment='ç›®æ\xa0‡è§’色',
existing_nullable=True)
op.alter_column('growth_paths', 'courses',
existing_type=mysql.JSON(),
comment='课程列表[{course_id, order, is_required}]',
existing_comment='课程列表[{course_id, order, is_required}]',
existing_nullable=True)
op.alter_column('growth_paths', 'estimated_duration_days',
existing_type=mysql.INTEGER(),
comment='预计完成天数',
existing_comment='预计完æˆ\x90天数',
existing_nullable=True)
op.alter_column('growth_paths', 'is_active',
existing_type=mysql.TINYINT(display_width=1),
comment='是否启用',
existing_comment='是å\x90¦å\x90¯ç”¨',
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.alter_column('growth_paths', 'sort_order',
existing_type=mysql.INTEGER(),
comment='排序顺序',
existing_comment='排åº\x8f顺åº\x8f',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
op.drop_index('idx_growth_paths_is_active', table_name='growth_paths')
op.drop_index('idx_growth_paths_is_deleted', table_name='growth_paths')
op.create_index(op.f('ix_growth_paths_id'), 'growth_paths', ['id'], unique=False)
op.drop_table_comment(
'growth_paths',
existing_comment='æˆ\x90长路径表',
schema=None
)
op.alter_column('knowledge_points', 'course_id',
existing_type=mysql.INTEGER(),
comment='课程ID',
existing_comment='课ç¨ID',
existing_nullable=False)
op.alter_column('knowledge_points', 'name',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200),
comment='知识点名称',
existing_comment='知识点å\x90\x8dç§°',
existing_nullable=False)
op.alter_column('knowledge_points', 'description',
existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'),
comment='知识点描述',
existing_comment='知识点æ\x8f\x8fè¿°',
existing_nullable=True)
op.alter_column('knowledge_points', 'parent_id',
existing_type=mysql.INTEGER(),
comment='父知识点ID',
existing_comment='父知识ç¹ID',
existing_nullable=True)
op.alter_column('knowledge_points', 'level',
existing_type=mysql.INTEGER(),
comment='层级深度',
existing_comment='层级深度',
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.alter_column('knowledge_points', 'path',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500),
comment='路径(如: 1.2.3)',
existing_comment='路径(如: 1.2.3)',
existing_nullable=True)
op.alter_column('knowledge_points', 'sort_order',
existing_type=mysql.INTEGER(),
comment='排序顺序',
existing_comment='排åº\x8f顺åº\x8f',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
op.alter_column('knowledge_points', 'weight',
existing_type=mysql.FLOAT(),
comment='权重',
existing_comment='æ\x9dƒé‡\x8d',
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.alter_column('knowledge_points', 'is_required',
existing_type=mysql.TINYINT(display_width=1),
comment='是否必修',
existing_comment='是å\x90¦å¿…ä¿®',
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.alter_column('knowledge_points', 'estimated_hours',
existing_type=mysql.FLOAT(),
comment='预计学习时间(小时)',
existing_comment='预计å\xad¦ä¹\xa0æ—¶é—´(å°\x8fæ—¶)',
existing_nullable=True)
op.drop_index('idx_knowledge_points_course_id', table_name='knowledge_points')
op.drop_index('idx_knowledge_points_is_deleted', table_name='knowledge_points')
op.drop_index('idx_knowledge_points_parent_id', table_name='knowledge_points')
op.create_index(op.f('ix_knowledge_points_id'), 'knowledge_points', ['id'], unique=False)
op.drop_table_comment(
'knowledge_points',
existing_comment='知识点表',
schema=None
)
op.drop_index('ix_teams_is_deleted', table_name='teams')
op.drop_index('ix_teams_parent_id', table_name='teams')
op.create_foreign_key(None, 'teams', 'users', ['leader_id'], ['id'], ondelete='SET NULL')
op.create_foreign_key(None, 'teams', 'teams', ['parent_id'], ['id'], ondelete='CASCADE')
op.drop_column('teams', 'deleted_at')
op.drop_column('teams', 'created_by')
op.drop_column('teams', 'updated_by')
op.drop_column('teams', 'is_deleted')
op.add_column('users', sa.Column('bio', sa.Text(), nullable=True))
op.add_column('users', sa.Column('is_verified', sa.Boolean(), nullable=False))
op.add_column('users', sa.Column('last_login_at', sa.DateTime(), nullable=True))
op.add_column('users', sa.Column('password_changed_at', sa.DateTime(), nullable=True))
op.alter_column('users', 'username',
existing_type=mysql.VARCHAR(length=50),
comment=None,
existing_comment='用户名',
existing_nullable=False)
op.alter_column('users', 'email',
existing_type=mysql.VARCHAR(length=100),
comment=None,
existing_comment='邮箱',
existing_nullable=False)
op.alter_column('users', 'phone',
existing_type=mysql.VARCHAR(length=20),
comment=None,
existing_comment='手机号',
existing_nullable=True)
op.alter_column('users', 'password_hash',
existing_type=mysql.VARCHAR(length=200),
comment=None,
existing_comment='密码哈希',
existing_nullable=False)
op.alter_column('users', 'full_name',
existing_type=mysql.VARCHAR(length=100),
comment=None,
existing_comment='全名',
existing_nullable=True)
op.alter_column('users', 'avatar_url',
existing_type=mysql.VARCHAR(length=500),
comment=None,
existing_comment='头像URL',
existing_nullable=True)
op.alter_column('users', 'role',
existing_type=mysql.VARCHAR(length=20),
nullable=False,
comment=None,
existing_comment='角色: trainee(学员), manager(管理者), admin(管理员)')
op.alter_column('users', 'is_active',
existing_type=mysql.TINYINT(display_width=1),
nullable=False,
comment=None,
existing_comment='是否激活')
op.alter_column('users', 'id',
existing_type=mysql.BIGINT(),
type_=sa.Integer(),
existing_nullable=False,
autoincrement=True)
op.create_unique_constraint(None, 'users', ['phone'])
op.drop_column('users', 'position')
op.drop_column('users', 'is_superuser')
op.drop_column('users', 'locked_until')
op.drop_column('users', 'login_count')
op.drop_column('users', 'failed_login_count')
op.drop_column('users', 'department')
op.drop_column('users', 'created_by')
op.drop_column('users', 'updated_by')
op.drop_column('users', 'last_login')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('last_login', mysql.VARCHAR(length=100), nullable=True, comment='最后登录时间'))
op.add_column('users', sa.Column('updated_by', mysql.BIGINT(), autoincrement=False, nullable=True))
op.add_column('users', sa.Column('created_by', mysql.BIGINT(), autoincrement=False, nullable=True))
op.add_column('users', sa.Column('department', mysql.VARCHAR(length=100), nullable=True, comment='部门'))
op.add_column('users', sa.Column('failed_login_count', mysql.VARCHAR(length=100), nullable=True, comment='失败登录次数'))
op.add_column('users', sa.Column('login_count', mysql.VARCHAR(length=100), nullable=True, comment='登录次数'))
op.add_column('users', sa.Column('locked_until', mysql.VARCHAR(length=100), nullable=True, comment='锁定到期时间'))
op.add_column('users', sa.Column('is_superuser', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True, comment='是否超级管理员'))
op.add_column('users', sa.Column('position', mysql.VARCHAR(length=100), nullable=True, comment='职位'))
op.drop_constraint(None, 'users', type_='unique')
op.alter_column('users', 'id',
existing_type=sa.Integer(),
type_=mysql.BIGINT(),
existing_nullable=False,
autoincrement=True)
op.alter_column('users', 'is_active',
existing_type=mysql.TINYINT(display_width=1),
nullable=True,
comment='是否激活')
op.alter_column('users', 'role',
existing_type=mysql.VARCHAR(length=20),
nullable=True,
comment='角色: trainee(学员), manager(管理者), admin(管理员)')
op.alter_column('users', 'avatar_url',
existing_type=mysql.VARCHAR(length=500),
comment='头像URL',
existing_nullable=True)
op.alter_column('users', 'full_name',
existing_type=mysql.VARCHAR(length=100),
comment='全名',
existing_nullable=True)
op.alter_column('users', 'password_hash',
existing_type=mysql.VARCHAR(length=200),
comment='密码哈希',
existing_nullable=False)
op.alter_column('users', 'phone',
existing_type=mysql.VARCHAR(length=20),
comment='手机号',
existing_nullable=True)
op.alter_column('users', 'email',
existing_type=mysql.VARCHAR(length=100),
comment='邮箱',
existing_nullable=False)
op.alter_column('users', 'username',
existing_type=mysql.VARCHAR(length=50),
comment='用户名',
existing_nullable=False)
op.drop_column('users', 'password_changed_at')
op.drop_column('users', 'last_login_at')
op.drop_column('users', 'is_verified')
op.drop_column('users', 'bio')
op.add_column('teams', sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text("'0'"), autoincrement=False, nullable=False))
op.add_column('teams', sa.Column('updated_by', mysql.INTEGER(), autoincrement=False, nullable=True))
op.add_column('teams', sa.Column('created_by', mysql.INTEGER(), autoincrement=False, nullable=True))
op.add_column('teams', sa.Column('deleted_at', mysql.DATETIME(), nullable=True))
op.drop_constraint(None, 'teams', type_='foreignkey')
op.drop_constraint(None, 'teams', type_='foreignkey')
op.create_index('ix_teams_parent_id', 'teams', ['parent_id'], unique=False)
op.create_index('ix_teams_is_deleted', 'teams', ['is_deleted'], unique=False)
op.create_table_comment(
'knowledge_points',
'知识点表',
existing_comment=None,
schema=None
)
op.drop_index(op.f('ix_knowledge_points_id'), table_name='knowledge_points')
op.create_index('idx_knowledge_points_parent_id', 'knowledge_points', ['parent_id'], unique=False)
op.create_index('idx_knowledge_points_is_deleted', 'knowledge_points', ['is_deleted'], unique=False)
op.create_index('idx_knowledge_points_course_id', 'knowledge_points', ['course_id'], unique=False)
op.alter_column('knowledge_points', 'estimated_hours',
existing_type=mysql.FLOAT(),
comment='预计å\xad¦ä¹\xa0æ—¶é—´(å°\x8fæ—¶)',
existing_comment='预计学习时间(小时)',
existing_nullable=True)
op.alter_column('knowledge_points', 'is_required',
existing_type=mysql.TINYINT(display_width=1),
comment='是å\x90¦å¿…ä¿®',
existing_comment='是否必修',
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.alter_column('knowledge_points', 'weight',
existing_type=mysql.FLOAT(),
comment='æ\x9dƒé‡\x8d',
existing_comment='权重',
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.alter_column('knowledge_points', 'sort_order',
existing_type=mysql.INTEGER(),
comment='排åº\x8f顺åº\x8f',
existing_comment='排序顺序',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
op.alter_column('knowledge_points', 'path',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500),
comment='路径(如: 1.2.3)',
existing_comment='路径(如: 1.2.3)',
existing_nullable=True)
op.alter_column('knowledge_points', 'level',
existing_type=mysql.INTEGER(),
comment='层级深度',
existing_comment='层级深度',
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.alter_column('knowledge_points', 'parent_id',
existing_type=mysql.INTEGER(),
comment='父知识ç¹ID',
existing_comment='父知识点ID',
existing_nullable=True)
op.alter_column('knowledge_points', 'description',
existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'),
comment='知识点æ\x8f\x8fè¿°',
existing_comment='知识点描述',
existing_nullable=True)
op.alter_column('knowledge_points', 'name',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200),
comment='知识点å\x90\x8dç§°',
existing_comment='知识点名称',
existing_nullable=False)
op.alter_column('knowledge_points', 'course_id',
existing_type=mysql.INTEGER(),
comment='课ç¨ID',
existing_comment='课程ID',
existing_nullable=False)
op.create_table_comment(
'growth_paths',
'æˆ\x90长路径表',
existing_comment=None,
schema=None
)
op.drop_index(op.f('ix_growth_paths_id'), table_name='growth_paths')
op.create_index('idx_growth_paths_is_deleted', 'growth_paths', ['is_deleted'], unique=False)
op.create_index('idx_growth_paths_is_active', 'growth_paths', ['is_active'], unique=False)
op.alter_column('growth_paths', 'sort_order',
existing_type=mysql.INTEGER(),
comment='排åº\x8f顺åº\x8f',
existing_comment='排序顺序',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
op.alter_column('growth_paths', 'is_active',
existing_type=mysql.TINYINT(display_width=1),
comment='是å\x90¦å\x90¯ç”¨',
existing_comment='是否启用',
existing_nullable=False,
existing_server_default=sa.text("'1'"))
op.alter_column('growth_paths', 'estimated_duration_days',
existing_type=mysql.INTEGER(),
comment='预计完æˆ\x90天数',
existing_comment='预计完成天数',
existing_nullable=True)
op.alter_column('growth_paths', 'courses',
existing_type=mysql.JSON(),
comment='课程列表[{course_id, order, is_required}]',
existing_comment='课程列表[{course_id, order, is_required}]',
existing_nullable=True)
op.alter_column('growth_paths', 'target_role',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=100),
comment='ç›®æ\xa0‡è§’色',
existing_comment='目标角色',
existing_nullable=True)
op.alter_column('growth_paths', 'description',
existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'),
comment='路径æ\x8f\x8fè¿°',
existing_comment='路径描述',
existing_nullable=True)
op.alter_column('growth_paths', 'name',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200),
comment='路径å\x90\x8dç§°',
existing_comment='路径名称',
existing_nullable=False)
op.create_table_comment(
'courses',
'课程表',
existing_comment=None,
schema=None
)
op.drop_index(op.f('ix_courses_id'), table_name='courses')
op.create_index('idx_courses_status', 'courses', ['status'], unique=False)
op.create_index('idx_courses_is_deleted', 'courses', ['is_deleted'], unique=False)
op.create_index('idx_courses_category', 'courses', ['category'], unique=False)
op.alter_column('courses', 'is_featured',
existing_type=mysql.TINYINT(display_width=1),
comment='是å\x90¦æŽ¨è\x8d\x90',
existing_comment='是否推荐',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
op.alter_column('courses', 'sort_order',
existing_type=mysql.INTEGER(),
comment='排åº\x8f顺åº\x8f',
existing_comment='排序顺序',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
op.alter_column('courses', 'publisher_id',
existing_type=mysql.INTEGER(),
comment='å\x8f布人ID',
existing_comment='发布人ID',
existing_nullable=True)
op.alter_column('courses', 'published_at',
existing_type=mysql.DATETIME(),
comment='å\x8f‘布时间',
existing_comment='发布时间',
existing_nullable=True)
op.alter_column('courses', 'tags',
existing_type=mysql.JSON(),
comment='æ\xa0‡ç\xad¾åˆ—表',
existing_comment='标签列表',
existing_nullable=True)
op.alter_column('courses', 'difficulty_level',
existing_type=mysql.INTEGER(),
comment='难度ç\xad‰çº§(1-5)',
existing_comment='难度等级(1-5)',
existing_nullable=True)
op.alter_column('courses', 'duration_hours',
existing_type=mysql.FLOAT(),
comment='课程时长(å°\x8fæ—¶)',
existing_comment='课程时长(小时)',
existing_nullable=True)
op.alter_column('courses', 'cover_image',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500),
comment='å°\x81é\x9d¢å¾ç‰‡URL',
existing_comment='封面图片URL',
existing_nullable=True)
op.alter_column('courses', 'status',
existing_type=mysql.ENUM('draft', 'published', 'archived', collation='utf8mb4_unicode_ci'),
comment='课程状æ€\x81',
existing_comment='课程状态',
existing_nullable=False,
existing_server_default=sa.text("'draft'"))
op.alter_column('courses', 'category',
existing_type=mysql.ENUM('technology', 'management', 'business', 'general', collation='utf8mb4_unicode_ci'),
comment='课程分类',
existing_comment='课程分类',
existing_nullable=False,
existing_server_default=sa.text("'general'"))
op.alter_column('courses', 'description',
existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'),
comment='课程æ\x8f\x8fè¿°',
existing_comment='课程描述',
existing_nullable=True)
op.alter_column('courses', 'name',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200),
comment='课程å\x90\x8dç§°',
existing_comment='课程名称',
existing_nullable=False)
op.create_table_comment(
'course_materials',
'课程资料表',
existing_comment=None,
schema=None
)
op.drop_index(op.f('ix_course_materials_id'), table_name='course_materials')
op.create_index('idx_course_materials_is_deleted', 'course_materials', ['is_deleted'], unique=False)
op.create_index('idx_course_materials_course_id', 'course_materials', ['course_id'], unique=False)
op.alter_column('course_materials', 'sort_order',
existing_type=mysql.INTEGER(),
comment='排åº\x8f顺åº\x8f',
existing_comment='排序顺序',
existing_nullable=False,
existing_server_default=sa.text("'0'"))
op.alter_column('course_materials', 'file_size',
existing_type=mysql.INTEGER(),
comment='文件大å°\x8f\xad—节)',
existing_comment='文件大小(字节)',
existing_nullable=False)
op.alter_column('course_materials', 'file_type',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=50),
comment='文件类型',
existing_comment='文件类型',
existing_nullable=False)
op.alter_column('course_materials', 'file_url',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500),
comment='æ‡ä»¶URL',
existing_comment='文件URL',
existing_nullable=False)
op.alter_column('course_materials', 'description',
existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'),
comment='资料æ\x8f\x8fè¿°',
existing_comment='资料描述',
existing_nullable=True)
op.alter_column('course_materials', 'name',
existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200),
comment='资料å\x90\x8dç§°',
existing_comment='资料名称',
existing_nullable=False)
op.alter_column('course_materials', 'course_id',
existing_type=mysql.INTEGER(),
comment='课ç¨ID',
existing_comment='课程ID',
existing_nullable=False)
op.drop_table('user_teams')
op.drop_index(op.f('ix_training_reports_user_id'), table_name='training_reports')
op.drop_index(op.f('ix_training_reports_id'), table_name='training_reports')
op.drop_table('training_reports')
op.drop_index(op.f('ix_training_messages_id'), table_name='training_messages')
op.drop_table('training_messages')
op.drop_index(op.f('ix_training_sessions_user_id'), table_name='training_sessions')
op.drop_index(op.f('ix_training_sessions_id'), table_name='training_sessions')
op.drop_table('training_sessions')
op.drop_index(op.f('ix_training_scenes_id'), table_name='training_scenes')
op.drop_table('training_scenes')
# ### end Alembic commands ###

View File

@@ -0,0 +1,35 @@
"""Add skills and level fields to positions table
Revision ID: add_position_skills_level
Revises: 0487635b5e95
Create Date: 2025-09-22 09:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_position_skills_level'
down_revision = '0487635b5e95'
branch_labels = None
depends_on = None
def upgrade():
"""添加skills和level字段到positions表"""
# 添加skills字段JSON类型存储技能数组
op.add_column('positions', sa.Column('skills', sa.JSON, nullable=True, comment='核心技能'))
# 添加level字段岗位等级
op.add_column('positions', sa.Column('level', sa.String(20), nullable=True, comment='岗位等级: junior/intermediate/senior/expert'))
# 添加sort_order字段排序
op.add_column('positions', sa.Column('sort_order', sa.Integer, nullable=True, default=0, comment='排序'))
def downgrade():
"""移除添加的字段"""
op.drop_column('positions', 'skills')
op.drop_column('positions', 'level')
op.drop_column('positions', 'sort_order')

View File

@@ -0,0 +1,36 @@
"""add users soft delete columns
Revision ID: add_users_soft_delete
Revises: 20250921_align_schema_to_design
Create Date: 2025-09-22 03:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'add_users_soft_delete'
down_revision: Union[str, None] = '20250921_align_schema_to_design'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 为 users 表添加软删除字段
op.add_column('users', sa.Column('is_deleted', sa.Boolean(), nullable=False, server_default='0'))
op.add_column('users', sa.Column('deleted_at', sa.DateTime(), nullable=True))
# 添加索引
op.create_index('idx_users_is_deleted', 'users', ['is_deleted'])
def downgrade() -> None:
# 删除索引
op.drop_index('idx_users_is_deleted', table_name='users')
# 删除字段
op.drop_column('users', 'deleted_at')
op.drop_column('users', 'is_deleted')