"""批量写入待投放账户三元组配置。 用法: python configure_creation_accounts.py --account-id 84545647 --audience-name 'R50*已转化' --bid 0.35 python configure_creation_accounts.py --csv inputs/accounts.csv CSV 字段: account_id,audience_name,bid """ from __future__ import annotations import argparse import csv import json import sys from dataclasses import dataclass from datetime import date from decimal import Decimal, ROUND_HALF_UP from pathlib import Path from dotenv import load_dotenv _HERE = Path(__file__).parent load_dotenv(_HERE / ".env") sys.path.insert(0, str(_HERE)) DEFAULT_DELIVERY_VERSION = "miniapp_user_growth_v1" DEFAULT_BRAND_KEY = "default_piaoquan" DEFAULT_FEEDBACK_KEY = "miniapp_click_default" DEFAULT_AGE_MIN = 45 DEFAULT_AGE_MAX = 66 DEFAULT_SOURCE_ACCOUNT_ID = 55615440 DEFAULT_BID_SCENE = "average_cost" @dataclass(frozen=True) class AccountConfigInput: account_id: int audience_name: str bid_min_fen: int bid_max_fen: int bid_amount_fen: int | None = None daily_budget_fen: int | None = None enabled: bool = True material_source: str = "history" ai_fallback_to_history: bool = False bid_scene: str = DEFAULT_BID_SCENE custom_cost_cap_fen: int | None = None automatic_site_enabled: bool | None = None site_set: list[str] | None = None age_min: int = DEFAULT_AGE_MIN age_max: int = DEFAULT_AGE_MAX location_types: list[str] | None = None region_ids: list[int] | None = None config_date: date | None = None def _bid_to_fen(raw: str) -> int: """将出价文本(元,可带"元"后缀)转换为分(int),四舍五入到分""" text = str(raw).strip().replace("元", "") if not text: raise ValueError("bid 为空") value = Decimal(text).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) return int((value * 100).to_integral_value(rounding=ROUND_HALF_UP)) def parse_bid(raw: str) -> tuple[int, int, int | None]: """解析出价文本,返回 (bid_min_fen, bid_max_fen, bid_amount_fen)。 支持固定值 "0.35" 或范围 "0.28-0.31";范围模式 bid_amount 为 None。 """ text = str(raw).strip() if "-" in text: left, right = text.split("-", 1) bid_min = _bid_to_fen(left) bid_max = _bid_to_fen(right) if bid_min > bid_max: raise ValueError(f"出价范围非法:{raw}") return bid_min, bid_max, None bid = _bid_to_fen(text) return bid, bid, bid def parse_budget_fen(raw: object) -> int | None: """解析预算文本(元)为分;空/不限制/不限/- 返回 None(使用模板默认预算)""" text = str(raw or "").strip() if not text: return None if text in {"不限制", "不限", "-"}: return 0 # 飞书里预算按元填,腾讯 API 用分。 return _bid_to_fen(text) def ensure_account_delivery_config_columns() -> None: """Add account-level delivery override columns when deploying old DBs.""" from db.connection import get_connection conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ad_creation_account_config' AND COLUMN_NAME IN ( 'bid_scene', 'custom_cost_cap_fen', 'automatic_site_enabled', 'site_set_json', 'location_types_json', 'region_ids_json', 'config_date' ) """ ) existing = {row["COLUMN_NAME"] for row in (cur.fetchall() or [])} if "bid_scene" not in existing: cur.execute( """ ALTER TABLE ad_creation_account_config ADD COLUMN bid_scene VARCHAR(50) DEFAULT NULL COMMENT '出价场景:BID_SCENE_NORMAL_AVERAGE/BID_SCENE_NORMAL_MAX' AFTER bid_amount_fen """ ) if "custom_cost_cap_fen" not in existing: cur.execute( """ ALTER TABLE ad_creation_account_config ADD COLUMN custom_cost_cap_fen INT DEFAULT NULL COMMENT '最大转化量控制成本(分),用于 custom_cost_cap' AFTER bid_scene """ ) if "automatic_site_enabled" not in existing: cur.execute( """ ALTER TABLE ad_creation_account_config ADD COLUMN automatic_site_enabled BOOLEAN DEFAULT NULL COMMENT '账户级是否开启智能版位;NULL使用模板默认手动版位' AFTER daily_budget_fen """ ) if "site_set_json" not in existing: cur.execute( """ ALTER TABLE ad_creation_account_config ADD COLUMN site_set_json TEXT DEFAULT NULL COMMENT '账户级投放版位覆盖JSON;NULL使用投放模板' AFTER automatic_site_enabled """ ) if "location_types_json" not in existing: cur.execute( """ ALTER TABLE ad_creation_account_config ADD COLUMN location_types_json TEXT DEFAULT NULL COMMENT '账户级地域类型覆盖JSON;NULL使用投放模板' AFTER site_set_json """ ) if "region_ids_json" not in existing: cur.execute( """ ALTER TABLE ad_creation_account_config ADD COLUMN region_ids_json MEDIUMTEXT DEFAULT NULL COMMENT '账户级地域ID覆盖JSON;NULL使用投放模板,空数组表示不限地域' AFTER location_types_json """ ) if "config_date" not in existing: cur.execute( """ ALTER TABLE ad_creation_account_config ADD COLUMN config_date DATE DEFAULT NULL COMMENT '飞书配置生效日期;仅当天配置参与自动创建/补创意' AFTER region_ids_json """ ) conn.commit() finally: conn.close() def _read_inputs(args: argparse.Namespace) -> list[AccountConfigInput]: """从 CSV 文件或命令行参数读取并校验账户配置输入""" rows: list[AccountConfigInput] = [] if args.csv: with open(args.csv, newline="", encoding="utf-8-sig") as f: reader = csv.DictReader(f) for idx, row in enumerate(reader, start=2): try: rows.append(AccountConfigInput( account_id=int(str(row.get("account_id") or "").strip()), audience_name=str(row.get("audience_name") or "").strip(), bid_min_fen=parse_bid(str(row.get("bid") or ""))[0], bid_max_fen=parse_bid(str(row.get("bid") or ""))[1], bid_amount_fen=parse_bid(str(row.get("bid") or ""))[2], )) except Exception as e: raise ValueError(f"CSV 第 {idx} 行格式错误:{e}") from e else: if args.account_id is None or not args.audience_name or not args.bid: raise ValueError("单账户模式必须提供 --account-id --audience-name --bid") bid_min, bid_max, bid_amount = parse_bid(args.bid) rows.append(AccountConfigInput( account_id=int(args.account_id), audience_name=args.audience_name.strip(), bid_min_fen=bid_min, bid_max_fen=bid_max, bid_amount_fen=bid_amount, )) for row in rows: if not row.audience_name: raise ValueError(f"account_id={row.account_id} audience_name 为空") if row.bid_min_fen <= 0 or row.bid_max_fen <= 0: raise ValueError(f"account_id={row.account_id} bid 必须大于 0") return rows def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> None: """将账户配置写入 DB(account_whitelist + ad_creation_account_config),存在则更新""" from db.connection import get_connection from tools.account_material_strategy import ensure_account_material_strategy_columns source_account_id = None if row.audience_name == "泛人群" else DEFAULT_SOURCE_ACCOUNT_ID grant_status = "not_required_no_audience_pack" if row.audience_name == "泛人群" else "pending_resolve" audience_pack_id = None if dry_run: print( f"[dry-run] account={row.account_id} audience={row.audience_name} " f"bid={row.bid_min_fen}-{row.bid_max_fen} source={source_account_id} " f"status={grant_status} material_source={row.material_source} " f"ai_fallback={row.ai_fallback_to_history} bid_scene={row.bid_scene} " f"custom_cost_cap={row.custom_cost_cap_fen} " f"automatic_site={row.automatic_site_enabled} site_set={row.site_set} " f"age={row.age_min}-{row.age_max} regions={row.region_ids} " f"config_date={row.config_date}" ) return ensure_account_material_strategy_columns() ensure_account_delivery_config_columns() conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ SELECT user_token FROM account_whitelist WHERE user_token IS NOT NULL ORDER BY updated_at DESC LIMIT 1 """ ) token_row = cur.fetchone() or {} user_token = token_row.get("user_token") cur.execute( """ INSERT INTO account_whitelist (account_id, account_name, business_line, enabled, remark, created_by, updated_by, audience_pack_id, audience_tier_label, user_token, crowd_package) VALUES (%s, %s, %s, TRUE, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE enabled=TRUE, remark=VALUES(remark), updated_by=VALUES(updated_by), audience_pack_id=VALUES(audience_pack_id), audience_tier_label=VALUES(audience_tier_label), user_token=COALESCE(account_whitelist.user_token, VALUES(user_token)), crowd_package=VALUES(crowd_package) """, ( row.account_id, f"auto-create-{row.account_id}", "auto_put_tencent", "configured from account_id/audience_name/bid triple", "configure_creation_accounts", "configure_creation_accounts", audience_pack_id, row.audience_name, user_token, row.audience_name, ), ) cur.execute( """ INSERT INTO ad_creation_account_config (account_id, enabled, delivery_version, brand_key, feedback_key, material_source, ai_fallback_to_history, audience_name, audience_pack_id, audience_tier_label, bid_min_fen, bid_max_fen, bid_amount_fen, bid_scene, custom_cost_cap_fen, daily_budget_fen, automatic_site_enabled, site_set_json, location_types_json, region_ids_json, age_min, age_max, config_date, audience_source_account_id, audience_grant_status, remark, created_by, updated_by) VALUES (%s, TRUE, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE enabled=TRUE, delivery_version=VALUES(delivery_version), brand_key=VALUES(brand_key), feedback_key=VALUES(feedback_key), material_source=VALUES(material_source), ai_fallback_to_history=VALUES(ai_fallback_to_history), audience_pack_id=IF( ad_creation_account_config.audience_name = VALUES(audience_name), ad_creation_account_config.audience_pack_id, VALUES(audience_pack_id) ), audience_tier_label=VALUES(audience_tier_label), bid_min_fen=VALUES(bid_min_fen), bid_max_fen=VALUES(bid_max_fen), bid_amount_fen=VALUES(bid_amount_fen), bid_scene=VALUES(bid_scene), custom_cost_cap_fen=VALUES(custom_cost_cap_fen), daily_budget_fen=VALUES(daily_budget_fen), automatic_site_enabled=VALUES(automatic_site_enabled), site_set_json=VALUES(site_set_json), location_types_json=VALUES(location_types_json), region_ids_json=VALUES(region_ids_json), age_min=VALUES(age_min), age_max=VALUES(age_max), config_date=VALUES(config_date), audience_source_account_id=IF( ad_creation_account_config.audience_name = VALUES(audience_name), ad_creation_account_config.audience_source_account_id, VALUES(audience_source_account_id) ), audience_grant_status=IF( ad_creation_account_config.audience_name = VALUES(audience_name), ad_creation_account_config.audience_grant_status, VALUES(audience_grant_status) ), audience_verified_at=IF( ad_creation_account_config.audience_name = VALUES(audience_name), ad_creation_account_config.audience_verified_at, NULL ), audience_name=VALUES(audience_name), remark=VALUES(remark), updated_by=VALUES(updated_by) """, ( row.account_id, DEFAULT_DELIVERY_VERSION, DEFAULT_BRAND_KEY, DEFAULT_FEEDBACK_KEY, row.material_source, 1 if row.ai_fallback_to_history else 0, row.audience_name, audience_pack_id, row.audience_name, row.bid_min_fen, row.bid_max_fen, row.bid_amount_fen, row.bid_scene, row.custom_cost_cap_fen, row.daily_budget_fen, None if row.automatic_site_enabled is None else (1 if row.automatic_site_enabled else 0), json.dumps(row.site_set, ensure_ascii=False) if row.site_set is not None else None, json.dumps(row.location_types, ensure_ascii=False) if row.location_types is not None else None, json.dumps(row.region_ids, ensure_ascii=False) if row.region_ids is not None else None, row.age_min, row.age_max, row.config_date, source_account_id, grant_status, "configured from account_id/audience_name/bid triple", "configure_creation_accounts", "configure_creation_accounts", ), ) conn.commit() finally: conn.close() print( f"configured account={row.account_id} audience={row.audience_name} " f"bid={row.bid_min_fen / 100:.2f}-{row.bid_max_fen / 100:.2f} " f"bid_scene={row.bid_scene} custom_cost_cap={row.custom_cost_cap_fen} " f"age={row.age_min}-{row.age_max} regions={row.region_ids} " f"config_date={row.config_date} status={grant_status}" ) def set_account_automation_enabled( account_id: int, enabled: bool, config_date: date | None = None, dry_run: bool = False, ) -> None: """启用/禁用账户的自动化投放开关(更新 ad_creation_account_config.enabled)""" if dry_run: print(f"[dry-run] set account={account_id} enabled={enabled} config_date={config_date}") return from db.connection import get_connection conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ UPDATE ad_creation_account_config SET enabled=%s, config_date=%s, updated_by='configure_creation_accounts' WHERE account_id=%s """, (1 if enabled else 0, config_date, account_id), ) conn.commit() finally: conn.close() print(f"set account={account_id} enabled={enabled}") def main() -> int: """命令行入口:读取输入(CSV 或单账户参数)并逐条写入账户配置""" parser = argparse.ArgumentParser() parser.add_argument("--account-id", type=int) parser.add_argument("--audience-name") parser.add_argument("--bid", help="元,例如 0.35") parser.add_argument("--csv", type=Path) parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() rows = _read_inputs(args) for row in rows: upsert_account_config(row, dry_run=args.dry_run) return 0 if __name__ == "__main__": raise SystemExit(main())