Compare commits

...

10 Commits

Author SHA1 Message Date
Misaka_Company
17b6b246c7 Merge feat/auto-create-table-and-verify-sync into main
Merge auto table creation with per-PK sync verification, combining
both features: target tables are auto-created when missing, and
every sync batch is verified by primary key before marking synced.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 10:52:54 +08:00
Misaka_Company
1ae901a040 feat: add auto table creation and per-PK sync verification
- Add new Access table mappings for transmitter contracts (25/26年变送器) and tube bending workshop (弯管车间烘洗)
- Add auto-create target table from Access schema when table does not exist (db_utils + init_full_sync)
- Implement per-primary-key verification in incremental sync: verify deletes are gone and inserts are present before marking Synced=1
- Add post-commit re-verification with Synced rollback on failure for automatic retry
- Batch IN clause parameters to stay under SQL Server limit
- Adjust poll interval from 5s to 30s
- Improve IDENTITY_INSERT cleanup in finally blocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 10:50:01 +08:00
Misaka_Company
3791966446 Improve transaction management and connection cleanup in incremental sync
- Add explicit BEGIN TRANSACTION for clear transaction boundaries
- Ensure IDENTITY_INSERT state is properly tracked and cleaned up
- Add finally block to guarantee Access connection cleanup
- Move table existence check inside sync loop for better scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-11 15:33:57 +08:00
Misaka_Company
11d284a6bf Add identity column check and manage IDENTITY_INSERT state during sync 2026-01-15 10:27:32 +08:00
Misaka_Company
086c59cc03 Refactor Uptime Kuma heartbeat implementation to use UptimeKumaMonitor class, enhancing maintainability and logging capabilities 2026-01-15 08:52:20 +08:00
Misaka_Company
2a2ec22d1c Enhance Excel synchronization by adding Uptime Kuma heartbeat functionality, new Excel sync configuration, and additional file mappings 2026-01-14 09:13:45 +08:00
Misaka_Company
3bcc5aafbe Add Uptime Kuma heartbeat configuration and implement heartbeat functionality 2026-01-12 17:43:33 +08:00
Misaka_Company
9192f15610 Change log message from log_success to log_info for table sync completion 2026-01-12 16:43:37 +08:00
Misaka_Company
a500f45a29 Remove settings.local.json and update .gitignore to include .claude directory 2026-01-12 16:25:23 +08:00
Misaka_Company
d4db287940 Add ntfy notification for full sync summary and enhance logging for success and failure results 2026-01-12 15:55:40 +08:00
10 changed files with 717 additions and 139 deletions

View File

@@ -1,14 +0,0 @@
{
"permissions": {
"allow": [
"Bash(python etl_manager.py --help)",
"Bash(python run_incremental_sync.py)",
"Bash(python -c \"import run_incremental_sync; print\\(''run_incremental_sync.py: import OK''\\)\")",
"Bash(python -c \"import etl_manager; print\\(''etl_manager.py: import OK''\\)\")",
"Bash(python -c \"import init_full_sync; print\\(''init_full_sync.py: import OK''\\)\")",
"Bash(python -c \"import migration; print\\(''migration.py: import OK''\\)\")",
"Bash(python migration.py)",
"Bash(python -c \"import log_utils; from log_utils import LoggerManager, log_start, log_complete; print\\(''log_utils: OK''\\)\")"
]
}
}

6
.gitignore vendored
View File

@@ -4,4 +4,8 @@ build
dist dist
log log
*.spec *.spec
temp temp
# Claude 临时文件
.claude/
tmpclaude-*

View File

@@ -12,8 +12,8 @@ from .field_mappings import TABLE_SCHEMA, CONTRACT_MAPPING
# 应用设置 # 应用设置
from .app_settings import ( from .app_settings import (
LOG_TABLE_CONFIG, NTFY_CONFIG, LOG_TABLE_CONFIG, NTFY_CONFIG, UPTIME_KUMA_CONFIG, EXCEL_SYNC_UPTIME_KUMA_CONFIG,
POLL_INTERVAL, BATCH_SIZE, CACHE_DIR, TEMP_DIR, POLL_INTERVAL, EXCEL_SYNC_INTERVAL, BATCH_SIZE, CACHE_DIR, TEMP_DIR,
EXECUTION_CARD_FIELDS, CONTRACT_DATA_FIELDS, CONTRACT_DATA_MAPPING EXECUTION_CARD_FIELDS, CONTRACT_DATA_FIELDS, CONTRACT_DATA_MAPPING
) )
@@ -26,7 +26,7 @@ __all__ = [
# Field Mappings # Field Mappings
'TABLE_SCHEMA', 'CONTRACT_MAPPING', 'TABLE_SCHEMA', 'CONTRACT_MAPPING',
# App Settings # App Settings
'LOG_TABLE_CONFIG', 'NTFY_CONFIG', 'LOG_TABLE_CONFIG', 'NTFY_CONFIG', 'UPTIME_KUMA_CONFIG', 'EXCEL_SYNC_UPTIME_KUMA_CONFIG',
'POLL_INTERVAL', 'BATCH_SIZE', 'CACHE_DIR', 'TEMP_DIR', 'POLL_INTERVAL', 'EXCEL_SYNC_INTERVAL', 'BATCH_SIZE', 'CACHE_DIR', 'TEMP_DIR',
'EXECUTION_CARD_FIELDS', 'CONTRACT_DATA_FIELDS', 'CONTRACT_DATA_MAPPING' 'EXECUTION_CARD_FIELDS', 'CONTRACT_DATA_FIELDS', 'CONTRACT_DATA_MAPPING'
] ]

View File

@@ -28,9 +28,25 @@ NTFY_CONFIG = {
} }
} }
# ================= Uptime Kuma 心跳配置 =================
# 增量同步服务心跳
UPTIME_KUMA_CONFIG = {
'enabled': True,
'push_url': 'https://uptimekuma.server10086.icu/api/push/clMSgOQs4CJeF7DJAOiHaFaaZzoL8eB9',
'heartbeat_interval': 59 # 心跳间隔(秒),需要与 Uptime Kuma 设置一致
}
# Excel 同步服务心跳
EXCEL_SYNC_UPTIME_KUMA_CONFIG = {
'enabled': True,
'push_url': 'https://uptimekuma.server10086.icu/api/push/MEXi5DYnC3nTMs2OfMqdelK3FawymtVY',
'heartbeat_interval': 59 # 心跳间隔(秒),需要与 Uptime Kuma 设置一致
}
# ================= 运行参数 ================= # ================= 运行参数 =================
# 合并原 config.py 和 update_config.py 的配置 # 合并原 config.py 和 update_config.py 的配置
POLL_INTERVAL = 5 # 轮询间隔(秒) POLL_INTERVAL = 30 # 轮询间隔(秒)
EXCEL_SYNC_INTERVAL = 600 # Excel 同步周期(秒),默认 10 分钟
BATCH_SIZE = 10000 # 批量处理大小 BATCH_SIZE = 10000 # 批量处理大小
CACHE_DIR = os.path.join(os.getcwd(), "temp") # Excel 缓存目录 CACHE_DIR = os.path.join(os.getcwd(), "temp") # Excel 缓存目录
TEMP_DIR = os.path.join(os.getcwd(), "temp") # 临时目录(兼容 migration.py TEMP_DIR = os.path.join(os.getcwd(), "temp") # 临时目录(兼容 migration.py

View File

@@ -26,6 +26,16 @@ SYNC_MAPPING = {
"target_schema": "productionContractData", "target_schema": "productionContractData",
"target_table": "25年温度计合同数据", "target_table": "25年温度计合同数据",
"pk_col": "ID" "pk_col": "ID"
},
"25年变送器合同数据": {
"target_schema": "productionContractData",
"target_table": "25年变送器合同数据",
"pk_col": "ID"
},
"26年变送器合同数据": {
"target_schema": "productionContractData",
"target_table": "26年变送器合同数据",
"pk_col": "ID"
} }
}, },
r"\\192.168.110.114\生产进度表\2026年数据\成品入库.accdb": { r"\\192.168.110.114\生产进度表\2026年数据\成品入库.accdb": {
@@ -86,6 +96,11 @@ SYNC_MAPPING = {
"target_schema": "machining", "target_schema": "machining",
"target_table": "喷涂寄出_YEAR2026", "target_table": "喷涂寄出_YEAR2026",
"pk_col": "ID" "pk_col": "ID"
},
"车波纹": {
"target_schema": "machining",
"target_table": "车波纹_YEAR2026",
"pk_col": "ID"
} }
}, },
r"\\192.168.110.114\生产进度表\2026年数据\计划.accdb": { r"\\192.168.110.114\生产进度表\2026年数据\计划.accdb": {
@@ -296,6 +311,13 @@ SYNC_MAPPING = {
"target_table": "执行卡下发记录_YEAR2026", "target_table": "执行卡下发记录_YEAR2026",
"pk_col": "ID" "pk_col": "ID"
} }
},
r"\\192.168.110.114\生产进度表\2026年数据\弯管车间.accdb": {
"执行卡下发记录": {
"target_schema": "tubeBending",
"target_table": "烘洗_YEAR2026",
"pk_col": "ID"
}
} }
} }

View File

@@ -1,7 +1,82 @@
# db_utils.py # db_utils.py
import pyodbc import pyodbc
import datetime
import decimal
from config import SQL_SERVER_CONN, ACCESS_DRIVER from config import SQL_SERVER_CONN, ACCESS_DRIVER
# ================= 表存在性检查 & 自动建表 =================
def table_exists(cursor, schema, table):
"""检查 SQL Server 表是否存在"""
query = """
SELECT COUNT(*)
FROM sys.tables t
JOIN sys.schemas s ON t.schema_id = s.schema_id
WHERE s.name = ? AND t.name = ?
"""
cursor.execute(query, (schema, table))
return cursor.fetchone()[0] > 0
def ensure_schema(cursor, schema):
"""确保 schema 存在,不存在则创建"""
cursor.execute("SELECT SCHEMA_ID(?)", (schema,))
if cursor.fetchone()[0] is None:
cursor.execute(f"CREATE SCHEMA [{schema}]")
def _access_col_to_sql(col_info, pk_col):
"""将 Access 列描述 (cursor.description 元组) 转为 SQL Server 列定义
Access ODBC 驱动的 type_code 是 Python 类型对象:
int → INT
str → NVARCHAR(size) (size > 4000 时为 Memo 字段 → NVARCHAR(MAX))
datetime.datetime → DATETIME
float → FLOAT
decimal.Decimal → DECIMAL(p,s)
bool → BIT
"""
col_name, type_code, _, size, precision, scale, nullable = col_info
is_pk = (col_name == pk_col)
if type_code is int:
sql_type = "INT"
if is_pk:
sql_type += " IDENTITY(1,1) PRIMARY KEY"
elif type_code is float:
sql_type = "FLOAT"
elif type_code is bool:
sql_type = "BIT"
elif type_code is datetime.datetime:
sql_type = "DATETIME"
elif type_code is decimal.Decimal:
sql_type = f"DECIMAL({precision or 18}, {scale or 0})"
elif type_code is str:
# size > 4000 → Access Memo 字段,用 NVARCHAR(MAX)
if not size or size <= 0 or size > 4000:
sql_type = "NVARCHAR(MAX)"
else:
sql_type = f"NVARCHAR({size})"
if is_pk:
sql_type += " PRIMARY KEY"
else:
sql_type = "NVARCHAR(255)"
if is_pk:
sql_type += " PRIMARY KEY"
return f"[{col_name}] {sql_type}"
def create_table_from_access(sql_cursor, target_schema, target_table,
acc_description, pk_col):
"""根据 Access cursor.description 在 SQL Server 自动建表"""
col_defs = [_access_col_to_sql(col, pk_col) for col in acc_description]
full_name = fmt_table(target_schema, target_table)
col_str = ",\n ".join(col_defs)
create_sql = f"CREATE TABLE {full_name} (\n {col_str}\n)"
sql_cursor.execute(create_sql)
def get_sql_conn(): def get_sql_conn():
"""获取 SQL Server 连接""" """获取 SQL Server 连接"""
# 显式添加 TrustServerCertificate=yes 以兼容 ODBC Driver 18+ # 显式添加 TrustServerCertificate=yes 以兼容 ODBC Driver 18+

View File

@@ -6,6 +6,7 @@ import argparse
import datetime import datetime
import urllib.parse import urllib.parse
import warnings import warnings
import time
import pandas as pd import pandas as pd
import numpy as np import numpy as np
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
@@ -14,8 +15,14 @@ from sqlalchemy.types import NVARCHAR, Integer, Date
# 导入配置 # 导入配置
from log_utils import (log_error, log_warning, log_info, log_processing, log_file, log_sync, from log_utils import (log_error, log_warning, log_info, log_processing, log_file, log_sync,
log_start, log_complete, LoggerManager) log_start, log_complete, log_stop, LoggerManager)
from config import DB_CONFIG, CACHE_DIR, EXCEL_CONFIGS, BATCH_SIZE, TABLE_SCHEMA from config import (DB_CONFIG, CACHE_DIR, EXCEL_CONFIGS, BATCH_SIZE, TABLE_SCHEMA,
EXCEL_SYNC_INTERVAL, EXCEL_SYNC_UPTIME_KUMA_CONFIG)
from uptime_kuma_utils import UptimeKumaMonitor
# 初始化 Uptime Kuma 监控器
excel_uptime_monitor = UptimeKumaMonitor(EXCEL_SYNC_UPTIME_KUMA_CONFIG)
excel_uptime_monitor.set_logger(log_warning)
# ================= 抑制 openpyxl 的数据验证警告 ================= # ================= 抑制 openpyxl 的数据验证警告 =================
warnings.filterwarnings('ignore', category=UserWarning, module='openpyxl') warnings.filterwarnings('ignore', category=UserWarning, module='openpyxl')
@@ -291,25 +298,72 @@ class DataSynchronizer:
except Exception as e: except Exception as e:
log_error(f"生成 ContractData 失败: {e}", exc_info=True) log_error(f"生成 ContractData 失败: {e}", exc_info=True)
# ================= Uptime Kuma 心跳 =================
# 使用 uptime_kuma_utils.UptimeKumaMonitor 替代原有实现
def main(): def main():
# 初始化日志管理器 # 初始化日志管理器
LoggerManager("etl_manager", log_prefix="sync") LoggerManager("excel_sync", log_prefix="excel_sync")
# 解析参数
parser = argparse.ArgumentParser(description="Excel数据同步至SQL Server") parser = argparse.ArgumentParser(description="Excel数据同步至SQL Server")
parser.add_argument('--force', action='store_true', help='强制同步所有文件') parser.add_argument('--force', action='store_true', help='强制同步所有文件')
parser.add_argument('--once', action='store_true', help='只运行一次后退出')
args = parser.parse_args() args = parser.parse_args()
syncer = DataSynchronizer(force_sync=args.force) syncer = DataSynchronizer(force_sync=args.force)
if args.force: # 启动信息
log_start("Excel 同步任务 (强制模式)") mode = "强制模式" if args.force else "增量模式"
else: if args.once:
log_start("Excel 同步任务 (增量模式)") log_start(f"Excel 同步任务 ({mode}, 单次运行)")
syncer.process_excel_files()
syncer.generate_contract_data()
log_complete("Excel 同步任务已完成")
return
syncer.process_excel_files() # 周期性运行模式
syncer.generate_contract_data() log_start(f"Excel 同步服务已启动 ({mode})")
log_info(f"同步周期: {EXCEL_SYNC_INTERVAL} 秒 ({EXCEL_SYNC_INTERVAL//60} 分钟)")
if EXCEL_SYNC_UPTIME_KUMA_CONFIG.get('enabled', False):
log_info(f"心跳间隔: {EXCEL_SYNC_UPTIME_KUMA_CONFIG['heartbeat_interval']}")
log_info("=" * 70)
log_complete("Excel 同步任务已完成") # 启动时发送第一次心跳
excel_uptime_monitor.send_heartbeat()
try:
while True:
try:
# 执行同步任务
log_info(f"开始执行周期性同步检查...")
syncer.process_excel_files()
syncer.generate_contract_data()
log_info(f"周期性同步检查完成")
# 下次同步时间
next_sync_time = time.time() + EXCEL_SYNC_INTERVAL
log_info(f"下次同步将在 {EXCEL_SYNC_INTERVAL//60} 分钟后进行")
# 等待下次同步,期间持续发送心跳
while time.time() < next_sync_time:
# 检查是否需要发送心跳
excel_uptime_monitor.check_and_send_heartbeat()
# 短暂休眠
time.sleep(1)
except KeyboardInterrupt:
log_info("=" * 70)
log_stop("收到停止信号,服务正在关闭...")
break
except Exception as e:
log_error(f"同步任务异常: {e}", exc_info=True)
log_info(f"将在 {EXCEL_SYNC_INTERVAL//60} 分钟后重试...")
time.sleep(EXCEL_SYNC_INTERVAL)
finally:
# 停止时发送心跳停止信号
excel_uptime_monitor.send_stop_signal()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -3,6 +3,7 @@ from config import SQL_SERVER_CONN, ACCESS_DRIVER, SYNC_MAPPING, BATCH_SIZE
import db_utils import db_utils
import time import time
import os import os
import ntfy_utils
from log_utils import (LoggerManager, log_success, log_error, log_warning, log_info, log_processing, from log_utils import (LoggerManager, log_success, log_error, log_warning, log_info, log_processing,
log_skip, log_start, log_file, log_database, log_sync, log_complete) log_skip, log_start, log_file, log_database, log_sync, log_complete)
@@ -21,7 +22,7 @@ def format_duration(seconds):
def has_identity_column(sql_cursor, schema, table): def has_identity_column(sql_cursor, schema, table):
"""检查表是否包含标识列""" """检查表是否包含标识列"""
query = """ query = """
SELECT COUNT(*) SELECT COUNT(*)
FROM sys.columns c FROM sys.columns c
JOIN sys.tables t ON c.object_id = t.object_id JOIN sys.tables t ON c.object_id = t.object_id
JOIN sys.schemas s ON t.schema_id = s.schema_id JOIN sys.schemas s ON t.schema_id = s.schema_id
@@ -30,18 +31,69 @@ def has_identity_column(sql_cursor, schema, table):
sql_cursor.execute(query, (schema, table)) sql_cursor.execute(query, (schema, table))
return sql_cursor.fetchone()[0] > 0 return sql_cursor.fetchone()[0] > 0
def _send_summary_notification(sync_results, success_count, failed_count, total_rows, total_time):
"""发送汇总 ntfy 通知"""
if not sync_results:
return
# 分类成功和失败的表
success_tables = [r for r in sync_results if r['status'] == 'success']
failed_tables = [r for r in sync_results if r['status'] == 'failed']
# 构建消息
lines = []
lines.append("🎯 全量同步完成\n")
# 成功的表
if success_tables:
lines.append(f"✅ 成功 {len(success_tables)} 张表:")
for r in success_tables:
lines.append(f"{r['table']}: {r['rows']:,} 行, {format_duration(r['duration'])}")
lines.append("")
# 失败的表
if failed_tables:
lines.append(f"❌ 失败 {failed_count} 张表:")
for r in failed_tables:
error_brief = r['error'][:50] + "..." if len(r['error']) > 50 else r['error']
lines.append(f"{r['table']}: {error_brief}")
lines.append("")
# 总计
lines.append(f"总计: {total_rows:,} 行 | 用时: {format_duration(total_time)}")
message = "\n".join(lines)
# 根据成功/失败情况决定优先级和标签
if failed_count > 0:
priority = "high"
tags = ["warning", "database"]
else:
priority = "default"
tags = ["white_check_mark", "database"]
ntfy_utils.send_ntfy(
message=message,
title="全量同步完成",
priority=priority,
tags=tags
)
def run_full_sync(): def run_full_sync():
log_info("=" * 70) log_info("=" * 70)
log_start( "全量初始化同步") log_start( "全量初始化同步")
log_info( f"配置文件数: {len(SYNC_MAPPING)}") log_info( f"配置文件数: {len(SYNC_MAPPING)}")
log_info( f"批次大小: {BATCH_SIZE}") log_info( f"批次大小: {BATCH_SIZE}")
log_info("=" * 70) log_info("=" * 70)
sync_start_time = time.time() sync_start_time = time.time()
total_tables = 0 total_tables = 0
success_tables = 0 success_tables = 0
failed_tables = 0 failed_tables = 0
total_rows = 0 total_rows = 0
# 收集每张表的同步结果,用于最终汇总通知
sync_results = [] # 格式: {'table': str, 'status': 'success'|'failed', 'rows': int, 'duration': float, 'error': str}
try: try:
sql_conn = db_utils.get_sql_conn() sql_conn = db_utils.get_sql_conn()
@@ -88,9 +140,18 @@ def run_full_sync():
log_info( f" 检测到 {len(columns)} 个列") log_info( f" 检测到 {len(columns)} 个列")
insert_sql = db_utils.generate_insert_sql(target_schema, target_table, columns) insert_sql = db_utils.generate_insert_sql(target_schema, target_table, columns)
# 3. TRUNCATE 目标表 # 3. 检查目标表是否存在,不存在则自动创建
sql_cursor.execute(f"TRUNCATE TABLE {full_target_name}") if not db_utils.table_exists(sql_cursor, target_schema, target_table):
log_info( f" 已清空目标表") db_utils.ensure_schema(sql_cursor, target_schema)
db_utils.create_table_from_access(
sql_cursor, target_schema, target_table,
acc_cursor.description, target_config['pk_col']
)
sql_conn.commit() # DDL 必须单独提交,否则 pyodbc 参数绑定无法获取正确的列元数据
log_info( f" 目标表不存在,已自动创建")
else:
sql_cursor.execute(f"TRUNCATE TABLE {full_target_name}")
log_info( f" 已清空目标表")
# 4. 检查并启用 IDENTITY_INSERT # 4. 检查并启用 IDENTITY_INSERT
has_identity = has_identity_column(sql_cursor, target_schema, target_table) has_identity = has_identity_column(sql_cursor, target_schema, target_table)
@@ -137,12 +198,21 @@ def run_full_sync():
log_warning( f" 关闭 IDENTITY_INSERT 时警告: {id_err}") log_warning( f" 关闭 IDENTITY_INSERT 时警告: {id_err}")
sql_conn.commit() sql_conn.commit()
# 显示最终统计 # 显示最终统计
table_time = time.time() - table_start_time table_time = time.time() - table_start_time
final_rate = table_rows / table_time if table_time > 0 else 0 final_rate = table_rows / table_time if table_time > 0 else 0
log_success( f"表 [{target_table}] 完成: {table_rows:,} 行 | 速率: {final_rate:,.0f} 行/秒 | 用时: {format_duration(table_time)}") log_info( f"表 [{target_table}] 完成: {table_rows:,} 行 | 速率: {final_rate:,.0f} 行/秒 | 用时: {format_duration(table_time)}")
# 收集成功结果
sync_results.append({
'table': target_table,
'status': 'success',
'rows': table_rows,
'duration': table_time,
'error': None
})
success_tables += 1 success_tables += 1
file_success += 1 file_success += 1
total_rows += table_rows total_rows += table_rows
@@ -151,9 +221,20 @@ def run_full_sync():
except Exception as tbl_err: except Exception as tbl_err:
failed_tables += 1 failed_tables += 1
file_failed += 1 file_failed += 1
log_error( f"表 [{acc_table}] 同步失败: {tbl_err}", exc_info=True) error_msg = str(tbl_err)
log_info( f"表 [{acc_table}] 同步失败: {error_msg}")
# 收集失败结果
sync_results.append({
'table': target_table,
'status': 'failed',
'rows': 0,
'duration': time.time() - table_start_time,
'error': error_msg
})
sql_conn.rollback() sql_conn.rollback()
# 确保清理 IDENTITY_INSERT 状态 # 确保清理 IDENTITY_INSERT 状态
try: try:
sql_cursor.execute(f"SET IDENTITY_INSERT {full_target_name} OFF") sql_cursor.execute(f"SET IDENTITY_INSERT {full_target_name} OFF")
@@ -175,11 +256,11 @@ def run_full_sync():
log_error( f"文件 [{os.path.basename(acc_path)}] 处理失败: {file_err}", exc_info=True) log_error( f"文件 [{os.path.basename(acc_path)}] 处理失败: {file_err}", exc_info=True)
sql_conn.close() sql_conn.close()
# 总结统计 # 总结统计
sync_time = time.time() - sync_start_time sync_time = time.time() - sync_start_time
log_info("\n" + "=" * 70) log_info("\n" + "=" * 70)
log_complete( "全量同步任务结束") log_info( "全量同步任务结束")
log_info("-" * 70) log_info("-" * 70)
log_info( f"总表数: {total_tables} 张 | 成功: {success_tables} 张 | 失败: {failed_tables}") log_info( f"总表数: {total_tables} 张 | 成功: {success_tables} 张 | 失败: {failed_tables}")
log_info( f"总行数: {total_rows:,}") log_info( f"总行数: {total_rows:,}")
@@ -188,5 +269,8 @@ def run_full_sync():
log_info( f"整体平均速率: {total_rows/sync_time:,.0f} 行/秒") log_info( f"整体平均速率: {total_rows/sync_time:,.0f} 行/秒")
log_info("=" * 70) log_info("=" * 70)
# 发送汇总 ntfy 通知
_send_summary_notification(sync_results, success_tables, failed_tables, total_rows, sync_time)
if __name__ == "__main__": if __name__ == "__main__":
run_full_sync() run_full_sync()

View File

@@ -2,58 +2,163 @@ import time
import os import os
import sys import sys
import pyodbc import pyodbc
from config import SQL_SERVER_CONN, ACCESS_DRIVER, SYNC_MAPPING, POLL_INTERVAL, LOG_TABLE_CONFIG from config import SQL_SERVER_CONN, ACCESS_DRIVER, SYNC_MAPPING, POLL_INTERVAL, LOG_TABLE_CONFIG, UPTIME_KUMA_CONFIG
import db_utils import db_utils
from log_utils import (LoggerManager, log_success, log_error, log_warning, log_info, log_processing, from log_utils import (LoggerManager, log_success, log_error, log_warning, log_info, log_processing,
log_skip, log_critical, log_start, log_stop, log_file, log_database, log_sync) log_skip, log_critical, log_start, log_stop, log_file, log_database, log_sync)
from uptime_kuma_utils import UptimeKumaMonitor
# 初始化日志管理器 # 初始化日志管理器
LoggerManager("run_incremental_sync", log_prefix="incremental") LoggerManager("run_incremental_sync", log_prefix="incremental")
# 初始化 Uptime Kuma 监控器
uptime_monitor = UptimeKumaMonitor(UPTIME_KUMA_CONFIG)
uptime_monitor.set_logger(log_warning)
# 提交后是否再做一次独立复核 (额外保险, 略增开销; 若不需要可设为 False)
ENABLE_POST_COMMIT_VERIFY = True
# IN 子句单批最大参数数 (SQL Server 上限约 2100, 留足余量)
IN_CLAUSE_BATCH = 900
class SyncVerificationError(Exception):
"""数据落库校验未通过时抛出, 触发事务回滚并保留 Synced=0 以便下一轮重试"""
pass
# ================= 辅助函数 =================
def has_identity_column(sql_cursor, schema, table):
"""检查表是否包含标识列"""
query = """
SELECT COUNT(*)
FROM sys.columns c
JOIN sys.tables t ON c.object_id = t.object_id
JOIN sys.schemas s ON t.schema_id = s.schema_id
WHERE s.name = ? AND t.name = ? AND c.is_identity = 1
"""
sql_cursor.execute(query, (schema, table))
return sql_cursor.fetchone()[0] > 0
def _chunked(values, size=IN_CLAUSE_BATCH):
"""将集合/列表分批, 避免 IN 子句参数数量超过 SQL Server 上限"""
items = list(values)
for i in range(0, len(items), size):
yield items[i:i + size]
def _norm_key(v):
"""主键归一化, 用于跨来源(日志侧 / Access 侧 / SQL 侧)的集合比较, 规避类型差异"""
return str(v).strip() if v is not None else None
def fetch_existing_pks(sql_cursor, full_name, pk_col, pk_values):
"""
拿一批主键去目标表查询, 返回其中【实际存在】的主键集合 (分批查询)。
这是逐主键校验的基础: 用 "查到了哪些" 来精确判断存在 / 不存在,
而不是用数量相减 (数量法会被 "一边少插一边漏删" 互相抵消而误判)。
"""
found = set()
for chunk in _chunked(pk_values):
if not chunk:
continue
placeholders = ','.join(['?'] * len(chunk))
sql = f"SELECT [{pk_col}] FROM {full_name} WHERE [{pk_col}] IN ({placeholders})"
sql_cursor.execute(sql, list(chunk))
for row in sql_cursor.fetchall():
found.add(row[0])
return found
def verify_sync_result(sql_cursor, full_name, pk_col, record_ids, inserted_pk_set):
"""
逐主键校验本批同步结果 (不依赖数量比较):
- 删除校验: 源端已读不到的记录 (record_ids 中不在 inserted_pk_set 的部分),
删除后必须【全部不存在】于目标表; 只要还查得到任意一条即判失败。
- 插入校验: 从 Access 实际读到并重新插入的主键 (inserted_pk_set),
必须【全部存在】于目标表; 只要有一条查不到即判失败。
校验不通过抛出 SyncVerificationError, 并给出具体出问题的主键样例。
返回值: 本批 "随源删除" 的记录条数 (供日志展示)。
"""
inserted_keys = {_norm_key(pk) for pk in inserted_pk_set}
# 1) 删除校验: 应删除的记录, 删除后必须不在目标表
to_delete = [rid for rid in record_ids if _norm_key(rid) not in inserted_keys]
if to_delete:
leftover = fetch_existing_pks(sql_cursor, full_name, pk_col, to_delete)
if leftover:
sample = list(leftover)[:5]
raise SyncVerificationError(
f"删除校验失败: {len(leftover)} 条记录删除后仍存在于目标表, 例如 {sample}")
# 2) 插入校验: 应插入的记录, 插入后必须存在于目标表
if inserted_pk_set:
present_keys = {_norm_key(p) for p in fetch_existing_pks(sql_cursor, full_name, pk_col, inserted_pk_set)}
missing = [pk for pk in inserted_pk_set if _norm_key(pk) not in present_keys]
if missing:
raise SyncVerificationError(
f"插入校验失败: {len(missing)} 条记录插入后在目标表查不到, 例如 {missing[:5]}")
return len(to_delete)
def find_pk_index(acc_cols, pk_col):
"""在 Access 结果列中定位主键列下标 (大小写不敏感兜底)"""
if pk_col in acc_cols:
return acc_cols.index(pk_col)
lower_map = {c.lower(): i for i, c in enumerate(acc_cols)}
return lower_map.get(pk_col.lower())
# ================= 主逻辑 ================= # ================= 主逻辑 =================
def process_sync_task(): def process_sync_task():
""" """
逻辑重构 逻辑重构:
遍历 SYNC_MAPPING 中的每一个文件 -> 去日志表查询该文件下特定表的未同步记录。 遍历 SYNC_MAPPING 中的每一个文件 -> 去日志表查询该文件下特定表的未同步记录。
校验机制 (逐主键确认):
删旧插新后, 在提交前对本批数据做删除校验 + 插入校验 ——
应删除的主键确认已不在目标表、应插入的主键确认已在目标表, 二者都通过才标记
Synced=1 并提交; 否则回滚整批, 保持 Synced=0, 下一轮自动重试。
""" """
sql_conn = db_utils.get_sql_conn() sql_conn = db_utils.get_sql_conn()
sql_cursor = sql_conn.cursor() sql_cursor = sql_conn.cursor()
sql_cursor.fast_executemany = True # 高性能开关 sql_cursor.fast_executemany = True # 高性能开关
# 标记是否有工作被处理(用于控制轮询休眠时间) # 标记是否有工作被处理(用于控制轮询休眠时间)
work_done = False work_done = False
cols = LOG_TABLE_CONFIG cols = LOG_TABLE_CONFIG
log_full_name = db_utils.fmt_table(cols['schema'], cols['table_name']) log_full_name = db_utils.fmt_table(cols['schema'], cols['table_name'])
try: try:
# === 核心循环:以配置文件为驱动 === # === 核心循环:以配置文件为驱动 ===
for clean_path, tables_map in SYNC_MAPPING.items(): for clean_path, tables_map in SYNC_MAPPING.items():
# 1. 准备查询条件 # 1. 准备查询条件
# 获取该文件下所有需要同步的表名列表
target_tables = list(tables_map.keys()) target_tables = list(tables_map.keys())
if not target_tables: if not target_tables:
continue continue
# 构造 TableAddress 的精确匹配条件 # 构造 TableAddress 的精确匹配条件
# VBA 逻辑:网络路径带 ";DATABASE=", 本地路径带 "LOCAL:"
# 我们直接构造这两个字符串,让 SQL Server 做精确匹配,效率极高
# 注意:将路径转为 Windows 标准反斜杠 # 注意:将路径转为 Windows 标准反斜杠
win_path = os.path.normpath(clean_path) win_path = os.path.normpath(clean_path)
addr_candidates = [ addr_candidates = [
f";DATABASE={win_path}", # 情况1 f";DATABASE={win_path}", # 情况1
f"LOCAL={win_path}", # 情况2 (注意 VBA 代码里可能是 LOCAL: 或 LOCAL=,请核对) f"LOCAL={win_path}", # 情况2
f"LOCAL:{win_path}", # 情况3 f"LOCAL:{win_path}", # 情况3
win_path # 情况4 (兼容没有前缀的情况) win_path # 情况4 (兼容没有前缀的情况)
] ]
# 2. 构造动态 SQL 查询 # 2. 构造动态 SQL 查询
# WHERE Synced=0 AND Address IN (...) AND TableName IN (...)
placeholders_addr = ','.join(['?'] * len(addr_candidates)) placeholders_addr = ','.join(['?'] * len(addr_candidates))
placeholders_tbl = ','.join(['?'] * len(target_tables)) placeholders_tbl = ','.join(['?'] * len(target_tables))
query_log = f""" query_log = f"""
SELECT TOP 1000 SELECT TOP 1000
{cols['col_log_id']}, {cols['col_log_id']},
@@ -66,21 +171,21 @@ def process_sync_task():
AND {cols['col_table_name']} IN ({placeholders_tbl}) AND {cols['col_table_name']} IN ({placeholders_tbl})
ORDER BY {cols['col_log_id']} ASC ORDER BY {cols['col_log_id']} ASC
""" """
# 参数列表:先放地址,再放表名 # 参数列表:先放地址,再放表名
params = addr_candidates + target_tables params = addr_candidates + target_tables
sql_cursor.execute(query_log, params) sql_cursor.execute(query_log, params)
logs = sql_cursor.fetchall() logs = sql_cursor.fetchall()
if not logs:
continue # 这个文件没有需要同步的记录,检查下一个文件
work_done = True # 标记有工作 if not logs:
continue # 这个文件没有需要同步的记录,检查下一个文件
work_done = True # 标记有工作
log_file(f"{os.path.basename(clean_path)} 发现 {len(logs)} 条待同步变更") log_file(f"{os.path.basename(clean_path)} 发现 {len(logs)} 条待同步变更")
# 3. 本地分组 (按表名) # 3. 本地分组 (按表名)
# 结构: table_tasks[TableName] = { ids: {}, log_ids: [] } # 结构: table_tasks[TableName] = { record_ids: set(), log_ids: [] }
table_tasks = {} table_tasks = {}
for row in logs: for row in logs:
log_id, acc_table, record_id = row log_id, acc_table, record_id = row
@@ -94,6 +199,7 @@ def process_sync_task():
log_error(f"无法访问文件: {clean_path}") log_error(f"无法访问文件: {clean_path}")
continue continue
acc_conn = None
try: try:
acc_conn = db_utils.get_access_conn(clean_path) acc_conn = db_utils.get_access_conn(clean_path)
acc_cursor = acc_conn.cursor() acc_cursor = acc_conn.cursor()
@@ -107,65 +213,137 @@ def process_sync_task():
file_error_count = 0 file_error_count = 0
file_total_records = 0 file_total_records = 0
for acc_table, data in table_tasks.items(): try:
record_ids = list(data['record_ids']) for acc_table, data in table_tasks.items():
log_ids = data['log_ids'] 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)} 条记录)") # 读取目标配置
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)
# 检查目标表是否存在,不存在则自动创建
if not db_utils.table_exists(sql_cursor, target_schema, target_table):
db_utils.ensure_schema(sql_cursor, target_schema)
acc_cursor.execute(f"SELECT TOP 1 * FROM [{acc_table}]")
db_utils.create_table_from_access(
sql_cursor, target_schema, target_table,
acc_cursor.description, pk_col
)
log_info(f"目标表 [{target_table}] 不存在,已自动创建")
log_processing(f"正在同步表 [{acc_table}] → [{target_table}] ({len(record_ids)} 条记录)")
identity_enabled = False
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]
# 定位主键列, 取出 Access 实际读到的主键集合 (校验基准)
pk_index = find_pk_index(acc_cols, pk_col)
if new_rows and pk_index is None:
raise SyncVerificationError(
f"在 Access 表 [{acc_table}] 中找不到主键列 [{pk_col}]")
inserted_pk_set = (set(row[pk_index] for row in new_rows)
if pk_index is not None else set())
# --- B. SQL Server 删除旧记录 ---
del_sql = f"DELETE FROM {target_full_name} WHERE [{pk_col}] IN ({ids_placeholders})"
sql_cursor.execute(del_sql, record_ids)
# --- C. 插入新记录 ---
if new_rows:
insert_sql = db_utils.generate_insert_sql(target_schema, target_table, acc_cols)
# 检查并启用 IDENTITY_INSERT
if has_identity_column(sql_cursor, target_schema, target_table):
try:
sql_cursor.execute(f"SET IDENTITY_INSERT {target_full_name} ON")
identity_enabled = True
except Exception as id_err:
log_error(f"无法启用 IDENTITY_INSERT: {id_err}")
raise
sql_cursor.executemany(insert_sql, new_rows)
# 立即关闭 IDENTITY_INSERT (会话级设置, 不随事务回滚)
if identity_enabled:
sql_cursor.execute(f"SET IDENTITY_INSERT {target_full_name} OFF")
identity_enabled = False
# --- D. 提交前校验 (逐主键确认: 应删的已不在 / 应插的已在) ---
removed_count = verify_sync_result(
sql_cursor, target_full_name, pk_col, record_ids, inserted_pk_set)
# --- E. 校验通过 -> 标记日志 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)
# --- F. 提交事务 (删/插/标记 原子生效) ---
sql_conn.commit()
# --- G. 提交后独立复核 (可选, 防 "提交成功但未持久化" 等极端情况) ---
if ENABLE_POST_COMMIT_VERIFY:
try:
verify_sync_result(
sql_cursor, target_full_name, pk_col, record_ids, inserted_pk_set)
except SyncVerificationError as post_err:
log_critical(
f"严重: 表 [{target_table}] 提交后复核失败! {post_err}; "
f"撤销同步标记以便重试")
revert_sql = f"""
UPDATE {log_full_name}
SET {cols['col_synced']} = 0
WHERE {cols['col_log_id']} IN ({log_placeholders})
"""
sql_cursor.execute(revert_sql, log_ids)
sql_conn.commit()
file_error_count += 1
continue
# 同步成功
msg = f"表 [{target_table}] 同步并校验通过: {len(inserted_pk_set)} 条入库"
if removed_count > 0:
msg += f", {removed_count} 条随源删除"
log_success(msg)
file_success_count += 1
file_total_records += len(record_ids)
except Exception as tbl_err:
# 清理可能残留的 IDENTITY_INSERT 会话状态
if identity_enabled:
try:
sql_cursor.execute(f"SET IDENTITY_INSERT {target_full_name} OFF")
except:
pass
# 回滚本表的 删/插/标记, 保持 Synced=0, 下一轮自动重试
try:
sql_conn.rollback()
except:
pass
log_error(f"表 [{acc_table}] 同步失败 (已回滚, 将重试): {tbl_err}")
file_error_count += 1
finally:
# 确保 Access 连接关闭
try: try:
# --- A. Access 查新数据 --- if acc_conn:
ids_placeholders = ','.join(['?'] * len(record_ids)) acc_conn.close()
acc_sql = f"SELECT * FROM [{acc_table}] WHERE [{pk_col}] IN ({ids_placeholders})" log_info(f"已关闭 Access 连接: {os.path.basename(clean_path)}")
acc_cursor.execute(acc_sql, record_ids) except Exception as close_err:
new_rows = acc_cursor.fetchall() log_warning(f"关闭 Access 连接时出错: {close_err}")
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: if file_success_count > 0 or file_error_count > 0:
summary = f"文件 [{os.path.basename(clean_path)}] 同步汇总: " summary = f"文件 [{os.path.basename(clean_path)}] 同步汇总: "
@@ -180,25 +358,46 @@ def process_sync_task():
log_critical(f"全局异常: {e}") log_critical(f"全局异常: {e}")
return False return False
finally: finally:
try: sql_conn.close() try:
except: pass sql_conn.close()
except:
pass
# ================= Uptime Kuma 心跳 =================
# 使用 uptime_kuma_utils.UptimeKumaMonitor 替代原有实现
if __name__ == "__main__": if __name__ == "__main__":
log_start("增量同步服务已启动 (配置驱动模式)") log_start("增量同步服务已启动 (配置驱动模式 + 逐主键落库校验)")
log_info(f"轮询间隔: {POLL_INTERVAL}") log_info(f"轮询间隔: {POLL_INTERVAL}")
log_info(f"监控配置: {len(SYNC_MAPPING)} 个文件") log_info(f"监控配置: {len(SYNC_MAPPING)} 个文件")
log_info(f"提交后复核: {'开启' if ENABLE_POST_COMMIT_VERIFY else '关闭'}")
if UPTIME_KUMA_CONFIG.get('enabled', False):
log_info(f"心跳间隔: {UPTIME_KUMA_CONFIG['heartbeat_interval']}")
log_info("=" * 70) log_info("=" * 70)
while True: # 启动时发送第一次心跳
try: uptime_monitor.send_heartbeat()
has_work = process_sync_task()
# 如果有工作,说明可能还有积压,休息短一点(0.1s) try:
# 如果没工作,休息标准间隔(5s) while True:
time.sleep(0.1 if has_work else POLL_INTERVAL) try:
except KeyboardInterrupt: has_work = process_sync_task()
log_info("=" * 70)
log_stop("收到停止信号,服务正在关闭...") # 检查是否需要发送心跳
break uptime_monitor.check_and_send_heartbeat()
except Exception as e:
log_critical(f"主循环崩溃: {e}") # 如果有工作,说明可能还有积压,休息短一点(0.1s)
time.sleep(5) # 如果没工作,休息标准间隔
time.sleep(0.1 if has_work else POLL_INTERVAL)
except KeyboardInterrupt:
log_info("=" * 70)
log_stop("收到停止信号,服务正在关闭...")
break
except Exception as e:
log_critical(f"主循环崩溃: {e}")
time.sleep(5)
finally:
# 停止时发送心跳停止信号(可选)
uptime_monitor.send_stop_signal()

138
uptime_kuma_utils.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Uptime Kuma 心跳监控工具
用于向 Uptime Kuma 服务发送心跳信号,监控服务运行状态。
"""
import time
import requests
class UptimeKumaMonitor:
"""
Uptime Kuma 心跳监控器
示例:
from uptime_kuma_utils import UptimeKumaMonitor
# 初始化监控器
monitor = UptimeKumaMonitor({
'enabled': True,
'push_url': 'https://uptimekuma.example.com/api/push/xxx',
'heartbeat_interval': 59
})
# 启动时发送首次心跳
monitor.send_heartbeat()
# 主循环中定期发送心跳
while True:
monitor.check_and_send_heartbeat()
# ... 执行任务 ...
# 停止时发送停止信号
monitor.send_stop_signal()
"""
def __init__(self, config):
"""
初始化监控器
Args:
config (dict): 配置字典,包含:
- enabled (bool): 是否启用心跳
- push_url (str): Uptime Kuma 推送 URL
- heartbeat_interval (int): 心跳间隔(秒)
"""
self.config = config or {}
self.enabled = self.config.get('enabled', False)
self.push_url = self.config.get('push_url')
self.heartbeat_interval = self.config.get('heartbeat_interval', 60)
self._last_heartbeat_time = 0
self._logger = None
def set_logger(self, logger_func):
"""
设置日志记录函数
Args:
logger_func: 日志记录函数,如 log_warning, log_info 等
"""
self._logger = logger_func
def _log(self, func_name, message):
"""内部日志记录方法"""
if self._logger:
self._logger(message)
def send_heartbeat(self):
"""
发送心跳信号到 Uptime Kuma
Returns:
bool: 是否成功发送
"""
if not self.enabled or not self.push_url:
return False
try:
params = {
'status': 'up',
'msg': 'OK',
'ping': ''
}
response = requests.get(self.push_url, params=params, timeout=5)
response.raise_for_status()
self._last_heartbeat_time = time.time()
return True
except Exception as e:
self._log('warning', f"心跳发送失败: {e}")
return False
def send_stop_signal(self):
"""
发送停止信号到 Uptime Kuma
Returns:
bool: 是否成功发送
"""
if not self.enabled or not self.push_url:
return False
try:
params = {'status': 'down', 'msg': 'Service stopped'}
response = requests.get(self.push_url, params=params, timeout=5)
response.raise_for_status()
return True
except Exception as e:
# 停止信号失败不影响主逻辑
return False
def check_and_send_heartbeat(self):
"""
检查是否需要发送心跳,如果需要则发送
Returns:
bool: 是否发送了心跳
"""
if not self.enabled:
return False
time_since_last = time.time() - self._last_heartbeat_time
if time_since_last >= self.heartbeat_interval:
return self.send_heartbeat()
return False
def get_time_since_last_heartbeat(self):
"""
获取距离上次心跳的时间(秒)
Returns:
float: 距离上次心跳的秒数
"""
return time.time() - self._last_heartbeat_time
@property
def last_heartbeat_time(self):
"""获取上次心跳时间戳"""
return self._last_heartbeat_time