diff --git a/README.en.md b/README.en.md index 57f0a31..c3dc5d8 100644 --- a/README.en.md +++ b/README.en.md @@ -13,7 +13,7 @@ Ten channels — **CodeBuddy** (CN + international, two separate channels), **TR ## Features - **OpenAI-compatible surface**: `/v1/chat/completions` (streaming / non-streaming), `/v1/responses` (Codex CLI), `/v1/messages` (Claude Code), `/v1/models` (incl. single-model lookup), `/v1/user/balance`. -- **Unified scheduling**: bare model names route by health; `model@provider` pins upstream. Free channels first → least in-flight → expiring credits → health with tiered cooldowns → largest balance. Catalog x0 rates are calibrated against actual charges in the last 24h, so an upstream that changed billing without updating its catalog can't hold the free tier forever. Model-scoped limit sidelines only that model; multi-turn sticks to one credential, rotates only on error. +- **Unified scheduling**: bare model names route by health; `model@provider` pins upstream. Free channels first → least in-flight → expiring credits → health with tiered cooldowns → largest balance. Admins can set a per-credential **weight** (`-1000..1000`): it only reorders within the same health tier (positive first, negative as standby) and never promotes cooling or exhausted credentials. Catalog x0 rates are calibrated against actual charges in the last 24h, so an upstream that changed billing without updating its catalog can't hold the free tier forever. Model-scoped limit sidelines only that model; multi-turn sticks to one credential, rotates only on error. - **Expiry-aware burn**: credits expiring inside main window (default 36h) burn first; ties fall to 7-day window. Units unify to credits. - **Credential-gated model list**: only channels with usable credential appear; no phantom models. - **Shared credential pool**: admins maintain, everyone shares, encrypted at rest (`APP_SECRET`); device-code login, account switching, quota probing, daily check-in, token pre-refresh. @@ -123,7 +123,7 @@ echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password- - This process only; it never tails file. Under multiple processes each sees its own slice. - **Redaction is hard precondition**: lines matching `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`, an `sk-…` shape or `Bearer ` dropped whole. - Buffer `LOG_BUFFER_LINES` (default 500, hot), oldest overwritten first; `0` disables page (both endpoints 404). -- 4000 chars per line; each carries `seq`, timestamp, level, logger, `request_id`. Subscriber limit 4, beyond → 429 + `Retry-After`. +- 4000 chars per line; each carries `seq`, timestamp, level, logger, `request_id`. **Click the `#first8` chip** to filter by that request id and collapse one request's whole chain (every rotation / cooldown / downgrade in the executor shares it); pasting the `X-Request-ID` response header into the keyword box does the same. stdout / journald / launchd files carry `[request_id]` too (background tasks show `-`). Subscriber limit 4, beyond → 429 + `Retry-After`. ## Usage diff --git a/README.md b/README.md index cb160e5..d0f1bd8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ## 特性 - **OpenAI 兼容出口**:`/v1/chat/completions`(流式 / 非流式)、`/v1/responses`(Codex CLI)、`/v1/messages`(Claude Code)、`/v1/models`(含单模型查询)、`/v1/user/balance`。 -- **统一调度**:扁平名按健康度选号;`模型@渠道` 强制。优先级:免费渠道 → 在途最少 → 到期额度 → 健康度 + 分级冷却 → 余额多者。目录里的 x0 会被最近 24h 的实测扣费校准(上游改计费不改目录时不至于永远占住免费档)。模型级限流只避让该模型;多轮对话粘同一凭证,出错才轮换。 +- **统一调度**:扁平名按健康度选号;`模型@渠道` 强制。优先级:免费渠道 → 在途最少 → 到期额度 → 健康度 + 分级冷却 → 余额多者。管理员可给凭证设**权重**(`-1000..1000`):只在同一健康档内重排(正数优先、负数当备用),不会把冷却/耗尽的号顶上来。目录里的 x0 会被最近 24h 的实测扣费校准(上游改计费不改目录时不至于永远占住免费档)。模型级限流只避让该模型;多轮对话粘同一凭证,出错才轮换。 - **到期额度优先**:主窗口(默认 36h)先烧将过期额度;打平再比 7 天窗口。单位统一积分。 - **模型列表按凭证加载**:只列有可用凭证的渠道 → 无幽灵模型。 - **公共凭证池**:admin 维护、全员共享、加密入库(`APP_SECRET`);设备码登录、多账号切换、额度探测、签到、token 预刷新。 @@ -123,7 +123,7 @@ echo "$PASSWORD" | python3 scripts/create_user.py alice --role admin --password- - 只收本进程 `logging`;不读文件、不 tail。多进程各看各的。 - **脱敏是硬前提**:每行发出前过白名单。命中 `token` / `secret` / `authorization` / `cookie` / `password` / `api_key`、`sk-…` 形状或 `Bearer <值>` → 整行丢弃。 - 缓冲 `LOG_BUFFER_LINES`(默认 500,可热更),环形覆盖最旧。`0` 关整页(两端点 404)。 -- 单行上限 4000 字符;每行带 `seq`、时间、级别、logger、`request_id`。订阅者上限 4,超限 429 + `Retry-After`。 +- 单行上限 4000 字符;每行带 `seq`、时间、级别、logger、`request_id`。**点行尾的 `#前8位`** 即按该 request_id 过滤,收起一次请求的全部日志(executor 每次换号 / 冷却 / 降级都带同一个 ID);也可以直接把响应头 `X-Request-ID` 粘进关键字框。stdout / journald / launchd 文件同样带 `[request_id]`(后台任务为 `-`)。订阅者上限 4,超限 429 + `Retry-After`。 ## 使用 diff --git a/TECHNICAL.md b/TECHNICAL.md index bb9e4ee..aef53b0 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -549,6 +549,8 @@ class Scheduler: 4. **账户剩余积分**多者优先——只作健康度**打平键**。 5. 仍同分按 `credential_id` 稳定。 +**管理员权重**(`credentials.routing_weight`,`-1000..1000`,NULL/0 = 默认)不参与上表的键比较,而是在排完序后再做一次**同健康档内的稳定重排**:取当前第一名所在的 `_rank` 档,档内按 `-weight` 重排,再接回其余档。因此权重能压过免费优先 / 在途 / 到期额度,但永远不能把 exhausted 或 unknown 的号越过 known 档(权重是偏好,不是准入条件);全 0 时排序与补列前逐字节一致。 + **手动 pin 优先**:有 `pinned` → 只在 pinned 间排序。到期积分只算一处 `expiring_credits()`:选号走 `Candidate.expiry_credits()`;列表走 `GET /api/credentials` 的 `quota_expiring_credits` / `_secondary`;共用 `expiry_windows()`。无到期信息 → `null`;窗口关闭或确无临近过期 → `0`(均不显示)。 > **TRAE 也落 `quota_expiry_ladder`**(权益包各自独立到期),两列同时填,口径「未过期 + 有余额」。`quota_cycle_end` 仍 `NULL`(各包未必共享重置点)。 diff --git a/src/api/admin_credentials.py b/src/api/admin_credentials.py index 2feb4ed..4f49d1f 100644 --- a/src/api/admin_credentials.py +++ b/src/api/admin_credentials.py @@ -30,6 +30,7 @@ ACTION_CREDENTIAL_TAGS, ACTION_CREDENTIAL_TOGGLE, ACTION_CREDENTIAL_WEB_COOKIE, + ACTION_CREDENTIAL_WEIGHT, ) from ..auth.rbac import require_operator from ..compat.openai.request import InvalidRequest @@ -71,6 +72,11 @@ # 上限只为拦住误粘贴整段请求头/文件内容,不做格式校验(Cookie 结构随上游变)。 WEB_COOKIE_MAX_LENGTH = 10_000 +# 调度权重范围:正数越优先、负数越靠后,0 = 默认。上限只为拦住误输入, +# 不参与归一化(同档内按数值稳定排序)。 +WEIGHT_MIN = -1000 +WEIGHT_MAX = 1000 + # 批量的在途并发上限。后台任务用 Pacer 严格串行化上游请求,但那是跨任务 # 节流;批量端点由操作员手动触发,若完全串行则 50 个号要等几分钟才返回。 # 折中:限制同时在途的凭证数,既不打爆上游,也不至于让请求长时间挂住。 @@ -498,6 +504,33 @@ async def set_credential_limits(credential_id: str, payload: dict, record = services.limits.get(credential_id) return {"limits": record.as_dict() if record is not None else None} + @router.put("/api/credentials/{credential_id}/weight") + async def set_credential_weight(credential_id: str, payload: dict, + _csrf: None = Depends(csrf_protected), + principal=Depends(principal_from_request)): + """设置调度权重(`null` = 恢复默认)。 + + 只影响选号顺序,不改变可调度性:同一健康分档内权重高者优先,档外 + 次序不变(exhausted / unknown 仍垫底)。改完立即生效——选号每轮都从 + 库里读候选,没有需要失效的缓存。 + """ + require_operator(principal) + weight = payload.get("weight") + if weight is not None: + if isinstance(weight, bool) or not isinstance(weight, int): + raise InvalidRequest("weight must be an integer or null") + if not WEIGHT_MIN <= weight <= WEIGHT_MAX: + raise InvalidRequest( + f"weight must be within [{WEIGHT_MIN}, {WEIGHT_MAX}]") + if not credentials.set_routing_weight(credential_id, weight): + raise InvalidRequest("credential not found") + label = "(默认)" if weight is None else str(weight) + logger.info("管理员 %s 设置凭证 %s 调度权重: %s", + principal.username, credential_id, label) + services.audit.record(actor=principal.username, action=ACTION_CREDENTIAL_WEIGHT, + target=credential_id, detail=label) + return {"routing_weight": weight} + @router.put("/api/credentials/{credential_id}/tags") async def set_credential_tags(credential_id: str, payload: dict, _csrf: None = Depends(csrf_protected), diff --git a/src/audit/actions.py b/src/audit/actions.py index 6aac02f..481abb5 100644 --- a/src/audit/actions.py +++ b/src/audit/actions.py @@ -27,6 +27,7 @@ ACTION_CREDENTIAL_TAGS = "credential.tags" ACTION_CREDENTIAL_EXPORT = "credential.export" ACTION_CREDENTIAL_WEB_COOKIE = "credential.web_cookie" +ACTION_CREDENTIAL_WEIGHT = "credential.weight" ACTION_API_KEY_QUOTA = "api_key.quota" ACTION_API_KEY_CREATE = "api_key.create" ACTION_API_KEY_DELETE = "api_key.delete" @@ -49,6 +50,7 @@ ACTION_CREDENTIAL_PIN, ACTION_CREDENTIAL_REVIVE, ACTION_CREDENTIAL_SWITCH_ACCOUNT, ACTION_CREDENTIAL_LIMITS, ACTION_CREDENTIAL_TAGS, ACTION_CREDENTIAL_EXPORT, ACTION_CREDENTIAL_WEB_COOKIE, + ACTION_CREDENTIAL_WEIGHT, ACTION_USER_POLICY, ACTION_API_KEY_QUOTA, ACTION_API_KEY_CREATE, ACTION_API_KEY_DELETE, @@ -83,6 +85,7 @@ ACTION_CREDENTIAL_TAGS: "设置标签", ACTION_CREDENTIAL_EXPORT: "导出凭证", ACTION_CREDENTIAL_WEB_COOKIE: "设置网页 Cookie", + ACTION_CREDENTIAL_WEIGHT: "设置调度权重", ACTION_API_KEY_QUOTA: "设置 Key 配额", ACTION_API_KEY_CREATE: "创建 API Key", ACTION_API_KEY_DELETE: "删除 API Key", diff --git a/src/db/migrate.py b/src/db/migrate.py index 6deea9f..af4d2cf 100644 --- a/src/db/migrate.py +++ b/src/db/migrate.py @@ -84,7 +84,10 @@ # 33:credentials 新增 web_cookie_state(网页 Cookie 状态:NULL/ok/expired)。 # 只在管理台提示「网页会话已失效,重贴 Cookie」,不参与调度;老库补 NULL = # 未知(下次探测写入)。额度本身仍以 statistics 汇总口径为准。 -SCHEMA_VERSION = 33 +# 34:credentials 新增 routing_weight(调度权重)。NULL/0 = 默认,正数越大越先 +# 被选中,用于管理员的显式偏好(灰度、保留备用号)。老库补 NULL 后选号顺序与 +# 补列前完全一致(所有比较键相等时仍按原链 cid 排序)。 +SCHEMA_VERSION = 34 # (表, 列定义):历史库升级时逐条补列 _MIGRATION_COLUMNS: tuple[tuple[str, str], ...] = ( @@ -106,6 +109,8 @@ # 避免为管理台展示改动调度排序行为 ("credentials", "quota_packages TEXT"), ("credentials", "web_cookie_state TEXT"), # 网页明细源状态:NULL/ok/expired + # 调度权重:NULL/0 = 默认;正数越大越优先(管理员显式偏好,见 Scheduler.select) + ("credentials", "routing_weight INTEGER"), # access token 签发/到期时间:上游显式 expires_at 缺失时回落 # JWT 的 iat/exp;NULL=老库未回填(列表读到按需派生),0=确实未知 ("credentials", "token_expires_at INTEGER"), diff --git a/src/db/repo.py b/src/db/repo.py index 9a7a9fb..48e8b32 100644 --- a/src/db/repo.py +++ b/src/db/repo.py @@ -250,6 +250,17 @@ def set_tags(self, credential_id: str, tags: list[str]) -> bool: (tags_text(tags), credential_id)) return cursor.rowcount > 0 + def set_routing_weight(self, credential_id: str, weight: int | None) -> bool: + """设置调度权重(None = 恢复默认)。返回是否命中该凭证。 + + 权重只影响**选号顺序**,不是准入条件:低于任一「可调度」前提(健康、 + 冷却、启用)不产生任何效果,也不会把低健康度的号顶上去。 + """ + with self._db.transaction() as conn: + cursor = conn.execute("UPDATE credentials SET routing_weight = ? WHERE id = ?", + (weight, credential_id)) + return cursor.rowcount > 0 + def delete(self, credential_id: str) -> bool: with self._db.transaction() as conn: cursor = conn.execute("DELETE FROM credentials WHERE id = ?", (credential_id,)) @@ -695,7 +706,8 @@ def candidates(self, providers: Iterable[str] | None = None, """ rows = self._db.connect().execute( "SELECT id, provider, health, cooling_until, disabled, enabled, err_count, pinned, " - "quota_remaining, quota_cycle_end, quota_expiry_ladder FROM credentials").fetchall() + "quota_remaining, quota_cycle_end, quota_expiry_ladder, routing_weight " + "FROM credentials").fetchall() allowed = set(providers) if providers is not None else None # 模型级冷却整表读一次(表很小:只在模型限流/负缓存时才有行), # 按凭证聚合后挂到 Candidate 上;每选一次号查一次库会抵消选号的开销优势。 @@ -718,6 +730,7 @@ def candidates(self, providers: Iterable[str] | None = None, pinned=bool(row["pinned"]), cycle_end=row["quota_cycle_end"], quota_remaining=row["quota_remaining"], expiry_ladder=_ladder_value(row["quota_expiry_ladder"]), + routing_weight=row["routing_weight"], model_cooldowns=by_credential.get(row["id"]), ) for row in rows if allowed is None or row["provider"] in allowed @@ -873,7 +886,7 @@ def list_all( "quota_total, quota_cycle_end, " "quota_probed_at, growth_last_run_at, growth_last_result, created_at, added_by, " "quota_expiry_ladder, quota_packages, data_enc, " - "web_cookie_state, " + "web_cookie_state, routing_weight, " "token_expires_at, token_issued_at, " "tags " "FROM credentials ORDER BY created_at").fetchall() diff --git a/src/db/schema.sql b/src/db/schema.sql index 6cd4358..6cf915c 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS credentials ( quota_expiry_ladder TEXT, -- 到期阶梯 JSON [[epoch, 剩余积分]];无到期信息为 NULL quota_packages TEXT, -- 额度包明细 JSON [{"name","total","used","end","expiring"}],仅展示 web_cookie_state TEXT, -- 网页明细源状态:NULL=未配置/未知,ok=正常,expired=会话失效(仅提示) + routing_weight INTEGER, -- 调度权重:NULL/0=默认;正数越大越先被选中(仅管理员显式偏好) quota_probed_at INTEGER, token_expires_at INTEGER, -- access token 到期 epoch;NULL=老库未回填,0=未知 token_issued_at INTEGER, -- access token 签发 epoch(JWT iat):进度条满量程与「最后续期」 diff --git a/src/engine/scheduler.py b/src/engine/scheduler.py index 07ed915..601cfa2 100644 --- a/src/engine/scheduler.py +++ b/src/engine/scheduler.py @@ -199,6 +199,11 @@ class Candidate: # 键:并发请求摊到空闲号,避免全部压在同一凭证上(见 Scheduler.select)。 # 0 = 空闲(含未装配在途护栏的部署,此时恒 0,排序退回原链)。 inflight: int = 0 + # 管理员的显式调度权重(credentials.routing_weight):NULL/0 = 默认, + # 正数越优先、负数越靠后。排在健康分档之后、其余偏好之前(见 select), + # 因此能压过免费优先 / 在途分摊 / 到期额度,但永远不能把 exhausted(-1) + # 或 unknown 的号顶到 known 前面。 + routing_weight: int | None = None # (凭证, 模型) 冷却表:model → ModelCooldown。模型级限流只写这里, # 不写 cooling_until,因此同账号的其他模型仍然可选 model_cooldowns: Mapping[str, ModelCooldown] | None = None @@ -314,6 +319,11 @@ def select(self, candidates: Iterable[Candidate], tried: set[str], 模型级冷却的过滤由调用方在候选集上完成(executor._select): 每个候选要按**自己所属上游**的原始模型名查冷却表,选号器不掌握 provider→模型名 的映射,硬塞进来只会造成两处各查一半。 + + **管理员权重**(`Candidate.routing_weight`):只在**同一健康分档内**做 + 稳定重排(权重高者优先),再接回其余档。因此高权重能压过免费优先 / + 在途分摊 / 到期额度,但永远不能让 exhausted 或 unknown 的号越过 known + 档——权重是偏好,不是准入条件。全 0(默认)时排序与无该字段时一致。 """ pool = [ c for c in candidates @@ -334,6 +344,15 @@ def select(self, candidates: Iterable[Candidate], tried: set[str], _rank(c.health), -(_health_value(c.health)), -_remaining_value(c.quota_remaining), c.credential_id), ) + # 管理员权重只调整**同一健康分档内**的次序:先把该档按权重稳定重排, + # 再接回其余档。这样默认(全 0)时排序与补该列前逐字节一致,也永远 + # 不会让 exhausted / unknown 的号借权重越过 known 档(权重是偏好, + # 不是准入条件,见 Candidate.routing_weight)。 + if any(c.routing_weight for c in chosen): + lead = _rank(chosen[0].health) + same = [c for c in chosen if _rank(c.health) == lead] + same.sort(key=lambda c: -_weight_value(c.routing_weight)) + chosen = same + [c for c in chosen if _rank(c.health) != lead] return chosen[0].credential_id def should_rotate(self, attempts: int) -> bool: @@ -523,6 +542,11 @@ def _remaining_value(remaining: float | None) -> float: return remaining if remaining is not None else 0.0 +def _weight_value(weight: int | None) -> int: + """管理员权重:NULL/0 = 默认。只用于同健康档内的稳定重排。""" + return weight or 0 + + def _free_first_rank(candidate: Candidate, free_present: bool) -> int: """免费优先档:0 = 免费渠道,1 = 其余。 diff --git a/src/webapp/logging.py b/src/webapp/logging.py index 42164c3..44f432d 100644 --- a/src/webapp/logging.py +++ b/src/webapp/logging.py @@ -27,9 +27,14 @@ import logging import sys +from ..request_context import get_request_id + # 带时间戳:launchd/newsyslog 重定向的文件里只有进程自己写的内容, -# 没有 journald/docker 那种外部时间戳,格式必须自立 -LOG_FORMAT = "%(asctime)s %(levelname)-7s %(name)s: %(message)s" +# 没有 journald/docker 那种外部时间戳,格式必须自立。 +# `request_id` 让平台日志(journald / launchd 文件)也能按一次请求串起来: +# executor 每次换号、冷却、降级都带同一个上下文,与响应头 `X-Request-ID` +# 和日志页的过滤值是同一个 ID。后台任务没有请求上下文 → 显示 `-`。 +LOG_FORMAT = "%(asctime)s %(levelname)-7s [%(request_id)s] %(name)s: %(message)s" DATE_FORMAT = "%Y-%m-%d %H:%M:%S" # 第三方库降噪:root 升到 INFO 后 httpx/httpcore 会给每个上游请求打一条 @@ -46,6 +51,19 @@ class _RootHandler(logging.StreamHandler): """ +class _RequestIdFilter(logging.Filter): + """把请求上下文里的 `request_id` 注进每条记录(后台任务为 `-`)。 + + 不放进 formatter 的逻辑:`LogRecord` 是 logging 构造的,模块级 filter 是 + 唯一能在格式化前改记录的地方;放 filter 里也保证「代码随手 new 一个 + LogRecord 直接 format」不会因缺属性抛 KeyError(`defaults` 兜底)。 + """ + + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = get_request_id() or "-" + return True + + def configure_logging(level: str = "INFO") -> None: """配置 root logger:应用日志走 stderr(平台采集/轮转)。 @@ -60,8 +78,13 @@ def configure_logging(level: str = "INFO") -> None: handler = next((h for h in root.handlers if isinstance(h, _RootHandler)), None) if handler is None: handler = _RootHandler(sys.stderr) - handler.setFormatter(logging.Formatter(LOG_FORMAT, DATE_FORMAT)) + # defaults:手工 format 一个没有 request_id 的 LogRecord 也能渲染 + handler.setFormatter(logging.Formatter( + LOG_FORMAT, DATE_FORMAT, defaults={"request_id": "-"})) root.addHandler(handler) + # 幂等:重复 configure 不能再叠加 filter(否则每条日志设两次,无害但脏) + if not any(isinstance(item, _RequestIdFilter) for item in handler.filters): + handler.addFilter(_RequestIdFilter()) handler.setLevel(normalized) # 降噪而非禁用:默认(INFO 及以上)把这两个库压到 WARNING; diff --git a/tests/api/test_admin_credentials_weight.py b/tests/api/test_admin_credentials_weight.py new file mode 100644 index 0000000..acc7114 --- /dev/null +++ b/tests/api/test_admin_credentials_weight.py @@ -0,0 +1,73 @@ +"""凭证调度权重端点:设置/清除、校验、审计、列表回显。 + +权重只影响选号顺序(`Scheduler.select` 同健康档内重排),不改变可调度性, +因此端点不做任何缓存失效。 +""" + +from __future__ import annotations + +from src.audit.actions import ACTION_CREDENTIAL_WEIGHT + + +def _add_credential(app): + return app.state.credentials.add( + provider="trae", credential_data={"accessToken": "t-1"}) + + +def _weight_of(app, credential_id): + return next(row for row in app.state.credentials.list_all() + if row["id"] == credential_id)["routing_weight"] + + +def test_set_and_clear_weight(admin_client): + app, client = admin_client + credential_id = _add_credential(app) + assert _weight_of(app, credential_id) is None + + response = client.put(f"/api/credentials/{credential_id}/weight", + json={"weight": 42}) + assert response.status_code == 200 + assert response.json() == {"routing_weight": 42} + assert _weight_of(app, credential_id) == 42 + + events = app.state.audit.query(action=ACTION_CREDENTIAL_WEIGHT) + assert len(events) == 1 + assert events[0]["target"] == credential_id + assert events[0]["detail"] == "42" + + # null = 恢复默认 + cleared = client.put(f"/api/credentials/{credential_id}/weight", + json={"weight": None}) + assert cleared.status_code == 200 + assert cleared.json() == {"routing_weight": None} + assert _weight_of(app, credential_id) is None + assert sorted(e["detail"] for e in + app.state.audit.query(action=ACTION_CREDENTIAL_WEIGHT)) == sorted( + ["42", "(默认)"]) + + +def test_negative_weight_is_allowed(admin_client): + """负数 = 尽量靠后(备用号),与 0(默认)区分。""" + app, client = admin_client + credential_id = _add_credential(app) + assert client.put(f"/api/credentials/{credential_id}/weight", + json={"weight": -100}).status_code == 200 + assert _weight_of(app, credential_id) == -100 + + +def test_weight_validation(admin_client): + app, client = admin_client + credential_id = _add_credential(app) + url = f"/api/credentials/{credential_id}/weight" + + # 布尔是 int 的子类,必须单独拒掉({"weight": true} 不能当 1) + assert client.put(url, json={"weight": True}).status_code == 400 + assert client.put(url, json={"weight": "5"}).status_code == 400 + assert client.put(url, json={"weight": 1001}).status_code == 400 + assert client.put(url, json={"weight": -1001}).status_code == 400 + # 缺失凭证 + assert client.put("/api/credentials/ghost/weight", + json={"weight": 1}).status_code == 400 + # 失败的请求不写审计、不改库 + assert _weight_of(app, credential_id) is None + assert app.state.audit.query(action=ACTION_CREDENTIAL_WEIGHT) == [] diff --git a/tests/db/test_repo_credit.py b/tests/db/test_repo_credit.py index 68aea87..eb14895 100644 --- a/tests/db/test_repo_credit.py +++ b/tests/db/test_repo_credit.py @@ -354,3 +354,24 @@ def test_save_quota_persists_web_cookie_state(tmp_path): # 重新粘贴/清除 Cookie 时由 API 复位为 NULL,下一轮探测再写 credentials.set_web_cookie_state(cid, None) assert credentials.list_all()[0]["web_cookie_state"] is None + + +def test_routing_weight_round_trip(tmp_path): + """权重随凭证落库:列表与调度候选都带出同值,None = 恢复默认。""" + db = Database(tmp_path / "t.sqlite3") + apply_schema(db.connect()) + credentials = CredentialRepository(db, CredentialCipher(SECRET)) + cid = credentials.add(provider="trae", credential_data={"accessToken": "t"}) + + assert credentials.candidates()[0].routing_weight is None + assert credentials.set_routing_weight(cid, 7) is True + assert credentials.candidates()[0].routing_weight == 7 + assert credentials.list_all()[0]["routing_weight"] == 7 + # 负数 = 备用(靠后),与默认 0 区分 + credentials.set_routing_weight(cid, -3) + assert credentials.candidates()[0].routing_weight == -3 + credentials.set_routing_weight(cid, None) + assert credentials.candidates()[0].routing_weight is None + assert credentials.list_all()[0]["routing_weight"] is None + assert credentials.set_routing_weight("ghost", 1) is False + db.close() diff --git a/tests/db/test_repo_migrate.py b/tests/db/test_repo_migrate.py index 3064403..88dde8a 100644 --- a/tests/db/test_repo_migrate.py +++ b/tests/db/test_repo_migrate.py @@ -640,3 +640,23 @@ def test_legacy_db_upgrade_adds_web_cookie_state(tmp_path): assert db.connect().execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION db.close() + + +def test_legacy_db_upgrade_adds_routing_weight(tmp_path): + """老库升级:credentials 无 routing_weight 时补列,历史行 NULL = 默认权重。""" + db = Database(tmp_path / "legacy.sqlite3") + conn = sqlite3.connect(db.path) + conn.execute( + "CREATE TABLE credentials (id TEXT PRIMARY KEY, provider TEXT NOT NULL," + " data_enc BLOB NOT NULL, created_at INTEGER NOT NULL)") + conn.execute("INSERT INTO credentials (id, provider, data_enc, created_at)" + " VALUES ('cred_old', 'trae', x'00', 1)") + conn.commit() + conn.close() + + apply_schema(db.connect()) + columns = {row[1] for row in db.connect().execute("PRAGMA table_info(credentials)")} + assert "routing_weight" in columns + assert db.connect().execute( + "SELECT routing_weight FROM credentials WHERE id = 'cred_old'").fetchone()[0] is None + db.close() diff --git a/tests/db/test_users_repo.py b/tests/db/test_users_repo.py index a35046e..87d7087 100644 --- a/tests/db/test_users_repo.py +++ b/tests/db/test_users_repo.py @@ -224,7 +224,7 @@ def test_schema_v14_adds_users_and_audit_tables(tmp_path): "SELECT name FROM sqlite_master WHERE type = 'table'")} assert {"users", "audit_events"} <= tables assert db.connect().execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION - assert SCHEMA_VERSION == 33 + assert SCHEMA_VERSION == 34 db.close() diff --git a/tests/engine/test_scheduler.py b/tests/engine/test_scheduler.py index 0fda40e..c5582b9 100644 --- a/tests/engine/test_scheduler.py +++ b/tests/engine/test_scheduler.py @@ -518,3 +518,50 @@ def test_select_skips_model_cooled_credential_when_filtered(): # 同一份候选换模型:a 的模型级冷却不生效,健康度更高者胜出 usable = [c for c in (cooled_a, b) if c.is_selectable(NOW, "kimi-k2")] assert s.select(usable, set(), NOW) == "a" + + +# ---------------------------------------------------------------- 管理员权重 + + +def test_select_weight_reorders_within_health_tier(): + """同健康档内高权重优先:压过到期积分与在途分摊。""" + pool = [ + cand("hot", health=90, routing_weight=50), + cand("expiring", health=90, expiry_ladder=[(NOW + 600, 500.0)]), + cand("busy", health=90, routing_weight=50, inflight=3), + ] + # hot 与 busy 同权重,退到原链按在途少者(hot 0 < busy 3) + assert Scheduler().select(pool, set(), NOW) == "hot" + # 高权重仍然压过「到期积分多」:busy(50) > expiring(0) + assert Scheduler().select([c for c in pool if c.credential_id != "hot"], + set(), NOW) == "busy" + # 权重打平才退回原链:到期积分多者优先 + tied = [cand("a", health=90, routing_weight=5), + cand("b", health=90, routing_weight=5, + expiry_ladder=[(NOW + 600, 500.0)])] + assert Scheduler().select(tied, set(), NOW) == "b" + + +def test_select_negative_weight_ranks_last_in_tier(): + """负权重 = 备用号:同档内排最后,哪怕它到期积分最多。""" + pool = [cand("normal", health=90), + cand("standby", health=90, routing_weight=-10, + expiry_ladder=[(NOW + 600, 999.0)])] + assert Scheduler().select(pool, set(), NOW) == "normal" + + +def test_select_weight_never_crosses_health_tier(): + """权重不是准入条件:exhausted / unknown 号即使权重拉满也不能越过 known。""" + pool = [cand("known", health=60), + cand("dead", health=EXHAUSTED, routing_weight=1000), + cand("unknown", health=None, routing_weight=1000)] + assert Scheduler().select(pool, set(), NOW) == "known" + assert Scheduler().select([pool[1], pool[2]], set(), NOW) == "unknown" + + +def test_select_without_weight_keeps_existing_order(): + """全默认(None/0)时排序与补该列前一致:到期积分仍压过健康度。""" + pool = [cand("steady", health=95), + cand("burn", health=10, expiry_ladder=[(NOW + 600, 100.0)]), + cand("zero", health=99, routing_weight=0)] + assert Scheduler().select(pool, set(), NOW) == "burn" diff --git a/tests/webapp/test_logging.py b/tests/webapp/test_logging.py index f2a3a69..21db76b 100644 --- a/tests/webapp/test_logging.py +++ b/tests/webapp/test_logging.py @@ -141,3 +141,49 @@ def test_audit_logger_not_downgraded(): logging.getLogger().handlers = [] configure_logging("INFO") assert logging.getLogger("src.api.admin_credentials").getEffectiveLevel() == logging.INFO + + +def test_log_line_carries_request_id_from_context(): + """平台日志(journald / launchd 文件)也能按一次请求串起来。 + + 值必须与响应头 `X-Request-ID`、日志页过滤用的是同一个 ID,否则两种视图 + 对不上;format 走真实 handler(filter 在 emit 前注入)。 + """ + from src.request_context import set_request_id + + logging.getLogger().handlers = [] + configure_logging("INFO") + handler = _root_handlers()[0] + + set_request_id("req-abc-123") + record = logging.LogRecord("src.engine.executor", logging.INFO, __file__, 1, + "换号 cred_1", None, None) + handler.handle(record) # 会走 filter + formatter + assert record.request_id == "req-abc-123" + assert "[req-abc-123]" in handler.format(record) + + +def test_log_line_without_request_context_uses_dash(): + """后台任务没有请求上下文 → `-`,不能把上一次请求的 ID 留在日志里。""" + from src.request_context import set_request_id + + logging.getLogger().handlers = [] + configure_logging("INFO") + handler = _root_handlers()[0] + + set_request_id(None) # 模拟后台任务(无入站请求) + record = logging.LogRecord("src.tasks.runner", logging.INFO, __file__, 1, + "签到任务完成", None, None) + handler.handle(record) + assert record.request_id == "-" + assert "[-]" in handler.format(record) + + +def test_request_id_filter_is_not_duplicated(): + """重复 configure(每个测试一次 build_app)不能叠加 filter。""" + logging.getLogger().handlers = [] + configure_logging("INFO") + configure_logging("INFO") + handler = _root_handlers()[0] + filters = [item for item in handler.filters if item.__class__.__name__ == "_RequestIdFilter"] + assert len(filters) == 1 diff --git a/web/src/api/client.ts b/web/src/api/client.ts index cc9cc09..0e07d5e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -353,6 +353,12 @@ export const api = { method: "PUT", ...json({ cookie }), }), + /** 调度权重(null = 恢复默认);只改同健康档内的选号次序。 */ + setCredentialWeight: (id: string, weight: number | null) => + request<{ routing_weight: number | null }>(`/api/credentials/${id}/weight`, { + method: "PUT", + ...json({ weight }), + }), /** 批量运维:一次对多条凭证执行同一动作,后端逐条隔离返回结果。 */ batchCredentials: (action: BatchAction, ids: string[], enabled?: boolean) => request("/api/credentials/batch", { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index a27638d..c9f4113 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -101,6 +101,8 @@ export interface Credential { limits: CredentialLimits | null; /** 运维标签(仅列表展示与筛选,不参与调度);无标签为空数组 */ tags: string[]; + /** 调度权重:null = 默认;正数越优先、负数当备用(同健康档内生效) */ + routing_weight: number | null; } /** 凭证级限额护栏(全部按北京自然日;0/null = 该护栏关闭)。 */ diff --git a/web/src/components/CredentialWeightDialog.tsx b/web/src/components/CredentialWeightDialog.tsx new file mode 100644 index 0000000..e6b955b --- /dev/null +++ b/web/src/components/CredentialWeightDialog.tsx @@ -0,0 +1,147 @@ +import { useState } from "react"; +import { Gauge, X } from "lucide-react"; +import { api } from "../api/client"; +import type { Credential } from "../api/types"; +import { useDialogFocus } from "../hooks/useDialogFocus"; +import { Button, Field, Input, Notice } from "../ui"; + +/** 与后端 `src/api/admin_credentials.py` 的 WEIGHT_MIN / WEIGHT_MAX 对齐。 */ +export const MIN_WEIGHT = -1000; +export const MAX_WEIGHT = 1000; + +/** 空串 / `default` → null(恢复默认);非法或越界 → "invalid"。 */ +export function parseWeightInput(raw: string): number | null | "invalid" { + const text = raw.trim(); + if (!text || text.toLowerCase() === "default") return null; + if (!/^-?\d+$/.test(text)) return "invalid"; + const value = Number(text); + if (!Number.isSafeInteger(value) || value < MIN_WEIGHT || value > MAX_WEIGHT) { + return "invalid"; + } + return value; +} + +/** + * 调度权重编辑对话框。 + * + * 权重只改**同一健康分档内**的选号次序:正数越优先,负数越靠后(备用号), + * 0/留空 = 默认。它压不过健康分档本身——exhausted 的号即使权重再高也不会 + * 被选中,所以提示里必须说清「不是准入条件」。 + */ +export function CredentialWeightDialog({ + credential, + onClose, + onSaved, + onError, +}: { + credential: Credential; + onClose: () => void; + onSaved: (message: string) => void; + onError: (message: string) => void; +}) { + const current = credential.routing_weight; + const [raw, setRaw] = useState(current === null || current === 0 ? "" : String(current)); + const [busy, setBusy] = useState(false); + const [localError, setLocalError] = useState(null); + const dialogRef = useDialogFocus(busy ? undefined : onClose); + + const parsed = parseWeightInput(raw); + + const submit = async () => { + if (parsed === "invalid") { + setLocalError(`权重需为 ${MIN_WEIGHT} ~ ${MAX_WEIGHT} 的整数,留空 = 默认`); + return; + } + setLocalError(null); + setBusy(true); + try { + await api.setCredentialWeight(credential.id, parsed); + onSaved(parsed === null ? "权重已恢复默认。" : `权重已设为 ${parsed}。`); + } catch (caught) { + onError(caught instanceof Error ? caught.message : "权重保存失败"); + } finally { + setBusy(false); + } + }; + + return ( +
!busy && onClose()} + > +
event.stopPropagation()} + className="max-h-[90vh] w-full max-w-md animate-in overflow-y-auto rounded-xl border border-border bg-card p-5 shadow-xl fade-in-0 zoom-in-95" + > +
+
+

+ 设置调度权重 +

+

+ {credential.nickname || credential.id.slice(0, 12)} +

+
+ +
+ + + { + setRaw(event.target.value); + setLocalError(null); + }} + /> + + + {localError && ( +

+ {localError} +

+ )} + + + 权重只影响同类健康号之间的先后,不会把不可用(冷却中 / 已耗尽 / 已停用) + 的号顶上来。高权重会压过免费优先与在途分摊,建议小幅调整。 + + +
+ + +
+
+
+ ); +} diff --git a/web/src/components/__tests__/CredentialWeightDialog.test.tsx b/web/src/components/__tests__/CredentialWeightDialog.test.tsx new file mode 100644 index 0000000..f56f536 --- /dev/null +++ b/web/src/components/__tests__/CredentialWeightDialog.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { api } from "../../api/client"; +import { makeCredential } from "../../test-utils"; +import { + CredentialWeightDialog, + MAX_WEIGHT, + MIN_WEIGHT, + parseWeightInput, +} from "../CredentialWeightDialog"; + +const credential = makeCredential({ id: "cred_w", nickname: "W" }); + +function setup(overrides: Partial[0]> = {}) { + const onSaved = vi.fn(); + const onError = vi.fn(); + render( + , + ); + return { onSaved, onError }; +} + +describe("parseWeightInput", () => { + it("空 / default → null,合法整数原样返回", () => { + expect(parseWeightInput("")).toBeNull(); + expect(parseWeightInput(" ")).toBeNull(); + expect(parseWeightInput("default")).toBeNull(); + expect(parseWeightInput("7")).toBe(7); + expect(parseWeightInput("-10")).toBe(-10); + }); + + it("非整数 / 越界 / 小数都判非法", () => { + for (const raw of ["abc", "1.5", "1e3", String(MAX_WEIGHT + 1), + String(MIN_WEIGHT - 1)]) { + expect(parseWeightInput(raw)).toBe("invalid"); + } + }); +}); + +describe("CredentialWeightDialog", () => { + it("保存权重并回调成功文案", async () => { + const saved = vi.spyOn(api, "setCredentialWeight").mockResolvedValue({ routing_weight: 42 }); + const { onSaved } = setup(); + + await userEvent.type(screen.getByTestId("credential-weight-input"), "42"); + await userEvent.click(screen.getByTestId("credential-weight-save")); + + await waitFor(() => expect(saved).toHaveBeenCalledWith("cred_w", 42)); + expect(onSaved).toHaveBeenCalledWith(expect.stringContaining("42")); + }); + + it("留空提交 = 恢复默认(null)", async () => { + const saved = vi.spyOn(api, "setCredentialWeight").mockResolvedValue({ routing_weight: null }); + const { onSaved } = setup(); + + await userEvent.click(screen.getByTestId("credential-weight-save")); + + await waitFor(() => expect(saved).toHaveBeenCalledWith("cred_w", null)); + expect(onSaved).toHaveBeenCalledWith(expect.stringContaining("默认")); + }); + + it("非法输入本地拦截,不打后端", async () => { + const saved = vi.spyOn(api, "setCredentialWeight"); + setup(); + + await userEvent.type(screen.getByTestId("credential-weight-input"), "abc"); + await userEvent.click(screen.getByTestId("credential-weight-save")); + + expect(screen.getByTestId("credential-weight-error")).toHaveTextContent("整数"); + expect(saved).not.toHaveBeenCalled(); + }); + + it("回填当前权重;0 视为默认留空", () => { + setup({ + credential: makeCredential({ id: "cred_w", routing_weight: -5 }) as never, + }); + expect(screen.getByTestId("credential-weight-input")).toHaveValue("-5"); + }); + + it("后端报错走 onError,不误报成功", async () => { + vi.spyOn(api, "setCredentialWeight").mockRejectedValue(new Error("boom")); + const { onSaved, onError } = setup(); + + await userEvent.type(screen.getByTestId("credential-weight-input"), "1"); + await userEvent.click(screen.getByTestId("credential-weight-save")); + + await waitFor(() => expect(onError).toHaveBeenCalledWith("boom")); + expect(onSaved).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/pages/CredentialsPage.tsx b/web/src/pages/CredentialsPage.tsx index 7cd1b34..a965b42 100644 --- a/web/src/pages/CredentialsPage.tsx +++ b/web/src/pages/CredentialsPage.tsx @@ -56,6 +56,7 @@ import { AddCredentialDialog } from "../components/AddCredentialDialog"; import { BatchImportDialog } from "../components/BatchImportDialog"; import { CredentialLimitsDialog } from "../components/CredentialLimitsDialog"; import { CredentialTagsDialog } from "../components/CredentialTagsDialog"; +import { CredentialWeightDialog } from "../components/CredentialWeightDialog"; import { CredentialWebCookieDialog } from "../components/CredentialWebCookieDialog"; import { groupModelsByProvider, rateLabel, rateSummary } from "../components/ModelPicker"; import { ToastViewport, useToasts } from "../components/Toast"; @@ -340,6 +341,7 @@ interface Actions { checkinLog: (credential: Credential) => void; limits: (credential: Credential) => void; tags: (credential: Credential) => void; + weight: (credential: Credential) => void; webCookie: (credential: Credential) => void; export: (credential: Credential) => void; } @@ -372,6 +374,8 @@ export function CredentialsPage() { const [limitsFor, setLimitsFor] = useState(null); // 标签编辑对话框:同样存 id,取最新快照 const [tagsFor, setTagsFor] = useState(null); + // 调度权重对话框:同样存 id,取最新快照 + const [weightFor, setWeightFor] = useState(null); // Qoder 网页 Cookie 对话框:同样存 id,取最新快照 const [webCookieFor, setWebCookieFor] = useState(null); // 列表筛选:池子一大就没有「找到某个号」的路径,这里收搜索 + 渠道 + 状态 + 标签四件套。 @@ -472,6 +476,10 @@ export function CredentialsPage() { () => credentials.find((item) => item.id === tagsFor) ?? null, [credentials, tagsFor], ); + const weightCredential = useMemo( + () => credentials.find((item) => item.id === weightFor) ?? null, + [credentials, weightFor], + ); // 网页 Cookie 对话框当前编辑的凭证:同上 const webCookieCredential = useMemo( () => credentials.find((item) => item.id === webCookieFor) ?? null, @@ -753,6 +761,7 @@ export function CredentialsPage() { // 只开对话框;保存与刷新由对话框自己回调(见 CredentialLimitsDialog) limits: (credential) => setLimitsFor(credential.id), tags: (credential) => setTagsFor(credential.id), + weight: (credential) => setWeightFor(credential.id), // 单条导出:文件内容与「批量导入」的条目同形,可原样再导入。 // 只开对话框;保存与探测由对话框/后端负责 webCookie: (credential) => setWebCookieFor(credential.id), @@ -1477,6 +1486,19 @@ export function CredentialsPage() { /> )} + {canWrite && weightCredential && ( + setWeightFor(null)} + onSaved={(message) => { + setWeightFor(null); + showNotice(message); + void refresh(); + }} + onError={(message) => showError(message)} + /> + )} + {canWrite && webCookieCredential && ( {credential.tags.length ? "编辑标签" : "设置标签"} + actions.weight(credential)} + title="只调整同健康档内的选号次序:正数越优先、负数当备用,不会把不可用的号顶上来" + > + + {credential.routing_weight + ? `权重 ${credential.routing_weight}` + : "设置权重"} + {["qoder", "codearts"].includes(credential.provider) && ( actions.webCookie(credential)} diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index dbaf882..90588f6 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -34,15 +34,22 @@ const LEVEL_TONE: Record = { CRITICAL: "danger", }; -/** 按级别集合与关键字过滤(纯函数,页面与测试共用)。 */ +/** 按级别集合与关键字过滤(纯函数,页面与测试共用)。 + * + * 关键字同时匹配 message / logger / request_id:排查一条请求跨渠道重试时, + * 直接把响应头的 `X-Request-ID`(或页面上显示的 8 位前缀)贴进来,就能收起 + * 该请求的全部服务端日志——executor 每次换号、冷却、降级都带同一个上下文。 + */ export function filterLines(lines: LogLine[], levels: ReadonlySet, keyword: string): LogLine[] { const needle = keyword.trim().toLowerCase(); + const bare = needle.replace(/^#/, ""); return lines.filter((line) => { if (!levels.has(line.level)) return false; if (!needle) return true; return line.message.toLowerCase().includes(needle) - || line.logger.toLowerCase().includes(needle); + || line.logger.toLowerCase().includes(needle) + || (line.request_id ?? "").toLowerCase().includes(bare); }); } @@ -197,7 +204,7 @@ export function LogsPage() { id="logs-keyword" data-testid="logs-keyword" value={keyword} - placeholder="按消息或 logger 过滤" + placeholder="按消息 / logger / request_id 过滤" onChange={(event) => setKeyword(event.target.value)} /> @@ -270,13 +277,15 @@ export function LogsPage() { {line.message} {line.request_id && ( - setKeyword(line.request_id ?? "")} > #{line.request_id.slice(0, 8)} - + )} ))} diff --git a/web/src/pages/__tests__/CredentialsPage.test.tsx b/web/src/pages/__tests__/CredentialsPage.test.tsx index 3672a81..5168f4e 100644 --- a/web/src/pages/__tests__/CredentialsPage.test.tsx +++ b/web/src/pages/__tests__/CredentialsPage.test.tsx @@ -1839,3 +1839,35 @@ describe("CredentialsPage 批量导入", () => { expect(screen.queryByTestId("open-batch-import")).not.toBeInTheDocument(); }); }); + + it("设置调度权重:菜单打开对话框并提交 PUT", async () => { + const fetchSpy = mockFetch({ + "/api/credentials/cred_1/weight": { routing_weight: 5 }, + "/api/credentials": listBody([makeCredential({ id: "cred_1" })]), + }); + renderPage(, ADMIN); + await settle(); + await expandCredentialGroups(); + await userEvent.click(screen.getByTestId("actions-cred_1")); + await userEvent.click(screen.getByRole("menuitem", { name: "设置权重" })); + await screen.findByTestId("credential-weight-dialog"); + + await userEvent.type(screen.getByTestId("credential-weight-input"), "5"); + await userEvent.click(screen.getByTestId("credential-weight-save")); + + await waitFor(() => + expect(fetchSpy).toHaveBeenCalledWith( + "/api/credentials/cred_1/weight", + expect.objectContaining({ method: "PUT", body: JSON.stringify({ weight: 5 }) }), + ), + ); + }); + + it("已有权重的凭证在菜单里显示当前值", async () => { + mockFetch({ "/api/credentials": listBody([makeCredential({ id: "cred_1", routing_weight: -3 })]) }); + renderPage(, ADMIN); + await settle(); + await expandCredentialGroups(); + await userEvent.click(screen.getByTestId("actions-cred_1")); + expect(screen.getByRole("menuitem", { name: "权重 -3" })).toBeInTheDocument(); + }); diff --git a/web/src/pages/__tests__/LogsPage.test.tsx b/web/src/pages/__tests__/LogsPage.test.tsx index 8a8fed9..0fe0d68 100644 --- a/web/src/pages/__tests__/LogsPage.test.tsx +++ b/web/src/pages/__tests__/LogsPage.test.tsx @@ -38,6 +38,19 @@ describe("LogsPage 纯函数", () => { .toEqual([1]); }); + it("按 request_id 过滤整条请求链路(支持页面上的 # 前缀)", () => { + const lines = [ + line({ seq: 1, message: "换号 cred_a", request_id: "ab12cd34ef" }), + line({ seq: 2, message: "换号 cred_b", request_id: "ab12cd34ef" }), + line({ seq: 3, message: "另一条请求", request_id: "ff00ff00ff" }), + line({ seq: 4, message: "后台任务(无请求上下文)" }), + ]; + expect(filterLines(lines, new Set(["INFO"]), "ab12cd34").map((item) => item.seq)) + .toEqual([1, 2]); + expect(filterLines(lines, new Set(["INFO"]), "#AB12CD34").map((item) => item.seq)) + .toEqual([1, 2]); + }); + it("时间戳格式化到毫秒,非法值给破折号", () => { expect(formatLogTime(0)).toMatch(/^\d{2}:\d{2}:\d{2}\.\d{3}$/); expect(formatLogTime(Number.NaN)).toBe("—"); @@ -175,7 +188,29 @@ describe("LogsPage", () => { await settle(); const chip = screen.getByTestId("log-request-id"); expect(chip).toHaveTextContent("#abcdef01"); - expect(chip).toHaveAttribute("title", "request_id=abcdef0123456789"); + expect(chip).toHaveAttribute( + "title", "只看这条请求的全部日志(request_id=abcdef0123456789)"); + }); + + it("点 request_id 徽标即筛出该请求的整条链路", async () => { + mockFetch({ + "/api/logs": { + capacity: 10, limit: 10, + lines: [ + line({ seq: 1, message: "换号 cred_a", request_id: "abcdef0123456789" }), + line({ seq: 2, message: "另一条请求", request_id: "1234567890abcdef" }), + ], + }, + }); + renderPage(, { username: "root", is_admin: true }); + await settle(); + expect(screen.getAllByTestId("log-row")).toHaveLength(2); + + await userEvent.click(screen.getAllByTestId("log-request-id")[0]); + expect(screen.getByTestId("logs-keyword")).toHaveValue("abcdef0123456789"); + await waitFor(() => { + expect(screen.getAllByTestId("log-row")).toHaveLength(1); + }); }); it("跟随最新可关闭(不改变已渲染内容)", async () => { diff --git a/web/src/test-utils/index.tsx b/web/src/test-utils/index.tsx index 0d2c26a..8b8ae9c 100644 --- a/web/src/test-utils/index.tsx +++ b/web/src/test-utils/index.tsx @@ -145,6 +145,7 @@ export function makeCredential(overrides: Record = {}) { added_by: "root", limits: null, tags: [], + routing_weight: null, ...overrides, }; }