From 84e8a36b6dcc3a9bb6fac3ff571ac7e8c856495e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:06:01 +0800 Subject: [PATCH] Make trial credits manual-only with visible claim results --- .env.example | 3 +- app/admin_api.py | 1 + app/control_store.py | 2 +- app/credential_actions.py | 24 +- app/gateway_management.py | 9 +- app/runtime_management.py | 3 +- app/settings.py | 5 +- app/trial_management.py | 114 +++++++++ app/trial_rewards.py | 65 +++-- converter.py | 32 +-- docker-compose.yml | 1 - docs/advanced.md | 9 +- docs/advanced.zh-CN.md | 9 +- docs/webui.md | 1 + docs/webui.zh-CN.md | 1 + tests/test_credential_actions.py | 4 +- tests/test_credential_automation.py | 4 +- tests/test_deployment.py | 3 +- tests/test_trial_integration.py | 372 +++++++++++++++++++++------- tests/test_trial_rewards.py | 6 +- tests/webui_fixture.py | 10 +- web/e2e/backend.integration.ts | 69 ++++++ web/src/Trial.tsx | 149 +++++++++++ web/src/api.ts | 4 +- web/src/pages/Credentials.tsx | 25 +- web/src/trial.test.tsx | 151 +++++++++++ web/src/values.tsx | 4 + 27 files changed, 914 insertions(+), 166 deletions(-) create mode 100644 app/trial_management.py create mode 100644 web/src/Trial.tsx create mode 100644 web/src/trial.test.tsx diff --git a/.env.example b/.env.example index f9c6098..040ab7e 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,7 @@ CODEBUDDY2API_LOG= # 正文日志最多预览 64 KiB;0 仅记录摘要,常见认证字段与图片 base64 会脱敏。 CODEBUDDY2API_LOG_BODY_LIMIT=65536 -# 国际 WorkBuddy 一次性体验积分领取;默认关闭,仅对符合上游资格的账号生效。 -CODEBUDDY2API_AUTO_TRIAL=false +# 一次性体验积分仅在 WebUI 凭证页手动领取,无自动领取开关。 # 换凭证重放次数:失败发生在向下游落第一个字节之前时,最多再换几个凭证就地重放。 # 默认 0(关闭):如实把上游 429/502 回给下游。只重放上游确定没收下请求体(建连失败/建连超时) diff --git a/app/admin_api.py b/app/admin_api.py index ee68e84..eb0825e 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -48,6 +48,7 @@ def _public_credential(item): "models", "remaining", "enterprise_id", "product", "status", "sync_pending", "sync_error", "fail_until", "cooldown_until", "cooldown_remaining", "last_failure_at", "catalog_ready", "bindings", "auto_checkin", "auto_travel", "travel_supported", "checkin", "travel", + "trial_supported", "trial", "token_expired", "token_expires_at", "last_refresh_time", "sessions", "sticky_sessions", "last_error_code"} result = {key: value for key, value in item.items() if key in fields} identity = item.get("account_key") or item.get("id") diff --git a/app/control_store.py b/app/control_store.py index fd94b82..8055255 100644 --- a/app/control_store.py +++ b/app/control_store.py @@ -98,7 +98,7 @@ def _load(self): data = json.loads(row[1]) if not isinstance(data, dict) or set(data) != {"settings", "models", "credentials"}: raise ValueError("管理数据库状态无效") - validate_settings(data["settings"]) + data["settings"] = validate_settings(data["settings"], legacy=True) if not isinstance(data["models"], dict) or not isinstance(data["credentials"], dict): raise ValueError("管理数据库策略无效") for source, rule in data["models"].items(): diff --git a/app/credential_actions.py b/app/credential_actions.py index 566475a..1ecf99f 100644 --- a/app/credential_actions.py +++ b/app/credential_actions.py @@ -4,16 +4,16 @@ from fastapi import HTTPException -from . import checkin, model_policy, travel +from . import checkin, model_policy, travel, trial_management from .credential_io import credential_file_lock def run(gateway, action, identity=None): - if action not in {"refresh", "checkin", "sync", "travel", "travel-status"} or (action in {"refresh", "travel", "travel-status"} and identity is None): + if action not in {"refresh", "checkin", "sync", "travel", "travel-status", "trial"} or (action in {"refresh", "travel", "travel-status", "trial"} and identity is None): raise HTTPException(404, "凭证操作不存在") config = gateway.CONFIG pool, ledger = config.get("cred_pool"), config.get("ledger") - if pool is None or (action != "refresh" and (ledger is None or gateway.credits_mod is None)): + if pool is None or (action not in {"refresh", "trial"} and (ledger is None or gateway.credits_mod is None)): raise HTTPException(503, "凭证维护尚未就绪") # Do not queue duplicate manual work behind the periodic maintenance sweep. if not gateway._HOUSEKEEP_LOCK.acquire(blocking=False): @@ -23,11 +23,15 @@ def run(gateway, action, identity=None): entries = [dict(e) for e in pool.entries() if identity is None or e.get("account_key") == identity] if identity is not None and not entries: raise HTTPException(404, "凭证不存在或身份已变化") + if action == "trial": + entries = entries[:1] # Duplicate files for one identity still represent one manual action. results = [] for entry in entries: + started = time.monotonic() result = {"id": entry.get("account_key"), "name": Path(entry["id"]).name, "action": action, "ok": False} if not model_policy.credential_enabled(config, entry): - result.update(skipped=True, message="账号已人工停用") + result.update(trial_management.failure("changed", skipped=True) if action == "trial" + else {"skipped": True, "message": "账号已人工停用"}) else: try: result.update(_one(gateway, pool, ledger, entry, action)) @@ -45,7 +49,13 @@ def run(gateway, action, identity=None): audit = config.get("audit_store") if audit: try: - audit.event("admin", "credential." + action, {"credential": result["id"], "ok": result["ok"]}) + details = {"credential": result["id"], "ok": result["ok"]} + if action == "trial": + details.update(outcome="success" if result["ok"] else "error", stage=result.get("state"), + status_code=result.get("status"), + code=str(result["code"]) if result.get("code") is not None else None, + duration_ms=(time.monotonic() - started) * 1000) + audit.event("admin", "credential." + action, details) except Exception: pass response = {"ok": bool(results) and all(r["ok"] for r in results), "results": results} @@ -57,6 +67,8 @@ def run(gateway, action, identity=None): def _one(gateway, pool, ledger, entry, action, *, automatic=False): + if action == "trial": + return trial_management.perform(gateway, pool, entry) cm, cid = entry["cm"], entry["id"] if not model_policy.credential_enabled(gateway.CONFIG, entry): return {"ok": False, "skipped": True, "message": "账号已人工停用"} @@ -102,7 +114,7 @@ def can_write(): return checkin.normalize({"state": "changed"}) return result failed = set() - ref = gateway._sync_credits(pool, ledger, entry, checkin=False, claim_trial=False, failed=failed, + ref = gateway._sync_credits(pool, ledger, entry, checkin=False, failed=failed, expected_identity=entry.get("account_key")) if ref is not None: if not pool.apply_if_current(cm, ref[1], lambda: entry.update(catalog_dirty=True)): diff --git a/app/gateway_management.py b/app/gateway_management.py index 402fefc..de9de7c 100644 --- a/app/gateway_management.py +++ b/app/gateway_management.py @@ -8,7 +8,7 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from starlette.staticfiles import StaticFiles -from . import checkin, model_policy, travel +from . import checkin, model_policy, travel, trial_management class Management: @@ -25,6 +25,7 @@ def admin_credential_inventory(self): return [] pool._rescan() now = time.time() + trials = trial_management.inventory(self.CONFIG.get("trial_ledger")) with pool._lock: entries = {entry["id"]: entry for entry in pool.entries()} result = [] @@ -48,6 +49,10 @@ def admin_credential_inventory(self): auto_travel=model_policy.credential_auto_travel(self.CONFIG, entry), travel_supported=travel.supported(entry.get("profile")), 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 + else trial_management.failure("storage_error")) + if entry.get("profile") == "intl-work" else trial_management.failure("not_applicable"), fail_until=until, cooldown_until=until, cooldown_remaining=max(0, round(until - now)), last_error_code=("http_401" if entry.get("last_error") == "backend HTTP 401" else @@ -185,8 +190,6 @@ def admin_apply_settings(self, values): self.CONFIG[key] = value if "model_catalog_ttl" in values and self.CONFIG.get("model_cache") is not None: self.CONFIG["model_cache"].ttl = values["model_catalog_ttl"] - if "auto_trial" in values and values["auto_trial"] and self.CONFIG.get("trial_ledger") is None: - self.CONFIG["trial_ledger"] = self.gateway.trial_rewards.TrialLedger(self.gateway.managed_auth_dir() / "trial-ledger.json") self.gateway.invalidate_model_table() diff --git a/app/runtime_management.py b/app/runtime_management.py index 0011238..76877d7 100644 --- a/app/runtime_management.py +++ b/app/runtime_management.py @@ -74,8 +74,7 @@ def initialize(gateway, args, argv=None): for key in SCHEMA: if hasattr(args, key) and key != "api_key": setattr(args, key, config[key]) - config["trial_ledger"] = (gateway.trial_rewards.TrialLedger(root / "trial-ledger.json") - if config["auto_trial"] else None) + config["trial_ledger"] = gateway.trial_rewards.TrialLedger(root / "trial-ledger.json") try: config["audit_store"] = AuditStore(root / "logs.sqlite3", max_bytes=config["audit_max_bytes"], retention_days=config["audit_retention_days"], diff --git a/app/settings.py b/app/settings.py index f51f846..f6edfa9 100644 --- a/app/settings.py +++ b/app/settings.py @@ -39,7 +39,6 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= "image_policy": _item("truncate", "string", "超额图片策略", env="CODEBUDDY2API_IMAGE_POLICY", choices=["truncate", "error"]), "max_request_bytes": _item(32 * 1024 * 1024, "integer", "请求字节上限", env="CODEBUDDY2API_MAX_REQUEST_BYTES", minimum=1, maximum=1024**3), "log_body_limit": _item(65536, "integer", "文本正文预览字节", env="CODEBUDDY2API_LOG_BODY_LIMIT", minimum=0, maximum=1024**2), - "auto_trial": _item(False, "boolean", "自动领取体验积分", env="CODEBUDDY2API_AUTO_TRIAL"), "failover_max": _item(0, "integer", "换凭证重放次数", env="CODEBUDDY2API_FAILOVER_MAX", minimum=0, maximum=10), "retry_write_timeout": _item(False, "boolean", "写超时参与重放", @@ -50,11 +49,13 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= } -def validate_settings(values): +def validate_settings(values, *, legacy=False): if not isinstance(values, dict): raise ValueError("values 必须是对象") clean = {} for key, value in values.items(): + if legacy and key == "auto_trial" and type(value) is bool: + continue # Retired persisted switch: accept old databases without enabling claims. spec = SCHEMA.get(key) if spec is None or spec["sensitive"]: raise ValueError("未知或启动来源锁定的配置项") diff --git a/app/trial_management.py b/app/trial_management.py new file mode 100644 index 0000000..38b551d --- /dev/null +++ b/app/trial_management.py @@ -0,0 +1,114 @@ +"""Safe manual trial results over the existing per-account ledger.""" +import time + +from . import model_policy, trial_rewards + +_MESSAGES = { + "available": "尚未领取,可手动向官方申请一次性体验积分", + "claimed": "体验积分已领取,可另行同步余额", + "already": "官方确认该账号已领取,无需重复申请", + "unconfirmed": "上次领取结果尚未确认,可能仍在执行;请勿立即重复申请", + "timeout": "领取请求超时,官方可能已处理;请先核对余额,勿重复点击", + "network_error": "连接官方失败或响应中断,请检查服务器网络、DNS 和代理;勿立即重复申请", + "invalid_response": "官方响应格式无效,未确认领取成功", + "response_too_large": "官方响应超过安全大小限制,已停止读取,未确认领取成功", + "auth_error": "官方拒绝了当前凭证,请刷新 Token 或重新登录后核对", + "credential_error": "凭证不可用或 Token 刷新失败,未发送领取请求;请先刷新凭证或重新登录", + "rejected": "官方未确认领取成功,资格和额度由官方决定", + "storage_error": "领取记录无法读取或保存,已停止操作;请检查凭证目录权限和磁盘空间", + "changed": "凭证身份、代次或启用状态已变化,已停止后续操作,请刷新列表核对", + "not_applicable": "仅支持国际 WorkBuddy 账号,国内账号及国际 CodeBuddy 不适用", + "internal_error": "领取操作异常,结果未确认;请先核对余额及领取记录,勿重复点击", +} + + +def failure(state, **fields): + return {"ok": False, "can_claim": False, "state": state, "message": _MESSAGES[state], **fields} + + +def view(record=None, *, now=None): + now = time.time() if now is None else now + record = record or {} + attempted = record.get("attempted_at") + retry_at = attempted + trial_rewards.RETRY_INTERVAL if attempted is not None else None + status, code = record.get("status"), record.get("code") + if record.get("ok") is True: + state = "claimed" + elif record.get("already") is True: + state = "already" + elif attempted is None: + state = "available" + elif record.get("finished_at") is None: + state = "unconfirmed" + elif status is None: + state = "network_error" + elif status in (401, 403): + state = "auth_error" + else: + state = "rejected" + done = state in {"claimed", "already"} + return {"ok": done, "can_claim": not done and (retry_at is None or now >= retry_at), + "state": state, "message": _MESSAGES[state], "code": code, "status": status, + "attempted_at": attempted, "finished_at": record.get("finished_at"), + "retry_at": None if done else retry_at} + + +def inventory(ledger): + if ledger is None: + return None + try: + return ledger.snapshot() + except (OSError, ValueError): + return None + + +def perform(gateway, pool, entry): + """Called only by a single-account admin POST under the maintenance lock.""" + if entry.get("profile") != "intl-work": + return failure("not_applicable", skipped=True) + ledger = gateway.CONFIG.get("trial_ledger") + records = inventory(ledger) + if records is None: + return failure("storage_error") + key, cm = entry.get("account_key"), entry["cm"] + previous = view(records.get(key)) + if not previous["can_claim"]: + return {**previous, "skipped": True} + try: + with cm._lock: + if cm.summary().get("account_key") != key: + return failure("changed") + try: + headers = cm.get_headers() + except Exception: + return failure("credential_error") + generation = cm._generation + profile = gateway.profile_for_headers(headers) + uid = headers.get("X-User-Id") + if (profile != "intl-work" or not uid or + gateway.account_key(profile, uid, headers.get("X-Enterprise-Id", "")) != key): + return failure("changed") + + def current(): + return (model_policy.credential_enabled(gateway.CONFIG, entry) + and pool.apply_if_current(cm, generation, lambda: None)) + + result = trial_rewards.attempt_trial(ledger, key, headers, can_claim=current) + # Retain the original account's result even if the token was replaced mid-flight. + records = inventory(ledger) + if records is None: + return failure("storage_error", claimed=result.get("ok") is True, + upstream_already=result.get("already") is True) + status = view(records.get(key)) + if result.get("error") in _MESSAGES and result.get("status") not in (401, 403): + status.update(ok=False, can_claim=False, state=result["error"], message=_MESSAGES[result["error"]]) + if not current(): + status["message"] += ";凭证已变化,请刷新列表核对原账号" + return status + except trial_rewards.TrialSaveError as error: + return failure("storage_error", claimed=error.result["ok"], upstream_already=error.result["already"], + message="官方请求已结束,但领取记录保存失败;请先核对余额,不要删除记录或立即重试") + except (OSError, ValueError): + return failure("storage_error") + except Exception: + return failure("internal_error") diff --git a/app/trial_rewards.py b/app/trial_rewards.py index 26419a4..0fa641e 100644 --- a/app/trial_rewards.py +++ b/app/trial_rewards.py @@ -20,14 +20,19 @@ RETRY_INTERVAL = 24 * 60 * 60 REQUEST_TIMEOUT = 12.0 +MAX_RESPONSE_BYTES = 64 * 1024 +RESPONSE_DEADLINE = 30.0 _MAX_BYTES = 1024 * 1024 _MAX_ACCOUNTS = 2048 # 满时拒绝新增,不能逐出已经领取的永久记录。 _RESULT_FIELDS = {"ok", "already", "code", "status"} _RECORD_FIELDS = _RESULT_FIELDS | {"attempted_at", "finished_at"} -def _result(code=None, status=None, *, ok=False, already=False): - return {"ok": ok, "already": already, "code": code, "status": status} +def _result(code=None, status=None, *, ok=False, already=False, error=None): + result = {"ok": ok, "already": already, "code": code, "status": status} + if error: + result["error"] = error + return result def _integer(value, lower, upper): @@ -64,19 +69,34 @@ def _trial_headers(headers): def claim_trial(headers: dict) -> dict: """仅一次同域 POST;保留传入身份头,不跟随重定向、不重试、不输出响应原文。""" headers = _trial_headers(headers) + status = None + started = time.monotonic() + request_headers = httpx.Headers(headers) + request_headers["Accept-Encoding"] = "identity" try: with httpx.Client(timeout=REQUEST_TIMEOUT, follow_redirects=False) as client: - response = client.post(PROFILE_ENDPOINTS["intl-work"] + "/billing/ide/trial", - headers=headers, json={}) + with client.stream("POST", PROFILE_ENDPOINTS["intl-work"] + "/billing/ide/trial", + headers=request_headers, json={}) as response: + status = response.status_code + if response.headers.get("content-encoding", "identity").lower() != "identity": + return _result(status=status, error="invalid_response") + content = bytearray() + for chunk in response.iter_bytes(): + if time.monotonic() - started > RESPONSE_DEADLINE: + return _result(status=status, error="timeout") + if len(content) + len(chunk) > MAX_RESPONSE_BYTES: + return _result(status=status, error="response_too_large") + content.extend(chunk) + except httpx.TimeoutException: + return _result(status=status, error="timeout") except httpx.HTTPError: - return _result() - status = response.status_code + return _result(status=status, error="network_error") try: - envelope = _strict_json(response.content) + envelope = _strict_json(content) except (ValueError, UnicodeError, RecursionError): - return _result(status=status) + return _result(status=status, error="invalid_response") if not isinstance(envelope, dict): - return _result(status=status) + return _result(status=status, error="invalid_response") code = _integer(envelope.get("code"), -(2**31), 2**31 - 1) result = _result(code, status) accepted = 200 <= status < 300 or (status in (400, 409) and code == 14051) @@ -100,7 +120,7 @@ def _key(key): def _timestamp(value): - if type(value) not in (int, float) or not math.isfinite(value) or not 0 <= value <= 1e12: + if type(value) not in (int, float) or not 0 <= value <= 1e12 or not math.isfinite(value): raise ValueError("Invalid trial timestamp") return value @@ -120,6 +140,13 @@ def _empty_record(): return {**_result(), "attempted_at": None, "finished_at": None} +class TrialSaveError(OSError): + """The upstream completed but its safe result could not be persisted.""" + def __init__(self, result): + super().__init__("Trial result persistence failed") + self.result = _safe_result(result) + + class TrialLedger: """锁保护的限量 JSON 账本;读改写均在同一跨进程锁内,不缓存磁盘状态。""" @@ -237,6 +264,11 @@ def finish(self, key, result, now=None) -> None: "finished_at": max(current, previous["attempted_at"])} self._save(accounts) + def snapshot(self) -> dict: + """Read one atomically published snapshot without creating or waiting on lock files.""" + return self._load() + + def summary(self, key) -> dict: """返回独立的安全快照,不包含指纹、路径、headers 或原始响应。""" key = _key(key) @@ -244,11 +276,16 @@ def summary(self, key) -> dict: return dict(self._load().get(key, _empty_record())) -def attempt_trial(ledger: TrialLedger, key: str, headers: dict) -> dict: - """推荐集成入口;先检查 profile,再落盘 attempt,最后一次 POST 和 finish。""" +def attempt_trial(ledger: TrialLedger, key: str, headers: dict, *, can_claim=lambda: True) -> dict: + """Reserve before the one manual POST; never replay an unconfirmed claim.""" headers = _trial_headers(headers) + if not can_claim(): + return _result(error="changed") if not ledger.begin(key): return _result() - result = claim_trial(headers) - ledger.finish(key, result) + result = claim_trial(headers) if can_claim() else _result(error="changed") + try: + ledger.finish(key, result) + except Exception: + raise TrialSaveError(result) from None return result diff --git a/converter.py b/converter.py index 43e829a..96cc77d 100644 --- a/converter.py +++ b/converter.py @@ -1106,25 +1106,7 @@ def _sync_error(pool, ledger, entry, generation, phase, error): _log(f"[{phase}] {Path(entry['id']).name} 同步失败(保留旧数据): {message}") -def _sync_trial(headers): - """可选福利领取与余额同步分离,持久化故障不阻断普通请求。""" - ledger = CONFIG.get("trial_ledger") - if not CONFIG.get("auto_trial") or ledger is None: - return - profile, uid = profile_for_headers(headers), headers.get("X-User-Id", "") - if profile != "intl-work" or not uid: - return - key = account_key(profile, uid, headers.get("X-Enterprise-Id", "")) - try: - previous = ledger.summary(key).get("attempted_at") - result = trial_rewards.attempt_trial(ledger, key, headers) - if ledger.summary(key).get("attempted_at") != previous: - _log(f"[trial] 领取检查 | ok={result['ok']} | already={result['already']} | code={result['code']} | status={result['status']}") - except Exception as error: - _log(f"[trial] 领取失败(不影响余额同步): {_network_error_text(error)}") - - -def _sync_credits(pool, ledger, entry, *, checkin, failed, claim_trial=True, expected_identity=None): +def _sync_credits(pool, ledger, entry, *, checkin, failed, expected_identity=None): if not model_policy.credential_enabled(CONFIG, entry): return None cm, cid = entry["cm"], entry["id"] @@ -1177,8 +1159,6 @@ def can_travel(): return None except Exception as error: _sync_error(pool, ledger, entry, generation, "travel", error) - if claim_trial: - _sync_trial(headers) if not model_policy.credential_enabled(CONFIG, entry): return None balance = credits_mod.fetch_credits(token, uid=uid, domain=domain) @@ -1459,7 +1439,7 @@ async def _protocol_http_exception(request: Request, exc: HTTPException): "model_catalogs": {}, # 仅供展示的产品合并目录(选择器子集) "account_catalogs": None, # 生产按账号指纹绑定;None 仅兼容无持久缓存的嵌入模式 # 每项含 models(选择器子集)与 serves(账号根表候选) - "auto_trial": False, "trial_ledger": None, + "trial_ledger": None, "model_guard": True, # 表外模型本地拦截,不转发上游 "max_images": 16, "image_policy": "truncate", "max_request_bytes": 32 * 1024 * 1024, "log_body_limit": 65536, @@ -3498,15 +3478,17 @@ def main(): "证明正文没写完,上游是否已按半截正文计费看不到,因此要显式开启(同时作用于连接" "重试与 --failover-max 换凭证重放)") ap.add_argument("--auto-trial", type=_boolean_arg, nargs="?", const=True, - default=os.environ.get("CODEBUDDY2API_AUTO_TRIAL", "false"), - help="自动领取国际 WorkBuddy 一次性体验积分,默认关闭") + default=None, help=argparse.SUPPRESS) args = ap.parse_args() + if args.auto_trial is not None or "CODEBUDDY2API_AUTO_TRIAL" in os.environ: + sys.stderr.write("[trial] AUTO_TRIAL / --auto-trial 已停用;请在 WebUI 凭证页手动领取体验积分。\n") + del args.auto_trial if args.image_policy not in ("truncate", "error"): ap.error("CODEBUDDY2API_IMAGE_POLICY 必须为 truncate 或 error") if args.command == "login": return login(site=args.site, open_browser=not args.no_browser) - for key in ("max_images", "image_policy", "max_request_bytes", "log_body_limit", "auto_trial", + for key in ("max_images", "image_policy", "max_request_bytes", "log_body_limit", "tool_call_max_retry", "max_inbound_bytes", "max_collect_bytes", "max_concurrent", "failover_max", "retry_write_timeout"): CONFIG[key] = getattr(args, key) diff --git a/docker-compose.yml b/docker-compose.yml index c0c2c2c..320075b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,6 @@ services: CODEBUDDY2API_MAX_REQUEST_BYTES: ${CODEBUDDY2API_MAX_REQUEST_BYTES:-33554432} CODEBUDDY2API_LOG_BODY_LIMIT: ${CODEBUDDY2API_LOG_BODY_LIMIT:-65536} CODEBUDDY2API_LOG: ${CODEBUDDY2API_LOG:-} - CODEBUDDY2API_AUTO_TRIAL: ${CODEBUDDY2API_AUTO_TRIAL:-false} # 容器内固定 0.0.0.0;对外暴露边界由上方 BIND 端口映射控制(默认仅回环) CODEBUDDY2API_ALLOW_OPEN_NOAUTH: "true" CODEBUDDY_AUTH_DIR: /data/auth diff --git a/docs/advanced.md b/docs/advanced.md index 1e1edbf..added63 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -26,7 +26,6 @@ Compose explicitly passes some environment variables and CLI flags, so deleting | `--usd-rate` | `7.15` | CNY per USD for billing conversion | | `--model-catalog-ttl` | `21600` | Model catalog cache TTL, seconds | | `--no-model-guard` | off | Disable the out-of-catalog guard; passthrough is limited to one product profile and still respects disabling, bindings and catalog readiness | -| `--auto-trial [true/false]` | `false` | Attempt one-time international WorkBuddy trial-credit claims | | `--max-images` | `16` | Total images per request; `0` permits no images | | `--image-policy` | `truncate` | Keep newest images; `error` rejects excess images with 413 | | `--tool-call-max-retry` | `3` | Extra generations after malformed tool calls (each consumes credits); `0` disables retries | @@ -38,9 +37,11 @@ Compose explicitly passes some environment variables and CLI flags, so deleting | `--max-request-bytes` | `33554432` | Positive byte limit for the processed upstream JSON | | `--log-body-limit` | `65536` | Text-log body preview bytes; `0` logs summaries only, not the SQLite diagnostic budget | -Environment variables include `CODEBUDDY_AUTH_DIR`, `CODEBUDDY_IMPORT_DIR`, `CODEBUDDY2API_KEY`, `CODEBUDDY2API_ADMIN_CSRF`, `CODEBUDDY2API_KEEP_TOOL_METADATA`, `CODEBUDDY2API_LOG`, `CODEBUDDY2API_MAX_IMAGES`, `CODEBUDDY2API_IMAGE_POLICY`, `CODEBUDDY2API_MAX_REQUEST_BYTES`, `CODEBUDDY2API_LOG_BODY_LIMIT`, `CODEBUDDY2API_AUTO_TRIAL`, `CODEBUDDY2API_FAILOVER_MAX` and `CODEBUDDY2API_RETRY_WRITE_TIMEOUT`. See [deployment](deployment.md) for startup examples. +Environment variables include `CODEBUDDY_AUTH_DIR`, `CODEBUDDY_IMPORT_DIR`, `CODEBUDDY2API_KEY`, `CODEBUDDY2API_ADMIN_CSRF`, `CODEBUDDY2API_KEEP_TOOL_METADATA`, `CODEBUDDY2API_LOG`, `CODEBUDDY2API_MAX_IMAGES`, `CODEBUDDY2API_IMAGE_POLICY`, `CODEBUDDY2API_MAX_REQUEST_BYTES`, `CODEBUDDY2API_LOG_BODY_LIMIT`, `CODEBUDDY2API_FAILOVER_MAX` and `CODEBUDDY2API_RETRY_WRITE_TIMEOUT`. See [deployment](deployment.md) for startup examples. -Trial-credit claims are off by default and only apply to upstream-eligible `intl-work` accounts. Successful/already-claimed results persist per account in `auth/trial-ledger.json`. Failures wait at least 24 hours without immediate POST replay; eligibility and amounts are determined upstream. Keep this file when upgrading. +Trial credits are manual-only for eligible `intl-work` accounts: use the credential row's claim drawer or `POST /admin/credentials/{id}/trial`. Startup, periodic maintenance and balance sync never claim. Results expose safe error categories, HTTP/business codes and retry time; response bodies are capped at 64 KiB and never returned to the browser. Success/already-claimed records persist in `auth/trial-ledger.json`; failures wait at least 24 hours before another manual attempt. Keep this file when upgrading. + +`CODEBUDDY2API_AUTO_TRIAL` and `--auto-trial` are retired: old startup options warn and do nothing; saved Boolean `auto_trial` settings are ignored on load. Remove them from deployment configuration. Before reverting to older code, check these old settings to avoid re-enabling automatic claims. ### Tool metadata retention @@ -76,7 +77,7 @@ Use a source/image build and Compose configuration containing this feature; recr | `POST /admin/oauth/start` · `GET /admin/oauth/poll` | Start/poll login; `site=cn` (default), `intl` (international WorkBuddy) or `intl-codebuddy` (international CodeBuddy) | | `GET /admin/credits` · `POST /admin/checkin` | Inspect credits; daily-idempotent check-in followed by domestic travel when enabled | | `POST /admin/sync` | Synchronize all enabled accounts' balances, catalogs and usage; no check-in or trial claims | -| `POST /admin/credentials/{id}/{action}` | Single-account `refresh`, `checkin`, `sync`, `travel-status` (query only), or `travel` (claim then dispatch) | +| `POST /admin/credentials/{id}/{action}` | Single-account `refresh`, `checkin`, `sync`, `travel-status` (query only), `travel` (claim then dispatch), or `trial` (one-time trial credits) | Pages use `/dashboard/*`, management APIs use `/admin/*`, and clients retain `/v1/*`. `/cn` and `/intl` API prefixes are not registered. Automatic model routing requires no client URL changes. diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 9207c1d..94e7942 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -26,7 +26,6 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 | `--usd-rate` | `7.15` | 每美元对应人民币金额,用于 billing 折算 | | `--model-catalog-ttl` | `21600` | 模型目录缓存有效期,秒 | | `--no-model-guard` | 关 | 关闭目录外模型的本地拦截;表外透传仅限单产品,不绕过禁用、绑定或目录就绪检查 | -| `--auto-trial [true/false]` | `false` | 尝试领取国际 WorkBuddy 一次性体验积分 | | `--max-images` | `16` | 单请求图片总数;`0` 不允许图片 | | `--image-policy` | `truncate` | 保留最新图片;设为 `error` 时超限返回 413 | | `--tool-call-max-retry` | `3` | 工具参数损坏时的额外生成上限(每次都消耗额度);`0` 不重试 | @@ -38,9 +37,11 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 | `--max-request-bytes` | `33554432` | 处理后的上游 JSON 字节上限,须为正整数 | | `--log-body-limit` | `65536` | 兼容文本日志正文预览字节;`0` 只记摘要,不控制 SQLite 诊断预算 | -环境变量包括 `CODEBUDDY_AUTH_DIR`、`CODEBUDDY_IMPORT_DIR`、`CODEBUDDY2API_KEY`、`CODEBUDDY2API_ADMIN_CSRF`、`CODEBUDDY2API_KEEP_TOOL_METADATA`、`CODEBUDDY2API_LOG`,以及 `CODEBUDDY2API_MAX_IMAGES`、`CODEBUDDY2API_IMAGE_POLICY`、`CODEBUDDY2API_MAX_REQUEST_BYTES`、`CODEBUDDY2API_LOG_BODY_LIMIT`、`CODEBUDDY2API_AUTO_TRIAL`、`CODEBUDDY2API_FAILOVER_MAX`、`CODEBUDDY2API_RETRY_WRITE_TIMEOUT`。启动示例见 [部署指南](deployment.zh-CN.md)。 +环境变量包括 `CODEBUDDY_AUTH_DIR`、`CODEBUDDY_IMPORT_DIR`、`CODEBUDDY2API_KEY`、`CODEBUDDY2API_ADMIN_CSRF`、`CODEBUDDY2API_KEEP_TOOL_METADATA`、`CODEBUDDY2API_LOG`,以及 `CODEBUDDY2API_MAX_IMAGES`、`CODEBUDDY2API_IMAGE_POLICY`、`CODEBUDDY2API_MAX_REQUEST_BYTES`、`CODEBUDDY2API_LOG_BODY_LIMIT`、`CODEBUDDY2API_FAILOVER_MAX`、`CODEBUDDY2API_RETRY_WRITE_TIMEOUT`。启动示例见 [部署指南](deployment.zh-CN.md)。 -体验积分领取默认关闭,仅适用于符合上游资格的 `intl-work` 账号。成功或已领取的结果按账号保存到 `auth/trial-ledger.json`,失败至少退避 24 小时,不立即重放 POST;资格与额度以上游为准,升级时保留该文件。 +体验积分仅供符合官方资格的 `intl-work` 账号手动领取:使用凭证行的领取抽屉或 `POST /admin/credentials/{id}/trial`。启动、定时维护、余额同步均不领取。结果显示安全错误类别、HTTP 状态/业务码及重试时间,响应正文限制为 64 KiB 且不返回浏览器。成功或已领取记录保存在 `auth/trial-ledger.json`,失败至少等待 24 小时才能再次手动申请;升级时保留该文件。 + +`CODEBUDDY2API_AUTO_TRIAL` 和 `--auto-trial` 已停用:旧启动选项仅提示、不触发任务;控制库中的旧布尔 `auto_trial` 设置在加载时忽略。请从部署配置中移除;回滚旧代码前也须核对这些旧设置,避免重新启用自动领取。 ### 工具描述保留 @@ -76,7 +77,7 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 | `POST /admin/oauth/start` · `GET /admin/oauth/poll` | 发起与轮询登录;`site=cn`(默认)、`intl`(国际 WorkBuddy)或 `intl-codebuddy`(国际 CodeBuddy) | | `GET /admin/credits` · `POST /admin/checkin` | 查询额度;按日幂等签到,国内按开关继续旅行 | | `POST /admin/sync` | 同步全部启用账号的余额、目录和用量,不签到、不领取试用 | -| `POST /admin/credentials/{id}/{action}` | 单账号 `refresh`、`checkin`、`sync`、`travel-status`(仅查询)或 `travel`(领取后派出) | +| `POST /admin/credentials/{id}/{action}` | 单账号 `refresh`、`checkin`、`sync`、`travel-status`(仅查询)、`travel`(领取后派出)或 `trial`(一次性体验积分) | 页面使用 `/dashboard/*`,管理 API 使用 `/admin/*`,客户端保留原 `/v1/*`;不注册 `/cn`、`/intl` API 前缀。模型自动选路不要求客户端改变地址。 diff --git a/docs/webui.md b/docs/webui.md index 60f85e1..fe2e557 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -15,6 +15,7 @@ Management is locked without a key. After changing it, sign in again and restart - **Credentials:** select Mainland China (CN), International WorkBuddy or International CodeBuddy for browser login, or import `.info`/ZIP files. Distinguish manual disabling, credential-level authentication circuits and model-level 429 cooldowns. Disabling keeps files; deletion removes them and requires removing model bindings first. Exports preserve safe UTF-8 filenames (otherwise `credential.info`/`credentials.zip`) and contain plaintext credentials; do not share them. - Successful OAuth enrollment closes the drawer and refreshes the list. The official tab keeps `noopener` isolation and must be closed manually. Failures stay visible; closing the drawer stops polling. - Each row offers Token refresh, check-in and balance sync; batch check-in and sync stay separate. Sync updates balances, catalogs and usage without check-in, travel or trial claims. Busy maintenance returns 409; a client timeout does not cancel server work. + - International WorkBuddy trial credits require manual confirmation; the drawer displays the result. “Refresh claim status” only reads the local ledger and never claims. On network or persistence failure, verify status before another attempt; environment-driven automatic claims are retired. - Automatic check-in and travel are persisted per account and apply live: on by default for domestic accounts, off internationally. International check-in can be enabled without a code update; inactive or unconfirmed activities never authorize claims. Disabled accounts run no automatic tasks. - Domestic check-in is followed by travel when its independent switch is on; already-checked-in accounts and accounts with automatic check-in off still check travel. “Travel status” only queries; “Claim / dispatch” claims arrivals, then rechecks idle state and the daily limit before randomly choosing location 1–4. Traveling accounts are not dispatched, and failed writes are not blindly retried. - Saving a preference does not claim immediately; it affects subsequent maintenance and cannot retract sent requests. The console shows last results, partial completion and uncertainty, retaining history on failure. diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 27b6bf3..74e2a03 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -15,6 +15,7 @@ - **凭证管理**:选择“中国大陆 · CN”“国际 · WorkBuddy”或“国际 · CodeBuddy”扫码添加账号,也可导入 `.info`、ZIP 文件。区分人工停用、凭证级认证熔断和模型级 429 冷却;停用保留文件,删除会移除文件,仍有模型绑定时需先解除。导出保留安全的 UTF-8 文件名,否则使用 `credential.info`/`credentials.zip`;文件含明文凭证,请勿分享。 - OAuth 入库成功后自动关闭添加抽屉并刷新列表;官方标签页保留 `noopener` 安全隔离,需手动关闭。失败保留错误,关闭抽屉会停止查询。 - 每行可刷新 Token、签到、同步余额;顶部批量签到和同步分开。同步仅更新余额、目录和用量,不签到、不旅行、不领取试用。维护繁忙时返回 409;客户端超时不代表后台任务已取消。 + - 国际 WorkBuddy 的“一次性体验积分”需手动确认领取,结果在抽屉内显示;“刷新领取状态”只读本地记录,不发领取请求。网络失败或记录保存异常时先核对状态,勿连续点击;环境变量自动领取已停用。 - 自动签到、自动旅行按账号保存并热生效,国内默认开启,国际默认关闭。国际签到可随时开启,不需更新代码;官方活动未开放或状态不明时不领取。账号停用期间不执行自动任务。 - 国内签到处理后按自动旅行开关继续旅行;已签到或关闭自动签到时,旅行仍按独立开关检查。手动“旅行状态”只查询,“旅行领派”先领取已到达奖励,再确认空闲且未达上限后随机派往 1–4 号地点;旅行中不派出,写请求失败不盲目重试。 - 保存开关不会立即领取,从后续维护起生效;关闭不撤回已发请求。界面显示上次结果,部分成功与状态未确认单独提示,失败保留历史。 diff --git a/tests/test_credential_actions.py b/tests/test_credential_actions.py index 3739789..eaec48a 100644 --- a/tests/test_credential_actions.py +++ b/tests/test_credential_actions.py @@ -60,7 +60,7 @@ def test_single_sync_never_signs_in_or_claims_trial_and_keeps_other_staleness(se other = self.entries["intl-work"]["id"] converter.CONFIG["usage_daily"] = {"stale_accounts": [Path(other).name]} with patch.object(converter.credits_mod, "daily_checkin") as checkin, \ - patch.object(converter, "_sync_trial") as trial, \ + patch.object(converter.trial_rewards, "claim_trial") as trial, \ patch.object(converter.credits_mod, "fetch_credits", return_value={"credits": 80, "intl": False}) as balance, \ patch.object(converter.credits_mod, "fetch_request_usage", return_value={"by_day": {}, "total_credits": 0, "requests": 0}) as usage: self.assertTrue(self.post("sync")["ok"]) @@ -135,7 +135,7 @@ def balance(token, **kwargs): with patch.object(converter.credits_mod, "fetch_credits", side_effect=balance), \ patch.object(converter.credits_mod, "fetch_request_usage", return_value={"by_day": {}, "total_credits": 0, "requests": 0}), \ patch.object(converter.credits_mod, "daily_checkin") as checkin, \ - patch.object(converter, "_sync_trial") as trial: + patch.object(converter.trial_rewards, "claim_trial") as trial: response = self.client.post("/admin/sync") self.assertEqual(response.status_code, 200) self.assertTrue(response.json()["ok"], response.text) diff --git a/tests/test_credential_automation.py b/tests/test_credential_automation.py index f7903ab..72da5bc 100644 --- a/tests/test_credential_automation.py +++ b/tests/test_credential_automation.py @@ -24,7 +24,7 @@ def preference(self, entry, field, enabled): 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(), claim_trial=False) + return converter._sync_credits(self.pool, self.ledger, entry, checkin=True, failed=set()) def test_defaults_are_domestic_on_international_off(self): rows = self.client.get('/admin/credentials').json()['credentials'] @@ -162,7 +162,7 @@ def status(*args, **kwargs): return {'state': 'available'} self.status_query.side_effect = status with patch.object(credits, 'daily_checkin') as claim, patch.object(credits, 'fetch_credits') as balance, \ - patch.object(converter, '_sync_trial') as trial: + patch.object(converter.trial_rewards, 'claim_trial') as trial: failed = set() self.assertIsNone(converter._sync_credits(self.pool, self.ledger, entry, checkin=True, failed=failed)) self.assertIn(entry['id'], failed) diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 4f5bd88..976899d 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -78,7 +78,8 @@ def test_env_template_matches_runtime_defaults_and_contains_no_shared_key(self): self.assertEqual(values["CODEBUDDY2API_KEY"], "") self.assertEqual(values["CODEBUDDY2API_BIND"], "127.0.0.1") self.assertEqual(values["CODEBUDDY2API_IMAGE"], "codebuddy2api:local") - self.assertEqual(values["CODEBUDDY2API_AUTO_TRIAL"], "false") + self.assertNotIn("CODEBUDDY2API_AUTO_TRIAL", values) + self.assertNotIn("CODEBUDDY2API_AUTO_TRIAL", (ROOT / "docker-compose.yml").read_text()) self.assertEqual(values["CODEBUDDY2API_ADMIN_CSRF"], str(RUNTIME_DEFAULTS["admin_csrf"]).lower()) self.assertNotIn("CODEBUDDY2API_KEEP_TOOL_METADATA", values) diff --git a/tests/test_trial_integration.py b/tests/test_trial_integration.py index 12b8ad6..3d9e97d 100644 --- a/tests/test_trial_integration.py +++ b/tests/test_trial_integration.py @@ -1,113 +1,303 @@ -"""自动 Trial 开关、身份和维护流程回归;所有网络均为 Mock。""" +"""Manual-only trial operations, safe feedback and retired-setting compatibility.""" import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根:允许直接运行本文件 - -from copy import deepcopy +from concurrent.futures import ThreadPoolExecutor import json -import tempfile -import time +import os +import subprocess +import threading import unittest from unittest.mock import patch +import httpx import converter -from app import credits -from app import trial_rewards +from app import trial_rewards, trial_management, settings +from app.control_store import ControlStore +from tests import test_credential_actions as fixtures +from tests.test_trial_rewards import headers, mock_http class TrialIntegrationTests(unittest.TestCase): + add_account = fixtures.CredentialActionTests.add_account + configure = fixtures.CredentialActionTests.configure + handle_upstream = fixtures.CredentialActionTests.handle_upstream + post = fixtures.CredentialActionTests.post + def setUp(self): - self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) - self.enterContext(patch.dict(converter.CONFIG, { - "cred": None, "cred_pool": None, "ledger": None, "log_path": None, - "auto_trial": False, "trial_ledger": trial_rewards.TrialLedger(self.root / "trials.json"), - })) - self.enterContext(patch.object(converter, "_log")) - self.order = [] - self.claim = self.enterContext(patch.object(trial_rewards, "claim_trial", side_effect=self.claimed)) - self.balance = self.enterContext(patch.object(credits, "fetch_credits", side_effect=self.credited)) - self.checkin = self.enterContext(patch.object(credits, "daily_checkin")) - - def claimed(self, headers): - self.order.append("trial") - self.assertEqual(headers["X-Domain"], "www.workbuddy.ai") - return {"ok": True, "already": False, "code": 0, "status": 200} - - def credited(self, token, **kwargs): - self.order.append("credits") - return {"credits": 123, "count": 1, "segments": [], "intl": self.domain.endswith(".ai")} - - def configure(self, domain="www.workbuddy.ai"): - self.domain = domain - self.path = self.root / "account.info" - self.data = {"account": {"uid": "synthetic-user", "enterpriseId": "synthetic-tenant"}, - "auth": {"domain": domain, "accessToken": "synthetic-token", - "refreshToken": "synthetic-refresh", "expiresAt": int((time.time() + 864000) * 1000)}} - self.path.write_text(json.dumps(self.data)) - self.pool = converter.CredentialPool([self.path]) - self.ledger = credits.CreditLedger(self.root / "credits.json") - self.pool.set_ledger(self.ledger) - converter.CONFIG.update(cred_pool=self.pool, ledger=self.ledger) - - def sync(self): - failed = set() - result = converter._sync_credits(self.pool, self.ledger, self.pool.entries()[0], checkin=False, failed=failed) - self.assertFalse(failed) - self.assertIsNotNone(result) - self.assertEqual(self.ledger.entry(str(self.path))["credits"]["credits"], 123) - - def test_default_off_still_refreshes_balance(self): - self.configure() - self.sync() + fixtures.CredentialActionTests.setUp(self) + self.entry = self.entries['intl-work'] + self.url = '/admin/credentials/' + self.entry['account_key'] + self.trials = trial_rewards.TrialLedger(self.root / 'trial-ledger.json') + converter.CONFIG['trial_ledger'] = self.trials + self.claim = self.enterContext(patch.object(trial_rewards, 'claim_trial', + return_value={'ok': True, 'already': False, 'code': 0, 'status': 200})) + + def status(self): + response = self.client.get('/admin/credentials') + self.assertEqual(response.status_code, 200) + return next(r for r in response.json()['credentials'] if r['id'] == self.entry['account_key'])['trial'] + + def test_inventory_is_read_only_and_support_is_product_specific(self): + response = self.client.get('/admin/credentials') + self.assertEqual(response.status_code, 200) + for row in response.json()['credentials']: + self.assertEqual(row['trial_supported'], row['profile'] == 'intl-work') + self.assertTrue(self.status()['can_claim']) + self.assertFalse(self.trials.path.exists()) + self.claim.assert_not_called() + + def test_manual_claim_is_scoped_idempotent_and_does_not_sync(self): + with patch.object(converter, '_sync_credits') as sync: + first = self.post('trial') + self.assertTrue(first['ok']) + self.assertEqual(first['state'], 'claimed') + self.assertFalse(first['can_claim']) + self.assertTrue(self.post('trial')['skipped']) + sync.assert_not_called() + self.claim.assert_called_once() + self.travel_mock.assert_not_called() + self.status_query.assert_not_called() + self.assertEqual(set(self.trials.snapshot()), {self.entry['account_key']}) + self.assertTrue(self.status()['ok']) + + def test_wrong_products_never_refresh_or_send(self): + for profile in ('cn-cli', 'cn-work', 'intl-cli'): + self.entry = self.entries[profile] + self.url = '/admin/credentials/' + self.entry['account_key'] + with patch.object(self.entry['cm'], 'get_headers') as token: + self.assertEqual(self.post('trial')['state'], 'not_applicable') + token.assert_not_called() + self.claim.assert_not_called() + + def test_disabled_and_missing_accounts_cannot_claim(self): + self.control.set_credential(self.entry['account_key'], False) + self.assertTrue(self.post('trial')['skipped']) + self.assertEqual(self.client.post('/admin/credentials/missing/trial').status_code, 404) self.claim.assert_not_called() - self.assertEqual(self.order, ["credits"]) - - def test_only_international_workbuddy_may_claim(self): - converter.CONFIG["auto_trial"] = True - for domain in ("www.codebuddy.cn", "www.workbuddy.cn", "www.codebuddy.ai"): - with self.subTest(domain=domain): - self.configure(domain) - self.sync() + + def test_old_auto_flag_does_not_trigger_claims_during_sync(self): + converter.CONFIG['auto_trial'] = True + with patch.object(converter.credits_mod, 'fetch_credits', return_value={'credits': 12, 'intl': True}): + failed = set() + converter._sync_credits(self.pool, self.ledger, self.entry, checkin=False, failed=failed) + self.assertFalse(failed) self.claim.assert_not_called() + self.assertFalse(self.trials.path.exists()) + + def test_duplicate_files_still_produce_one_manual_result(self): + with patch.object(self.pool, 'entries', return_value=[self.entry, self.entry]): + response = self.client.post(self.url + '/trial') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()['results']), 1) + self.claim.assert_called_once() + + def test_legacy_startup_options_warn_without_background_claims(self): + source = ''' +import converter, json, sys +from unittest.mock import patch +sys.argv = ['converter.py', '--skip-check', '--auto-trial', 'true'] +with patch.object(converter.threading.Thread, 'start'), patch.object(converter, 'seed_credentials'), \\ + patch.object(converter.uvicorn, 'run'), patch.object(converter.trial_rewards, 'claim_trial') as claim: + converter.main() + assert not converter.CONFIG.get('auto_trial') + assert converter.CONFIG.get('trial_ledger') is not None + claim.assert_not_called() +print(json.dumps({'manual_only': True})) +''' + env = {'PATH': os.defpath, 'PYTHONPATH': str(Path(converter.__file__).parent), + 'HOME': str(self.root), 'CODEBUDDY_AUTH_DIR': str(self.root / 'startup-auth'), + 'CODEBUDDY2API_KEY': 'synthetic', 'CODEBUDDY2API_AUTO_TRIAL': 'true'} + process = subprocess.run([sys.executable, '-B', '-c', source], cwd=self.root, + env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(process.returncode, 0, process.stderr) + self.assertIn('AUTO_TRIAL / --auto-trial 已停用', process.stderr) + self.assertTrue(json.loads(process.stdout)['manual_only']) + self.assertFalse((self.root / 'startup-auth/trial-ledger.json').exists()) - def test_enabled_claims_once_before_authoritative_balance_query(self): - self.configure() - converter.CONFIG["auto_trial"] = True - self.sync() - self.sync() - self.assertEqual(self.order, ["trial", "credits", "credits"]) - self.checkin.assert_not_called() - - def test_relogin_and_new_ledger_object_do_not_claim_twice(self): - self.configure() - converter.CONFIG["auto_trial"] = True - self.sync() - replacement = deepcopy(self.data) - replacement["auth"]["accessToken"] = "replacement-synthetic-token" - converter._store_credential(self.root, self.path.name, json.dumps(replacement).encode(), "synthetic-user") - converter.CONFIG["trial_ledger"] = trial_rewards.TrialLedger(self.root / "trials.json") - self.sync() + + def test_timeout_is_safe_visible_and_does_not_repeat_after_refresh(self): + self.claim.return_value = {'ok': False, 'already': False, 'code': None, 'status': None, 'error': 'timeout'} + first = self.post('trial') + self.assertEqual(first['state'], 'timeout') + self.assertIn('超时', first['message']) + self.assertFalse(first['can_claim']) + self.assertGreater(first['retry_at'], first['attempted_at']) + converter.CONFIG['trial_ledger'] = trial_rewards.TrialLedger(self.trials.path) + self.assertFalse(self.status()['can_claim']) + self.assertTrue(self.post('trial')['skipped']) self.claim.assert_called_once() + self.assertNotIn('error', next(iter(self.trials.snapshot().values()))) + + def test_backoff_expiry_enables_only_a_manual_attempt_and_success_is_permanent(self): + key = self.entry['account_key'] + self.trials.begin(key, now=100) + self.trials.finish(key, {'ok': False}, now=101) + record = self.trials.snapshot()[key] + self.assertFalse(trial_management.view(record, now=100 + 86400 - 1)['can_claim']) + self.assertTrue(trial_management.view(record, now=100 + 86400)['can_claim']) + self.claim.assert_not_called() + self.trials.finish(key, {'ok': True, 'already': False, 'status': 200, 'code': 0}, now=102) + self.assertFalse(trial_management.view(self.trials.snapshot()[key], now=1e10)['can_claim']) - def test_persistence_failure_does_not_block_balance_or_send_claim(self): - self.configure() - converter.CONFIG["auto_trial"] = True - with patch.object(converter.CONFIG["trial_ledger"], "begin", side_effect=OSError("synthetic storage failure")): - self.sync() + def test_oversized_legacy_timestamp_is_a_visible_storage_error(self): + key = self.entry['account_key'] + self.trials.begin(key) + document = json.loads(self.trials.path.read_text()) + document['accounts'][key]['attempted_at'] = 10**500 + self.trials.path.write_text(json.dumps(document)) + self.assertEqual(self.status()['state'], 'storage_error') + self.assertEqual(self.post('trial')['state'], 'storage_error') self.claim.assert_not_called() - self.assertEqual(self.order, ["credits"]) - - def test_failed_claim_is_throttled_without_changing_balance(self): - self.configure() - converter.CONFIG["auto_trial"] = True - self.claim.side_effect = None - self.claim.return_value = {"ok": False, "already": False, "code": None, "status": None} - self.sync() - self.sync() + + + def test_official_denial_and_already_are_distinct(self): + self.claim.return_value = {'ok': False, 'already': False, 'code': 9876, 'status': 403} + result = self.post('trial') + self.assertEqual(result['state'], 'auth_error') + self.assertEqual(result['status'], 403) + self.assertEqual(result['code'], 9876) + event = self.audit.list_records(kind='admin')['items'][0]['details'] + self.assertEqual(event['stage'], 'auth_error') + self.assertEqual(event['status_code'], 403) + self.assertEqual(event['code'], '9876') + self.assertEqual(event['outcome'], 'error') + self.assertGreaterEqual(event['duration_ms'], 0) + self.assertFalse(result['ok']) + converter.CONFIG['trial_ledger'] = trial_rewards.TrialLedger(self.root / 'already.json') + self.claim.return_value = {'ok': False, 'already': True, 'code': 14051, 'status': 409} + result = self.post('trial') + self.assertTrue(result['ok']) + self.assertEqual(result['state'], 'already') + + def test_persist_before_send_and_partial_success_on_finish_failure(self): + with patch.object(self.trials, 'begin', side_effect=OSError('synthetic-secret')): + result = self.post('trial') + self.assertEqual(result['state'], 'storage_error') + self.assertNotIn('synthetic-secret', json.dumps(result)) + self.claim.assert_not_called() + with patch.object(self.trials, 'finish', side_effect=OSError('synthetic-secret')): + result = self.post('trial') + self.assertFalse(result['ok']) + self.assertTrue(result['claimed']) + self.assertEqual(result['state'], 'storage_error') + self.assertNotIn('synthetic-secret', json.dumps(result)) + self.assertEqual(self.status()['state'], 'unconfirmed') + self.assertFalse(self.status()['can_claim']) + self.assertTrue(self.post('trial')['skipped']) self.claim.assert_called_once() - self.assertEqual(self.order, ["credits", "credits"]) + + def test_corrupt_ledger_preserved_and_does_not_break_inventory(self): + self.trials.path.write_text('invalid synthetic contents') + self.assertEqual(self.status()['state'], 'storage_error') + self.assertEqual(self.post('trial')['state'], 'storage_error') + self.assertEqual(self.trials.path.read_text(), 'invalid synthetic contents') + self.claim.assert_not_called() + + def test_token_failure_is_not_misreported_as_storage_or_claim_failure(self): + with patch.object(self.entry['cm'], 'get_headers', side_effect=httpx.ReadTimeout('synthetic-secret')): + result = self.post('trial') + self.assertEqual(result['state'], 'credential_error') + self.assertIn('未发送', result['message']) + self.assertNotIn('synthetic-secret', json.dumps(result)) + self.assertFalse(self.trials.path.exists()) + self.claim.assert_not_called() + + + def test_relogin_between_reservation_and_send_stops_claim(self): + begin = self.trials.begin + def reserve(key): + value = begin(key) + self.entry['cm'].invalidate() + return value + with patch.object(self.trials, 'begin', side_effect=reserve): + self.assertEqual(self.post('trial')['state'], 'changed') + self.claim.assert_not_called() + + def test_success_stays_bound_to_original_account_if_token_changes(self): + def finish(headers): + self.entry['cm'].invalidate() + return {'ok': True, 'already': False, 'code': 0, 'status': 200, 'raw': 'synthetic-secret'} + self.claim.side_effect = finish + result = self.post('trial') + self.assertTrue(result['ok']) + self.assertIn('凭证已变化', result['message']) + self.assertNotIn('synthetic-secret', json.dumps(result)) + self.assertEqual(set(self.trials.snapshot()), {self.entry['account_key']}) + + def test_concurrent_manual_clicks_do_not_queue_more_posts(self): + entered, release = threading.Event(), threading.Event() + def wait(headers): + entered.set() + if not release.wait(5): + raise AssertionError('test release missing') + return {'ok': True, 'already': False, 'code': 0, 'status': 200} + self.claim.side_effect = wait + with ThreadPoolExecutor(max_workers=1) as executor: + task = executor.submit(self.client.post, self.url + '/trial') + try: + self.assertTrue(entered.wait(2)) + self.assertEqual(self.client.post(self.url + '/trial').status_code, 409) + finally: + release.set() + self.assertEqual(task.result(timeout=3).status_code, 200) + self.claim.assert_called_once() + + def test_manual_post_requires_auth_and_csrf(self): + self.client.headers.pop('Authorization') + self.assertEqual(self.client.post(self.url + '/trial').status_code, 401) + login = self.client.post('/admin/session', headers={'Origin': 'https://testserver'}, + json={'api_key': 'synthetic-management-key'}) + self.assertEqual(login.status_code, 200) + self.assertEqual(self.client.post(self.url + '/trial').status_code, 403) + self.claim.assert_not_called() + response = self.client.post(self.url + '/trial', headers={'Origin': 'https://testserver', + 'X-CSRF-Token': login.json()['csrf_token']}) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()['results'][0]['ok']) + + def test_legacy_saved_boolean_loads_without_exposing_a_toggle(self): + payload = {'settings': {'auto_trial': True}, 'models': {}, 'credentials': {}} + self.control._db.execute('UPDATE control SET payload=? WHERE id=1', (json.dumps(payload),)) + loaded = ControlStore(self.root / 'control.sqlite3') + self.addCleanup(loaded.close) + self.assertNotIn('auto_trial', loaded.snapshot()['settings']) + self.assertNotIn('auto_trial', settings.SCHEMA) + with self.assertRaises(ValueError): + loaded.update_settings({'auto_trial': True}, loaded.snapshot()['revision']) + with self.assertRaises(ValueError): + settings.validate_settings({'auto_trial': 'true'}, legacy=True) + + +class ManualTrialTransportTests(unittest.TestCase): + def test_response_limit_stops_reading_and_closes_connection(self): + closed = [] + class Body(httpx.SyncByteStream): + def __iter__(self): + yield b'x' * (trial_rewards.MAX_RESPONSE_BYTES + 1) + raise AssertionError('must stop at limit') + def close(self): + closed.append(True) + with mock_http(lambda r: httpx.Response(200, stream=Body())): + result = trial_rewards.claim_trial(headers()) + self.assertEqual(result['error'], 'response_too_large') + self.assertFalse(result['ok']) + self.assertTrue(closed) + + def test_response_budget_does_not_accept_endless_trickle(self): + class Body(httpx.SyncByteStream): + def __iter__(self): + yield b' ' + raise AssertionError('must stop after deadline') + with mock_http(lambda r: httpx.Response(200, stream=Body())), \ + patch.object(trial_rewards.time, 'monotonic', side_effect=[0, 31]): + self.assertEqual(trial_rewards.claim_trial(headers())['error'], 'timeout') + + def test_compressed_or_malformed_response_is_not_read_unbounded(self): + with mock_http(lambda r: httpx.Response(200, headers={'content-encoding': 'gzip'}, stream=httpx.ByteStream(b'bad'))): + self.assertEqual(trial_rewards.claim_trial(headers())['error'], 'invalid_response') -if __name__ == "__main__": +if __name__ == '__main__': unittest.main() diff --git a/tests/test_trial_rewards.py b/tests/test_trial_rewards.py index 1fbdddf..0eafe31 100644 --- a/tests/test_trial_rewards.py +++ b/tests/test_trial_rewards.py @@ -65,7 +65,8 @@ def respond(request): with mock_http(respond): result = trial.claim_trial(headers()) self.assertEqual(len(calls), 1) - self.assertEqual(set(result), {"ok", "already", "code", "status"}) + self.assertTrue({"ok", "already", "code", "status"} <= set(result)) + self.assertFalse(set(result) - {"ok", "already", "code", "status", "error"}) return result def test_four_profiles_and_invalid_routing_rejected_before_client(self): @@ -177,7 +178,8 @@ def fail(request): with self.subTest(exception=exception), mock_http(fail): result = trial.claim_trial(headers()) - self.assertEqual(result, {"ok": False, "already": False, "code": None, "status": None}) + self.assertEqual(result, {"ok": False, "already": False, "code": None, "status": None, + "error": "timeout" if exception is httpx.ReadTimeout else "network_error"}) self.assertEqual(len(calls), 1) diff --git a/tests/webui_fixture.py b/tests/webui_fixture.py index f6c23b4..236d292 100644 --- a/tests/webui_fixture.py +++ b/tests/webui_fixture.py @@ -37,11 +37,18 @@ def forbid_connect(*args, **kwargs): raise AssertionError("Fixture must never open an upstream connection") +def fake_trial(headers): + if headers.get("X-User-Id") == "trial-timeout": + return {"ok": False, "already": False, "code": None, "status": None, "error": "timeout"} + return {"ok": True, "already": False, "code": 0, "status": 200} + + def main(): with tempfile.TemporaryDirectory(prefix="codebuddy-webui-browser-") as directory: root = Path(directory) os.environ["CODEBUDDY_AUTH_DIR"] = directory - gateway.CONFIG.update(api_key="synthetic-e2e-key", log_path=None, auto_trial=False, + gateway.CONFIG.update(api_key="synthetic-e2e-key", log_path=None, + trial_ledger=gateway.trial_rewards.TrialLedger(root / "trial-ledger.json"), control_store=ControlStore(root / "control.sqlite3"), audit_store=AuditStore(root / "logs.sqlite3")) apply_persisted_settings(gateway.CONFIG, explicit=("api_key",), environ={}) @@ -61,6 +68,7 @@ def main(): pool._sync_event.clear() gateway.CONFIG["management"] = Management(gateway) gateway.open_backend_stream = fake_backend + gateway.trial_rewards.claim_trial = fake_trial socket.socket.connect = forbid_connect install(gateway) try: diff --git a/web/e2e/backend.integration.ts b/web/e2e/backend.integration.ts index d1c1c29..e5cf656 100644 --- a/web/e2e/backend.integration.ts +++ b/web/e2e/backend.integration.ts @@ -75,3 +75,72 @@ test("real management API: session, model alias, audit, clear, and file credenti expect((await page.request.get("/admin/missing")).status()).toBe(404); expect(errors).toEqual([]); }); + +test("manual trial uses the real admin API and persists success and failed-attempt backoff", async ({ + page, +}) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/dashboard/login"); + await page.getByLabel("API key", { exact: true }).fill("synthetic-e2e-key"); + await page.getByRole("button", { name: "进入工作台" }).click(); + await expect(page.getByRole("heading", { name: "运行概览" })).toBeVisible(); + const session = await (await page.request.get("/admin/session")).json(); + const settings = await (await page.request.get("/admin/settings")).json(); + expect(settings.items.some((item: { key: string }) => item.key === "auto_trial")).toBe(false); + for (const uid of ["trial-timeout", "trial-success"]) { + const response = await page.request.post("/admin/credentials/upload", { + headers: { Origin: "http://127.0.0.1:5175", "X-CSRF-Token": session.csrf_token }, + data: { + files: [ + { + name: `${uid}.info`, + content: JSON.stringify({ + account: { uid, nickname: uid }, + auth: { + domain: "www.workbuddy.ai", + accessToken: "synthetic-only-access", + refreshToken: "synthetic-only-refresh", + expiresAt: Date.now() + 86400000, + }, + }), + }, + ], + replace: false, + }, + }); + expect(response.status()).toBe(200); + expect((await response.json()).results[0].ok).toBe(true); + } + await page.goto("/dashboard/credentials"); + await page.getByRole("button", { name: "领取体验积分 trial-timeout.info" }).click(); + const drawer = page.getByRole("dialog"); + await expect(drawer.getByRole("button", { name: "确认领取" })).toBeEnabled(); + await drawer.getByRole("button", { name: "确认领取" }).click(); + await expect(drawer.getByRole("alert")).toContainText("领取请求超时"); + await expect(drawer.getByRole("button", { name: "确认领取" })).toBeDisabled(); + await page.screenshot({ path: "test-results/manual-trial-timeout.png", fullPage: true }); + await drawer.getByRole("button", { name: "刷新领取状态" }).click(); + await expect(drawer.getByRole("button", { name: "确认领取" })).toBeDisabled(); + await drawer.getByRole("button", { name: "关闭抽屉" }).click(); + await expect(drawer).toHaveCount(0); + await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "领取体验积分 trial-success.info" }).click(); + await drawer.getByRole("button", { name: "确认领取" }).click(); + await expect(drawer.getByText("已领取", { exact: true })).toBeVisible(); + await expect(drawer.getByRole("button", { name: "确认领取" })).toBeDisabled(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: "test-results/manual-trial-success-mobile.png", fullPage: true }); + await page.reload(); + await page.getByRole("button", { name: "领取体验积分 trial-success.info" }).click(); + await expect(page.getByRole("dialog").getByRole("button", { name: "确认领取" })).toBeDisabled(); + const rows = (await (await page.request.get("/admin/credentials")).json()).credentials; + expect(rows.find((row: { name: string }) => row.name === "trial-success.info").trial.state).toBe( + "claimed", + ); + expect( + rows.find((row: { name: string }) => row.name === "trial-timeout.info").trial.can_claim, + ).toBe(false); + expect(errors).toEqual([]); +}); diff --git a/web/src/Trial.tsx b/web/src/Trial.tsx new file mode 100644 index 0000000..3799f52 --- /dev/null +++ b/web/src/Trial.tsx @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState } from "react"; +import { api, credentialResponse, errorMessage, list, object, type Credential } from "./api"; +import { Badge, Drawer, ErrorNotice } from "./components"; +import s from "./ui.module.scss"; + +type TrialResult = { + ok: boolean; + can_claim: boolean; + state: string; + message: string; + status?: number | null; + code?: number | null; + retry_at?: number | null; + claimed?: boolean; + upstream_already?: boolean; +}; +function result(value: unknown): TrialResult { + const row = object(value, "体验积分结果"); + if ( + typeof row.ok !== "boolean" || + typeof row.can_claim !== "boolean" || + typeof row.state !== "string" || + typeof row.message !== "string" + ) + throw new Error("领取结果不完整,请刷新状态核验,不要直接重复领取"); + for (const key of ["status", "code", "retry_at"]) + if ( + row[key] !== undefined && + row[key] !== null && + (typeof row[key] !== "number" || !Number.isFinite(row[key])) + ) + throw new Error("领取结果格式无效,请刷新状态核验"); + return row as TrialResult; +} +const unknown: TrialResult = { + ok: false, + can_claim: false, + state: "unknown", + message: "请先刷新领取状态", +}; +export function Trial({ + credential, + onClose, + onDone, +}: { + credential: Credential; + onClose: () => void; + onDone: () => void; +}) { + const [status, setStatus] = useState(unknown); + const [error, setError] = useState(null); + const [busy, setBusy] = useState<"claim" | "status" | null>(null); + const controller = useRef(null); + useEffect(() => { + try { + setStatus(result(credential.trial)); + } catch (error) { + setError(errorMessage(error)); + } + return () => controller.current?.abort(); + }, [credential]); + const execute = async (claim: boolean) => { + if (controller.current || (claim && !status.can_claim)) return; + const abort = new AbortController(); + controller.current = abort; + setBusy(claim ? "claim" : "status"); + setError(null); + if (claim) setStatus((old) => ({ ...old, can_claim: false })); + try { + if (claim) { + const response = await api.post( + `/credentials/${encodeURIComponent(credential.id)}/trial`, + undefined, + { signal: abort.signal, timeout: 45000 }, + ); + const rows = list(object(response.data).results, "领取操作结果"); + if (rows.length !== 1 || rows[0].id !== credential.id) + throw new Error("领取账号或结果未确认,请刷新状态核验"); + if (!abort.signal.aborted) setStatus(result(rows[0])); + } else { + const response = await api.get("/credentials", { signal: abort.signal }); + const row = credentialResponse(response.data).find((row) => row.id === credential.id); + if (!row || row.trial_supported !== true || row.enabled !== true) + throw new Error("账号已变化、停用或不适用,请关闭后刷新凭证列表"); + if (!abort.signal.aborted) setStatus(result(row.trial)); + } + } catch (error) { + if (!abort.signal.aborted) { + setStatus((old) => ({ ...old, can_claim: false })); + setError(`${errorMessage(error)};请求失败不代表后台已停止,请先刷新状态核验。`); + } + } finally { + controller.current = null; + if (!abort.signal.aborted) { + setBusy(null); + onDone(); + } + } + }; + const failed = !status.ok && !["available", "unknown"].includes(status.state); + return ( + +

+ {credential.name ?? credential.id} · 国际 WorkBuddy +

+

+ 仅向官方申请当前账号的一次性体验积分,不签到、不旅行、不切换账号。资格和额度由官方决定。 +

+
+ + {status.claimed || status.upstream_already + ? "官方已确认,记录未保存" + : status.ok + ? "已领取" + : "未确认领取"} + + {!failed &&

{status.message}

} + {busy &&

{busy === "claim" ? "正在申请,请勿重复点击…" : "正在读取本地领取记录…"}

} +
+ + {(status.status != null || status.code != null) && ( +

+ HTTP 状态:{status.status ?? "未收到"} · 官方业务码:{status.code ?? "未知"} +

+ )} + {!status.can_claim && status.retry_at != null && ( +

+ 最早可再次手动申请:{new Date(status.retry_at * 1000).toLocaleString("zh-CN")} +

+ )} +
+ + +
+

+ 刷新状态不会领取。失败至少等待 24 + 小时;关闭页面不会撤销已发送请求。领取成功后可另行同步余额。 +

+
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts index 3ab689b..e00545e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -215,10 +215,10 @@ export function credentialResponse(value: unknown): Credential[] { const id = c.account_key ?? c.id; if (typeof id !== "string" || !id) throw new Error("凭证缺少公开 account_key"); const name = c.name ?? c.filename; - for (const field of ["auto_checkin", "auto_travel", "travel_supported"]) + for (const field of ["auto_checkin", "auto_travel", "travel_supported", "trial_supported"]) if (c[field] !== undefined && typeof c[field] !== "boolean") throw new Error("自动任务状态必须为布尔值"); - for (const field of ["checkin", "travel"]) + for (const field of ["checkin", "travel", "trial"]) if (c[field] !== undefined && c[field] !== null) object(c[field], "自动任务结果"); return { ...c, id, name: typeof name === "string" && !/[\\/]/.test(name) ? name : null }; }); diff --git a/web/src/pages/Credentials.tsx b/web/src/pages/Credentials.tsx index 86ec785..50252ad 100644 --- a/web/src/pages/Credentials.tsx +++ b/web/src/pages/Credentials.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { OAuth } from "../OAuth"; +import { Trial } from "../Trial"; import { api, credentialResponse, @@ -102,6 +103,7 @@ export function Credentials() { const [drawer, setDrawer] = useState<"oauth" | "import" | "export" | null>(null); const [detail, setDetail] = useState(null); const [deleting, setDeleting] = useState(null); + const [trialTarget, setTrialTarget] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -272,7 +274,7 @@ export function Credentials() { 凭证 / 产品 人工状态 - 自动任务 / 上次结果 + 账号任务 / 上次结果 认证健康 模型 429 冷却 官方余额 / 到期 @@ -366,6 +368,9 @@ export function Credentials() { {trip?.stale === true && 状态可能已变化,请先查询核验} {c.enabled === false && 账号停用期间不执行自动任务} + {c.trial_supported === true && c.trial != null && ( + 体验积分:{text(object(c.trial).message)} + )} 同步余额 + {c.trial_supported === true && ( + + )} {c.travel_supported === true && ( <>