Files
codex-app-server/cloud/sub2api.py
host f39ed3654e ci3: lint/mypy 第二阶段清理 + 覆盖率 76% -> 78%(门槛不动)
基底:#251 头 174b631(堆叠于 #242 feat-ci-expand 之上),
基线 origin/main aebc6238;本轨不改 #251 已定语义、不动
workflow 门槛、不删检查、不加 skip。

lint(ruff E,F@120):109 -> 10
- F401 43 -> 0:删未用导入;try/except 双绑定的名字出现
  “打地鼠”(删 except 分支后 try 分支又被标出),两分支
  均已核对正文无引用后清掉;MCP 三模块的 Error 名不删,
  改走 importlib 属性绑定(见下),两路径绑定都保留。
- E702 40 -> 0:全在 host.py main() 的 argparse 段,纯分行,
  tokenize 按分号 token 拆(含 2377 行多行语句只断首个分号);
  一次 5 空格缩进失误已回滚重做并以 ast + 全量 host 测试验证。
- E501 13 -> 0:host.py 5 项随 E702 分行连带清掉;mcp_server
  3 项把描述拆成相邻字面量(AST 比对 24 条工具描述与原值
  完全一致,注意 ruff 按东亚宽度计长,CJK 算 2);test_host
  5 项断开 git commit 参数表。
- E402 8 -> 10:生产侧 2 项清掉(db.py _re、github_app
  _base64 移到顶部,之前无引用、无重名);剩余 10 项全是
  tests/ 的 sys.path 前置 import(6 项旧 + 4 个新测试文件
  沿用同一既定模式),移上去会破坏单文件直跑,有意保留。
- F841 4 -> 0:只去绑定、不删调用(run_task、DB 入队、
  tarfile 上下文、git 提交),各有注释与原测试/新测试覆盖;
  host dispatch_from_branch 新增真实 git 仓库回归测试。
- F811 1 -> 0:server 本地 _row_get 与 peer_messages.row_get
  逐行相同,删本地定义,全部调用点解析到同一 import;
  新增 test_server_row_get(同一对象 + 三路行为)。

mypy(生产范围):24 -> 0,零新增 type: ignore
- no-redef 9 -> 0:MCP 三模块 try/except 两次 from-import
  改 importlib.import_module + 属性绑定(cloud 优先、flat
  copy 回落、内层 ImportError 同样回落,语义与原来一致;
  flat-copy 子进程测试证实回落后 __module__ 为顶层名);
  server _row_get 随 F811 消除。
- arg-type 10 -> 0:harness2_tasks shared 改 TypedDict
  逐键精确类型;CodexProbeAdapter 补 no-op validate_tree
  (_adapter_validate 本就跳过缺失方法,行为一致)。
- var-annotated 3 -> 0:sub2api _SHARED_ADMIN/_login_slots、
  verify-multiuser RESULTS 加精确注解。
- assignment/misc 2 -> 0:marker_tools ZoneInfo 先声明
  Any 再 try import(草稿先验 mypy 通过)。

bandit:总数 286 不变,High 仍 4 项,全在 host.py,本轨不改
(属第 3 项白名单轨范围),只做 triage:
- 1319 run_verify:无插值,整串验测命令,管道/&& 须 shell
  语义(新增 test_host_run_verify 断言管道与 && 真实生效)。
- 1497 notify:模板来自算子配置,三处插值全部 shlex.quote,
  证据为既有 test_notify_failure_quotes_shell_metacharacters
  (; touch 与 $(touch ) 不执行)。
- 2099/2103 goal verify:无插值,任务配置验测串,同 run_verify。
- 无新增 High;pip-audit 0 漏洞。

覆盖率(同口径三套件 coverage 计量):75.96% -> 77.87%
(22392 行/缺 5385 -> 22437 行/缺 4963,显示 76% -> 78%)
- client.py 0% -> 90%(pipe 假子进程 + main 分支)
- run_parallel.py 0% -> 97%、migrate 包装 0% -> 95%
- playwright_mcp 37% -> 62%、imagegen_mcp 47% -> 79%、
  websearch_mcp 51% -> 86%、deploy.py 56% -> 77%
- verify-multiuser 0% -> 27%(仅 check/req helper;main()
  是主机侧真实多用户实弹验证,按门禁不建生产任务、
  不以 mock 冒充,仍为缺口)
- host.py 55% -> 57%(文件 2020 -> 2060 行系 E702 分行
  的计量口径变化,非代码膨胀)
- 未到 85%:门槛保持 75% 不动,不删低覆盖模块出统计口径;
  缺口主力仍是 server.py / worker.py / host.py 大文件,
  下一阶段须针对性深测,非本轨快赢可及。

验证:三套件 1576 -> 1629 项全过(tests 127、cloud/tests
1449 skipped=4、deploy/tests 53);ruff E9 通过;mypy 0;
bandit High 4(同上);pip-audit 0 漏洞;试跑产物已删,
提交后工作树干净。
2026-11-03 02:48:54 +00:00

604 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""sub2api 网关接入:系统用管理 API 为每位用户建号发 key。
设计(2026-10-27 独立部署 sub2api 后):
- 系统侧只用管理 API 管用户:管理员登录 -> 建 sub2api 账号 ->
以该账号登录 -> 发 key -> key 存我方库,任务注入该用户的 key。
- key 创建是用户侧端点 POST /api/v1/keys(不是 /api/v1/api-keys,
后者只有管理员改组);管理员不能代用户建/删 key,所以 refresh_token
也要存下来:以后轮换/吊销 key 时靠它换用户 access token。
- key 明文存 PG:与 github_app_config 的 App 私钥同一信任边界
(库只在宿主 loopback 可达,服务端 env 同机);at-rest 加密是
后续项,不在本批假装已做。
- 零第三方依赖,只用标准库;HTTP 可注入 request 可调用对象,
便于单测(见 cloud/tests/test_sub2api.py)。
"""
import collections
import json
import os
import random
import secrets
import threading
import time
import urllib.error
import urllib.request
GATEWAY_TABLE_SQL = (
"CREATE TABLE IF NOT EXISTS user_gateway_keys("
"user_id TEXT PRIMARY KEY, "
"sub2api_user_id BIGINT NOT NULL, "
"api_key TEXT NOT NULL, "
"refresh_token TEXT, "
"created_at DOUBLE PRECISION, "
"updated_at DOUBLE PRECISION)")
class Sub2apiError(RuntimeError):
def __init__(self, message, status=None):
super().__init__(message)
self.status = status
def _default_request(method, url, body=None, token=None):
"""urllib 实现的 request:返回 (status, parsed_json_dict)。"""
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request(
url, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read() or b"{}"
return resp.status, json.loads(raw)
except urllib.error.HTTPError as e:
try:
data = json.loads(e.read() or b"{}")
except ValueError:
data = {}
return e.code, data
def gateway_origin():
"""sub2api origin(不带 /v1):未配则返回 None。"""
raw = os.environ.get("SUB2API_BASE_URL", "").strip()
return raw.rstrip("/") or None
def gateway_name():
raw = os.environ.get("CLOUD_GATEWAY", "xcmapi").strip().lower()
return raw if raw in ("xcmapi", "sub2api") else "xcmapi"
def gateway_group_name():
return os.environ.get("SUB2API_GROUP_NAME", "codex-system").strip() \
or "codex-system"
def opening_balance():
"""开通时授的初始余额(美元):默认 10,显式值优先。
新用户余额为 0 时网关鉴权层直接 403(余额门在任何上游路由
之前),所以开通必须同时授余额,否则发出的 key 立刻不可用。
显式配 0 表示不授(调用方自理);非法/负值回落默认 10。
"""
raw = os.environ.get(
"CLOUD_SUB2API_OPENING_BALANCE_USD", "").strip()
if not raw:
return 10.0
try:
value = float(raw)
except ValueError:
return 10.0
return value if value >= 0 else 10.0
def ensure_gateway_keys_table(conn):
conn.execute(GATEWAY_TABLE_SQL)
def get_user_gateway_key(conn, user_id):
"""只读:返回该用户的网关行 dict 或 None,绝不触发开通。"""
ensure_gateway_keys_table(conn)
cur = conn.execute(
"SELECT user_id, sub2api_user_id, api_key, refresh_token "
"FROM user_gateway_keys WHERE user_id = ?",
(user_id,))
row = cur.fetchone()
if row is None:
return None
try:
keys = ("user_id", "sub2api_user_id", "api_key", "refresh_token")
return dict(zip(keys, row))
except (TypeError, ValueError):
return None
# 进程内共享管理员会话:(origin, email) -> (token, refresh, expires_at)。
# 只复用 token 元组,不共用实例(实例不跨线程);注册与首任务开通
# 每次新建实例都要登录一次(实测约 111ms,占开通链路约 1/3),
# 共享后同一进程内第二次起免登录。401/刷新失败会回落到重登录,
# 密码轮换后下一次登录即用新密码刷新缓存,不会卡死。
_SHARED_ADMIN: dict[tuple[str, str], tuple[str | None, str | None, float]] = {}
_SHARED_ADMIN_LOCK = threading.RLock()
def _store_shared_session(admin):
key = getattr(admin, "_shared_key", None)
if key is None or not admin._token:
return
with _SHARED_ADMIN_LOCK:
_SHARED_ADMIN[key] = (admin._token, admin._refresh,
admin._expires_at)
def _evict_shared_session(admin):
key = getattr(admin, "_shared_key", None)
if key is None:
return
with _SHARED_ADMIN_LOCK:
_SHARED_ADMIN.pop(key, None)
def shared_admin_session(request=None):
"""带进程内 token 复用的管理员会话(生产开通路径默认用它)。
缓存命中且未过期时直接播种 token,免一次管理员登录;
未命中/已过期时在锁内登录一次再返回:突发冷缓存下
N 个并发开通也只登录一次(其余线程等锁后直接播种),
不会各登各的把网关刷出 N 次 bcrypt。调用方显式传
admin 的单测不受影响(不走这条工厂)。
"""
admin = Sub2apiAdmin.from_env(request=request)
key = (admin.origin, admin.email)
admin._shared_key = key
with _SHARED_ADMIN_LOCK:
cached = _SHARED_ADMIN.get(key)
if cached and cached[2] > time.time():
admin._token, admin._refresh, admin._expires_at = cached
return admin
# 缓存未命中:在锁内登录(_login 成功后经
# _store_shared_session 写回同一缓存,RLock 可重入)。
admin._login()
return admin
def _is_rate_limited_error(exc):
if getattr(exc, "status", None) == 429:
return True
return "too many requests" in str(exc).lower()
# 登录重试:网关登录端点 20 次/分钟/IP(固定窗口),突发注册
# 必然撞限;退避 2/4/8/16/32 秒(+0-2 秒抖动)可跨过 60 秒窗口。
_LOGIN_RETRY_ATTEMPTS = 6
_LOGIN_RETRY_BASE_SECONDS = 2.0
def _call_with_login_retry(fn):
"""登录类调用:先取进程内节流名额,再遇 429 退避重试。
节流名额保证单进程内登录不超过预算(网关 20 次/分钟/IP,
留 5 次余量给其它进程与人工登录);重试兜底跨进程竞争。
其它错误与耗尽次数后原错抛出。
"""
_acquire_login_slot()
delay = _LOGIN_RETRY_BASE_SECONDS
for attempt in range(_LOGIN_RETRY_ATTEMPTS):
try:
return fn()
except Sub2apiError as exc:
if not _is_rate_limited_error(exc) \
or attempt == _LOGIN_RETRY_ATTEMPTS - 1:
raise
time.sleep(delay + random.uniform(0, 2))
delay *= 2
# 进程内登录节流:60 秒窗口内最多 _LOGIN_BUDGET_PER_MINUTE 次。
# 只协调本进程;多进程突发仍靠 429 重试兜底(硬保证需共享
# Redis 节流,控制面目前没有 Redis 客户端,未做)。
_LOGIN_BUDGET_PER_MINUTE = 15
_LOGIN_WINDOW_SECONDS = 60.0
_login_slots: collections.deque[float] = collections.deque()
_login_slots_lock = threading.Lock()
def _acquire_login_slot():
"""等一个登录名额:窗口满了就等最早一次滑出窗口再进。
等待时长由排队位置决定(突发 100 路约 7 分钟);注册是
best-effort 语义,失败仍有 worker 首任务懒开通兜底,故不设
提前放弃——放弃会把有序排队打回同步重试波。
"""
while True:
with _login_slots_lock:
now = time.time()
while _login_slots \
and _login_slots[0] <= now - _LOGIN_WINDOW_SECONDS:
_login_slots.popleft()
if len(_login_slots) < _LOGIN_BUDGET_PER_MINUTE:
_login_slots.append(now)
return
wait = _login_slots[0] + _LOGIN_WINDOW_SECONDS - now
time.sleep(max(wait, 0))
class Sub2apiAdmin:
"""管理员会话:登录、建/删用户、查组。token 自动刷新一次。
每次实例只在进程内复用 token 元组(见 shared_admin_session):
实例本身不跨线程共用,只复用 (token, refresh, expires_at),
401 时走已有刷新->重登录链路自愈,密码轮换后下一次登录
即用新密码并刷新缓存,不会卡在旧 token 上。
"""
def __init__(self, origin, email, password, request=None):
if not origin:
raise Sub2apiError("SUB2API_BASE_URL 未配置")
if not email or not password:
raise Sub2apiError("SUB2API 管理员邮箱/密码未配置")
self.origin = origin
self.email = email
self.password = password
self.request = request or _default_request
self._token = None
self._refresh = None
self._expires_at = 0
self._shared_key = None
@classmethod
def from_env(cls, request=None):
return cls(gateway_origin(),
os.environ.get("SUB2API_ADMIN_EMAIL", ""),
os.environ.get("SUB2API_ADMIN_PASSWORD", ""),
request=request)
def _login(self):
def _once():
st, data = self.request(
"POST", self.origin + "/api/v1/auth/login",
{"email": self.email, "password": self.password})
payload = (data or {}).get("data") or {}
if st != 200 or not payload.get("access_token"):
raise Sub2apiError(
"sub2api 管理员登录失败: %s"
% ((data or {}).get("message") or st), status=st)
return payload
try:
payload = _call_with_login_retry(_once)
except Sub2apiError:
_evict_shared_session(self)
raise
self._token = payload["access_token"]
self._refresh = payload.get("refresh_token")
try:
ttl = int(payload.get("expires_in") or 3600)
except (TypeError, ValueError):
ttl = 3600
self._expires_at = time.time() + max(60, ttl - 60)
_store_shared_session(self)
def _refresh_token(self):
if not self._refresh:
self._login()
return
st, data = self.request(
"POST", self.origin + "/api/v1/auth/refresh",
{"refresh_token": self._refresh})
payload = (data or {}).get("data") or {}
if st == 200 and payload.get("access_token"):
self._token = payload["access_token"]
if payload.get("refresh_token"):
self._refresh = payload["refresh_token"]
try:
ttl = int(payload.get("expires_in") or 3600)
except (TypeError, ValueError):
ttl = 3600
self._expires_at = time.time() + max(60, ttl - 60)
_store_shared_session(self)
return
self._login()
def _call(self, method, path, body=None):
if not self._token or time.time() >= self._expires_at:
self._login()
st, data = self.request(method, self.origin + path, body,
token=self._token)
if st == 401:
self._refresh_token()
st, data = self.request(method, self.origin + path, body,
token=self._token)
return st, data
def create_user(self, email, password, username):
st, data = self._call("POST", "/api/v1/admin/users", {
"email": email, "password": password,
"username": username, "role": "user"})
payload = (data or {}).get("data") or {}
if st not in (200, 201) or payload.get("id") is None:
raise Sub2apiError(
"sub2api 建用户失败: %s"
% ((data or {}).get("message") or st), status=st)
return payload["id"]
def delete_user(self, sub_user_id):
st, _data = self._call(
"DELETE", "/api/v1/admin/users/%s" % sub_user_id)
return st in (200, 204)
def set_balance(self, sub_user_id, amount):
st, data = self._call(
"POST", "/api/v1/admin/users/%s/balance" % sub_user_id,
{"balance": amount, "operation": "set",
"notes": "codex provisioning opening balance"})
if st != 200:
raise Sub2apiError(
"sub2api 授余额失败: %s"
% ((data or {}).get("message") or st), status=st)
def list_groups(self):
st, data = self._call("GET", "/api/v1/admin/groups")
payload = (data or {}).get("data")
if st != 200:
raise Sub2apiError("sub2api 查组失败: %s" % st, status=st)
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in ("items", "list", "groups"):
if isinstance(payload.get(key), list):
return payload[key]
return []
def resolve_group_id(self):
raw = os.environ.get("SUB2API_GROUP_ID", "").strip()
if raw:
try:
return int(raw)
except ValueError:
raise Sub2apiError("SUB2API_GROUP_ID 非法: %r" % raw)
want = gateway_group_name()
for grp in self.list_groups():
if isinstance(grp, dict) and grp.get("name") == want:
if grp.get("id") is not None:
return grp["id"]
raise Sub2apiError("sub2api 组不存在: %s" % want)
def user_login(origin, email, password, request=None):
"""以 sub2api 用户身份登录:返回 (access_token, refresh_token)。
遇网关登录限流(429)时退避重试,见 _call_with_login_retry。
"""
req = request or _default_request
def _once():
st, data = req("POST", origin + "/api/v1/auth/login",
{"email": email, "password": password})
payload = (data or {}).get("data") or {}
if st != 200 or not payload.get("access_token"):
raise Sub2apiError(
"sub2api 用户登录失败: %s"
% ((data or {}).get("message") or st), status=st)
return payload["access_token"], payload.get("refresh_token")
return _call_with_login_retry(_once)
def user_create_key(origin, user_token, name, group_id, request=None):
"""以用户身份发 key:返回 (key_id, key_value)。"""
req = request or _default_request
body = {"name": name}
if group_id is not None:
body["group_id"] = group_id
st, data = req("POST", origin + "/api/v1/keys", body, token=user_token)
payload = (data or {}).get("data") or {}
value = payload.get("key") or payload.get("api_key") \
or payload.get("value")
if st not in (200, 201) or not value:
raise Sub2apiError(
"sub2api 发 key 失败: %s"
% ((data or {}).get("message") or st), status=st)
return payload.get("id"), value
def user_delete_key(origin, user_token, key_id, request=None):
req = request or _default_request
st, _data = req("DELETE", origin + "/api/v1/keys/%s" % key_id,
token=user_token)
return st in (200, 204)
def user_refresh_access(origin, refresh_token, request=None):
"""refresh_token 换 access_token:返回 (新 access, 新 refresh|None)。"""
req = request or _default_request
st, data = req("POST", origin + "/api/v1/auth/refresh",
{"refresh_token": refresh_token})
payload = (data or {}).get("data") or {}
if st != 200 or not payload.get("access_token"):
raise Sub2apiError("sub2api refresh 失败: %s" % st, status=st)
return payload["access_token"], payload.get("refresh_token")
def list_gateway_models(origin, api_key, request=None):
"""网关 /v1/models:返回模型 id 列表(key 无模型时为空列表)。"""
req = request or _default_request
st, data = req("GET", origin + "/v1/models", token=api_key)
if st != 200:
raise Sub2apiError("sub2api 查模型失败: %s" % st, status=st)
payload = (data or {}).get("data", data)
if isinstance(payload, dict):
payload = payload.get("data") or []
out = []
if isinstance(payload, list):
for item in payload:
if isinstance(item, dict) and item.get("id"):
out.append(item["id"])
elif isinstance(item, str):
out.append(item)
return out
def gateway_key_health(origin, api_key, request=None):
"""真健康检查:用已有 key 调一次 /v1/models。
返回 True(200,可用)/ False(401/403:key 已失效,或余额
为 0——网关在余额为 0 时鉴权层直接 403,两者都算死 key)/
"unknown"(其它状态或网络异常:网关本身不可用时不据此轮换,
避免把一次抖动当成 key 死亡、反复删号重建)。
"""
req = request or _default_request
try:
st, _data = req("GET", origin + "/v1/models", token=api_key)
except Exception:
return "unknown"
if st == 200:
return True
if st in (401, 403):
return False
return "unknown"
def _create_fresh_gateway_key(conn, user_id, admin=None, request=None):
"""建号->登录->发 key->授余额->入库,返回 (key, sub_id, key_id)。
入库失败时尽力清理刚建的 key 与账号(避免孤儿 key),再抛原错;
授余额失败同样走这条清理(余额为 0 的 key 在网关侧直接 403,
留着没有意义)。同一用户并发开通时建号撞 email 已存在,
则轮询复用胜出者入库的行。管理员工单默认走进程内共享会话
(同一进程内第二次起免管理员登录,见 shared_admin_session)。
调用方负责 conn 的提交语义:本函数在入库后
commit(开通是独立事务,不跟调用方的业务事务绑在一起)。
调用方须保证调用前本用户无有效行(ensure/rotate 先处理
旧行),否则会主键冲突(并发撞号除外,走轮询复用)。
"""
origin = gateway_origin()
if not origin:
raise Sub2apiError("SUB2API_BASE_URL 未配置,无法开通网关账号")
admin = admin or shared_admin_session(request=request)
req = request or _default_request
email = "codex-%s@codex.local" % user_id
username = "codex-" + str(user_id)[:12]
password = secrets.token_hex(16)
try:
sub_id = admin.create_user(email, password, username)
except Sub2apiError as exc:
if "already exists" not in str(exc).lower():
raise
# 并发开通竞态:同一用户的另一路已建号(email 唯一),
# 等它入库后复用那一行;等不到则原错抛出(任务失败后
# 可重跑,孤儿号场景下重跑仍撞号需管理员介入清理)。
for _ in range(5):
time.sleep(2)
row = get_user_gateway_key(conn, user_id)
if row is not None:
# 合并修复(P1 rebase 到 d254240):main 的 #92 竞态
# 分支原在单体 ensure 里只回一个 key 字符串;P1
# 抽出 _create_fresh 后契约是 3 元组,4 个调用点
# 都按 3 元组解包,裸字符串会被当成字符序列解包
# 而 ValueError。这里按契约回 3 元组,key_id 未知
# 故为 None(与 validate_or_rotate 的 ok 路一致)。
return (row["api_key"], row["sub2api_user_id"], None)
raise
key_id = None
user_token = None
try:
user_token, refresh = user_login(origin, email, password,
request=req)
group_id = admin.resolve_group_id()
key_id, key = user_create_key(origin, user_token,
"codex-task-key", group_id,
request=req)
amount = opening_balance()
if amount > 0:
admin.set_balance(sub_id, amount)
now = time.time()
conn.execute(
"INSERT INTO user_gateway_keys(user_id, sub2api_user_id, "
"api_key, refresh_token, created_at, updated_at) "
"VALUES(?, ?, ?, ?, ?, ?)",
(user_id, sub_id, key, refresh, now, now))
conn.commit()
except Exception:
if key_id is not None and user_token is not None:
try:
user_delete_key(origin, user_token, key_id, request=req)
except Exception:
pass
try:
admin.delete_user(sub_id)
except Exception:
pass
raise
return key, sub_id, key_id
def ensure_user_gateway_key(conn, user_id, admin=None, request=None):
"""幂等开通:已有行直接返回 key;否则走 _create_fresh 开通。
只保证“行存在”,不保证 key 仍有效:要校验有效性用
validate_or_rotate_gateway_key(死 key 会触发轮换重开)。
"""
ensure_gateway_keys_table(conn)
row = get_user_gateway_key(conn, user_id)
if row is not None:
return row["api_key"]
key, _sub_id, _key_id = _create_fresh_gateway_key(
conn, user_id, admin=admin, request=request)
return key
def validate_or_rotate_gateway_key(conn, user_id, admin=None, request=None):
"""懒开通/健康检查合一:返回 (status, key, sub_id, key_id)。
status:ok(旧 key 经 /v1/models 实测可用)/ provisioned
(无行,新开通)/ rotated(旧 key 经实测已死:401/403,
含余额为 0 的 403;删旧行后重开,死 key 不再被当成已开通)。
网关本身不可用(健康检查 unknown)时抛 Sub2apiError,调用方
应回 502、保留旧行不轮换;绝不在此时把“待开通”当成可用。
"""
ensure_gateway_keys_table(conn)
row = get_user_gateway_key(conn, user_id)
origin = gateway_origin()
if not origin:
raise Sub2apiError("SUB2API_BASE_URL 未配置,无法开通网关账号")
if row is None or not row.get("api_key"):
key, sub_id, key_id = _create_fresh_gateway_key(
conn, user_id, admin=admin, request=request)
return "provisioned", key, sub_id, key_id
health = gateway_key_health(origin, row["api_key"], request=request)
if health is True:
return "ok", row["api_key"], row.get("sub2api_user_id"), None
if health is False:
conn.execute(
"DELETE FROM user_gateway_keys WHERE user_id = ?",
(user_id,))
conn.commit()
key, sub_id, key_id = _create_fresh_gateway_key(
conn, user_id, admin=admin, request=request)
return "rotated", key, sub_id, key_id
raise Sub2apiError("sub2api 网关健康检查暂不可用,保留现有 key")
def rotate_user_gateway_key(conn, user_id, admin=None, request=None):
"""仅限本人调用的重新开通/轮换:删旧行后重开。
无旧行时直接走开通(与 validate 的 provisioned 等价);返回
(status, key, sub_id, key_id),调用方记审计时只可用 sub_id /
key_id,绝不记 key 明文。旧 key 即使只是“疑似失效”也会被
换掉:轮换是用户显式动作,不以健康检查为前提。
"""
ensure_gateway_keys_table(conn)
row = get_user_gateway_key(conn, user_id)
if row is not None:
conn.execute(
"DELETE FROM user_gateway_keys WHERE user_id = ?",
(user_id,))
conn.commit()
key, sub_id, key_id = _create_fresh_gateway_key(
conn, user_id, admin=admin, request=request)
return ("rotated" if row is not None else "provisioned",
key, sub_id, key_id)