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,15 +1,41 @@
|
||||
"""CLI 入口:用法说明与 argv 分发。"""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CLI entry point: argv dispatch for account-manager v2.
|
||||
|
||||
Subcommand tree:
|
||||
platform list/get
|
||||
account add-web|add-secret|get|list|pick-web|mark-session|mark-error|mark-used
|
||||
credential pick|resolve
|
||||
lease release|list|cleanup
|
||||
(legacy) add|list|get|list-json|pick-web|open|delete
|
||||
|
||||
Machine commands output single-line JSON where appropriate.
|
||||
"""
|
||||
import sys
|
||||
|
||||
from service.account_service import (
|
||||
cmd_add,
|
||||
cmd_account_add_secret,
|
||||
cmd_account_add_web,
|
||||
cmd_account_get,
|
||||
cmd_account_list,
|
||||
cmd_account_mark_error,
|
||||
cmd_account_mark_session,
|
||||
cmd_account_mark_used,
|
||||
cmd_account_pick_web,
|
||||
cmd_credential_pick,
|
||||
cmd_credential_resolve,
|
||||
cmd_delete_by_id,
|
||||
cmd_delete_by_platform,
|
||||
cmd_delete_by_platform_phone,
|
||||
cmd_get,
|
||||
cmd_lease_cleanup,
|
||||
cmd_lease_list,
|
||||
cmd_lease_release,
|
||||
cmd_list,
|
||||
cmd_list_json,
|
||||
cmd_pick_web,
|
||||
cmd_platform_get,
|
||||
cmd_platform_list,
|
||||
)
|
||||
from service.browser_service import cmd_open
|
||||
from util.constants import LOG_LOGGER_NAME, SKILL_SLUG
|
||||
@@ -17,97 +43,223 @@ from util.logging_config import get_skill_logger, setup_skill_logging
|
||||
from util.platforms import _platform_list_cn_for_help, resolve_platform_key
|
||||
from util.runtime_paths import _maybe_print_paths_debug_cli
|
||||
|
||||
# =========================================================================
|
||||
# Help text
|
||||
# =========================================================================
|
||||
|
||||
def _cli_print_full_usage() -> None:
|
||||
"""总览(未带子命令或需要完整说明时)。"""
|
||||
print("用法概览(将 main.py 换为你的路径):")
|
||||
print(" python main.py add <平台中文名> <手机号>")
|
||||
print(" python main.py list [平台|all|全部]")
|
||||
print(" python main.py pick-web <平台> # 跨技能用:按最近更新选一条账号,一行 JSON")
|
||||
print(" python main.py list-json <平台|all> [--limit N] # 跨技能用:一行 JSON 数组,元素同 get")
|
||||
print(" python main.py get <id>")
|
||||
print(" python main.py open <id>")
|
||||
print(" python main.py delete id <id>")
|
||||
print(" python main.py delete platform <平台>")
|
||||
print(" python main.py delete platform <平台> <手机号>")
|
||||
print(" python main.py <平台> # 等价于只列该平台")
|
||||
print(" python main.py <平台> list # 同上")
|
||||
print()
|
||||
print("支持的平台(可用下列中文称呼,亦支持英文键如 sohu):")
|
||||
print(" " + _platform_list_cn_for_help())
|
||||
_USAGE = """account-manager v2 — Credential & Browser Profile Manager
|
||||
|
||||
平台管理:
|
||||
platform list [--domain logistics|llm|content]
|
||||
platform get <platform>
|
||||
|
||||
账号管理:
|
||||
account add-web --platform <p> --login-id <id> --login-id-type <type>
|
||||
[--label <label>] [--tenant-id <t>] [--environment <env>]
|
||||
[--role <role>] [--provider-code <code>] [--url <url>]
|
||||
[--extra-json <json>] [--profile-dir <path>]
|
||||
account add-secret --platform <p> --credential-type <type>
|
||||
--secret-storage env|windows_credential|none
|
||||
[--secret-ref <env_var>] [--secret-stdin]
|
||||
[--label <label>] [--account-id <id>]
|
||||
[--environment <env>] [--role <role>]
|
||||
account get <id> [--with-credentials]
|
||||
account list [--platform <p>] [--environment <env>]
|
||||
[--tenant-id <t>] [--role <role>] [--limit N]
|
||||
account pick-web --platform <p> [--environment <env>] [--tenant-id <t>]
|
||||
[--role <role>] [--lease] [--ttl-sec 900]
|
||||
[--holder <holder>] [--purpose <purpose>]
|
||||
account mark-session <id> --session-status <status>
|
||||
account mark-error <id> --code <code> --message <msg>
|
||||
account mark-used <id>
|
||||
account open <id>
|
||||
|
||||
凭据管理:
|
||||
credential pick --platform <p> [--type <type>] [--environment <env>]
|
||||
[--role <role>] [--reveal]
|
||||
credential resolve --id <credential_id> [--reveal]
|
||||
|
||||
租约管理:
|
||||
lease release <lease_token>
|
||||
lease list [--active]
|
||||
lease cleanup
|
||||
|
||||
浏览器:
|
||||
account open <id>
|
||||
open <id> # 兼容别名
|
||||
|
||||
兼容入口(旧版):
|
||||
get <id>
|
||||
list-json <platform|all> [--limit N]
|
||||
pick-web <platform>
|
||||
list [platform|all]
|
||||
add <platform> <phone>
|
||||
delete id|platform <...>
|
||||
"""
|
||||
|
||||
|
||||
def _cli_fail_add() -> None:
|
||||
print("ERROR:CLI_ADD_MISSING_ARGS")
|
||||
print()
|
||||
print("【add 添加账号】参数不足。")
|
||||
print(" 必填:平台名称(中文即可,如 搜狐号、知乎、微信公众号)")
|
||||
print(" 必填:中国大陆 11 位手机号(1 开头,第二位 3~9),同平台下不可重复")
|
||||
print()
|
||||
print("示例:")
|
||||
print(" python main.py add 搜狐号 13800138000")
|
||||
print(" python main.py add 知乎 13900001111")
|
||||
print()
|
||||
print("支持的平台:" + _platform_list_cn_for_help())
|
||||
# =========================================================================
|
||||
# Argument parsing helpers
|
||||
# =========================================================================
|
||||
|
||||
def _get_opt(argv: list[str], flag: str, default=None):
|
||||
"""Get value of --flag from argv, returning default if not found."""
|
||||
if flag in argv:
|
||||
idx = argv.index(flag)
|
||||
if idx + 1 < len(argv):
|
||||
return argv[idx + 1]
|
||||
return default
|
||||
|
||||
|
||||
def _cli_fail_need_account_id(subcmd: str) -> None:
|
||||
print(f"ERROR:CLI_{subcmd.upper()}_MISSING_ID")
|
||||
print()
|
||||
print(f"【{subcmd}】缺少必填参数:账号 id(数据库自增整数,可先 list 查看)。")
|
||||
print("示例:")
|
||||
print(f" python main.py {subcmd} 1")
|
||||
def _has_flag(argv: list[str], flag: str) -> bool:
|
||||
return flag in argv
|
||||
|
||||
|
||||
def _cli_fail_delete() -> None:
|
||||
print("ERROR:CLI_DELETE_MISSING_ARGS")
|
||||
print()
|
||||
print("【delete 删除账号】参数不足。支持三种方式:")
|
||||
print(" 按 id:python main.py delete id <id>")
|
||||
print(" 按平台(删该平台下全部):python main.py delete platform <平台中文名或英文键>")
|
||||
print(" 按平台+手机号:python main.py delete platform <平台> <手机号>")
|
||||
print()
|
||||
print("示例:")
|
||||
print(" python main.py delete id 3")
|
||||
print(" python main.py delete platform 搜狐号")
|
||||
print(" python main.py delete platform 头条号 18925203701")
|
||||
print()
|
||||
print("说明:会同时删除数据库记录与 profile 用户数据目录(若存在)。")
|
||||
def _get_int_opt(argv: list[str], flag: str, default: int) -> int:
|
||||
v = _get_opt(argv, flag)
|
||||
if v is not None:
|
||||
try:
|
||||
return int(v)
|
||||
except ValueError:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Main dispatch
|
||||
# =========================================================================
|
||||
|
||||
def main() -> None:
|
||||
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
|
||||
log = get_skill_logger()
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
_cli_print_full_usage()
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME)
|
||||
get_skill_logger().info("cli_start argv=%s", sys.argv)
|
||||
cmd = sys.argv[1]
|
||||
log.info("cli_start argv=%s", sys.argv)
|
||||
av = sys.argv
|
||||
cmd = av[1]
|
||||
_maybe_print_paths_debug_cli()
|
||||
|
||||
# Determine if first arg is a platform key (for legacy shorthand)
|
||||
plat_from_first = resolve_platform_key(cmd)
|
||||
|
||||
if len(sys.argv) >= 3 and sys.argv[2] == "list" and plat_from_first:
|
||||
cmd_list(plat_from_first)
|
||||
elif plat_from_first and len(sys.argv) == 2:
|
||||
cmd_list(plat_from_first)
|
||||
elif cmd == "add":
|
||||
if len(sys.argv) < 4:
|
||||
_cli_fail_add()
|
||||
# ---- platform subcommand ----
|
||||
if cmd == "platform":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS platform 需要子命令:list | get")
|
||||
sys.exit(1)
|
||||
cmd_add(sys.argv[2], sys.argv[3])
|
||||
elif cmd == "list":
|
||||
cmd_list(sys.argv[2] if len(sys.argv) >= 3 else "all")
|
||||
sub = av[2]
|
||||
if sub == "list":
|
||||
domain = _get_opt(av, "--domain")
|
||||
cmd_platform_list(domain)
|
||||
elif sub == "get":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。")
|
||||
sys.exit(1)
|
||||
cmd_platform_get(av[3])
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list 或 get。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- account subcommand ----
|
||||
elif cmd == "account":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS account 需要子命令(add-web / get / …)。")
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
sub = av[2]
|
||||
if sub == "add-web":
|
||||
_cmd_add_web(av)
|
||||
elif sub == "add-secret":
|
||||
_cmd_add_secret(av)
|
||||
elif sub == "get":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account get 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
with_creds = _has_flag(av, "--with-credentials")
|
||||
cmd_account_get(aid, include_credentials=with_creds)
|
||||
elif sub == "list":
|
||||
_cmd_account_list(av)
|
||||
elif sub == "pick-web":
|
||||
_cmd_pick_web_new(av)
|
||||
elif sub == "mark-session":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account mark-session 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
ss = _get_opt(av, "--session-status", "unknown")
|
||||
cmd_account_mark_session(aid, ss)
|
||||
elif sub == "mark-error":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account mark-error 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
ec = _get_opt(av, "--code", "")
|
||||
em = _get_opt(av, "--message", "")
|
||||
cmd_account_mark_error(aid, ec, em)
|
||||
elif sub == "mark-used":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account mark-used 需要 <id>。")
|
||||
sys.exit(1)
|
||||
aid = av[3]
|
||||
cmd_account_mark_used(aid)
|
||||
elif sub == "open":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS account open 需要 <id>。")
|
||||
sys.exit(1)
|
||||
cmd_open(av[3])
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS 未知的 account 子命令。")
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
# ---- credential subcommand ----
|
||||
elif cmd == "credential":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS credential 需要子命令:pick | resolve")
|
||||
sys.exit(1)
|
||||
sub = av[2]
|
||||
if sub == "pick":
|
||||
_cmd_cred_pick(av)
|
||||
elif sub == "resolve":
|
||||
_cmd_cred_resolve(av)
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS credential 子命令必须是 pick 或 resolve。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- lease subcommand ----
|
||||
elif cmd == "lease":
|
||||
if len(av) < 3:
|
||||
print("ERROR:CLI_BAD_ARGS lease 需要子命令:release | list | cleanup")
|
||||
sys.exit(1)
|
||||
sub = av[2]
|
||||
if sub == "release":
|
||||
if len(av) < 4:
|
||||
print("ERROR:CLI_BAD_ARGS lease release 需要 <lease_token>。")
|
||||
sys.exit(1)
|
||||
cmd_lease_release(av[3])
|
||||
elif sub == "list":
|
||||
active = _has_flag(av, "--active")
|
||||
cmd_lease_list(active)
|
||||
elif sub == "cleanup":
|
||||
cmd_lease_cleanup()
|
||||
else:
|
||||
print("ERROR:CLI_BAD_ARGS lease 子命令必须是 release、list 或 cleanup。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- Legacy aliases ----
|
||||
elif cmd == "get":
|
||||
if len(sys.argv) < 3:
|
||||
_cli_fail_need_account_id("get")
|
||||
print("ERROR:CLI_GET_MISSING_ID")
|
||||
sys.exit(1)
|
||||
cmd_get(sys.argv[2])
|
||||
|
||||
elif cmd == "list-json":
|
||||
if len(sys.argv) < 3:
|
||||
print("ERROR:CLI_LIST_JSON_MISSING_PLATFORM")
|
||||
print("用法:python main.py list-json <平台|all|全部> [--limit N]")
|
||||
print("说明:跨技能编排;成功时 stdout 仅一行 JSON 数组(元素与 get 一致)。")
|
||||
sys.exit(1)
|
||||
av = sys.argv[2:]
|
||||
platform_arg = av[0]
|
||||
@@ -120,21 +272,45 @@ def main() -> None:
|
||||
except ValueError:
|
||||
pass
|
||||
cmd_list_json(platform_arg, limit=lim)
|
||||
|
||||
elif cmd == "pick-web":
|
||||
if len(sys.argv) < 3:
|
||||
print("ERROR:CLI_PICK_WEB_MISSING_ARGS")
|
||||
print("用法:python main.py pick-web <平台中文名或英文键>")
|
||||
print("说明:按 updated_at 选一条账号;是否已登录由调用方网页会话自行处理。")
|
||||
sys.exit(1)
|
||||
cmd_pick_web(sys.argv[2])
|
||||
|
||||
elif cmd == "open":
|
||||
if len(sys.argv) < 3:
|
||||
_cli_fail_need_account_id("open")
|
||||
print("ERROR:CLI_OPEN_MISSING_ID")
|
||||
sys.exit(1)
|
||||
cmd_open(sys.argv[2])
|
||||
|
||||
elif cmd == "list":
|
||||
platform_arg = sys.argv[2] if len(sys.argv) >= 3 else "all"
|
||||
cmd_list(platform_arg)
|
||||
|
||||
elif cmd == "add":
|
||||
if len(sys.argv) < 4:
|
||||
print("ERROR:CLI_ADD_MISSING_ARGS")
|
||||
sys.exit(1)
|
||||
# Legacy add maps to add-web for backward compat
|
||||
cmd_account_add_web(
|
||||
platform_input=sys.argv[2],
|
||||
login_id=sys.argv[3],
|
||||
login_id_type="phone",
|
||||
label=None,
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="default",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
|
||||
elif cmd == "delete":
|
||||
if len(sys.argv) < 4:
|
||||
_cli_fail_delete()
|
||||
print("ERROR:CLI_DELETE_MISSING_ARGS")
|
||||
sys.exit(1)
|
||||
mode = (sys.argv[2] or "").strip().lower()
|
||||
if mode == "id":
|
||||
@@ -145,17 +321,151 @@ def main() -> None:
|
||||
else:
|
||||
cmd_delete_by_platform(sys.argv[3])
|
||||
else:
|
||||
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」,请使用 id 或 platform。")
|
||||
_cli_fail_delete()
|
||||
print(f"ERROR:CLI_DELETE_BAD_MODE 不支持的删除方式「{sys.argv[2]}」。")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- Platform shorthand: "python main.py <platform>" ----
|
||||
elif plat_from_first:
|
||||
cmd_list(sys.argv[1])
|
||||
|
||||
# ---- Unknown ----
|
||||
else:
|
||||
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别,或参数组合不合法。")
|
||||
print(f"ERROR:CLI_UNKNOWN_OR_BAD_ARGS 子命令「{cmd}」无法识别。")
|
||||
print()
|
||||
_cli_print_full_usage()
|
||||
print()
|
||||
print("说明:list 的筛选可为 all/全部,或上面列出的任一平台中文名。")
|
||||
print(_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sub-parsers for complex commands
|
||||
# =========================================================================
|
||||
|
||||
def _cmd_add_web(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
login_id = _get_opt(argv, "--login-id")
|
||||
login_id_type = _get_opt(argv, "--login-id-type", "unknown")
|
||||
label = _get_opt(argv, "--label")
|
||||
tenant_id = _get_opt(argv, "--tenant-id")
|
||||
environment = _get_opt(argv, "--environment", "production")
|
||||
role = _get_opt(argv, "--role", "default")
|
||||
provider_code = _get_opt(argv, "--provider-code")
|
||||
url = _get_opt(argv, "--url")
|
||||
extra_json_str = _get_opt(argv, "--extra-json")
|
||||
profile_dir_override = _get_opt(argv, "--profile-dir")
|
||||
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS account add-web 需要 --platform <platform>。")
|
||||
return
|
||||
cmd_account_add_web(
|
||||
platform_input=plat,
|
||||
login_id=login_id,
|
||||
login_id_type=login_id_type,
|
||||
label=label,
|
||||
tenant_id=tenant_id,
|
||||
environment=environment,
|
||||
role=role,
|
||||
provider_code=provider_code,
|
||||
url=url,
|
||||
extra_json_str=extra_json_str,
|
||||
profile_dir_override=profile_dir_override,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_add_secret(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
ctype = _get_opt(argv, "--credential-type", "api_key")
|
||||
label = _get_opt(argv, "--label")
|
||||
storage = _get_opt(argv, "--secret-storage", "env")
|
||||
stdin_mode = _has_flag(argv, "--secret-stdin")
|
||||
sref = _get_opt(argv, "--secret-ref")
|
||||
aid = _get_int_opt(argv, "--account-id", 0) or None
|
||||
env = _get_opt(argv, "--environment", "production")
|
||||
role = _get_opt(argv, "--role", "api")
|
||||
extra_json_str = _get_opt(argv, "--extra-json")
|
||||
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS account add-secret 需要 --platform <platform>。")
|
||||
return
|
||||
cmd_account_add_secret(
|
||||
platform_input=plat,
|
||||
credential_type=ctype,
|
||||
label=label,
|
||||
secret_storage=storage,
|
||||
secret_stdin=stdin_mode,
|
||||
secret_ref=sref,
|
||||
account_id=aid,
|
||||
environment=env,
|
||||
role=role,
|
||||
extra_json_str=extra_json_str,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_account_list(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform")
|
||||
env = _get_opt(argv, "--environment")
|
||||
tenant = _get_opt(argv, "--tenant-id")
|
||||
role = _get_opt(argv, "--role")
|
||||
limit = _get_int_opt(argv, "--limit", 200)
|
||||
cmd_account_list(
|
||||
platform_input=plat,
|
||||
environment=env,
|
||||
tenant_id=tenant,
|
||||
role=role,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_pick_web_new(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
env = _get_opt(argv, "--environment")
|
||||
tenant = _get_opt(argv, "--tenant-id")
|
||||
role = _get_opt(argv, "--role")
|
||||
do_lease = _has_flag(argv, "--lease")
|
||||
ttl = _get_int_opt(argv, "--ttl-sec", 900)
|
||||
holder = _get_opt(argv, "--holder")
|
||||
purpose = _get_opt(argv, "--purpose")
|
||||
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS account pick-web 需要 --platform <platform>。")
|
||||
return
|
||||
cmd_account_pick_web(
|
||||
platform_input=plat,
|
||||
environment=env,
|
||||
tenant_id=tenant,
|
||||
role=role,
|
||||
do_lease=do_lease,
|
||||
ttl_sec=ttl,
|
||||
holder=holder,
|
||||
purpose=purpose,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_cred_pick(argv: list[str]) -> None:
|
||||
plat = _get_opt(argv, "--platform", "")
|
||||
ctype = _get_opt(argv, "--type")
|
||||
env = _get_opt(argv, "--environment")
|
||||
role = _get_opt(argv, "--role")
|
||||
reveal = _has_flag(argv, "--reveal")
|
||||
if not plat:
|
||||
print("ERROR:CLI_BAD_ARGS credential pick 需要 --platform <platform>。")
|
||||
return
|
||||
cmd_credential_pick(
|
||||
platform_input=plat,
|
||||
credential_type=ctype,
|
||||
environment=env,
|
||||
role=role,
|
||||
reveal=reveal,
|
||||
)
|
||||
|
||||
|
||||
def _cmd_cred_resolve(argv: list[str]) -> None:
|
||||
cid = _get_int_opt(argv, "--id", 0)
|
||||
reveal = _has_flag(argv, "--reveal")
|
||||
if cid <= 0:
|
||||
print("ERROR:CLI_BAD_ARGS credential resolve 需要 --id <credential_id>。")
|
||||
return
|
||||
cmd_credential_resolve(cid, reveal=reveal)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,28 @@
|
||||
"""Chromium/Edge 检测与 Playwright:仅打开浏览器(不写库、不做登录态 DOM 检测)。"""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Chromium/Edge browser profile opener.
|
||||
|
||||
Uses system Chrome/Edge channel, Playwright launch_persistent_context.
|
||||
Does NOT download Playwright Chromium.
|
||||
Does NOT check login status.
|
||||
Does NOT write secret to SQLite.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from db.connection import init_db
|
||||
from db.accounts_repo import get_account_by_id
|
||||
from util.logging_config import (
|
||||
get_skill_logger,
|
||||
subprocess_env_with_trace,
|
||||
)
|
||||
from util.platforms import PLATFORM_URLS
|
||||
from util.platforms import PLATFORMS
|
||||
|
||||
# 子进程入口脚本与本模块同目录(独立解释器执行,非 import)
|
||||
_SERVICE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
# PyArmor 在模块首行注入对 pyarmor_runtime_* 的 import;若以「脚本路径」启动子进程,
|
||||
# sys.path[0] 仅为 service/,找不到 scripts/ 下的运行时包。必须用 -m 且 cwd=scripts/(标准做法)。
|
||||
_SCRIPTS_ROOT = os.path.dirname(_SERVICE_DIR)
|
||||
|
||||
|
||||
@@ -68,11 +75,17 @@ def _print_browser_install_hint():
|
||||
|
||||
|
||||
def cmd_open(account_id):
|
||||
"""打开该账号的持久化浏览器,仅用于肉眼核对;不写数据库、不判定是否已登录。"""
|
||||
get_skill_logger().info("open account_id=%r", account_id)
|
||||
"""
|
||||
打开该账号的持久化浏览器,仅用于肉眼核对。
|
||||
不写数据库、不判定是否已登录。
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
init_db()
|
||||
log.info("browser_open_start account_id=%r", account_id)
|
||||
|
||||
target = get_account_by_id(account_id)
|
||||
if not target:
|
||||
get_skill_logger().warning("open_not_found account_id=%r", account_id)
|
||||
log.warning("browser_open_error account_id=%r not_found", account_id)
|
||||
print("ERROR:ACCOUNT_NOT_FOUND")
|
||||
return
|
||||
|
||||
@@ -85,23 +98,33 @@ def cmd_open(account_id):
|
||||
import playwright # noqa: F401
|
||||
except ImportError:
|
||||
print(
|
||||
"ERROR:需要 playwright Python 包:请在匠厂共享环境中已含 playwright;"
|
||||
"本机需安装 Google Chrome 或 Microsoft Edge(channel 模式,无需 playwright install chromium)"
|
||||
"ERROR:需要 playwright Python 包。"
|
||||
)
|
||||
return
|
||||
|
||||
profile_dir = (target.get("profile_dir") or "").strip()
|
||||
if not profile_dir:
|
||||
print("ERROR:PROFILE_DIR_MISSING 库中 profile_dir 为空。")
|
||||
log.warning("browser_open_error account_id=%s profile_dir_missing", account_id)
|
||||
print("ERROR:PROFILE_DIR_MISSING 账号中 profile_dir 为空。")
|
||||
return
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
url = (target.get("url") or "").strip() or PLATFORM_URLS.get(
|
||||
target["platform"], "https://www.google.com"
|
||||
|
||||
# Use account url, fall back to platform default
|
||||
platform_spec = PLATFORMS.get(target["platform_key"], {})
|
||||
url = (target.get("url") or "").strip() or platform_spec.get("default_url", "")
|
||||
|
||||
log.info(
|
||||
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s",
|
||||
profile_dir,
|
||||
url,
|
||||
channel,
|
||||
target.get("platform_key"),
|
||||
)
|
||||
|
||||
browser_name = "Google Chrome" if channel == "chrome" else "Microsoft Edge"
|
||||
print(f"正在打开 [{target['name']}] 的 {browser_name}(仅查看,不写入数据库)")
|
||||
print(f"正在打开 [{target['account_label']}] 的 {browser_name}(仅查看,不写入数据库)")
|
||||
print(f"地址:{url}")
|
||||
print("是否已登录由业务侧(如大模型网页引擎)在使用账号时自行处理;本命令不做登录检测。")
|
||||
print("本命令不做登录检测。")
|
||||
|
||||
cfg = {"channel": channel, "profile_dir": profile_dir, "url": url}
|
||||
with tempfile.NamedTemporaryFile(
|
||||
@@ -115,6 +138,10 @@ def cmd_open(account_id):
|
||||
cwd=_SCRIPTS_ROOT,
|
||||
env=subprocess_env_with_trace(),
|
||||
)
|
||||
log.info("browser_open_close account_id=%s channel=%s", account_id, channel)
|
||||
except Exception as e:
|
||||
log.error("browser_open_error account_id=%s error=%s", account_id, str(e))
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
os.unlink(cfg_path)
|
||||
|
||||
306
scripts/service/secret_store.py
Normal file
306
scripts/service/secret_store.py
Normal file
@@ -0,0 +1,306 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Secret storage abstraction layer.
|
||||
|
||||
Supported storage backends:
|
||||
- none : No secret stored (e.g., browser profile login state)
|
||||
- env : Secret is read from environment variable at runtime
|
||||
- windows_credential : Secret stored in Windows Credential Manager (ctypes)
|
||||
|
||||
Raw secret values are NEVER logged or written to SQLite.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from util.logging_config import get_skill_logger
|
||||
|
||||
|
||||
def mask_secret(secret: str) -> str:
|
||||
"""
|
||||
Return a masked version of the secret for display/storage.
|
||||
Never returns the full original secret or a reversible prefix.
|
||||
|
||||
Examples:
|
||||
- Long API key -> \"****abcd\" (last 4 chars only, after separator)
|
||||
- Short / empty -> \"****\"
|
||||
"""
|
||||
if secret is None:
|
||||
return "****"
|
||||
s = str(secret).strip()
|
||||
if not s:
|
||||
return "****"
|
||||
if len(s) <= 4:
|
||||
return "****"
|
||||
return "****" + s[-4:]
|
||||
|
||||
|
||||
def make_windows_credential_target(
|
||||
platform_key: str,
|
||||
credential_type: str,
|
||||
credential_id_or_label: str,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a Windows Credential Manager target name for a credential.
|
||||
|
||||
Format: openclaw/account-manager/{user_id}/{platform_key}/{credential_type}/{uuid_short}
|
||||
"""
|
||||
user_id = (
|
||||
(os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip()
|
||||
or "_anon"
|
||||
)
|
||||
safe_label = re.sub(r"[^a-zA-Z0-9_-]", "_", credential_id_or_label)[:32]
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
return (
|
||||
f"openclaw/account-manager/{user_id}/"
|
||||
f"{platform_key}/{credential_type}/{safe_label}_{uid}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_secret(storage: str, secret_ref: str, secret_value: str) -> None:
|
||||
"""
|
||||
Write a secret to the specified storage backend.
|
||||
Raises ValueError on unsupported storage or write failure.
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
log.info(
|
||||
"secret_write_attempt storage=%s ref_prefix=%s",
|
||||
storage,
|
||||
secret_ref[:16] if secret_ref else "none",
|
||||
)
|
||||
storage_n = storage.strip().lower()
|
||||
|
||||
if storage_n == "none":
|
||||
log.info("secret_write_success storage=none ref=%s", secret_ref)
|
||||
return
|
||||
|
||||
elif storage_n == "env":
|
||||
log.info("secret_write_success storage=env ref=%s", secret_ref)
|
||||
return
|
||||
|
||||
elif storage_n == "windows_credential":
|
||||
_win_cred_write_impl(secret_ref, secret_value)
|
||||
log.info(
|
||||
"secret_write_success storage=windows_credential ref=%s",
|
||||
secret_ref[:48],
|
||||
)
|
||||
return
|
||||
|
||||
else:
|
||||
log.error("secret_write_failed unsupported storage=%s", storage)
|
||||
raise ValueError(f"Unsupported secret storage type: {storage}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_secret(storage: str, secret_ref: str) -> str:
|
||||
"""
|
||||
Read/retrieve a secret from the specified storage backend.
|
||||
Returns the secret value as a string.
|
||||
Raises ValueError on unsupported storage, missing ref, or read failure.
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
log.info(
|
||||
"secret_resolve_attempt storage=%s ref_prefix=%s",
|
||||
storage,
|
||||
secret_ref[:16] if secret_ref else "none",
|
||||
)
|
||||
storage_n = storage.strip().lower()
|
||||
|
||||
if storage_n == "none":
|
||||
log.info("secret_resolve_success storage=none")
|
||||
return ""
|
||||
|
||||
elif storage_n == "env":
|
||||
val = os.environ.get(secret_ref)
|
||||
if val is None:
|
||||
log.error("secret_resolve_failed env_missing ref=%s", secret_ref)
|
||||
raise ValueError(
|
||||
f"Environment variable '{secret_ref}' is not set (SECRET_ENV_MISSING)"
|
||||
)
|
||||
log.info("secret_resolve_success storage=env ref=%s", secret_ref)
|
||||
return val
|
||||
|
||||
elif storage_n == "windows_credential":
|
||||
val = _win_cred_read_impl(secret_ref)
|
||||
log.info(
|
||||
"secret_resolve_success storage=windows_credential ref=%s",
|
||||
secret_ref[:48],
|
||||
)
|
||||
return val
|
||||
|
||||
else:
|
||||
log.error("secret_resolve_failed unsupported storage=%s", storage)
|
||||
raise ValueError(f"Unsupported secret storage type: {storage}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def delete_secret(storage: str, secret_ref: str) -> None:
|
||||
"""Delete a secret from the specified storage backend."""
|
||||
log = get_skill_logger()
|
||||
log.info(
|
||||
"secret_delete_attempt storage=%s ref_prefix=%s",
|
||||
storage,
|
||||
secret_ref[:16] if secret_ref else "none",
|
||||
)
|
||||
storage_n = storage.strip().lower()
|
||||
|
||||
if storage_n == "none":
|
||||
return
|
||||
elif storage_n == "env":
|
||||
log.info("secret_delete_env_nop ref=%s", secret_ref)
|
||||
return
|
||||
elif storage_n == "windows_credential":
|
||||
_win_cred_delete_impl(secret_ref)
|
||||
log.info(
|
||||
"secret_delete_success storage=windows_credential ref=%s",
|
||||
secret_ref[:48],
|
||||
)
|
||||
return
|
||||
else:
|
||||
raise ValueError(f"Unsupported secret storage type: {storage}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows Credential Manager implementation (ctypes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_WINCRED_TYPE_GENERIC = 1 # CRED_TYPE_GENERIC
|
||||
_WINCRED_PERSIST_LOCAL_MACHINE = 2 # CRED_PERSIST_LOCAL_MACHINE
|
||||
|
||||
|
||||
def _win_cred_available() -> bool:
|
||||
"""Check if Windows Credential Manager API is available."""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
import ctypes # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _win_cred_unavailable() -> OSError:
|
||||
return OSError(
|
||||
"Windows Credential Manager is not available on this platform "
|
||||
"(WINDOWS_CREDENTIAL_UNAVAILABLE)"
|
||||
)
|
||||
|
||||
|
||||
def _win_cred_write_impl(target: str, secret_value: str) -> None:
|
||||
if not _win_cred_available():
|
||||
raise _win_cred_unavailable()
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class CREDENTIALW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Flags", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
("TargetName", wintypes.LPCWSTR),
|
||||
("Comment", wintypes.LPCWSTR),
|
||||
("LastWritten", wintypes.FILETIME),
|
||||
("CredentialBlobSize", wintypes.DWORD),
|
||||
("CredentialBlob", ctypes.POINTER(ctypes.c_byte)),
|
||||
("Persist", wintypes.DWORD),
|
||||
("AttributeCount", wintypes.DWORD),
|
||||
("Attributes", ctypes.c_void_p),
|
||||
("TargetAlias", wintypes.LPCWSTR),
|
||||
("UserName", wintypes.LPCWSTR),
|
||||
]
|
||||
|
||||
secret_bytes = secret_value.encode("utf-16-le")
|
||||
blob_arr = (ctypes.c_byte * len(secret_bytes)).from_buffer_copy(secret_bytes)
|
||||
|
||||
cred = CREDENTIALW()
|
||||
cred.Type = _WINCRED_TYPE_GENERIC
|
||||
cred.TargetName = target
|
||||
cred.CredentialBlobSize = len(secret_bytes)
|
||||
cred.CredentialBlob = ctypes.cast(blob_arr, ctypes.POINTER(ctypes.c_byte))
|
||||
cred.Persist = _WINCRED_PERSIST_LOCAL_MACHINE
|
||||
cred.UserName = "account-manager"
|
||||
|
||||
advapi32 = ctypes.windll.advapi32
|
||||
if not advapi32.CredWriteW(ctypes.byref(cred), 0):
|
||||
err = ctypes.get_last_error()
|
||||
raise OSError(f"CredWriteW failed with error {err} (SECRET_WRITE_FAILED)")
|
||||
del blob_arr, cred
|
||||
|
||||
|
||||
def _win_cred_read_impl(target: str) -> str:
|
||||
if not _win_cred_available():
|
||||
raise _win_cred_unavailable()
|
||||
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class CREDENTIALW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Flags", wintypes.DWORD),
|
||||
("Type", wintypes.DWORD),
|
||||
("TargetName", wintypes.LPCWSTR),
|
||||
("Comment", wintypes.LPCWSTR),
|
||||
("LastWritten", wintypes.FILETIME),
|
||||
("CredentialBlobSize", wintypes.DWORD),
|
||||
("CredentialBlob", ctypes.POINTER(ctypes.c_byte)),
|
||||
("Persist", wintypes.DWORD),
|
||||
("AttributeCount", wintypes.DWORD),
|
||||
("Attributes", ctypes.c_void_p),
|
||||
("TargetAlias", wintypes.LPCWSTR),
|
||||
("UserName", wintypes.LPCWSTR),
|
||||
]
|
||||
|
||||
PCREDENTIALW = ctypes.POINTER(CREDENTIALW)
|
||||
|
||||
advapi32 = ctypes.windll.advapi32
|
||||
advapi32.CredReadW.argtypes = [
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(PCREDENTIALW),
|
||||
]
|
||||
advapi32.CredReadW.restype = wintypes.BOOL
|
||||
advapi32.CredFree.argtypes = [ctypes.c_void_p]
|
||||
advapi32.CredFree.restype = None
|
||||
|
||||
ppcred = PCREDENTIALW()
|
||||
if not advapi32.CredReadW(target, _WINCRED_TYPE_GENERIC, 0, ctypes.byref(ppcred)):
|
||||
err = ctypes.get_last_error()
|
||||
if err == 1168:
|
||||
raise ValueError(
|
||||
f"Credential not found in Windows Credential Manager: target={target}"
|
||||
)
|
||||
raise OSError(f"CredReadW failed with error {err} (SECRET_READ_FAILED)")
|
||||
|
||||
try:
|
||||
cred = ppcred.contents
|
||||
blob_size = int(cred.CredentialBlobSize)
|
||||
if blob_size <= 0 or not cred.CredentialBlob:
|
||||
return ""
|
||||
raw = ctypes.string_at(cred.CredentialBlob, blob_size)
|
||||
return raw.decode("utf-16-le")
|
||||
finally:
|
||||
advapi32.CredFree(ppcred)
|
||||
|
||||
|
||||
def _win_cred_delete_impl(target: str) -> None:
|
||||
if not _win_cred_available():
|
||||
raise _win_cred_unavailable()
|
||||
|
||||
import ctypes
|
||||
|
||||
advapi32 = ctypes.windll.advapi32
|
||||
if not advapi32.CredDeleteW(target, _WINCRED_TYPE_GENERIC, 0):
|
||||
err = ctypes.get_last_error()
|
||||
if err != 1168:
|
||||
raise OSError(f"CredDeleteW failed with error {err}")
|
||||
@@ -1,145 +1,341 @@
|
||||
"""平台配置、别名解析、手机号校验。"""
|
||||
import re
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Platform definitions and resolution.
|
||||
|
||||
# 平台唯一配置表:只改这里即可。键 = 入库的 platform;label = 帮助/提示里的展示名;
|
||||
# prefix = 默认账号名「{prefix}{序号}号」,省略时等于 label;
|
||||
# aliases = 额外 CLI 称呼(英文键与 label 已自动参与解析,不必重复写)。
|
||||
# 登录检测:仅看页面 DOM。匹配 anchor 的标签页上出现「未登录」选择器则未登录;否则视为已登录。
|
||||
# - _LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS:通用「登录」按钮/链接等(可被 login_skip_generic_logged_out_dom 关闭)。
|
||||
# - login_logged_out_selectors:按平台追加;误判时也可用 login_skip_generic_logged_out_dom + 仅平台自选。
|
||||
PLATFORMS = {
|
||||
"sohu": {
|
||||
"url": "https://mp.sohu.com",
|
||||
"label": "搜狐号",
|
||||
"prefix": "搜狐",
|
||||
"aliases": ["搜狐"],
|
||||
Central authoritative registry for all platforms (logistics, LLM, content, etc.).
|
||||
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from util.logging_config import get_skill_logger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform specification fields:
|
||||
# display_name – Human-readable name (e.g. "Maersk")
|
||||
# domain – Business domain: logistics / llm / content / ecommerce / generic
|
||||
# provider_code – Supplier code (logistics); can be same as key
|
||||
# default_url – Default entry URL (empty string if none)
|
||||
# aliases – List of name aliases (Chinese/English, for CLI resolution)
|
||||
# capabilities – Dict of capability flags:
|
||||
# {"web": bool, "api_key": bool, "rpa": bool}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PLATFORMS: dict[str, dict[str, Any]] = {
|
||||
# =========================================================================
|
||||
# Logistics platforms
|
||||
# =========================================================================
|
||||
"maersk": {
|
||||
"display_name": "Maersk",
|
||||
"domain": "logistics",
|
||||
"provider_code": "maersk",
|
||||
"default_url": "https://www.maersk.com",
|
||||
"aliases": ["马士基", "maersk", "Maersk"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"toutiao": {
|
||||
"url": "https://mp.toutiao.com/",
|
||||
"label": "头条号",
|
||||
"prefix": "头条",
|
||||
"aliases": ["头条"],
|
||||
"cosco": {
|
||||
"display_name": "COSCO",
|
||||
"domain": "logistics",
|
||||
"provider_code": "cosco",
|
||||
"default_url": "",
|
||||
"aliases": ["中远海运", "COSCO", "cosco"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"zhihu": {
|
||||
"url": "https://www.zhihu.com",
|
||||
"label": "知乎",
|
||||
"aliases": ["知乎号"],
|
||||
"msc": {
|
||||
"display_name": "MSC",
|
||||
"domain": "logistics",
|
||||
"provider_code": "msc",
|
||||
"default_url": "",
|
||||
"aliases": ["MSC", "地中海航运"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"wechat": {
|
||||
"url": "https://mp.weixin.qq.com",
|
||||
"label": "微信公众号",
|
||||
"prefix": "微信",
|
||||
"aliases": ["公众号", "微信"],
|
||||
"cma_cgm": {
|
||||
"display_name": "CMA CGM",
|
||||
"domain": "logistics",
|
||||
"provider_code": "cma_cgm",
|
||||
"default_url": "",
|
||||
"aliases": ["达飞", "CMA CGM", "cma cgm"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"kimi": {
|
||||
"url": "https://kimi.moonshot.cn",
|
||||
"label": "Kimi",
|
||||
"aliases": ["月之暗面"],
|
||||
"evergreen": {
|
||||
"display_name": "Evergreen",
|
||||
"domain": "logistics",
|
||||
"provider_code": "evergreen",
|
||||
"default_url": "",
|
||||
"aliases": ["长荣", "Evergreen", "evergreen"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"cargo_wise": {
|
||||
"display_name": "CargoWise",
|
||||
"domain": "logistics",
|
||||
"provider_code": "cargo_wise",
|
||||
"default_url": "",
|
||||
"aliases": ["CargoWise", "Cargo Wise", "cargo wise"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
"freightos": {
|
||||
"display_name": "Freightos",
|
||||
"domain": "logistics",
|
||||
"provider_code": "freightos",
|
||||
"default_url": "",
|
||||
"aliases": ["Freightos", "freightos"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"webcargo": {
|
||||
"display_name": "WebCargo",
|
||||
"domain": "logistics",
|
||||
"provider_code": "webcargo",
|
||||
"default_url": "",
|
||||
"aliases": ["WebCargo", "web cargo"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"sinotrans": {
|
||||
"display_name": "Sinotrans",
|
||||
"domain": "logistics",
|
||||
"provider_code": "sinotrans",
|
||||
"default_url": "",
|
||||
"aliases": ["中外运", "Sinotrans", "sinotrans"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": True},
|
||||
},
|
||||
# =========================================================================
|
||||
# LLM / AI platforms
|
||||
# =========================================================================
|
||||
"deepseek": {
|
||||
"url": "https://chat.deepseek.com",
|
||||
"label": "DeepSeek",
|
||||
"display_name": "DeepSeek",
|
||||
"domain": "llm",
|
||||
"provider_code": "deepseek",
|
||||
"default_url": "https://chat.deepseek.com",
|
||||
"aliases": ["DeepSeek", "deepseek"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"doubao": {
|
||||
"url": "https://www.doubao.com",
|
||||
"label": "豆包",
|
||||
"display_name": "Doubao",
|
||||
"domain": "llm",
|
||||
"provider_code": "doubao",
|
||||
"default_url": "https://www.doubao.com",
|
||||
"aliases": ["豆包", "Doubao"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"kimi": {
|
||||
"display_name": "Kimi",
|
||||
"domain": "llm",
|
||||
"provider_code": "kimi",
|
||||
"default_url": "https://kimi.moonshot.cn",
|
||||
"aliases": ["Kimi", "月之暗面"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"qianwen": {
|
||||
"url": "https://tongyi.aliyun.com",
|
||||
"label": "通义千问",
|
||||
"prefix": "通义",
|
||||
"aliases": ["通义", "千问"],
|
||||
"display_name": "Qianwen",
|
||||
"domain": "llm",
|
||||
"provider_code": "qianwen",
|
||||
"default_url": "https://tongyi.aliyun.com",
|
||||
"aliases": ["通义千问", "通义", "千问", "Qianwen"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"yiyan": {
|
||||
"url": "https://yiyan.baidu.com",
|
||||
"label": "文心一言",
|
||||
"prefix": "文心",
|
||||
"aliases": ["文心", "一言"],
|
||||
"display_name": "YiYan",
|
||||
"domain": "llm",
|
||||
"provider_code": "yiyan",
|
||||
"default_url": "https://yiyan.baidu.com",
|
||||
"aliases": ["文心一言", "文心", "一言", "YiYan"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"yuanbao": {
|
||||
"url": "https://yuanbao.tencent.com",
|
||||
"label": "腾讯元宝",
|
||||
"prefix": "元宝",
|
||||
"aliases": ["元宝"],
|
||||
"display_name": "Yuanbao",
|
||||
"domain": "llm",
|
||||
"provider_code": "yuanbao",
|
||||
"default_url": "https://yuanbao.tencent.com",
|
||||
"aliases": ["腾讯元宝", "元宝", "Yuanbao"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"gemini": {
|
||||
"url": "https://gemini.google.com",
|
||||
"label": "Gemini",
|
||||
"prefix": "Gemini",
|
||||
"aliases": ["谷歌Gemini", "Google Gemini", "google gemini", "Bard"],
|
||||
"display_name": "Gemini",
|
||||
"domain": "llm",
|
||||
"provider_code": "gemini",
|
||||
"default_url": "https://gemini.google.com",
|
||||
"aliases": ["Gemini", "谷歌Gemini", "Google Gemini", "Bard"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
"minimax": {
|
||||
"display_name": "MiniMax",
|
||||
"domain": "llm",
|
||||
"provider_code": "minimax",
|
||||
"default_url": "",
|
||||
"aliases": ["MiniMax", "minimax"],
|
||||
"capabilities": {"web": True, "api_key": True, "rpa": False},
|
||||
},
|
||||
# =========================================================================
|
||||
# Content platforms (carried forward from v1)
|
||||
# =========================================================================
|
||||
"sohu": {
|
||||
"display_name": "搜狐号",
|
||||
"domain": "content",
|
||||
"provider_code": "sohu",
|
||||
"default_url": "https://mp.sohu.com",
|
||||
"aliases": ["搜狐", "搜狐号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"toutiao": {
|
||||
"display_name": "头条号",
|
||||
"domain": "content",
|
||||
"provider_code": "toutiao",
|
||||
"default_url": "https://mp.toutiao.com/",
|
||||
"aliases": ["头条", "头条号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"zhihu": {
|
||||
"display_name": "知乎",
|
||||
"domain": "content",
|
||||
"provider_code": "zhihu",
|
||||
"default_url": "https://www.zhihu.com",
|
||||
"aliases": ["知乎", "知乎号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
"wechat": {
|
||||
"display_name": "微信公众号",
|
||||
"domain": "content",
|
||||
"provider_code": "wechat",
|
||||
"default_url": "https://mp.weixin.qq.com",
|
||||
"aliases": ["公众号", "微信", "微信公众号"],
|
||||
"capabilities": {"web": True, "api_key": False, "rpa": False},
|
||||
},
|
||||
}
|
||||
|
||||
# 未登录页共性:主行动点含「登录」——submit 按钮、Element 主按钮、Semi 文案、标题、通栏主按钮等,Playwright :has-text 可覆盖多数站点。
|
||||
_LOGIN_LOGGED_OUT_DOM_GENERIC_SELECTORS = (
|
||||
'button:has-text("登录")',
|
||||
'role=button[name="登录"]',
|
||||
'a:has-text("登录")',
|
||||
'h4:has-text("登录")',
|
||||
'span.semi-button-content:has-text("登录")',
|
||||
)
|
||||
|
||||
|
||||
def _build_platform_derived():
|
||||
urls = {}
|
||||
name_prefix = {}
|
||||
primary_cn = {}
|
||||
alias_to_key = {}
|
||||
|
||||
def _register_alias(alias: str, key: str) -> None:
|
||||
if not alias or not str(alias).strip():
|
||||
return
|
||||
a = str(alias).strip()
|
||||
if a not in alias_to_key:
|
||||
alias_to_key[a] = key
|
||||
lo = a.lower()
|
||||
if lo not in alias_to_key:
|
||||
alias_to_key[lo] = key
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived alias map
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_alias_map() -> dict[str, str]:
|
||||
"""Build a case-insensitive flat alias -> key map."""
|
||||
m: dict[str, str] = {}
|
||||
for key, spec in PLATFORMS.items():
|
||||
urls[key] = spec["url"]
|
||||
primary_cn[key] = spec["label"]
|
||||
name_prefix[key] = (spec.get("prefix") or spec["label"]).strip() or key
|
||||
_register_alias(key, key)
|
||||
_register_alias(spec["label"], key)
|
||||
for a in spec.get("aliases") or []:
|
||||
_register_alias(a, key)
|
||||
|
||||
return urls, name_prefix, primary_cn, alias_to_key
|
||||
# Register the key itself (lowercase)
|
||||
m[key.lower()] = key
|
||||
# Register all aliases (original + lowercase)
|
||||
for alias in [spec["display_name"]] + spec.get("aliases", []):
|
||||
a = str(alias).strip()
|
||||
if a:
|
||||
m[a] = key
|
||||
m[a.lower()] = key
|
||||
return m
|
||||
|
||||
|
||||
PLATFORM_URLS, _PLATFORM_NAME_CN, _PLATFORM_PRIMARY_CN, _PLATFORM_ALIAS_TO_KEY = _build_platform_derived()
|
||||
_PLATFORM_ALIAS_MAP = _build_alias_map()
|
||||
|
||||
|
||||
def resolve_platform_key(name: str):
|
||||
"""将用户输入解析为内部 platform 键;无法识别返回 None。"""
|
||||
if name is None:
|
||||
def resolve_platform_key(name: str) -> Optional[str]:
|
||||
"""
|
||||
Resolve user input to a canonical platform key.
|
||||
Supports: key, display_name, any alias. Case-insensitive.
|
||||
Returns None if unrecognized.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
s = str(name).strip()
|
||||
if not s:
|
||||
return None
|
||||
sl = s.lower()
|
||||
if sl in PLATFORM_URLS:
|
||||
# Direct key match
|
||||
if sl in PLATFORMS:
|
||||
return sl
|
||||
return _PLATFORM_ALIAS_TO_KEY.get(s)
|
||||
# Alias map
|
||||
if sl in _PLATFORM_ALIAS_MAP:
|
||||
return _PLATFORM_ALIAS_MAP[sl]
|
||||
if s in _PLATFORM_ALIAS_MAP:
|
||||
return _PLATFORM_ALIAS_MAP[s]
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_phone_digits(phone: str) -> str:
|
||||
"""从输入中提取数字串,供号段规则校验与入库去重(不对外承诺可随意夹杂符号)。"""
|
||||
d = re.sub(r"\D", "", (phone or "").strip())
|
||||
if d.startswith("86") and len(d) >= 13:
|
||||
d = d[2:]
|
||||
return d
|
||||
def get_platform(key: str) -> Optional[dict[str, Any]]:
|
||||
"""Return the full platform spec dict for a validated key."""
|
||||
return PLATFORMS.get(key)
|
||||
|
||||
|
||||
def _is_valid_cn_mobile11(digits: str) -> bool:
|
||||
"""中国大陆 11 位手机号:1 开头,第二位 3–9,共 11 位数字。"""
|
||||
return bool(re.fullmatch(r"1[3-9]\d{9}", digits or ""))
|
||||
def list_platforms(domain: Optional[str] = None) -> list[dict[str, Any]]:
|
||||
"""List all platforms, optionally filtered by domain.
|
||||
Returns a list of dicts with metadata."""
|
||||
result = []
|
||||
for key, spec in PLATFORMS.items():
|
||||
if domain and spec["domain"] != domain:
|
||||
continue
|
||||
result.append({
|
||||
"platform_key": key,
|
||||
"display_name": spec["display_name"],
|
||||
"domain": spec["domain"],
|
||||
"provider_code": spec.get("provider_code"),
|
||||
"default_url": spec.get("default_url", ""),
|
||||
"aliases": spec.get("aliases", []),
|
||||
"capabilities": spec.get("capabilities", {}),
|
||||
"enabled": True,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def seed_platforms(conn) -> None:
|
||||
"""
|
||||
Upsert all platform definitions into the 'platforms' table.
|
||||
Must be called after init_db() in the same connection lifecycle.
|
||||
"""
|
||||
log = get_skill_logger()
|
||||
log.info("platform_seed_start")
|
||||
cur = conn.cursor()
|
||||
now = int(time.time())
|
||||
upsert_count = 0
|
||||
for key, spec in PLATFORMS.items():
|
||||
aliases_json = json.dumps(spec.get("aliases", []), ensure_ascii=False)
|
||||
capabilities_json = json.dumps(spec.get("capabilities", {}), ensure_ascii=False)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO platforms (platform_key, display_name, domain, provider_code,
|
||||
default_url, aliases_json, capabilities_json,
|
||||
enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(platform_key) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
domain = excluded.domain,
|
||||
provider_code = excluded.provider_code,
|
||||
default_url = excluded.default_url,
|
||||
aliases_json = excluded.aliases_json,
|
||||
capabilities_json = excluded.capabilities_json,
|
||||
enabled = 1,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
key,
|
||||
spec["display_name"],
|
||||
spec["domain"],
|
||||
spec.get("provider_code"),
|
||||
spec.get("default_url", ""),
|
||||
aliases_json,
|
||||
capabilities_json,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
upsert_count += 1
|
||||
conn.commit()
|
||||
log.info("platform_seed_done count=%s", upsert_count)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy compatibility helpers (for old account_service v1 code references)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Kept for backward compatibility so old imports don't break immediately.
|
||||
# New code should use PLATFORMS dict directly and resolve_platform_key().
|
||||
_PLATFORM_PRIMARY_CN: dict[str, str] = {
|
||||
k: spec["display_name"] for k, spec in PLATFORMS.items()
|
||||
}
|
||||
PLATFORM_URLS: dict[str, str] = {
|
||||
k: (spec.get("default_url") or "") for k, spec in PLATFORMS.items()
|
||||
}
|
||||
|
||||
|
||||
def _platform_list_cn_for_help() -> str:
|
||||
"""帮助文案:一行中文展示名(不重复内部键)。"""
|
||||
parts = []
|
||||
for k in sorted(PLATFORM_URLS.keys()):
|
||||
parts.append(_PLATFORM_PRIMARY_CN.get(k, k))
|
||||
return "、".join(parts)
|
||||
"""帮助文案:一行展示名,供 CLI 用法提示。"""
|
||||
return "、".join(
|
||||
spec["display_name"] for _k, spec in sorted(
|
||||
PLATFORMS.items(), key=lambda x: x[1]["display_name"]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""数据目录、profile 路径、CLI 路径调试输出。"""
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Data directory, profile path, CLI path debugging output.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -6,21 +9,20 @@ from typing import Optional
|
||||
|
||||
from jiangchang_skill_core.runtime_env import get_data_root, get_user_id
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.platforms import _PLATFORM_PRIMARY_CN
|
||||
|
||||
|
||||
def get_skill_data_dir():
|
||||
def get_skill_data_dir() -> str:
|
||||
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_db_path():
|
||||
def get_db_path() -> str:
|
||||
return os.path.join(get_skill_data_dir(), "account-manager.db")
|
||||
|
||||
|
||||
def _runtime_paths_debug_text():
|
||||
"""供排查「为何 list 没数据」:终端未注入环境变量时会落到 _anon 等默认目录,与网关里用的库不是同一个文件。"""
|
||||
def _runtime_paths_debug_text() -> str:
|
||||
"""For debugging: print current data path info to stderr."""
|
||||
env_root = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip() or "(未设置)"
|
||||
env_uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "(未设置→使用 _anon)"
|
||||
return (
|
||||
@@ -33,8 +35,8 @@ def _runtime_paths_debug_text():
|
||||
)
|
||||
|
||||
|
||||
def _maybe_print_paths_debug_cli():
|
||||
"""设置 JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 时,每次执行子命令前在 stderr 打印路径。"""
|
||||
def _maybe_print_paths_debug_cli() -> None:
|
||||
"""Set JIANGCHANG_ACCOUNT_DEBUG_PATHS=1 to print paths in stderr."""
|
||||
v = (os.getenv("JIANGCHANG_ACCOUNT_DEBUG_PATHS") or "").strip().lower()
|
||||
if v in ("1", "true", "yes", "on"):
|
||||
print(_runtime_paths_debug_text(), file=sys.stderr)
|
||||
@@ -49,7 +51,7 @@ _WIN_RESERVED_NAMES = frozenset(
|
||||
|
||||
|
||||
def _fs_safe_segment(name: str, fallback: str) -> str:
|
||||
"""单级目录名:去掉 Windows 非法字符,避免末尾点/空格;空则用 fallback。"""
|
||||
"""Single directory segment: strip Windows-unsafe chars; empty -> fallback."""
|
||||
t = _WIN_FS_FORBIDDEN.sub("_", (name or "").strip())
|
||||
t = t.rstrip(" .")
|
||||
if not t:
|
||||
@@ -59,22 +61,33 @@ def _fs_safe_segment(name: str, fallback: str) -> str:
|
||||
return t
|
||||
|
||||
|
||||
def get_default_profile_dir(
|
||||
platform_key: str, phone: Optional[str], account_id: Optional[int] = None
|
||||
def get_default_profile_dir_for_web(
|
||||
platform_key: str,
|
||||
label: str,
|
||||
login_id: Optional[str] = None,
|
||||
domain: str = "generic",
|
||||
) -> str:
|
||||
"""
|
||||
默认可读路径:profiles/<平台展示名>/<手机号>/(便于在资源管理器中辨认)。
|
||||
无手机号时退化为 profiles/<平台展示名>/no_phone_<id>/。
|
||||
Generate a browser profile directory path.
|
||||
|
||||
Structure:
|
||||
{DATA_ROOT}/{USER_ID}/account-manager/profiles/{domain}/{platform_key}/{safe_label}[_{login_id_suffix]/
|
||||
|
||||
This keeps profiles organized by domain and platform for easy
|
||||
identification in file explorer.
|
||||
"""
|
||||
label = _PLATFORM_PRIMARY_CN.get(platform_key, platform_key or "account")
|
||||
label_seg = _fs_safe_segment(label, _fs_safe_segment(platform_key or "", "platform"))
|
||||
ph = (phone or "").strip()
|
||||
if ph:
|
||||
phone_seg = _fs_safe_segment(ph, f"id_{account_id}" if account_id is not None else "phone")
|
||||
else:
|
||||
phone_seg = _fs_safe_segment(
|
||||
"", f"no_phone_{account_id}" if account_id is not None else "no_phone"
|
||||
)
|
||||
path = os.path.join(get_skill_data_dir(), "profiles", label_seg, phone_seg)
|
||||
label_seg = _fs_safe_segment(label, platform_key)
|
||||
login_suffix = ""
|
||||
if login_id:
|
||||
safe_login = _fs_safe_segment(login_id, "")
|
||||
if safe_login:
|
||||
login_suffix = f"_{safe_login[:16]}"
|
||||
path = os.path.join(
|
||||
get_skill_data_dir(),
|
||||
"profiles",
|
||||
_fs_safe_segment(domain, "generic"),
|
||||
_fs_safe_segment(platform_key, "unknown"),
|
||||
f"{label_seg}{login_suffix}",
|
||||
)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
Reference in New Issue
Block a user