From 2d1dcb8f9fafa102d13d4bb88c3cc4f65828525c Mon Sep 17 00:00:00 2001 From: SmartUp Developer Date: Tue, 30 Jun 2026 09:13:03 +0800 Subject: [PATCH] fix: round website rates to two decimals --- backend/app/routers/websites.py | 3 +- backend/app/services/website_client.py | 10 ++--- backend/app/services/website_sync.py | 17 +++++--- backend/app/utils/number.py | 19 ++++++++- backend/test_group_binding_create_sync.py | 18 ++++----- backend/test_website_client.py | 47 +++++++++++++++++++++-- 6 files changed, 88 insertions(+), 26 deletions(-) diff --git a/backend/app/routers/websites.py b/backend/app/routers/websites.py index e5fc1a5..a0f2c68 100644 --- a/backend/app/routers/websites.py +++ b/backend/app/routers/websites.py @@ -45,6 +45,7 @@ from app.services.website_sync import ( sync_account_priorities_for_upstream, ) from app.utils.auth import get_current_user +from app.utils.number import fixed_decimal_number router = APIRouter(tags=["websites"]) logger = logging.getLogger(__name__) @@ -339,7 +340,7 @@ def import_groups_from_upstream( "name": target_name, "description": group.get("description") or f"Imported from {upstream.name} / {source_name}", "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: create_body["rpm_limit"] = group.get("rpm_limit") diff --git a/backend/app/services/website_client.py b/backend/app/services/website_client.py index 1aaa106..b99135d 100644 --- a/backend/app/services/website_client.py +++ b/backend/app/services/website_client.py @@ -7,7 +7,7 @@ from urllib.parse import quote import httpx -from app.utils.number import decimal_string +from app.utils.number import fixed_decimal_number, fixed_decimal_string 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)) if pct < 0: 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: @@ -122,7 +122,7 @@ def normalize_groups(value: Any) -> list[dict[str, Any]]: groups.append({ "id": str(gid), "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, }) return groups @@ -209,9 +209,9 @@ class Sub2ApiWebsiteClient: msg = str(last_error) if last_error else "拉取分组失败" 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="")) - 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]: resp = self._request("POST", endpoint, body) diff --git a/backend/app/services/website_sync.py b/backend/app/services/website_sync.py index 4656c9f..3da2714 100644 --- a/backend/app/services/website_sync.py +++ b/backend/app/services/website_sync.py @@ -11,9 +11,10 @@ from app.models.snapshot import UpstreamRateSnapshot from app.models.upstream import Upstream from app.models.upstream_key import UpstreamGeneratedKey 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 import webhook_service +from app.utils.number import fixed_decimal_string logger = logging.getLogger(__name__) @@ -91,8 +92,8 @@ def _log( algorithm=binding.algorithm, percent=binding.percent, source_rates_json=json.dumps(source_rates, ensure_ascii=False), - old_rate=decimal_string(old_rate) if old_rate not in (None, "") else None, - new_rate=decimal_string(new_rate) if new_rate not in (None, "") else None, + old_rate=fixed_decimal_string(old_rate, 2) if old_rate not in (None, "") else None, + new_rate=fixed_decimal_string(new_rate, 2) if new_rate not in (None, "") else None, status=status, message=message, ) @@ -147,7 +148,11 @@ def sync_binding(db: Session, binding: WebsiteGroupBinding, write: bool = True) groups = client.get_groups(website.groups_endpoint) 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 - 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_error = None 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) db.commit() 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 - new_rate_str = decimal_string(target_rate) + old_rate_str = fixed_decimal_string(old_rate, 2) if old_rate not in (None, "") else None + new_rate_str = fixed_decimal_string(target_rate, 2) if old_rate_str != new_rate_str: webhook_service.send_website_rate_changed( db, diff --git a/backend/app/utils/number.py b/backend/app/utils/number.py index 5db9640..c96bcc2 100644 --- a/backend/app/utils/number.py +++ b/backend/app/utils/number.py @@ -1,7 +1,7 @@ """Shared numeric formatting utilities.""" from __future__ import annotations -from decimal import Decimal, InvalidOperation +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from typing import Any @@ -23,3 +23,20 @@ def decimal_string(value: Any) -> str: if n == n.to_integral(): return str(n.quantize(Decimal("1"))) 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)) diff --git a/backend/test_group_binding_create_sync.py b/backend/test_group_binding_create_sync.py index 9f6297e..5378f14 100644 --- a/backend/test_group_binding_create_sync.py +++ b/backend/test_group_binding_create_sync.py @@ -129,12 +129,12 @@ def test_create_binding_runs_initial_sync(monkeypatch, db_session): ) 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() assert log.status == "success" assert log.message == "同步成功" - assert log.old_rate == "1" - assert log.new_rate == "2.2" + assert log.old_rate == "1.00" + assert log.new_rate == "2.20" 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.message == "网站未启用自动同步,未写回" 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): @@ -168,7 +168,7 @@ def test_create_binding_skips_write_when_binding_disabled(db_session): log = db_session.query(WebsiteSyncLog).one() assert log.status == "success" 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): @@ -241,11 +241,11 @@ def test_update_binding_runs_sync_after_save(monkeypatch, db_session): ) 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() assert log.status == "success" 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): @@ -339,8 +339,8 @@ def test_create_binding_notifies_when_website_rate_changes(monkeypatch, db_sessi _, payload = sent_payloads[0] assert payload["event"] == "website_rate_changed" assert payload["website"]["id"] == website.id - assert payload["target_group"]["old_rate"] == "1" - assert payload["target_group"]["new_rate"] == "2.2" + assert payload["target_group"]["old_rate"] == "1.00" + assert payload["target_group"]["new_rate"] == "2.20" log = db_session.query(NotificationLog).one() assert log.event_type == "website_rate_changed" assert log.status == "success" diff --git a/backend/test_website_client.py b/backend/test_website_client.py index 2b97c08..f4334cd 100644 --- a/backend/test_website_client.py +++ b/backend/test_website_client.py @@ -1,3 +1,5 @@ +from decimal import Decimal + import httpx import pytest @@ -5,6 +7,7 @@ from app.services.website_client import ( WebsiteError, _friendly_connection_error, _friendly_http_error, + calculate_target_rate, 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 groups[0]["rate_multiplier"] == "1" - assert groups[1]["rate_multiplier"] == "1.5" + assert groups[0]["rate_multiplier"] == "1.00" + assert groups[1]["rate_multiplier"] == "1.50" assert groups[2]["rate_multiplier"] is None @@ -45,7 +48,7 @@ def test_normalize_groups_unwraps_wrapped_list_response(): assert groups == [{ "id": "default", "name": "Default", - "rate_multiplier": "2", + "rate_multiplier": "2.00", "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 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 ———