diff --git a/scripts/db/accounts_repo.py b/scripts/db/accounts_repo.py index 9566e78..613b7ed 100644 --- a/scripts/db/accounts_repo.py +++ b/scripts/db/accounts_repo.py @@ -10,7 +10,9 @@ import time from datetime import datetime from typing import Any, Optional +from db.auth_strategy import ALL_AUTH_STRATEGIES, AUTH_STRATEGY_PASSWORD_AUTO from db.connection import get_conn, init_db +from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY from util.logging_config import get_skill_logger @@ -32,8 +34,30 @@ def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]: return None +_UNSET = object() + + +def resolve_auth_strategy_for_platform(conn, platform_key: str) -> str: + """Pick auth_strategy from platforms.default_auth_strategy, built-in map, or password_auto.""" + cur = conn.cursor() + cur.execute( + "SELECT default_auth_strategy FROM platforms WHERE platform_key = ?", + (platform_key,), + ) + row = cur.fetchone() + if row and row[0]: + s = str(row[0]) + if s in ALL_AUTH_STRATEGIES: + return s + mapped = PLATFORM_DEFAULT_AUTH_STRATEGY.get(platform_key) + if mapped and mapped in ALL_AUTH_STRATEGIES: + return mapped + return AUTH_STRATEGY_PASSWORD_AUTO + + def _acc_row_to_dict(row) -> dict[str, Any]: """Convert an accounts table row tuple to a dict matching the v2 schema.""" + dev_fp = row[20] return { "id": row[0], "platform_key": row[1], @@ -53,8 +77,11 @@ def _acc_row_to_dict(row) -> dict[str, Any]: "last_error_code": row[15] or "", "last_error_message": row[16] or "", "extra_json": _safe_json_loads(row[17]), - "created_at": _unix_to_iso_local(row[18]), - "updated_at": _unix_to_iso_local(row[19]), + "auth_strategy": row[18], + "session_persistent": int(row[19]) if row[19] is not None else 1, + "device_fingerprint": dev_fp if dev_fp is not None else None, + "created_at": _unix_to_iso_local(row[21]), + "updated_at": _unix_to_iso_local(row[22]), } @@ -74,14 +101,14 @@ def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]: cur.execute( "SELECT platform_key, display_name, domain, provider_code, " "default_url, aliases_json, capabilities_json, enabled, " - "created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key", + "default_auth_strategy, created_at, updated_at FROM platforms WHERE domain = ? ORDER BY platform_key", (domain,), ) else: cur.execute( "SELECT platform_key, display_name, domain, provider_code, " "default_url, aliases_json, capabilities_json, enabled, " - "created_at, updated_at FROM platforms ORDER BY platform_key" + "default_auth_strategy, created_at, updated_at FROM platforms ORDER BY platform_key" ) rows = cur.fetchall() result = [] @@ -95,8 +122,9 @@ def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]: "aliases": _safe_json_loads(r[5], []), "capabilities": _safe_json_loads(r[6], {}), "enabled": bool(r[7]), - "created_at": _unix_to_iso_local(r[8]), - "updated_at": _unix_to_iso_local(r[9]), + "default_auth_strategy": r[8], + "created_at": _unix_to_iso_local(r[9]), + "updated_at": _unix_to_iso_local(r[10]), }) return result @@ -106,7 +134,7 @@ def get_platform_from_db(conn, platform_key: str) -> Optional[dict]: cur.execute( "SELECT platform_key, display_name, domain, provider_code, " "default_url, aliases_json, capabilities_json, enabled, " - "created_at, updated_at FROM platforms WHERE platform_key = ?", + "default_auth_strategy, created_at, updated_at FROM platforms WHERE platform_key = ?", (platform_key,), ) r = cur.fetchone() @@ -121,8 +149,9 @@ def get_platform_from_db(conn, platform_key: str) -> Optional[dict]: "aliases": _safe_json_loads(r[5], []), "capabilities": _safe_json_loads(r[6], {}), "enabled": bool(r[7]), - "created_at": _unix_to_iso_local(r[8]), - "updated_at": _unix_to_iso_local(r[9]), + "default_auth_strategy": r[8], + "created_at": _unix_to_iso_local(r[9]), + "updated_at": _unix_to_iso_local(r[10]), } @@ -144,20 +173,30 @@ def insert_account( url: Optional[str], extra_json: Optional[str], now: int, + auth_strategy: str, + session_persistent: int = 1, + device_fingerprint: Optional[str] = None, ) -> int: + if auth_strategy not in ALL_AUTH_STRATEGIES: + raise ValueError(f"invalid auth_strategy: {auth_strategy!r}") + if session_persistent not in (0, 1): + raise ValueError("session_persistent must be 0 or 1") cur = conn.cursor() cur.execute( """ INSERT INTO accounts (platform_key, account_label, login_id, login_id_type, tenant_id, environment, role, provider_code, profile_dir, url, - status, session_status, extra_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?) + status, session_status, extra_json, + auth_strategy, session_persistent, device_fingerprint, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'unknown', ?, ?, ?, ?, ?, ?) """, ( platform_key, account_label, login_id, login_id_type, tenant_id, environment, role, provider_code, profile_dir, url, - extra_json or "{}", now, now, + extra_json or "{}", auth_strategy, session_persistent, device_fingerprint, + now, now, ), ) return int(cur.lastrowid) @@ -176,6 +215,7 @@ def get_account_by_id(account_id) -> Optional[dict]: profile_dir, url, status, session_status, last_used_at, last_login_check_at, last_error_code, last_error_message, extra_json, + auth_strategy, session_persistent, device_fingerprint, created_at, updated_at FROM accounts WHERE id = ? """, @@ -232,7 +272,9 @@ def find_accounts( "SELECT id, platform_key, account_label, login_id, login_id_type, " "tenant_id, environment, role, provider_code, profile_dir, url, " "status, session_status, last_used_at, last_login_check_at, " - "last_error_code, last_error_message, extra_json, created_at, updated_at " + "last_error_code, last_error_message, extra_json, " + "auth_strategy, session_persistent, device_fingerprint, " + "created_at, updated_at " f"FROM accounts {where} ORDER BY updated_at DESC, id DESC LIMIT ?" ) params.append(int(limit)) @@ -328,6 +370,42 @@ def update_account_error( conn.close() +def update_account( + conn, + account_id: int, + *, + auth_strategy: Optional[str] = None, + session_persistent: Optional[int] = None, + device_fingerprint: Any = _UNSET, +) -> None: + """Optional field updates for accounts v2 columns.""" + sets: list[str] = [] + params: list[Any] = [] + if auth_strategy is not None: + if auth_strategy not in ALL_AUTH_STRATEGIES: + raise ValueError(f"invalid auth_strategy: {auth_strategy!r}") + sets.append("auth_strategy = ?") + params.append(auth_strategy) + if session_persistent is not None: + if session_persistent not in (0, 1): + raise ValueError("session_persistent must be 0 or 1") + sets.append("session_persistent = ?") + params.append(session_persistent) + if device_fingerprint is not _UNSET: + sets.append("device_fingerprint = ?") + params.append(device_fingerprint) + if not sets: + return + sets.append("updated_at = ?") + params.append(_now_unix()) + params.append(int(account_id)) + cur = conn.cursor() + cur.execute( + f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?", + tuple(params), + ) + + def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool: cur = conn.cursor() cur.execute( diff --git a/scripts/db/connection.py b/scripts/db/connection.py index 326c905..f4bdbf5 100644 --- a/scripts/db/connection.py +++ b/scripts/db/connection.py @@ -14,7 +14,7 @@ import sqlite3 import time from typing import Optional -from db.schema import ALL_DDL, SCHEMA_VERSION +from db.schema import SCHEMA_VERSION, ensure_schema from util.constants import SKILL_SLUG from util.logging_config import get_skill_logger from util.runtime_paths import get_db_path @@ -67,13 +67,6 @@ def _check_schema_version(conn: sqlite3.Connection) -> int: return int(row[0]) if row else 0 -def _create_new_tables(conn: sqlite3.Connection) -> None: - """Execute ALL_DDL (all CREATE TABLE/INDEX IF NOT EXISTS).""" - cur = conn.cursor() - cur.executescript(ALL_DDL) - conn.commit() - - def init_db() -> None: """Idempotent: migrate if needed, create tables, set schema version.""" log = get_skill_logger() @@ -88,10 +81,14 @@ def init_db() -> None: backed_up = _backup_old_accounts(conn) if backed_up: log.info("schema_init_backup_done old_version=%s", old_version) - _create_new_tables(conn) - cur = conn.cursor() - cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") - conn.commit() + + ensure_schema(conn) + + cur = conn.cursor() + cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + conn.commit() + + if old_version < SCHEMA_VERSION: log.info( "schema_init_done version=%s migrated_from=%s", SCHEMA_VERSION, @@ -99,12 +96,6 @@ def init_db() -> None: ) else: log.info("schema_init_uptodate version=%s", SCHEMA_VERSION) - - # Ensure all tables exist even if schema version matches - _create_new_tables(conn) - cur = conn.cursor() - cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") - conn.commit() except Exception: log.exception("schema_init_failure") raise diff --git a/scripts/db/schema.py b/scripts/db/schema.py index 61405c5..114443a 100644 --- a/scripts/db/schema.py +++ b/scripts/db/schema.py @@ -6,11 +6,16 @@ Time stored as INTEGER Unix seconds (UTC). DDL comments are preserved in sqlite_master, visible in DBeaver / Navicat. Schema version management: - PRAGMA user_version = 2 (set in connection.py after migration). + - PRAGMA user_version = 2 (set in connection.py after ensure_schema). + - _schema_meta row schema_version=2 (written by migrate_v1_to_v2). """ +import sqlite3 +import sys import textwrap +from db.auth_strategy import AUTH_STRATEGY_PASSWORD_AUTO + # --------------------------------------------------------------------------- # platforms — Registry of all platforms (logistics, LLM, content, etc.) # --------------------------------------------------------------------------- @@ -24,6 +29,7 @@ PLATFORMS_TABLE_SQL = textwrap.dedent("""\ aliases_json TEXT NOT NULL DEFAULT '[]',-- 别名 JSON 数组,中英文别名 capabilities_json TEXT NOT NULL DEFAULT '{}', -- 能力声明,例如 {"web":true,"api_key":true,"rpa":true} enabled INTEGER NOT NULL DEFAULT 1,-- 是否启用 (1=yes 0=no) + default_auth_strategy TEXT, -- 新账号默认认证策略;可空,手工维护 created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC ); @@ -57,6 +63,9 @@ ACCOUNTS_TABLE_SQL = textwrap.dedent("""\ last_error_code TEXT, -- 最近一次错误代码 last_error_message TEXT, -- 最近一次错误消息 extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON + auth_strategy TEXT NOT NULL, -- 认证策略(见 db.auth_strategy) + session_persistent INTEGER NOT NULL DEFAULT 1, -- 是否需要 profile 持久化 (0/1) + device_fingerprint TEXT, -- device_bound 策略用;可空 created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC ); @@ -98,7 +107,6 @@ CREDENTIALS_TABLE_SQL = textwrap.dedent("""\ CREDENTIALS_INDEXES_SQL = textwrap.dedent("""\ CREATE INDEX IF NOT EXISTS idx_credentials_platform_type ON credentials(platform_key, credential_type); - CREATE INDEX IF NOT EXISTS idx_credentials_account ON credentials(account_id); CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials(status); """) @@ -128,6 +136,16 @@ ACCOUNT_LEASES_INDEXES_SQL = textwrap.dedent("""\ CREATE INDEX IF NOT EXISTS idx_leases_expires ON account_leases(expires_at); """) +# --------------------------------------------------------------------------- +# schema meta — logical schema version (v1→v2 migration marker) +# --------------------------------------------------------------------------- +SCHEMA_META_TABLE_SQL = textwrap.dedent("""\ + CREATE TABLE IF NOT EXISTS _schema_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); +""") + # --------------------------------------------------------------------------- # Full DDL assembly # --------------------------------------------------------------------------- @@ -140,6 +158,112 @@ ALL_DDL = ( + CREDENTIALS_INDEXES_SQL + ACCOUNT_LEASES_TABLE_SQL + ACCOUNT_LEASES_INDEXES_SQL + + SCHEMA_META_TABLE_SQL ) SCHEMA_VERSION = 2 + + +def column_exists(conn: sqlite3.Connection, table: str, col: str) -> bool: + cur = conn.cursor() + cur.execute(f"PRAGMA table_info({table})") + return any(row[1] == col for row in cur.fetchall()) + + +def _schema_meta_get(conn: sqlite3.Connection, key: str) -> str | None: + cur = conn.cursor() + cur.execute("SELECT value FROM _schema_meta WHERE key = ?", (key,)) + row = cur.fetchone() + return str(row[0]) if row and row[0] is not None else None + + +def _schema_meta_upsert(conn: sqlite3.Connection, key: str, value: str) -> None: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO _schema_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """, + (key, value), + ) + + +def migrate_v1_to_v2(conn: sqlite3.Connection) -> None: + """Apply ALTER steps + backfills once; idempotent via _schema_meta.schema_version.""" + from db.platform_defaults import PLATFORM_DEFAULT_AUTH_STRATEGY + + cur = conn.cursor() + if _schema_meta_get(conn, "schema_version") == "2": + return + + n_platform_updates = 0 + + # 1–2: platforms.default_auth_strategy + if not column_exists(conn, "platforms", "default_auth_strategy"): + cur.execute("ALTER TABLE platforms ADD COLUMN default_auth_strategy TEXT") + + for pk, strat in PLATFORM_DEFAULT_AUTH_STRATEGY.items(): + cur.execute( + "UPDATE platforms SET default_auth_strategy = ? WHERE platform_key = ?", + (strat, pk), + ) + n_platform_updates += cur.rowcount + + # 3: accounts columns (nullable auth_strategy during ALTER for legacy rows) + if not column_exists(conn, "accounts", "auth_strategy"): + cur.execute("ALTER TABLE accounts ADD COLUMN auth_strategy TEXT") + if not column_exists(conn, "accounts", "session_persistent"): + cur.execute( + "ALTER TABLE accounts ADD COLUMN session_persistent INTEGER NOT NULL DEFAULT 1" + ) + if not column_exists(conn, "accounts", "device_fingerprint"): + cur.execute("ALTER TABLE accounts ADD COLUMN device_fingerprint TEXT") + + # 4: fill accounts.auth_strategy + session_persistent + cur.execute( + """ + UPDATE accounts SET + auth_strategy = COALESCE( + (SELECT p.default_auth_strategy FROM platforms p + WHERE p.platform_key = accounts.platform_key), + ? + ), + session_persistent = COALESCE(session_persistent, 1) + WHERE auth_strategy IS NULL OR TRIM(auth_strategy) = '' + """, + (AUTH_STRATEGY_PASSWORD_AUTO,), + ) + m_accounts_updated = cur.rowcount + + # 5: credentials.account_id (v1 may omit it) + if not column_exists(conn, "credentials", "account_id"): + cur.execute("ALTER TABLE credentials ADD COLUMN account_id INTEGER") + + cur.execute( + "CREATE INDEX IF NOT EXISTS idx_credentials_account ON credentials(account_id)" + ) + + cur.execute("SELECT COUNT(*) FROM platforms WHERE default_auth_strategy IS NULL") + k_null_platforms = int(cur.fetchone()[0]) + + _schema_meta_upsert(conn, "schema_version", "2") + conn.commit() + + print( + f"[migration] account-manager v1→v2: updated {n_platform_updates} platforms, " + f"{m_accounts_updated} accounts.", + file=sys.stderr, + ) + print( + "[migration] platforms with default_auth_strategy=NULL: " + f"{k_null_platforms} (manually fix later)", + file=sys.stderr, + ) + + +def ensure_schema(conn: sqlite3.Connection) -> None: + """CREATE TABLE/INDEX IF NOT EXISTS, then run migrate_v1_to_v2.""" + cur = conn.cursor() + cur.executescript(ALL_DDL) + conn.commit() + migrate_v1_to_v2(conn) diff --git a/scripts/service/account_service.py b/scripts/service/account_service.py index 32388e0..835ea39 100644 --- a/scripts/service/account_service.py +++ b/scripts/service/account_service.py @@ -36,6 +36,7 @@ from db.accounts_repo import ( list_platforms_from_db, platform_exists, release_lease, + resolve_auth_strategy_for_platform, update_account_error, update_account_last_used, update_account_session_status, @@ -108,6 +109,7 @@ def _insert_secret_holder_account( url=url, extra_json="{}", now=now, + auth_strategy=resolve_auth_strategy_for_platform(conn, platform_key), ) @@ -261,6 +263,7 @@ def cmd_account_add_web( conn = get_conn() try: seed_platforms(conn) + resolved_auth = resolve_auth_strategy_for_platform(conn, key) new_id = insert_account( conn=conn, platform_key=key, @@ -275,6 +278,7 @@ def cmd_account_add_web( url=resolved_url, extra_json=resolved_extra, now=now, + auth_strategy=resolved_auth, ) conn.commit() except Exception: