mock_db.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  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. from datetime import datetime
  12. # 允许创建的模拟库名;其余含 aidop 的库名一律阻断
  13. SIM_DB_DEFAULT = "aidop_integration_sim"
  14. _BLOCKED_DB_HINTS = ("aidopdev", "aidop_uat", "aidopprod", "aidop_prod", "admin", "mdp")
  15. class BlockedDatabaseError(RuntimeError):
  16. pass
  17. def assert_sim_database(database: str) -> None:
  18. db = (database or "").strip().lower()
  19. if not db:
  20. raise BlockedDatabaseError("database name required")
  21. if db != SIM_DB_DEFAULT and any(h in db for h in _BLOCKED_DB_HINTS):
  22. raise BlockedDatabaseError(
  23. f"database '{database}' looks like an Ai-DOP database; simulator may only use '{SIM_DB_DEFAULT}'")
  24. if db != SIM_DB_DEFAULT and not db.startswith("aidop_integration_sim"):
  25. # 允许 aidop_integration_sim_* 派生测试库,其余含 aidop 的阻断
  26. if "aidop" in db:
  27. raise BlockedDatabaseError(
  28. f"database '{database}' contains 'aidop' but is not the simulator schema")
  29. def mysql_conn_params(database: str | None = None) -> dict:
  30. db = database or os.environ.get("AIDOP_SIM_MYSQL_DATABASE", SIM_DB_DEFAULT)
  31. assert_sim_database(db)
  32. return {
  33. "host": os.environ.get("AIDOP_SIM_MYSQL_HOST", "127.0.0.1"),
  34. "port": int(os.environ.get("AIDOP_SIM_MYSQL_PORT", "3306")),
  35. "user": os.environ.get("AIDOP_SIM_MYSQL_USER", "root"),
  36. "password": os.environ.get("AIDOP_SIM_MYSQL_PASSWORD", ""),
  37. "database": db,
  38. "charset": "utf8mb4",
  39. }
  40. def connect(database: str | None = None):
  41. import pymysql # 延迟导入:单元测试不依赖真实驱动
  42. params = mysql_conn_params(database)
  43. return pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
  44. # ---------------------------------------------------------------------------
  45. # DDL 生成(纯函数,可单测)
  46. # ---------------------------------------------------------------------------
  47. def infer_mysql_type(values: list) -> str:
  48. has_text = has_int = has_float = has_bool = False
  49. max_len = 0
  50. for v in values:
  51. if v is None:
  52. continue
  53. if isinstance(v, bool):
  54. has_bool = True
  55. elif isinstance(v, int):
  56. has_int = True
  57. elif isinstance(v, float):
  58. has_float = True
  59. else:
  60. has_text = True
  61. max_len = max(max_len, len(str(v)))
  62. if has_text:
  63. return f"VARCHAR({max(64, min(512, (max_len // 32 + 1) * 32))})"
  64. if has_bool:
  65. return "TINYINT(1)"
  66. if has_float:
  67. return "DECIMAL(18,6)"
  68. if has_int:
  69. return "BIGINT"
  70. return "VARCHAR(191)"
  71. def generate_ddl(table: str, rows: list[dict], business_key: list[str] | None = None,
  72. increment_column: str = "sourceUpdatedAt", unique_key: bool = True) -> str:
  73. """按样例行推断列类型;业务键唯一索引;增量列建普通索引。列名保持样例原样(反引号包裹)。
  74. unique_key=False:不建业务键唯一索引(N1 重复业务键场景)。"""
  75. if not rows:
  76. raise ValueError(f"cannot generate DDL for {table} from empty rows")
  77. business_key = business_key or ["bizKey"]
  78. columns: dict[str, list] = {}
  79. for row in rows:
  80. for k, v in row.items():
  81. columns.setdefault(k, []).append(v)
  82. lines = []
  83. for name, values in columns.items():
  84. lines.append(f" `{name}` {infer_mysql_type(values)} NULL")
  85. if unique_key:
  86. uk = ", ".join(f"`{k}`" for k in business_key if k in columns)
  87. if uk:
  88. lines.append(f" UNIQUE KEY `uk_{table}` ({uk})")
  89. if increment_column in columns:
  90. lines.append(f" KEY `ix_{table}_incr` (`{increment_column}`)")
  91. return (f"CREATE TABLE IF NOT EXISTS `{table}` (\n" + ",\n".join(lines) +
  92. "\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
  93. # ---------------------------------------------------------------------------
  94. # 库操作
  95. # ---------------------------------------------------------------------------
  96. def _col_names(rows: list[dict]) -> list[str]:
  97. cols: list[str] = []
  98. for row in rows:
  99. for k in row:
  100. if k not in cols:
  101. cols.append(k)
  102. return cols
  103. def _insert_sql(table: str, cols: list[str]) -> str:
  104. names = ", ".join(f"`{c}`" for c in cols)
  105. marks = ", ".join(["%s"] * len(cols))
  106. updates = ", ".join(f"`{c}`=VALUES(`{c}`)" for c in cols)
  107. return f"INSERT INTO `{table}` ({names}) VALUES ({marks}) ON DUPLICATE KEY UPDATE {updates}"
  108. def _convert(v):
  109. if isinstance(v, (dict, list)):
  110. return json.dumps(v, ensure_ascii=False)
  111. if isinstance(v, bool):
  112. return int(v)
  113. return v
  114. def init_schema(tables: dict[str, dict]) -> dict:
  115. """tables: {table: {"rows": [...], "businessKey": [...], "incrementColumn": str}}。建库建表。"""
  116. result = {"database": None, "tables": []}
  117. import pymysql
  118. params = mysql_conn_params()
  119. database = params.pop("database")
  120. conn = pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
  121. try:
  122. with conn.cursor() as cur:
  123. cur.execute(f"CREATE DATABASE IF NOT EXISTS `{database}` DEFAULT CHARACTER SET utf8mb4")
  124. cur.execute(f"USE `{database}`")
  125. result["database"] = database
  126. for table, spec in tables.items():
  127. ddl = generate_ddl(table, spec["rows"], spec.get("businessKey"),
  128. spec.get("incrementColumn", "sourceUpdatedAt"),
  129. unique_key=spec.get("unique_key", True))
  130. cur.execute(ddl)
  131. result["tables"].append(table)
  132. finally:
  133. conn.close()
  134. return result
  135. def _use_db():
  136. import pymysql
  137. params = mysql_conn_params()
  138. return pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
  139. def reset_table(table: str) -> None:
  140. conn = _use_db()
  141. try:
  142. with conn.cursor() as cur:
  143. cur.execute(f"TRUNCATE TABLE `{table}`")
  144. finally:
  145. conn.close()
  146. def upsert_rows(table: str, rows: list[dict]) -> int:
  147. if not rows:
  148. return 0
  149. cols = _col_names(rows)
  150. sql = _insert_sql(table, cols)
  151. conn = _use_db()
  152. try:
  153. with conn.cursor() as cur:
  154. cur.executemany(sql, [tuple(_convert(row.get(c)) for c in cols) for row in rows])
  155. return len(rows)
  156. finally:
  157. conn.close()
  158. def update_rows(table: str, updates: dict, where: dict,
  159. increment_column: str = "sourceUpdatedAt",
  160. new_increment: str | None = None,
  161. advance_increment: bool = True) -> int:
  162. """更新已有数据并推进增量列(DB_SYNC 增量更新用例)。
  163. advance_increment=False:不推进增量列(N1 增量列不推进场景)。"""
  164. sets = {**updates}
  165. if increment_column and advance_increment:
  166. sets[increment_column] = new_increment or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
  167. set_clause = ", ".join(f"`{k}`=%s" for k in sets)
  168. where_clause = " AND ".join(f"`{k}`=%s" for k in where)
  169. sql = f"UPDATE `{table}` SET {set_clause} WHERE {where_clause}"
  170. conn = _use_db()
  171. try:
  172. with conn.cursor() as cur:
  173. cur.execute(sql, tuple(sets.values()) + tuple(where.values()))
  174. return cur.rowcount
  175. finally:
  176. conn.close()
  177. def insert_rows_raw(table: str, rows: list[dict]) -> int:
  178. """原始 INSERT(不合并):供 N1 重复业务键场景装载(表须无唯一键)。"""
  179. if not rows:
  180. return 0
  181. cols = _col_names(rows)
  182. names = ", ".join(f"`{c}`" for c in cols)
  183. marks = ", ".join(["%s"] * len(cols))
  184. sql = f"INSERT INTO `{table}` ({names}) VALUES ({marks})"
  185. conn = _use_db()
  186. try:
  187. with conn.cursor() as cur:
  188. cur.executemany(sql, [tuple(_convert(row.get(c)) for c in cols) for row in rows])
  189. return len(rows)
  190. finally:
  191. conn.close()
  192. def drop_table(table: str) -> None:
  193. """删除源表(N1 源表不存在场景;恢复时用 ensure 重建)。"""
  194. conn = _use_db()
  195. try:
  196. with conn.cursor() as cur:
  197. cur.execute(f"DROP TABLE IF EXISTS `{table}`")
  198. finally:
  199. conn.close()
  200. def build_slow_view_ddl(view: str, table: str, sleep_seconds: float) -> str:
  201. """受控延迟视图 DDL(纯函数可单测):每行 SLEEP 固定秒数。
  202. Ai-DOP 侧把实体 source_table_name 人工配置为该视图名即可制造慢查询。
  203. """
  204. return (f"CREATE OR REPLACE VIEW `{view}` AS "
  205. f"SELECT t.*, SLEEP({sleep_seconds}) AS `__sim_sleep` FROM `{table}` t")
  206. def create_slow_view(view: str, table: str, sleep_seconds: float) -> None:
  207. conn = _use_db()
  208. try:
  209. with conn.cursor() as cur:
  210. cur.execute(build_slow_view_ddl(view, table, sleep_seconds))
  211. finally:
  212. conn.close()
  213. def drop_view(view: str) -> None:
  214. conn = _use_db()
  215. try:
  216. with conn.cursor() as cur:
  217. cur.execute(f"DROP VIEW IF EXISTS `{view}`")
  218. finally:
  219. conn.close()
  220. def table_stats(table: str, increment_column: str = "sourceUpdatedAt") -> dict:
  221. conn = _use_db()
  222. try:
  223. with conn.cursor() as cur:
  224. cur.execute(f"SELECT COUNT(*) AS cnt, MAX(`{increment_column}`) AS max_incr FROM `{table}`")
  225. row = cur.fetchone() or {}
  226. return {"table": table, "rowCount": row.get("cnt", 0),
  227. "maxIncrement": str(row.get("max_incr") or "")}
  228. finally:
  229. conn.close()
  230. # ---------------------------------------------------------------------------
  231. # Ai-DOP 配置清单生成(只输出建议,不写库)
  232. # ---------------------------------------------------------------------------
  233. SIM_SYSTEM_CODE = "SIM_MODE3"
  234. SIM_SOURCES = {
  235. "DB_SYNC": "SIM_MODE3_DB",
  236. "API_PULL": "SIM_MODE3_API",
  237. "API_INBOUND": "SIM_MODE3_INBOUND",
  238. }
  239. def generate_aidop_config(obj: dict, mock_api_base_url: str = "http://127.0.0.1:8018") -> dict:
  240. """按对象生成 mdp_source / mdp_entity 建议配置,供人工在 Ai-DOP 配置页登记。"""
  241. db_sync = obj.get("dbSync") or {}
  242. api_pull = obj.get("apiPull") or {}
  243. inbound = obj.get("apiInbound") or {}
  244. config: dict = {"objectCode": obj["objectCode"], "systemCode": SIM_SYSTEM_CODE, "sources": [], "entities": []}
  245. if db_sync.get("supported"):
  246. config["sources"].append({
  247. "source_code": SIM_SOURCES["DB_SYNC"], "source_type": "DB", "conn_mode": "EXTERNAL",
  248. "hint": "连接指向模拟库 aidop_integration_sim(独立账号,勿用 Ai-DOP 主库账号)",
  249. })
  250. config["entities"].append({
  251. "entity_code": f"SIM_{obj['objectCode']}_DB", "source_code": SIM_SOURCES["DB_SYNC"],
  252. "source_table_name": db_sync.get("table"), "target_table_name": f"mdp_stg_sim_{db_sync.get('table')}",
  253. "biz_key_expr": ",".join(db_sync.get("businessKey") or ["bizKey"]),
  254. "incr_column": db_sync.get("incrementColumn"),
  255. })
  256. if api_pull.get("supported"):
  257. config["sources"].append({
  258. "source_code": SIM_SOURCES["API_PULL"], "source_type": "API",
  259. "api_base_url": mock_api_base_url, "api_auth_type": "TOKEN",
  260. "hint": "token 值即 Mock 控制台当前 TOKEN(环境变量/控制台设置,不落盘)",
  261. })
  262. config["entities"].append({
  263. "entity_code": f"SIM_{obj['objectCode']}_API", "source_code": SIM_SOURCES["API_PULL"],
  264. "source_api_path": api_pull.get("path"), "target_table_name": f"mdp_stg_sim_{db_sync.get('table')}",
  265. "response_data_path": api_pull.get("responsePath"), "dedup_key_path": api_pull.get("dedupKeyPath"),
  266. "biz_key_expr": ",".join(db_sync.get("businessKey") or ["bizKey"]),
  267. "incr_column": "cursor",
  268. })
  269. if inbound.get("supported"):
  270. config["sources"].append({
  271. "source_code": SIM_SOURCES["API_INBOUND"], "source_type": "API_INBOUND",
  272. "hint": "需在 mdp_inbound_grant 为测试 AccessKey 授权该 entityCode,并绑定测试租户开放身份",
  273. })
  274. config["entities"].append({
  275. "entity_code": inbound.get("entityCode"), "source_code": SIM_SOURCES["API_INBOUND"],
  276. "inbound_enabled": 1,
  277. })
  278. return config