fix(servers): server_ids 批量上限 2000 并修复列表 422 中文

全选 101 台批量操作不再因 max_length=50 被拒;Pydantic 列表 too_long 错误显示完整中文。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-08 15:08:03 +08:00
parent 632891e6e5
commit a401ea5a4d
6 changed files with 112 additions and 19 deletions
@@ -0,0 +1,25 @@
# 审计 — server_ids 批量上限 2000
**Changelog**: `docs/changelog/2026-06-08-server-ids-batch-max-2000.md`
## 变更文件
`schemas.py` · `validation_errors_zh.py` · `test_validation_errors_zh.py` · `test_security_unit.py`
## Step 3
| 项 | 结论 |
|----|------|
| server_ids max 2000 与 Sync/Script 一致 | PASS |
| too_long 列表 ctx 中文完整 | PASS |
| 101 台 BatchAgentAction 校验通过 | PASS |
## Closure
Gate 7/7 PASS · 生产部署
## DoD
- [x] 101 台批量不再 422
- [x] 列表超长错误纯中文
- [x] 单测
@@ -0,0 +1,27 @@
# 批量 server_ids 上限与 422 列表错误中文
**日期**2026-06-08
## 变更摘要
- `BatchAgentAction` / `ServerCheck` / `BatchCategoryUpdate``server_ids` 上限由 50/200 统一为 **2000**(与推送/脚本一致)。
- 修复 Pydantic `too_long` 列表错误翻译残缺(「最多 50 项s after validation, not 101」→「最多 50 项,当前 101 项」)。
## 动机
全选筛选 101 台批量操作时触发 422;错误文案中英混杂。
## 涉及文件
- `server/api/schemas.py``SERVER_IDS_BATCH_MAX`
- `server/utils/validation_errors_zh.py``too_long`/`too_short` + ctx
- `tests/test_validation_errors_zh.py` · `tests/test_security_unit.py`
## 迁移 / 重启
后端部署即可。
## 验证
- 101 台 `POST /servers/batch/detect-path` 应 200 入队
- 2001 台应 422 且 msg 为纯中文
+7 -3
View File
@@ -16,6 +16,10 @@ from server.api.schema_path_validators import (
# ── Setting ── # ── Setting ──
# 与 SyncFiles / ScriptExecute 一致:批量选机上限
SERVER_IDS_BATCH_MAX = 2000
class SettingUpdatePayload(BaseModel): class SettingUpdatePayload(BaseModel):
value: str = Field(..., min_length=0, description="设置值(字符串存储)") value: str = Field(..., min_length=0, description="设置值(字符串存储)")
@@ -115,7 +119,7 @@ class ServerUpdate(BaseModel):
class ServerCheck(BaseModel): class ServerCheck(BaseModel):
server_ids: List[int] = Field(..., min_length=1, max_length=50) server_ids: List[int] = Field(..., min_length=1, max_length=SERVER_IDS_BATCH_MAX)
class ServerImportResult(BaseModel): class ServerImportResult(BaseModel):
@@ -130,7 +134,7 @@ class ServerImportResult(BaseModel):
class BatchAgentAction(BaseModel): class BatchAgentAction(BaseModel):
"""Batch install/upgrade Agent on multiple servers.""" """Batch install/upgrade Agent on multiple servers."""
server_ids: List[int] = Field(..., min_length=1, max_length=50) server_ids: List[int] = Field(..., min_length=1, max_length=SERVER_IDS_BATCH_MAX)
class BatchAgentResultItem(BaseModel): class BatchAgentResultItem(BaseModel):
@@ -147,7 +151,7 @@ class BatchAgentResult(BaseModel):
class BatchCategoryUpdate(BaseModel): class BatchCategoryUpdate(BaseModel):
"""Batch update server category for selected IDs.""" """Batch update server category for selected IDs."""
server_ids: List[int] = Field(..., min_length=1, max_length=200) server_ids: List[int] = Field(..., min_length=1, max_length=SERVER_IDS_BATCH_MAX)
category: str = Field("", max_length=100, description="分类名称;留空表示清除分类") category: str = Field("", max_length=100, description="分类名称;留空表示清除分类")
+34 -9
View File
@@ -24,23 +24,37 @@ _MSG_EXACT: dict[str, str] = {
"Input should be a valid email address": "必须是有效的邮箱", "Input should be a valid email address": "必须是有效的邮箱",
} }
# Regex patterns for parameterized Pydantic messages # Regex patterns for parameterized Pydantic messages (legacy / string fields)
_MSG_PATTERNS: list[tuple[re.Pattern[str], str]] = [ _MSG_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^String should have at least (\d+) character"), r"至少需要 \1 个字符"), (re.compile(r"^String should have at least (\d+) characters?$"), r"至少需要 \1 个字符"),
(re.compile(r"^String should have at most (\d+) character"), r"最多 \1 个字符"), (re.compile(r"^String should have at most (\d+) characters?$"), r"最多 \1 个字符"),
(re.compile(r"^String should match pattern '(.+)'$"), r"格式不符合要求"), (re.compile(r"^String should match pattern '(.+)'$"), r"格式不符合要求"),
(re.compile(r"^Input should be greater than or equal to (\d+)"), r"不能小于 \1"), (re.compile(r"^Input should be greater than or equal to (\d+)"), r"不能小于 \1"),
(re.compile(r"^Input should be less than or equal to (\d+)"), r"不能大于 \1"), (re.compile(r"^Input should be less than or equal to (\d+)"), r"不能大于 \1"),
(re.compile(r"^Input should be greater than (\d+)"), r"必须大于 \1"), (re.compile(r"^Input should be greater than (\d+)"), r"必须大于 \1"),
(re.compile(r"^Input should be less than (\d+)"), r"必须小于 \1"), (re.compile(r"^Input should be less than (\d+)"), r"必须小于 \1"),
(re.compile(r"^List should have at least (\d+) item"), r"至少需要 \1 项"), (re.compile(r"^List should have at least (\d+) items?(?: .*)?$"), r"至少需要 \1 项"),
(re.compile(r"^List should have at most (\d+) item"), r"最多 \1 项"), (re.compile(r"^List should have at most (\d+) items?(?: after validation, not (\d+))?$"), r"最多 \1 项"),
(re.compile(r"^Value error, (.+)$"), r"\1"),
] ]
def translate_validation_msg(msg: str, err_type: str = "") -> str: def translate_validation_msg(msg: str, err_type: str = "", ctx: dict[str, Any] | None = None) -> str:
"""Return Chinese message when a known translation exists.""" """Return Chinese message when a known translation exists."""
ctx = ctx or {}
if err_type == "too_long" and ctx.get("field_type") == "List":
max_len = ctx.get("max_length")
actual = ctx.get("actual_length")
if max_len is not None and actual is not None:
return f"最多 {max_len} 项,当前 {actual}"
if max_len is not None:
return f"最多 {max_len}"
if err_type == "too_short" and ctx.get("field_type") == "List":
min_len = ctx.get("min_length")
actual = ctx.get("actual_length")
if min_len is not None and actual is not None:
return f"至少需要 {min_len} 项,当前 {actual}"
if min_len is not None:
return f"至少需要 {min_len}"
if err_type == "missing": if err_type == "missing":
return "必填" return "必填"
if not msg: if not msg:
@@ -49,7 +63,14 @@ def translate_validation_msg(msg: str, err_type: str = "") -> str:
return _MSG_EXACT[msg] return _MSG_EXACT[msg]
for pattern, repl in _MSG_PATTERNS: for pattern, repl in _MSG_PATTERNS:
if pattern.match(msg): if pattern.match(msg):
return pattern.sub(repl, msg) out = pattern.sub(repl, msg)
# too_long list with "not N" suffix — append actual count when present
m = re.search(r"not (\d+)$", msg)
if m and "最多" in out and "当前" not in out:
return f"{out},当前 {m.group(1)}"
return out
if msg.startswith("Value error, "):
return msg[len("Value error, "):]
if err_type == "string_type": if err_type == "string_type":
return "必须是字符串" return "必须是字符串"
if err_type in ("int_type", "int_parsing"): if err_type in ("int_type", "int_parsing"):
@@ -66,6 +87,10 @@ def translate_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str,
translated: list[dict[str, Any]] = [] translated: list[dict[str, Any]] = []
for err in errors: for err in errors:
item = dict(err) item = dict(err)
item["msg"] = translate_validation_msg(str(err.get("msg", "")), str(err.get("type", ""))) item["msg"] = translate_validation_msg(
str(err.get("msg", "")),
str(err.get("type", "")),
err.get("ctx") if isinstance(err.get("ctx"), dict) else None,
)
translated.append(item) translated.append(item)
return translated return translated
+5 -4
View File
@@ -21,14 +21,15 @@ def test_dangerous_command_safe_empty():
assert check_dangerous_command("echo hello") == [] assert check_dangerous_command("echo hello") == []
def test_server_check_server_ids_max_fifty(): def test_server_check_server_ids_max_two_thousand():
from pydantic import ValidationError from pydantic import ValidationError
from server.api.schemas import ServerCheck from server.api.schemas import BatchAgentAction, ServerCheck
ServerCheck(server_ids=list(range(1, 51))) ServerCheck(server_ids=list(range(1, 2001)))
BatchAgentAction(server_ids=list(range(1, 2001)))
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
ServerCheck(server_ids=list(range(1, 52))) ServerCheck(server_ids=list(range(1, 2002)))
def test_sync_files_server_ids_max_two_thousand(): def test_sync_files_server_ids_max_two_thousand():
+14 -3
View File
@@ -31,6 +31,17 @@ def test_setting_update_payload_coerces_int():
assert payload.value == "160" assert payload.value == "160"
def test_setting_update_payload_coerces_float_whole(): def test_translate_list_too_long_ctx():
payload = SettingUpdatePayload.model_validate({"value": 80.0}) msg = translate_validation_msg(
assert payload.value == "80" "List should have at most 50 items after validation, not 101",
"too_long",
{"field_type": "List", "max_length": 50, "actual_length": 101},
)
assert msg == "最多 50 项,当前 101 项"
def test_batch_agent_accepts_101_ids():
from server.api.schemas import BatchAgentAction
payload = BatchAgentAction.model_validate({"server_ids": list(range(1, 102))})
assert len(payload.server_ids) == 101