config_store.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import json
  2. import re
  3. from dataclasses import dataclass
  4. from typing import Any
  5. from sqlalchemy import text
  6. from app.core.config import settings
  7. from app.db.mysql import SessionLocal
  8. IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
  9. def _safe_identifier(name: str) -> str:
  10. if not IDENTIFIER_RE.match(name):
  11. raise ValueError(f"invalid sql identifier: {name}")
  12. return name
  13. @dataclass(frozen=True)
  14. class StrategyConfigRecord:
  15. strategy_id: str
  16. name: str
  17. version: str
  18. params: dict[str, Any]
  19. active: bool
  20. daily_write_limit: int = 0
  21. priority: int = 0
  22. create_time: str | None = None
  23. updated_time: str | None = None
  24. def _parse_params(raw: object) -> dict[str, Any]:
  25. if raw is None:
  26. return {}
  27. if isinstance(raw, dict):
  28. return raw
  29. if isinstance(raw, (bytes, bytearray)):
  30. raw = raw.decode("utf-8")
  31. if isinstance(raw, str):
  32. text_value = raw.strip()
  33. if not text_value:
  34. return {}
  35. return json.loads(text_value)
  36. raise TypeError(f"unsupported params type: {type(raw)!r}")
  37. def ensure_strategy_tables() -> None:
  38. config_table = _safe_identifier(settings.strategy_config_table)
  39. staging_table = _safe_identifier(settings.strategy_staging_table)
  40. config_sql = f"""
  41. CREATE TABLE IF NOT EXISTS {config_table}
  42. (
  43. id BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增id',
  44. strategy_id VARCHAR(64) NOT NULL COMMENT '全局唯一id,英文名称',
  45. name VARCHAR(128) NOT NULL COMMENT '策略名称',
  46. version VARCHAR(32) NOT NULL COMMENT '策略版本,用于追踪代码变更',
  47. params JSON NULL COMMENT '策略运行参数,如阈值、窗口期等',
  48. daily_write_limit INT NOT NULL DEFAULT 0 COMMENT '实验:每日写入上限,0=不限',
  49. priority INT NOT NULL DEFAULT 0 COMMENT '实验:跨策略去重优先级,越小越优先',
  50. active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用,0=不参与产出和合并',
  51. create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  52. updated_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  53. PRIMARY KEY (id),
  54. UNIQUE KEY uk_strategy_id (strategy_id)
  55. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='策略配置表'
  56. """
  57. staging_sql = f"""
  58. CREATE TABLE IF NOT EXISTS {staging_table}
  59. (
  60. id BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增id',
  61. strategy_config_id VARCHAR(64) NOT NULL COMMENT '来源策略ID,关联 strategy_config.strategy_id',
  62. strategy VARCHAR(32) NULL COMMENT '策略名称',
  63. demand_id VARCHAR(64) NULL COMMENT '需求id',
  64. demand_name VARCHAR(512) NULL COMMENT '需求',
  65. weight DOUBLE NULL COMMENT '需求权重',
  66. `type` VARCHAR(64) NULL COMMENT '需求类型',
  67. video_count INT NULL COMMENT '视频数量',
  68. video_list JSON NULL COMMENT '视频列表',
  69. extend JSON NULL COMMENT '拓展字段',
  70. batch_date VARCHAR(32) NULL COMMENT '归属批次日期,e.g. 20250526',
  71. create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  72. PRIMARY KEY (id),
  73. KEY idx_strategy_config_id (strategy_config_id),
  74. KEY idx_batch_date (batch_date),
  75. KEY idx_demand_id (demand_id)
  76. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='策略暂存表'
  77. """
  78. with SessionLocal() as session:
  79. session.execute(text(config_sql))
  80. session.execute(text(staging_sql))
  81. _migrate_strategy_config_columns(session, config_table)
  82. session.commit()
  83. def _migrate_strategy_config_columns(session, config_table: str) -> None:
  84. rows = session.execute(
  85. text(
  86. """
  87. SELECT COLUMN_NAME
  88. FROM information_schema.COLUMNS
  89. WHERE TABLE_SCHEMA = DATABASE()
  90. AND TABLE_NAME = :table_name
  91. """
  92. ),
  93. {"table_name": config_table},
  94. ).all()
  95. existing = {str(row[0]) for row in rows}
  96. if "daily_write_limit" not in existing:
  97. session.execute(
  98. text(
  99. f"""
  100. ALTER TABLE {config_table}
  101. ADD COLUMN daily_write_limit INT NOT NULL DEFAULT 0
  102. COMMENT '实验:每日写入上限,0=不限'
  103. """
  104. )
  105. )
  106. if "priority" not in existing:
  107. session.execute(
  108. text(
  109. f"""
  110. ALTER TABLE {config_table}
  111. ADD COLUMN priority INT NOT NULL DEFAULT 0
  112. COMMENT '实验:跨策略去重优先级,越小越优先'
  113. """
  114. )
  115. )
  116. def _format_timestamp(value: object) -> str | None:
  117. if value is None:
  118. return None
  119. if hasattr(value, "isoformat"):
  120. return value.isoformat(sep=" ", timespec="seconds")
  121. return str(value)
  122. def _row_to_record(row: object) -> StrategyConfigRecord:
  123. mapping = row._mapping if hasattr(row, "_mapping") else row
  124. return StrategyConfigRecord(
  125. strategy_id=str(mapping["strategy_id"]),
  126. name=str(mapping["name"]),
  127. version=str(mapping["version"]),
  128. params=_parse_params(mapping["params"]),
  129. active=bool(mapping["active"]),
  130. daily_write_limit=int(mapping.get("daily_write_limit") or 0),
  131. priority=int(mapping.get("priority") or 0),
  132. create_time=_format_timestamp(mapping.get("create_time")),
  133. updated_time=_format_timestamp(mapping.get("updated_time")),
  134. )
  135. def fetch_all_configs() -> list[StrategyConfigRecord]:
  136. config_table = _safe_identifier(settings.strategy_config_table)
  137. query = text(
  138. f"""
  139. SELECT strategy_id, name, version, params, active,
  140. daily_write_limit, priority, create_time, updated_time
  141. FROM {config_table}
  142. ORDER BY strategy_id ASC
  143. """
  144. )
  145. with SessionLocal() as session:
  146. rows = session.execute(query).mappings().all()
  147. return [_row_to_record(row) for row in rows]
  148. def fetch_config_by_id(strategy_id: str) -> StrategyConfigRecord | None:
  149. config_table = _safe_identifier(settings.strategy_config_table)
  150. query = text(
  151. f"""
  152. SELECT strategy_id, name, version, params, active,
  153. daily_write_limit, priority, create_time, updated_time
  154. FROM {config_table}
  155. WHERE strategy_id = :strategy_id
  156. LIMIT 1
  157. """
  158. )
  159. with SessionLocal() as session:
  160. row = session.execute(query, {"strategy_id": strategy_id}).mappings().first()
  161. if row is None:
  162. return None
  163. return _row_to_record(row)
  164. def insert_config(
  165. *,
  166. strategy_id: str,
  167. name: str,
  168. version: str,
  169. params: dict[str, Any],
  170. active: bool,
  171. daily_write_limit: int = 0,
  172. priority: int = 0,
  173. ) -> StrategyConfigRecord:
  174. config_table = _safe_identifier(settings.strategy_config_table)
  175. query = text(
  176. f"""
  177. INSERT INTO {config_table}
  178. (strategy_id, name, version, params, active, daily_write_limit, priority)
  179. VALUES
  180. (:strategy_id, :name, :version, CAST(:params AS JSON), :active,
  181. :daily_write_limit, :priority)
  182. """
  183. )
  184. with SessionLocal() as session:
  185. session.execute(
  186. query,
  187. {
  188. "strategy_id": strategy_id,
  189. "name": name,
  190. "version": version,
  191. "params": json.dumps(params, ensure_ascii=False),
  192. "active": int(active),
  193. "daily_write_limit": daily_write_limit,
  194. "priority": priority,
  195. },
  196. )
  197. session.commit()
  198. record = fetch_config_by_id(strategy_id)
  199. if record is None:
  200. raise RuntimeError(f"failed to load inserted strategy config: {strategy_id}")
  201. return record
  202. def update_config(
  203. *,
  204. strategy_id: str,
  205. params: dict[str, Any] | None = None,
  206. active: bool | None = None,
  207. daily_write_limit: int | None = None,
  208. priority: int | None = None,
  209. ) -> StrategyConfigRecord:
  210. if (
  211. params is None
  212. and active is None
  213. and daily_write_limit is None
  214. and priority is None
  215. ):
  216. raise ValueError("至少需提供一项可更新字段")
  217. config_table = _safe_identifier(settings.strategy_config_table)
  218. set_clauses: list[str] = []
  219. payload: dict[str, object] = {"strategy_id": strategy_id}
  220. if params is not None:
  221. set_clauses.append("params = CAST(:params AS JSON)")
  222. payload["params"] = json.dumps(params, ensure_ascii=False)
  223. if active is not None:
  224. set_clauses.append("active = :active")
  225. payload["active"] = int(active)
  226. if daily_write_limit is not None:
  227. set_clauses.append("daily_write_limit = :daily_write_limit")
  228. payload["daily_write_limit"] = daily_write_limit
  229. if priority is not None:
  230. set_clauses.append("priority = :priority")
  231. payload["priority"] = priority
  232. query = text(
  233. f"""
  234. UPDATE {config_table}
  235. SET {", ".join(set_clauses)}
  236. WHERE strategy_id = :strategy_id
  237. """
  238. )
  239. with SessionLocal() as session:
  240. result = session.execute(query, payload)
  241. if result.rowcount == 0:
  242. raise KeyError(f"strategy config not found: {strategy_id}")
  243. session.commit()
  244. record = fetch_config_by_id(strategy_id)
  245. if record is None:
  246. raise RuntimeError(f"failed to load updated strategy config: {strategy_id}")
  247. return record