configure_creation_accounts.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. """批量写入待投放账户三元组配置。
  2. 用法:
  3. python configure_creation_accounts.py --account-id 84545647 --audience-name 'R50*已转化' --bid 0.35
  4. python configure_creation_accounts.py --csv inputs/accounts.csv
  5. CSV 字段:
  6. account_id,audience_name,bid
  7. """
  8. from __future__ import annotations
  9. import argparse
  10. import csv
  11. import json
  12. import sys
  13. from dataclasses import dataclass
  14. from datetime import date
  15. from decimal import Decimal, ROUND_HALF_UP
  16. from pathlib import Path
  17. from dotenv import load_dotenv
  18. _HERE = Path(__file__).parent
  19. load_dotenv(_HERE / ".env")
  20. sys.path.insert(0, str(_HERE))
  21. DEFAULT_DELIVERY_VERSION = "miniapp_user_growth_v1"
  22. DEFAULT_BRAND_KEY = "default_piaoquan"
  23. DEFAULT_FEEDBACK_KEY = "miniapp_click_default"
  24. DEFAULT_AGE_MIN = 45
  25. DEFAULT_AGE_MAX = 66
  26. DEFAULT_SOURCE_ACCOUNT_ID = 55615440
  27. DEFAULT_BID_SCENE = "average_cost"
  28. @dataclass(frozen=True)
  29. class AccountConfigInput:
  30. account_id: int
  31. audience_name: str
  32. bid_min_fen: int
  33. bid_max_fen: int
  34. bid_amount_fen: int | None = None
  35. daily_budget_fen: int | None = None
  36. enabled: bool = True
  37. material_source: str = "history"
  38. ai_fallback_to_history: bool = False
  39. bid_scene: str = DEFAULT_BID_SCENE
  40. custom_cost_cap_fen: int | None = None
  41. automatic_site_enabled: bool | None = None
  42. site_set: list[str] | None = None
  43. age_min: int = DEFAULT_AGE_MIN
  44. age_max: int = DEFAULT_AGE_MAX
  45. location_types: list[str] | None = None
  46. region_ids: list[int] | None = None
  47. config_date: date | None = None
  48. def _bid_to_fen(raw: str) -> int:
  49. """将出价文本(元,可带"元"后缀)转换为分(int),四舍五入到分"""
  50. text = str(raw).strip().replace("元", "")
  51. if not text:
  52. raise ValueError("bid 为空")
  53. value = Decimal(text).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
  54. return int((value * 100).to_integral_value(rounding=ROUND_HALF_UP))
  55. def parse_bid(raw: str) -> tuple[int, int, int | None]:
  56. """解析出价文本,返回 (bid_min_fen, bid_max_fen, bid_amount_fen)。
  57. 支持固定值 "0.35" 或范围 "0.28-0.31";范围模式 bid_amount 为 None。
  58. """
  59. text = str(raw).strip()
  60. if "-" in text:
  61. left, right = text.split("-", 1)
  62. bid_min = _bid_to_fen(left)
  63. bid_max = _bid_to_fen(right)
  64. if bid_min > bid_max:
  65. raise ValueError(f"出价范围非法:{raw}")
  66. return bid_min, bid_max, None
  67. bid = _bid_to_fen(text)
  68. return bid, bid, bid
  69. def parse_budget_fen(raw: object) -> int | None:
  70. """解析预算文本(元)为分;空/不限制/不限/- 返回 None(使用模板默认预算)"""
  71. text = str(raw or "").strip()
  72. if not text:
  73. return None
  74. if text in {"不限制", "不限", "-"}:
  75. return 0
  76. # 飞书里预算按元填,腾讯 API 用分。
  77. return _bid_to_fen(text)
  78. def ensure_account_delivery_config_columns() -> None:
  79. """Add account-level delivery override columns when deploying old DBs."""
  80. from db.connection import get_connection
  81. conn = get_connection()
  82. try:
  83. with conn.cursor() as cur:
  84. cur.execute(
  85. """
  86. SELECT COLUMN_NAME
  87. FROM information_schema.COLUMNS
  88. WHERE TABLE_SCHEMA = DATABASE()
  89. AND TABLE_NAME = 'ad_creation_account_config'
  90. AND COLUMN_NAME IN (
  91. 'bid_scene', 'custom_cost_cap_fen',
  92. 'automatic_site_enabled', 'site_set_json',
  93. 'location_types_json', 'region_ids_json', 'config_date'
  94. )
  95. """
  96. )
  97. existing = {row["COLUMN_NAME"] for row in (cur.fetchall() or [])}
  98. if "bid_scene" not in existing:
  99. cur.execute(
  100. """
  101. ALTER TABLE ad_creation_account_config
  102. ADD COLUMN bid_scene VARCHAR(50) DEFAULT NULL
  103. COMMENT '出价场景:BID_SCENE_NORMAL_AVERAGE/BID_SCENE_NORMAL_MAX'
  104. AFTER bid_amount_fen
  105. """
  106. )
  107. if "custom_cost_cap_fen" not in existing:
  108. cur.execute(
  109. """
  110. ALTER TABLE ad_creation_account_config
  111. ADD COLUMN custom_cost_cap_fen INT DEFAULT NULL
  112. COMMENT '最大转化量控制成本(分),用于 custom_cost_cap'
  113. AFTER bid_scene
  114. """
  115. )
  116. if "automatic_site_enabled" not in existing:
  117. cur.execute(
  118. """
  119. ALTER TABLE ad_creation_account_config
  120. ADD COLUMN automatic_site_enabled BOOLEAN DEFAULT NULL
  121. COMMENT '账户级是否开启智能版位;NULL使用模板默认手动版位'
  122. AFTER daily_budget_fen
  123. """
  124. )
  125. if "site_set_json" not in existing:
  126. cur.execute(
  127. """
  128. ALTER TABLE ad_creation_account_config
  129. ADD COLUMN site_set_json TEXT DEFAULT NULL
  130. COMMENT '账户级投放版位覆盖JSON;NULL使用投放模板'
  131. AFTER automatic_site_enabled
  132. """
  133. )
  134. if "location_types_json" not in existing:
  135. cur.execute(
  136. """
  137. ALTER TABLE ad_creation_account_config
  138. ADD COLUMN location_types_json TEXT DEFAULT NULL
  139. COMMENT '账户级地域类型覆盖JSON;NULL使用投放模板'
  140. AFTER site_set_json
  141. """
  142. )
  143. if "region_ids_json" not in existing:
  144. cur.execute(
  145. """
  146. ALTER TABLE ad_creation_account_config
  147. ADD COLUMN region_ids_json MEDIUMTEXT DEFAULT NULL
  148. COMMENT '账户级地域ID覆盖JSON;NULL使用投放模板,空数组表示不限地域'
  149. AFTER location_types_json
  150. """
  151. )
  152. if "config_date" not in existing:
  153. cur.execute(
  154. """
  155. ALTER TABLE ad_creation_account_config
  156. ADD COLUMN config_date DATE DEFAULT NULL
  157. COMMENT '飞书配置生效日期;仅当天配置参与自动创建/补创意'
  158. AFTER region_ids_json
  159. """
  160. )
  161. conn.commit()
  162. finally:
  163. conn.close()
  164. def _read_inputs(args: argparse.Namespace) -> list[AccountConfigInput]:
  165. """从 CSV 文件或命令行参数读取并校验账户配置输入"""
  166. rows: list[AccountConfigInput] = []
  167. if args.csv:
  168. with open(args.csv, newline="", encoding="utf-8-sig") as f:
  169. reader = csv.DictReader(f)
  170. for idx, row in enumerate(reader, start=2):
  171. try:
  172. rows.append(AccountConfigInput(
  173. account_id=int(str(row.get("account_id") or "").strip()),
  174. audience_name=str(row.get("audience_name") or "").strip(),
  175. bid_min_fen=parse_bid(str(row.get("bid") or ""))[0],
  176. bid_max_fen=parse_bid(str(row.get("bid") or ""))[1],
  177. bid_amount_fen=parse_bid(str(row.get("bid") or ""))[2],
  178. ))
  179. except Exception as e:
  180. raise ValueError(f"CSV 第 {idx} 行格式错误:{e}") from e
  181. else:
  182. if args.account_id is None or not args.audience_name or not args.bid:
  183. raise ValueError("单账户模式必须提供 --account-id --audience-name --bid")
  184. bid_min, bid_max, bid_amount = parse_bid(args.bid)
  185. rows.append(AccountConfigInput(
  186. account_id=int(args.account_id),
  187. audience_name=args.audience_name.strip(),
  188. bid_min_fen=bid_min,
  189. bid_max_fen=bid_max,
  190. bid_amount_fen=bid_amount,
  191. ))
  192. for row in rows:
  193. if not row.audience_name:
  194. raise ValueError(f"account_id={row.account_id} audience_name 为空")
  195. if row.bid_min_fen <= 0 or row.bid_max_fen <= 0:
  196. raise ValueError(f"account_id={row.account_id} bid 必须大于 0")
  197. return rows
  198. def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> None:
  199. """将账户配置写入 DB(account_whitelist + ad_creation_account_config),存在则更新"""
  200. from db.connection import get_connection
  201. from tools.account_material_strategy import ensure_account_material_strategy_columns
  202. source_account_id = None if row.audience_name == "泛人群" else DEFAULT_SOURCE_ACCOUNT_ID
  203. grant_status = "not_required_no_audience_pack" if row.audience_name == "泛人群" else "pending_resolve"
  204. audience_pack_id = None
  205. if dry_run:
  206. print(
  207. f"[dry-run] account={row.account_id} audience={row.audience_name} "
  208. f"bid={row.bid_min_fen}-{row.bid_max_fen} source={source_account_id} "
  209. f"status={grant_status} material_source={row.material_source} "
  210. f"ai_fallback={row.ai_fallback_to_history} bid_scene={row.bid_scene} "
  211. f"custom_cost_cap={row.custom_cost_cap_fen} "
  212. f"automatic_site={row.automatic_site_enabled} site_set={row.site_set} "
  213. f"age={row.age_min}-{row.age_max} regions={row.region_ids} "
  214. f"config_date={row.config_date}"
  215. )
  216. return
  217. ensure_account_material_strategy_columns()
  218. ensure_account_delivery_config_columns()
  219. conn = get_connection()
  220. try:
  221. with conn.cursor() as cur:
  222. cur.execute(
  223. """
  224. SELECT user_token
  225. FROM account_whitelist
  226. WHERE user_token IS NOT NULL
  227. ORDER BY updated_at DESC
  228. LIMIT 1
  229. """
  230. )
  231. token_row = cur.fetchone() or {}
  232. user_token = token_row.get("user_token")
  233. cur.execute(
  234. """
  235. INSERT INTO account_whitelist
  236. (account_id, account_name, business_line, enabled, remark,
  237. created_by, updated_by, audience_pack_id, audience_tier_label,
  238. user_token, crowd_package)
  239. VALUES
  240. (%s, %s, %s, TRUE, %s,
  241. %s, %s, %s, %s,
  242. %s, %s)
  243. ON DUPLICATE KEY UPDATE
  244. enabled=TRUE,
  245. remark=VALUES(remark),
  246. updated_by=VALUES(updated_by),
  247. audience_pack_id=VALUES(audience_pack_id),
  248. audience_tier_label=VALUES(audience_tier_label),
  249. user_token=COALESCE(account_whitelist.user_token, VALUES(user_token)),
  250. crowd_package=VALUES(crowd_package)
  251. """,
  252. (
  253. row.account_id,
  254. f"auto-create-{row.account_id}",
  255. "auto_put_tencent",
  256. "configured from account_id/audience_name/bid triple",
  257. "configure_creation_accounts",
  258. "configure_creation_accounts",
  259. audience_pack_id,
  260. row.audience_name,
  261. user_token,
  262. row.audience_name,
  263. ),
  264. )
  265. cur.execute(
  266. """
  267. INSERT INTO ad_creation_account_config
  268. (account_id, enabled, delivery_version, brand_key, feedback_key,
  269. material_source, ai_fallback_to_history,
  270. audience_name, audience_pack_id, audience_tier_label,
  271. bid_min_fen, bid_max_fen, bid_amount_fen, bid_scene,
  272. custom_cost_cap_fen,
  273. daily_budget_fen, automatic_site_enabled, site_set_json,
  274. location_types_json, region_ids_json,
  275. age_min, age_max, config_date, audience_source_account_id, audience_grant_status,
  276. remark, created_by, updated_by)
  277. VALUES
  278. (%s, TRUE, %s, %s, %s,
  279. %s, %s,
  280. %s, %s, %s,
  281. %s, %s, %s, %s,
  282. %s,
  283. %s, %s, %s,
  284. %s, %s,
  285. %s, %s, %s, %s, %s,
  286. %s, %s, %s)
  287. ON DUPLICATE KEY UPDATE
  288. enabled=TRUE,
  289. delivery_version=VALUES(delivery_version),
  290. brand_key=VALUES(brand_key),
  291. feedback_key=VALUES(feedback_key),
  292. material_source=VALUES(material_source),
  293. ai_fallback_to_history=VALUES(ai_fallback_to_history),
  294. audience_pack_id=IF(
  295. ad_creation_account_config.audience_name = VALUES(audience_name),
  296. ad_creation_account_config.audience_pack_id,
  297. VALUES(audience_pack_id)
  298. ),
  299. audience_tier_label=VALUES(audience_tier_label),
  300. bid_min_fen=VALUES(bid_min_fen),
  301. bid_max_fen=VALUES(bid_max_fen),
  302. bid_amount_fen=VALUES(bid_amount_fen),
  303. bid_scene=VALUES(bid_scene),
  304. custom_cost_cap_fen=VALUES(custom_cost_cap_fen),
  305. daily_budget_fen=VALUES(daily_budget_fen),
  306. automatic_site_enabled=VALUES(automatic_site_enabled),
  307. site_set_json=VALUES(site_set_json),
  308. location_types_json=VALUES(location_types_json),
  309. region_ids_json=VALUES(region_ids_json),
  310. age_min=VALUES(age_min),
  311. age_max=VALUES(age_max),
  312. config_date=VALUES(config_date),
  313. audience_source_account_id=IF(
  314. ad_creation_account_config.audience_name = VALUES(audience_name),
  315. ad_creation_account_config.audience_source_account_id,
  316. VALUES(audience_source_account_id)
  317. ),
  318. audience_grant_status=IF(
  319. ad_creation_account_config.audience_name = VALUES(audience_name),
  320. ad_creation_account_config.audience_grant_status,
  321. VALUES(audience_grant_status)
  322. ),
  323. audience_verified_at=IF(
  324. ad_creation_account_config.audience_name = VALUES(audience_name),
  325. ad_creation_account_config.audience_verified_at,
  326. NULL
  327. ),
  328. audience_name=VALUES(audience_name),
  329. remark=VALUES(remark),
  330. updated_by=VALUES(updated_by)
  331. """,
  332. (
  333. row.account_id,
  334. DEFAULT_DELIVERY_VERSION,
  335. DEFAULT_BRAND_KEY,
  336. DEFAULT_FEEDBACK_KEY,
  337. row.material_source,
  338. 1 if row.ai_fallback_to_history else 0,
  339. row.audience_name,
  340. audience_pack_id,
  341. row.audience_name,
  342. row.bid_min_fen,
  343. row.bid_max_fen,
  344. row.bid_amount_fen,
  345. row.bid_scene,
  346. row.custom_cost_cap_fen,
  347. row.daily_budget_fen,
  348. None if row.automatic_site_enabled is None else (1 if row.automatic_site_enabled else 0),
  349. json.dumps(row.site_set, ensure_ascii=False) if row.site_set is not None else None,
  350. json.dumps(row.location_types, ensure_ascii=False) if row.location_types is not None else None,
  351. json.dumps(row.region_ids, ensure_ascii=False) if row.region_ids is not None else None,
  352. row.age_min,
  353. row.age_max,
  354. row.config_date,
  355. source_account_id,
  356. grant_status,
  357. "configured from account_id/audience_name/bid triple",
  358. "configure_creation_accounts",
  359. "configure_creation_accounts",
  360. ),
  361. )
  362. conn.commit()
  363. finally:
  364. conn.close()
  365. print(
  366. f"configured account={row.account_id} audience={row.audience_name} "
  367. f"bid={row.bid_min_fen / 100:.2f}-{row.bid_max_fen / 100:.2f} "
  368. f"bid_scene={row.bid_scene} custom_cost_cap={row.custom_cost_cap_fen} "
  369. f"age={row.age_min}-{row.age_max} regions={row.region_ids} "
  370. f"config_date={row.config_date} status={grant_status}"
  371. )
  372. def set_account_automation_enabled(
  373. account_id: int,
  374. enabled: bool,
  375. config_date: date | None = None,
  376. dry_run: bool = False,
  377. ) -> None:
  378. """启用/禁用账户的自动化投放开关(更新 ad_creation_account_config.enabled)"""
  379. if dry_run:
  380. print(f"[dry-run] set account={account_id} enabled={enabled} config_date={config_date}")
  381. return
  382. from db.connection import get_connection
  383. conn = get_connection()
  384. try:
  385. with conn.cursor() as cur:
  386. cur.execute(
  387. """
  388. UPDATE ad_creation_account_config
  389. SET enabled=%s, config_date=%s, updated_by='configure_creation_accounts'
  390. WHERE account_id=%s
  391. """,
  392. (1 if enabled else 0, config_date, account_id),
  393. )
  394. conn.commit()
  395. finally:
  396. conn.close()
  397. print(f"set account={account_id} enabled={enabled}")
  398. def main() -> int:
  399. """命令行入口:读取输入(CSV 或单账户参数)并逐条写入账户配置"""
  400. parser = argparse.ArgumentParser()
  401. parser.add_argument("--account-id", type=int)
  402. parser.add_argument("--audience-name")
  403. parser.add_argument("--bid", help="元,例如 0.35")
  404. parser.add_argument("--csv", type=Path)
  405. parser.add_argument("--dry-run", action="store_true")
  406. args = parser.parse_args()
  407. rows = _read_inputs(args)
  408. for row in rows:
  409. upsert_account_config(row, dry_run=args.dry_run)
  410. return 0
  411. if __name__ == "__main__":
  412. raise SystemExit(main())