fix: round website rates to two decimals

This commit is contained in:
SmartUp Developer
2026-06-30 11:26:48 +08:00
parent a914d3d222
commit 2d1dcb8f9f
6 changed files with 88 additions and 26 deletions
+2 -1
View File
@@ -45,6 +45,7 @@ from app.services.website_sync import (
sync_account_priorities_for_upstream, sync_account_priorities_for_upstream,
) )
from app.utils.auth import get_current_user from app.utils.auth import get_current_user
from app.utils.number import fixed_decimal_number
router = APIRouter(tags=["websites"]) router = APIRouter(tags=["websites"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -339,7 +340,7 @@ def import_groups_from_upstream(
"name": target_name, "name": target_name,
"description": group.get("description") or f"Imported from {upstream.name} / {source_name}", "description": group.get("description") or f"Imported from {upstream.name} / {source_name}",
"platform": group.get("platform") or "openai", "platform": group.get("platform") or "openai",
"rate_multiplier": _source_group_rate(group), "rate_multiplier": fixed_decimal_number(_source_group_rate(group), 2),
} }
if group.get("rpm_limit") is not None: if group.get("rpm_limit") is not None:
create_body["rpm_limit"] = group.get("rpm_limit") create_body["rpm_limit"] = group.get("rpm_limit")
+5 -5
View File
@@ -7,7 +7,7 @@ from urllib.parse import quote
import httpx import httpx
from app.utils.number import decimal_string from app.utils.number import fixed_decimal_number, fixed_decimal_string
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -67,7 +67,7 @@ def calculate_target_rate(values: list[Any], percent: Any = 0, algorithm: str =
pct = Decimal(str(percent or 0)) pct = Decimal(str(percent or 0))
if pct < 0: if pct < 0:
raise WebsiteError("百分比不能为负数") raise WebsiteError("百分比不能为负数")
return (base * (Decimal("1") + pct / Decimal("100"))).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) return (base * (Decimal("1") + pct / Decimal("100"))).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _unwrap_data(value: Any) -> Any: def _unwrap_data(value: Any) -> Any:
@@ -122,7 +122,7 @@ def normalize_groups(value: Any) -> list[dict[str, Any]]:
groups.append({ groups.append({
"id": str(gid), "id": str(gid),
"name": str(name), "name": str(name),
"rate_multiplier": decimal_string(rate) if rate is not None else None, "rate_multiplier": fixed_decimal_string(rate, 2) if rate is not None else None,
"raw": item, "raw": item,
}) })
return groups return groups
@@ -209,9 +209,9 @@ class Sub2ApiWebsiteClient:
msg = str(last_error) if last_error else "拉取分组失败" msg = str(last_error) if last_error else "拉取分组失败"
raise WebsiteError(f"{msg}(尝试接口:{''.join(tried_paths)}") raise WebsiteError(f"{msg}(尝试接口:{''.join(tried_paths)}")
def update_group_rate(self, endpoint_template: str, group_id: str, rate: Decimal) -> Any: def update_group_rate(self, endpoint_template: str, group_id: str, rate: Any) -> Any:
path = endpoint_template.replace("{id}", quote(group_id, safe="")) path = endpoint_template.replace("{id}", quote(group_id, safe=""))
return self._request("PUT", path, {"rate_multiplier": float(rate)}) return self._request("PUT", path, {"rate_multiplier": fixed_decimal_number(rate, 2)})
def create_group(self, body: dict[str, Any], endpoint: str = "/groups") -> dict[str, Any]: def create_group(self, body: dict[str, Any], endpoint: str = "/groups") -> dict[str, Any]:
resp = self._request("POST", endpoint, body) resp = self._request("POST", endpoint, body)
+11 -6
View File
@@ -11,9 +11,10 @@ from app.models.snapshot import UpstreamRateSnapshot
from app.models.upstream import Upstream from app.models.upstream import Upstream
from app.models.upstream_key import UpstreamGeneratedKey from app.models.upstream_key import UpstreamGeneratedKey
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog
from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate, decimal_string from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate
from app.services.upstream_client import UpstreamClient from app.services.upstream_client import UpstreamClient
from app.services import webhook_service from app.services import webhook_service
from app.utils.number import fixed_decimal_string
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -91,8 +92,8 @@ def _log(
algorithm=binding.algorithm, algorithm=binding.algorithm,
percent=binding.percent, percent=binding.percent,
source_rates_json=json.dumps(source_rates, ensure_ascii=False), source_rates_json=json.dumps(source_rates, ensure_ascii=False),
old_rate=decimal_string(old_rate) if old_rate not in (None, "") else None, old_rate=fixed_decimal_string(old_rate, 2) if old_rate not in (None, "") else None,
new_rate=decimal_string(new_rate) if new_rate not in (None, "") else None, new_rate=fixed_decimal_string(new_rate, 2) if new_rate not in (None, "") else None,
status=status, status=status,
message=message, message=message,
) )
@@ -147,7 +148,11 @@ def sync_binding(db: Session, binding: WebsiteGroupBinding, write: bool = True)
groups = client.get_groups(website.groups_endpoint) groups = client.get_groups(website.groups_endpoint)
target = next((item for item in groups if item.get("id") == binding.target_group_id), None) target = next((item for item in groups if item.get("id") == binding.target_group_id), None)
old_rate = target.get("rate_multiplier") if target else None old_rate = target.get("rate_multiplier") if target else None
client.update_group_rate(website.group_update_endpoint, binding.target_group_id, target_rate) client.update_group_rate(
website.group_update_endpoint,
binding.target_group_id,
target_rate,
)
website.last_status = "healthy" website.last_status = "healthy"
website.last_error = None website.last_error = None
except Exception as exc: except Exception as exc:
@@ -157,8 +162,8 @@ def sync_binding(db: Session, binding: WebsiteGroupBinding, write: bool = True)
return _log(db, binding, website, source_rates, "failed", f"写回失败:{exc}", old_rate, target_rate) return _log(db, binding, website, source_rates, "failed", f"写回失败:{exc}", old_rate, target_rate)
db.commit() db.commit()
log = _log(db, binding, website, source_rates, "success", "同步成功", old_rate, target_rate) log = _log(db, binding, website, source_rates, "success", "同步成功", old_rate, target_rate)
old_rate_str = decimal_string(old_rate) if old_rate not in (None, "") else None old_rate_str = fixed_decimal_string(old_rate, 2) if old_rate not in (None, "") else None
new_rate_str = decimal_string(target_rate) new_rate_str = fixed_decimal_string(target_rate, 2)
if old_rate_str != new_rate_str: if old_rate_str != new_rate_str:
webhook_service.send_website_rate_changed( webhook_service.send_website_rate_changed(
db, db,
+18 -1
View File
@@ -1,7 +1,7 @@
"""Shared numeric formatting utilities.""" """Shared numeric formatting utilities."""
from __future__ import annotations from __future__ import annotations
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Any from typing import Any
@@ -23,3 +23,20 @@ def decimal_string(value: Any) -> str:
if n == n.to_integral(): if n == n.to_integral():
return str(n.quantize(Decimal("1"))) return str(n.quantize(Decimal("1")))
return format(n, "f") return format(n, "f")
def fixed_decimal_string(value: Any, places: int = 2) -> str:
"""Format a numeric value with a fixed number of decimal places."""
if value is None or value == "":
return ""
try:
d = Decimal(str(value))
except (InvalidOperation, ValueError):
return str(value)
quantum = Decimal("1").scaleb(-places)
return format(d.quantize(quantum, rounding=ROUND_HALF_UP), f".{places}f")
def fixed_decimal_number(value: Any, places: int = 2) -> float:
"""Round a numeric value to a fixed precision for JSON number payloads."""
return float(fixed_decimal_string(value, places))
+9 -9
View File
@@ -129,12 +129,12 @@ def test_create_binding_runs_initial_sync(monkeypatch, db_session):
) )
assert result.target_group_id == "target" assert result.target_group_id == "target"
assert calls == [("/groups/{id}", "target", "2.2000")] assert calls == [("/groups/{id}", "target", "2.20")]
log = db_session.query(WebsiteSyncLog).one() log = db_session.query(WebsiteSyncLog).one()
assert log.status == "success" assert log.status == "success"
assert log.message == "同步成功" assert log.message == "同步成功"
assert log.old_rate == "1" assert log.old_rate == "1.00"
assert log.new_rate == "2.2" assert log.new_rate == "2.20"
def test_create_binding_skips_write_when_website_auto_sync_disabled(db_session): def test_create_binding_skips_write_when_website_auto_sync_disabled(db_session):
@@ -152,7 +152,7 @@ def test_create_binding_skips_write_when_website_auto_sync_disabled(db_session):
assert log.status == "success" assert log.status == "success"
assert log.message == "网站未启用自动同步,未写回" assert log.message == "网站未启用自动同步,未写回"
assert log.old_rate is None assert log.old_rate is None
assert log.new_rate == "2.2" assert log.new_rate == "2.20"
def test_create_binding_skips_write_when_binding_disabled(db_session): def test_create_binding_skips_write_when_binding_disabled(db_session):
@@ -168,7 +168,7 @@ def test_create_binding_skips_write_when_binding_disabled(db_session):
log = db_session.query(WebsiteSyncLog).one() log = db_session.query(WebsiteSyncLog).one()
assert log.status == "success" assert log.status == "success"
assert log.message == "绑定未启用,未写回" assert log.message == "绑定未启用,未写回"
assert log.new_rate == "2.2" assert log.new_rate == "2.20"
def test_create_binding_keeps_binding_when_initial_sync_calculation_fails(db_session): def test_create_binding_keeps_binding_when_initial_sync_calculation_fails(db_session):
@@ -241,11 +241,11 @@ def test_update_binding_runs_sync_after_save(monkeypatch, db_session):
) )
assert result.target_group_id == "target" assert result.target_group_id == "target"
assert calls == [("/groups/{id}", "target", "2.4000")] assert calls == [("/groups/{id}", "target", "2.40")]
log = db_session.query(WebsiteSyncLog).one() log = db_session.query(WebsiteSyncLog).one()
assert log.status == "success" assert log.status == "success"
assert log.message == "同步成功" assert log.message == "同步成功"
assert log.new_rate == "2.4" assert log.new_rate == "2.40"
def test_update_binding_skips_write_when_disabled(monkeypatch, db_session): def test_update_binding_skips_write_when_disabled(monkeypatch, db_session):
@@ -339,8 +339,8 @@ def test_create_binding_notifies_when_website_rate_changes(monkeypatch, db_sessi
_, payload = sent_payloads[0] _, payload = sent_payloads[0]
assert payload["event"] == "website_rate_changed" assert payload["event"] == "website_rate_changed"
assert payload["website"]["id"] == website.id assert payload["website"]["id"] == website.id
assert payload["target_group"]["old_rate"] == "1" assert payload["target_group"]["old_rate"] == "1.00"
assert payload["target_group"]["new_rate"] == "2.2" assert payload["target_group"]["new_rate"] == "2.20"
log = db_session.query(NotificationLog).one() log = db_session.query(NotificationLog).one()
assert log.event_type == "website_rate_changed" assert log.event_type == "website_rate_changed"
assert log.status == "success" assert log.status == "success"
+43 -4
View File
@@ -1,3 +1,5 @@
from decimal import Decimal
import httpx import httpx
import pytest import pytest
@@ -5,6 +7,7 @@ from app.services.website_client import (
WebsiteError, WebsiteError,
_friendly_connection_error, _friendly_connection_error,
_friendly_http_error, _friendly_http_error,
calculate_target_rate,
normalize_groups, normalize_groups,
) )
@@ -28,8 +31,8 @@ def test_normalize_groups_unwraps_sub2api_paginated_response():
}) })
assert [group["id"] for group in groups] == ["codex-free", "my-plus", "deepseek"] assert [group["id"] for group in groups] == ["codex-free", "my-plus", "deepseek"]
assert groups[0]["rate_multiplier"] == "1" assert groups[0]["rate_multiplier"] == "1.00"
assert groups[1]["rate_multiplier"] == "1.5" assert groups[1]["rate_multiplier"] == "1.50"
assert groups[2]["rate_multiplier"] is None assert groups[2]["rate_multiplier"] is None
@@ -45,7 +48,7 @@ def test_normalize_groups_unwraps_wrapped_list_response():
assert groups == [{ assert groups == [{
"id": "default", "id": "default",
"name": "Default", "name": "Default",
"rate_multiplier": "2", "rate_multiplier": "2.00",
"raw": {"id": "default", "name": "Default", "rateMultiplier": "2.0"}, "raw": {"id": "default", "name": "Default", "rateMultiplier": "2.0"},
}] }]
@@ -78,7 +81,43 @@ def test_normalize_groups_keeps_plain_dict_mapping_compatibility():
}) })
assert [group["id"] for group in groups] == ["free", "paid"] assert [group["id"] for group in groups] == ["free", "paid"]
assert groups[0]["rate_multiplier"] == "1" assert groups[0]["rate_multiplier"] == "1.00"
def test_update_group_rate_writes_rounded_two_decimal_number():
from app.services.website_client import Sub2ApiWebsiteClient
requests = []
def handler(req: httpx.Request) -> httpx.Response:
requests.append(req)
return httpx.Response(200, json={"ok": True}, request=req)
client = Sub2ApiWebsiteClient(
base_url="https://target.example",
api_prefix="/api/v1/admin",
auth_type="api_key",
auth_config={"key": "admin-key"},
)
client._client = httpx.Client(transport=httpx.MockTransport(handler))
try:
client.update_group_rate("/groups/{id}", "vip", Decimal("2.2"))
client.update_group_rate("/groups/{id}", "free", Decimal("1"))
client.update_group_rate("/groups/{id}", "small", Decimal("0.1275"))
finally:
client.close()
assert requests[0].url.path == "/api/v1/admin/groups/vip"
assert requests[0].read() == b'{"rate_multiplier":2.2}'
assert requests[1].url.path == "/api/v1/admin/groups/free"
assert requests[1].read() == b'{"rate_multiplier":1.0}'
assert requests[2].url.path == "/api/v1/admin/groups/small"
assert requests[2].read() == b'{"rate_multiplier":0.13}'
def test_calculate_target_rate_rounds_to_two_decimal_places():
assert calculate_target_rate(["0.1275"], 0, "max_plus_percent") == Decimal("0.13")
# ——— _get_account_ids / account_exists ——— # ——— _get_account_ids / account_exists ———