diff --git a/README.md b/README.md index 9a343b6..6151e10 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PicGen Console -一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.60**。它把 +一个面向 OpenAI 兼容图像生成 / 编辑接口的本地工作台,当前版本 **0.1.61**。它把 `/v1/images/generations`、`/v1/images/edits` 与 `/v1/responses`(含 `image_generation` 工具) 包装成统一可观测的代理,前端是一套零依赖的 Web 控制台。 @@ -14,7 +14,9 @@ ![PicGen Console 主程序界面](demo1.png) -## 0.1.60 主要特性 +## 0.1.61 主要特性 + +- **正常页面刷新不再误限流**:业务 API 仍受每分钟总配额保护,5 秒 burst 只约束写请求;生成完成后的统计刷新、工作区重载和作品列表读取不会互相挤占短时写入配额。 - **多候选兼容参数路径**:上游若以 `tools[0].n` 等嵌套参数路径拒绝候选数量,自动去掉 `n` 并并发补齐候选;结构化识别只针对候选数字段,不会误重试内容审核错误。 @@ -62,7 +64,7 @@ - `RequestIdMiddleware` — 生成或透传 `X-Request-ID` - `SecurityHeadersMiddleware` — `X-Content-Type-Options / X-Frame-Options / Referrer-Policy / Permissions-Policy / Cache-Control` - `BodySizeLimitMiddleware` — 拒绝超大请求(默认 64 MB) - - `RateLimitMiddleware` — 滑动窗口限流(默认 120 req/min + 20 burst/5s) + - `RateLimitMiddleware` — 滑动窗口限流(默认业务 API 120 req/min + 写请求 20 burst/5s) - `ProxyAuthMiddleware` — 可选 Bearer / `X-Proxy-Token` 鉴权 - `CORSMiddleware` — 配置化跨域 - **原子化落盘**:图片与 sidecar JSON 用临时文件 + rename 写入,崩溃不留半截文件;核心生成、 @@ -113,10 +115,10 @@ PICGEN_LOG_FORMAT=json \ ### Docker ```bash -docker build -t minorli/picgen:0.1.60 . +docker build -t minorli/picgen:0.1.61 . docker run --rm -p 8000:8000 \ -v picgen-data:/app/data \ - minorli/picgen:0.1.60 + minorli/picgen:0.1.61 ``` 或: @@ -131,10 +133,10 @@ docker compose up -d ./scripts/docker-build-push.sh ``` -默认会构建并推送 `minorli/picgen:0.1.60`。也可以覆盖: +默认会构建并推送 `minorli/picgen:0.1.61`。也可以覆盖: ```bash -IMAGE=minorli/picgen VERSION=0.1.60 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh +IMAGE=minorli/picgen VERSION=0.1.61 PLATFORM=linux/amd64 ./scripts/docker-build-push.sh ``` 镜像不会包含 `.env`、本地用户库或历史图片。容器内置 `HEALTHCHECK` 探测 `/api/health`,以非 root @@ -247,7 +249,7 @@ Bug 反馈和找回密码申请会先写入本地认证库,再优先发送到 ## 图像通道 -PicGen 0.1.60 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: +PicGen 0.1.61 把四类图像操作统一提交给 `/api/image-jobs`,实际通道由服务端决定: | 用户操作 | 默认接口 | 默认模型 | | --- | --- | --- | diff --git a/docker-compose.yml b/docker-compose.yml index e84978d..b355c5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: picgen: - image: minorli/picgen:0.1.60 + image: minorli/picgen:0.1.61 build: context: . ports: diff --git a/pyproject.toml b/pyproject.toml index 2739a4b..c19879d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "picgen" -version = "0.1.60" +version = "0.1.61" description = "Enterprise-grade local web console for OpenAI-compatible image generation and editing APIs." readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/docker-build-push.sh b/scripts/docker-build-push.sh index d4b77b9..a1cffde 100755 --- a/scripts/docker-build-push.sh +++ b/scripts/docker-build-push.sh @@ -2,7 +2,7 @@ set -euo pipefail IMAGE="${IMAGE:-minorli/picgen}" -VERSION="${VERSION:-0.1.60}" +VERSION="${VERSION:-0.1.61}" PLATFORM="${PLATFORM:-linux/amd64}" docker buildx build \ diff --git a/src/picgen/middleware.py b/src/picgen/middleware.py index d93871b..89bd276 100644 --- a/src/picgen/middleware.py +++ b/src/picgen/middleware.py @@ -243,6 +243,7 @@ async def dispatch(self, request: Request, call_next: RequestHandler) -> Respons if request.url.path in {"/api/health", "/api/ready", "/api/config"}: return await call_next(request) client_key = self._client_key(request) + uses_burst_limit = request.method.upper() not in {"GET", "HEAD", "OPTIONS"} now = time.monotonic() async with self._lock: if now - self._last_sweep >= 60.0: @@ -252,7 +253,9 @@ async def dispatch(self, request: Request, call_next: RequestHandler) -> Respons burst_bucket = self._burst_buckets.setdefault(client_key, deque()) self._evict(minute_bucket, now - 60.0) self._evict(burst_bucket, now - 5.0) - if len(minute_bucket) >= self.per_minute or (self.burst > 0 and len(burst_bucket) >= self.burst): + if len(minute_bucket) >= self.per_minute or ( + uses_burst_limit and self.burst > 0 and len(burst_bucket) >= self.burst + ): retry_after = 5 if len(minute_bucket) >= self.per_minute and minute_bucket: # The minute window is what's exhausted — tell the client @@ -275,7 +278,8 @@ async def dispatch(self, request: Request, call_next: RequestHandler) -> Respons extra_headers={"Retry-After": str(retry_after)}, ) minute_bucket.append(now) - burst_bucket.append(now) + if uses_burst_limit: + burst_bucket.append(now) return await call_next(request) diff --git a/static/app.js b/static/app.js index 05e369f..ac24a7d 100644 --- a/static/app.js +++ b/static/app.js @@ -1,11 +1,11 @@ -import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.60" +import { calculateLogoPlacementScore, chooseLogoPlacement } from "./logo-placement.mjs?v=0.1.61" import { DEFAULT_RESPONSES_MODEL, RESPONSES_MODEL_STORAGE_VERSION, RESPONSES_REASONING_STORAGE_VERSION, migrateStoredResponsesReasoningSettings, migrateStoredResponsesSettings, -} from "./responses-settings.mjs?v=0.1.60" +} from "./responses-settings.mjs?v=0.1.61" const RESPONSES_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max", "ultra"]) const DEFAULT_RESPONSES_REASONING_EFFORT = "xhigh" diff --git a/static/index.html b/static/index.html index 7e91a03..7c41af5 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,7 @@ PicGen Console - +
@@ -1046,7 +1046,7 @@

管理员高级设置

@@ -1356,6 +1356,6 @@

图片预览

- + diff --git a/tests/test_api.py b/tests/test_api.py index 00b3d2a..26f665e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3392,8 +3392,8 @@ async def send(message: dict[str, object]) -> None: assert response_start["status"] == 204 -def test_rate_limit_returns_429(make_client, settings_factory): - settings = settings_factory(rate_limit_per_minute=2, rate_limit_burst=2) +def test_rate_limit_write_burst_returns_429(make_client, settings_factory): + settings = settings_factory(rate_limit_per_minute=100, rate_limit_burst=2) client, _, _ = make_client(settings=settings) # generate endpoint -> still validation 400, but counts toward rate limit client.post("/api/generate", json={}) @@ -3404,6 +3404,44 @@ def test_rate_limit_returns_429(make_client, settings_factory): assert "Retry-After" in response.headers +def test_rate_limit_burst_does_not_block_read_only_ui_refresh(make_client, settings_factory): + settings = settings_factory(rate_limit_per_minute=100, rate_limit_burst=2) + client, _, _ = make_client(settings=settings) + + responses = [client.get("/api/recipes") for _ in range(5)] + + assert all(response.status_code != 429 for response in responses) + + +def test_read_requests_do_not_consume_write_burst(make_client, settings_factory): + settings = settings_factory(rate_limit_per_minute=100, rate_limit_burst=2) + client, _, _ = make_client(settings=settings) + + for _ in range(5): + assert client.get("/api/recipes").status_code != 429 + + first_write = client.post("/api/generate", json={}) + second_write = client.post("/api/generate", json={}) + blocked_write = client.post("/api/generate", json={}) + + assert first_write.status_code != 429 + assert second_write.status_code != 429 + assert blocked_write.status_code == 429 + + +def test_rate_limit_per_minute_still_applies_to_read_requests(make_client, settings_factory): + settings = settings_factory(rate_limit_per_minute=2, rate_limit_burst=1) + client, _, _ = make_client(settings=settings) + + first = client.get("/api/recipes") + second = client.get("/api/recipes") + third = client.get("/api/recipes") + + assert first.status_code != 429 + assert second.status_code != 429 + assert third.status_code == 429 + + def test_proxy_auth_token_enforced(make_client, settings_factory): settings = settings_factory(proxy_auth_token="topsecret") client, _, _ = make_client(settings=settings) diff --git a/tests/test_static_assets.py b/tests/test_static_assets.py index c1632cd..9f59b1a 100644 --- a/tests/test_static_assets.py +++ b/tests/test_static_assets.py @@ -13,7 +13,7 @@ def test_legacy_responses_model_storage_is_migrated_once() -> None: settings_js = (ROOT_DIR / "static" / "responses-settings.mjs").read_text(encoding="utf-8") assert 'const DEPRECATED_RESPONSES_MODELS = new Set(["gpt-5.4"])' in app_js - assert 'from "./responses-settings.mjs?v=0.1.60"' in app_js + assert 'from "./responses-settings.mjs?v=0.1.61"' in app_js assert 'const LEGACY_DEFAULT_RESPONSES_MODEL = "gpt-5.5"' in settings_js assert "const RESPONSES_MODEL_STORAGE_VERSION = 4" in settings_js assert "function migrateStoredResponsesSettings" in settings_js @@ -38,7 +38,7 @@ def test_logo_overlay_uses_uploaded_asset_without_ai_guidance() -> None: assert 'const COMPANY_LOGO_URL = "6renyou.png"' in app_js assert "composeLogoOverlayForCandidates" in app_js assert "createOfficialLogoCanvas" in app_js - assert 'from "./logo-placement.mjs?v=0.1.60"' in app_js + assert 'from "./logo-placement.mjs?v=0.1.61"' in app_js assert "chooseLogoPlacement" in app_js assert "calculateLogoPlacementScore" in app_js assert "expandLogoSafetyRegion" in placement_js diff --git a/uv.lock b/uv.lock index 5272fff..628a2b5 100644 --- a/uv.lock +++ b/uv.lock @@ -299,7 +299,7 @@ wheels = [ [[package]] name = "picgen" -version = "0.1.60" +version = "0.1.61" source = { editable = "." } dependencies = [ { name = "anyio" },