Files
BLD_sync/run_incremental_sync.py
Misaka_Company 83c90f0161 Refactor configuration management and remove deprecated files
- Consolidated database, file source, and field mapping configurations into dedicated modules under the `config` directory.
- Removed hardcoded database connection details from `migration.py` and replaced them with imports from the new configuration structure.
- Updated `ntfy_utils.py` and `run_incremental_sync.py` to utilize the new configuration imports for cleaner code and better maintainability.
- Deleted `update_config.py` as its contents have been integrated into the new configuration files.
- Added a new `settings.local.json` for managing permissions related to script execution.
- Enhanced the structure of the migration tasks and Excel configurations for better organization and clarity.
2026-01-12 12:49:51 +08:00

284 lines
11 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import time
import os
import sys
import pyodbc
from config import SQL_SERVER_CONN, ACCESS_DRIVER, SYNC_MAPPING, POLL_INTERVAL, LOG_TABLE_CONFIG
import db_utils
import logging
from logging.handlers import TimedRotatingFileHandler
import ntfy_utils
# ================= 日志系统配置 =================
def setup_logger():
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'log')
if not os.path.exists(log_dir):
os.makedirs(log_dir)
logger = logging.getLogger("SyncService")
logger.setLevel(logging.INFO)
logger.handlers = []
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)
console_handler = logging.StreamHandler(sys.stdout)
console_fmt = logging.Formatter('%(asctime)s - %(message)s', datefmt='%H:%M:%S')
console_handler.setFormatter(console_fmt)
logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger
logger = setup_logger()
# ================= 日志辅助函数 =================
def log_success(message):
"""成功消息 - 绿色感觉"""
logger.info(f"✅ [成功] {message}")
def log_error(message):
"""错误消息 - 红色感觉"""
logger.error(f"❌ [错误] {message}")
ntfy_utils.send_error(f"❌ [错误] {message}")
def log_warning(message):
"""警告消息 - 黄色感觉"""
logger.warning(f"⚠️ [警告] {message}")
ntfy_utils.send_error(f"⚠️ [警告] {message}")
def log_info(message):
"""信息消息"""
logger.info(f" {message}")
def log_processing(message):
"""处理中消息"""
logger.info(f"🔄 [处理] {message}")
def log_skip(message):
"""跳过消息"""
logger.info(f"⏭️ [跳过] {message}")
def log_critical(message):
"""严重错误"""
logger.critical(f"🔥 [严重] {message}")
ntfy_utils.send_critical(f"🔥 [严重] {message}")
def log_start(message):
"""启动消息"""
logger.info(f"🚀 [启动] {message}")
ntfy_utils.send_ntfy(f"🚀 [启动] {message}")
def log_stop(message):
"""停止消息"""
logger.info(f"🛑 [停止] {message}")
ntfy_utils.send_ntfy(f"🛑 [停止] {message}")
def log_file(message):
"""文件操作消息"""
logger.info(f"📂 [文件] {message}")
def log_database(message):
"""数据库操作消息"""
logger.info(f"💾 [数据库] {message}")
def log_sync(message):
"""同步操作消息"""
logger.info(f"🔃 [同步] {message}")
# ================= 主逻辑 =================
def process_sync_task():
"""
逻辑重构:
遍历 SYNC_MAPPING 中的每一个文件 -> 去日志表查询该文件下特定表的未同步记录。
"""
sql_conn = db_utils.get_sql_conn()
sql_cursor = sql_conn.cursor()
sql_cursor.fast_executemany = True # 高性能开关
# 标记是否有工作被处理(用于控制轮询休眠时间)
work_done = False
cols = LOG_TABLE_CONFIG
log_full_name = db_utils.fmt_table(cols['schema'], cols['table_name'])
try:
# === 核心循环:以配置文件为驱动 ===
for clean_path, tables_map in SYNC_MAPPING.items():
# 1. 准备查询条件
# 获取该文件下所有需要同步的表名列表
target_tables = list(tables_map.keys())
if not target_tables:
continue
# 构造 TableAddress 的精确匹配条件
# VBA 逻辑:网络路径带 ";DATABASE=", 本地路径带 "LOCAL:"
# 我们直接构造这两个字符串,让 SQL Server 做精确匹配,效率极高
# 注意:将路径转为 Windows 标准反斜杠
win_path = os.path.normpath(clean_path)
addr_candidates = [
f";DATABASE={win_path}", # 情况1
f"LOCAL={win_path}", # 情况2 (注意 VBA 代码里可能是 LOCAL: 或 LOCAL=,请核对)
f"LOCAL:{win_path}", # 情况3
win_path # 情况4 (兼容没有前缀的情况)
]
# 2. 构造动态 SQL 查询
# WHERE Synced=0 AND Address IN (...) AND TableName IN (...)
placeholders_addr = ','.join(['?'] * len(addr_candidates))
placeholders_tbl = ','.join(['?'] * len(target_tables))
query_log = f"""
SELECT TOP 1000
{cols['col_log_id']},
{cols['col_table_name']},
{cols['col_record_id']}
FROM {log_full_name}
WHERE {cols['col_synced']} = 0
AND TableType = 'LINKED_ACCESS'
AND {cols['col_address']} IN ({placeholders_addr})
AND {cols['col_table_name']} IN ({placeholders_tbl})
ORDER BY {cols['col_log_id']} ASC
"""
# 参数列表:先放地址,再放表名
params = addr_candidates + target_tables
sql_cursor.execute(query_log, params)
logs = sql_cursor.fetchall()
if not logs:
continue # 这个文件没有需要同步的记录,检查下一个文件
work_done = True # 标记有工作
log_file(f"{os.path.basename(clean_path)} 发现 {len(logs)} 条待同步变更")
# 3. 本地分组 (按表名)
# 结构: table_tasks[TableName] = { ids: {}, log_ids: [] }
table_tasks = {}
for row in logs:
log_id, acc_table, record_id = row
if acc_table not in table_tasks:
table_tasks[acc_table] = {'record_ids': set(), 'log_ids': []}
table_tasks[acc_table]['record_ids'].add(record_id)
table_tasks[acc_table]['log_ids'].append(log_id)
# 4. 执行同步 (连接一次 Access处理多张表)
if not os.path.exists(clean_path):
log_error(f"无法访问文件: {clean_path}")
continue
try:
acc_conn = db_utils.get_access_conn(clean_path)
acc_cursor = acc_conn.cursor()
log_database(f"已连接 Access 文件: {os.path.basename(clean_path)}")
except Exception as conn_err:
log_error(f"连接 Access 失败 [{os.path.basename(clean_path)}]: {conn_err}")
continue
# 统计每个文件的同步情况
file_success_count = 0
file_error_count = 0
file_total_records = 0
for acc_table, data in table_tasks.items():
record_ids = list(data['record_ids'])
log_ids = data['log_ids']
# 读取目标配置
target_conf = tables_map[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)
log_processing(f"正在同步表 [{acc_table}] → [{target_table}] ({len(record_ids)} 条记录)")
try:
# --- A. Access 查新数据 ---
ids_placeholders = ','.join(['?'] * len(record_ids))
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 删旧插新 (事务) ---
# 1. 删除
del_sql = f"DELETE FROM {target_full_name} WHERE [{pk_col}] IN ({ids_placeholders})"
sql_cursor.execute(del_sql, record_ids)
# 2. 插入
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
# 3. 标记日志 Synced = 1
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()
log_success(f"表 [{target_table}] 同步完成: {len(record_ids)} 条记录")
file_success_count += 1
file_total_records += len(record_ids)
except Exception as tbl_err:
log_error(f"表 [{acc_table}] 同步失败: {tbl_err}")
sql_conn.rollback()
file_error_count += 1
acc_conn.close() # 关闭 Access 连接
# 输出文件级别的汇总
if file_success_count > 0 or file_error_count > 0:
summary = f"文件 [{os.path.basename(clean_path)}] 同步汇总: "
summary += f"成功 {file_success_count} 张表 ({file_total_records} 条记录)"
if file_error_count > 0:
summary += f" | 失败 {file_error_count} 张表"
log_sync(summary)
return work_done
except Exception as e:
log_critical(f"全局异常: {e}")
return False
finally:
try: sql_conn.close()
except: pass
if __name__ == "__main__":
log_start("增量同步服务已启动 (配置驱动模式)")
log_info(f"轮询间隔: {POLL_INTERVAL}")
log_info(f"监控配置: {len(SYNC_MAPPING)} 个文件")
logger.info("=" * 70)
while True:
try:
has_work = process_sync_task()
# 如果有工作,说明可能还有积压,休息短一点(0.1s)
# 如果没工作,休息标准间隔(5s)
time.sleep(0.1 if has_work else POLL_INTERVAL)
except KeyboardInterrupt:
logger.info("=" * 70)
log_stop("收到停止信号,服务正在关闭...")
break
except Exception as e:
log_critical(f"主循环崩溃: {e}")
time.sleep(5)