Release v1.0.56: Credential & Browser Profile Manager
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 name;none 时为空
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user