first commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
.venv
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
log
|
||||||
|
*.spec
|
||||||
51
check_drivers.py
Normal file
51
check_drivers.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import pyodbc
|
||||||
|
|
||||||
|
def check_drivers():
|
||||||
|
print("正在扫描系统 ODBC 驱动...\n")
|
||||||
|
try:
|
||||||
|
drivers = pyodbc.drivers()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 错误: 无法读取驱动列表。原因: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not drivers:
|
||||||
|
print("❌ 未找到任何 ODBC 驱动。")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"✅ 共发现 {len(drivers)} 个驱动:")
|
||||||
|
print("=" * 50)
|
||||||
|
for d in drivers:
|
||||||
|
print(f" • {d}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
print("\n[配置建议]")
|
||||||
|
|
||||||
|
# --- 1. 寻找 SQL Server 驱动 ---
|
||||||
|
# 优先级:ODBC Driver 18 > 17 > 13 > Native Client > SQL Server (旧)
|
||||||
|
sql_candidates = [d for d in drivers if "SQL Server" in d]
|
||||||
|
best_sql = None
|
||||||
|
|
||||||
|
# 简单的版本优先级判断
|
||||||
|
if any("ODBC Driver 18" in d for d in sql_candidates):
|
||||||
|
best_sql = "ODBC Driver 18 for SQL Server"
|
||||||
|
elif any("ODBC Driver 17" in d for d in sql_candidates):
|
||||||
|
best_sql = "ODBC Driver 17 for SQL Server"
|
||||||
|
elif any("ODBC Driver 13" in d for d in sql_candidates):
|
||||||
|
best_sql = "ODBC Driver 13 for SQL Server"
|
||||||
|
elif sql_candidates:
|
||||||
|
best_sql = sql_candidates[0] # 随便取一个其他的
|
||||||
|
|
||||||
|
if best_sql:
|
||||||
|
print(f"👉 SQL Server 驱动 (填入 config.py): {{{best_sql}}}")
|
||||||
|
else:
|
||||||
|
print("⚠️ 未找到 SQL Server 驱动,请下载安装 'ODBC Driver 17 for SQL Server'")
|
||||||
|
|
||||||
|
# --- 2. 寻找 Access 驱动 ---
|
||||||
|
acc_candidates = [d for d in drivers if "Access Driver" in d]
|
||||||
|
if acc_candidates:
|
||||||
|
print(f"👉 Access 驱动 (填入 config.py): {{{acc_candidates[0]}}}")
|
||||||
|
else:
|
||||||
|
print("⚠️ 未找到 Access 驱动,请下载安装 'Access Database Engine'")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_drivers()
|
||||||
58
config.py
Normal file
58
config.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# config.py
|
||||||
|
|
||||||
|
# ================= 数据库连接配置 =================
|
||||||
|
SQL_SERVER_CONN = {
|
||||||
|
'driver': '{ODBC Driver 18 for SQL Server}',
|
||||||
|
'server': '192.168.110.114',
|
||||||
|
'database': 'CompanyDB',
|
||||||
|
'uid': 'peng',
|
||||||
|
'pwd': 'Cqbld123456.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Access 驱动配置 (根据你的 Office 版本选择 mdb 或 accdb)
|
||||||
|
ACCESS_DRIVER = "{Microsoft Access Driver (*.mdb, *.accdb)}"
|
||||||
|
|
||||||
|
# ================= 日志表配置 =================
|
||||||
|
# 根据你提供的表结构配置列名
|
||||||
|
LOG_TABLE_CONFIG = {
|
||||||
|
'schema': 'dbo',
|
||||||
|
'table_name': 'TableChangeLog',
|
||||||
|
# 下面定义字段名,方便代码引用,防止硬编码
|
||||||
|
'col_log_id': 'LogID',
|
||||||
|
'col_table_name': 'TableName',
|
||||||
|
'col_record_id': 'RecordID',
|
||||||
|
'col_address': 'TableAddress', # 使用 TableAddress 作为文件路径来源
|
||||||
|
'col_synced': 'Synced'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ================= 同步映射配置 (核心重构) =================
|
||||||
|
# 结构: { "Access文件绝对路径": { "Access表名": { SQL目标配置 } } }
|
||||||
|
# 这样即使不同文件有相同的表名,也能区分开
|
||||||
|
SYNC_MAPPING = {
|
||||||
|
# 第一个 Access 文件
|
||||||
|
r"\\192.168.110.114\生产进度表\2025年数据\生产合同数据.accdb": {
|
||||||
|
"26年压力表合同数据": {
|
||||||
|
"target_schema": "productionContractData",
|
||||||
|
"target_table": "26年压力表合同数据", # 映射到 A 表
|
||||||
|
"pk_col": "ID"
|
||||||
|
},
|
||||||
|
"26年温度计合同数据": {
|
||||||
|
"target_schema": "productionContractData",
|
||||||
|
"target_table": "26年温度计合同数据",
|
||||||
|
"pk_col": "ID"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
# # 第二个 Access 文件 (可能有同名的 UserInfo 表)
|
||||||
|
# r"D:\Data\ProjectB\backup.mdb": {
|
||||||
|
# "UserInfo": {
|
||||||
|
# "target_schema": "dbo",
|
||||||
|
# "target_table": "ProjB_UserInfo", # 映射到 B 表
|
||||||
|
# "pk_col": "ID"
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ================= 运行参数 =================
|
||||||
|
POLL_INTERVAL = 60 # 轮询间隔(秒)
|
||||||
|
BATCH_SIZE = 1000 # 批量处理大小
|
||||||
36
db_utils.py
Normal file
36
db_utils.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# db_utils.py
|
||||||
|
import pyodbc
|
||||||
|
import config
|
||||||
|
|
||||||
|
def get_sql_conn():
|
||||||
|
"""获取 SQL Server 连接"""
|
||||||
|
c = config.SQL_SERVER_CONN
|
||||||
|
# 显式添加 TrustServerCertificate=yes 以兼容 ODBC Driver 18+
|
||||||
|
conn_str = (
|
||||||
|
f"DRIVER={c['driver']};SERVER={c['server']};"
|
||||||
|
f"DATABASE={c['database']};UID={c['uid']};PWD={c['pwd']};"
|
||||||
|
"Encrypt=yes;TrustServerCertificate=yes;"
|
||||||
|
)
|
||||||
|
return pyodbc.connect(conn_str)
|
||||||
|
|
||||||
|
def get_access_conn(file_path):
|
||||||
|
"""获取 Access 连接"""
|
||||||
|
conn_str = f"DRIVER={config.ACCESS_DRIVER};DBQ={file_path};"
|
||||||
|
return pyodbc.connect(conn_str)
|
||||||
|
|
||||||
|
def fmt_table(schema, table):
|
||||||
|
"""格式化 SQL Server 表名 [schema].[table]"""
|
||||||
|
return f"[{schema}].[{table}]"
|
||||||
|
|
||||||
|
def get_columns(cursor, table_name):
|
||||||
|
"""获取 Access 表的列名"""
|
||||||
|
# Access 查询表名加 []
|
||||||
|
cursor.execute(f"SELECT TOP 1 * FROM [{table_name}]")
|
||||||
|
return [column[0] for column in cursor.description]
|
||||||
|
|
||||||
|
def generate_insert_sql(target_schema, target_table, columns):
|
||||||
|
"""生成带架构的 INSERT 语句"""
|
||||||
|
full_table_name = fmt_table(target_schema, target_table)
|
||||||
|
col_str = ",".join([f"[{col}]" for col in columns])
|
||||||
|
placeholders = ",".join(["?"] * len(columns))
|
||||||
|
return f"INSERT INTO {full_table_name} ({col_str}) VALUES ({placeholders})"
|
||||||
83
init_full_sync.py
Normal file
83
init_full_sync.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# init_full_sync.py
|
||||||
|
import pyodbc
|
||||||
|
import config
|
||||||
|
import db_utils
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
|
def run_full_sync():
|
||||||
|
print("=== 开始全量初始化同步 ===")
|
||||||
|
print("注意:将基于 config.SYNC_MAPPING 中的文件路径进行处理")
|
||||||
|
|
||||||
|
sql_conn = db_utils.get_sql_conn()
|
||||||
|
sql_cursor = sql_conn.cursor()
|
||||||
|
sql_cursor.fast_executemany = True
|
||||||
|
|
||||||
|
# 第一层循环:遍历配置文件中的所有文件路径
|
||||||
|
for acc_path, tables_map in config.SYNC_MAPPING.items():
|
||||||
|
if not os.path.exists(acc_path):
|
||||||
|
print(f"⚠️ [跳过] 文件不存在: {acc_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"\n📂 正在处理文件: {acc_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
acc_conn = db_utils.get_access_conn(acc_path)
|
||||||
|
acc_cursor = acc_conn.cursor()
|
||||||
|
|
||||||
|
# 第二层循环:遍历该文件下的所有表映射
|
||||||
|
for acc_table, target_config in tables_map.items():
|
||||||
|
target_schema = target_config['target_schema']
|
||||||
|
target_table = target_config['target_table']
|
||||||
|
full_target_name = db_utils.fmt_table(target_schema, target_table)
|
||||||
|
|
||||||
|
print(f" 👉 同步表: [{acc_table}] -> {full_target_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 检查 Access 表是否存在
|
||||||
|
acc_cursor.execute(f"SELECT TOP 1 * FROM [{acc_table}]")
|
||||||
|
|
||||||
|
# 2. 获取列结构
|
||||||
|
columns = [col[0] for col in acc_cursor.description]
|
||||||
|
insert_sql = db_utils.generate_insert_sql(target_schema, target_table, columns)
|
||||||
|
|
||||||
|
# 3. TRUNCATE 目标表
|
||||||
|
sql_cursor.execute(f"TRUNCATE TABLE {full_target_name}")
|
||||||
|
|
||||||
|
# 4. 开启 IDENTITY_INSERT (尝试)
|
||||||
|
try: sql_cursor.execute(f"SET IDENTITY_INSERT {full_target_name} ON")
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# 5. 传输数据
|
||||||
|
print(f" 正在读取并写入数据...")
|
||||||
|
acc_cursor.execute(f"SELECT * FROM [{acc_table}]")
|
||||||
|
|
||||||
|
total_rows = 0
|
||||||
|
while True:
|
||||||
|
rows = acc_cursor.fetchmany(config.BATCH_SIZE)
|
||||||
|
if not rows: break
|
||||||
|
sql_cursor.executemany(insert_sql, rows)
|
||||||
|
total_rows += len(rows)
|
||||||
|
print(f" 已写入 {total_rows} 行...", end='\r')
|
||||||
|
|
||||||
|
# 6. 关闭 IDENTITY_INSERT
|
||||||
|
try: sql_cursor.execute(f"SET IDENTITY_INSERT {full_target_name} OFF")
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
sql_conn.commit()
|
||||||
|
print(f"\n ✅ 完成。")
|
||||||
|
|
||||||
|
except Exception as tbl_err:
|
||||||
|
print(f" ❌ 表级错误: {tbl_err}")
|
||||||
|
sql_conn.rollback()
|
||||||
|
|
||||||
|
acc_conn.close()
|
||||||
|
|
||||||
|
except Exception as file_err:
|
||||||
|
print(f" ❌ 文件级错误: {file_err}")
|
||||||
|
|
||||||
|
sql_conn.close()
|
||||||
|
print("\n=== 全量同步结束 ===")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_full_sync()
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pyodbc>=5.0.0
|
||||||
222
run_incremental_sync.py
Normal file
222
run_incremental_sync.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import time
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pyodbc
|
||||||
|
import config
|
||||||
|
import db_utils
|
||||||
|
import logging
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
# ================= 日志系统配置 (新增) =================
|
||||||
|
|
||||||
|
def setup_logger():
|
||||||
|
"""
|
||||||
|
配置双向日志系统:控制台输出 + 按天轮转的文件日志
|
||||||
|
"""
|
||||||
|
# 1. 确保日志目录存在
|
||||||
|
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'log')
|
||||||
|
if not os.path.exists(log_dir):
|
||||||
|
os.makedirs(log_dir)
|
||||||
|
|
||||||
|
# 2. 创建 Logger
|
||||||
|
logger = logging.getLogger("SyncService")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.handlers = [] # 清除旧句柄,防止重复打印
|
||||||
|
|
||||||
|
# 3. 文件处理器 (按天轮转)
|
||||||
|
# filename: 正在写入的日志名
|
||||||
|
# when='midnight': 每天午夜轮转
|
||||||
|
# interval=1: 间隔1天
|
||||||
|
# backupCount=30: 保留最近30个文件
|
||||||
|
log_file_path = os.path.join(log_dir, 'sync_service.log')
|
||||||
|
file_handler = TimedRotatingFileHandler(
|
||||||
|
log_file_path, when='midnight', interval=1, backupCount=30, encoding='utf-8'
|
||||||
|
)
|
||||||
|
file_handler.suffix = "%Y-%m-%d" # 轮转后的文件名后缀
|
||||||
|
# 文件日志格式:[时间] [级别] 消息
|
||||||
|
file_fmt = logging.Formatter('%(asctime)s - [%(levelname)s] - %(message)s')
|
||||||
|
file_handler.setFormatter(file_fmt)
|
||||||
|
|
||||||
|
# 4. 控制台处理器 (屏幕输出)
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
# 控制台日志格式:[时间] 消息 (保持简洁)
|
||||||
|
console_fmt = logging.Formatter('%(asctime)s - %(message)s', datefmt='%H:%M:%S')
|
||||||
|
console_handler.setFormatter(console_fmt)
|
||||||
|
|
||||||
|
# 5. 添加处理器
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
# 初始化全局 Logger
|
||||||
|
logger = setup_logger()
|
||||||
|
|
||||||
|
# ================= 辅助函数 =================
|
||||||
|
|
||||||
|
def clean_access_path(raw_addr):
|
||||||
|
"""清洗 TableAddress 字段"""
|
||||||
|
if not raw_addr: return ""
|
||||||
|
s = str(raw_addr).strip()
|
||||||
|
if s.upper().startswith(";DATABASE="): return s[10:]
|
||||||
|
if s.upper().startswith("LOCAL:"): return s[6:]
|
||||||
|
return s
|
||||||
|
|
||||||
|
def get_norm_path(path):
|
||||||
|
"""获取标准化路径"""
|
||||||
|
if not path: return ""
|
||||||
|
return os.path.normpath(path).lower()
|
||||||
|
|
||||||
|
# ================= 主逻辑 =================
|
||||||
|
|
||||||
|
def process_logs():
|
||||||
|
sql_conn = db_utils.get_sql_conn()
|
||||||
|
sql_cursor = sql_conn.cursor()
|
||||||
|
sql_cursor.fast_executemany = True
|
||||||
|
|
||||||
|
cols = config.LOG_TABLE_CONFIG
|
||||||
|
|
||||||
|
# --- 0. 预处理配置映射 ---
|
||||||
|
normalized_mapping = {}
|
||||||
|
for cfg_path, cfg_tables in config.SYNC_MAPPING.items():
|
||||||
|
norm_key = get_norm_path(cfg_path)
|
||||||
|
normalized_mapping[norm_key] = {
|
||||||
|
'original_path': cfg_path,
|
||||||
|
'tables': cfg_tables
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
log_full_name = db_utils.fmt_table(cols['schema'], cols['table_name'])
|
||||||
|
|
||||||
|
# --- 1. 读取日志 ---
|
||||||
|
query_log = f"""
|
||||||
|
SELECT TOP 1000
|
||||||
|
{cols['col_log_id']},
|
||||||
|
{cols['col_table_name']},
|
||||||
|
{cols['col_record_id']},
|
||||||
|
{cols['col_address']}
|
||||||
|
FROM {log_full_name}
|
||||||
|
WHERE {cols['col_synced']} = 0
|
||||||
|
AND TableType = 'LINKED_ACCESS'
|
||||||
|
ORDER BY {cols['col_log_id']} ASC
|
||||||
|
"""
|
||||||
|
sql_cursor.execute(query_log)
|
||||||
|
logs = sql_cursor.fetchall()
|
||||||
|
|
||||||
|
if not logs:
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"📥 收到 {len(logs)} 条链接表变更日志...")
|
||||||
|
|
||||||
|
# --- 2. 任务分组 ---
|
||||||
|
tasks = defaultdict(lambda: defaultdict(lambda: {'record_ids': set(), 'log_ids': []}))
|
||||||
|
|
||||||
|
for row in logs:
|
||||||
|
log_id, acc_table, record_id, raw_address = row
|
||||||
|
|
||||||
|
clean_path = clean_access_path(raw_address)
|
||||||
|
norm_lookup_key = get_norm_path(clean_path)
|
||||||
|
|
||||||
|
# 校验配置
|
||||||
|
if norm_lookup_key not in normalized_mapping:
|
||||||
|
# 记录一条警告日志,但不刷屏
|
||||||
|
logger.warning(f"忽略未配置的文件路径: {clean_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
mapping_node = normalized_mapping[norm_lookup_key]
|
||||||
|
|
||||||
|
if acc_table not in mapping_node['tables']:
|
||||||
|
logger.warning(f"忽略未配置的表: {acc_table} (在文件 {os.path.basename(clean_path)} 中)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
tasks[clean_path][acc_table]['record_ids'].add(record_id)
|
||||||
|
tasks[clean_path][acc_table]['log_ids'].append(log_id)
|
||||||
|
|
||||||
|
# --- 3. 执行同步循环 ---
|
||||||
|
for file_path, tables_data in tasks.items():
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
logger.error(f"无法访问文件: {file_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
acc_conn = db_utils.get_access_conn(file_path)
|
||||||
|
acc_cursor = acc_conn.cursor()
|
||||||
|
except Exception as conn_err:
|
||||||
|
logger.error(f"连接 Access 失败 [{file_path}]: {conn_err}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
norm_key = get_norm_path(file_path)
|
||||||
|
config_node = normalized_mapping[norm_key]
|
||||||
|
|
||||||
|
for acc_table, data in tables_data.items():
|
||||||
|
record_ids = list(data['record_ids'])
|
||||||
|
log_ids = data['log_ids']
|
||||||
|
|
||||||
|
target_conf = config_node['tables'][acc_table]
|
||||||
|
target_schema = target_conf['target_schema']
|
||||||
|
target_table = target_conf['target_table']
|
||||||
|
pk_col = target_conf['pk_col']
|
||||||
|
target_full_name = db_utils.fmt_table(target_schema, target_table)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ids_placeholders = ','.join(['?'] * len(record_ids))
|
||||||
|
|
||||||
|
# A. Access 读取
|
||||||
|
acc_sql = f"SELECT * FROM [{acc_table}] WHERE [{pk_col}] IN ({ids_placeholders})"
|
||||||
|
acc_cursor.execute(acc_sql, record_ids)
|
||||||
|
new_rows = acc_cursor.fetchall()
|
||||||
|
acc_cols = [col[0] for col in acc_cursor.description]
|
||||||
|
|
||||||
|
# B. SQL Server 写入
|
||||||
|
del_sql = f"DELETE FROM {target_full_name} WHERE [{pk_col}] IN ({ids_placeholders})"
|
||||||
|
sql_cursor.execute(del_sql, record_ids)
|
||||||
|
|
||||||
|
if new_rows:
|
||||||
|
insert_sql = db_utils.generate_insert_sql(target_schema, target_table, acc_cols)
|
||||||
|
try: sql_cursor.execute(f"SET IDENTITY_INSERT {target_full_name} ON")
|
||||||
|
except: pass
|
||||||
|
sql_cursor.executemany(insert_sql, new_rows)
|
||||||
|
try: sql_cursor.execute(f"SET IDENTITY_INSERT {target_full_name} OFF")
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# C. 更新日志状态
|
||||||
|
log_placeholders = ','.join(['?'] * len(log_ids))
|
||||||
|
update_log_sql = f"""
|
||||||
|
UPDATE {log_full_name}
|
||||||
|
SET {cols['col_synced']} = 1
|
||||||
|
WHERE {cols['col_log_id']} IN ({log_placeholders})
|
||||||
|
"""
|
||||||
|
sql_cursor.execute(update_log_sql, log_ids)
|
||||||
|
|
||||||
|
sql_conn.commit()
|
||||||
|
logger.info(f"✅ 同步成功: {target_table} 更新 {len(record_ids)} 条 (来源: {os.path.basename(file_path)})")
|
||||||
|
|
||||||
|
except Exception as tbl_err:
|
||||||
|
logger.error(f"处理表 {acc_table} 异常: {tbl_err}")
|
||||||
|
sql_conn.rollback()
|
||||||
|
|
||||||
|
acc_conn.close()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical(f"全局严重异常: {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
try: sql_conn.close()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger.info("=== 增量同步服务已启动 (日志模式: log/sync_service.log) ===")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
has_work = process_logs()
|
||||||
|
time.sleep(0.5 if has_work else config.POLL_INTERVAL)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("收到停止指令,服务正在关闭...")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical(f"主循环崩溃: {e}")
|
||||||
|
time.sleep(5) # 防止死循环刷屏
|
||||||
350
vba.txt
Normal file
350
vba.txt
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
Option Compare Database
|
||||||
|
Option Explicit
|
||||||
|
|
||||||
|
' === 模块级变量 ===
|
||||||
|
Private m_IsNewRecord As Boolean
|
||||||
|
Private m_DeletedIDs As New Collection
|
||||||
|
|
||||||
|
' === 安全的字符串转义 ===
|
||||||
|
Private Function SafeSQL(txt As String) As String
|
||||||
|
SafeSQL = Replace(txt, "'", "''") ' SQL标准转义
|
||||||
|
End Function
|
||||||
|
|
||||||
|
' === 获取窗体数据源表名 (修复版) ===
|
||||||
|
Private Function GetFormTableName() As String
|
||||||
|
On Error Resume Next
|
||||||
|
Dim tableName As String
|
||||||
|
|
||||||
|
' 方法 1: 【最推荐】直接检查主键字段的来源表
|
||||||
|
' 既然你的查询里有 "缺件记录.ID",那么 ID 字段的 SourceTable 属性就是 "缺件记录"
|
||||||
|
' 这比解析字符串准确得多,也不怕查询改名
|
||||||
|
tableName = Me.Recordset.Fields("ID").SourceTable
|
||||||
|
|
||||||
|
' 如果方法1成功,直接返回
|
||||||
|
If Len(tableName) > 0 Then
|
||||||
|
GetFormTableName = tableName
|
||||||
|
Exit Function
|
||||||
|
End If
|
||||||
|
|
||||||
|
' 方法 2: 检查窗体的 UniqueTable 属性
|
||||||
|
' Access 经常在属性表中设置这个值来指定多表查询中哪个表可更新
|
||||||
|
tableName = Me.UniqueTable
|
||||||
|
If Len(tableName) > 0 Then
|
||||||
|
GetFormTableName = tableName
|
||||||
|
Exit Function
|
||||||
|
End If
|
||||||
|
|
||||||
|
' 方法 3: (保底方案) 解析 RecordSource 字符串
|
||||||
|
' 只有当上面都失败时,才尝试去解析 SQL
|
||||||
|
Dim recordSource As String
|
||||||
|
recordSource = Trim(Me.recordSource & "")
|
||||||
|
|
||||||
|
' 如果是保存的查询名(不含SELECT),需要先获取查询的SQL
|
||||||
|
If InStr(UCase(recordSource), "SELECT") = 0 Then
|
||||||
|
' 检查是否为查询对象
|
||||||
|
Dim qdf As DAO.QueryDef
|
||||||
|
Set qdf = CurrentDb.QueryDefs(recordSource)
|
||||||
|
If Not qdf Is Nothing Then
|
||||||
|
recordSource = qdf.sql ' 获取查询背后的真实 SQL
|
||||||
|
Else
|
||||||
|
' 既不是SELECT又不是查询,那只能是直接的表名了
|
||||||
|
GetFormTableName = recordSource
|
||||||
|
Exit Function
|
||||||
|
End If
|
||||||
|
Set qdf = Nothing
|
||||||
|
End If
|
||||||
|
|
||||||
|
' 解析 SQL: 提取 FROM 之后、JOIN 之前的第一个词
|
||||||
|
Dim fromPos As Long
|
||||||
|
fromPos = InStr(1, UCase(recordSource), " FROM ", vbTextCompare)
|
||||||
|
|
||||||
|
If fromPos > 0 Then
|
||||||
|
Dim tempStr As String
|
||||||
|
tempStr = Mid(recordSource, fromPos + 6) ' 跳过 " FROM "
|
||||||
|
tempStr = Trim(tempStr)
|
||||||
|
|
||||||
|
' 截断点:遇到 JOIN, WHERE, ORDER BY, GROUP BY 或逗号时停止
|
||||||
|
Dim stopChars As Variant
|
||||||
|
Dim i As Integer, minPos As Long, p As Long
|
||||||
|
stopChars = Array(" LEFT ", " RIGHT ", " INNER ", " OUTER ", " JOIN ", " WHERE ", " ORDER ", " GROUP ", ",")
|
||||||
|
|
||||||
|
minPos = Len(tempStr) + 1
|
||||||
|
|
||||||
|
For i = LBound(stopChars) To UBound(stopChars)
|
||||||
|
p = InStr(1, UCase(tempStr), stopChars(i), vbTextCompare)
|
||||||
|
If p > 0 And p < minPos Then minPos = p
|
||||||
|
Next i
|
||||||
|
|
||||||
|
tableName = Trim(Left(tempStr, minPos - 1))
|
||||||
|
|
||||||
|
' 清理括号
|
||||||
|
tableName = Replace(tableName, "[", "")
|
||||||
|
tableName = Replace(tableName, "]", "")
|
||||||
|
End If
|
||||||
|
|
||||||
|
' 默认值
|
||||||
|
If Len(tableName) = 0 Then tableName = "UnknownTable"
|
||||||
|
|
||||||
|
GetFormTableName = tableName
|
||||||
|
On Error GoTo 0
|
||||||
|
End Function
|
||||||
|
|
||||||
|
' === 获取表地址(连接字符串或本地路径) ===
|
||||||
|
Private Function GetTableAddress(tableName As String) As String
|
||||||
|
On Error Resume Next
|
||||||
|
Dim db As DAO.Database
|
||||||
|
Dim tdf As DAO.TableDef
|
||||||
|
Dim address As String
|
||||||
|
|
||||||
|
Set db = CurrentDb
|
||||||
|
Set tdf = db.TableDefs(tableName)
|
||||||
|
|
||||||
|
If Not tdf Is Nothing Then
|
||||||
|
If Len(Trim(tdf.Connect & "")) > 0 Then
|
||||||
|
' 链接表:返回连接字符串
|
||||||
|
address = tdf.Connect
|
||||||
|
Else
|
||||||
|
' 本地表:返回当前数据库路径
|
||||||
|
address = "LOCAL:" & CurrentDb.Name
|
||||||
|
End If
|
||||||
|
Else
|
||||||
|
address = "UNKNOWN"
|
||||||
|
End If
|
||||||
|
|
||||||
|
' 清理过长的连接字符串(可选)
|
||||||
|
If Len(address) > 500 Then
|
||||||
|
address = Left(address, 500) & "..."
|
||||||
|
End If
|
||||||
|
|
||||||
|
GetTableAddress = address
|
||||||
|
|
||||||
|
Set tdf = Nothing
|
||||||
|
Set db = Nothing
|
||||||
|
On Error GoTo 0
|
||||||
|
End Function
|
||||||
|
|
||||||
|
' === 获取表类型 ===
|
||||||
|
Private Function GetTableType(tableName As String) As String
|
||||||
|
On Error Resume Next
|
||||||
|
Dim db As DAO.Database
|
||||||
|
Dim tdf As DAO.TableDef
|
||||||
|
Dim connectStr As String
|
||||||
|
Dim tableType As String
|
||||||
|
|
||||||
|
Set db = CurrentDb
|
||||||
|
Set tdf = db.TableDefs(tableName)
|
||||||
|
|
||||||
|
If Not tdf Is Nothing Then
|
||||||
|
connectStr = UCase(Trim(tdf.Connect & ""))
|
||||||
|
|
||||||
|
If Len(connectStr) = 0 Then
|
||||||
|
' 本地表
|
||||||
|
tableType = "LOCAL"
|
||||||
|
ElseIf InStr(connectStr, "ODBC;") > 0 Then
|
||||||
|
' ODBC链接表
|
||||||
|
If InStr(connectStr, "SQL SERVER") > 0 Then
|
||||||
|
tableType = "LINKED_SQLSERVER"
|
||||||
|
ElseIf InStr(connectStr, "MYSQL") > 0 Then
|
||||||
|
tableType = "LINKED_MYSQL"
|
||||||
|
ElseIf InStr(connectStr, "ORACLE") > 0 Then
|
||||||
|
tableType = "LINKED_ORACLE"
|
||||||
|
Else
|
||||||
|
tableType = "LINKED_ODBC"
|
||||||
|
End If
|
||||||
|
ElseIf InStr(connectStr, "MS ACCESS") > 0 Or InStr(connectStr, ".ACCDB") > 0 Or InStr(connectStr, ".MDB") > 0 Then
|
||||||
|
' Access链接表
|
||||||
|
tableType = "LINKED_ACCESS"
|
||||||
|
ElseIf InStr(connectStr, "EXCEL") > 0 Or InStr(connectStr, ".XLS") > 0 Then
|
||||||
|
' Excel链接表
|
||||||
|
tableType = "LINKED_EXCEL"
|
||||||
|
ElseIf InStr(connectStr, "TEXT;") > 0 Or InStr(connectStr, ".TXT") > 0 Or InStr(connectStr, ".CSV") > 0 Then
|
||||||
|
' 文本/CSV链接表
|
||||||
|
tableType = "LINKED_TEXT"
|
||||||
|
Else
|
||||||
|
' 其他类型链接表
|
||||||
|
tableType = "LINKED_OTHER"
|
||||||
|
End If
|
||||||
|
Else
|
||||||
|
tableType = "UNKNOWN"
|
||||||
|
End If
|
||||||
|
|
||||||
|
GetTableType = tableType
|
||||||
|
|
||||||
|
Set tdf = Nothing
|
||||||
|
Set db = Nothing
|
||||||
|
On Error GoTo 0
|
||||||
|
End Function
|
||||||
|
|
||||||
|
' === 获取本机IP地址(带超时保护) ===
|
||||||
|
Private Function GetLocalIPAddress() As String
|
||||||
|
On Error Resume Next
|
||||||
|
Dim objWMI As Object
|
||||||
|
Dim colItems As Object
|
||||||
|
Dim objItem As Object
|
||||||
|
Dim ip As String
|
||||||
|
Dim startTime As Double
|
||||||
|
|
||||||
|
startTime = Timer
|
||||||
|
Set objWMI = GetObject("winmgmts:\\.\root\cimv2")
|
||||||
|
|
||||||
|
' 超时保护:WMI查询最多等待2秒
|
||||||
|
If Timer - startTime > 2 Then
|
||||||
|
GetLocalIPAddress = "127.0.0.1"
|
||||||
|
Exit Function
|
||||||
|
End If
|
||||||
|
|
||||||
|
Set colItems = objWMI.ExecQuery("SELECT IPAddress FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True")
|
||||||
|
|
||||||
|
For Each objItem In colItems
|
||||||
|
If Not IsNull(objItem.ipAddress) Then
|
||||||
|
ip = objItem.ipAddress(0)
|
||||||
|
If InStr(ip, ":") = 0 Then
|
||||||
|
GetLocalIPAddress = ip
|
||||||
|
Exit Function
|
||||||
|
End If
|
||||||
|
End If
|
||||||
|
Next
|
||||||
|
|
||||||
|
GetLocalIPAddress = "127.0.0.1"
|
||||||
|
On Error GoTo 0
|
||||||
|
End Function
|
||||||
|
|
||||||
|
' === 异步日志写入(不阻塞用户操作) ===
|
||||||
|
Private Sub WriteLog(actionType As String, recordID As String)
|
||||||
|
Dim sql As String
|
||||||
|
Dim ipAddr As String
|
||||||
|
Dim remoteTable As String
|
||||||
|
Dim localTable As String
|
||||||
|
Dim tableAddr As String
|
||||||
|
Dim tableType As String
|
||||||
|
Dim computerName As String ' 新增:计算机名称变量
|
||||||
|
' --- 配置区域 ---
|
||||||
|
remoteTable = "dbo_TableChangeLog"
|
||||||
|
' ----------------
|
||||||
|
|
||||||
|
' 【关键1】立即返回,不等待耗时操作
|
||||||
|
On Error Resume Next
|
||||||
|
|
||||||
|
' 【新增】动态获取窗体数据源表名
|
||||||
|
localTable = GetFormTableName()
|
||||||
|
|
||||||
|
' 【新增】动态获取表地址
|
||||||
|
tableAddr = GetTableAddress(localTable)
|
||||||
|
|
||||||
|
' 【新增】动态获取表类型
|
||||||
|
tableType = GetTableType(localTable)
|
||||||
|
|
||||||
|
' 快速验证:表是否存在
|
||||||
|
If IsNull(DLookup("Name", "MSysObjects", "Name='" & SafeSQL(remoteTable) & "'")) Then
|
||||||
|
Debug.Print "Log Warning: 表 " & remoteTable & " 不存在,跳过日志"
|
||||||
|
Exit Sub
|
||||||
|
End If
|
||||||
|
|
||||||
|
' 【关键2】防止空值和特殊字符
|
||||||
|
If Len(Trim(recordID & "")) = 0 Then recordID = "NULL"
|
||||||
|
recordID = SafeSQL(recordID)
|
||||||
|
|
||||||
|
' 【关键3】快速获取IP(带缓存)
|
||||||
|
Static cachedIP As String
|
||||||
|
If cachedIP = "" Then cachedIP = GetLocalIPAddress()
|
||||||
|
ipAddr = cachedIP
|
||||||
|
|
||||||
|
' 获取当前电脑的【计算机名称】,替代原有的 Windows登录用户名
|
||||||
|
computerName = Environ$("COMPUTERNAME")
|
||||||
|
|
||||||
|
|
||||||
|
' 【关键4】参数化查询(如果支持)或安全拼接
|
||||||
|
' 注意:不写 ChangeDate 字段,让 SQL Server 的 DEFAULT GETDATE() 自动填充
|
||||||
|
sql = "INSERT INTO " & remoteTable & " " & _
|
||||||
|
"(TableName, TableAddress, TableType, RecordID, ActionType, IPAddress, UserName, FilePath) " & _
|
||||||
|
"VALUES ('" & SafeSQL(localTable) & "', " & _
|
||||||
|
"'" & SafeSQL(tableAddr) & "', " & _
|
||||||
|
"'" & SafeSQL(tableType) & "', " & _
|
||||||
|
"'" & recordID & "', " & _
|
||||||
|
"'" & SafeSQL(actionType) & "', " & _
|
||||||
|
"'" & SafeSQL(ipAddr) & "', " & _
|
||||||
|
"'" & SafeSQL(computerName) & "', " & _
|
||||||
|
"'" & SafeSQL(CurrentDb.Name) & "')"
|
||||||
|
|
||||||
|
' 【关键5】静默执行,绝不影响用户
|
||||||
|
CurrentDb.Execute sql ' 移除 dbFailOnError!
|
||||||
|
|
||||||
|
If Err.Number <> 0 Then
|
||||||
|
' 仅记录到立即窗口,不弹窗
|
||||||
|
Debug.Print Now & " - Log Failed: " & Err.Description & " | SQL: " & sql
|
||||||
|
' 可选:写入本地备份表
|
||||||
|
Call WriteLocalBackupLog(localTable, recordID, actionType)
|
||||||
|
End If
|
||||||
|
|
||||||
|
On Error GoTo 0
|
||||||
|
End Sub
|
||||||
|
|
||||||
|
' === 本地备份日志(防止远程失败) ===
|
||||||
|
Private Sub WriteLocalBackupLog(tblName As String, recID As String, actType As String)
|
||||||
|
On Error Resume Next
|
||||||
|
' Access 本地表使用 Now() 函数
|
||||||
|
CurrentDb.Execute "INSERT INTO LocalLogBackup (TableName, RecordID, ActionType, LogTime) " & _
|
||||||
|
"VALUES ('" & SafeSQL(tblName) & "', '" & SafeSQL(recID) & "', '" & SafeSQL(actType) & "', Now())"
|
||||||
|
On Error GoTo 0
|
||||||
|
End Sub
|
||||||
|
|
||||||
|
' ==============================
|
||||||
|
' 窗体事件逻辑(增强版)
|
||||||
|
' ==============================
|
||||||
|
|
||||||
|
' 1. 保存前状态判断
|
||||||
|
Private Sub Form_BeforeUpdate(Cancel As Integer)
|
||||||
|
On Error Resume Next ' 【保护】防止日志逻辑影响保存
|
||||||
|
m_IsNewRecord = Me.NewRecord
|
||||||
|
On Error GoTo 0
|
||||||
|
End Sub
|
||||||
|
|
||||||
|
' 2. 修改保存后
|
||||||
|
Private Sub Form_AfterUpdate()
|
||||||
|
On Error Resume Next
|
||||||
|
If Not m_IsNewRecord Then
|
||||||
|
Call WriteLog("UPDATE", Nz(Me.ID.Value, ""))
|
||||||
|
End If
|
||||||
|
On Error GoTo 0
|
||||||
|
End Sub
|
||||||
|
|
||||||
|
' 3. 新增确认后
|
||||||
|
Private Sub Form_AfterInsert()
|
||||||
|
On Error Resume Next
|
||||||
|
Call WriteLog("INSERT", Nz(Me.ID.Value, ""))
|
||||||
|
m_IsNewRecord = False
|
||||||
|
On Error GoTo 0
|
||||||
|
End Sub
|
||||||
|
|
||||||
|
' 4. 删除开始(收集ID)
|
||||||
|
Private Sub Form_Delete(Cancel As Integer)
|
||||||
|
On Error Resume Next
|
||||||
|
If Not IsNull(Me.ID.Value) Then
|
||||||
|
m_DeletedIDs.Add CStr(Me.ID.Value)
|
||||||
|
End If
|
||||||
|
On Error GoTo 0
|
||||||
|
End Sub
|
||||||
|
|
||||||
|
' 5. 删除确认后(批量写入)
|
||||||
|
Private Sub Form_AfterDelConfirm(Status As Integer)
|
||||||
|
On Error Resume Next
|
||||||
|
Dim vID As Variant
|
||||||
|
|
||||||
|
If Status = acDeleteOK Then
|
||||||
|
For Each vID In m_DeletedIDs
|
||||||
|
Call WriteLog("DELETE", CStr(vID))
|
||||||
|
Next
|
||||||
|
End If
|
||||||
|
|
||||||
|
' 清理集合
|
||||||
|
Set m_DeletedIDs = Nothing
|
||||||
|
Set m_DeletedIDs = New Collection
|
||||||
|
On Error GoTo 0
|
||||||
|
End Sub
|
||||||
|
|
||||||
|
' === 窗体关闭时清理(可选) ===
|
||||||
|
Private Sub Form_Unload(Cancel As Integer)
|
||||||
|
On Error Resume Next
|
||||||
|
Set m_DeletedIDs = Nothing
|
||||||
|
On Error GoTo 0
|
||||||
|
End Sub
|
||||||
96
vbareplace.py
Normal file
96
vbareplace.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import win32com.client
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox
|
||||||
|
|
||||||
|
# 隐藏 tkinter 主窗口
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 检查是否通过拖拽传入文件
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
messagebox.showerror("错误", "请将一个 Access 数据库文件拖拽到此 EXE 上运行!")
|
||||||
|
return
|
||||||
|
|
||||||
|
access_file = sys.argv[1]
|
||||||
|
|
||||||
|
# 检查文件是否存在且是 Access 文件
|
||||||
|
if not os.path.exists(access_file):
|
||||||
|
messagebox.showerror("错误", f"文件不存在:\n{access_file}")
|
||||||
|
return
|
||||||
|
|
||||||
|
ext = os.path.splitext(access_file)[1].lower()
|
||||||
|
if ext not in ['.accdb', '.mdb']:
|
||||||
|
messagebox.showwarning("警告", "这不是一个 Access 数据库文件(.accdb 或 .mdb)\n将尝试继续,但可能失败。")
|
||||||
|
|
||||||
|
# 获取脚本所在目录(EXE 同目录)
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
script_dir = os.path.dirname(sys.executable)
|
||||||
|
else:
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file))
|
||||||
|
|
||||||
|
vba_txt_file = os.path.join(script_dir, "vba.txt")
|
||||||
|
|
||||||
|
if not os.path.exists(vba_txt_file):
|
||||||
|
messagebox.showerror("错误", f"未找到 vba.txt 文件!\n请确保 vba.txt 与 EXE 在同一目录。\n路径: {vba_txt_file}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 读取新 VBA 代码
|
||||||
|
try:
|
||||||
|
with open(vba_txt_file, "r", encoding="utf-8") as f:
|
||||||
|
new_code = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("错误", f"读取 vba.txt 失败:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 启动 Access
|
||||||
|
access = win32com.client.Dispatch("Access.Application")
|
||||||
|
access.Visible = False # 后台运行,不显示窗口
|
||||||
|
|
||||||
|
# 打开数据库
|
||||||
|
access.OpenCurrentDatabase(access_file)
|
||||||
|
|
||||||
|
all_forms = access.CurrentProject.AllForms
|
||||||
|
form_count = all_forms.Count
|
||||||
|
|
||||||
|
if form_count == 0:
|
||||||
|
messagebox.showinfo("完成", "数据库中没有窗体,无需替换。")
|
||||||
|
access.CloseCurrentDatabase()
|
||||||
|
access.Quit()
|
||||||
|
return
|
||||||
|
|
||||||
|
replaced = 0
|
||||||
|
for i in range(form_count):
|
||||||
|
form_name = all_forms.Item(i).Name
|
||||||
|
|
||||||
|
try:
|
||||||
|
access.DoCmd.OpenForm(form_name, 1) # acDesign = 1
|
||||||
|
form = access.Forms(form_name)
|
||||||
|
module = form.Module
|
||||||
|
|
||||||
|
line_count = module.CountOfLines
|
||||||
|
if line_count > 0:
|
||||||
|
module.DeleteLines(1, line_count)
|
||||||
|
|
||||||
|
if new_code.strip():
|
||||||
|
module.InsertLines(1, new_code)
|
||||||
|
|
||||||
|
access.DoCmd.Close(0, form_name, 1) # acForm=0, acSaveYes=1
|
||||||
|
replaced += 1
|
||||||
|
except Exception as e:
|
||||||
|
access.DoCmd.Close(0, form_name, 0) # 尝试关闭,避免卡住
|
||||||
|
print(f"处理窗体 {form_name} 时出错: {e}")
|
||||||
|
|
||||||
|
access.CloseCurrentDatabase()
|
||||||
|
access.Quit()
|
||||||
|
|
||||||
|
messagebox.showinfo("成功", f"操作完成!\n\n已处理 {form_count} 个窗体,成功替换 {replaced} 个。\n\n文件已保存: {os.path.basename(access_file)}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("运行错误", f"操作失败:\n{e}\n\n请检查是否已启用 VBA 项目访问信任。")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user