293 lines
15 KiB
Python
293 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Database schema v2 — Credential & Browser Profile Manager.
|
||
|
||
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 ensure_schema).
|
||
- _schema_meta schema_version: "2" after migrate_v1_to_v2, "2.1" after migrate_v2_to_v2_1.
|
||
"""
|
||
|
||
import sqlite3
|
||
import sys
|
||
import textwrap
|
||
|
||
from db.auth_strategy import AUTH_STRATEGY_PASSWORD_AUTO
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# platforms — Registry of all platforms (logistics, LLM, content, etc.)
|
||
# ---------------------------------------------------------------------------
|
||
PLATFORMS_TABLE_SQL = textwrap.dedent("""\
|
||
CREATE TABLE IF NOT EXISTS platforms (
|
||
platform_key TEXT PRIMARY KEY, -- 平台唯一键,例如 maersk / deepseek / sohu
|
||
display_name TEXT NOT NULL, -- 展示名称,例如 Maersk / DeepSeek / 搜狐号
|
||
domain TEXT NOT NULL, -- 业务域:logistics / llm / content / ecommerce / generic
|
||
provider_code TEXT, -- 供应商代码,物流 profile 可复用;例如 maersk、cma_cgm
|
||
default_url TEXT, -- 默认入口 URL
|
||
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
|
||
);
|
||
""")
|
||
|
||
PLATFORMS_INDEXES_SQL = textwrap.dedent("""\
|
||
CREATE INDEX IF NOT EXISTS idx_platforms_domain ON platforms(domain);
|
||
CREATE INDEX IF NOT EXISTS idx_platforms_provider_code ON platforms(provider_code);
|
||
""")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# accounts — An identity that can be used (web account, RPA profile, API account)
|
||
# ---------------------------------------------------------------------------
|
||
ACCOUNTS_TABLE_SQL = textwrap.dedent("""\
|
||
CREATE TABLE IF NOT EXISTS accounts (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键(自增)
|
||
platform_key TEXT NOT NULL, -- 对应 platforms.platform_key
|
||
account_label TEXT NOT NULL, -- 人可读名称,例如 Maersk simulator booking
|
||
login_id TEXT, -- 登录标识:邮箱、用户名、手机号、客户代码等
|
||
login_id_type TEXT NOT NULL DEFAULT 'unknown', -- email / username / phone / customer_code / api_account / unknown
|
||
tenant_id TEXT, -- 租户/客户/项目标识,可空
|
||
environment TEXT NOT NULL DEFAULT 'production',-- simulator / staging / production / test
|
||
role TEXT NOT NULL DEFAULT 'default', -- booking / tracking / admin / sales / api / default
|
||
provider_code TEXT, -- 冗余供应商码,便于业务 skill 查询
|
||
profile_dir TEXT, -- Playwright 持久化浏览器用户数据目录;web/rpa 账号使用
|
||
url TEXT, -- 账号默认入口 URL;为空时用 platforms.default_url
|
||
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / needs_review
|
||
session_status TEXT NOT NULL DEFAULT 'unknown', -- unknown / likely_valid / needs_login / expired
|
||
last_used_at INTEGER, -- 最近被业务 skill pick/使用时间
|
||
last_login_check_at INTEGER, -- 最近一次登录状态检查时间,可空
|
||
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
|
||
);
|
||
""")
|
||
|
||
ACCOUNTS_INDEXES_SQL = textwrap.dedent("""\
|
||
CREATE INDEX IF NOT EXISTS idx_accounts_platform ON accounts(platform_key);
|
||
CREATE INDEX IF NOT EXISTS idx_accounts_pick_web ON accounts(platform_key, environment, tenant_id, role, status, updated_at);
|
||
CREATE INDEX IF NOT EXISTS idx_accounts_provider ON accounts(provider_code);
|
||
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status);
|
||
""")
|
||
|
||
# Note: Unique constraint (platform_key, login_id, tenant_id, environment, role)
|
||
# is intentionally NOT added here because SQLite allows multiple NULL login_id.
|
||
# Application-level checks are used when login_id is provided.
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# credentials — Secret-type credentials (API Key, Token, Password, etc.)
|
||
# Raw secret is NEVER stored in SQLite. Only metadata, secret_ref, secret_mask.
|
||
# ---------------------------------------------------------------------------
|
||
CREDENTIALS_TABLE_SQL = textwrap.dedent("""\
|
||
CREATE TABLE IF NOT EXISTS credentials (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 凭据主键(自增)
|
||
account_id INTEGER, -- 可空;关联 accounts.id;有些 API Key 可直接挂平台,也可挂具体账号
|
||
platform_key TEXT NOT NULL, -- 平台唯一键
|
||
credential_label TEXT NOT NULL, -- 人可读名称,例如 DeepSeek main API key
|
||
credential_type TEXT NOT NULL, -- api_key / password / bearer_token / oauth_client / refresh_token / browser_profile / note
|
||
secret_storage TEXT NOT NULL, -- none / env / windows_credential / local_encrypted
|
||
secret_ref TEXT, -- env 时为环境变量名;windows_credential 时为 target name;none/local_encrypted 时为空
|
||
secret_mask TEXT, -- 打码展示,例如 sk-****abcd;不能反推出原文
|
||
secret_ciphertext TEXT, -- secret_storage='local_encrypted' 时为 Fernet 密文;其它后端为 NULL
|
||
expires_at INTEGER, -- 过期时间,Unix 秒 UTC;可空
|
||
status TEXT NOT NULL DEFAULT 'active', -- active / disabled / expired / needs_rotation
|
||
last_used_at INTEGER, -- 最近使用时间
|
||
extra_json TEXT NOT NULL DEFAULT '{}', -- 扩展字段 JSON
|
||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||
);
|
||
""")
|
||
|
||
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_status ON credentials(status);
|
||
""")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# account_leases — RPA / profile concurrency locks
|
||
# Prevents multiple tasks from opening the same profile_dir simultaneously.
|
||
# ---------------------------------------------------------------------------
|
||
ACCOUNT_LEASES_TABLE_SQL = textwrap.dedent("""\
|
||
CREATE TABLE IF NOT EXISTS account_leases (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 租约主键(自增)
|
||
account_id INTEGER NOT NULL, -- 关联 accounts.id
|
||
lease_token TEXT NOT NULL UNIQUE, -- 随机 token,用于 release / heartbeat
|
||
holder TEXT NOT NULL, -- 持有者,例如 monitor-competitor-rates 或 run id
|
||
purpose TEXT, -- 用途,例如 maersk_sim_rpa / booking_collect
|
||
expires_at INTEGER NOT NULL, -- 租约过期时间,Unix 秒 UTC
|
||
heartbeat_at INTEGER, -- 最近一次心跳时间
|
||
released_at INTEGER, -- 主动释放时间
|
||
status TEXT NOT NULL DEFAULT 'active', -- active / released / expired
|
||
created_at INTEGER NOT NULL, -- 记录创建时间,Unix 秒 UTC
|
||
updated_at INTEGER NOT NULL -- 记录最后更新时间,Unix 秒 UTC
|
||
);
|
||
""")
|
||
|
||
ACCOUNT_LEASES_INDEXES_SQL = textwrap.dedent("""\
|
||
CREATE INDEX IF NOT EXISTS idx_leases_account_status ON account_leases(account_id, status);
|
||
CREATE INDEX IF NOT EXISTS idx_leases_token ON account_leases(lease_token);
|
||
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
|
||
# ---------------------------------------------------------------------------
|
||
ALL_DDL = (
|
||
PLATFORMS_TABLE_SQL
|
||
+ PLATFORMS_INDEXES_SQL
|
||
+ ACCOUNTS_TABLE_SQL
|
||
+ ACCOUNTS_INDEXES_SQL
|
||
+ CREDENTIALS_TABLE_SQL
|
||
+ 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()
|
||
sv = _schema_meta_get(conn, "schema_version")
|
||
if sv == "2" or (sv and sv.startswith("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 migrate_v2_to_v2_1(conn: sqlite3.Connection) -> None:
|
||
"""v2 → v2.1: credentials 表加 secret_ciphertext 列。幂等。"""
|
||
cur = conn.cursor()
|
||
if _schema_meta_get(conn, "schema_version") == "2.1":
|
||
return
|
||
|
||
if column_exists(conn, "credentials", "secret_ciphertext"):
|
||
_schema_meta_upsert(conn, "schema_version", "2.1")
|
||
conn.commit()
|
||
return
|
||
|
||
cur.execute("ALTER TABLE credentials ADD COLUMN secret_ciphertext TEXT")
|
||
_schema_meta_upsert(conn, "schema_version", "2.1")
|
||
conn.commit()
|
||
print(
|
||
"[migration] account-manager v2→v2.1: credentials.secret_ciphertext column added.",
|
||
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)
|
||
migrate_v2_to_v2_1(conn)
|