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/export/utils.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""SQL / 数据源工具函数(从 costexcel 移植并适配新模型)。
|
|
2
|
+
|
|
3
|
+
主要提供:
|
|
4
|
+
* 使用 SQLAlchemy 从外部数据库拉取数据(``fetch_data_from_temp_db``)。
|
|
5
|
+
* 使用 sqlglot 在最外层 SELECT 上增删 WHERE / ORDER BY / LIMIT / OFFSET。
|
|
6
|
+
* 计算 id 字段、外层 count SQL、数据指纹、SQL 性能评级等。
|
|
7
|
+
|
|
8
|
+
参数命名由旧的 ``type`` 改为 ``db_type``,避免与内置关键字冲突。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import socket
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
from sqlglot import exp
|
|
19
|
+
from sqlglot import parse_one
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def safe_quote(string):
|
|
23
|
+
if not string:
|
|
24
|
+
return string
|
|
25
|
+
return quote(str(string))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# 统一使用项目共享的 Jinja2 环境渲染 SQL 模板(内置 now()/today()/sqlstr() 等,
|
|
29
|
+
# 可通过 lysync.export.jinja_env.register_global / register_filter 扩展)。
|
|
30
|
+
from lysync.export.jinja_env import get_jinja_env, render_sql_template # noqa: E402,F401
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fetch_data_from_temp_db(db_type, host, port, user, password, database, options, query, params=None, skip_rows=0):
|
|
35
|
+
"""使用 SQLAlchemy 从外部临时数据库获取数据,返回 ``list[dict]``。"""
|
|
36
|
+
from sqlalchemy import create_engine, text
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
port = int(port)
|
|
40
|
+
except Exception:
|
|
41
|
+
raise ValueError(f"端口应为整数:{host}")
|
|
42
|
+
try:
|
|
43
|
+
socket.gethostbyname(host)
|
|
44
|
+
except socket.gaierror:
|
|
45
|
+
raise ValueError(f"DNS解析失败,请确认配置了正确的数据库主机地址:{host}")
|
|
46
|
+
|
|
47
|
+
if db_type == "mysql":
|
|
48
|
+
dialect = "mysql+pymysql"
|
|
49
|
+
elif db_type == "postgresql":
|
|
50
|
+
dialect = "postgresql+psycopg2"
|
|
51
|
+
elif db_type == "tsql":
|
|
52
|
+
dialect = "mssql+pymssql"
|
|
53
|
+
else:
|
|
54
|
+
raise ValueError(f"不支持的数据库类型: {db_type}")
|
|
55
|
+
|
|
56
|
+
db_url = f"{dialect}://{safe_quote(user)}:{safe_quote(password)}@{safe_quote(host)}:{port}/{safe_quote(database)}"
|
|
57
|
+
connect_args = options if isinstance(options, dict) else {}
|
|
58
|
+
|
|
59
|
+
engine = create_engine(db_url, connect_args=connect_args)
|
|
60
|
+
|
|
61
|
+
with engine.connect() as connection:
|
|
62
|
+
import re
|
|
63
|
+
|
|
64
|
+
num_params = query.count("@p")
|
|
65
|
+
param_names = [f"p{i}" for i in range(num_params)]
|
|
66
|
+
query_named = re.sub(r"@p", lambda m, c=iter(param_names): f":{next(c)}", query)
|
|
67
|
+
|
|
68
|
+
params_dict = {}
|
|
69
|
+
if params:
|
|
70
|
+
if isinstance(params, (list, tuple)):
|
|
71
|
+
if len(params) != num_params:
|
|
72
|
+
raise ValueError(f"查询有 {num_params} 个占位符,但传入了 {len(params)} 个参数")
|
|
73
|
+
params_dict = dict(zip(param_names, params))
|
|
74
|
+
else:
|
|
75
|
+
raise TypeError(f"不支持的 params 类型: {type(params)}")
|
|
76
|
+
|
|
77
|
+
stmt = text(query_named)
|
|
78
|
+
result = connection.execute(stmt, params_dict)
|
|
79
|
+
|
|
80
|
+
columns = list(result.keys())
|
|
81
|
+
return [dict(zip(columns, row)) for index, row in enumerate(result) if index >= skip_rows]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def add_order_by_with_sqlglot(db_type, sql, order_fields):
|
|
85
|
+
"""仅替换最外层 SELECT 的 ORDER BY。
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
order_fields: ``[(field, desc), ...]``,如 ``[("c2", True), ("c0", False)]``。
|
|
89
|
+
"""
|
|
90
|
+
order_exprs = []
|
|
91
|
+
for field, desc in order_fields:
|
|
92
|
+
order_expr = exp.Ordered(this=parse_one(field, read=db_type), desc=desc)
|
|
93
|
+
order_exprs.append(order_expr)
|
|
94
|
+
|
|
95
|
+
new_order = exp.Order(expressions=order_exprs)
|
|
96
|
+
|
|
97
|
+
parsed_sql = parse_one(sql, dialect=db_type)
|
|
98
|
+
select_stmt = parsed_sql if isinstance(parsed_sql, exp.Select) else parsed_sql.find(exp.Select)
|
|
99
|
+
if select_stmt:
|
|
100
|
+
select_stmt.set("order", new_order)
|
|
101
|
+
return parsed_sql.sql(dialect=db_type)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def add_where_condition_with_sqlglot(db_type, sql, new_condition_str):
|
|
105
|
+
"""仅处理最外层 SELECT 的 WHERE 子句(已有则 AND 叠加)。"""
|
|
106
|
+
parsed_sql = parse_one(sql, dialect=db_type)
|
|
107
|
+
new_condition_parsed = parse_one(new_condition_str, dialect=db_type)
|
|
108
|
+
|
|
109
|
+
select_stmt = parsed_sql if isinstance(parsed_sql, exp.Select) else parsed_sql.find(exp.Select)
|
|
110
|
+
if select_stmt:
|
|
111
|
+
existing_where = select_stmt.args.get("where")
|
|
112
|
+
if existing_where:
|
|
113
|
+
combined = exp.And(this=existing_where.this, expression=new_condition_parsed)
|
|
114
|
+
select_stmt.set("where", exp.Where(this=combined))
|
|
115
|
+
else:
|
|
116
|
+
select_stmt.set("where", exp.Where(this=new_condition_parsed))
|
|
117
|
+
return parsed_sql.sql(dialect=db_type)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def add_limit_with_sqlglot(db_type, sql, limit_value):
|
|
121
|
+
"""仅替换或添加最外层 SELECT 的 LIMIT 子句。"""
|
|
122
|
+
parsed_sql = parse_one(sql, dialect=db_type)
|
|
123
|
+
new_limit = exp.Limit(expression=exp.Literal.number(limit_value))
|
|
124
|
+
|
|
125
|
+
select_stmt = parsed_sql if isinstance(parsed_sql, exp.Select) else parsed_sql.find(exp.Select)
|
|
126
|
+
if select_stmt:
|
|
127
|
+
select_stmt.set("limit", new_limit)
|
|
128
|
+
return parsed_sql.sql(dialect=db_type)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def add_offset_with_sqlglot(db_type, sql, offset_value):
|
|
132
|
+
"""仅替换或添加最外层 SELECT 的 OFFSET 子句。"""
|
|
133
|
+
if offset_value <= 0:
|
|
134
|
+
return sql
|
|
135
|
+
parsed_sql = parse_one(sql, dialect=db_type)
|
|
136
|
+
new_offset = exp.Offset(expression=exp.Literal.number(offset_value))
|
|
137
|
+
|
|
138
|
+
select_stmt = parsed_sql if isinstance(parsed_sql, exp.Select) else parsed_sql.find(exp.Select)
|
|
139
|
+
if select_stmt:
|
|
140
|
+
select_stmt.set("offset", new_offset)
|
|
141
|
+
return parsed_sql.sql(dialect=db_type)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_id_field(sql, db_type):
|
|
145
|
+
"""从 SELECT 语句中解析 ``id`` 字段对应的真实列名(默认 ``id``)。"""
|
|
146
|
+
parsed = parse_one(sql, dialect=db_type)
|
|
147
|
+
if not isinstance(parsed, exp.Select):
|
|
148
|
+
raise ValueError("不是SELECT查询语句")
|
|
149
|
+
id_field = "id"
|
|
150
|
+
for expr in parsed.expressions:
|
|
151
|
+
field_name = expr.this.sql() if getattr(expr, "this", None) else str(expr)
|
|
152
|
+
alias_name = expr.alias if getattr(expr, "alias", None) else field_name
|
|
153
|
+
if alias_name == "id":
|
|
154
|
+
id_field = field_name
|
|
155
|
+
return id_field
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_outer_count_sql(db_type: str, sql: str, alias_name: str = "total") -> str:
|
|
159
|
+
"""生成 ``SELECT count(*) AS total FROM (...)`` 形式的外层计数 SQL。"""
|
|
160
|
+
expr = parse_one(sql, dialect=db_type).select(f"count(*) as {alias_name}", append=False, dialect=db_type)
|
|
161
|
+
return expr.sql(dialect=db_type)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_outer_sql(db_type: str, sql: str) -> str:
|
|
165
|
+
"""把原 SQL 包成子查询并 ``SELECT * FROM (<subquery>) AS t``。"""
|
|
166
|
+
original_expr = parse_one(sql, dialect=db_type)
|
|
167
|
+
subquery_expr = original_expr.copy()
|
|
168
|
+
subquery = exp.alias_(exp.Subquery(this=subquery_expr), "t")
|
|
169
|
+
outer_select = exp.select("*").from_(subquery)
|
|
170
|
+
return outer_select.sql(dialect=db_type)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def format_like_value(value):
|
|
174
|
+
value = value.replace("_", r"\_").replace("%", r"\%").replace("[", r"\[").replace("]", r"\]")
|
|
175
|
+
return f"%{value}%"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def format_datasource_error_message(error_msg, db_name, db_host, db_port):
|
|
179
|
+
error_msg = error_msg.replace("(pymysql.err.OperationalError) ", "")
|
|
180
|
+
if "Access denied for user" in error_msg:
|
|
181
|
+
return "用户名或密码错误"
|
|
182
|
+
elif "Unknown database" in error_msg:
|
|
183
|
+
return f"数据库{db_name}不存在"
|
|
184
|
+
elif "Can't connect to MySQL server on" in error_msg:
|
|
185
|
+
return f"无法连接到MySQL服务器: {db_host}:{db_port}"
|
|
186
|
+
return error_msg
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def calculate_row_fingerprint(values) -> str:
|
|
190
|
+
"""根据一组字段值计算行指纹(md5)。"""
|
|
191
|
+
data_to_hash = []
|
|
192
|
+
for v in values:
|
|
193
|
+
if v is None or (isinstance(v, str) and v.strip() == ""):
|
|
194
|
+
v = ""
|
|
195
|
+
data_to_hash.append(str(v))
|
|
196
|
+
hash_str = "|".join(data_to_hash)
|
|
197
|
+
return hashlib.md5(hash_str.encode("utf-8")).hexdigest()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ============================ SQL 性能评级(移植) ============================
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
grade_order = ["A1", "A2", "B1", "B2", "C1", "C2", "D1", "D2", "E1"]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def sql_performance_grade(explain_result):
|
|
207
|
+
"""根据 EXPLAIN 结果评估 SQL 性能等级。"""
|
|
208
|
+
type_ = explain_result.get("type") or ""
|
|
209
|
+
rows = explain_result.get("rows") or 0
|
|
210
|
+
extra = explain_result.get("Extra") or ""
|
|
211
|
+
|
|
212
|
+
using_temporary = "Using temporary" in extra
|
|
213
|
+
using_filesort = "Using filesort" in extra
|
|
214
|
+
|
|
215
|
+
if type_ in ["const", "eq_ref"] and not using_filesort and not using_temporary:
|
|
216
|
+
return "A1"
|
|
217
|
+
if type_ in ["ref", "range"] and not using_filesort and not using_temporary:
|
|
218
|
+
return "A2"
|
|
219
|
+
if type_ in ["ref", "range"] and using_filesort and not using_temporary:
|
|
220
|
+
return "B1"
|
|
221
|
+
if type_ in ["ref", "range"] and not using_filesort and using_temporary:
|
|
222
|
+
return "B2"
|
|
223
|
+
if type_ in ["index", "range"] and using_filesort and using_temporary:
|
|
224
|
+
return "C1"
|
|
225
|
+
if type_ == "ALL" and not using_filesort and not using_temporary:
|
|
226
|
+
return "C2"
|
|
227
|
+
if type_ == "ALL" and (using_filesort or using_temporary):
|
|
228
|
+
if rows > 100000:
|
|
229
|
+
return "E1"
|
|
230
|
+
return "D1"
|
|
231
|
+
if type_ == "ALL" and using_filesort and using_temporary:
|
|
232
|
+
return "D2"
|
|
233
|
+
return "C1"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_sql_context_for_ai(sql: str, db_type, host, port, user, password, database_name, options) -> str:
|
|
237
|
+
"""为 AI 收集 SQL 上下文信息(原始 SQL、EXPLAIN 结果、表/视图创建语句)。"""
|
|
238
|
+
from django.conf import settings
|
|
239
|
+
import sqlglot
|
|
240
|
+
from sqlglot import exp
|
|
241
|
+
|
|
242
|
+
context = []
|
|
243
|
+
|
|
244
|
+
context.append("--- 原始SQL ---")
|
|
245
|
+
context.append(sql)
|
|
246
|
+
context.append("\n")
|
|
247
|
+
|
|
248
|
+
db_settings = settings.DATABASES["default"]
|
|
249
|
+
db_type_local = db_settings["ENGINE"].split(".")[-1]
|
|
250
|
+
if "postgresql" in db_type_local:
|
|
251
|
+
db_type_local = "postgresql"
|
|
252
|
+
elif "mysql" in db_type_local:
|
|
253
|
+
db_type_local = "mysql"
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
# SQL 现在是 jinja2 模板,先渲染成可解析的 SQL 再做 EXPLAIN
|
|
257
|
+
rendered_sql = render_sql_template(
|
|
258
|
+
sql, start_time=datetime.datetime(2020, 1, 1), end_time=datetime.datetime(2020, 1, 2)
|
|
259
|
+
)
|
|
260
|
+
parsed = parse_one(rendered_sql, dialect=db_type)
|
|
261
|
+
if not isinstance(parsed, exp.Select):
|
|
262
|
+
raise Exception("SQL不是SELECT语句")
|
|
263
|
+
if settings.EXPORT_DATA_LIMIT:
|
|
264
|
+
rendered_sql = add_limit_with_sqlglot(db_type, rendered_sql, settings.EXPORT_DATA_LIMIT)
|
|
265
|
+
try:
|
|
266
|
+
explain_sql = f"EXPLAIN FORMAT=TRADITIONAL {rendered_sql}"
|
|
267
|
+
explain_result = fetch_data_from_temp_db(
|
|
268
|
+
db_type, host, port, user, password, database_name, options, explain_sql
|
|
269
|
+
)
|
|
270
|
+
except Exception:
|
|
271
|
+
explain_sql = f"EXPLAIN {rendered_sql}"
|
|
272
|
+
explain_result = fetch_data_from_temp_db(
|
|
273
|
+
db_type, host, port, user, password, database_name, options, explain_sql
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
context.append(
|
|
277
|
+
"--- EXPLAIN结果(此结果是在原始SQL渲染后增加了LIMIT,优化SQL时仅针对原始SQL优化,不要增加LIMIT) ---"
|
|
278
|
+
)
|
|
279
|
+
context.append(json.dumps(explain_result, indent=2, ensure_ascii=False))
|
|
280
|
+
context.append("\n")
|
|
281
|
+
except Exception as e:
|
|
282
|
+
context.append("--- EXPLAIN结果 ---")
|
|
283
|
+
context.append(f"获取EXPLAIN失败: {e}")
|
|
284
|
+
context.append("\n")
|
|
285
|
+
|
|
286
|
+
context.append("--- 表和视图创建SQL ---")
|
|
287
|
+
try:
|
|
288
|
+
parsed = sqlglot.parse_one(sql)
|
|
289
|
+
table_names = {t.name.lower() for t in parsed.find_all(exp.Table)}
|
|
290
|
+
|
|
291
|
+
if not table_names:
|
|
292
|
+
context.append("在SQL中未发现任何表或视图。")
|
|
293
|
+
else:
|
|
294
|
+
for name in table_names:
|
|
295
|
+
try:
|
|
296
|
+
check_type_sql = "SELECT table_type as type FROM information_schema.tables WHERE table_name = @p"
|
|
297
|
+
result = fetch_data_from_temp_db(
|
|
298
|
+
db_type, host, port, user, password, database_name, options, check_type_sql, params=[name]
|
|
299
|
+
)
|
|
300
|
+
table_type = result[0]["type"] if result else None
|
|
301
|
+
|
|
302
|
+
if table_type == "VIEW":
|
|
303
|
+
get_view_sql = (
|
|
304
|
+
"SELECT view_definition as definition FROM information_schema.views WHERE table_name = @p"
|
|
305
|
+
)
|
|
306
|
+
view_def_result = fetch_data_from_temp_db(
|
|
307
|
+
db_type, host, port, user, password, database_name, options, get_view_sql, params=[name]
|
|
308
|
+
)
|
|
309
|
+
view_def = view_def_result[0]["definition"] if view_def_result else ""
|
|
310
|
+
context.append(f"-- 视图: {name} --")
|
|
311
|
+
context.append(f"CREATE VIEW {name} AS {view_def};")
|
|
312
|
+
context.append("")
|
|
313
|
+
elif table_type == "BASE TABLE":
|
|
314
|
+
get_create_sql = "SHOW CREATE TABLE {}".format(name)
|
|
315
|
+
result = fetch_data_from_temp_db(
|
|
316
|
+
db_type, host, port, user, password, database_name, options, get_create_sql
|
|
317
|
+
)
|
|
318
|
+
if result:
|
|
319
|
+
context.append(f"-- 表: {name} --")
|
|
320
|
+
context.append(result[0]["Create Table"])
|
|
321
|
+
context.append("")
|
|
322
|
+
else:
|
|
323
|
+
logger.warning(f"未找到或不支持的类型: {name} ({table_type})")
|
|
324
|
+
except Exception as e:
|
|
325
|
+
logger.exception(str(e))
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
context.append(f"解析SQL或获取表结构失败: {e}")
|
|
329
|
+
|
|
330
|
+
return "\n".join(context)
|
lysync/export/writer.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""数据写入层(直接导入目标库,去掉了中间的 Excel 逻辑)。
|
|
2
|
+
|
|
3
|
+
将处理后的行(``list[dict]``,键为 ``SyncModelField.field_name``)写入由
|
|
4
|
+
:class:`~lysync.models.SyncModelType` 的 ``app_label`` / ``model`` 字符串解析出的目标 Django 模型。
|
|
5
|
+
|
|
6
|
+
写入策略:
|
|
7
|
+
* 以配置了 ``is_unique=True`` 的字段组合作为唯一键做 upsert(存在则更新,不存在则新建)。
|
|
8
|
+
* 每条数据的“唯一标识”由其 ``is_unique`` 字段值计算 md5(``compute_unique_hash``),
|
|
9
|
+
作为落库 / 变更检测的稳定键。
|
|
10
|
+
* 计算并落库数据指纹(``SyncDataFingerprint``)用于变更审计,按
|
|
11
|
+
``(export_task, origin_recode_unique_hash)`` 唯一。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
|
|
17
|
+
from django.db.models import Q
|
|
18
|
+
|
|
19
|
+
from lysync.models import SyncDataFingerprint, SyncModelField
|
|
20
|
+
from lysync.export.utils import calculate_row_fingerprint
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_unique_hash(row, unique_field_names):
|
|
24
|
+
"""根据唯一字段值计算稳定 md5 哈希,作为数据行的唯一标识。
|
|
25
|
+
|
|
26
|
+
空值(``None`` / 空字符串)按空串处理,与 :func:`calculate_row_fingerprint` 规则一致。
|
|
27
|
+
"""
|
|
28
|
+
parts = []
|
|
29
|
+
for name in unique_field_names:
|
|
30
|
+
v = row.get(name)
|
|
31
|
+
if v is None or (isinstance(v, str) and v.strip() == ""):
|
|
32
|
+
v = ""
|
|
33
|
+
parts.append(str(v))
|
|
34
|
+
return hashlib.md5("|".join(parts).encode("utf-8")).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_rows_to_target(model_type, processed_rows, task, org_id=0):
|
|
38
|
+
"""将处理后的行写入目标模型,返回 ``(created, updated, unique_pairs)``。
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
model_type: :class:`~lysync.models.SyncModelType` 实例。
|
|
42
|
+
processed_rows: 处理后的 ``list[dict]``。
|
|
43
|
+
task: :class:`~lysync.models.SyncExportTask` 实例(用于落库指纹)。
|
|
44
|
+
org_id: 组织 ID。
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
``unique_pairs`` 为 ``[(unique_hash, fingerprint), ...]``,顺序与
|
|
48
|
+
``processed_rows`` 一致,便于调用方取“最后一条”唯一标识。
|
|
49
|
+
"""
|
|
50
|
+
target_model = model_type.get_target_model()
|
|
51
|
+
fields = list(SyncModelField.objects.filter(model_type=model_type).order_by("id"))
|
|
52
|
+
if not fields:
|
|
53
|
+
return 0, 0, []
|
|
54
|
+
|
|
55
|
+
unique_field_names = [f.field_name for f in fields if f.is_unique]
|
|
56
|
+
if not unique_field_names:
|
|
57
|
+
# 退化为使用第一个字段作为唯一键
|
|
58
|
+
unique_field_names = [fields[0].field_name]
|
|
59
|
+
|
|
60
|
+
# 规整每行:仅保留已知字段
|
|
61
|
+
cleaned_rows = []
|
|
62
|
+
for raw in processed_rows:
|
|
63
|
+
cleaned = {f.field_name: raw.get(f.field_name) for f in fields}
|
|
64
|
+
cleaned_rows.append((raw, cleaned))
|
|
65
|
+
|
|
66
|
+
# 查询已存在记录(按唯一键组合)
|
|
67
|
+
existing_map = {}
|
|
68
|
+
if unique_field_names:
|
|
69
|
+
q_objs = []
|
|
70
|
+
for _raw, cleaned in cleaned_rows:
|
|
71
|
+
q_objs.append(Q(**{name: cleaned[name] for name in unique_field_names}))
|
|
72
|
+
if q_objs:
|
|
73
|
+
combined = q_objs[0]
|
|
74
|
+
for q in q_objs[1:]:
|
|
75
|
+
combined |= q
|
|
76
|
+
for inst in target_model.objects.filter(combined):
|
|
77
|
+
key = tuple(getattr(inst, name) for name in unique_field_names)
|
|
78
|
+
existing_map[key] = inst
|
|
79
|
+
|
|
80
|
+
to_create = []
|
|
81
|
+
to_update = []
|
|
82
|
+
unique_pairs = []
|
|
83
|
+
|
|
84
|
+
for raw, cleaned in cleaned_rows:
|
|
85
|
+
key = tuple(cleaned[name] for name in unique_field_names)
|
|
86
|
+
inst = existing_map.get(key)
|
|
87
|
+
if inst is None:
|
|
88
|
+
inst = target_model(**cleaned)
|
|
89
|
+
to_create.append(inst)
|
|
90
|
+
else:
|
|
91
|
+
changed = False
|
|
92
|
+
for name, val in cleaned.items():
|
|
93
|
+
if getattr(inst, name) != val:
|
|
94
|
+
setattr(inst, name, val)
|
|
95
|
+
changed = True
|
|
96
|
+
if changed:
|
|
97
|
+
to_update.append(inst)
|
|
98
|
+
|
|
99
|
+
unique_hash = compute_unique_hash(cleaned, unique_field_names)
|
|
100
|
+
fp = calculate_row_fingerprint([cleaned[name] for name in sorted(cleaned.keys())])
|
|
101
|
+
unique_pairs.append((unique_hash, fp))
|
|
102
|
+
|
|
103
|
+
created = updated = 0
|
|
104
|
+
if to_create:
|
|
105
|
+
target_model.objects.bulk_create(to_create, batch_size=1000)
|
|
106
|
+
created = len(to_create)
|
|
107
|
+
if to_update:
|
|
108
|
+
update_fields = [f.field_name for f in fields]
|
|
109
|
+
target_model.objects.bulk_update(to_update, update_fields, batch_size=1000)
|
|
110
|
+
updated = len(to_update)
|
|
111
|
+
|
|
112
|
+
# 保存指纹
|
|
113
|
+
if unique_pairs and task is not None:
|
|
114
|
+
_save_fingerprints(task, org_id, unique_pairs)
|
|
115
|
+
|
|
116
|
+
return created, updated, unique_pairs
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _save_fingerprints(task, org_id, unique_pairs):
|
|
120
|
+
"""批量 upsert 数据指纹(按 export_task + origin_recode_unique_hash 唯一)。"""
|
|
121
|
+
existing = {
|
|
122
|
+
fp.origin_recode_unique_hash: fp
|
|
123
|
+
for fp in SyncDataFingerprint.objects.filter(
|
|
124
|
+
export_task=task, origin_recode_unique_hash__in=[p[0] for p in unique_pairs]
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
to_create = []
|
|
128
|
+
to_update = []
|
|
129
|
+
for unique_hash, fp_value in unique_pairs:
|
|
130
|
+
old = existing.get(unique_hash)
|
|
131
|
+
if old is None:
|
|
132
|
+
to_create.append(
|
|
133
|
+
SyncDataFingerprint(
|
|
134
|
+
org_id=org_id, export_task=task, origin_recode_unique_hash=unique_hash, fingerprint=fp_value
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
elif old.fingerprint != fp_value:
|
|
138
|
+
old.fingerprint = fp_value
|
|
139
|
+
to_update.append(old)
|
|
140
|
+
if to_create:
|
|
141
|
+
SyncDataFingerprint.objects.bulk_create(to_create, batch_size=1000)
|
|
142
|
+
if to_update:
|
|
143
|
+
SyncDataFingerprint.objects.bulk_update(to_update, ["fingerprint"], batch_size=1000)
|
lysync/funcs/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""自定义函数注册包。
|
|
2
|
+
|
|
3
|
+
导入本包即会触发内置 process / collect 函数的注册,确保在 Celery 任务或 Web
|
|
4
|
+
请求执行 ``process_list`` / ``custom_func`` 之前,所有函数已进入注册表。
|
|
5
|
+
|
|
6
|
+
自定义函数可在宿主项目中任意模块用 ``@register_func(...)`` 注册,
|
|
7
|
+
只要该模块被导入过即可(推荐在 app 的 ``ready()`` 或 ``tasks.py`` 中 import)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .registry import ( # noqa: F401
|
|
11
|
+
COLLECT_FUNCS,
|
|
12
|
+
PROCESS_FUNCS,
|
|
13
|
+
FuncType,
|
|
14
|
+
ProcessScope,
|
|
15
|
+
get_collect_func,
|
|
16
|
+
get_process_func,
|
|
17
|
+
list_registered,
|
|
18
|
+
register_collect,
|
|
19
|
+
register_func,
|
|
20
|
+
register_process,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# 触发内置函数注册
|
|
24
|
+
from . import collect # noqa: F401,E402
|
|
25
|
+
from . import process # noqa: F401,E402
|
lysync/funcs/collect.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""内置采集函数(``source_type='custom'`` 时调用)。
|
|
2
|
+
|
|
3
|
+
采集函数签名:``func(**kwargs) -> list[dict]``,返回的字典列表键名应与目标模型
|
|
4
|
+
字段(``SyncModelField.field_name``)一致。
|
|
5
|
+
|
|
6
|
+
数据源上的 ``custom_func`` 填写这里注册的配置名;``custom_func_kwargs`` 与任务上的
|
|
7
|
+
``custom_func_kwargs`` 会合并后作为关键字参数传入(任务参数优先级更高)。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .registry import FuncType, register_func
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register_func("sample_collect", FuncType.COLLECT)
|
|
16
|
+
def sample_collect(**kwargs):
|
|
17
|
+
"""示例采集函数:返回两条示例数据。
|
|
18
|
+
|
|
19
|
+
仅用于演示如何编写自定义采集函数,实际项目中请替换为真实的数据拉取逻辑
|
|
20
|
+
(调用第三方 API、读取文件、内部 RPC 等)。
|
|
21
|
+
"""
|
|
22
|
+
return [
|
|
23
|
+
{"id": 1, "name": "示例-A", "value": 100, "time": "2026-01-01 00:00:00"},
|
|
24
|
+
{"id": 2, "name": "示例-B", "value": 250, "time": "2026-01-02 00:00:00"},
|
|
25
|
+
]
|