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

View File

@@ -0,0 +1,20 @@
-- 添加播课功能相关字段到 courses 表
-- 日期: 2025-10-14
USE kaopeilian;
-- 添加播课音频URL字段
ALTER TABLE courses
ADD COLUMN broadcast_audio_url VARCHAR(500) NULL COMMENT '播课音频URL';
-- 添加播课生成时间字段
ALTER TABLE courses
ADD COLUMN broadcast_generated_at DATETIME NULL COMMENT '播课生成时间';
-- 验证字段添加
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME IN ('broadcast_audio_url', 'broadcast_generated_at');

View File

@@ -0,0 +1,24 @@
-- 添加播课异步生成状态管理字段
-- 日期: 2025-10-14
USE kaopeilian;
-- 添加播课生成状态字段
ALTER TABLE courses
ADD COLUMN broadcast_status VARCHAR(20) NULL COMMENT '播课生成状态: pending/generating/completed/failed';
-- 添加Coze工作流任务ID字段
ALTER TABLE courses
ADD COLUMN broadcast_task_id VARCHAR(100) NULL COMMENT 'Coze工作流任务ID';
-- 添加错误信息字段
ALTER TABLE courses
ADD COLUMN broadcast_error_message TEXT NULL COMMENT '生成失败错误信息';
-- 验证字段添加
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME IN ('broadcast_status', 'broadcast_task_id', 'broadcast_error_message');

View File

@@ -0,0 +1,25 @@
-- 迁移脚本:为课程表添加资料下载开关字段
-- 创建时间2025-12-17
-- 功能描述:添加 allow_download 字段,用于控制学员是否可以下载课程资料
-- 检查并添加 allow_download 字段
ALTER TABLE courses
ADD COLUMN IF NOT EXISTS allow_download TINYINT(1) NOT NULL DEFAULT 0
COMMENT '是否允许下载资料0=不允许1=允许';
-- 验证字段是否添加成功
SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'courses'
AND COLUMN_NAME = 'allow_download';

View File

@@ -0,0 +1,298 @@
-- ============================================
-- 考培练系统 SaaS 超级管理后台数据库架构
-- 数据库名kaopeilian_admin
-- 创建日期2026-01-18
-- ============================================
-- 创建数据库
CREATE DATABASE IF NOT EXISTS kaopeilian_admin
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE kaopeilian_admin;
-- ============================================
-- 1. 平台管理员表 (admin_users)
-- ============================================
CREATE TABLE IF NOT EXISTS `admin_users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
`email` VARCHAR(100) UNIQUE COMMENT '邮箱',
`phone` VARCHAR(20) UNIQUE COMMENT '手机号',
`password_hash` VARCHAR(200) NOT NULL COMMENT '密码哈希',
`full_name` VARCHAR(100) COMMENT '姓名',
`avatar_url` VARCHAR(500) COMMENT '头像URL',
`role` VARCHAR(20) DEFAULT 'admin' COMMENT '角色: superadmin, admin, viewer',
`is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活',
`last_login_at` DATETIME COMMENT '最后登录时间',
`last_login_ip` VARCHAR(50) COMMENT '最后登录IP',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_role (role),
INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台管理员表';
-- ============================================
-- 2. 租户表 (tenants)
-- ============================================
CREATE TABLE IF NOT EXISTS `tenants` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`code` VARCHAR(20) NOT NULL UNIQUE COMMENT '租户编码hua, yy, hl',
`name` VARCHAR(100) NOT NULL COMMENT '租户名称',
`display_name` VARCHAR(200) COMMENT '显示名称(如:华尔倍丽-考培练系统)',
`domain` VARCHAR(200) NOT NULL COMMENT '域名hua.ireborn.com.cn',
`logo_url` VARCHAR(500) COMMENT 'Logo URL',
`favicon_url` VARCHAR(500) COMMENT 'Favicon URL',
`contact_name` VARCHAR(50) COMMENT '联系人',
`contact_phone` VARCHAR(20) COMMENT '联系电话',
`contact_email` VARCHAR(100) COMMENT '联系邮箱',
`industry` VARCHAR(50) DEFAULT 'medical_beauty' COMMENT '行业medical_beauty, pet, education',
`status` VARCHAR(20) DEFAULT 'active' COMMENT '状态active, inactive, suspended',
`expire_at` DATE COMMENT '服务到期日期',
`remarks` TEXT COMMENT '备注',
`created_by` INT COMMENT '创建人ID',
`updated_by` INT COMMENT '更新人ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES admin_users(id) ON DELETE SET NULL,
INDEX idx_code (code),
INDEX idx_status (status),
INDEX idx_domain (domain)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户表';
-- ============================================
-- 3. 租户配置表 (tenant_configs)
-- Key-Value 形式存储各类配置
-- ============================================
CREATE TABLE IF NOT EXISTS `tenant_configs` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`tenant_id` INT NOT NULL COMMENT '租户ID',
`config_group` VARCHAR(50) NOT NULL COMMENT '配置分组database, redis, dify, coze, ai, yanji, security',
`config_key` VARCHAR(100) NOT NULL COMMENT '配置键',
`config_value` TEXT COMMENT '配置值',
`value_type` VARCHAR(20) DEFAULT 'string' COMMENT '值类型string, int, bool, json, secret',
`is_encrypted` BOOLEAN DEFAULT FALSE COMMENT '是否加密存储',
`description` VARCHAR(500) COMMENT '配置说明',
`is_required` BOOLEAN DEFAULT FALSE COMMENT '是否必填',
`default_value` TEXT COMMENT '默认值',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
UNIQUE KEY uk_tenant_group_key (tenant_id, config_group, config_key),
INDEX idx_tenant_id (tenant_id),
INDEX idx_config_group (config_group)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户配置表';
-- ============================================
-- 4. AI 提示词模板表 (ai_prompts)
-- ============================================
CREATE TABLE IF NOT EXISTS `ai_prompts` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`code` VARCHAR(50) NOT NULL COMMENT '提示词编码knowledge_analysis, exam_generator',
`name` VARCHAR(100) NOT NULL COMMENT '提示词名称',
`description` TEXT COMMENT '提示词说明',
`module` VARCHAR(50) NOT NULL COMMENT '所属模块course, exam, practice, ability',
`system_prompt` TEXT NOT NULL COMMENT '系统提示词',
`user_prompt_template` TEXT COMMENT '用户提示词模板',
`variables` JSON COMMENT '变量列表(如:["course_name", "content"]',
`output_schema` JSON COMMENT '输出 JSON Schema',
`model_recommendation` VARCHAR(100) COMMENT '推荐模型',
`max_tokens` INT DEFAULT 4096 COMMENT '最大 token 数',
`temperature` DECIMAL(3,2) DEFAULT 0.70 COMMENT '温度参数',
`is_system` BOOLEAN DEFAULT TRUE COMMENT '是否系统内置(内置不可删除)',
`is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用',
`version` INT DEFAULT 1 COMMENT '当前版本号',
`created_by` INT COMMENT '创建人ID',
`updated_by` INT COMMENT '更新人ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES admin_users(id) ON DELETE SET NULL,
UNIQUE KEY uk_code (code),
INDEX idx_module (module),
INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI提示词模板表';
-- ============================================
-- 5. AI 提示词版本历史表 (ai_prompt_versions)
-- ============================================
CREATE TABLE IF NOT EXISTS `ai_prompt_versions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`prompt_id` INT NOT NULL COMMENT '提示词ID',
`version` INT NOT NULL COMMENT '版本号',
`system_prompt` TEXT NOT NULL COMMENT '系统提示词',
`user_prompt_template` TEXT COMMENT '用户提示词模板',
`variables` JSON COMMENT '变量列表',
`output_schema` JSON COMMENT '输出 JSON Schema',
`change_summary` VARCHAR(500) COMMENT '变更说明',
`created_by` INT COMMENT '创建人ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (prompt_id) REFERENCES ai_prompts(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL,
UNIQUE KEY uk_prompt_version (prompt_id, version),
INDEX idx_prompt_id (prompt_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI提示词版本历史表';
-- ============================================
-- 6. 租户自定义提示词表 (tenant_prompts)
-- 租户可覆盖系统默认提示词
-- ============================================
CREATE TABLE IF NOT EXISTS `tenant_prompts` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`tenant_id` INT NOT NULL COMMENT '租户ID',
`prompt_id` INT NOT NULL COMMENT '基础提示词ID',
`system_prompt` TEXT COMMENT '自定义系统提示词(为空则使用默认)',
`user_prompt_template` TEXT COMMENT '自定义用户提示词模板',
`is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用自定义',
`created_by` INT COMMENT '创建人ID',
`updated_by` INT COMMENT '更新人ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (prompt_id) REFERENCES ai_prompts(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES admin_users(id) ON DELETE SET NULL,
UNIQUE KEY uk_tenant_prompt (tenant_id, prompt_id),
INDEX idx_tenant_id (tenant_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户自定义提示词表';
-- ============================================
-- 7. 功能开关表 (feature_switches)
-- ============================================
CREATE TABLE IF NOT EXISTS `feature_switches` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`tenant_id` INT COMMENT '租户IDNULL表示全局默认',
`feature_code` VARCHAR(50) NOT NULL COMMENT '功能编码',
`feature_name` VARCHAR(100) NOT NULL COMMENT '功能名称',
`feature_group` VARCHAR(50) COMMENT '功能分组exam, practice, broadcast, yanji',
`is_enabled` BOOLEAN DEFAULT TRUE COMMENT '是否启用',
`config` JSON COMMENT '功能配置参数',
`description` VARCHAR(500) COMMENT '功能说明',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
UNIQUE KEY uk_tenant_feature (tenant_id, feature_code),
INDEX idx_tenant_id (tenant_id),
INDEX idx_feature_code (feature_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='功能开关表';
-- ============================================
-- 8. 操作审计日志表 (operation_logs)
-- ============================================
CREATE TABLE IF NOT EXISTS `operation_logs` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`admin_user_id` INT COMMENT '操作人ID',
`admin_username` VARCHAR(50) COMMENT '操作人用户名',
`tenant_id` INT COMMENT '涉及租户ID',
`tenant_code` VARCHAR(20) COMMENT '涉及租户编码',
`operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型create, update, delete, enable, disable',
`resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型tenant, config, prompt, feature',
`resource_id` INT COMMENT '资源ID',
`resource_name` VARCHAR(200) COMMENT '资源名称',
`old_value` JSON COMMENT '变更前值',
`new_value` JSON COMMENT '变更后值',
`ip_address` VARCHAR(50) COMMENT '操作IP',
`user_agent` VARCHAR(500) COMMENT '浏览器信息',
`remarks` TEXT COMMENT '备注',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_admin_user_id (admin_user_id),
INDEX idx_tenant_id (tenant_id),
INDEX idx_operation_type (operation_type),
INDEX idx_resource_type (resource_type),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作审计日志表';
-- ============================================
-- 9. 配置模板表 (config_templates)
-- 定义各配置项的元数据
-- ============================================
CREATE TABLE IF NOT EXISTS `config_templates` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`config_group` VARCHAR(50) NOT NULL COMMENT '配置分组',
`config_key` VARCHAR(100) NOT NULL COMMENT '配置键',
`display_name` VARCHAR(100) NOT NULL COMMENT '显示名称',
`description` TEXT COMMENT '配置说明',
`value_type` VARCHAR(20) DEFAULT 'string' COMMENT '值类型',
`default_value` TEXT COMMENT '默认值',
`is_required` BOOLEAN DEFAULT FALSE COMMENT '是否必填',
`is_secret` BOOLEAN DEFAULT FALSE COMMENT '是否敏感信息',
`validation_rule` VARCHAR(500) COMMENT '验证规则(正则)',
`options` JSON COMMENT '可选值列表(下拉选择)',
`sort_order` INT DEFAULT 0 COMMENT '排序',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_group_key (config_group, config_key),
INDEX idx_config_group (config_group)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='配置模板表';
-- ============================================
-- 初始化数据
-- ============================================
-- 1. 插入超级管理员
INSERT INTO admin_users (username, email, password_hash, full_name, role) VALUES
('superadmin', 'admin@ireborn.com.cn', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.ynB8dC.m5QZ9Hy', '超级管理员', 'superadmin');
-- 密码: Superadmin123!
-- 2. 插入配置模板
INSERT INTO config_templates (config_group, config_key, display_name, description, value_type, default_value, is_required, is_secret, sort_order) VALUES
-- 数据库配置
('database', 'MYSQL_HOST', 'MySQL主机', 'MySQL数据库主机地址', 'string', 'prod-mysql', TRUE, FALSE, 1),
('database', 'MYSQL_PORT', 'MySQL端口', 'MySQL数据库端口', 'int', '3306', TRUE, FALSE, 2),
('database', 'MYSQL_USER', 'MySQL用户', 'MySQL数据库用户名', 'string', 'root', TRUE, FALSE, 3),
('database', 'MYSQL_PASSWORD', 'MySQL密码', 'MySQL数据库密码', 'string', NULL, TRUE, TRUE, 4),
('database', 'MYSQL_DATABASE', '数据库名', '租户数据库名称', 'string', NULL, TRUE, FALSE, 5),
-- Redis配置
('redis', 'REDIS_HOST', 'Redis主机', 'Redis缓存主机地址', 'string', 'localhost', TRUE, FALSE, 1),
('redis', 'REDIS_PORT', 'Redis端口', 'Redis端口', 'int', '6379', TRUE, FALSE, 2),
('redis', 'REDIS_DB', 'Redis DB', 'Redis数据库编号', 'int', '0', FALSE, FALSE, 3),
-- 安全配置
('security', 'SECRET_KEY', 'JWT密钥', 'JWT Token签名密钥', 'string', NULL, TRUE, TRUE, 1),
('security', 'CORS_ORIGINS', 'CORS域名', '允许跨域的域名列表', 'json', '[]', FALSE, FALSE, 2),
-- Dify配置
('dify', 'DIFY_API_KEY', '知识点分析 Key', 'Dify 01-知识点分析工作流 API Key', 'string', NULL, FALSE, TRUE, 1),
('dify', 'DIFY_EXAM_GENERATOR_API_KEY', '试题生成器 Key', 'Dify 02-试题生成器工作流 API Key', 'string', NULL, FALSE, TRUE, 2),
('dify', 'DIFY_PRACTICE_API_KEY', '陪练知识准备 Key', 'Dify 03-陪练知识准备工作流 API Key', 'string', NULL, FALSE, TRUE, 3),
('dify', 'DIFY_COURSE_CHAT_API_KEY', '课程对话 Key', 'Dify 04-与课程对话工作流 API Key', 'string', NULL, FALSE, TRUE, 4),
('dify', 'DIFY_YANJI_ANALYSIS_API_KEY', '智能工牌分析 Key', 'Dify 05-智能工牌能力分析工作流 API Key', 'string', NULL, FALSE, TRUE, 5),
-- Coze配置
('coze', 'COZE_PRACTICE_BOT_ID', '陪练Bot ID', 'Coze 陪练机器人ID', 'string', '7560643598174683145', FALSE, FALSE, 1),
('coze', 'COZE_BROADCAST_WORKFLOW_ID', '播课工作流ID', 'Coze 播课工作流ID', 'string', NULL, FALSE, FALSE, 2),
('coze', 'COZE_BROADCAST_SPACE_ID', '播课空间ID', 'Coze 播课工作流空间ID', 'string', '7474971491470688296', FALSE, FALSE, 3),
('coze', 'COZE_OAUTH_CLIENT_ID', 'OAuth Client ID', 'Coze OAuth 客户端ID', 'string', NULL, FALSE, FALSE, 4),
('coze', 'COZE_OAUTH_PUBLIC_KEY_ID', 'OAuth Public Key ID', 'Coze OAuth 公钥ID', 'string', NULL, FALSE, FALSE, 5),
-- AI服务配置
('ai', 'AI_PRIMARY_API_KEY', '首选AI服务Key', '首选AI服务商(4sapi.com) API Key', 'string', NULL, FALSE, TRUE, 1),
('ai', 'AI_PRIMARY_BASE_URL', '首选AI服务地址', '首选AI服务商API地址', 'string', 'https://4sapi.com/v1', FALSE, FALSE, 2),
('ai', 'AI_FALLBACK_API_KEY', '备选AI服务Key', '备选AI服务商(OpenRouter) API Key', 'string', NULL, FALSE, TRUE, 3),
('ai', 'AI_FALLBACK_BASE_URL', '备选AI服务地址', '备选AI服务商API地址', 'string', 'https://openrouter.ai/api/v1', FALSE, FALSE, 4),
('ai', 'AI_DEFAULT_MODEL', '默认模型', '默认使用的AI模型', 'string', 'gemini-3-flash-preview', FALSE, FALSE, 5),
('ai', 'AI_TIMEOUT', 'AI请求超时', 'AI服务请求超时时间(秒)', 'int', '120', FALSE, FALSE, 6),
-- 言迹工牌配置
('yanji', 'YANJI_CLIENT_ID', '客户端ID', '言迹开放平台Client ID', 'string', NULL, FALSE, FALSE, 1),
('yanji', 'YANJI_CLIENT_SECRET', '客户端密钥', '言迹开放平台Client Secret', 'string', NULL, FALSE, TRUE, 2),
('yanji', 'YANJI_TENANT_ID', '租户ID', '言迹租户ID', 'string', NULL, FALSE, FALSE, 3),
('yanji', 'YANJI_ESTATE_ID', '门店ID', '言迹门店ID', 'string', NULL, FALSE, FALSE, 4),
-- 文件存储配置
('storage', 'UPLOAD_DIR', '上传目录', '文件上传目录', 'string', 'uploads', FALSE, FALSE, 1),
('storage', 'MAX_UPLOAD_SIZE', '最大上传大小', '最大文件上传大小(字节)', 'int', '15728640', FALSE, FALSE, 2);
-- 3. 插入功能开关模板(全局默认)
INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) VALUES
(NULL, 'exam_module', '考试模块', 'exam', TRUE, '考试功能总开关'),
(NULL, 'exam_ai_generate', 'AI试题生成', 'exam', TRUE, '使用AI自动生成试题'),
(NULL, 'exam_three_rounds', '三轮考试', 'exam', TRUE, '启用三轮考试机制'),
(NULL, 'practice_module', '陪练模块', 'practice', TRUE, '陪练功能总开关'),
(NULL, 'practice_voice', '语音陪练', 'practice', TRUE, '支持语音对话陪练'),
(NULL, 'broadcast_module', '播课模块', 'broadcast', TRUE, '播课功能总开关'),
(NULL, 'broadcast_auto_generate', '自动生成播课', 'broadcast', TRUE, '自动生成课程播课内容'),
(NULL, 'course_chat', '课程对话', 'course', TRUE, '与课程知识点对话功能'),
(NULL, 'knowledge_ai_analyze', 'AI知识点分析', 'course', TRUE, '使用AI分析提取知识点'),
(NULL, 'yanji_module', '智能工牌模块', 'yanji', FALSE, '言迹智能工牌对接功能'),
(NULL, 'yanji_ability_analysis', '能力分析', 'yanji', FALSE, '基于工牌数据的能力分析');
-- ============================================
-- 完成
-- ============================================

View File

View File

@@ -0,0 +1,17 @@
-- 清理播课功能不必要的字段
-- 日期: 2025-10-14
-- 说明: 新策略下Coze工作流直接写数据库不需要状态跟踪字段
USE kaopeilian;
-- 删除不再需要的字段
ALTER TABLE courses DROP COLUMN broadcast_status;
ALTER TABLE courses DROP COLUMN broadcast_task_id;
ALTER TABLE courses DROP COLUMN broadcast_error_message;
-- 验证剩余字段
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME LIKE 'broadcast%';

104
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,104 @@
"""
Alembic 环境配置
"""
import asyncio
import os
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
# 将项目根目录添加到 Python 路径
sys.path.append(str(Path(__file__).resolve().parent.parent))
from app.core.config import settings
from app.models.base import Base
# 导入所有模型以确保 Alembic 能够检测到它们
from app.models.user import User, Team # noqa
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# 设置数据库URL
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,131 @@
-- 课程相关表结构
-- 用于手动创建课程模块所需的数据库表
-- 1. 课程表
CREATE TABLE IF NOT EXISTS `courses` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(200) NOT NULL COMMENT '课程名称',
`description` TEXT COMMENT '课程描述',
`category` ENUM('technology', 'management', 'business', 'general') NOT NULL DEFAULT 'general' COMMENT '课程分类',
`status` ENUM('draft', 'published', 'archived') NOT NULL DEFAULT 'draft' COMMENT '课程状态',
`cover_image` VARCHAR(500) COMMENT '封面图片URL',
`duration_hours` FLOAT COMMENT '课程时长(小时)',
`difficulty_level` INT COMMENT '难度等级(1-5)',
`tags` JSON COMMENT '标签列表',
`published_at` DATETIME COMMENT '发布时间',
`publisher_id` INT COMMENT '发布人ID',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序',
`is_featured` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否推荐',
-- 基础字段
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 软删除字段
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
`deleted_at` DATETIME,
-- 审计字段
`created_by` INT,
`updated_by` INT,
PRIMARY KEY (`id`),
INDEX `idx_courses_status` (`status`),
INDEX `idx_courses_category` (`category`),
INDEX `idx_courses_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程表';
-- 2. 课程资料表
CREATE TABLE IF NOT EXISTS `course_materials` (
`id` INT NOT NULL AUTO_INCREMENT,
`course_id` INT NOT NULL COMMENT '课程ID',
`name` VARCHAR(200) NOT NULL COMMENT '资料名称',
`description` TEXT COMMENT '资料描述',
`file_url` VARCHAR(500) NOT NULL COMMENT '文件URL',
`file_type` VARCHAR(50) NOT NULL COMMENT '文件类型',
`file_size` INT NOT NULL COMMENT '文件大小(字节)',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序',
-- 基础字段
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 软删除字段
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
`deleted_at` DATETIME,
PRIMARY KEY (`id`),
FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE CASCADE,
INDEX `idx_course_materials_course_id` (`course_id`),
INDEX `idx_course_materials_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表';
-- 3. 知识点表
CREATE TABLE IF NOT EXISTS `knowledge_points` (
`id` INT NOT NULL AUTO_INCREMENT,
`course_id` INT NOT NULL COMMENT '课程ID',
`name` VARCHAR(200) NOT NULL COMMENT '知识点名称',
`description` TEXT COMMENT '知识点描述',
`parent_id` INT COMMENT '父知识点ID',
`level` INT NOT NULL DEFAULT 1 COMMENT '层级深度',
`path` VARCHAR(500) COMMENT '路径(如: 1.2.3)',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序',
`weight` FLOAT NOT NULL DEFAULT 1.0 COMMENT '权重',
`is_required` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否必修',
`estimated_hours` FLOAT COMMENT '预计学习时间(小时)',
-- 基础字段
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 软删除字段
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
`deleted_at` DATETIME,
PRIMARY KEY (`id`),
FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`parent_id`) REFERENCES `knowledge_points`(`id`) ON DELETE CASCADE,
INDEX `idx_knowledge_points_course_id` (`course_id`),
INDEX `idx_knowledge_points_parent_id` (`parent_id`),
INDEX `idx_knowledge_points_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表';
-- 4. 成长路径表
CREATE TABLE IF NOT EXISTS `growth_paths` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(200) NOT NULL COMMENT '路径名称',
`description` TEXT COMMENT '路径描述',
`target_role` VARCHAR(100) COMMENT '目标角色',
`courses` JSON COMMENT '课程列表[{course_id, order, is_required}]',
`estimated_duration_days` INT COMMENT '预计完成天数',
`is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用',
`sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序',
-- 基础字段
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 软删除字段
`is_deleted` BOOLEAN NOT NULL DEFAULT FALSE,
`deleted_at` DATETIME,
PRIMARY KEY (`id`),
INDEX `idx_growth_paths_is_active` (`is_active`),
INDEX `idx_growth_paths_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表';
-- 添加测试数据
INSERT INTO `courses` (`name`, `description`, `category`, `status`, `difficulty_level`, `tags`) VALUES
('Python基础入门', 'Python编程语言基础教程适合零基础学员', 'technology', 'published', 1, '["Python", "编程基础", "入门"]'),
('项目管理实战', '敏捷项目管理方法论与实践', 'management', 'published', 3, '["项目管理", "敏捷", "Scrum"]'),
('商业分析技巧', '商业数据分析与决策支持', 'business', 'draft', 2, '["数据分析", "商业智能", "Excel"]');
-- 为Python课程添加知识点
INSERT INTO `knowledge_points` (`course_id`, `name`, `description`, `parent_id`, `level`, `path`, `sort_order`, `weight`, `is_required`, `estimated_hours`) VALUES
(1, 'Python基础语法', 'Python语言基础语法知识', NULL, 1, '1', 1, 1.0, TRUE, 5.0),
(1, '变量与数据类型', '学习Python中的变量定义和基本数据类型', 1, 2, '1.1', 1, 0.8, TRUE, 2.0),
(1, '控制流程', '条件语句和循环结构', 1, 2, '1.2', 2, 0.9, TRUE, 3.0);
-- 添加成长路径
INSERT INTO `growth_paths` (`name`, `description`, `target_role`, `courses`, `estimated_duration_days`) VALUES
('Python开发工程师', '从零基础到Python开发工程师的成长路径', 'Python开发工程师', '[{"course_id": 1, "order": 1, "is_required": true}]', 90);

View File

@@ -0,0 +1,26 @@
-- 修改知识点表将material_id设为必填字段
-- 执行日期2025-09-27
-- 说明将knowledge_points表的material_id字段从可选改为必填
-- 1. 首先检查是否有material_id为NULL的记录
SELECT COUNT(*) as null_count FROM knowledge_points WHERE material_id IS NULL AND is_deleted = FALSE;
-- 2. 如果有NULL值需要先处理这些记录可以删除或设置默认值
-- 这里先删除material_id为NULL的记录如果有的话
DELETE FROM knowledge_points WHERE material_id IS NULL;
-- 3. 修改字段为NOT NULL并修改外键约束
ALTER TABLE knowledge_points
MODIFY COLUMN material_id INT NOT NULL COMMENT '关联资料ID';
-- 4. 删除旧的外键约束
ALTER TABLE knowledge_points
DROP FOREIGN KEY knowledge_points_ibfk_2;
-- 5. 添加新的外键约束CASCADE删除
ALTER TABLE knowledge_points
ADD CONSTRAINT knowledge_points_ibfk_2
FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE CASCADE;
-- 6. 验证修改结果
DESCRIBE knowledge_points;

View File

@@ -0,0 +1,92 @@
-- 手动创建training模块相关表的SQL脚本
-- 用于解决Alembic迁移失败的备选方案
-- 1. 创建training_scenes表
CREATE TABLE IF NOT EXISTS training_scenes (
id INTEGER NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '场景名称',
description TEXT COMMENT '场景描述',
category VARCHAR(50) NOT NULL COMMENT '场景分类',
ai_config JSON COMMENT 'AI配置如Coze Bot ID等',
prompt_template TEXT COMMENT '提示词模板',
evaluation_criteria JSON COMMENT '评估标准',
status ENUM('DRAFT', 'ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'DRAFT' COMMENT '场景状态',
is_public BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否公开',
required_level INTEGER COMMENT '所需用户等级',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at DATETIME,
created_by INTEGER,
updated_by INTEGER,
PRIMARY KEY (id),
INDEX ix_training_scenes_id (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表';
-- 2. 创建training_sessions表
CREATE TABLE IF NOT EXISTS training_sessions (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
scene_id INTEGER NOT NULL COMMENT '场景ID',
coze_conversation_id VARCHAR(100) COMMENT 'Coze会话ID',
start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间',
end_time DATETIME COMMENT '结束时间',
duration_seconds INTEGER COMMENT '持续时长(秒)',
status ENUM('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR') NOT NULL DEFAULT 'CREATED' COMMENT '会话状态',
session_config JSON COMMENT '会话配置',
total_score FLOAT COMMENT '总分',
evaluation_result JSON COMMENT '评估结果详情',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by INTEGER,
updated_by INTEGER,
PRIMARY KEY (id),
INDEX ix_training_sessions_id (id),
INDEX ix_training_sessions_user_id (user_id),
FOREIGN KEY (scene_id) REFERENCES training_scenes(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表';
-- 3. 创建training_messages表
CREATE TABLE IF NOT EXISTS training_messages (
id INTEGER NOT NULL AUTO_INCREMENT,
session_id INTEGER NOT NULL COMMENT '会话ID',
role ENUM('USER', 'ASSISTANT', 'SYSTEM') NOT NULL COMMENT '消息角色',
type ENUM('TEXT', 'VOICE', 'SYSTEM') NOT NULL COMMENT '消息类型',
content TEXT NOT NULL COMMENT '消息内容',
voice_url VARCHAR(500) COMMENT '语音文件URL',
voice_duration FLOAT COMMENT '语音时长(秒)',
message_metadata JSON COMMENT '消息元数据',
coze_message_id VARCHAR(100) COMMENT 'Coze消息ID',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX ix_training_messages_id (id),
FOREIGN KEY (session_id) REFERENCES training_sessions(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练消息表';
-- 4. 创建training_reports表
CREATE TABLE IF NOT EXISTS training_reports (
id INTEGER NOT NULL AUTO_INCREMENT,
session_id INTEGER NOT NULL COMMENT '会话ID',
user_id BIGINT NOT NULL COMMENT '用户ID',
overall_score FLOAT NOT NULL COMMENT '总体得分',
dimension_scores JSON NOT NULL COMMENT '各维度得分',
strengths JSON NOT NULL COMMENT '优势点',
weaknesses JSON NOT NULL COMMENT '待改进点',
suggestions JSON NOT NULL COMMENT '改进建议',
detailed_analysis TEXT COMMENT '详细分析',
transcript TEXT COMMENT '对话文本记录',
statistics JSON COMMENT '统计数据',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by INTEGER,
updated_by INTEGER,
PRIMARY KEY (id),
UNIQUE KEY (session_id),
INDEX ix_training_reports_id (id),
INDEX ix_training_reports_user_id (user_id),
FOREIGN KEY (session_id) REFERENCES training_sessions(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练报告表';
-- 5. 更新Alembic版本表标记迁移已完成
INSERT INTO alembic_version (version_num) VALUES ('9245f8845fe1') ON DUPLICATE KEY UPDATE version_num = '9245f8845fe1';

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,13 @@
-- 更新生产环境数据库播课字段
-- 日期: 2025-10-14
-- 说明: 清理旧的异步状态字段,只保留必要的播课字段
USE kaopeilian;
-- 查看当前字段结构
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME LIKE 'broadcast%'
ORDER BY COLUMN_NAME;

View File

@@ -0,0 +1,25 @@
-- 步骤1检查生产环境数据库播课字段
-- 日期: 2025-10-14
USE kaopeilian;
-- 查看当前字段结构
SELECT '=== 当前播课相关字段 ===' AS info;
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME LIKE 'broadcast%'
ORDER BY COLUMN_NAME;
-- 检查是否有播课数据
SELECT '=== 已生成的播课数据 ===' AS info;
SELECT COUNT(*) as broadcast_count
FROM courses
WHERE broadcast_audio_url IS NOT NULL;
SELECT id, name, broadcast_audio_url, broadcast_generated_at
FROM courses
WHERE broadcast_audio_url IS NOT NULL
LIMIT 5;

View File

@@ -0,0 +1,86 @@
-- 步骤2更新生产环境数据库播课字段
-- 日期: 2025-10-14
-- 说明: 清理旧的异步状态字段,只保留必要的播课字段
-- 执行前请先运行 step1_check.sql 查看当前字段情况
USE kaopeilian;
-- 删除不再需要的字段
-- 如果字段不存在会报错,可以忽略错误继续执行
-- 删除 broadcast_status
SET @sql = IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME = 'broadcast_status') > 0,
'ALTER TABLE courses DROP COLUMN broadcast_status',
'SELECT "broadcast_status 不存在,跳过" AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 删除 broadcast_task_id
SET @sql = IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME = 'broadcast_task_id') > 0,
'ALTER TABLE courses DROP COLUMN broadcast_task_id',
'SELECT "broadcast_task_id 不存在,跳过" AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 删除 broadcast_error_message
SET @sql = IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME = 'broadcast_error_message') > 0,
'ALTER TABLE courses DROP COLUMN broadcast_error_message',
'SELECT "broadcast_error_message 不存在,跳过" AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 添加必要字段(如果不存在)
-- 添加 broadcast_audio_url
SET @sql = IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME = 'broadcast_audio_url') = 0,
'ALTER TABLE courses ADD COLUMN broadcast_audio_url VARCHAR(500) NULL COMMENT ''播课音频URL''',
'SELECT "broadcast_audio_url 已存在,跳过" AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 添加 broadcast_generated_at
SET @sql = IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME = 'broadcast_generated_at') = 0,
'ALTER TABLE courses ADD COLUMN broadcast_generated_at DATETIME NULL COMMENT ''播课生成时间''',
'SELECT "broadcast_generated_at 已存在,跳过" AS info'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 验证最终字段
SELECT '=== 更新后的播课字段 ===' AS info;
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'kaopeilian'
AND TABLE_NAME = 'courses'
AND COLUMN_NAME LIKE 'broadcast%'
ORDER BY COLUMN_NAME;

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')