fix(priority-sync): handle missing rate data and backfill target group on re-import

P1 - Missing rate data now skips account instead of falling back to 1.0:
  In sync_account_priorities_for_upstream(), the rated list now filters
  out accounts whose upstream snapshot has no rate entry for their group_id.
  If after filtering a competitive bucket has fewer than 2 accounts with
  valid rate data, the entire bucket is silently skipped (no update_account
  call, no webhook) rather than treating missing rates as 1.0 and
  potentially triggering spurious notifications.

P2 - Re-importing an existing account now backfills imported_target_group_id:
  In the exists-is-True idempotency branch of import_upstream_keys_as_accounts(),
  if the current request supplies a target_group_id for the account's source group
  and it differs from what is stored, the field is written back and committed.
  This lets operators fix old data by simply re-running the import dialog.

Tests added:
  - test_missing_rate_skips_entire_competitive_group: all accounts in
    competitive group lack snapshot → bucket skipped, no update called
  - test_partial_missing_rate_sufficient_accounts_still_updates: 3 accounts
    in same bucket, 1 missing rate → the 2 with rates still compete normally

All 27 tests pass.
This commit is contained in:
liumangmang
2026-06-01 19:27:35 +08:00
parent e519d1804b
commit f17317b13c
3 changed files with 72 additions and 2 deletions
+5
View File
@@ -558,6 +558,11 @@ def import_upstream_keys_as_accounts(
old_account_id = row.imported_account_id
exists = c.account_exists(row.imported_account_id)
if exists is True:
# 顺手回填 imported_target_group_id(老数据升级后可通过重导自动补齐)
new_tgid = body.target_group_map.get(row.group_id) or None
if new_tgid and row.imported_target_group_id != new_tgid:
row.imported_target_group_id = new_tgid
db.commit()
items.append(ImportAccountItem(
upstream_key_id=row.id,
source_group_id=row.group_id,
+10 -2
View File
@@ -372,11 +372,19 @@ def sync_account_priorities_for_upstream(db: Session, upstream_id: int) -> list[
# priority_assignment: account_id → new_priority
priority_assignment: dict[str, int] = {}
for comp_key, comp_rows in competitive_buckets.items():
# 取每行的倍率(查不到则 fallback 1.0
# 只保留快照中能查到倍率的账号;无数据的账号不参与排序
rated = [
(row, raw_rate_map.get(f"{row.upstream_id}:{row.group_id}", 1.0))
(row, raw_rate_map[f"{row.upstream_id}:{row.group_id}"])
for row in comp_rows
if f"{row.upstream_id}:{row.group_id}" in raw_rate_map
]
# 过滤后有效账号不足 2 个 → 此分组无竞争意义,整组跳过
if len(rated) < 2:
logger.info(
"skip competitive bucket %s for website %s: only %d account(s) have rate data",
comp_key, wid, len(rated),
)
continue
# 组内按倍率升序排序(倍率低 → priority 小 → 优先)
unique_rates = sorted(set(r for _, r in rated))
rate_to_prio = {rate: idx + 1 for idx, rate in enumerate(unique_rates)}
+57
View File
@@ -282,6 +282,63 @@ def test_single_account_in_mixed_website(db_session, monkeypatch):
assert updated_ids == {"A1", "A2"}
def test_missing_rate_skips_entire_competitive_group(db_session, monkeypatch):
"""竞争分组内所有账号均无快照倍率 → 有效账号 < 2 → 整组跳过,不调用 update_account。"""
w = Website(name="W1", base_url="http://w1", enabled=True,
auth_config_json="{}", timeout_seconds=30)
u1 = Upstream(name="U1", base_url="http://u1")
db_session.add_all([w, u1])
db_session.commit()
db_session.refresh(w); db_session.refresh(u1)
# 故意不插快照 → raw_rate_map 为空
_make_key(db_session, u1.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
_make_key(db_session, u1.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
update_calls = []
monkeypatch.setattr(
"app.services.website_sync.Sub2ApiWebsiteClient",
make_mock_client(update_calls),
)
results = sync_account_priorities_for_upstream(db_session, u1.id)
assert update_calls == [], "无快照倍率时不应调用 update_account"
assert results == []
def test_partial_missing_rate_sufficient_accounts_still_updates(db_session, monkeypatch):
"""竞争分组内 3 账号,1 个无快照倍率,剩余 2 个有倍率 → 仍有竞争,2 个有倍率的账号正常更新。"""
w = Website(name="W1", base_url="http://w1", enabled=True,
auth_config_json="{}", timeout_seconds=30)
u1 = Upstream(name="U1", base_url="http://u1")
db_session.add_all([w, u1])
db_session.commit()
db_session.refresh(w); db_session.refresh(u1)
# G1、G2 有快照,G3 没有快照
_make_snapshot(db_session, u1.id, {"G1": 1.0, "G2": 2.0})
_make_key(db_session, u1.id, "G1", "K1", "V1", w.id, "A1", imported_target_group_id="TG1")
_make_key(db_session, u1.id, "G2", "K2", "V2", w.id, "A2", imported_target_group_id="TG1")
_make_key(db_session, u1.id, "G3", "K3", "V3", w.id, "A3", imported_target_group_id="TG1")
update_calls = []
monkeypatch.setattr(
"app.services.website_sync.Sub2ApiWebsiteClient",
make_mock_client(update_calls),
)
sync_account_priorities_for_upstream(db_session, u1.id)
updated = {c[0]: c[1]["priority"] for c in update_calls}
# A3 无快照 → 不参与排序,不被更新
assert "A3" not in updated
# A1(G1, rate=1.0) → priority=1A2(G2, rate=2.0) → priority=2
assert updated["A1"] == 1
assert updated["A2"] == 2
def test_priority_sync_log_structure(db_session, monkeypatch):
"""日志写入格式验证(需要竞争分组才会写日志)。"""
w = Website(name="W1", base_url="http://w1", enabled=True,