feat: migrate configuration to .env environment variables

This commit implements a complete migration from JSON-based configuration
to .env environment variables, providing better security and flexibility.

Key Changes:
- Add python-dotenv dependency for environment variable support
- Create config/env_loader.py with type conversion utilities
- Add from_env() class methods to all config dataclasses
- Update ConfigLoader to prioritize environment variables
- Add save_to_env() method for .env file management
- Implement database connection factory pattern
- Add base DAO and connection classes for better abstraction
- Support both SQL Server and MySQL with unified interface
- Create migration script (scripts/migrate_to_env.py)
- Update GUI to read/write .env files
- Add comprehensive migration documentation

New Files:
- config/env_loader.py - Environment variable loader
- db/base_connection.py - Base database connection interface
- db/base_dao.py - Base DAO with common utilities
- db/connection_factory.py - Factory for creating connections
- db/mysql_connection.py - MySQL-specific connection
- db/sqlserver_connection.py - SQL Server-specific connection
- db/table_name_converter.py - SQL dialect converter
- scripts/migrate_to_env.py - Configuration migration tool
- docs/ENV_MIGRATION.md - Complete migration guide
- .env.example - Environment variable template

Testing:
- Verified MySQL connection (8.0.44)
- Tested all DAO operations
- Confirmed 150 tables accessible
- Validated configuration loading

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-02-09 22:39:14 +08:00
parent 04b99292ad
commit aaa46ef282
22 changed files with 2545 additions and 563 deletions

View File

@@ -4,6 +4,7 @@
配置管理器
负责加载、保存和管理用户配置。
支持从环境变量和 .env 文件加载配置。
"""
import os
from typing import TYPE_CHECKING
@@ -19,15 +20,17 @@ if TYPE_CHECKING:
class ConfigManager:
"""配置管理器"""
def __init__(self, config_file: str = "config/user_settings.json"):
def __init__(self, config_file: str = "config/user_settings.json", use_env: bool = True):
"""
初始化配置管理器
Args:
config_file: 配置文件路径
config_file: 配置文件路径(向后兼容)
use_env: 是否使用环境变量,默认为 True
"""
self.config_file = config_file
self.config: AppConfig = ConfigLoader.load(config_file)
self.use_env = use_env
self.config: AppConfig = ConfigLoader.load(config_file, use_env=use_env)
# 验证配置
errors = self.config.validate()
@@ -40,10 +43,16 @@ class ConfigManager:
"""
保存配置到文件
如果使用环境变量,则保存到 .env 文件
否则保存到 JSON 文件(向后兼容)
Returns:
保存是否成功
"""
return ConfigLoader.save(self.config, self.config_file)
if self.use_env:
return ConfigLoader.save_to_env(self.config, ".env")
else:
return ConfigLoader.save(self.config, self.config_file)
def get(self, key: str, default=None):
"""
@@ -90,7 +99,7 @@ class ConfigManager:
def reset_to_defaults(self) -> None:
"""重置为默认配置"""
self.config = ConfigLoader.load("default") # 重新加载默认配置
self.config = AppConfig.from_env() # 重新从环境变量加载默认配置
self.save()
@property

View File

@@ -10,6 +10,7 @@ import tkinter as tk
from tkinter import ttk, messagebox
import pyodbc
from gui.config_manager import ConfigManager
from config.schema import DatabaseType
class SettingsTab(ttk.Frame):
@@ -116,35 +117,74 @@ class SettingsTab(ttk.Frame):
group = ttk.LabelFrame(parent, text="数据库配置", padding=10)
group.grid(row=1, column=0, columnspan=2, pady=10, padx=10, sticky="ew")
# 服务器
ttk.Label(group, text="服务器:").grid(row=0, column=0, sticky="w", pady=5)
# 数据库类型选择
ttk.Label(group, text="数据库类型:").grid(row=0, column=0, sticky="w", pady=5)
self.db_type_var = tk.StringVar()
db_type_combo = ttk.Combobox(
group,
textvariable=self.db_type_var,
values=["sqlserver", "mysql"],
state="readonly",
width=30,
)
db_type_combo.grid(row=0, column=1, sticky="w", pady=5)
db_type_combo.bind("<<ComboboxSelected>>", self._on_db_type_changed)
# SQL Server 配置
self.sqlserver_frame = ttk.Frame(group)
self.sqlserver_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
ttk.Label(self.sqlserver_frame, text="服务器:").grid(row=0, column=0, sticky="w", pady=5)
self.db_server_var = tk.StringVar()
ttk.Entry(group, textvariable=self.db_server_var, width=50).grid(
ttk.Entry(self.sqlserver_frame, textvariable=self.db_server_var, width=50).grid(
row=0, column=1, pady=5, sticky="ew"
)
# 数据库名
ttk.Label(group, text="数据库:").grid(row=1, column=0, sticky="w", pady=5)
self.db_name_var = tk.StringVar()
ttk.Entry(group, textvariable=self.db_name_var, width=50).grid(
row=1, column=1, pady=5, sticky="ew"
# MySQL 配置
self.mysql_frame = ttk.Frame(group)
ttk.Label(self.mysql_frame, text="主机:").grid(row=0, column=0, sticky="w", pady=5)
self.mysql_host_var = tk.StringVar()
ttk.Entry(self.mysql_frame, textvariable=self.mysql_host_var, width=50).grid(
row=0, column=1, pady=5, sticky="ew"
)
# 用户名
ttk.Label(group, text="用户名:").grid(row=2, column=0, sticky="w", pady=5)
self.db_username_var = tk.StringVar()
ttk.Entry(group, textvariable=self.db_username_var, width=50).grid(
ttk.Label(self.mysql_frame, text="端口:").grid(row=1, column=0, sticky="w", pady=5)
self.mysql_port_var = tk.IntVar(value=3306)
ttk.Spinbox(
self.mysql_frame, from_=1, to=65535, textvariable=self.mysql_port_var, width=10
).grid(row=1, column=1, sticky="w", pady=5)
# 通用配置(两种数据库都需要)
ttk.Label(group, text="数据库:").grid(row=2, column=0, sticky="w", pady=5)
self.db_name_var = tk.StringVar()
ttk.Entry(group, textvariable=self.db_name_var, width=50).grid(
row=2, column=1, pady=5, sticky="ew"
)
# 密码
ttk.Label(group, text="密码:").grid(row=3, column=0, sticky="w", pady=5)
ttk.Label(group, text="用户名:").grid(row=3, column=0, sticky="w", pady=5)
self.db_username_var = tk.StringVar()
ttk.Entry(group, textvariable=self.db_username_var, width=50).grid(
row=3, column=1, pady=5, sticky="ew"
)
ttk.Label(group, text="密码:").grid(row=4, column=0, sticky="w", pady=5)
self.db_password_var = tk.StringVar()
entry = ttk.Entry(group, textvariable=self.db_password_var, width=50, show="*")
entry.grid(row=3, column=1, pady=5, sticky="ew")
entry.grid(row=4, column=1, pady=5, sticky="ew")
group.columnconfigure(1, weight=1)
def _on_db_type_changed(self, event=None):
"""数据库类型改变时的回调"""
db_type = self.db_type_var.get()
if db_type == "mysql":
self.sqlserver_frame.grid_forget()
self.mysql_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
else:
self.mysql_frame.grid_forget()
self.sqlserver_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
def _create_browser_group(self, parent):
"""创建浏览器配置组"""
group = ttk.LabelFrame(parent, text="浏览器设置", padding=10)
@@ -224,7 +264,7 @@ class SettingsTab(ttk.Frame):
# 数据库持久化
self.enable_db_persistence_var = tk.BooleanVar()
ttk.Checkbutton(
group, text="保存到数据库 (同时写入 SQL Server)", variable=self.enable_db_persistence_var
group, text="保存到数据库", variable=self.enable_db_persistence_var
).grid(row=4, column=0, columnspan=2, sticky="w", pady=5)
def _create_validation_group(self, parent):
@@ -292,11 +332,23 @@ class SettingsTab(ttk.Frame):
self.erp_password_var.set(self.config.get("erp.password", ""))
# 数据库设置
self.db_server_var.set(self.config.get("database.server", ""))
db_type = self.config.get("database.db_type", "sqlserver")
self.db_type_var.set(db_type)
if db_type == "mysql":
self.db_server_var.set(self.config.get("database.server", ""))
self.mysql_host_var.set(self.config.get("database.mysql.host", ""))
self.mysql_port_var.set(self.config.get("database.mysql.port", 3306))
else:
self.db_server_var.set(self.config.get("database.server", ""))
self.db_name_var.set(self.config.get("database.database", ""))
self.db_username_var.set(self.config.get("database.username", ""))
self.db_password_var.set(self.config.get("database.password", ""))
# 更新界面显示
self._on_db_type_changed()
# 浏览器设置(已合并到 ERP 配置中)
self.browser_headless_var.set(self.config.get("erp.headless", True))
self.browser_ignore_https_var.set(
@@ -331,7 +383,18 @@ class SettingsTab(ttk.Frame):
self.config.set("erp.password", self.erp_password_var.get())
# 数据库设置
self.config.set("database.server", self.db_server_var.get())
db_type = self.db_type_var.get()
self.config.set("database.db_type", db_type)
if db_type == "mysql":
# MySQL: 使用 host 字段
self.config.set("database.server", self.mysql_host_var.get())
self.config.set("database.mysql.host", self.mysql_host_var.get())
self.config.set("database.mysql.port", self.mysql_port_var.get())
else:
# SQL Server: 使用 server 字段
self.config.set("database.server", self.db_server_var.get())
self.config.set("database.database", self.db_name_var.get())
self.config.set("database.username", self.db_username_var.get())
self.config.set("database.password", self.db_password_var.get())
@@ -368,20 +431,41 @@ class SettingsTab(ttk.Frame):
def test_db_connection(self):
"""测试数据库连接"""
db_type = self.db_type_var.get()
try:
conn_str = (
f"DRIVER={{ODBC Driver 18 for SQL Server}};"
f"SERVER={self.db_server_var.get()};"
f"DATABASE={self.db_name_var.get()};"
f"UID={self.db_username_var.get()};"
f"PWD={self.db_password_var.get()};"
f"TrustServerCertificate=yes;"
)
if db_type == "mysql":
import mysql.connector
from mysql.connector import Error
conn = pyodbc.connect(conn_str, timeout=5)
conn.close()
messagebox.showinfo("成功", "数据库连接测试成功!")
conn = mysql.connector.connect(
host=self.mysql_host_var.get(),
port=self.mysql_port_var.get(),
database=self.db_name_var.get(),
user=self.db_username_var.get(),
password=self.db_password_var.get(),
connection_timeout=5
)
conn.close()
messagebox.showinfo("成功", "MySQL 数据库连接测试成功!")
else:
conn_str = (
f"DRIVER={{ODBC Driver 18 for SQL Server}};"
f"SERVER={self.db_server_var.get()};"
f"DATABASE={self.db_name_var.get()};"
f"UID={self.db_username_var.get()};"
f"PWD={self.db_password_var.get()};"
f"TrustServerCertificate=yes;"
)
conn = pyodbc.connect(conn_str, timeout=5)
conn.close()
messagebox.showinfo("成功", "SQL Server 数据库连接测试成功!")
except ImportError:
if db_type == "mysql":
messagebox.showerror("错误", "未安装 mysql-connector-python请运行\npip install mysql-connector-python")
else:
messagebox.showerror("错误", "未安装 pyodbc请运行\npip install pyodbc")
except Exception as e:
messagebox.showerror("错误", f"数据库连接失败:\n{str(e)}")
@@ -392,7 +476,9 @@ class SettingsTab(ttk.Frame):
def reset_defaults(self):
"""恢复默认设置"""
if messagebox.askyesno("确认", "确定要恢复默认设置吗?"):
self.config.reset_to_defaults()
if messagebox.askyesno("确认", "确定要恢复默认设置吗?这将覆盖 .env 文件中的所有配置。"):
from config.schema import AppConfig
self.config.config = AppConfig.from_env() # 重新加载默认配置
self.config.save()
self.load_settings()
messagebox.showinfo("成功", "已恢复默认设置")