lysync 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lysync/__init__.py +0 -0
- lysync/admin.py +3 -0
- lysync/apps.py +25 -0
- lysync/export/__init__.py +10 -0
- lysync/export/collector.py +72 -0
- lysync/export/engine.py +306 -0
- lysync/export/jinja_env.py +110 -0
- lysync/export/processor.py +40 -0
- lysync/export/utils.py +330 -0
- lysync/export/writer.py +143 -0
- lysync/funcs/__init__.py +25 -0
- lysync/funcs/collect.py +25 -0
- lysync/funcs/process.py +286 -0
- lysync/funcs/registry.py +116 -0
- lysync/migrations/0001_initial.py +207 -0
- lysync/migrations/0002_alter_syncexporttask_explain_json.py +19 -0
- lysync/migrations/__init__.py +0 -0
- lysync/models.py +383 -0
- lysync/signals/__init__.py +3 -0
- lysync/signals/datasource.py +46 -0
- lysync/tasks.py +453 -0
- lysync/tests.py +3 -0
- lysync/urls.py +31 -0
- lysync/utils.py +22 -0
- lysync/views.py +765 -0
- lysync-1.0.0.dist-info/METADATA +12 -0
- lysync-1.0.0.dist-info/RECORD +29 -0
- lysync-1.0.0.dist-info/WHEEL +5 -0
- lysync-1.0.0.dist-info/top_level.txt +1 -0
lysync/__init__.py
ADDED
|
File without changes
|
lysync/admin.py
ADDED
lysync/apps.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LysyncConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "lysync"
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
# 确保内置自定义函数(process / collect)完成注册
|
|
10
|
+
import lysync.funcs # noqa: F401
|
|
11
|
+
# 收集 Celery 任务定义
|
|
12
|
+
import lysync.tasks # noqa: F401
|
|
13
|
+
|
|
14
|
+
# 注册数据源信号(post_save / 批量更新连接检查)
|
|
15
|
+
import lysync.signals # noqa: F401 (触发 post_save 注册)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# 初始化周期性任务(导出 / 中断校验)
|
|
19
|
+
try:
|
|
20
|
+
from lysync.tasks import init_periodic_tasks
|
|
21
|
+
|
|
22
|
+
init_periodic_tasks()
|
|
23
|
+
except Exception:
|
|
24
|
+
# 在缺少 django_celery_beat 或还未迁移的环境中静默忽略
|
|
25
|
+
pass
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""导出功能包:采集 -> 处理 -> 写入目标库。
|
|
2
|
+
|
|
3
|
+
子模块各自独立导入即可(避免在本 __init__ 中提前引入需要 Django 的子模块):
|
|
4
|
+
|
|
5
|
+
* ``from lysync.export.collector import collect``
|
|
6
|
+
* ``from lysync.export.processor import run_field_process_list, run_dataset_process_list``
|
|
7
|
+
* ``from lysync.export.writer import write_rows_to_target``
|
|
8
|
+
* ``from lysync.export.engine import exec_sync_export_task_item, exec_sync_export_check_item``
|
|
9
|
+
* ``from lysync.export.utils import fetch_data_from_temp_db, render_sql_template, ...``
|
|
10
|
+
"""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""数据采集层。
|
|
2
|
+
|
|
3
|
+
根据 :class:`~lysync.models.SyncDataSource` 的类型,从外部数据库(SQL)或
|
|
4
|
+
自定义采集函数(``custom_func``)获取原始数据,返回 ``list[dict]``。
|
|
5
|
+
|
|
6
|
+
数据库采集改为 **按天(前闭后开)** 同步:``SyncExportTask.sql`` 即 jinja2 模板,
|
|
7
|
+
由 :func:`~lysync.export.utils.render_sql_template` 注入 ``start_time`` /
|
|
8
|
+
``end_time``(均为 ``datetime``,区间为 ``[start_time, end_time)``)渲染成最终 SQL。
|
|
9
|
+
区间过滤逻辑由模板内 SQL 自己实现(不再使用 sqlglot 追加分页参数)。
|
|
10
|
+
|
|
11
|
+
返回的字典键名应与目标模型字段(``SyncModelField.field_name``)保持一致。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from lysync.funcs.registry import get_collect_func
|
|
16
|
+
from lysync.models import SyncDataSource, SyncExportTask
|
|
17
|
+
from lysync.export.utils import fetch_data_from_temp_db, render_sql_template
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def collect_from_db(data_source: SyncDataSource, sql: str, start_time, end_time):
|
|
21
|
+
"""从数据库采集:用 jinja2 渲染 SQL 模板(注入 ``start_time`` / ``end_time``)。"""
|
|
22
|
+
exec_sql = render_sql_template(sql, start_time, end_time)
|
|
23
|
+
rows = fetch_data_from_temp_db(
|
|
24
|
+
data_source.db_type,
|
|
25
|
+
data_source.db_host,
|
|
26
|
+
data_source.db_port,
|
|
27
|
+
data_source.db_user,
|
|
28
|
+
data_source.db_password,
|
|
29
|
+
data_source.db_name,
|
|
30
|
+
data_source.db_options,
|
|
31
|
+
exec_sql,
|
|
32
|
+
)
|
|
33
|
+
return rows or [], exec_sql
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def collect_custom(
|
|
37
|
+
data_source: SyncDataSource,
|
|
38
|
+
task: SyncExportTask = None,
|
|
39
|
+
start_time=None,
|
|
40
|
+
end_time=None,
|
|
41
|
+
**extra_kwargs,
|
|
42
|
+
):
|
|
43
|
+
"""调用自定义采集函数,并把 ``start_time`` / ``end_time`` 透传进去。
|
|
44
|
+
|
|
45
|
+
自定义函数签名:``func(start_time=..., end_time=..., **kwargs) -> list[dict]``。
|
|
46
|
+
"""
|
|
47
|
+
if not data_source.custom_func:
|
|
48
|
+
raise ValueError("数据源未配置 custom_func(自定义采集函数名称)")
|
|
49
|
+
func = get_collect_func(data_source.custom_func)
|
|
50
|
+
|
|
51
|
+
kwargs = {}
|
|
52
|
+
if isinstance(data_source.custom_func_kwargs, dict):
|
|
53
|
+
kwargs.update(data_source.custom_func_kwargs)
|
|
54
|
+
if task is not None and isinstance(task.custom_func_kwargs, dict):
|
|
55
|
+
kwargs.update(task.custom_func_kwargs)
|
|
56
|
+
kwargs.update(start_time=start_time, end_time=end_time)
|
|
57
|
+
kwargs.update(extra_kwargs)
|
|
58
|
+
|
|
59
|
+
rows = func(**kwargs)
|
|
60
|
+
if not isinstance(rows, list):
|
|
61
|
+
raise TypeError(f"自定义采集函数 {data_source.custom_func} 应返回 list[dict],实际返回 {type(rows)}")
|
|
62
|
+
return rows, None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def collect(task: SyncExportTask, start_time=None, end_time=None, **extra_kwargs):
|
|
66
|
+
"""统一采集入口。返回 ``(rows, exec_sql)``。"""
|
|
67
|
+
data_source = task.data_source
|
|
68
|
+
if data_source.source_type == SyncDataSource.SOURCE_TYPE_CUSTOM:
|
|
69
|
+
return collect_custom(data_source, task, start_time=start_time, end_time=end_time, **extra_kwargs)
|
|
70
|
+
if not task.sql:
|
|
71
|
+
raise ValueError("数据库类型数据源必须配置 SQL")
|
|
72
|
+
return collect_from_db(data_source, task.sql, start_time, end_time)
|
lysync/export/engine.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""导出引擎(核心编排)。
|
|
2
|
+
|
|
3
|
+
替代旧版“先导出 Excel 再二次处理”的流程:直接把源数据经 ``process_list`` 处理链
|
|
4
|
+
加工后写入目标库(upsert)。
|
|
5
|
+
|
|
6
|
+
与旧版的关键差异:
|
|
7
|
+
* **按天同步**:每次执行只同步 ``SyncExportTask.data_start_date`` 这一天的数据。
|
|
8
|
+
``SyncExportTask.sql`` 是 jinja2 模板,渲染时注入 ``start_time``(当天 00:00:00)
|
|
9
|
+
与 ``end_time``(次日 00:00:00),构成 **前闭后开** 区间 ``[start_time, end_time)``,
|
|
10
|
+
区间过滤由模板内 SQL 自己实现(不再用 sqlglot 追加分页参数)。
|
|
11
|
+
* 执行完成后 ``data_start_date`` 推进到次日;若仍早于“今天”则 ``has_more=True``,
|
|
12
|
+
由上层 Celery 触发下一天的同步。
|
|
13
|
+
* 数据变更检测按天进行:每次只处理当天的行,并通过 ``SyncDataFingerprint`` 比对指纹。
|
|
14
|
+
|
|
15
|
+
处理顺序:``SyncModelField``(字段级) -> ``SyncModelType``(数据集级) ->
|
|
16
|
+
``SyncExportTask``(数据集级)。
|
|
17
|
+
|
|
18
|
+
对外主要函数:
|
|
19
|
+
* :func:`exec_sync_export_task_item` —— 执行一天的导出任务。
|
|
20
|
+
* :func:`exec_sync_export_check_item` —— 对已有导出记录做按天补录。
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import datetime
|
|
25
|
+
import uuid
|
|
26
|
+
|
|
27
|
+
from celery.exceptions import SoftTimeLimitExceeded
|
|
28
|
+
from django.conf import settings
|
|
29
|
+
from django.utils import timezone
|
|
30
|
+
from loguru import logger
|
|
31
|
+
|
|
32
|
+
from lysync.models import (
|
|
33
|
+
EXPORT_RECORD_STATUS_FAIL,
|
|
34
|
+
EXPORT_RECORD_STATUS_RUNNING,
|
|
35
|
+
EXPORT_RECORD_STATUS_SUCCESS,
|
|
36
|
+
SyncDataSource,
|
|
37
|
+
SyncExportCheckRecord,
|
|
38
|
+
SyncExportRecord,
|
|
39
|
+
SyncExportTask,
|
|
40
|
+
SyncModelField,
|
|
41
|
+
SyncModelType,
|
|
42
|
+
)
|
|
43
|
+
from lysync.export.collector import collect
|
|
44
|
+
from lysync.export.processor import run_dataset_process_list, run_field_process_list
|
|
45
|
+
from lysync.export.utils import fetch_data_from_temp_db, render_sql_template
|
|
46
|
+
from lysync.utils import local_today
|
|
47
|
+
from lysync.export.writer import write_rows_to_target
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _patch_logger(request_id=None):
|
|
51
|
+
try:
|
|
52
|
+
from util.middleware import get_request_id
|
|
53
|
+
|
|
54
|
+
return logger.patch(lambda record: record["extra"].update(**get_request_id()))
|
|
55
|
+
except Exception:
|
|
56
|
+
return logger
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _day_bounds(data_start_date):
|
|
60
|
+
"""将日期转换为当天 00:00:00 与次日 00:00:00 的 datetime(naive,本地时区语义)。"""
|
|
61
|
+
start_time = datetime.datetime(data_start_date.year, data_start_date.month, data_start_date.day)
|
|
62
|
+
end_time = start_time + datetime.timedelta(days=1)
|
|
63
|
+
return start_time, end_time
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _last_unique_hash(processed_rows, unique_pairs, fields):
|
|
67
|
+
"""取当天批次中“时间索引字段最大”的那条行的唯一标识。
|
|
68
|
+
|
|
69
|
+
优先使用配置了 ``is_datetime_index=True`` 的字段作为时间维度;若没有该字段或
|
|
70
|
+
批次为空,则回退到列表最后一条。
|
|
71
|
+
"""
|
|
72
|
+
if not unique_pairs:
|
|
73
|
+
return None
|
|
74
|
+
dt_field = next((f.field_name for f in fields if getattr(f, "is_datetime_index", False)), None)
|
|
75
|
+
if not dt_field:
|
|
76
|
+
return unique_pairs[-1][0]
|
|
77
|
+
|
|
78
|
+
def _sortable(i):
|
|
79
|
+
v = processed_rows[i].get(dt_field)
|
|
80
|
+
if v is None:
|
|
81
|
+
return (1, 0)
|
|
82
|
+
if hasattr(v, "timestamp"):
|
|
83
|
+
return (0, v.timestamp())
|
|
84
|
+
try:
|
|
85
|
+
return (0, float(v))
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
return (0, 0)
|
|
88
|
+
|
|
89
|
+
best = max(range(len(processed_rows)), key=_sortable)
|
|
90
|
+
return unique_pairs[best][0]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _process_rows(task: SyncExportTask, raw_rows):
|
|
94
|
+
"""按处理顺序执行字段级 / 数据集级 process_list。
|
|
95
|
+
|
|
96
|
+
字段采集开关 ``is_import_column``:
|
|
97
|
+
* ``True``:从采集结果中保留其值,参与字段级处理并写入目标库。
|
|
98
|
+
* ``False``:**采集函数不应输出该字段的值**——其值由 ``SyncModelType`` 的
|
|
99
|
+
``process_list``(数据集级)等逻辑负责计算 / 产出。因此在字段级处理前
|
|
100
|
+
将其从采集结果中剔除,字段级处理也跳过它。
|
|
101
|
+
"""
|
|
102
|
+
model_type = task.model_type
|
|
103
|
+
fields = list(SyncModelField.objects.filter(model_type=model_type).order_by("id"))
|
|
104
|
+
import_fields = [f for f in fields if f.is_import_column]
|
|
105
|
+
non_import_names = {f.field_name for f in fields if not f.is_import_column}
|
|
106
|
+
|
|
107
|
+
# 剔除非导入列的值(收集函数不应输出该字段的值)
|
|
108
|
+
if non_import_names:
|
|
109
|
+
raw_rows = [{k: v for k, v in row.items() if k not in non_import_names} for row in raw_rows]
|
|
110
|
+
|
|
111
|
+
# 1) 字段级:仅对导入列逐字段处理值
|
|
112
|
+
processed = []
|
|
113
|
+
for raw in raw_rows:
|
|
114
|
+
row = dict(raw) # 避免修改原始数据
|
|
115
|
+
for field in import_fields:
|
|
116
|
+
row[field.field_name] = run_field_process_list(
|
|
117
|
+
field.process_list, row.get(field.field_name), field.field_name
|
|
118
|
+
)
|
|
119
|
+
processed.append(row)
|
|
120
|
+
|
|
121
|
+
# 2) 数据集级:SyncModelType(此处可产出 is_import_column=False 的派生字段)
|
|
122
|
+
processed = run_dataset_process_list(model_type.process_list, processed)
|
|
123
|
+
# 3) 数据集级:SyncExportTask
|
|
124
|
+
processed = run_dataset_process_list(task.process_list, processed)
|
|
125
|
+
return processed
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def exec_sync_export_task_item(task: SyncExportTask, request_id=None):
|
|
129
|
+
"""执行一天的导出任务(采集 -> 处理 -> 写入目标库)。
|
|
130
|
+
|
|
131
|
+
以 ``task.data_start_date`` 为当天,渲染 ``[start_time, end_time)`` 区间 SQL,
|
|
132
|
+
同步完成后把 ``data_start_date`` 推进到次日。
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
``(success: bool, message: str, has_more: bool)``,``has_more`` 表示是否还有
|
|
136
|
+
后续日期需要继续同步(``data_start_date`` 仍早于今天)。
|
|
137
|
+
"""
|
|
138
|
+
request_id = request_id or uuid.uuid4().hex
|
|
139
|
+
log = _patch_logger(request_id)
|
|
140
|
+
|
|
141
|
+
task.last_export_time = timezone.now()
|
|
142
|
+
data_source = task.data_source
|
|
143
|
+
is_db = data_source.source_type != SyncDataSource.SOURCE_TYPE_CUSTOM
|
|
144
|
+
|
|
145
|
+
if not task.data_start_date:
|
|
146
|
+
msg = "请先配置 data_start_date(数据开始日期)后再执行同步"
|
|
147
|
+
log.warning(f"[{request_id}]{msg}: id={task.id}")
|
|
148
|
+
record = SyncExportRecord(
|
|
149
|
+
org_id=task.org_id, export_task=task, export_status=EXPORT_RECORD_STATUS_FAIL, message=msg
|
|
150
|
+
)
|
|
151
|
+
record.save()
|
|
152
|
+
return False, msg, False
|
|
153
|
+
|
|
154
|
+
start_time, end_time = _day_bounds(task.data_start_date)
|
|
155
|
+
|
|
156
|
+
record = SyncExportRecord()
|
|
157
|
+
record.org_id = task.org_id
|
|
158
|
+
record.export_task = task
|
|
159
|
+
record.data_date = task.data_start_date
|
|
160
|
+
record.export_status = EXPORT_RECORD_STATUS_RUNNING
|
|
161
|
+
try:
|
|
162
|
+
raw_rows, exec_sql = collect(task, start_time=start_time, end_time=end_time)
|
|
163
|
+
record.original_sql = task.sql or ""
|
|
164
|
+
record.exec_sql = exec_sql or ""
|
|
165
|
+
record.save()
|
|
166
|
+
task.save(update_fields=["last_export_time", "update_time"])
|
|
167
|
+
|
|
168
|
+
if not raw_rows:
|
|
169
|
+
log.info(f"[{request_id}]无新数据: id={task.id}, name={task.name}, date={task.data_start_date}")
|
|
170
|
+
record.export_status = EXPORT_RECORD_STATUS_SUCCESS
|
|
171
|
+
record.message = "无新数据"
|
|
172
|
+
record.save()
|
|
173
|
+
# 当天无数据也要推进到次日,避免死循环
|
|
174
|
+
task.data_start_date = end_time.date()
|
|
175
|
+
task.save(update_fields=["data_start_date", "last_export_time", "update_time"])
|
|
176
|
+
has_more = task.data_start_date < local_today()
|
|
177
|
+
return True, "无新数据", has_more
|
|
178
|
+
|
|
179
|
+
fields = list(SyncModelField.objects.filter(model_type=task.model_type).order_by("id"))
|
|
180
|
+
processed = _process_rows(task, raw_rows)
|
|
181
|
+
created, updated, unique_pairs = write_rows_to_target(task.model_type, processed, task, task.org_id)
|
|
182
|
+
|
|
183
|
+
record.export_status = EXPORT_RECORD_STATUS_SUCCESS
|
|
184
|
+
record.message = f"导入成功,新增 {created},更新 {updated}"
|
|
185
|
+
record.export_count = len(processed)
|
|
186
|
+
record.export_total_count = len(processed)
|
|
187
|
+
record.export_unique_hash_list = [h for h, _ in unique_pairs]
|
|
188
|
+
record.save()
|
|
189
|
+
|
|
190
|
+
# 推进日期与位点
|
|
191
|
+
last_hash = _last_unique_hash(processed, unique_pairs, fields)
|
|
192
|
+
task.last_export_unique_hash = last_hash
|
|
193
|
+
task.data_start_date = end_time.date()
|
|
194
|
+
task.last_export_time = timezone.now()
|
|
195
|
+
task.save(
|
|
196
|
+
update_fields=["last_export_unique_hash", "data_start_date", "last_export_time", "update_time"]
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
has_more = task.data_start_date < local_today()
|
|
200
|
+
log.info(
|
|
201
|
+
f"[{request_id}]导出成功: id={task.id}, name={task.name}, date={record.data_date}, "
|
|
202
|
+
f"新增{created}, 更新{updated}"
|
|
203
|
+
)
|
|
204
|
+
return True, record.message, has_more
|
|
205
|
+
except SoftTimeLimitExceeded:
|
|
206
|
+
log.exception(f"[{request_id}]task_id={task.id}. 任务超时")
|
|
207
|
+
record.export_status = EXPORT_RECORD_STATUS_FAIL
|
|
208
|
+
record.message = "任务超时"
|
|
209
|
+
record.save()
|
|
210
|
+
return False, "任务超时", False
|
|
211
|
+
except Exception as e:
|
|
212
|
+
import traceback
|
|
213
|
+
|
|
214
|
+
bt = str(traceback.format_exc())
|
|
215
|
+
msg = "执行查询超时" if "SoftTimeLimitExceeded" in bt else str(e)
|
|
216
|
+
log.exception(f"[{request_id}]task_id={task.id}. 导出数据失败: {e}")
|
|
217
|
+
try:
|
|
218
|
+
record.export_status = EXPORT_RECORD_STATUS_FAIL
|
|
219
|
+
record.message = msg
|
|
220
|
+
record.save()
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
return False, msg, False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def exec_sync_export_check_item(export_record: SyncExportRecord, request_id=None):
|
|
227
|
+
"""对已有导出记录做按天补录(重新同步该记录所属日期的数据并 upsert 变更行)。"""
|
|
228
|
+
request_id = request_id or uuid.uuid4().hex
|
|
229
|
+
log = _patch_logger(request_id)
|
|
230
|
+
|
|
231
|
+
export_task = export_record.export_task
|
|
232
|
+
data_source = export_task.data_source
|
|
233
|
+
is_db = data_source.source_type != SyncDataSource.SOURCE_TYPE_CUSTOM
|
|
234
|
+
|
|
235
|
+
check_record = SyncExportCheckRecord()
|
|
236
|
+
check_record.org_id = export_record.org_id
|
|
237
|
+
check_record.export_task = export_task
|
|
238
|
+
check_record.export_record = export_record
|
|
239
|
+
check_record.export_status = EXPORT_RECORD_STATUS_RUNNING
|
|
240
|
+
try:
|
|
241
|
+
if not export_record.data_date:
|
|
242
|
+
raise ValueError("导出记录缺少 data_date,无法按天补录")
|
|
243
|
+
start_time, end_time = _day_bounds(export_record.data_date)
|
|
244
|
+
if is_db:
|
|
245
|
+
exec_sql = render_sql_template(export_task.sql, start_time, end_time)
|
|
246
|
+
raw_rows = (
|
|
247
|
+
fetch_data_from_temp_db(
|
|
248
|
+
data_source.db_type,
|
|
249
|
+
data_source.db_host,
|
|
250
|
+
data_source.db_port,
|
|
251
|
+
data_source.db_user,
|
|
252
|
+
data_source.db_password,
|
|
253
|
+
data_source.db_name,
|
|
254
|
+
data_source.db_options,
|
|
255
|
+
exec_sql,
|
|
256
|
+
)
|
|
257
|
+
or []
|
|
258
|
+
)
|
|
259
|
+
check_record.original_sql = export_task.sql
|
|
260
|
+
check_record.exec_sql = exec_sql
|
|
261
|
+
else:
|
|
262
|
+
raw_rows, _ = collect(export_task, start_time=start_time, end_time=end_time)
|
|
263
|
+
check_record.original_sql = export_task.sql or ""
|
|
264
|
+
check_record.exec_sql = ""
|
|
265
|
+
|
|
266
|
+
check_record.save()
|
|
267
|
+
export_record.save(update_fields=["last_check_time", "update_time"])
|
|
268
|
+
|
|
269
|
+
if not raw_rows:
|
|
270
|
+
check_record.delete()
|
|
271
|
+
return True, None, False
|
|
272
|
+
|
|
273
|
+
processed = _process_rows(export_task, raw_rows)
|
|
274
|
+
created, updated, unique_pairs = write_rows_to_target(
|
|
275
|
+
export_task.model_type, processed, export_task, export_record.org_id
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
check_record.export_status = EXPORT_RECORD_STATUS_SUCCESS
|
|
279
|
+
check_record.message = "补录导入成功"
|
|
280
|
+
check_record.export_count = created + updated
|
|
281
|
+
check_record.export_unique_hash_list = [h for h, _ in unique_pairs]
|
|
282
|
+
check_record.save()
|
|
283
|
+
|
|
284
|
+
export_record.last_check_record_id = check_record.id
|
|
285
|
+
export_record.success_check_record_count = SyncExportCheckRecord.objects.filter(
|
|
286
|
+
export_record_id=export_record.id, export_status=EXPORT_RECORD_STATUS_SUCCESS
|
|
287
|
+
).count()
|
|
288
|
+
export_record.save(
|
|
289
|
+
update_fields=["success_check_record_count", "last_check_record_id", "update_time"]
|
|
290
|
+
)
|
|
291
|
+
log.info(
|
|
292
|
+
f"[{request_id}]补录成功: id={export_record.id}, name={export_task.name}, 新增{created}, 更新{updated}"
|
|
293
|
+
)
|
|
294
|
+
return True, check_record.message, False
|
|
295
|
+
except SoftTimeLimitExceeded:
|
|
296
|
+
log.exception(f"[{request_id}]task_record_id={export_record.id}. 任务超时")
|
|
297
|
+
check_record.export_status = EXPORT_RECORD_STATUS_FAIL
|
|
298
|
+
check_record.message = "任务超时"
|
|
299
|
+
check_record.save()
|
|
300
|
+
return False, "任务超时", False
|
|
301
|
+
except Exception as e:
|
|
302
|
+
log.exception(f"[{request_id}]task_record_id={export_record.id}. 补录失败: {e}")
|
|
303
|
+
check_record.export_status = EXPORT_RECORD_STATUS_FAIL
|
|
304
|
+
check_record.message = str(e)
|
|
305
|
+
check_record.save()
|
|
306
|
+
return False, str(e), False
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""统一的 Jinja2 环境(单例)。
|
|
2
|
+
|
|
3
|
+
整个项目里所有 SQL 模板渲染共用同一个 :class:`jinja2.Environment`,便于:
|
|
4
|
+
|
|
5
|
+
* **扩展内部函数 / 数据**:通过 :func:`register_global` / :func:`register_filter`
|
|
6
|
+
注册自定义全局函数与过滤器,模板内即可直接调用;渲染时也可通过
|
|
7
|
+
:func:`render_sql_template` 的关键字参数注入任意额外上下文。
|
|
8
|
+
* **复用编译缓存**:``env.from_string(sql)`` 比每次 ``Template(sql).render`` 更高效,
|
|
9
|
+
且共享同一套全局/过滤器配置。
|
|
10
|
+
* **统一配置**:SQL 模板不需要 HTML 自动转义,这里统一关闭,避免把 ``< > &`` 等符号转义。
|
|
11
|
+
|
|
12
|
+
内置可用资源(模板内):
|
|
13
|
+
|
|
14
|
+
* 全局函数:``now()`` / ``utcnow()`` / ``today()`` 返回当前时间/日期;
|
|
15
|
+
``sqlstr(value)`` 返回带单引号转义的 SQL 字面量。
|
|
16
|
+
* 过滤器:``value | date(fmt)`` / ``value | strftime(fmt)`` 格式化日期;
|
|
17
|
+
``value | sqlstr`` 等价于 ``sqlstr(value)``。
|
|
18
|
+
|
|
19
|
+
扩展示例(在 app 启动时或任意可导入处调用)::
|
|
20
|
+
|
|
21
|
+
from lysync.export.jinja_env import register_global
|
|
22
|
+
|
|
23
|
+
def fiscal_year(dt):
|
|
24
|
+
return dt.year if dt.month >= 4 else dt.year - 1
|
|
25
|
+
|
|
26
|
+
register_global("fiscal_year", fiscal_year)
|
|
27
|
+
|
|
28
|
+
之后模板即可使用 ``{{ start_time | fiscal_year }}``。
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import datetime
|
|
33
|
+
|
|
34
|
+
from jinja2 import Environment
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# SQL 模板不需要 HTML 自动转义;关闭以避免把 < > & 等符号转义成实体
|
|
38
|
+
_AUTOESCAPE = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sqlstr(value):
|
|
42
|
+
"""返回带单引号转义的 SQL 字符串字面量(最小实现,仅转义单引号)。"""
|
|
43
|
+
if value is None:
|
|
44
|
+
return "NULL"
|
|
45
|
+
return "'" + str(value).replace("'", "''") + "''"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# 默认注入模板的内置全局函数
|
|
49
|
+
_DEFAULT_GLOBALS = {
|
|
50
|
+
"now": datetime.datetime.now,
|
|
51
|
+
"utcnow": datetime.datetime.utcnow,
|
|
52
|
+
"today": datetime.date.today,
|
|
53
|
+
"sqlstr": _sqlstr,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# 默认注入模板的内置过滤器
|
|
57
|
+
_DEFAULT_FILTERS = {
|
|
58
|
+
"date": lambda value, fmt="%Y-%m-%d": value.strftime(fmt) if isinstance(value, (datetime.datetime, datetime.date)) else (value or ""),
|
|
59
|
+
"strftime": lambda value, fmt="%Y-%m-%d": value.strftime(fmt) if isinstance(value, (datetime.datetime, datetime.date)) else (value or ""),
|
|
60
|
+
"sqlstr": _sqlstr,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_env = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_jinja_env() -> Environment:
|
|
68
|
+
"""返回全局共享的 Jinja2 Environment(懒加载单例)。
|
|
69
|
+
|
|
70
|
+
首次调用时创建并注册内置全局函数 / 过滤器。后续可通过
|
|
71
|
+
:func:`register_global` / :func:`register_filter` 继续扩展。
|
|
72
|
+
"""
|
|
73
|
+
global _env
|
|
74
|
+
if _env is None:
|
|
75
|
+
_env = Environment(autoescape=_AUTOESCAPE, trim_blocks=False, lstrip_blocks=False)
|
|
76
|
+
_env.globals.update(_DEFAULT_GLOBALS)
|
|
77
|
+
_env.filters.update(_DEFAULT_FILTERS)
|
|
78
|
+
return _env
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def register_global(name: str, func):
|
|
82
|
+
"""注册一个模板可用的全局函数(覆盖同名项),之后可在模板中直接调用。"""
|
|
83
|
+
get_jinja_env().globals[name] = func
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register_filter(name: str, func):
|
|
87
|
+
"""注册一个模板可用的过滤器(覆盖同名项),之后可用 ``value | name`` 调用。"""
|
|
88
|
+
get_jinja_env().filters[name] = func
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_sql_template(sql, start_time=None, end_time=None, **context):
|
|
92
|
+
"""使用统一 Jinja2 环境渲染 SQL 模板。
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
sql: jinja2 模板字符串(可含 ``{{ start_time }}`` / ``{{ end_time }}`` 等)。
|
|
96
|
+
start_time / end_time: 默认注入的上下文变量(多为 datetime,构成前闭后开区间)。
|
|
97
|
+
**context: 任意额外的模板上下文,覆盖/补充默认注入的变量。
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
渲染后的 SQL 字符串。不存在 ``{{ }}`` 的普通 SQL 由原样返回,兼容旧写法。
|
|
101
|
+
|
|
102
|
+
模板内还可使用内置全局函数(``now()`` / ``today()`` / ``sqlstr()`` 等)与
|
|
103
|
+
过滤器(``|date`` / ``|strftime`` / ``|sqlstr``),并可通过
|
|
104
|
+
:func:`register_global` / :func:`register_filter` 扩展。
|
|
105
|
+
"""
|
|
106
|
+
if not sql:
|
|
107
|
+
return sql
|
|
108
|
+
ctx = {"start_time": start_time, "end_time": end_time}
|
|
109
|
+
ctx.update(context)
|
|
110
|
+
return get_jinja_env().from_string(sql).render(**ctx)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""数据处理链(``process_list``)执行层。
|
|
2
|
+
|
|
3
|
+
处理顺序约定为:
|
|
4
|
+
``SyncModelField``(字段级) -> ``SyncModelType``(数据集级) -> ``SyncExportTask``(数据集级)。
|
|
5
|
+
|
|
6
|
+
* 字段级:对**单个字段的值**依次调用 ``process_list`` 中的每个函数,
|
|
7
|
+
签名 ``func(value, **kwargs) -> value``。
|
|
8
|
+
* 数据集级:对**整批数据**(``list[dict]``)依次调用,签名 ``func(rows, **kwargs) -> rows``。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from lysync.funcs.registry import get_process_func
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_field_process_list(process_list, value, field_name=None):
|
|
16
|
+
"""对单个字段值执行 ``process_list``。"""
|
|
17
|
+
if not process_list:
|
|
18
|
+
return value
|
|
19
|
+
for item in process_list:
|
|
20
|
+
name = item.get("func")
|
|
21
|
+
if not name:
|
|
22
|
+
continue
|
|
23
|
+
kwargs = item.get("kwargs") or {}
|
|
24
|
+
func, _scope = get_process_func(name)
|
|
25
|
+
value = func(value, **kwargs)
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_dataset_process_list(process_list, rows):
|
|
30
|
+
"""对整批数据执行 ``process_list``。"""
|
|
31
|
+
if not process_list:
|
|
32
|
+
return rows
|
|
33
|
+
for item in process_list:
|
|
34
|
+
name = item.get("func")
|
|
35
|
+
if not name:
|
|
36
|
+
continue
|
|
37
|
+
kwargs = item.get("kwargs") or {}
|
|
38
|
+
func, _scope = get_process_func(name)
|
|
39
|
+
rows = func(rows, **kwargs)
|
|
40
|
+
return rows
|