mock_db.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  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") -> str:
  73. """按样例行推断列类型;业务键唯一索引;增量列建普通索引。列名保持样例原样(反引号包裹)。"""
  74. if not rows:
  75. raise ValueError(f"cannot generate DDL for {table} from empty rows")
  76. business_key = business_key or ["bizKey"]
  77. columns: dict[str, list] = {}
  78. for row in rows:
  79. for k, v in row.items():
  80. columns.setdefault(k, []).append(v)
  81. lines = []
  82. for name, values in columns.items():
  83. lines.append(f" `{name}` {infer_mysql_type(values)} NULL")
  84. uk = ", ".join(f"`{k}`" for k in business_key if k in columns)
  85. if uk:
  86. lines.append(f" UNIQUE KEY `uk_{table}` ({uk})")
  87. if increment_column in columns:
  88. lines.append(f" KEY `ix_{table}_incr` (`{increment_column}`)")
  89. return (f"CREATE TABLE IF NOT EXISTS `{table}` (\n" + ",\n".join(lines) +
  90. "\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
  91. # ---------------------------------------------------------------------------
  92. # 库操作
  93. # ---------------------------------------------------------------------------
  94. def _col_names(rows: list[dict]) -> list[str]:
  95. cols: list[str] = []
  96. for row in rows:
  97. for k in row:
  98. if k not in cols:
  99. cols.append(k)
  100. return cols
  101. def _insert_sql(table: str, cols: list[str]) -> str:
  102. names = ", ".join(f"`{c}`" for c in cols)
  103. marks = ", ".join(["%s"] * len(cols))
  104. updates = ", ".join(f"`{c}`=VALUES(`{c}`)" for c in cols)
  105. return f"INSERT INTO `{table}` ({names}) VALUES ({marks}) ON DUPLICATE KEY UPDATE {updates}"
  106. def _convert(v):
  107. if isinstance(v, (dict, list)):
  108. return json.dumps(v, ensure_ascii=False)
  109. if isinstance(v, bool):
  110. return int(v)
  111. return v
  112. def init_schema(tables: dict[str, dict]) -> dict:
  113. """tables: {table: {"rows": [...], "businessKey": [...], "incrementColumn": str}}。建库建表。"""
  114. result = {"database": None, "tables": []}
  115. import pymysql
  116. params = mysql_conn_params()
  117. database = params.pop("database")
  118. conn = pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
  119. try:
  120. with conn.cursor() as cur:
  121. cur.execute(f"CREATE DATABASE IF NOT EXISTS `{database}` DEFAULT CHARACTER SET utf8mb4")
  122. cur.execute(f"USE `{database}`")
  123. result["database"] = database
  124. for table, spec in tables.items():
  125. ddl = generate_ddl(table, spec["rows"], spec.get("businessKey"),
  126. spec.get("incrementColumn", "sourceUpdatedAt"))
  127. cur.execute(ddl)
  128. result["tables"].append(table)
  129. finally:
  130. conn.close()
  131. return result
  132. def _use_db():
  133. import pymysql
  134. params = mysql_conn_params()
  135. return pymysql.connect(cursorclass=pymysql.cursors.DictCursor, autocommit=True, **params)
  136. def reset_table(table: str) -> None:
  137. conn = _use_db()
  138. try:
  139. with conn.cursor() as cur:
  140. cur.execute(f"TRUNCATE TABLE `{table}`")
  141. finally:
  142. conn.close()
  143. def upsert_rows(table: str, rows: list[dict]) -> int:
  144. if not rows:
  145. return 0
  146. cols = _col_names(rows)
  147. sql = _insert_sql(table, cols)
  148. conn = _use_db()
  149. try:
  150. with conn.cursor() as cur:
  151. cur.executemany(sql, [tuple(_convert(row.get(c)) for c in cols) for row in rows])
  152. return len(rows)
  153. finally:
  154. conn.close()
  155. def update_rows(table: str, updates: dict, where: dict,
  156. increment_column: str = "sourceUpdatedAt",
  157. new_increment: str | None = None) -> int:
  158. """更新已有数据并推进增量列(DB_SYNC 增量更新用例)。"""
  159. sets = {**updates}
  160. if increment_column:
  161. sets[increment_column] = new_increment or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
  162. set_clause = ", ".join(f"`{k}`=%s" for k in sets)
  163. where_clause = " AND ".join(f"`{k}`=%s" for k in where)
  164. sql = f"UPDATE `{table}` SET {set_clause} WHERE {where_clause}"
  165. conn = _use_db()
  166. try:
  167. with conn.cursor() as cur:
  168. cur.execute(sql, tuple(sets.values()) + tuple(where.values()))
  169. return cur.rowcount
  170. finally:
  171. conn.close()
  172. def table_stats(table: str, increment_column: str = "sourceUpdatedAt") -> dict:
  173. conn = _use_db()
  174. try:
  175. with conn.cursor() as cur:
  176. cur.execute(f"SELECT COUNT(*) AS cnt, MAX(`{increment_column}`) AS max_incr FROM `{table}`")
  177. row = cur.fetchone() or {}
  178. return {"table": table, "rowCount": row.get("cnt", 0),
  179. "maxIncrement": str(row.get("max_incr") or "")}
  180. finally:
  181. conn.close()
  182. # ---------------------------------------------------------------------------
  183. # Ai-DOP 配置清单生成(只输出建议,不写库)
  184. # ---------------------------------------------------------------------------
  185. SIM_SYSTEM_CODE = "SIM_MODE3"
  186. SIM_SOURCES = {
  187. "DB_SYNC": "SIM_MODE3_DB",
  188. "API_PULL": "SIM_MODE3_API",
  189. "API_INBOUND": "SIM_MODE3_INBOUND",
  190. }
  191. def generate_aidop_config(obj: dict, mock_api_base_url: str = "http://127.0.0.1:8018") -> dict:
  192. """按对象生成 mdp_source / mdp_entity 建议配置,供人工在 Ai-DOP 配置页登记。"""
  193. db_sync = obj.get("dbSync") or {}
  194. api_pull = obj.get("apiPull") or {}
  195. inbound = obj.get("apiInbound") or {}
  196. config: dict = {"objectCode": obj["objectCode"], "systemCode": SIM_SYSTEM_CODE, "sources": [], "entities": []}
  197. if db_sync.get("supported"):
  198. config["sources"].append({
  199. "source_code": SIM_SOURCES["DB_SYNC"], "source_type": "DB", "conn_mode": "EXTERNAL",
  200. "hint": "连接指向模拟库 aidop_integration_sim(独立账号,勿用 Ai-DOP 主库账号)",
  201. })
  202. config["entities"].append({
  203. "entity_code": f"SIM_{obj['objectCode']}_DB", "source_code": SIM_SOURCES["DB_SYNC"],
  204. "source_table_name": db_sync.get("table"), "target_table_name": f"mdp_stg_sim_{db_sync.get('table')}",
  205. "biz_key_expr": ",".join(db_sync.get("businessKey") or ["bizKey"]),
  206. "incr_column": db_sync.get("incrementColumn"),
  207. })
  208. if api_pull.get("supported"):
  209. config["sources"].append({
  210. "source_code": SIM_SOURCES["API_PULL"], "source_type": "API",
  211. "api_base_url": mock_api_base_url, "api_auth_type": "TOKEN",
  212. "hint": "token 值即 Mock 控制台当前 TOKEN(环境变量/控制台设置,不落盘)",
  213. })
  214. config["entities"].append({
  215. "entity_code": f"SIM_{obj['objectCode']}_API", "source_code": SIM_SOURCES["API_PULL"],
  216. "source_api_path": api_pull.get("path"), "target_table_name": f"mdp_stg_sim_{db_sync.get('table')}",
  217. "response_data_path": api_pull.get("responsePath"), "dedup_key_path": api_pull.get("dedupKeyPath"),
  218. "biz_key_expr": ",".join(db_sync.get("businessKey") or ["bizKey"]),
  219. "incr_column": "cursor",
  220. })
  221. if inbound.get("supported"):
  222. config["sources"].append({
  223. "source_code": SIM_SOURCES["API_INBOUND"], "source_type": "API_INBOUND",
  224. "hint": "需在 mdp_inbound_grant 为测试 AccessKey 授权该 entityCode,并绑定测试租户开放身份",
  225. })
  226. config["entities"].append({
  227. "entity_code": inbound.get("entityCode"), "source_code": SIM_SOURCES["API_INBOUND"],
  228. "inbound_enabled": 1,
  229. })
  230. return config