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
|
||||
|
||||
Reference in New Issue
Block a user