mock_db.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. """DB_SYNC 模拟源库(任务书 P1-C)。
  2. 职责边界:
  3. - 只在独立 MySQL 模拟 schema(默认 aidop_integration_sim)建表、装载、清理;
  4. - 绝不连接 Ai-DOP 库写贴源/标准/KPI 表;库名含 aidop 且非模拟库名时硬阻断;
  5. - 不使用 Ai-DOP 主库账号;连接凭据从环境变量读取(密码 AIDOP_SIM_MYSQL_PASSWORD);
  6. - 生成 Ai-DOP 配置清单(mdp_source / mdp_entity 建议行),仅供人工在配置页登记,不自动写入。
  7. """
  8. from __future__ import annotations
  9. import json
  10. import os
  11. import time
  12. from datetime import datetime
  13. # 允许创建的模拟库名;其余含 aidop 的库名一律阻断
  14. SIM_DB_DEFAULT = "aidop_integration_sim"
  15. _BLOCKED_DB_HINTS = ("aidopdev", "aidop_uat", "aidopprod", "aidop_prod", "admin", "mdp")
  16. class BlockedDatabaseError(RuntimeError):
  17. pass
  18. def assert_sim_database(database: str) -> None:
  19. db = (database or "").strip().lower()
  20. if not db:
  21. raise BlockedDatabaseError("database name required")
  22. if db != SIM_DB_DEFAULT and any(h in db for h in _BLOCKED_DB_HINTS):
  23. raise BlockedDatabaseError(
  24. f"database '{database}' looks like an Ai-DOP database; simulator may only use '{SIM_DB_DEFAULT}'")
  25. if db != SIM_DB_DEFAULT and not db.startswith("aidop_integration_sim"):
  26. # 允许 aidop_integration_sim_* 派生测试库,其余含 aidop 的阻断
  27. if "aidop" in db:
  28. raise BlockedDatabaseError(
  29. f"database '{database}' contains 'aidop' but is not the simulator schema")
  30. def mysql_conn_params(database: str | None = None) -> dict:
  31. db = database or os.environ.get("AIDOP_SIM_MYSQL_DATABASE", SIM_DB_DEFAULT)
  32. assert_sim_database(db)
  33. return {
  34. "host": os.environ.get("AIDOP_SIM_MYSQL_HOST", "127.0.0.1"),
  35. "port": int(os.environ.get("AIDOP_SIM_MYSQL_PORT", "3306")),
  36. "user": os.environ.get("AIDOP_SIM_MYSQL_USER", "root"),
  37. "password": os.environ.get("AIDOP_SIM_MYSQL_PASSWORD", ""),
  38. "database": db,
  39. "charset": "utf8mb4",
  40. }
  41. def connect(database: str | None = None):
  42. import pymysql # 延迟导入:单元测试不依赖真实驱动
  43. params = mysql_conn_params(database)
  44. return pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
  45. # ---------------------------------------------------------------------------
  46. # DDL 生成(纯函数,可单测)
  47. # ---------------------------------------------------------------------------
  48. def infer_mysql_type(values: list) -> str:
  49. has_text = has_int = has_float = has_bool = False
  50. max_len = 0
  51. for v in values:
  52. if v is None:
  53. continue
  54. if isinstance(v, bool):
  55. has_bool = True
  56. elif isinstance(v, int):
  57. has_int = True
  58. elif isinstance(v, float):
  59. has_float = True
  60. else:
  61. has_text = True
  62. max_len = max(max_len, len(str(v)))
  63. if has_text:
  64. return f"VARCHAR({max(64, min(512, (max_len // 32 + 1) * 32))})"
  65. if has_bool:
  66. return "TINYINT(1)"
  67. if has_float:
  68. return "DECIMAL(18,6)"
  69. if has_int:
  70. return "BIGINT"
  71. return "VARCHAR(191)"
  72. def generate_ddl(table: str, rows: list[dict], business_key: list[str] | None = None,
  73. increment_column: str = "sourceUpdatedAt", unique_key: bool = True) -> str:
  74. """按样例行推断列类型;业务键唯一索引;增量列建普通索引。列名保持样例原样(反引号包裹)。
  75. unique_key=False:不建业务键唯一索引(N1 重复业务键场景)。"""
  76. if not rows:
  77. raise ValueError(f"cannot generate DDL for {table} from empty rows")
  78. business_key = business_key or ["bizKey"]
  79. columns: dict[str, list] = {}
  80. for row in rows:
  81. for k, v in row.items():
  82. columns.setdefault(k, []).append(v)
  83. lines = []
  84. for name, values in columns.items():
  85. lines.append(f" `{name}` {infer_mysql_type(values)} NULL")
  86. if unique_key:
  87. uk = ", ".join(f"`{k}`" for k in business_key if k in columns)
  88. if uk:
  89. lines.append(f" UNIQUE KEY `uk_{table}` ({uk})")
  90. if increment_column in columns:
  91. lines.append(f" KEY `ix_{table}_incr` (`{increment_column}`)")
  92. return (f"CREATE TABLE IF NOT EXISTS `{table}` (\n" + ",\n".join(lines) +
  93. "\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
  94. # ---------------------------------------------------------------------------
  95. # 库操作
  96. # ---------------------------------------------------------------------------
  97. def _col_names(rows: list[dict]) -> list[str]:
  98. cols: list[str] = []
  99. for row in rows:
  100. for k in row:
  101. if k not in cols:
  102. cols.append(k)
  103. return cols
  104. def _insert_sql(table: str, cols: list[str]) -> str:
  105. names = ", ".join(f"`{c}`" for c in cols)
  106. marks = ", ".join(["%s"] * len(cols))
  107. updates = ", ".join(f"`{c}`=VALUES(`{c}`)" for c in cols)
  108. return f"INSERT INTO `{table}` ({names}) VALUES ({marks}) ON DUPLICATE KEY UPDATE {updates}"
  109. def _convert(v):
  110. if isinstance(v, (dict, list)):
  111. return json.dumps(v, ensure_ascii=False)
  112. if isinstance(v, bool):
  113. return int(v)
  114. return v
  115. def init_schema(tables: dict[str, dict]) -> dict:
  116. """tables: {table: {"rows": [...], "businessKey": [...], "incrementColumn": str}}。建库建表。"""
  117. result = {"database": None, "tables": []}
  118. import pymysql
  119. params = mysql_conn_params()
  120. database = params.pop("database")
  121. conn = pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
  122. try:
  123. with conn.cursor() as cur:
  124. cur.execute(f"CREATE DATABASE IF NOT EXISTS `{database}` DEFAULT CHARACTER SET utf8mb4")
  125. cur.execute(f"USE `{database}`")
  126. result["database"] = database
  127. for table, spec in tables.items():
  128. ddl = generate_ddl(table, spec["rows"], spec.get("businessKey"),
  129. spec.get("incrementColumn", "sourceUpdatedAt"),
  130. unique_key=spec.get("unique_key", True))
  131. cur.execute(ddl)
  132. result["tables"].append(table)
  133. finally:
  134. conn.close()
  135. return result
  136. def _use_db():
  137. import pymysql
  138. params = mysql_conn_params()
  139. return pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
  140. def reset_table(table: str) -> None:
  141. conn = _use_db()
  142. try:
  143. with conn.cursor() as cur:
  144. cur.execute(f"TRUNCATE TABLE `{table}`")
  145. finally:
  146. conn.close()
  147. def upsert_rows(table: str, rows: list[dict]) -> int:
  148. if not rows:
  149. return 0
  150. cols = _col_names(rows)
  151. sql = _insert_sql(table, cols)
  152. conn = _use_db()
  153. try:
  154. with conn.cursor() as cur:
  155. cur.executemany(sql, [tuple(_convert(row.get(c)) for c in cols) for row in rows])
  156. return len(rows)
  157. finally:
  158. conn.close()
  159. def update_rows(table: str, updates: dict, where: dict,
  160. increment_column: str = "sourceUpdatedAt",
  161. new_increment: str | None = None,
  162. advance_increment: bool = True) -> int:
  163. """更新已有数据并推进增量列(DB_SYNC 增量更新用例)。
  164. advance_increment=False:不推进增量列(N1 增量列不推进场景)。"""
  165. sets = {**updates}
  166. if increment_column and advance_increment:
  167. sets[increment_column] = new_increment or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
  168. set_clause = ", ".join(f"`{k}`=%s" for k in sets)
  169. where_clause = " AND ".join(f"`{k}`=%s" for k in where)
  170. sql = f"UPDATE `{table}` SET {set_clause} WHERE {where_clause}"
  171. conn = _use_db()
  172. try:
  173. with conn.cursor() as cur:
  174. cur.execute(sql, tuple(sets.values()) + tuple(where.values()))
  175. return cur.rowcount
  176. finally:
  177. conn.close()
  178. def insert_rows_raw(table: str, rows: list[dict]) -> int:
  179. """原始 INSERT(不合并):供 N1 重复业务键场景装载(表须无唯一键)。"""
  180. if not rows:
  181. return 0
  182. cols = _col_names(rows)
  183. names = ", ".join(f"`{c}`" for c in cols)
  184. marks = ", ".join(["%s"] * len(cols))
  185. sql = f"INSERT INTO `{table}` ({names}) VALUES ({marks})"
  186. conn = _use_db()
  187. try:
  188. with conn.cursor() as cur:
  189. cur.executemany(sql, [tuple(_convert(row.get(c)) for c in cols) for row in rows])
  190. return len(rows)
  191. finally:
  192. conn.close()
  193. def drop_table(table: str) -> None:
  194. """删除源表(N1 源表不存在场景;恢复时用 ensure 重建)。"""
  195. conn = _use_db()
  196. try:
  197. with conn.cursor() as cur:
  198. cur.execute(f"DROP TABLE IF EXISTS `{table}`")
  199. finally:
  200. conn.close()
  201. def build_slow_view_ddl(view: str, table: str, sleep_seconds: float) -> str:
  202. """受控延迟视图 DDL(纯函数可单测):每行 SLEEP 固定秒数。
  203. Ai-DOP 侧把实体 source_table_name 人工配置为该视图名即可制造慢查询。
  204. """
  205. return (f"CREATE OR REPLACE VIEW `{view}` AS "
  206. f"SELECT t.*, SLEEP({sleep_seconds}) AS `__sim_sleep` FROM `{table}` t")
  207. def create_slow_view(view: str, table: str, sleep_seconds: float) -> None:
  208. conn = _use_db()
  209. try:
  210. with conn.cursor() as cur:
  211. cur.execute(build_slow_view_ddl(view, table, sleep_seconds))
  212. finally:
  213. conn.close()
  214. def drop_view(view: str) -> None:
  215. conn = _use_db()
  216. try:
  217. with conn.cursor() as cur:
  218. cur.execute(f"DROP VIEW IF EXISTS `{view}`")
  219. finally:
  220. conn.close()
  221. def table_stats(table: str, increment_column: str = "sourceUpdatedAt") -> dict:
  222. conn = _use_db()
  223. try:
  224. with conn.cursor() as cur:
  225. cur.execute(f"SELECT COUNT(*) AS cnt, MAX(`{increment_column}`) AS max_incr FROM `{table}`")
  226. row = cur.fetchone() or {}
  227. return {"table": table, "rowCount": row.get("cnt", 0),
  228. "maxIncrement": str(row.get("max_incr") or "")}
  229. finally:
  230. conn.close()
  231. # ---------------------------------------------------------------------------
  232. # 第三阶段 P1 批量数据生成(§18:档位生成、增量比例、字段宽度、一键停止)
  233. # ---------------------------------------------------------------------------
  234. # §18 数据规模档位(XL 须审批后自定义行数)
  235. LOAD_SIZES = {"S": 100, "M": 10_000, "L": 100_000}
  236. def _pad_text(value, width: int) -> object:
  237. if width <= 0 or not isinstance(value, str):
  238. return value
  239. if len(value) >= width:
  240. return value
  241. return value + "x" * (width - len(value))
  242. def generate_row_blocks(template_rows: list[dict], count: int, *, increment_ratio: float = 1.0,
  243. field_width: int = 0, batch_size: int = 1000,
  244. stop_event=None):
  245. """分块生成压力行(生成器,纯函数可单测)。每块生成前检查 stop_event,
  246. 支持「一键停止后续数据生成」(§18 模拟器功能)。
  247. - increment_ratio:新业务键占比(0-1);其余行复用模板 bizKey,模拟「更新已有行」;
  248. - field_width:文本字段垫长到该宽度(模拟大字段);
  249. - 每行 bizKey 唯一后缀、sourceUpdatedAt 递增,保证业务键不重复且增量可推进。
  250. """
  251. if not template_rows or count <= 0:
  252. return
  253. ratio = min(1.0, max(0.0, increment_ratio))
  254. new_count = int(count * ratio)
  255. for start in range(0, count, batch_size):
  256. if stop_event is not None and stop_event.is_set():
  257. return
  258. block = []
  259. for i in range(start, min(start + batch_size, count)):
  260. row = dict(template_rows[i % len(template_rows)])
  261. if i < new_count:
  262. if "bizKey" in row:
  263. row["bizKey"] = f"{row['bizKey']}#LOAD-{i}"
  264. if field_width > 0:
  265. row = {k: _pad_text(v, field_width) for k, v in row.items()}
  266. if "sourceUpdatedAt" in row:
  267. row["sourceUpdatedAt"] = (datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
  268. + f".{i % 1000:03d}")
  269. block.append(row)
  270. yield block
  271. def bulk_generate(table: str, template_rows: list[dict], count: int, *, increment_ratio: float = 1.0,
  272. field_width: int = 0, batch_size: int = 1000, stop_event=None) -> dict:
  273. """分块装载大档数据(S/M/L);每块检查 stop_event 支持一键停止。
  274. 返回生成耗时、实际装载行数、块数、是否被停止。"""
  275. started = time.monotonic()
  276. loaded = 0
  277. batches = 0
  278. stopped = False
  279. for block in generate_row_blocks(template_rows, count, increment_ratio=increment_ratio,
  280. field_width=field_width, batch_size=batch_size,
  281. stop_event=stop_event):
  282. if stop_event is not None and stop_event.is_set():
  283. stopped = True
  284. break
  285. upsert_rows(table, block)
  286. loaded += len(block)
  287. batches += 1
  288. return {"table": table, "requested": count, "loaded": loaded, "batches": batches,
  289. "stopped": stopped, "elapsedSeconds": round(time.monotonic() - started, 2)}
  290. def db_status() -> dict:
  291. """模拟库连接与基础统计(§18:查看数据库侧当前连接和基础统计)。"""
  292. import pymysql
  293. params = mysql_conn_params()
  294. database = params["database"]
  295. conn = pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
  296. try:
  297. with conn.cursor() as cur:
  298. cur.execute("SHOW STATUS LIKE 'Threads_connected'")
  299. threads = int((cur.fetchone() or {}).get("Value") or 0)
  300. cur.execute("SHOW STATUS LIKE 'Uptime'")
  301. uptime = int((cur.fetchone() or {}).get("Value") or 0)
  302. cur.execute(
  303. "SELECT COUNT(*) AS cnt FROM information_schema.tables "
  304. "WHERE table_schema=%s", (database,))
  305. tables = int((cur.fetchone() or {}).get("cnt") or 0)
  306. cur.execute(
  307. "SELECT table_name, table_rows FROM information_schema.tables "
  308. "WHERE table_schema=%s ORDER BY table_name LIMIT 200", (database,))
  309. table_rows = {r["table_name"]: r["table_rows"] for r in cur.fetchall()}
  310. cur.execute(
  311. "SELECT COUNT(*) AS cnt FROM information_schema.processlist "
  312. "WHERE db=%s", (database,))
  313. active = int((cur.fetchone() or {}).get("cnt") or 0)
  314. return {"database": database, "threadsConnected": threads, "uptimeSeconds": uptime,
  315. "tableCount": tables, "tableRows": table_rows,
  316. "connectionsOnDatabase": active}
  317. finally:
  318. conn.close()
  319. # ---------------------------------------------------------------------------
  320. # Ai-DOP 配置清单生成(只输出建议,不写库)
  321. # ---------------------------------------------------------------------------
  322. SIM_SYSTEM_CODE = "SIM_MODE3"
  323. SIM_SOURCES = {
  324. "DB_SYNC": "SIM_MODE3_DB",
  325. "API_PULL": "SIM_MODE3_API",
  326. "API_INBOUND": "SIM_MODE3_INBOUND",
  327. }
  328. def generate_aidop_config(obj: dict, mock_api_base_url: str = "http://127.0.0.1:8018") -> dict:
  329. """按对象生成 mdp_source / mdp_entity 建议配置,供人工在 Ai-DOP 配置页登记。"""
  330. db_sync = obj.get("dbSync") or {}
  331. api_pull = obj.get("apiPull") or {}
  332. inbound = obj.get("apiInbound") or {}
  333. config: dict = {"objectCode": obj["objectCode"], "systemCode": SIM_SYSTEM_CODE, "sources": [], "entities": []}
  334. if db_sync.get("supported"):
  335. config["sources"].append({
  336. "source_code": SIM_SOURCES["DB_SYNC"], "source_type": "DB", "conn_mode": "EXTERNAL",
  337. "hint": "连接指向模拟库 aidop_integration_sim(独立账号,勿用 Ai-DOP 主库账号)",
  338. })
  339. config["entities"].append({
  340. "entity_code": f"SIM_{obj['objectCode']}_DB", "source_code": SIM_SOURCES["DB_SYNC"],
  341. "source_table_name": db_sync.get("table"), "target_table_name": f"mdp_stg_sim_{db_sync.get('table')}",
  342. "biz_key_expr": ",".join(db_sync.get("businessKey") or ["bizKey"]),
  343. "incr_column": db_sync.get("incrementColumn"),
  344. })
  345. if api_pull.get("supported"):
  346. config["sources"].append({
  347. "source_code": SIM_SOURCES["API_PULL"], "source_type": "API",
  348. "api_base_url": mock_api_base_url, "api_auth_type": "TOKEN",
  349. "hint": "token 值即 Mock 控制台当前 TOKEN(环境变量/控制台设置,不落盘)",
  350. })
  351. config["entities"].append({
  352. "entity_code": f"SIM_{obj['objectCode']}_API", "source_code": SIM_SOURCES["API_PULL"],
  353. "source_api_path": api_pull.get("path"), "target_table_name": f"mdp_stg_sim_{db_sync.get('table')}",
  354. "response_data_path": api_pull.get("responsePath"), "dedup_key_path": api_pull.get("dedupKeyPath"),
  355. "biz_key_expr": ",".join(db_sync.get("businessKey") or ["bizKey"]),
  356. "incr_column": "cursor",
  357. })
  358. if inbound.get("supported"):
  359. config["sources"].append({
  360. "source_code": SIM_SOURCES["API_INBOUND"], "source_type": "API_INBOUND",
  361. "hint": "需在 mdp_inbound_grant 为测试 AccessKey 授权该 entityCode,并绑定测试租户开放身份",
  362. })
  363. config["entities"].append({
  364. "entity_code": inbound.get("entityCode"), "source_code": SIM_SOURCES["API_INBOUND"],
  365. "inbound_enabled": 1,
  366. })
  367. return config