Release v1.0.56: Credential & Browser Profile Manager
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
2026-05-05 18:51:00 +08:00
parent 6b6d2969c3
commit 91057fa3b7
29 changed files with 4582 additions and 900 deletions

View File

@@ -1,19 +1,29 @@
"""Accounts table: CRUD and queries (no stdout)."""
# -*- coding: utf-8 -*-
"""
Accounts, Credentials, Leases, and Platforms CRUD (no stdout).
All functions operate on a connection (passed or opened locally).
Time fields are INTEGER Unix seconds (UTC).
"""
import json
import time
from datetime import datetime
from typing import Any, Optional
from db.connection import get_conn, init_db
from util.platforms import PLATFORM_URLS, _PLATFORM_NAME_CN
from util.logging_config import get_skill_logger
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _now_unix() -> int:
return int(time.time())
def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
"""对外 JSON本地时区 ISO8601 字符串,便于人读。"""
"""Convert Unix timestamp to local ISO8601 string, or None."""
if ts is None:
return None
try:
@@ -22,197 +32,636 @@ def _unix_to_iso_local(ts: Optional[int]) -> Optional[str]:
return None
def normalize_account_id(account_id):
if account_id is None:
return None
s = str(account_id).strip()
if s.isdigit():
return int(s)
return s
def _acc_row_to_dict(row) -> dict[str, Any]:
"""Convert an accounts table row tuple to a dict matching the v2 schema."""
return {
"id": row[0],
"platform_key": row[1],
"account_label": row[2],
"login_id": row[3] or "",
"login_id_type": row[4],
"tenant_id": row[5] or "",
"environment": row[6],
"role": row[7],
"provider_code": row[8] or "",
"profile_dir": (row[9] or "").strip(),
"url": row[10] or "",
"status": row[11],
"session_status": row[12],
"last_used_at": _unix_to_iso_local(row[13]),
"last_login_check_at": _unix_to_iso_local(row[14]),
"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]),
}
def default_name_for_platform(platform: str, index: int) -> str:
cn = _PLATFORM_NAME_CN.get(platform)
if cn:
return f"{cn}{index}"
return f"{platform}_{index}"
# ============================================================================
# Platforms
# ============================================================================
def get_account_by_id(account_id):
init_db()
aid = normalize_account_id(account_id)
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id, name, platform, phone, profile_dir, url, extra_json,
created_at, updated_at
FROM accounts WHERE id = ?
""",
(aid,),
)
row = cur.fetchone()
if not row:
return None
acc: dict[str, Any] = {
"id": row[0],
"name": row[1],
"platform": row[2],
"phone": row[3] or "",
"profile_dir": (row[4] or "").strip(),
"url": row[5] or PLATFORM_URLS.get(row[2], ""),
"created_at": _unix_to_iso_local(row[7]),
"updated_at": _unix_to_iso_local(row[8]),
}
if row[6]:
try:
extra = json.loads(row[6])
if isinstance(extra, dict):
acc.update(extra)
except Exception:
pass
return acc
finally:
conn.close()
def fetch_list_rows(platform_key: str, limit: int):
"""platform_key 'all' or platform slug; returns list of row tuples (full row)."""
conn = get_conn()
try:
cur = conn.cursor()
sql = (
"SELECT id, name, platform, phone, profile_dir, url, "
"extra_json, created_at, updated_at FROM accounts "
)
if platform_key == "all":
cur.execute(sql + "ORDER BY created_at DESC, id DESC LIMIT ?", (int(limit),))
else:
cur.execute(
sql + "WHERE platform = ? ORDER BY created_at DESC, id DESC LIMIT ?",
(platform_key, int(limit)),
)
return cur.fetchall()
finally:
conn.close()
def fetch_ids_for_list_json(platform_key: str, lim: int):
conn = get_conn()
try:
cur = conn.cursor()
if platform_key == "all":
cur.execute(
"SELECT id FROM accounts ORDER BY updated_at DESC, id DESC LIMIT ?",
(lim,),
)
else:
cur.execute(
"SELECT id FROM accounts WHERE platform = ? "
"ORDER BY updated_at DESC, id DESC LIMIT ?",
(platform_key, lim),
)
return [r[0] for r in cur.fetchall()]
finally:
conn.close()
def fetch_pick_web_candidate_id(platform_key: str) -> Optional[int]:
"""该平台一条账号:按 updated_at、created_at 倒序,与是否登录无关。"""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id FROM accounts
WHERE platform = ?
ORDER BY updated_at DESC, created_at DESC, id DESC
LIMIT 1
""",
(platform_key,),
)
row = cur.fetchone()
return int(row[0]) if row else None
finally:
conn.close()
def has_duplicate_phone_conn(conn, platform_key: str, phone_store: str) -> bool:
def platform_exists(conn, platform_key: str) -> bool:
cur = conn.cursor()
cur.execute(
"SELECT id FROM accounts WHERE platform = ? AND phone = ?",
(platform_key, phone_store),
)
cur.execute("SELECT 1 FROM platforms WHERE platform_key = ?", (platform_key,))
return cur.fetchone() is not None
def count_platform_accounts_conn(conn, platform_key: str) -> int:
def list_platforms_from_db(conn, domain: Optional[str] = None) -> list[dict]:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM accounts WHERE platform = ?", (platform_key,))
return int(cur.fetchone()[0])
if domain:
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",
(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"
)
rows = cur.fetchall()
result = []
for r in rows:
result.append({
"platform_key": r[0],
"display_name": r[1],
"domain": r[2],
"provider_code": r[3] or "",
"default_url": r[4] or "",
"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]),
})
return result
def insert_account_row(
def get_platform_from_db(conn, platform_key: str) -> Optional[dict]:
cur = conn.cursor()
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 = ?",
(platform_key,),
)
r = cur.fetchone()
if not r:
return None
return {
"platform_key": r[0],
"display_name": r[1],
"domain": r[2],
"provider_code": r[3] or "",
"default_url": r[4] or "",
"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]),
}
# ============================================================================
# Accounts — CRUD
# ============================================================================
def insert_account(
conn,
name: str,
platform_key: str,
phone_store: str,
profile_dir: str,
url: str,
account_label: str,
login_id: Optional[str],
login_id_type: str,
tenant_id: Optional[str],
environment: str,
role: str,
provider_code: Optional[str],
profile_dir: Optional[str],
url: Optional[str],
extra_json: Optional[str],
now: int,
) -> int:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO accounts (name, platform, phone, profile_dir, url, extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
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', ?, ?, ?)
""",
(name, platform_key, phone_store, profile_dir, url, now, now),
(
platform_key, account_label, login_id, login_id_type, tenant_id,
environment, role, provider_code, profile_dir, url,
extra_json or "{}", now, now,
),
)
return int(cur.lastrowid)
def fetch_delete_row_by_id_conn(conn, aid: int):
def get_account_by_id(account_id) -> Optional[dict]:
"""Get a single account by id. Returns dict or None."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
cur.execute(
"""
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
FROM accounts WHERE id = ?
""",
(int(account_id),),
)
row = cur.fetchone()
if not row:
return None
return _acc_row_to_dict(row)
finally:
conn.close()
def find_accounts(
platform_key: Optional[str] = None,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
status: Optional[str] = None,
provider_code: Optional[str] = None,
limit: int = 200,
) -> list[dict]:
"""Find accounts by optional filters. Returns list of dicts."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = []
params = []
if platform_key:
conditions.append("platform_key = ?")
params.append(platform_key)
if environment:
conditions.append("environment = ?")
params.append(environment)
if tenant_id:
conditions.append("tenant_id = ?")
params.append(tenant_id)
if role:
conditions.append("role = ?")
params.append(role)
if status:
conditions.append("status = ?")
params.append(status)
if provider_code:
conditions.append("provider_code = ?")
params.append(provider_code)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
sql = (
"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 "
f"FROM accounts {where} ORDER BY updated_at DESC, id DESC LIMIT ?"
)
params.append(int(limit))
cur.execute(sql, params)
return [_acc_row_to_dict(row) for row in cur.fetchall()]
finally:
conn.close()
def find_account_ids(
platform_key: str,
environment: Optional[str] = None,
tenant_id: Optional[str] = None,
role: Optional[str] = None,
) -> list[int]:
"""Return account IDs matching filters, excluding those with active leases."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = ["a.status = 'active'", "a.platform_key = ?"]
params = [platform_key]
if environment:
conditions.append("a.environment = ?")
params.append(environment)
if tenant_id:
conditions.append("a.tenant_id = ?")
params.append(tenant_id)
if role:
conditions.append("a.role = ?")
params.append(role)
# Exclude accounts with active non-expired leases
now = _now_unix()
conditions.append(
"a.id NOT IN (SELECT account_id FROM account_leases "
"WHERE status = 'active' AND expires_at > ?)"
)
params.append(now)
where = " AND ".join(conditions)
cur.execute(
f"SELECT a.id FROM accounts a WHERE {where} "
"ORDER BY CASE WHEN a.session_status = 'expired' THEN 1 ELSE 0 END ASC, "
"a.last_used_at IS NULL DESC, a.updated_at DESC, a.id DESC",
params,
)
return [int(row[0]) for row in cur.fetchall()]
finally:
conn.close()
def update_account_last_used(account_id: int) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE accounts SET last_used_at = ?, updated_at = ? WHERE id = ?",
(_now_unix(), _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def update_account_session_status(account_id: int, session_status: str) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE accounts SET session_status = ?, last_login_check_at = ?, updated_at = ? WHERE id = ?",
(session_status, _now_unix(), _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def update_account_error(
account_id: int,
error_code: str,
error_message: str,
) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE accounts SET last_error_code = ?, last_error_message = ?, updated_at = ? WHERE id = ?",
(error_code, error_message, _now_unix(), account_id),
)
conn.commit()
finally:
conn.close()
def account_exists_with_login_id(conn, platform_key: str, login_id: str, environment: str, role: str) -> bool:
cur = conn.cursor()
cur.execute(
"SELECT id, name, platform, phone, profile_dir FROM accounts WHERE id = ?",
(aid,),
"SELECT id FROM accounts WHERE platform_key = ? AND login_id = ? AND environment = ? AND role = ?",
(platform_key, login_id, environment, role),
)
return cur.fetchone()
return cur.fetchone() is not None
def delete_account_by_id_conn(conn, rid: int) -> None:
cur = conn.cursor()
cur.execute("DELETE FROM accounts WHERE id = ?", (rid,))
# ============================================================================
# Credentials
# ============================================================================
def fetch_ids_profile_for_platform_conn(conn, platform_key: str):
def insert_credential(
conn,
account_id: Optional[int],
platform_key: str,
credential_label: str,
credential_type: str,
secret_storage: str,
secret_ref: Optional[str],
secret_mask: Optional[str],
expires_at: Optional[int],
extra_json: Optional[str],
now: int,
) -> int:
cur = conn.cursor()
cur.execute(
"SELECT id, profile_dir FROM accounts WHERE platform = ?",
(platform_key,),
"""
INSERT INTO credentials
(account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, expires_at, status,
extra_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
""",
(
account_id, platform_key, credential_label, credential_type,
secret_storage, secret_ref, secret_mask, expires_at,
extra_json or "{}", now, now,
),
)
return cur.fetchall()
return int(cur.lastrowid)
def delete_accounts_by_platform_conn(conn, platform_key: str) -> None:
cur = conn.cursor()
cur.execute("DELETE FROM accounts WHERE platform = ?", (platform_key,))
def get_credential_by_id(credential_id: int) -> Optional[dict]:
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
cur.execute(
"SELECT id, account_id, platform_key, credential_label, credential_type, "
"secret_storage, secret_ref, secret_mask, expires_at, status, last_used_at, "
"extra_json, created_at, updated_at FROM credentials WHERE id = ?",
(credential_id,),
)
row = cur.fetchone()
if not row:
return None
return {
"id": row[0],
"account_id": row[1],
"platform_key": row[2],
"credential_label": row[3],
"credential_type": row[4],
"secret_storage": row[5],
"secret_ref": row[6] or "",
"secret_mask": row[7] or "",
"expires_at": _unix_to_iso_local(row[8]),
"status": row[9],
"last_used_at": _unix_to_iso_local(row[10]),
"created_at": _unix_to_iso_local(row[12]),
"updated_at": _unix_to_iso_local(row[13]),
}
finally:
conn.close()
def fetch_row_platform_phone_conn(conn, platform_key: str, phone_norm: str):
cur = conn.cursor()
cur.execute(
"SELECT id, name, profile_dir FROM accounts WHERE platform = ? AND phone = ?",
(platform_key, phone_norm),
)
return cur.fetchone()
def find_credentials(
platform_key: str,
credential_type: Optional[str] = None,
environment: Optional[str] = None,
role: Optional[str] = None,
status: str = "active",
limit: int = 50,
) -> list[dict]:
"""Find credentials, optionally joining accounts for environment/role filter."""
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
conditions = ["c.status = ?"]
params = [status]
conditions.append("c.platform_key = ?")
params.append(platform_key)
if credential_type:
conditions.append("c.credential_type = ?")
params.append(credential_type)
# If environment/role filters provided, join accounts table
if environment or role:
conditions.append("c.account_id = a.id")
if environment:
conditions.append("a.environment = ?")
params.append(environment)
if role:
conditions.append("a.role = ?")
params.append(role)
where = " AND ".join(conditions)
cur.execute(
f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, "
f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, "
f"c.expires_at, c.status, c.last_used_at, c.extra_json, "
f"c.created_at, c.updated_at "
f"FROM credentials c, accounts a WHERE {where} "
f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC "
f"LIMIT ?",
params + [int(limit)],
)
else:
where = " AND ".join(conditions)
cur.execute(
f"SELECT c.id, c.account_id, c.platform_key, c.credential_label, "
f"c.credential_type, c.secret_storage, c.secret_ref, c.secret_mask, "
f"c.expires_at, c.status, c.last_used_at, c.extra_json, "
f"c.created_at, c.updated_at "
f"FROM credentials c WHERE {where} "
f"ORDER BY c.last_used_at IS NULL DESC, c.updated_at DESC, c.id DESC "
f"LIMIT ?",
params + [int(limit)],
)
rows = cur.fetchall()
result = []
for row in rows:
result.append({
"id": row[0],
"account_id": row[1],
"platform_key": row[2],
"credential_label": row[3],
"credential_type": row[4],
"secret_storage": row[5],
"secret_ref": row[6] or "",
"secret_mask": row[7] or "",
"expires_at": _unix_to_iso_local(row[8]),
"status": row[9],
"last_used_at": _unix_to_iso_local(row[10]),
"created_at": _unix_to_iso_local(row[12]),
"updated_at": _unix_to_iso_local(row[13]),
})
return result
finally:
conn.close()
def delete_account_platform_phone_conn(conn, platform_key: str, phone_norm: str) -> None:
cur = conn.cursor()
cur.execute(
"DELETE FROM accounts WHERE platform = ? AND phone = ?",
(platform_key, phone_norm),
)
def update_credential_last_used(credential_id: int) -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"UPDATE credentials SET last_used_at = ?, updated_at = ? WHERE id = ?",
(_now_unix(), _now_unix(), credential_id),
)
conn.commit()
finally:
conn.close()
# ============================================================================
# Leases
# ============================================================================
def acquire_lease(
account_id: int,
lease_token: str,
holder: str,
purpose: Optional[str],
ttl_sec: int,
) -> dict:
"""
Acquire a lease for an account. Expires active leases first.
Returns the lease record dict on success.
Raises ValueError if a non-expired active lease already exists.
"""
log = get_skill_logger()
log.info("lease_acquire_attempt account_id=%s holder=%s purpose=%s ttl=%s", account_id, holder, purpose, ttl_sec)
conn = get_conn()
try:
conn.row_factory = None
cur = conn.cursor()
now = _now_unix()
# Mark any expired active leases as expired
cur.execute(
"UPDATE account_leases SET status = 'expired', updated_at = ? "
"WHERE account_id = ? AND status = 'active' AND expires_at <= ?",
(now, account_id, now),
)
if cur.rowcount > 0:
log.info("lease_expired_cleanup account_id=%s count=%s", account_id, cur.rowcount)
# Check for existing active non-expired lease
cur.execute(
"SELECT id, lease_token, holder FROM account_leases "
"WHERE account_id = ? AND status = 'active' AND expires_at > ?",
(account_id, now),
)
existing = cur.fetchone()
if existing:
log.warning(
"lease_conflict account_id=%s existing_holder=%s existing_token_prefix=%s",
account_id, existing[2], existing[1][:8],
)
raise ValueError(
f"Account {account_id} already has an active lease "
f"(holder={existing[2]}, token_prefix={existing[1][:8]}) (LEASE_CONFLICT)"
)
expires_at = now + max(1, int(ttl_sec))
cur.execute(
"""
INSERT INTO account_leases
(account_id, lease_token, holder, purpose, expires_at, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
""",
(account_id, lease_token, holder, purpose, expires_at, now, now),
)
conn.commit()
lease_id = int(cur.lastrowid)
log.info("lease_acquire_success account_id=%s lease_id=%s token_prefix=%s", account_id, lease_id, lease_token[:8])
return {
"id": lease_id,
"account_id": account_id,
"lease_token": lease_token,
"holder": holder,
"purpose": purpose,
"expires_at": _unix_to_iso_local(expires_at),
"status": "active",
"created_at": _unix_to_iso_local(now),
}
finally:
conn.close()
def release_lease(lease_token: str) -> bool:
"""Release a lease by token. Returns True if found and released."""
log = get_skill_logger()
log.info("lease_release_attempt token_prefix=%s", lease_token[:8])
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"UPDATE account_leases SET status = 'released', released_at = ?, updated_at = ? "
"WHERE lease_token = ? AND status = 'active'",
(now, now, lease_token),
)
conn.commit()
if cur.rowcount == 0:
log.warning("lease_release_not_found token_prefix=%s", lease_token[:8])
return False
log.info("lease_release_success token_prefix=%s", lease_token[:8])
return True
finally:
conn.close()
def list_active_leases() -> list[dict]:
"""List all active (non-expired, non-released) leases."""
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"SELECT id, account_id, lease_token, holder, purpose, expires_at, "
"heartbeat_at, released_at, status, created_at, updated_at "
"FROM account_leases WHERE status = 'active' AND expires_at > ? "
"ORDER BY created_at DESC",
(now,),
)
rows = cur.fetchall()
result = []
for r in rows:
result.append({
"id": r[0],
"account_id": r[1],
"lease_token": r[2],
"holder": r[3],
"purpose": r[4] or "",
"expires_at": _unix_to_iso_local(r[5]),
"heartbeat_at": _unix_to_iso_local(r[6]),
"released_at": _unix_to_iso_local(r[7]),
"status": r[8],
"created_at": _unix_to_iso_local(r[9]),
"updated_at": _unix_to_iso_local(r[10]),
})
return result
finally:
conn.close()
def cleanup_expired_leases() -> int:
"""Mark all expired active leases as expired. Returns count cleaned."""
log = get_skill_logger()
conn = get_conn()
try:
cur = conn.cursor()
now = _now_unix()
cur.execute(
"UPDATE account_leases SET status = 'expired', updated_at = ? "
"WHERE status = 'active' AND expires_at <= ?",
(now, now),
)
count = cur.rowcount
conn.commit()
if count > 0:
log.info("lease_cleanup_expired count=%s", count)
return count
finally:
conn.close()
# ============================================================================
# Internal helpers
# ============================================================================
def _safe_json_loads(val: Any, default: Any = None) -> Any:
if val is None:
return default
try:
return json.loads(val) if isinstance(val, str) else val
except (json.JSONDecodeError, TypeError):
return default

View File

@@ -1,21 +1,124 @@
"""SQLite connection and schema bootstrap."""
import sqlite3
# -*- coding: utf-8 -*-
"""
SQLite connection with schema migration (v1 -> v2).
from db.schema import ACCOUNTS_TABLE_SQL
Migration strategy:
- Check PRAGMA user_version.
- If version < 2 and old 'accounts' table exists, back it up as
'accounts_legacy_backup_<timestamp>' before creating new tables.
- New tables are created with CREATE TABLE IF NOT EXISTS.
- Logs all migration steps.
"""
import os
import sqlite3
import time
from typing import Optional
from db.schema import ALL_DDL, SCHEMA_VERSION
from util.constants import SKILL_SLUG
from util.logging_config import get_skill_logger
from util.runtime_paths import get_db_path
def get_conn():
return sqlite3.connect(get_db_path())
def get_conn() -> sqlite3.Connection:
"""Open a connection to the skill database (autocommit off)."""
conn = sqlite3.connect(get_db_path())
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=OFF")
return conn
def init_db():
def _backup_old_accounts(conn: sqlite3.Connection) -> bool:
"""Rename old 'accounts' table to 'accounts_legacy_backup_<ts>' if it exists with old schema.
Returns True if backup was performed."""
cur = conn.cursor()
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
)
if not cur.fetchone():
return False
# Check if it's the old schema (has 'phone' column but no 'platform_key')
# We just check the column count — old schema had fewer columns.
cur.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cur.fetchall()]
# Old schema: id, name, platform, phone, profile_dir, url, extra_json, created_at, updated_at = 9
# New schema: many more. If it looks like old, back it up.
has_legacy_structure = "phone" in columns and "platform_key" not in columns
if not has_legacy_structure:
return False
ts = int(time.time())
backup_name = f"accounts_legacy_backup_{ts}"
cur.execute(f"ALTER TABLE accounts RENAME TO {backup_name}")
conn.commit()
logger = get_skill_logger()
logger.info(
"schema_migration_backup old_table=accounts backup_table=%s",
backup_name,
)
return True
def _check_schema_version(conn: sqlite3.Connection) -> int:
cur = conn.cursor()
cur.execute("PRAGMA user_version")
row = cur.fetchone()
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()
log.info("schema_init_start")
conn = get_conn()
try:
old_version = _check_schema_version(conn)
log.info("schema_current_version=%s", old_version)
if old_version < SCHEMA_VERSION:
if old_version < 2:
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()
log.info(
"schema_init_done version=%s migrated_from=%s",
SCHEMA_VERSION,
old_version,
)
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
finally:
conn.close()
def get_db_version() -> int:
"""Return current PRAGMA user_version without initializing."""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
if not cur.fetchone():
cur.executescript(ACCOUNTS_TABLE_SQL)
conn.commit()
cur.execute("PRAGMA user_version")
row = cur.fetchone()
return int(row[0]) if row else 0
finally:
conn.close()

View File

@@ -1,16 +1,145 @@
# SQLite 无独立 DATETIME 类型: 时间统一存 INTEGER Unix 秒 (UTC), 查询/JSON 再转 ISO8601.
# 下列 DDL 含注释, 会原样写入 sqlite_master, 便于 Navicat / DBeaver 等查看建表语句.
ACCOUNTS_TABLE_SQL = """
/* 多平台账号表: 与本地 Chromium/Edge 用户数据目录 (profile) 绑定 */
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 账号主键 (自增)
name TEXT NOT NULL, -- 展示名称, 如「搜狐1号」
platform TEXT NOT NULL, -- 平台标识, 与内置 PLATFORMS 键一致, 如 sohu
phone TEXT, -- 可选绑定手机号
profile_dir TEXT, -- Playwright 用户数据目录 (绝对路径); 默认可读结构 profiles/<平台展示名>/<手机号>/
url TEXT, -- 平台入口或登录页 URL
extra_json TEXT, -- 扩展字段 JSON
created_at INTEGER NOT NULL, -- 记录创建时间, Unix 秒 UTC
updated_at INTEGER NOT NULL -- 记录最后更新时间, Unix 秒 UTC
);
# -*- 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 migration).
"""
import textwrap
# ---------------------------------------------------------------------------
# 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)
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
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
secret_ref TEXT, -- env 时为环境变量名windows_credential 时为 target namenone 时为空
secret_mask TEXT, -- 打码展示,例如 sk-****abcd不能反推出原文
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_account ON credentials(account_id);
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);
""")
# ---------------------------------------------------------------------------
# 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_VERSION = 2