diff --git a/backend/app/services/website_client.py b/backend/app/services/website_client.py index 8c348f0..f9fac59 100644 --- a/backend/app/services/website_client.py +++ b/backend/app/services/website_client.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import sys import time -from decimal import Decimal, InvalidOperation, ROUND_HALF_UP +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_UP from typing import Any from urllib.parse import quote, urlparse @@ -66,25 +66,16 @@ def calculate_target_rate(values: list[Any], percent: Any = 0, algorithm: str = elif algorithm == "max_plus_percent": base = max(rates) elif algorithm == "priority_weighted_plus_percent": - rates_sorted = sorted(rates) - N = len(rates_sorted) - if N == 1: - base = rates_sorted[0] - else: - w1 = Decimal("0.85") - w_others = Decimal("0.15") / Decimal(N - 1) - base = rates_sorted[0] * w1 + sum(rates_sorted[1:], Decimal("0")) * w_others + min_rate = min(rates) + max_rate = max(rates) + raw_target = max(min_rate * Decimal("1.50"), max_rate * Decimal("1.10")) + return raw_target.quantize(Decimal("0.01"), rounding=ROUND_UP) else: raise WebsiteError(f"不支持的算法:{algorithm}") pct = Decimal(str(percent or 0)) if pct < 0: raise WebsiteError("百分比不能为负数") - target_rate = (base * (Decimal("1") + pct / Decimal("100"))).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - if algorithm == "priority_weighted_plus_percent": - min_rate_2d = min(rates).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) - if target_rate < min_rate_2d: - target_rate = min_rate_2d - return target_rate + return (base * (Decimal("1") + pct / Decimal("100"))).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) def _unwrap_data(value: Any) -> Any: diff --git a/backend/app/services/website_sync.py b/backend/app/services/website_sync.py index f7cf48a..ae68a0d 100644 --- a/backend/app/services/website_sync.py +++ b/backend/app/services/website_sync.py @@ -2,7 +2,7 @@ from __future__ import annotations import json import logging -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_UP from datetime import datetime, timezone from typing import Any @@ -14,7 +14,7 @@ from app.models.upstream import Upstream from app.models.upstream_key import UpstreamGeneratedKey from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog from app.services.auth_config import normalize_auth_config -from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate +from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError, _extract_id, calculate_target_rate, parse_positive_decimal from app.services.upstream_client import UpstreamClient from app.services import webhook_service from app.utils.number import fixed_decimal_string @@ -168,6 +168,21 @@ def sync_binding(db: Session, binding: WebsiteGroupBinding, write: bool = True) }) try: target_rate = calculate_target_rate([item.get("rate") for item in source_rates], binding.percent, binding.algorithm) + if binding.algorithm == "priority_weighted_plus_percent": + rates = [rate for rate in (parse_positive_decimal(item.get("rate")) for item in source_rates) if rate is not None] + if rates: + rates_sorted = sorted(rates) + N = len(rates_sorted) + if N == 1: + base = rates_sorted[0] + else: + w1 = Decimal("0.85") + w_others = Decimal("0.15") / Decimal(N - 1) + base = rates_sorted[0] * w1 + sum(rates_sorted[1:], Decimal("0")) * w_others + + if base > 0: + effective_percent = (target_rate / base - Decimal("1")) * Decimal("100") + binding.percent = fixed_decimal_string(effective_percent.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), 2) except Exception as exc: return _log(db, binding, website, source_rates, "failed", str(exc)) diff --git a/backend/test_group_binding_create_sync.py b/backend/test_group_binding_create_sync.py index 5378f14..d18d14d 100644 --- a/backend/test_group_binding_create_sync.py +++ b/backend/test_group_binding_create_sync.py @@ -387,3 +387,49 @@ def test_create_binding_does_not_notify_when_website_rate_unchanged(monkeypatch, assert result.target_group_id == "target" assert db_session.query(NotificationLog).count() == 0 + + +def test_sync_binding_auto_percent_writeback(monkeypatch, db_session): + website, upstream = seed_rows(db_session) + binding = WebsiteGroupBinding( + website_id=website.id, + target_group_id="target", + target_group_name="Target group", + source_groups_json=json.dumps([{ + "upstream_id": upstream.id, + "upstream_name": "Upstream", + "group_id": "source", + "group_name": "Source", + }], ensure_ascii=False), + percent="0.00", + algorithm="priority_weighted_plus_percent", + enabled=True, + ) + db_session.add(binding) + db_session.commit() + db_session.refresh(binding) + + class FakeClient: + def __init__(self, **kwargs): + pass + def __enter__(self): + return self + def __exit__(self, *args): + pass + def get_groups(self, endpoint): + return [{"id": "target", "name": "Target group", "rate_multiplier": "2.0"}] + def update_group_rate(self, endpoint, group_id, rate): + pass + + monkeypatch.setattr(websites_router, "Sub2ApiWebsiteClient", FakeClient) + monkeypatch.setattr("app.services.website_sync.Sub2ApiWebsiteClient", FakeClient) + + from app.services.website_sync import sync_binding + sync_binding(db_session, binding, write=True) + + db_session.refresh(binding) + assert binding.percent == "50.00" + + log = db_session.query(WebsiteSyncLog).filter(WebsiteSyncLog.binding_id == binding.id).one() + assert log.percent == "50.00" + assert log.new_rate == "3.00" diff --git a/backend/test_website_client.py b/backend/test_website_client.py index 4e27aae..561893a 100644 --- a/backend/test_website_client.py +++ b/backend/test_website_client.py @@ -402,21 +402,18 @@ def test_list_accounts_pagination_returns_none_when_any_page_fails(): def test_calculate_target_rate_priority_weighted(): - # 0.18、0.25 + 50% 应得到约 0.29 - assert calculate_target_rate(["0.18", "0.25"], 50, "priority_weighted_plus_percent") == Decimal("0.29") - # 单来源时等同于该来源倍率 + 百分比 - assert calculate_target_rate(["0.18"], 50, "priority_weighted_plus_percent") == Decimal("0.27") - # 多来源时最低倍率占 85%,其余平分 15%: - # 0.18 * 0.85 + (0.25 + 0.38) * 0.075 = 0.20025 -> 0.20025 * 1.5 = 0.300375 -> 0.30 - assert calculate_target_rate(["0.18", "0.25", "0.38"], 50, "priority_weighted_plus_percent") == Decimal("0.30") + # 0.18、0.25 应当至少满足 min * 1.50 (0.27) 和 max * 1.10 (0.275),向上取整为 0.28,忽略 percent 输入 + assert calculate_target_rate(["0.18", "0.25"], 50, "priority_weighted_plus_percent") == Decimal("0.28") + assert calculate_target_rate(["0.18", "0.25"], 0, "priority_weighted_plus_percent") == Decimal("0.28") - # 4 个来源以锁定均分 15% 的权重语义: - # 0.18 * 0.85 + (0.24 + 0.30 + 0.36) * 0.05 = 0.198 -> 0.198 * 1.5 = 0.297 -> 0.30 - assert calculate_target_rate(["0.18", "0.24", "0.30", "0.36"], 50, "priority_weighted_plus_percent") == Decimal("0.30") - assert calculate_target_rate(["0.18", "0.24", "0.30", "0.36"], 0, "priority_weighted_plus_percent") == Decimal("0.20") + # 单来源 0.05 自动得到 0.08 (max(0.05 * 1.50, 0.05 * 1.10) = 0.075 -> ROUND_UP -> 0.08) + assert calculate_target_rate(["0.05"], 0, "priority_weighted_plus_percent") == Decimal("0.08") - # 验证保护线与精度保护:最低来源 0.124,应当四舍五入并返回两位小数 - assert calculate_target_rate(["0.124"], 0, "priority_weighted_plus_percent") == Decimal("0.12") + # 4 个来源:min=0.18, max=0.36. max(0.27, 0.396) = 0.396 -> ROUND_UP -> 0.40 + assert calculate_target_rate(["0.18", "0.24", "0.30", "0.36"], 50, "priority_weighted_plus_percent") == Decimal("0.40") + + # 验证保护线与精度保护:最低来源 0.124。max(0.186, 0.1364) = 0.186 -> ROUND_UP -> 0.19 + assert calculate_target_rate(["0.124"], 0, "priority_weighted_plus_percent") == Decimal("0.19") # 无可用正数倍率时报错 with pytest.raises(WebsiteError, match="没有可用的正数上游倍率"): diff --git a/frontend/src/views/Websites.vue b/frontend/src/views/Websites.vue index 3c8ad58..03a4f67 100644 --- a/frontend/src/views/Websites.vue +++ b/frontend/src/views/Websites.vue @@ -158,7 +158,7 @@
{{ binding.website_name }} / {{ binding.target_group_name || binding.target_group_id }}
- {{ algorithmLabel(binding.algorithm) }} + {{ binding.percent }}% · + {{ binding.algorithm === 'priority_weighted_plus_percent' ? `${algorithmLabel(binding.algorithm)} · 自动约 ${binding.percent}%` : `${algorithmLabel(binding.algorithm)} + ${binding.percent}%` }} · {{ binding.source_groups.length }} 个上游分组
@@ -297,8 +297,8 @@ -
- -
@@ -2588,6 +2590,27 @@ onMounted(loadAll) margin: 0; } +.custom-percent-stepper.disabled { + opacity: 0.6; + background: #0f0706; + border-color: #2b1c18; +} + +.custom-percent-stepper.disabled .stepper-btn { + cursor: not-allowed; + color: #5c4a45; +} + +.custom-percent-stepper.disabled .stepper-btn:hover { + background: transparent; + color: #5c4a45; +} + +.custom-percent-stepper.disabled .stepper-input { + cursor: not-allowed; + color: #8c746b; +} + /* Custom Switch Toggle */ .switch-toggle-line { display: flex;