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)
+ ? "企业账号不参与成长中心,无法旅行"
+ : "旅行仅适用于国内账号"}