From 96f5c14a4c025e9d4de78115ec9374b733f5de6a Mon Sep 17 00:00:00 2001 From: moshouhot Date: Fri, 16 Oct 2026 20:31:44 +0800 Subject: [PATCH] Treat the growth center as personal-only for travel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An enterprise credential with a domestic product profile was scheduled for travel and answered every request with HTTP 403 / business code 403 and "growth system is only available for personal users", so the card showed 阶段:状态查询 · 上游 HTTP 错误 · HTTP 403 · 业务码 403 while the account was in fact permanently exempt. The region/product profile alone never described eligibility: the growth center is personal-only. Honour the tenant type before any request and stop recording a capability bound as the account's current travel state. - app/travel.py: supported()/unavailable() take the tenant flag, perform() refuses a tenant without opening a client, and the refusal is labelled personal_only so the message reads as a boundary in every phase rather than a retryable outage. - app/model_policy.py, app/gateway_management.py, app/credential_actions.py, converter.py: carry the tenant flag onto the pool entry and through the manual and automatic travel paths. - clear_unsupported() replaces a stale upstream refusal with the terminal notice once, so the periodic sweep stays write-free for exempt accounts. - The shared test fixture no longer marks every synthetic account as enterprise; no test asserted that value, and it contradicted the real personal-only rule. --- README.md | 2 +- README.zh-CN.md | 2 +- app/credential_actions.py | 9 +++-- app/gateway_management.py | 7 ++-- app/model_policy.py | 5 ++- app/travel.py | 55 ++++++++++++++++++++++++----- converter.py | 11 ++++++ docs/advanced.md | 2 +- docs/advanced.zh-CN.md | 2 +- docs/webui.md | 2 +- docs/webui.zh-CN.md | 2 +- tests/test_credential_automation.py | 53 ++++++++++++++++++++++++++- tests/test_region_routing.py | 9 +++-- tests/test_travel.py | 55 +++++++++++++++++++++++++++++ web/src/automation.test.tsx | 34 ++++++++++++++++-- web/src/pages/Credentials.tsx | 4 ++- 16 files changed, 227 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5891d49..2b82e72 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Use your **WorkBuddy / CodeBuddy (Tencent)** subscription as local **OpenAI- and - Chat Completions, Responses and Anthropic Messages, with tool calling and streaming. - Built-in **WebUI** for browser login, models, credentials, logs and settings — no desktop client required. - Automatic multi-account routing across domestic and international sites, with credential refresh. -- Per-account automation: domestic check-in then Buddy travel by default, with independent switches; international check-in is opt-in. +- Per-account automation: domestic personal accounts check in and dispatch Buddy travel by default, with independent switches; international check-in is opt-in. The growth center is personal-only, so enterprise accounts are excluded. ## Quick start diff --git a/README.zh-CN.md b/README.zh-CN.md index b1847cd..71f4056 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -7,7 +7,7 @@ - 支持 Chat Completions、Responses 和 Anthropic Messages,包含工具调用与流式输出。 - 内置 **WebUI**:扫码添加账号,管理模型、凭证、日志与设置,无需桌面端。 - 多账号自动选路,兼容国内/国际站,自动刷新凭证。 -- 按账号配置自动任务:国内默认签到后派 Buddy 旅行,可分别关闭;国际自动签到默认关闭。 +- 按账号配置自动任务:国内个人账号默认签到后派 Buddy 旅行,可分别关闭;国际自动签到默认关闭。成长中心仅面向个人账号,企业账号不参与。 ## 快速开始 diff --git a/app/credential_actions.py b/app/credential_actions.py index d99b792..55789a5 100644 --- a/app/credential_actions.py +++ b/app/credential_actions.py @@ -144,8 +144,12 @@ def _one(gateway, pool, ledger, entry, action, *, automatic=False, consent_revis cm, cid = entry["cm"], entry["id"] if not model_policy.credential_enabled(gateway.CONFIG, entry): return {"ok": False, "skipped": True, "message": "账号已人工停用"} - if action in {"travel", "travel-status"} and not travel.supported(entry.get("profile")): - return travel.unavailable() + if action in {"travel", "travel-status"} and not travel.supported(entry.get("profile"), entry.get("enterprise") is True): + # Record the terminal exemption so a previous HTTP refusal stops being shown as + # the account's current travel state. + notice = travel.unavailable(entry.get("profile"), entry.get("enterprise") is True) + travel.clear_unsupported(ledger, entry["id"], notice) + return notice with cm._lock: if cm.summary().get("account_key") != entry.get("account_key"): return {"ok": False, "state": "changed", "message": "凭证身份已变化,请刷新列表"} @@ -168,6 +172,7 @@ def can_write(): and pool.apply_if_current(cm, generation, lambda: None)) result = travel.perform(gateway._bearer_token(headers), gateway.profile_for_headers(headers), read_only=action == "travel-status", can_write=can_write, + enterprise=entry.get("enterprise") is True, buddy_context=gateway._buddy_context(entry, headers, consent_revision)) if not pool.apply_if_current(cm, generation, lambda: travel.remember(ledger, cid, result)): return {**result, "ok": False, "stale": True, "message": "凭证已变化,旅行结果未写入,请刷新核验"} diff --git a/app/gateway_management.py b/app/gateway_management.py index 7a92a77..71d0bcd 100644 --- a/app/gateway_management.py +++ b/app/gateway_management.py @@ -48,7 +48,7 @@ def admin_credential_inventory(self): auto_checkin=model_policy.credential_auto_checkin(self.CONFIG, entry), checkin=checkin.view(balance.get("checkin") or {}), auto_travel=model_policy.credential_auto_travel(self.CONFIG, entry), - travel_supported=travel.supported(entry.get("profile")), + travel_supported=travel.supported(entry.get("profile"), entry.get("enterprise") is True), travel=balance.get("travel") or {"state": "unknown", "message": "尚未查询旅行状态"}, trial_supported=entry.get("profile") == "intl-work", trial=(trial_management.view(trials.get(identity), now=now) if trials is not None @@ -110,8 +110,9 @@ def admin_set_auto_travel(self, identity, enabled): entry = next((entry for entry in pool.entries() if entry.get("account_key") == identity), None) if entry is None: raise HTTPException(404, "凭证不存在") - if enabled and not travel.supported(entry.get("profile")): - raise HTTPException(400, "旅行仅适用于国内账号") + if enabled and not travel.supported(entry.get("profile"), entry.get("enterprise") is True): + raise HTTPException(400, "企业账号不参与成长中心,无法旅行" + if entry.get("enterprise") is True else "旅行仅适用于国内账号") self.CONFIG["control_store"].set_auto_travel(identity, enabled) def admin_set_auto_daily_chat(self, identity, enabled): diff --git a/app/model_policy.py b/app/model_policy.py index 66aa180..89e6d7b 100644 --- a/app/model_policy.py +++ b/app/model_policy.py @@ -3,6 +3,7 @@ from fastapi import HTTPException +from app import travel from app.model_capabilities import route_metadata _request_policy = ContextVar("gateway_model_policy", default=None) @@ -37,7 +38,9 @@ def credential_auto_checkin(config, entry): def credential_auto_travel(config, entry): - supported = entry.get("profile") in {"cn-cli", "cn-work"} + # The growth center is personal-only, so an enterprise tenant must never be scheduled + # for travel even though it shares the domestic product profile. + supported = travel.supported(entry.get("profile"), entry.get("enterprise") is True) return supported and snapshot(config)["credentials"].get(entry.get("account_key"), {}).get("auto_travel", True) diff --git a/app/travel.py b/app/travel.py index ec5486d..927a4e7 100644 --- a/app/travel.py +++ b/app/travel.py @@ -20,12 +20,42 @@ def __init__(self, kind, http_status=200, code=0, reason=None): self.diagnostics["reason"] = reason -def supported(profile): - return profile in {"cn-cli", "cn-work"} +PERSONAL_PROFILES = frozenset({"cn-cli", "cn-work"}) -def unavailable(): - return {"ok": False, "skipped": True, "state": "unavailable", "message": "旅行仅适用于国内账号"} +def supported(profile, enterprise=False): + """Whether the growth-center travel program can serve this account. + + The upstream growth center is personal-only: an enterprise tenant is refused + with ``growth system is only available for personal users`` (HTTP 403 / + business code 403) on every buddy and travel path, so the region/product + profile alone is not sufficient and the tenant type must be honoured first. + """ + return profile in PERSONAL_PROFILES and not enterprise + + +def unavailable(profile=None, enterprise=False): + """Explain the exemption, naming the most specific cause for the operator.""" + if enterprise and (profile is None or profile in PERSONAL_PROFILES): + message = "企业账号不参与成长中心,无法旅行" + elif profile is None or profile in PERSONAL_PROFILES: + message = "当前账号不参与成长中心,无法旅行" + else: + message = "旅行仅适用于国内账号" + return {"ok": False, "skipped": True, "state": "unavailable", "message": message} + + +def clear_unsupported(ledger, cid, notice): + """Persist the terminal exemption once so a stale refusal cannot outlive the gate. + + Returns whether the stored record changed, which keeps the periodic sweep from + rewriting the ledger on every pass for accounts that can never travel. + """ + previous = ledger.entry(cid).get("travel") or {} + if previous.get("state") == "unavailable" and previous.get("message") == notice["message"]: + return False + ledger.update_travel(cid, {**notice, "at": time.time()}) + return True def _number(value): @@ -59,6 +89,9 @@ def _request(client, token, operation, *, body=None): reason = next((value for text, value in { "no active buddy": "no_active_buddy", "daily limit": "daily_limit", "already traveling": "already_traveling", "location not available": "location_unavailable", + # A tenant that reaches the growth center anyway is exempt, not broken: label it + # so the operator sees a capability boundary instead of a bare HTTP failure. + "only available for personal users": "personal_only", }.items() if isinstance(message, str) and len(message) <= 512 and text in message.lower()), None) if status != 200: raise _Failure("http", status, code, reason) @@ -105,9 +138,10 @@ def _status(data): "server_now": server_now, "remaining_seconds": remaining} -def perform(token, profile, *, read_only=False, can_write=lambda: True, buddy_context=None): - if not supported(profile): - return unavailable() +def perform(token, profile, *, read_only=False, can_write=lambda: True, buddy_context=None, + enterprise=False): + if not supported(profile, enterprise): + return unavailable(profile, enterprise) result = {"ok": False, "state": "unknown", "claimed": False, "departed": False, "stale": False, "phase": "status"} phase = "status" context = buddy_context or {} @@ -282,7 +316,12 @@ def record_departure(stage, outcome): "daily_limit": "官方拒绝派遣:今日派遣已达上限", "already_traveling": "官方拒绝派遣:猫猫已在旅行,请查询最新状态", "location_unavailable": "官方拒绝派遣:该地点暂时不可用"}.get(result.get("reason")) - if phase == "depart" and refusal: + if result.get("reason") == "personal_only": + # A capability boundary, not a transient failure: say so in every phase so the + # operator does not read a permanent exemption as a retryable outage. + result["message"] = ("旅行积分已领取;" if result["claimed"] else "") + \ + "该账号不参与成长中心(仅个人账号可用),已停止旅行操作" + elif phase == "depart" and refusal: result["message"] = ("旅行积分已领取;" if result["claimed"] else "") + refusal if diagnostics["error_kind"] == "storage": receipt = ("领取已确认,但" if write_operation == "claim" else "派遣已确认,但") if confirmed else "" diff --git a/converter.py b/converter.py index c1a8b0e..3a3fb04 100644 --- a/converter.py +++ b/converter.py @@ -614,6 +614,7 @@ def reload(self, paths: list[Path], *, reset: bool = True): if entry.get("uid"): have_uids.pop(old_identity, None) entry.update(uid=summary.get("uid"), profile=summary["profile"], site=summary["site"], + enterprise=summary.get("enterprise") is True, account_key=identity, generation=generation, catalog_dirty=True) self._bind_entry(entry) if reset or replaced: @@ -655,6 +656,7 @@ def reload(self, paths: list[Path], *, reset: bool = True): continue entry = {"id": cid, "cm": manager, "fail_until": 0.0 if summary else time.time() + CRED_COOLDOWN, "uid": uid, "site": summary.get("site"), "profile": profile, "generation": manager._generation, + "enterprise": summary.get("enterprise") is True, "account_key": identity_key, "catalog_dirty": True} self._bind_entry(entry) self._adopt_cooldowns(entry) @@ -1546,12 +1548,21 @@ def can_claim(): _sync_error(pool, ledger, entry, generation, "checkin", error) if not model_policy.credential_enabled(CONFIG, entry): return None + if checkin and not travel.supported(entry.get("profile"), entry.get("enterprise") is True): + # The exemption is terminal, so keep the ledger honest instead of leaving the + # previous upstream refusal as this account's current travel state. A stored + # notice is only rewritten when it differs, so the sweep stays a no-op after. + notice = travel.unavailable(entry.get("profile"), entry.get("enterprise") is True) + if not pool.apply_if_current(cm, generation, lambda: travel.clear_unsupported(ledger, cid, notice)): + failed.add(cid) + return None if checkin and model_policy.credential_auto_travel(CONFIG, entry): try: def can_travel(): return (model_policy.credential_auto_travel(CONFIG, entry) and pool.apply_if_current(cm, generation, lambda: None)) trip = travel.perform(token, profile_for_headers(headers), can_write=can_travel, + enterprise=entry.get("enterprise") is True, buddy_context=_buddy_context(entry, headers)) if not pool.apply_if_current(cm, generation, lambda: travel.remember(ledger, cid, trip)): failed.add(cid) diff --git a/docs/advanced.md b/docs/advanced.md index 228eebc..17150de 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -135,7 +135,7 @@ Switch back to `legacy` to restore old behavior for new requests; in-flight requ ## Automation and rewards -Automatic check-in and Buddy travel are per-account switches (WebUI credentials page): on by default domestically, off internationally, applied live without restart. +Automatic check-in and Buddy travel are per-account switches (WebUI credentials page): on by default for domestic personal accounts, off internationally, applied live without restart. The growth center is personal-only, so enterprise accounts never travel and no longer surface the upstream 403. `CODEBUDDY2API_AUTO_ACCEPT_BUDDY` is startup-only and defaults to `false`. It preauthorizes enabled domestic accounts for first-Buddy onboarding, agreement and travel; automatic travel still respects its account switch. `first_buddy` needs no acceptance API: pending states, including `not_accepted`, allow one real domestic WorkBuddy conversation on that account. Prefer an eligible zero-rate model, otherwise the lowest known rate; request at most 32 output tokens with possible credit usage. Other reward tasks, paid boxes, pet switching and international trials are excluded. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 6dcf37e..d280c06 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -135,7 +135,7 @@ scoped 模式可选传入 `X-Codebuddy-Session-ID`、`metadata.conversation_id` ## 自动化与奖励 -自动签到与 Buddy 旅行是按账号设置的开关(WebUI 凭证页):国内默认开启,国际默认关闭,保存即生效,无需重启。 +自动签到与 Buddy 旅行是按账号设置的开关(WebUI 凭证页):国内个人账号默认开启,国际默认关闭,保存即生效,无需重启。成长中心仅面向个人账号,企业账号不参与旅行(也不再显示上游 403)。 `CODEBUDDY2API_AUTO_ACCEPT_BUDDY` 仅启动读取,默认 `false`;预授权已启用国内账号完成首次领猫任务、协议及旅行,自动旅行仍受账号开关控制。`first_buddy` 无需单独接取,包含 `not_accepted` 在内的待完成状态可直接发起一次本账号的真实国内 WorkBuddy 对话;优先可用零倍率模型,否则取最低已知倍率,最多请求 32 个输出 token,可能消耗少量积分。不执行其他奖励任务、不付费开盒、不切猫、不领取国际试用积分。 diff --git a/docs/webui.md b/docs/webui.md index 70af9e3..7703c10 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -24,7 +24,7 @@ Add accounts by browser login — Mainland China (CN), International WorkBuddy o ### Automation -Automatic check-in and travel are persisted per account and apply live: on by default for domestic accounts, off internationally. Disabled accounts run no automatic tasks; inactive or unconfirmed activities never authorize claims. Saving a preference does not claim immediately and cannot retract sent requests; the console shows last results, partial completion and uncertainty. +Automatic check-in and travel are persisted per account and apply live: on by default for domestic personal accounts, off internationally. Enterprise accounts are outside the personal-only growth center, so they never travel and no longer render the upstream 403. Disabled accounts run no automatic tasks; inactive or unconfirmed activities never authorize claims. Saving a preference does not claim immediately and cannot retract sent requests; the console shows last results, partial completion and uncertainty. - **Domestic travel** follows its own switch, including when check-in is already complete or disabled. "Travel status" only queries; "Claim / dispatch" claims arrivals, rechecks idle state and the daily limit, then chooses a current upstream location. Invalid location configuration stops dispatch. - **First-Buddy onboarding:** one agreement checkbox completes `first_buddy` through one real WorkBuddy conversation if needed, then adopts and dispatches. No separate task acceptance is required. The conversation requests at most 32 output tokens and may use credits; an eligible zero-rate model is preferred, and official completion is checked before adoption. `CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` preauthorizes enabled domestic accounts, including future imports, after restart; default is false, automatic follow-up respects the travel switch, and no other reward tasks, paid boxes or trial claims run. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 04d4841..ed30c2f 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -24,7 +24,7 @@ ### 自动化 -自动签到与旅行按账号持久化并即时生效:国内账号默认开启,国际默认关闭。停用的账号不执行任何自动任务;未开始或未确认的活动不会触发领取。保存开关不会立即领取,也无法撤回已发出的请求;界面展示最近一次结果、部分完成与不确定状态。 +自动签到与旅行按账号持久化并即时生效:国内个人账号默认开启,国际默认关闭;企业账号不参与成长中心,因此不旅行且不再显示上游 403。停用的账号不执行任何自动任务;未开始或未确认的活动不会触发领取。保存开关不会立即领取,也无法撤回已发出的请求;界面展示最近一次结果、部分完成与不确定状态。 - **国内旅行** 有独立开关,即使签到已完成或关闭也按此开关执行。「查询状态」只读;「领取/派出」先领取到达奖励,复核空闲状态与每日上限,再选择当前上游位置。位置配置无效会停止派出。 - **首次领养(first_buddy):** 勾选一个协议复选框,必要时通过一次真实 WorkBuddy 对话完成官方新手任务,然后领养并派出。无需单独接取任务。该对话最多请求 32 个输出 token,可能消耗积分;优先使用可用的零倍率模型,并在领养前核验官方完成状态。`CODEBUDDY2API_AUTO_ACCEPT_BUDDY=true` 在重启后预授权所有启用的国内账号(含后续导入);默认关闭,自动后续仍受旅行开关控制,不执行其他奖励任务、付费开盒或试用领取。 diff --git a/tests/test_credential_automation.py b/tests/test_credential_automation.py index 72da5bc..4914065 100644 --- a/tests/test_credential_automation.py +++ b/tests/test_credential_automation.py @@ -22,6 +22,11 @@ def preference(self, entry, field, enabled): self.assertEqual(response.status_code, 200, response.text) self.assertIs(response.json()[field], enabled) + def post(self, entry, action): + response = self.client.post('/admin/credentials/' + entry['account_key'] + '/' + action) + self.assertEqual(response.status_code, 200, response.text) + return response.json()['results'][0] + def periodic(self, entry): with patch.object(credits, 'fetch_credits', return_value={'credits': 10, 'intl': entry['profile'].startswith('intl')}): return converter._sync_credits(self.pool, self.ledger, entry, checkin=True, failed=set()) @@ -181,6 +186,53 @@ def test_cookie_preferences_require_csrf(self): self.preference(self.entry, 'auto_checkin', False) + def test_enterprise_travel_is_exempt_and_no_longer_reports_an_upstream_403(self): + """Replays the reported symptom: an enterprise card stuck on 阶段:状态查询 · HTTP 403. + + The growth center is personal-only, so the account must be recognised as exempt + before any request is made, and the stale refusal must be cleared from the ledger + so the dashboard stops presenting a permanent bound as the current travel state. + """ + self.add_account('enterprise-tenant', 'cn-cli', enterprise='fylzonuezfnk') + # A tenant is a separate identity, so the pool must be rebuilt around both files. + self.configure(profiles=('cn-cli', 'enterprise-tenant')) + entry = self.entries['enterprise-tenant'] + # Seed the exact failure the operator saw. + self.ledger.update_travel(entry['id'], {'ok': False, 'state': 'unknown', 'phase': 'status', + 'error_kind': 'http', 'http_status': 403, 'code': 403, + 'message': '状态查询失败,未执行写操作'}) + self.assertTrue(entry['enterprise']) + self.assertFalse(model_policy.credential_auto_travel(converter.CONFIG, entry)) + # The periodic sweep clears the stale refusal instead of replaying it. + with patch.object(credits, 'fetch_credits', return_value={'credits': 10, 'intl': False}): + converter._sync_credits(self.pool, self.ledger, entry, checkin=True, failed=set()) + travel_row = self.ledger.entry(entry['id'])['travel'] + self.assertEqual(travel_row['state'], 'unavailable') + self.assertIsNone(travel_row.get('http_status')) + self.assertIsNone(travel_row.get('error_kind')) + self.assertIn('企业账号', travel_row['message']) + # The admin row and the manual action both report the exemption without any request. + rows = self.client.get('/admin/credentials').json()['credentials'] + row = next(r for r in rows if r['name'] == 'enterprise-tenant.info') + self.assertIs(row['travel_supported'], False) + self.assertIs(row['auto_travel'], False) + self.assertEqual(row['travel']['state'], 'unavailable') + self.travel_mock.reset_mock() + self.requests.clear() + result = self.post(entry, 'travel') + self.assertTrue(result['skipped']) + self.assertIn('企业账号', result['message']) + self.travel_mock.assert_not_called() + self.assertEqual(self.requests, []) + + def test_a_personal_account_keeps_travelling(self): + entry = self.entries['cn-cli'] + self.assertFalse(entry.get('enterprise') is True) + self.assertTrue(model_policy.credential_auto_travel(converter.CONFIG, entry)) + self.assertTrue(self.post(entry, 'travel')['ok']) + self.travel_mock.assert_called_once() + + class CheckinProtocolTests(unittest.TestCase): def test_status_requires_active_and_explicit_today_flag(self): cases = [({}, 'unknown'), ({'active': True}, 'unknown'), ({'active': 'true', 'today_checked_in': False}, 'unknown'), @@ -213,6 +265,5 @@ def test_unknown_status_never_claims(self): self.assertFalse(result['ok']) claim.assert_not_called() - if __name__ == '__main__': unittest.main() diff --git a/tests/test_region_routing.py b/tests/test_region_routing.py index c1c67c3..567e306 100644 --- a/tests/test_region_routing.py +++ b/tests/test_region_routing.py @@ -94,9 +94,14 @@ def async_client(*args, **kwargs): self.configure() self.client = self.enterContext(TestClient(converter.app)) - def add_account(self, uid, profile): + def add_account(self, uid, profile, enterprise=""): now = time.time() - value = {"account": {"uid": uid, "enterpriseId": "synthetic-enterprise"}, + # Real accounts are personal unless they carry a tenant, and the growth center + # refuses a tenant outright, so the default fixture must stay personal. + account = {"uid": uid} + if enterprise: + account["enterpriseId"] = enterprise + value = {"account": account, "auth": {"domain": DOMAINS[profile], "accessToken": "synthetic-access-" + uid, "refreshToken": "synthetic-refresh-" + uid, "expiresAt": (now + 86400) * 1000, "lastRefreshTime": now * 1000}} diff --git a/tests/test_travel.py b/tests/test_travel.py index de15b11..8d33287 100644 --- a/tests/test_travel.py +++ b/tests/test_travel.py @@ -492,6 +492,61 @@ def test_international_never_opens_a_network_client(self): self.assertTrue(travel.perform('synthetic-token', profile)['skipped']) client.assert_not_called() + def test_enterprise_never_opens_a_network_client_and_says_why(self): + """A tenant is refused by the growth center, so travel must not even try.""" + with patch('app.travel.httpx.Client') as client: + for profile in ('cn-cli', 'cn-work', None): + with self.subTest(profile=profile): + result = travel.perform('synthetic-token', profile, enterprise=True) + self.assertTrue(result['skipped']) + self.assertEqual(result['state'], 'unavailable') + self.assertIn('企业账号', result['message']) + for profile in ('intl-work', 'intl-cli', 'unknown'): + with self.subTest(profile=profile): + # The region bound is the more actionable explanation for these. + result = travel.perform('synthetic-token', profile, enterprise=True) + self.assertTrue(result['skipped']) + self.assertIn('国内账号', result['message']) + client.assert_not_called() + + def test_unsupported_gate_matches_the_upstream_personal_only_rule(self): + for profile, enterprise, expected in (('cn-cli', False, True), ('cn-work', False, True), + ('cn-cli', True, False), ('cn-work', True, False), + ('intl-cli', False, False), ('intl-work', False, False), + (None, False, False), ('unknown', False, False)): + with self.subTest(profile=profile, enterprise=enterprise): + self.assertIs(travel.supported(profile, enterprise), expected) + # A bare profile keeps the historical call shape working. + self.assertTrue(travel.supported('cn-work')) + self.assertFalse(travel.supported('intl-work')) + + def test_personal_only_refusal_is_labelled_instead_of_read_as_an_outage(self): + """A tenant that slips past the gate must read as a boundary, not a retryable failure.""" + refusal = httpx.Response(403, json={'code': 403, 'msg': 'growth system is only available for personal users'}) + # The gate is not told about the tenant here, so the upstream refusal is what + # surfaces: it must still be labelled as a capability bound. + result, _ = self.run_trip([refusal], profile='cn-work') + self.assertFalse(result['ok']) + self.assertEqual(result['reason'], 'personal_only') + self.assertIn('不参与成长中心', result['message']) + self.assertNotIn('失败', result['message']) + + def test_clearing_an_unsupported_account_drops_a_stale_upstream_failure(self): + """The stored 403 must not outlive the gate, and repeats must not rewrite the ledger.""" + with tempfile.TemporaryDirectory() as folder: + ledger = CreditLedger(Path(folder)/'ledger.json') + ledger.update_travel('one', {'ok': False, 'state': 'unknown', 'phase': 'status', + 'error_kind': 'http', 'http_status': 403, 'code': 403, + 'message': '状态查询失败,未执行写操作'}) + notice = travel.unavailable('cn-cli', True) + self.assertTrue(travel.clear_unsupported(ledger, 'one', notice)) + stored = ledger.entry('one')['travel'] + self.assertEqual(stored['state'], 'unavailable') + self.assertIsNone(stored.get('http_status')) + self.assertIsNone(stored.get('error_kind')) + # A second sweep is a no-op, so the housekeeper stays write-free for these accounts. + self.assertFalse(travel.clear_unsupported(ledger, 'one', notice)) + def test_failure_keeps_last_success_without_recursive_growth(self): with tempfile.TemporaryDirectory() as folder: ledger = CreditLedger(Path(folder)/'ledger.json') diff --git a/web/src/automation.test.tsx b/web/src/automation.test.tsx index f29398a..383460a 100644 --- a/web/src/automation.test.tsx +++ b/web/src/automation.test.tsx @@ -1,7 +1,7 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { beforeEach, expect, it, vi } from "vite-plus/test"; import { Credentials } from "./pages/Credentials"; -import { api, credentialResponse, useResource } from "./api"; +import { api, credentialResponse, useResource, type Credential } from "./api"; import { TravelSummary } from "./Travel"; function openRowMenu(credentialName: string) { @@ -19,7 +19,7 @@ vi.mock("./api", async (original) => ({ })); beforeEach(() => vi.restoreAllMocks()); function fixture() { - const rows = [ + const rows: Credential[] = [ { id: "cn", name: "cn.info", @@ -109,6 +109,34 @@ it("renders server preferences, allows international checkin opt-in, but never o expect(post).not.toHaveBeenCalled(); }); +it("explains an enterprise account's travel exemption instead of blaming the region", async () => { + const { rows } = fixture(); + // The growth center is personal-only, so the enterprise card must state the real reason. + rows.push({ + id: "ent", + name: "ent.info", + profile: "cn-cli", + enabled: true, + health: "ready", + enterprise: true, + enterpriseName: "示例企业", + auto_checkin: true, + auto_travel: false, + travel_supported: false, + travel: { state: "unavailable", message: "企业账号不参与成长中心,无法旅行" }, + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "任务配置 ent.info" })); + expect(screen.getByRole("switch", { name: "自动旅行 ent.info" })).toHaveProperty( + "disabled", + true, + ); + expect(screen.getByText("企业账号不参与成长中心,无法旅行")).toBeTruthy(); + // The manual travel actions are hidden, so no request can be sent for this account. + expect(screen.queryByRole("button", { name: "旅行领派 ent.info" })).toBeNull(); + expect(screen.queryByRole("button", { name: "旅行状态 ent.info" })).toBeNull(); +}); + it("does not pretend a failed or malformed save succeeded", async () => { fixture(); const patch = vi.spyOn(api, "patch").mockResolvedValue({ data: { id: "intl", revision: 1 } }); @@ -205,7 +233,7 @@ it("disables switches when an older backend omits them and rejects malformed typ it("shows server-provided travel locations and snapshot durations without triggering requests", () => { const { rows } = fixture(); - Object.assign(rows[0].travel, { + Object.assign(rows[0].travel as Record, { location_name: "海边书店", remaining_seconds: 3661, phase: "after_depart", diff --git a/web/src/pages/Credentials.tsx b/web/src/pages/Credentials.tsx index 090b6ba..fe04595 100644 --- a/web/src/pages/Credentials.tsx +++ b/web/src/pages/Credentials.tsx @@ -701,7 +701,9 @@ export function Credentials() { {c.travel_supported === true ? `上次旅行:${trip ? text(trip.message) : "尚未查询"}` - : "旅行仅适用于国内账号"} + : isEnterprise(c) + ? "企业账号不参与成长中心,无法旅行" + : "旅行仅适用于国内账号"}