#!/usr/bin/env python3 """ 租户配置迁移脚本 功能:将各租户的 .env 文件配置迁移到 kaopeilian_admin 数据库 使用方法: python scripts/migrate_env_to_db.py 说明: 1. 读取各租户的 .env 文件 2. 创建租户记录 3. 将配置写入 tenant_configs 表 4. 保留原 .env 文件作为备份 """ import os import sys import re import pymysql from datetime import datetime from typing import Dict, List, Tuple, Optional # 添加项目根目录到路径 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # ============================================ # 配置 # ============================================ # 管理库连接配置 ADMIN_DB_CONFIG = { "host": os.getenv("ADMIN_DB_HOST", "120.79.247.16"), "port": int(os.getenv("ADMIN_DB_PORT", "3309")), "user": os.getenv("ADMIN_DB_USER", "root"), "password": os.getenv("ADMIN_DB_PASSWORD", "ProdMySQL2025!@#"), "db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"), "charset": "utf8mb4", } # 租户配置 TENANTS = [ { "code": "demo", "name": "演示版", "display_name": "考培练系统-演示版", "domain": "aiedu.ireborn.com.cn", "env_file": "/root/aiedu/kaopeilian-backend/.env.production", "industry": "medical_beauty", }, { "code": "hua", "name": "华尔倍丽", "display_name": "华尔倍丽-考培练系统", "domain": "hua.ireborn.com.cn", "env_file": "/root/aiedu/kaopeilian-backend/.env.hua", "industry": "medical_beauty", }, { "code": "yy", "name": "杨扬宠物", "display_name": "杨扬宠物-考培练系统", "domain": "yy.ireborn.com.cn", "env_file": "/root/aiedu/kaopeilian-backend/.env.yy", "industry": "pet", }, { "code": "hl", "name": "武汉禾丽", "display_name": "武汉禾丽-考培练系统", "domain": "hl.ireborn.com.cn", "env_file": "/root/aiedu/kaopeilian-backend/.env.hl", "industry": "medical_beauty", }, { "code": "xy", "name": "芯颜定制", "display_name": "芯颜定制-考培练系统", "domain": "xy.ireborn.com.cn", "env_file": "/root/aiedu/kaopeilian-backend/.env.xy", "industry": "medical_beauty", }, { "code": "fw", "name": "飞沃", "display_name": "飞沃-考培练系统", "domain": "fw.ireborn.com.cn", "env_file": "/root/aiedu/kaopeilian-backend/.env.fw", "industry": "medical_beauty", }, { "code": "ex", "name": "恩喜成都总院", "display_name": "恩喜成都总院-考培练系统", "domain": "ex.ireborn.com.cn", "env_file": "/root/aiedu/kaopeilian-backend/.env.ex", "industry": "medical_beauty", }, { "code": "kpl", "name": "瑞小美", "display_name": "瑞小美-考培练系统", "domain": "kpl.ireborn.com.cn", "env_file": "/root/aiedu/kaopeilian-backend/.env.kpl", "industry": "medical_beauty", }, ] # 配置键到分组的映射 CONFIG_MAPPING = { # 数据库配置 "MYSQL_HOST": ("database", "string", False), "MYSQL_PORT": ("database", "int", False), "MYSQL_USER": ("database", "string", False), "MYSQL_PASSWORD": ("database", "string", True), "MYSQL_DATABASE": ("database", "string", False), # Redis配置 "REDIS_HOST": ("redis", "string", False), "REDIS_PORT": ("redis", "int", False), "REDIS_DB": ("redis", "int", False), "REDIS_URL": ("redis", "string", False), # 安全配置 "SECRET_KEY": ("security", "string", True), "CORS_ORIGINS": ("security", "json", False), # Dify配置 "DIFY_API_KEY": ("dify", "string", True), "DIFY_EXAM_GENERATOR_API_KEY": ("dify", "string", True), "DIFY_PRACTICE_API_KEY": ("dify", "string", True), "DIFY_COURSE_CHAT_API_KEY": ("dify", "string", True), "DIFY_YANJI_ANALYSIS_API_KEY": ("dify", "string", True), # Coze配置 "COZE_PRACTICE_BOT_ID": ("coze", "string", False), "COZE_BROADCAST_WORKFLOW_ID": ("coze", "string", False), "COZE_BROADCAST_SPACE_ID": ("coze", "string", False), "COZE_BROADCAST_BOT_ID": ("coze", "string", False), "COZE_OAUTH_CLIENT_ID": ("coze", "string", False), "COZE_OAUTH_PUBLIC_KEY_ID": ("coze", "string", False), "COZE_OAUTH_PRIVATE_KEY_PATH": ("coze", "string", False), # AI配置 "AI_PRIMARY_API_KEY": ("ai", "string", True), "AI_PRIMARY_BASE_URL": ("ai", "string", False), "AI_FALLBACK_API_KEY": ("ai", "string", True), "AI_FALLBACK_BASE_URL": ("ai", "string", False), "AI_DEFAULT_MODEL": ("ai", "string", False), "AI_TIMEOUT": ("ai", "int", False), # 言迹配置 "YANJI_CLIENT_ID": ("yanji", "string", False), "YANJI_CLIENT_SECRET": ("yanji", "string", True), "YANJI_TENANT_ID": ("yanji", "string", False), "YANJI_ESTATE_ID": ("yanji", "string", False), # 其他配置 "APP_NAME": ("basic", "string", False), "PUBLIC_DOMAIN": ("basic", "string", False), } def parse_env_file(filepath: str) -> Dict[str, str]: """解析 .env 文件""" config = {} if not os.path.exists(filepath): print(f" 警告: 文件不存在 {filepath}") return config with open(filepath, 'r', encoding='utf-8') as f: for line in f: line = line.strip() # 跳过注释和空行 if not line or line.startswith('#'): continue # 解析 KEY=VALUE if '=' in line: key, value = line.split('=', 1) key = key.strip() value = value.strip() # 去除引号 if (value.startswith('"') and value.endswith('"')) or \ (value.startswith("'") and value.endswith("'")): value = value[1:-1] config[key] = value return config def create_tenant(cursor, tenant: Dict) -> int: """创建租户记录,返回租户ID""" # 检查是否已存在 cursor.execute( "SELECT id FROM tenants WHERE code = %s", (tenant["code"],) ) row = cursor.fetchone() if row: print(f" 租户已存在,ID: {row['id']}") return row["id"] # 创建新租户 cursor.execute( """ INSERT INTO tenants (code, name, display_name, domain, industry, status, created_by) VALUES (%s, %s, %s, %s, %s, 'active', 1) """, (tenant["code"], tenant["name"], tenant["display_name"], tenant["domain"], tenant["industry"]) ) tenant_id = cursor.lastrowid print(f" 创建租户成功,ID: {tenant_id}") return tenant_id def migrate_config(cursor, tenant_id: int, config: Dict[str, str]) -> Tuple[int, int]: """迁移配置到数据库""" inserted = 0 updated = 0 for key, value in config.items(): if key not in CONFIG_MAPPING: continue config_group, value_type, is_secret = CONFIG_MAPPING[key] # 检查是否已存在 cursor.execute( """ SELECT id FROM tenant_configs WHERE tenant_id = %s AND config_group = %s AND config_key = %s """, (tenant_id, config_group, key) ) row = cursor.fetchone() if row: # 更新 cursor.execute( """ UPDATE tenant_configs SET config_value = %s, value_type = %s, is_encrypted = %s, updated_at = NOW() WHERE id = %s """, (value, value_type, is_secret, row["id"]) ) updated += 1 else: # 插入 cursor.execute( """ INSERT INTO tenant_configs (tenant_id, config_group, config_key, config_value, value_type, is_encrypted) VALUES (%s, %s, %s, %s, %s, %s) """, (tenant_id, config_group, key, value, value_type, is_secret) ) inserted += 1 return inserted, updated def main(): """主函数""" print("=" * 60) print("租户配置迁移脚本") print("=" * 60) print(f"\n目标数据库: {ADMIN_DB_CONFIG['host']}:{ADMIN_DB_CONFIG['port']}/{ADMIN_DB_CONFIG['db']}") print(f"待迁移租户: {len(TENANTS)} 个\n") # 连接数据库 conn = pymysql.connect(**ADMIN_DB_CONFIG, cursorclass=pymysql.cursors.DictCursor) try: with conn.cursor() as cursor: total_inserted = 0 total_updated = 0 for tenant in TENANTS: print(f"\n处理租户: {tenant['name']} ({tenant['code']})") print(f" 环境文件: {tenant['env_file']}") # 解析 .env 文件 config = parse_env_file(tenant['env_file']) print(f" 读取配置: {len(config)} 项") # 创建租户 tenant_id = create_tenant(cursor, tenant) # 迁移配置 if config: inserted, updated = migrate_config(cursor, tenant_id, config) print(f" 迁移结果: 新增 {inserted} 项, 更新 {updated} 项") total_inserted += inserted total_updated += updated else: print(" 跳过迁移(无配置)") # 提交事务 conn.commit() print("\n" + "=" * 60) print("迁移完成!") print(f"总计: 新增 {total_inserted} 项, 更新 {total_updated} 项") print("=" * 60) except Exception as e: conn.rollback() print(f"\n错误: {e}") raise finally: conn.close() if __name__ == "__main__": main()