Compare commits

...

3 Commits

Author SHA1 Message Date
23f4a2c11a chore: auto release commit (2026-06-19 09:53:29)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-06-19 09:53:29 +08:00
cb980704bf chore: auto release commit (2026-05-30 19:51:46)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-05-30 19:51:46 +08:00
a7bd6ff9d8 chore: auto release commit (2026-05-11 19:34:47)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-05-11 19:34:47 +08:00
9 changed files with 947 additions and 40 deletions

View File

@@ -1,3 +1,44 @@
# Vendored from jiangchang-platform-kit `tools/release.ps1`.
# Source: https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit/-/blob/main/tools/release.ps1
# The PyPI wheel does not ship this script; sync periodically when upstream workflow changes.
<#
.SYNOPSIS
One-command release script for skill repos.
.DESCRIPTION
- Optional auto-commit
- Push current branch
- Auto-increment semantic tag (vX.Y.Z)
- Create & push tag
- Fail fast on unsafe states
.EXAMPLES
# Safe mode (recommended): requires clean working tree
.\release.ps1
# Auto commit tracked/untracked changes then release
.\release.ps1 -AutoCommit -CommitMessage "chore: update skill config"
# Dry run (show what would happen)
.\release.ps1 -DryRun
# Custom tag prefix
.\release.ps1 -Prefix "v" -Message "正式发布"
.NOTES
Requires: git, PowerShell 5+
加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行。CI 会:
- 加密 scripts/(递归 PyArmor -r输出到包内 scripts/,与源码目录树一致)
- 复制 SKILL.md
- 复制 references/(排除 REQUIREMENTS.md
- 复制 assets/
- 复制 tests/
- 复制 evals/(除 scripts 外均为明文;若目录不存在则跳过;复制时排除常见缓存与运行产物)
本脚本在打 tag 前会做一次 scripts/ 结构自检,避免子目录未提交却仍发布。
#>
[CmdletBinding()] [CmdletBinding()]
param( param(
[string]$Prefix = "v", [string]$Prefix = "v",
@@ -11,13 +52,218 @@ param(
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path function Invoke-Git {
$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1" param([Parameter(Mandatory = $true)][string]$Args)
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript) Write-Host ">> git $Args" -ForegroundColor DarkGray
# 将 git 的 stderr 合并进 stdout避免 PowerShell 在 $ErrorActionPreference=Stop 下
if (-not (Test-Path $sharedScript)) { # 把 "remote: ..." / "warning: LF -> CRLF" 等非错误输出误判为异常。
throw "Shared release script not found: $sharedScript" & cmd /c "git $Args 2>&1"
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
} }
& $sharedScript @PSBoundParameters function Get-GitOutput {
exit $LASTEXITCODE param([Parameter(Mandatory = $true)][string]$Args)
$output = & cmd /c "git $Args" 2>$null
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
}
return @($output)
}
function Test-Repo {
& git rev-parse --is-inside-work-tree *> $null
return ($LASTEXITCODE -eq 0)
}
function Get-CurrentBranch {
$b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim()
return $b
}
function Get-StatusPorcelain {
$lines = @(Get-GitOutput "status --porcelain")
return $lines
}
function Parse-SemVerTag {
param(
[string]$Tag,
[string]$TagPrefix
)
$escaped = [regex]::Escape($TagPrefix)
$m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$")
if (-not $m.Success) { return $null }
return [pscustomobject]@{
Raw = $Tag
Major = [int]$m.Groups[1].Value
Minor = [int]$m.Groups[2].Value
Patch = [int]$m.Groups[3].Value
}
}
function Get-NextTag {
param([string]$TagPrefix)
$tags = Get-GitOutput "tag --list"
$parsed = @()
foreach ($t in $tags) {
$t = $t.Trim()
if (-not $t) { continue }
$obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix
if ($null -ne $obj) { $parsed += $obj }
}
if ($parsed.Count -eq 0) {
return "${TagPrefix}1.0.1"
}
$latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
}
function Assert-SkillReleasePackagingSources {
param([Parameter(Mandatory = $true)][string]$SkillRoot)
$scriptsDir = Join-Path $SkillRoot "scripts"
$mainPy = Join-Path $scriptsDir "main.py"
if (-not (Test-Path -LiteralPath $mainPy)) {
return
}
$text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
if ([string]::IsNullOrWhiteSpace($text)) {
return
}
$need = @()
if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' }
if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' }
if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' }
if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' }
if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' }
if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' }
if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' }
if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' }
foreach ($p in ($need | Select-Object -Unique)) {
$folder = Join-Path $scriptsDir $p
if (-not (Test-Path -LiteralPath $folder)) {
throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder"
}
}
$pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue)
Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray
}
function Ensure-CleanOrAutoCommit {
param(
[switch]$DoAutoCommit,
[switch]$NeedClean,
[switch]$IsDryRun,
[string]$Msg
)
$status = @(Get-StatusPorcelain)
if ($status.Length -eq 0) { return }
if ($NeedClean) {
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
& git status --short
throw "Abort: dirty working tree."
}
# 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启
$commitMsg = $Msg
if ([string]::IsNullOrWhiteSpace($commitMsg)) {
$commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))"
}
if (-not $DoAutoCommit) {
Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow
}
if ($IsDryRun) {
Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow
return
}
Invoke-Git "add -A"
Invoke-Git "commit -m `"$commitMsg`""
}
try {
Write-Host "=== Release Script Start ===" -ForegroundColor Cyan
if (-not (Test-Repo)) {
throw "Current directory is not a git repository."
}
$branch = Get-CurrentBranch
if ([string]::IsNullOrWhiteSpace($branch)) {
throw "Unable to determine current branch."
}
if ($branch -notin @("main", "master")) {
throw "Current branch is '$branch'. Release is only allowed from main/master."
}
Invoke-Git "fetch --tags --prune origin"
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
$skillRoot = (Get-GitOutput "rev-parse --show-toplevel" | Select-Object -First 1).Trim()
if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) {
Assert-SkillReleasePackagingSources -SkillRoot $skillRoot
}
# 用 cmd /c + 2>&1 将 stderr 合并进 stdout避免 PowerShell 在 $ErrorActionPreference=Stop
# 下把首次发布时 "fatal: ambiguous argument '@{u}'" 之类的 stderr 误判为异常。
$upstream = & cmd /c 'git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>&1'
$hasUpstream = ($LASTEXITCODE -eq 0)
if ($DryRun) {
if ($hasUpstream) {
Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow
} else {
Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow
}
} else {
if ($hasUpstream) {
Invoke-Git "push"
} else {
Invoke-Git "push -u origin $branch"
}
}
$nextTag = Get-NextTag -TagPrefix $Prefix
Write-Host "Next tag: $nextTag" -ForegroundColor Green
$existing = @(Get-GitOutput "tag --list `"$nextTag`"")
if ($existing.Length -gt 0) {
throw "Tag already exists: $nextTag"
}
if ($DryRun) {
Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
exit 0
}
Invoke-Git "tag -a $nextTag -m `"$Message`""
Invoke-Git "push origin $nextTag"
Write-Host "Release success: $nextTag" -ForegroundColor Green
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan
exit 0
}
catch {
Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red
exit 1
}

View File

@@ -0,0 +1,7 @@
# Optional: install shared SDK from Gitea Package Registry (matches vendored scripts/jiangchang_skill_core at release time).
#
# pip install -r requirements-platform-kit.txt ^
# --index-url https://git.jc2009.com/api/packages/client-jiangchang/pypi/simple/ ^
# --extra-index-url https://pypi.org/simple
#
jiangchang-platform-kit>=0.1.0

View File

@@ -3,7 +3,7 @@
CLI entry point: argv dispatch for account-manager v2. CLI entry point: argv dispatch for account-manager v2.
Subcommand tree: Subcommand tree:
platform list/get platform list/get/ensure
account add-web|add-secret|get|get-credential|list|pick-web|mark-session|mark-error|mark-used|open account add-web|add-secret|get|get-credential|list|pick-web|mark-session|mark-error|mark-used|open
init-master-key|export-credentials|import-credentials init-master-key|export-credentials|import-credentials
credential pick|resolve credential pick|resolve
@@ -42,6 +42,7 @@ from service.account_service import (
cmd_list, cmd_list,
cmd_list_json, cmd_list_json,
cmd_pick_web, cmd_pick_web,
cmd_platform_ensure,
cmd_platform_get, cmd_platform_get,
cmd_platform_list, cmd_platform_list,
) )
@@ -60,6 +61,8 @@ _USAGE = """account-manager v2 — Credential & Browser Profile Manager
平台管理: 平台管理:
platform list [--domain logistics|llm|content] platform list [--domain logistics|llm|content]
platform get <platform> platform get <platform>
platform ensure --key <key> --display-name <name> [--domain <d>]
[--url <url>] [--auth-strategy <s>]
账号管理: 账号管理:
account add-web --platform <p> --login-id <id> --login-id-type <type> account add-web --platform <p> --login-id <id> --login-id-type <type>
@@ -161,7 +164,7 @@ def main() -> None:
# ---- platform subcommand ---- # ---- platform subcommand ----
if cmd == "platform": if cmd == "platform":
if len(av) < 3: if len(av) < 3:
print("ERROR:CLI_BAD_ARGS platform 需要子命令list | get") print("ERROR:CLI_BAD_ARGS platform 需要子命令list | get | ensure")
sys.exit(1) sys.exit(1)
sub = av[2] sub = av[2]
if sub == "list": if sub == "list":
@@ -172,8 +175,30 @@ def main() -> None:
print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。") print("ERROR:CLI_BAD_ARGS platform get 需要 <platform>。")
sys.exit(1) sys.exit(1)
cmd_platform_get(av[3]) cmd_platform_get(av[3])
elif sub == "ensure":
import argparse as _ap
p = _ap.ArgumentParser(prog="platform ensure")
p.add_argument("--key", required=True)
p.add_argument("--display-name", default="")
p.add_argument("--domain", default="generic")
p.add_argument("--url", default="")
p.add_argument("--auth-strategy", default=None)
p.add_argument("--aliases", default=None,
help="JSON 数组字符串,如 '[\"1688\",\"阿里巴巴1688\"]'")
p.add_argument("--capabilities", default=None,
help='JSON 对象,如 \'{"web":true,"api_key":false,"rpa":true}\'')
args = p.parse_args(av[3:])
sys.exit(cmd_platform_ensure(
key=args.key,
display_name=args.display_name or args.key,
domain=args.domain,
url=args.url,
auth_strategy=args.auth_strategy,
aliases=args.aliases,
capabilities=args.capabilities,
))
else: else:
print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list 或 get") print("ERROR:CLI_BAD_ARGS platform 子命令必须是 list、get 或 ensure")
sys.exit(1) sys.exit(1)
# ---- account subcommand ---- # ---- account subcommand ----

View File

@@ -763,6 +763,49 @@ def get_active_lease_by_token(conn, lease_token: str) -> Optional[dict[str, Any]
} }
def upsert_platform(
conn,
key: str,
display_name: str,
domain: str = "generic",
provider_code: str | None = None,
default_url: str = "",
aliases_json: str = "[]",
capabilities_json: str = '{"web": true, "api_key": false, "rpa": true}',
default_auth_strategy: str | None = None,
) -> None:
"""幂等地注册或更新平台到 DB。"""
now = int(time.time())
if provider_code is None:
provider_code = key
conn.execute(
"""
INSERT INTO platforms (
platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
enabled, default_auth_strategy, 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,
default_auth_strategy = COALESCE(excluded.default_auth_strategy,
platforms.default_auth_strategy),
updated_at = excluded.updated_at
""",
(
key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
default_auth_strategy, now, now,
),
)
conn.commit()
# ============================================================================ # ============================================================================
# Internal helpers # Internal helpers
# ============================================================================ # ============================================================================

View File

@@ -42,6 +42,7 @@ from db.accounts_repo import (
update_account_last_used, update_account_last_used,
update_account_session_status, update_account_session_status,
update_credential_last_used, update_credential_last_used,
upsert_platform,
) )
from db.auth_strategy import ( from db.auth_strategy import (
ALL_AUTH_STRATEGIES, ALL_AUTH_STRATEGIES,
@@ -62,9 +63,11 @@ from db.connection import get_conn, init_db
from util.constants import SKILL_SLUG from util.constants import SKILL_SLUG
from util.logging_config import get_skill_logger from util.logging_config import get_skill_logger
from util.platforms import ( from util.platforms import (
PLATFORMS, DEFAULT_CAPABILITIES_JSON,
_platform_list_cn_for_help, _platform_list_cn_for_help,
resolve_platform_key, get_platform_spec,
is_valid_platform_key_format,
resolve_platform_key_dynamic,
seed_platforms, seed_platforms,
) )
from util.runtime_paths import ( from util.runtime_paths import (
@@ -189,16 +192,28 @@ def _remove_profile_dir(path: str) -> None:
def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]: def _resolve_or_fail(input_name: str, hint: str = "") -> Optional[str]:
"""Resolve platform key or print ERROR:INVALID_PLATFORM and return None.""" """Resolve platform key or emit structured JSON error and return None."""
key = resolve_platform_key((input_name or "").strip()) init_db()
if not key: conn = get_conn()
_say(f"ERROR:INVALID_PLATFORM 无法识别的平台「{input_name}」。") try:
if hint: result = resolve_platform_key_dynamic(input_name, conn=conn)
finally:
conn.close()
if result.ok:
return result.platform_key
code = result.error_code or "INVALID_PLATFORM"
if not code.startswith("ERROR:"):
code = f"ERROR:{code}"
_say(_err_json(code, result.message or "无法识别平台。"))
if hint and result.error_code == "PLATFORM_NOT_REGISTERED":
_say(hint) _say(hint)
else: elif hint:
_say(hint)
elif result.error_code not in ("PLATFORM_NOT_REGISTERED", "INVALID_PLATFORM_KEY", "PLATFORM_DISABLED"):
_say("支持:" + _platform_list_cn_for_help()) _say("支持:" + _platform_list_cn_for_help())
return None return None
return key
# ============================================================================ # ============================================================================
@@ -235,7 +250,65 @@ def cmd_platform_get(input_key: str) -> None:
if plat: if plat:
_say(_ok_json(plat)) _say(_ok_json(plat))
else: else:
_say(_err_json("ERROR:INVALID_PLATFORM", f"Platform '{key}' not found in DB.")) _say(_err_json(
"ERROR:PLATFORM_NOT_REGISTERED",
f"Platform '{key}' not found in DB.",
))
def cmd_platform_ensure(
key: str,
display_name: str,
domain: str = "generic",
url: str = "",
auth_strategy: Optional[str] = None,
aliases: Optional[str] = None,
capabilities: Optional[str] = None,
) -> int:
"""
幂等地注册或更新平台到 DB。
输出单行 JSON{"ok": true, "platform_key": "xxx", "action": "created|updated"}
"""
log = get_skill_logger()
key = (key or "").strip()
display_name = (display_name or key).strip()
if not key:
_say('ERROR:INVALID_ARGS platform ensure 需要 --key 参数')
return 1
if not is_valid_platform_key_format(key):
_say(_err_json(
"ERROR:INVALID_PLATFORM_KEY",
f"平台 key 格式非法:「{key}」。建议使用小写字母、数字、下划线或连字符。",
))
return 1
aliases_json = aliases if aliases else json.dumps([key, display_name], ensure_ascii=False)
capabilities_json = capabilities if capabilities else DEFAULT_CAPABILITIES_JSON
init_db()
conn = get_conn()
try:
existed = bool(
conn.execute(
"SELECT 1 FROM platforms WHERE platform_key = ?", (key,)
).fetchone()
)
upsert_platform(
conn,
key=key,
display_name=display_name,
domain=domain,
default_url=url,
aliases_json=aliases_json,
capabilities_json=capabilities_json,
default_auth_strategy=auth_strategy,
)
finally:
conn.close()
action = "updated" if existed else "created"
log.info("platform_ensure key=%s action=%s", key, action)
_say(json.dumps({"ok": True, "platform_key": key, "action": action}, ensure_ascii=False))
return 0
# ============================================================================ # ============================================================================
@@ -269,8 +342,14 @@ def cmd_account_add_web(
return return
init_db() init_db()
platform_spec = PLATFORMS.get(key, {})
now = int(time.time()) now = int(time.time())
conn = get_conn()
try:
seed_platforms(conn)
platform_spec = get_platform_spec(key, conn=conn)
finally:
conn.close()
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {environment} {role}" safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {environment} {role}"
extra = {} extra = {}
if extra_json_str: if extra_json_str:
@@ -416,7 +495,12 @@ def cmd_account_add_secret(
return return
init_db() init_db()
platform_spec = PLATFORMS.get(key, {}) conn = get_conn()
try:
seed_platforms(conn)
platform_spec = get_platform_spec(key, conn=conn)
finally:
conn.close()
now = int(time.time()) now = int(time.time())
safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}" safe_label = (label or "").strip() or f"{platform_spec.get('display_name', key)} {credential_type}"
@@ -1289,7 +1373,8 @@ def cmd_delete_by_platform(platform_input: str) -> None:
) )
conn.commit() conn.commit()
log.info("delete_by_platform_done platform=%s count=%s", key, len(rows)) log.info("delete_by_platform_done platform=%s count=%s", key, len(rows))
_say(f"✅ 已删除 {PLATFORMS.get(key, {}).get('display_name', key)} 下共 {len(rows)} 条账号。") display = get_platform_spec(key, conn=conn).get("display_name", key)
_say(f"✅ 已删除 {display} 下共 {len(rows)} 条账号。")
finally: finally:
conn.close() conn.close()

View File

@@ -13,8 +13,8 @@ import subprocess
import sys import sys
import tempfile import tempfile
from db.connection import init_db from db.connection import get_conn, init_db
from db.accounts_repo import get_account_by_id from db.accounts_repo import get_account_by_id, get_platform_from_db
from util.logging_config import ( from util.logging_config import (
get_skill_logger, get_skill_logger,
subprocess_env_with_trace, subprocess_env_with_trace,
@@ -74,6 +74,31 @@ def _print_browser_install_hint():
print(" • Edge: https://www.microsoft.com/zh-cn/edge") print(" • Edge: https://www.microsoft.com/zh-cn/edge")
def resolve_account_browser_url(account: dict, conn=None) -> str:
"""account.url 优先,其次 platforms 表 default_url最后内置 PLATFORMS。"""
url = (account.get("url") or "").strip()
if url:
return url
pk = (account.get("platform_key") or "").strip()
if not pk:
return ""
close_conn = False
if conn is None:
init_db()
conn = get_conn()
close_conn = True
try:
plat = get_platform_from_db(conn, pk)
if plat and (plat.get("default_url") or "").strip():
return plat["default_url"].strip()
finally:
if close_conn:
conn.close()
return (PLATFORMS.get(pk, {}).get("default_url") or "").strip()
def cmd_open(account_id): def cmd_open(account_id):
""" """
打开该账号的持久化浏览器,仅用于肉眼核对。 打开该账号的持久化浏览器,仅用于肉眼核对。
@@ -109,9 +134,11 @@ def cmd_open(account_id):
return return
os.makedirs(profile_dir, exist_ok=True) os.makedirs(profile_dir, exist_ok=True)
# Use account url, fall back to platform default conn = get_conn()
platform_spec = PLATFORMS.get(target["platform_key"], {}) try:
url = (target.get("url") or "").strip() or platform_spec.get("default_url", "") url = resolve_account_browser_url(target, conn=conn)
finally:
conn.close()
log.info( log.info(
"browser_open_context profile_dir=%s url=%s channel=%s platform=%s", "browser_open_context profile_dir=%s url=%s channel=%s platform=%s",

View File

@@ -6,11 +6,17 @@ Central authoritative registry for all platforms (logistics, LLM, content, etc.)
Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms(). Import and use PLATFORMS dict, resolve_platform_key(), and seed_platforms().
""" """
import json import json
import re
import time import time
from dataclasses import dataclass
from typing import Any, Optional from typing import Any, Optional
from util.logging_config import get_skill_logger from util.logging_config import get_skill_logger
PLATFORM_KEY_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,63}$")
DEFAULT_CAPABILITIES_JSON = '{"web": true, "api_key": false, "rpa": true}'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Platform specification fields: # Platform specification fields:
# display_name Human-readable name (e.g. "Maersk") # display_name Human-readable name (e.g. "Maersk")
@@ -309,7 +315,7 @@ _PLATFORM_ALIAS_MAP = _build_alias_map()
def resolve_platform_key(name: str) -> Optional[str]: def resolve_platform_key(name: str) -> Optional[str]:
""" """
Resolve user input to a canonical platform key. Resolve user input to a canonical platform key (built-in PLATFORMS only).
Supports: key, display_name, any alias. Case-insensitive. Supports: key, display_name, any alias. Case-insensitive.
Returns None if unrecognized. Returns None if unrecognized.
""" """
@@ -330,8 +336,154 @@ def resolve_platform_key(name: str) -> Optional[str]:
return None return None
def is_valid_platform_key_format(name: str) -> bool:
"""平台 key 格式:^[a-z0-9][a-z0-9_-]{1,63}$"""
s = (name or "").strip().lower()
if not s:
return False
return bool(PLATFORM_KEY_PATTERN.match(s))
def _parse_aliases_json(val: Any) -> list[str]:
if val is None:
return []
try:
data = json.loads(val) if isinstance(val, str) else val
if isinstance(data, list):
return [str(a).strip() for a in data if str(a).strip()]
except (json.JSONDecodeError, TypeError, ValueError):
pass
return []
@dataclass(frozen=True)
class PlatformResolveResult:
platform_key: Optional[str] = None
error_code: Optional[str] = None
message: Optional[str] = None
@property
def ok(self) -> bool:
return bool(self.platform_key) and not self.error_code
def _resolve_platform_from_db(conn, raw: str) -> PlatformResolveResult:
"""在 platforms 表中解析:精确 keyenabled=1与 aliases_json。"""
raw_stripped = (raw or "").strip()
raw_lower = raw_stripped.lower()
if is_valid_platform_key_format(raw_stripped):
row = conn.execute(
"SELECT platform_key, enabled FROM platforms WHERE platform_key = ?",
(raw_lower,),
).fetchone()
if row:
if not row[1]:
return PlatformResolveResult(
error_code="PLATFORM_DISABLED",
message=f"平台 {row[0]} 已禁用。",
)
return PlatformResolveResult(platform_key=row[0])
rows = conn.execute(
"SELECT platform_key, enabled, aliases_json FROM platforms"
).fetchall()
for platform_key, enabled, aliases_json in rows:
if not enabled:
continue
names = {platform_key.lower()}
for alias in _parse_aliases_json(aliases_json):
names.add(alias.lower())
names.add(alias)
if raw_lower in names or raw_stripped in names:
return PlatformResolveResult(platform_key=platform_key)
return PlatformResolveResult()
def resolve_platform_key_dynamic(input_name: str, conn=None) -> PlatformResolveResult:
"""
统一平台解析:内置 PLATFORMS → DB platforms 表 → 错误分类。
"""
raw = (input_name or "").strip()
if not raw:
return PlatformResolveResult(
error_code="INVALID_PLATFORM",
message="平台名不能为空。",
)
builtin = resolve_platform_key(raw)
if builtin:
return PlatformResolveResult(platform_key=builtin)
close_conn = False
if conn is None:
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
close_conn = True
try:
db_result = _resolve_platform_from_db(conn, raw)
if db_result.ok or db_result.error_code:
return db_result
finally:
if close_conn:
conn.close()
if not is_valid_platform_key_format(raw):
return PlatformResolveResult(
error_code="INVALID_PLATFORM_KEY",
message=(
f"平台 key 格式非法:「{raw}」。"
"建议使用小写字母、数字、下划线或连字符,例如 jiangchang。"
),
)
key_hint = raw.lower()
return PlatformResolveResult(
error_code="PLATFORM_NOT_REGISTERED",
message=(
f"平台 {key_hint} 尚未注册,请先执行 "
f"account platform ensure --key {key_hint} --display-name <名称> [--url <url>]"
),
)
def get_platform_spec(platform_key: str, conn=None) -> dict[str, Any]:
"""内置 PLATFORMS 优先结构;自定义平台从 DB platforms 表读取。"""
spec = PLATFORMS.get(platform_key)
if spec:
return dict(spec)
close_conn = False
if conn is None:
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
close_conn = True
try:
from db.accounts_repo import get_platform_from_db
plat = get_platform_from_db(conn, platform_key)
if plat:
return {
"display_name": plat["display_name"],
"domain": plat.get("domain") or "generic",
"provider_code": plat.get("provider_code") or platform_key,
"default_url": plat.get("default_url") or "",
"aliases": plat.get("aliases") or [],
"capabilities": plat.get("capabilities") or {},
}
finally:
if close_conn:
conn.close()
return {}
def get_platform(key: str) -> Optional[dict[str, Any]]: def get_platform(key: str) -> Optional[dict[str, Any]]:
"""Return the full platform spec dict for a validated key.""" """Return the full platform spec dict for a validated built-in key."""
return PLATFORMS.get(key) return PLATFORMS.get(key)

View File

@@ -0,0 +1,246 @@
# -*- coding: utf-8 -*-
"""自定义平台动态解析、platform ensure、add-web / pick-web / browser URL。"""
from __future__ import annotations
import json
import os
import pytest
JIANGCHANG_URL = "https://jc2009.com/admin/index.html"
def _ensure_jiangchang(clean_db) -> None:
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(
key="jiangchang",
display_name="匠厂后台",
domain="generic",
url=JIANGCHANG_URL,
auth_strategy=None,
aliases=json.dumps(["jc", "匠厂后台"], ensure_ascii=False),
capabilities=None,
)
assert rc == 0
class TestBuiltinPlatformsUnchanged:
def test_douyin_alias(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("抖音", conn=clean_db)
assert result.ok
assert result.platform_key == "douyin"
def test_toutiao_key(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("toutiao", conn=clean_db)
assert result.ok
assert result.platform_key == "toutiao"
class TestDynamicPlatformResolution:
def test_unregistered_valid_key(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("unknown_custom", conn=clean_db)
assert not result.ok
assert result.error_code == "PLATFORM_NOT_REGISTERED"
assert "platform ensure" in (result.message or "")
def test_invalid_platform_key_format(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("江厂 空格", conn=clean_db)
assert not result.ok
assert result.error_code == "INVALID_PLATFORM_KEY"
def test_empty_platform(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("", conn=clean_db)
assert result.error_code == "INVALID_PLATFORM"
def test_registered_jiangchang(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
_ensure_jiangchang(clean_db)
result = resolve_platform_key_dynamic("jiangchang", conn=clean_db)
assert result.ok
assert result.platform_key == "jiangchang"
def test_registered_alias_jc(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
_ensure_jiangchang(clean_db)
result = resolve_platform_key_dynamic("jc", conn=clean_db)
assert result.ok
assert result.platform_key == "jiangchang"
class TestPlatformEnsure:
def test_ensure_returns_json(self, clean_db, capsys):
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(
key="jiangchang",
display_name="匠厂后台",
url=JIANGCHANG_URL,
)
assert rc == 0
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["ok"] is True
assert payload["platform_key"] == "jiangchang"
assert payload["action"] in ("created", "updated")
class TestCustomPlatformAccounts:
def test_add_web_after_ensure(self, clean_db):
from service.account_service import cmd_account_add_web
from db.accounts_repo import find_accounts
_ensure_jiangchang(clean_db)
cmd_account_add_web(
platform_input="jiangchang",
login_id="manual",
login_id_type="manual",
label="匠厂后台 手工登录",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
accs = find_accounts(platform_key="jiangchang")
assert len(accs) == 1
assert accs[0]["platform_key"] == "jiangchang"
assert accs[0]["url"] == JIANGCHANG_URL
assert accs[0]["profile_dir"]
def test_pick_web_after_ensure(self, clean_db, capsys):
from service.account_service import cmd_account_add_web, cmd_account_pick_web
_ensure_jiangchang(clean_db)
cmd_account_add_web(
platform_input="jiangchang",
login_id="manual",
login_id_type="manual",
label="匠厂后台",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
capsys.readouterr()
cmd_account_pick_web(
platform_input="jiangchang",
do_lease=True,
holder="test-holder",
ttl_sec=300,
)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["platform_key"] == "jiangchang"
assert payload["profile_dir"]
assert payload.get("lease_token")
def test_pick_web_unregistered_returns_structured_error(self, clean_db, capsys):
from service.account_service import cmd_account_pick_web
cmd_account_pick_web(platform_input="unknown_custom")
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
assert "platform ensure" in payload["error"]["message"]
def test_add_web_unregistered_returns_structured_error(self, clean_db, capsys):
from service.account_service import cmd_account_add_web
cmd_account_add_web(
platform_input="unknown_custom",
login_id="manual",
login_id_type="manual",
label="x",
tenant_id=None,
environment="production",
role="admin",
provider_code=None,
url=None,
extra_json_str=None,
profile_dir_override=None,
)
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["success"] is False
assert payload["error"]["code"] == "ERROR:PLATFORM_NOT_REGISTERED"
class TestBrowserServiceUrlResolution:
def test_custom_platform_default_url_from_db(self, clean_db):
from db.accounts_repo import insert_account, upsert_platform
from service.browser_service import resolve_account_browser_url
import time
now = int(time.time())
upsert_platform(
clean_db,
key="jiangchang",
display_name="匠厂后台",
default_url=JIANGCHANG_URL,
)
aid = insert_account(
conn=clean_db,
platform_key="jiangchang",
account_label="test",
login_id=None,
login_id_type="manual",
tenant_id=None,
environment="production",
role="admin",
provider_code="jiangchang",
profile_dir="/tmp/test-profile",
url="",
extra_json="{}",
now=now,
auth_strategy="qr_code_manual",
)
clean_db.commit()
account = {
"id": aid,
"platform_key": "jiangchang",
"url": "",
}
assert resolve_account_browser_url(account, conn=clean_db) == JIANGCHANG_URL
def test_account_url_overrides_platform_default(self, clean_db):
from service.browser_service import resolve_account_browser_url
account = {
"platform_key": "jiangchang",
"url": "https://custom.example.com/admin",
}
assert (
resolve_account_browser_url(account, conn=clean_db)
== "https://custom.example.com/admin"
)
class TestPlatformKeyFormatValidation:
def test_ensure_rejects_invalid_key(self, clean_db, capsys):
from service.account_service import cmd_platform_ensure
rc = cmd_platform_ensure(key="江厂 空格", display_name="bad")
assert rc == 1
out = capsys.readouterr().out.strip()
payload = json.loads(out)
assert payload["error"]["code"] == "ERROR:INVALID_PLATFORM_KEY"

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""resolve_platform_key_dynamic 与 JSON 解析边界。"""
from __future__ import annotations
import json
import pytest
class TestResolvePlatformKeyDynamicParsing:
def test_builtin_still_works_without_db_row(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
result = resolve_platform_key_dynamic("马士基", conn=clean_db)
assert result.platform_key == "maersk"
def test_disabled_platform_returns_disabled(self, clean_db):
from db.accounts_repo import upsert_platform
from util.platforms import resolve_platform_key_dynamic
upsert_platform(
clean_db,
key="custom_off",
display_name="Off",
default_url="",
)
clean_db.execute(
"UPDATE platforms SET enabled = 0 WHERE platform_key = ?",
("custom_off",),
)
clean_db.commit()
result = resolve_platform_key_dynamic("custom_off", conn=clean_db)
assert result.error_code == "PLATFORM_DISABLED"
def test_get_platform_spec_from_db(self, clean_db):
from db.accounts_repo import upsert_platform
from util.platforms import get_platform_spec
upsert_platform(
clean_db,
key="jiangchang",
display_name="匠厂后台",
default_url="https://jc2009.com/admin/index.html",
)
spec = get_platform_spec("jiangchang", conn=clean_db)
assert spec["display_name"] == "匠厂后台"
assert spec["default_url"] == "https://jc2009.com/admin/index.html"
def test_malformed_aliases_json_does_not_crash(self, clean_db):
from util.platforms import resolve_platform_key_dynamic
import time
now = int(time.time())
clean_db.execute(
"""
INSERT INTO platforms (
platform_key, display_name, domain, provider_code,
default_url, aliases_json, capabilities_json,
enabled, default_auth_strategy, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, NULL, ?, ?)
""",
(
"bad_alias",
"Bad",
"generic",
"bad_alias",
"",
"not-json",
"{}",
now,
now,
),
)
clean_db.commit()
result = resolve_platform_key_dynamic("bad_alias", conn=clean_db)
assert result.platform_key == "bad_alias"