diff --git a/CHANGELOG.md b/CHANGELOG.md index 660f55d..1aab700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.23.1] - 2026-08-12 + +### Fixed + +- 修复单源部署未提供官网 `images` 静态目录,导致产品截图被 SPA 首页回退响应替代的问题。 +- 外部官网浏览器回归改用提交阶段导航,避免第三方观测脚本阻塞页面验证。 + ## [0.23.0] - 2026-08-12 ### Added diff --git a/backend/cmd/asterrouter/VERSION b/backend/cmd/asterrouter/VERSION index ca222b7..610e287 100644 --- a/backend/cmd/asterrouter/VERSION +++ b/backend/cmd/asterrouter/VERSION @@ -1 +1 @@ -0.23.0 +0.23.1 diff --git a/backend/internal/server/spa.go b/backend/internal/server/spa.go index 6e14e68..10d2bcd 100644 --- a/backend/internal/server/spa.go +++ b/backend/internal/server/spa.go @@ -22,9 +22,12 @@ func serveSPA(r *gin.Engine, dir string) { return } r.Static("/assets", filepath.Join(abs, "assets")) + r.Static("/images", filepath.Join(abs, "images")) r.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path - if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v1/") { + if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v1/") || + path == "/assets" || strings.HasPrefix(path, "/assets/") || + path == "/images" || strings.HasPrefix(path, "/images/") { httpx.Error(c, http.StatusNotFound, 1404, "not found") return } diff --git a/backend/internal/server/spa_test.go b/backend/internal/server/spa_test.go new file mode 100644 index 0000000..12c709d --- /dev/null +++ b/backend/internal/server/spa_test.go @@ -0,0 +1,62 @@ +package server + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestServeSPAProvidesPublicImages(t *testing.T) { + gin.SetMode(gin.TestMode) + dist := t.TempDir() + if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("
"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dist, "images"), 0o700); err != nil { + t.Fatal(err) + } + image := []byte("synthetic-webp") + if err := os.WriteFile(filepath.Join(dist, "images", "workbench.webp"), image, 0o600); err != nil { + t.Fatal(err) + } + + router := gin.New() + serveSPA(router, dist) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/images/workbench.webp", nil)) + + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if contentType := recorder.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "image/webp") { + t.Fatalf("content-type=%q", contentType) + } + if !bytes.Equal(recorder.Body.Bytes(), image) { + t.Fatalf("body=%q", recorder.Body.Bytes()) + } +} + +func TestServeSPADoesNotFallbackForMissingStaticFiles(t *testing.T) { + gin.SetMode(gin.TestMode) + dist := t.TempDir() + if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte(""), 0o600); err != nil { + t.Fatal(err) + } + + router := gin.New() + serveSPA(router, dist) + + for _, path := range []string{"/assets/missing.js", "/images/missing.webp"} { + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + if recorder.Code != http.StatusNotFound { + t.Fatalf("path=%s status=%d body=%s", path, recorder.Code, recorder.Body.String()) + } + } +} diff --git a/frontend/e2e/marketing-home.spec.ts b/frontend/e2e/marketing-home.spec.ts index e274f85..5662fef 100644 --- a/frontend/e2e/marketing-home.spec.ts +++ b/frontend/e2e/marketing-home.spec.ts @@ -4,7 +4,7 @@ import { captureBrowserErrors, expectNoHorizontalOverflow } from './fixtures' test('@marketing official website is public, localized, and responsive', async ({ page }, testInfo) => { const errors = captureBrowserErrors(page) - await page.goto('/') + await page.goto('/', { waitUntil: 'commit' }) await expect(page).toHaveURL('/') await expect(page.getByRole('heading', { level: 1, name: 'AsterRouter' })).toBeVisible() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6e28af1..d641c0b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "asterrouter-frontend", - "version": "0.23.0", + "version": "0.23.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "asterrouter-frontend", - "version": "0.23.0", + "version": "0.23.1", "dependencies": { "@lucide/vue": "^1.24.0", "@vitejs/plugin-vue": "^6.0.7", diff --git a/frontend/package.json b/frontend/package.json index 123bf59..019619c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "asterrouter-frontend", "private": true, - "version": "0.23.0", + "version": "0.23.1", "type": "module", "overrides": { "nanoid": "^3.3.17" diff --git a/frontend/src/i18n/locales/marketing.ts b/frontend/src/i18n/locales/marketing.ts index bdac31f..a60ddc2 100644 --- a/frontend/src/i18n/locales/marketing.ts +++ b/frontend/src/i18n/locales/marketing.ts @@ -9,7 +9,7 @@ export const marketingZh = { description: '在企业应用与已授权 AI 供应商之间建立统一控制面。一次完成身份校验、访问准入、资源选择、成本约束和请求级证据记录。', primaryAction: '进入企业控制台', secondaryAction: '查看请求决策', factsLabel: '产品事实', factProtocols: '统一接入 OpenAI、Anthropic 与 Gemini 兼容协议', factEvidence: '策略、路由、用量、成本与 Trace 使用同一证据链', - imageAlt: 'AsterRouter 路由策略工作台实际界面', actualProduct: '实际产品界面', actualProductDetail: '路由策略工作台 · v0.23.0' + imageAlt: 'AsterRouter 路由策略工作台实际界面', actualProduct: '实际产品界面', actualProductDetail: '路由策略工作台 · v0.23.1' }, decision: { overline: 'ONE REQUEST, ONE EXPLAINABLE DECISION', title: '每一次模型请求,都经过同一条企业决策链', @@ -59,7 +59,7 @@ export const marketingEn = { description: 'Place one control plane between enterprise applications and authorized AI providers. Enforce identity, access, supply selection, cost boundaries, and request-level evidence on every call.', primaryAction: 'Open enterprise console', secondaryAction: 'Inspect the decision flow', factsLabel: 'Product facts', factProtocols: 'One gateway for OpenAI, Anthropic, and Gemini-compatible protocols', factEvidence: 'Policy, routing, usage, cost, and traces share one evidence chain', - imageAlt: 'Actual AsterRouter routing policy workbench', actualProduct: 'Actual product interface', actualProductDetail: 'Routing policy workbench · v0.23.0' + imageAlt: 'Actual AsterRouter routing policy workbench', actualProduct: 'Actual product interface', actualProductDetail: 'Routing policy workbench · v0.23.1' }, decision: { overline: 'ONE REQUEST, ONE EXPLAINABLE DECISION', title: 'Every model request follows the same enterprise decision chain', diff --git a/scripts/test-single-origin.sh b/scripts/test-single-origin.sh index 79484b2..5b82df2 100755 --- a/scripts/test-single-origin.sh +++ b/scripts/test-single-origin.sh @@ -59,5 +59,6 @@ curl -fsS "http://127.0.0.1:${PORT}/api/v1/settings/public" | grep -Fq '"demo_mo for path in / /login /console/workbench /portal/overview; do curl -fsS "http://127.0.0.1:${PORT}${path}" | grep -Fq '' done +curl -fsS "http://127.0.0.1:${PORT}/images/asterrouter-routing-workbench.webp" | cmp -s - "${ROOT_DIR}/frontend/dist/images/asterrouter-routing-workbench.webp" echo "Single-origin smoke passed on port ${PORT}."