refactor: 平台改为代码内置 provider 架构,一次性迁移现有数据 - 新增 app/providers:BalanceProvider 基类 + DeepSeek 适配器 + 注册表 - platforms 表去除 method/url/headers/body/balance_path,新增 provider_id - 现有数据库已一次性迁移(备份 data/monitor.db.bak-v1),不保留迁移工具 - 平台 UI 改为选择内置提供方;fetcher/monitor 走 provider 构建请求与解析 - 表达式引擎(运算符/函数)随架构保留,供 provider 内部使用 - 测试更新至 provider 模式,共 92 个
This commit is contained in:
+1
-1
@@ -14,7 +14,7 @@ PLATFORM = {"name": "OpenAI", "currency": "USD"}
|
||||
|
||||
def _insert_account(test_db, armed=1, enabled=1):
|
||||
with db.get_conn() as conn:
|
||||
conn.execute("INSERT INTO platforms (id, name, url, balance_path) VALUES (1, 'OpenAI', 'http://x', 'b')")
|
||||
conn.execute("INSERT INTO platforms (id, provider_id, name) VALUES (1, 'deepseek', 'OpenAI')")
|
||||
conn.execute(
|
||||
"""INSERT INTO accounts (id, platform_id, name, api_key, threshold, enabled, alert_armed)
|
||||
VALUES (1, 1, '主账号', 'a2tva2V5', 20.0, ?, ?)""",
|
||||
|
||||
+18
-10
@@ -45,10 +45,7 @@ class TestAuth:
|
||||
|
||||
|
||||
PLATFORM_PAYLOAD = {
|
||||
"name": "OpenAI", "currency": "USD", "icon": "OpenAI", "method": "GET",
|
||||
"url": "https://x.test?key={{apiKey}}",
|
||||
"headers": {"Authorization": "Bearer {{apiKey}}"},
|
||||
"body": "", "balance_path": "data.balance",
|
||||
"provider_id": "deepseek", "name": "DeepSeek-Test",
|
||||
"interval_seconds": 120, "retry_count": 1, "timeout_seconds": 15,
|
||||
"enabled": True, "note": "",
|
||||
}
|
||||
@@ -60,11 +57,13 @@ class TestPlatforms:
|
||||
pid = client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).json()["id"]
|
||||
|
||||
lst = client.get("/api/platforms", headers=h).json()
|
||||
assert len(lst) == 1 and lst[0]["account_count"] == 0 and lst[0]["url"] == PLATFORM_PAYLOAD["url"]
|
||||
assert len(lst) == 1 and lst[0]["account_count"] == 0
|
||||
assert lst[0]["provider_id"] == "deepseek"
|
||||
assert lst[0]["currency"] == "CNY" # 默认取 provider 的货币
|
||||
|
||||
upd = client.put(f"/api/platforms/{pid}", json={"currency": "CNY", "interval_seconds": 300}, headers=h)
|
||||
upd = client.put(f"/api/platforms/{pid}", json={"currency": "USD", "interval_seconds": 300}, headers=h)
|
||||
assert upd.status_code == 200
|
||||
assert client.get("/api/platforms", headers=h).json()[0]["currency"] == "CNY"
|
||||
assert client.get("/api/platforms", headers=h).json()[0]["currency"] == "USD"
|
||||
|
||||
assert client.delete(f"/api/platforms/{pid}", headers=h).status_code == 200
|
||||
assert client.get("/api/platforms", headers=h).json() == []
|
||||
@@ -74,10 +73,19 @@ class TestPlatforms:
|
||||
client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h)
|
||||
assert client.post("/api/platforms", json=PLATFORM_PAYLOAD, headers=h).status_code == 409
|
||||
|
||||
def test_unknown_provider_400(self, client):
|
||||
h = _auth(client)
|
||||
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, provider_id="nope"), headers=h).status_code == 400
|
||||
|
||||
def test_validation_errors(self, client):
|
||||
h = _auth(client)
|
||||
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, method="DELETE"), headers=h).status_code == 422
|
||||
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, url=""), headers=h).status_code == 422
|
||||
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, name=""), headers=h).status_code == 422
|
||||
assert client.post("/api/platforms", json=dict(PLATFORM_PAYLOAD, interval_seconds=5), headers=h).status_code == 422
|
||||
|
||||
def test_providers_list(self, client):
|
||||
h = _auth(client)
|
||||
provs = client.get("/api/providers", headers=h).json()
|
||||
assert any(p["id"] == "deepseek" and p["currency"] == "CNY" for p in provs)
|
||||
|
||||
def test_delete_cascades_accounts(self, client, test_db):
|
||||
from app import db
|
||||
@@ -100,7 +108,7 @@ class TestAccounts:
|
||||
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "主", "api_key": "sk-secret", "threshold": 10}, headers=h).json()["id"]
|
||||
acc = client.get("/api/accounts", headers=h).json()[0]
|
||||
assert acc["id"] == aid and acc["api_key"] == "sk-secret"
|
||||
assert acc["platform_name"] == "OpenAI" and acc["currency"] == "USD"
|
||||
assert acc["platform_name"] == "DeepSeek-Test" and acc["currency"] == "CNY"
|
||||
|
||||
def test_key_stored_base64(self, client, test_db):
|
||||
from app import db
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""余额表达式引擎测试:运算符、函数、兼容性与安全。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.expr import ExprError, evaluate_balance
|
||||
|
||||
DATA = {
|
||||
"data": {
|
||||
"balance": "1.50",
|
||||
"total": 100,
|
||||
"used": 40,
|
||||
"fee": 2.5,
|
||||
},
|
||||
"list": [
|
||||
{"x": 1, "y": 10},
|
||||
{"x": 2, "y": 20},
|
||||
],
|
||||
"balances": [1, 2, 3],
|
||||
"nested": {"a": {"b": 6}},
|
||||
}
|
||||
|
||||
|
||||
class TestPlainPath:
|
||||
def test_dot_path(self):
|
||||
assert evaluate_balance({"data": {"balance": 12.5}}, "data.balance") == 12.5
|
||||
|
||||
def test_array_index(self):
|
||||
assert evaluate_balance(DATA, "list[0].x") == 1
|
||||
|
||||
def test_dollar_prefix(self):
|
||||
assert evaluate_balance(DATA, "$.data.total") == 100
|
||||
|
||||
def test_string_number(self):
|
||||
assert evaluate_balance(DATA, "data.balance") == 1.5
|
||||
|
||||
def test_missing_path(self):
|
||||
with pytest.raises(ExprError, match="路径不存在"):
|
||||
evaluate_balance(DATA, "data.nope")
|
||||
|
||||
def test_index_out_of_range(self):
|
||||
with pytest.raises(ExprError, match="数组索引越界"):
|
||||
evaluate_balance(DATA, "list[5].x")
|
||||
|
||||
|
||||
class TestOperators:
|
||||
def test_division(self):
|
||||
assert evaluate_balance(DATA, "data.total / 100") == 1.0
|
||||
|
||||
def test_addition(self):
|
||||
assert evaluate_balance(DATA, "data.total + data.used") == 140
|
||||
|
||||
def test_priority(self):
|
||||
assert evaluate_balance(DATA, "data.total + data.used * 2") == 180
|
||||
assert evaluate_balance(DATA, "(data.total + data.used) * 2") == 280
|
||||
|
||||
def test_floor_div_and_mod(self):
|
||||
assert evaluate_balance(DATA, "data.total // 30") == 3
|
||||
assert evaluate_balance(DATA, "data.total % 30") == 10
|
||||
|
||||
def test_power(self):
|
||||
assert evaluate_balance(DATA, "2 ** 3 * 5") == 40
|
||||
|
||||
def test_unary_minus(self):
|
||||
assert evaluate_balance(DATA, "-data.total") == -100
|
||||
assert evaluate_balance(DATA, "data.total - -data.used") == 140
|
||||
|
||||
def test_float_result(self):
|
||||
assert evaluate_balance(DATA, "data.total / 8") == 12.5
|
||||
|
||||
|
||||
class TestFunctions:
|
||||
def test_float(self):
|
||||
assert evaluate_balance(DATA, "float(data.balance)") == 1.5
|
||||
|
||||
def test_int(self):
|
||||
assert evaluate_balance(DATA, "int(data.total / 3)") == 33
|
||||
|
||||
def test_abs(self):
|
||||
assert evaluate_balance(DATA, "abs(data.used - data.total)") == 60
|
||||
|
||||
def test_round_one_arg(self):
|
||||
assert evaluate_balance(DATA, "round(data.fee * 3)") == 8
|
||||
|
||||
def test_round_two_args(self):
|
||||
assert evaluate_balance(DATA, "round(data.fee, 1)") == 2.5
|
||||
assert evaluate_balance(DATA, "round(3.14159, 2)") == 3.14
|
||||
|
||||
def test_sum_multi_args(self):
|
||||
assert evaluate_balance(DATA, "sum(data.total, data.used, data.fee)") == 142.5
|
||||
|
||||
def test_sum_array(self):
|
||||
assert evaluate_balance(DATA, "sum(balances)") == 6
|
||||
|
||||
def test_min_max(self):
|
||||
assert evaluate_balance(DATA, "min(data.total, data.used)") == 40
|
||||
assert evaluate_balance(DATA, "max(data.total, data.used)") == 100
|
||||
assert evaluate_balance(DATA, "min(balances)") == 1
|
||||
assert evaluate_balance(DATA, "max(balances)") == 3
|
||||
|
||||
def test_len(self):
|
||||
assert evaluate_balance(DATA, "len(balances)") == 3
|
||||
|
||||
def test_nested_call(self):
|
||||
assert evaluate_balance(DATA, "round(abs(data.used - data.total) / 3, 1)") == 20.0
|
||||
|
||||
|
||||
class TestErrors:
|
||||
def test_unknown_function(self):
|
||||
with pytest.raises(ExprError, match="不支持的函数"):
|
||||
evaluate_balance(DATA, "eval(data.balance)")
|
||||
|
||||
def test_syntax_error(self):
|
||||
with pytest.raises(ExprError):
|
||||
evaluate_balance(DATA, "data.total +")
|
||||
with pytest.raises(ExprError):
|
||||
evaluate_balance(DATA, "(data.total")
|
||||
|
||||
def test_bad_arity(self):
|
||||
with pytest.raises(ExprError, match="float"):
|
||||
evaluate_balance(DATA, "float()")
|
||||
with pytest.raises(ExprError, match="round"):
|
||||
evaluate_balance(DATA, "round(data.total, 2, 3)")
|
||||
|
||||
def test_division_by_zero(self):
|
||||
with pytest.raises((ZeroDivisionError, ExprError)):
|
||||
evaluate_balance(DATA, "data.total / 0")
|
||||
|
||||
def test_non_numeric_result(self):
|
||||
with pytest.raises(ExprError, match="不是数字"):
|
||||
evaluate_balance(DATA, "sum(list)") # 数组元素是对象,无法转数字
|
||||
|
||||
def test_string_literal_rejected(self):
|
||||
with pytest.raises(ExprError):
|
||||
evaluate_balance(DATA, "data.total + 'abc'")
|
||||
+47
-18
@@ -68,13 +68,32 @@ class FakeResponse:
|
||||
return self._json
|
||||
|
||||
|
||||
PLATFORM_GET = {
|
||||
"method": "GET",
|
||||
"url": "https://x.test/api?key={{apiKey}}",
|
||||
"headers": {"Authorization": "Bearer {{apiKey}}"},
|
||||
"body": "",
|
||||
"balance_path": "data.balance",
|
||||
}
|
||||
class FakeProvider:
|
||||
"""测试用 provider:URL 带 key、余额在 data.balance。"""
|
||||
|
||||
method = "GET"
|
||||
|
||||
def __init__(self, path="data.balance"):
|
||||
self.path = path
|
||||
|
||||
def build_request(self, api_key):
|
||||
return (
|
||||
"https://x.test/api?key=" + api_key,
|
||||
{"Authorization": "Bearer " + api_key},
|
||||
None,
|
||||
)
|
||||
|
||||
def extract_balance(self, data):
|
||||
from app.expr import evaluate_balance
|
||||
|
||||
return evaluate_balance(data, self.path)
|
||||
|
||||
|
||||
class FakePostProvider(FakeProvider):
|
||||
method = "POST"
|
||||
|
||||
def build_request(self, api_key):
|
||||
return ("https://x.test/api", {}, '{"api_key": "' + api_key + '"}')
|
||||
|
||||
|
||||
class TestFetchBalance:
|
||||
@@ -88,12 +107,11 @@ class TestFetchBalance:
|
||||
return FakeResponse(200, json_data={"data": {"balance": 42.5}})
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "sk-1", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "sk-1", retry_count=2, timeout=10)
|
||||
assert result.ok and result.balance == 42.5
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_post_with_body(self, monkeypatch):
|
||||
platform = dict(PLATFORM_GET, method="POST", body='{"api_key": "{{apiKey}}"}')
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, data=None, timeout=10):
|
||||
@@ -101,10 +119,21 @@ class TestFetchBalance:
|
||||
return FakeResponse(200, json_data={"data": {"balance": 1}})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
result = fetch_balance(platform, "sk-2", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakePostProvider(), "sk-2", retry_count=2, timeout=10)
|
||||
assert result.ok
|
||||
assert captured["data"] == '{"api_key": "sk-2"}'
|
||||
|
||||
def test_post_content_type_auto(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, headers=None, data=None, timeout=10):
|
||||
captured["headers"] = headers
|
||||
return FakeResponse(200, json_data={"data": {"balance": 1}})
|
||||
|
||||
monkeypatch.setattr("requests.post", fake_post)
|
||||
fetch_balance(FakePostProvider(), "sk-2", retry_count=0, timeout=10)
|
||||
assert captured["headers"].get("Content-Type") == "application/json"
|
||||
|
||||
def test_401_retries_then_auth_error(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -113,7 +142,7 @@ class TestFetchBalance:
|
||||
return FakeResponse(401, text="unauthorized")
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "bad", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "bad", retry_count=2, timeout=10)
|
||||
assert not result.ok and result.auth_error
|
||||
assert len(calls) == 3 # 401 也按重试次数确认后再判定
|
||||
|
||||
@@ -125,7 +154,7 @@ class TestFetchBalance:
|
||||
return FakeResponse(401) if len(calls) == 1 else FakeResponse(200, json_data={"data": {"balance": 6.6}})
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
|
||||
assert result.ok and result.balance == 6.6
|
||||
assert len(calls) == 2 # 瞬时 401 重试后成功,不误判禁用
|
||||
|
||||
@@ -137,13 +166,13 @@ class TestFetchBalance:
|
||||
return FakeResponse(500) if len(calls) < 3 else FakeResponse(200, json_data={"data": {"balance": 5}})
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
|
||||
assert result.ok and result.balance == 5
|
||||
assert len(calls) == 3
|
||||
|
||||
def test_5xx_all_fail(self, monkeypatch):
|
||||
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(503))
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
|
||||
assert not result.ok and not result.auth_error
|
||||
|
||||
def test_network_error_retries(self, monkeypatch):
|
||||
@@ -157,7 +186,7 @@ class TestFetchBalance:
|
||||
return FakeResponse(200, json_data={"data": {"balance": 8}})
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
|
||||
assert result.ok and result.balance == 8
|
||||
|
||||
def test_other_4xx_no_retry(self, monkeypatch):
|
||||
@@ -168,19 +197,19 @@ class TestFetchBalance:
|
||||
return FakeResponse(404, text="not found")
|
||||
|
||||
monkeypatch.setattr("requests.get", fake_get)
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=2, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=2, timeout=10)
|
||||
assert not result.ok and not result.auth_error
|
||||
assert "404" in result.error
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_bad_json_path(self, monkeypatch):
|
||||
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, json_data={"x": 1}))
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=0, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=0, timeout=10)
|
||||
assert not result.ok
|
||||
assert "路径不存在" in result.error
|
||||
|
||||
def test_invalid_json_body(self, monkeypatch):
|
||||
monkeypatch.setattr("requests.get", lambda *a, **k: FakeResponse(200, text="<html>"))
|
||||
result = fetch_balance(PLATFORM_GET, "k", retry_count=0, timeout=10)
|
||||
result = fetch_balance(FakeProvider(), "k", retry_count=0, timeout=10)
|
||||
assert not result.ok
|
||||
assert "JSON" in result.error
|
||||
@@ -12,8 +12,7 @@ def _row(**overrides):
|
||||
"threshold": 0, "enabled": 1, "alert_armed": 1,
|
||||
"last_balance": None, "last_status": "pending", "last_error": "",
|
||||
"last_check_at": None, "note": "",
|
||||
"platform_name": "P", "currency": "USD", "icon": "", "method": "GET",
|
||||
"url": "http://x", "headers": "{}", "body": "", "balance_path": "b",
|
||||
"provider_id": "deepseek", "platform_name": "P", "currency": "USD", "icon": "",
|
||||
"interval_seconds": None, "retry_count": None, "timeout_seconds": None,
|
||||
"platform_enabled": 1, "platform_note": "",
|
||||
}
|
||||
@@ -32,8 +31,8 @@ class TestSplit:
|
||||
assert platform["name"] == "P"
|
||||
assert platform["id"] == 2
|
||||
assert platform["enabled"] == 1
|
||||
assert platform["url"] == "http://x"
|
||||
assert platform["balance_path"] == "b"
|
||||
assert platform["provider_id"] == "deepseek"
|
||||
assert platform["currency"] == "USD"
|
||||
|
||||
def test_plain_key_passthrough(self):
|
||||
"""未编码的 key(历史数据)原样使用,不抛错。"""
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""内置平台适配器测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.expr import ExprError
|
||||
from app.providers import get_provider, list_providers
|
||||
from app.providers.deepseek import DeepSeekProvider
|
||||
|
||||
DEEPSEEK_RESPONSE = {
|
||||
"is_available": True,
|
||||
"balance_infos": [
|
||||
{
|
||||
"currency": "CNY",
|
||||
"total_balance": "1.34",
|
||||
"granted_balance": "0.00",
|
||||
"topped_up_balance": "1.34",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestDeepSeek:
|
||||
def test_build_request(self):
|
||||
prov = DeepSeekProvider()
|
||||
url, headers, body = prov.build_request("sk-abc")
|
||||
assert url == "https://api.deepseek.com/user/balance"
|
||||
assert headers["Authorization"] == "Bearer sk-abc"
|
||||
assert body is None
|
||||
assert prov.method == "GET"
|
||||
|
||||
def test_extract_balance(self):
|
||||
assert DeepSeekProvider().extract_balance(DEEPSEEK_RESPONSE) == 1.34
|
||||
|
||||
def test_extract_missing(self):
|
||||
with pytest.raises(ExprError):
|
||||
DeepSeekProvider().extract_balance({"is_available": False})
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_get_provider(self):
|
||||
prov = get_provider("deepseek")
|
||||
assert isinstance(prov, DeepSeekProvider)
|
||||
|
||||
def test_unknown_provider_none(self):
|
||||
assert get_provider("nope") is None
|
||||
|
||||
def test_list_providers(self):
|
||||
provs = list_providers()
|
||||
ids = [p["id"] for p in provs]
|
||||
assert "deepseek" in ids
|
||||
dp = next(p for p in provs if p["id"] == "deepseek")
|
||||
assert dp["name"] == "DeepSeek" and dp["currency"] == "CNY" and dp["icon"] == "DeepSeek"
|
||||
Reference in New Issue
Block a user