diff --git a/docs/PRODUCT_READINESS.md b/docs/PRODUCT_READINESS.md
index e0384e5..18a64a9 100644
--- a/docs/PRODUCT_READINESS.md
+++ b/docs/PRODUCT_READINESS.md
@@ -193,9 +193,17 @@
> client-side 直連 Gemini → 繞過後端計費 + 繞過 review gate。盤點見
> [EDUSTUDIO_UI_WIRING.md](EDUSTUDIO_UI_WIRING.md)。多為前端工。
-- [ ] 🔴 **U-1 `/studio` 直連 Gemini 改走後端**(offline,前端 + 確認後端端點)— 把 `/studio`
- 仍 client-side 呼叫 Gemini 的路徑改打 `/api/generate` 等後端端點(後端大多現成),堵住
- 「繞過計費 + 繞過審查」漏洞。或者若 U-3 直接退場 /studio,則本項併入「功能搬進 /app」。
+- [x] 🔴 **U-1 `/studio` 直連 Gemini 改走後端 → 退場**(offline)— ✅ 2026-06-15 完成
+ (走「退場」分支)。`/studio` 的原 infoCard 前端 **client-side 直連 Gemini**(繞過後端計費 +
+ 繞過 review gate),其**源碼不在本 repo**,無法「改走後端」;且 U-2 已把唯一「大」缺口(海報/
+ 圖卡逐區 refine + 區域選擇)在 `/app` 補齊對等。故依拍板「先補 /app 對等再退場 /studio」執行
+ 退場:`server/main.py` 移除 `/studio` 的 StaticFiles mount + SPA fallback,改**無條件** `GET
+ /studio` 與 `GET /studio/{path}` → **307 轉址 `/app/`**(暫時轉址=不被瀏覽器永久快取、保留
+ 反悔餘地;無條件註冊=即使殘留 `web/studio` build 也不再被服務)。一舉關掉「繞過計費 + 繞過
+ 審查」漏洞、舊書籤/連結不 404。`landing.html` 移除 `/studio` legacy 卡片;banner helper 移除
+ now-dead 的 `studio` 參數(僅 `/ui` 仍用)。補/改 `tests/test_legacy_banner.py`(banner 注入
+ 4 測 + `/studio` 根與深連結 307→`/app/` 2 測,TestClient 不依賴 build 產物)。本機全套 2706
+ passed(剩 3 個 QR/journal 字型像素為容器缺 Noto CJK 假象,CI 權威)。
- [x] 🟡 **U-2 `/app` 補齊 `/studio` 缺的視覺功能 — 含逐區 refine**(offline,**拍板要做
2026-06-07**)— 盤點顯示 `/app` 視覺站缺「海報/圖卡逐區 refine、區域選擇」(後端 refine
圖卡未移植 = 唯一「大」缺口)。**定案:移植後端逐區 refine + 前端區域選擇 UI**(不是首發
@@ -215,9 +223,12 @@
regenerateImage}`、讀 `data.section`、patch 回 `result.data.sections[idx]`。**純前端一檔**
(`frontend/edustudio/app.jsx`),`npm run build`(vite/node22)編譯通過;後端 8 測
(`test_infocards_refine.py`)已涵蓋端點,視覺由人後驗。至此 U-2 ①②③ 到齊。
-- [ ] 🟡 **U-3 `/ui` `/studio` 標 legacy / 退場**(offline)— 在舊 UI 頁頂加 banner「此介面
- 將退場,請用 /app」+ README/介面表標 legacy。完全移除 build 產物等 `/app` 功能對等確認後
- 再做(避免反悔)。
+- [x] 🟡 **U-3 `/ui` `/studio` 標 legacy / 退場**(offline)— ✅ 完成。banner 部分於 **PR #33
+ (2026-06-08)** 落地:`/ui`·`/studio` 頁頂注入「此介面即將退場,請改用 /app」退場 banner +
+ `landing.html` 舊版介面卡片標 `legacy`(先前 checkbox 漏勾,此處補正)。`/studio` 進一步於
+ **U-1(2026-06-15)退場**=307 轉址 `/app/`(已過 `/app` 功能對等確認,呼應本項「完全移除
+ build 產物等對等確認後再做」)。`/ui`(影片站,已與 `/app` 對等)暫保留 banner 過渡,待後續
+ 小 PR 視情況比照退場。
- [ ] 🟡 **U-4 成本面板真實化收尾**(offline,接 Phase 4)— 現況部分 mock。等 Phase 4 計費
補完後,把成本面板數字接真 `/api/usage`,移除「示意」假數字。
- [ ] 🟢 **U-5 發布站多語上傳驅動**(GATE)— 現況多語版本選擇只是視覺。要驅動真多語上傳碰
diff --git a/server/main.py b/server/main.py
index e8151f0..6fb8578 100644
--- a/server/main.py
+++ b/server/main.py
@@ -63,40 +63,34 @@
# 對齊 vite.config.ts 的 build outDir
WEB_DIST = PROJECT_ROOT / "web" / "dist"
-# eduStudio 合併 C-4: infoCard 前端 build(base=/studio/)serve 在此。
-WEB_STUDIO = PROJECT_ROOT / "web" / "studio"
# eduStudio 合併 C-4: 統一 app(Claude Design 設計、infoCard React19 build,base=/app/)。
WEB_EDUAPP = PROJECT_ROOT / "web" / "eduapp"
# eduStudio 合併 C-4 方案 A: 統一入口 landing(外觀可獨立替換,待 Claude Design 重做)。
LANDING_PAGE = PROJECT_ROOT / "server" / "static" / "landing.html"
-def _legacy_banner_html(*, studio: bool) -> str:
- """legacy UI (/ui, /studio) 頂部退場提示 banner(U-3)。
+def _legacy_banner_html() -> str:
+ """legacy UI (`/ui`) 頂部退場提示 banner(U-3)。
收斂到 `/app` 單一介面前的過渡步驟:在舊介面頂部固定一條提示,導使用者改用
- `/app`。`/studio` 仍 client-side 直連 Gemini(繞過後端計費 + review gate,見 U-1),
- 故額外標警告。純前端提示、不移除任何功能、可逆(避免反悔)。
+ `/app`。純前端提示、不移除任何功能、可逆(避免反悔)。
+
+ (`/studio` 已於 U-1 退場 → 改 307 轉址至 `/app/`,不再注入 banner。)
"""
- extra = (
- "(此介面直連 Gemini、未走後端計費與 review 審查)"
- if studio
- else ""
- )
return (
'
'
- "⚠ 此介面為
legacy(即將退場)" + extra + ",請改用統一介面 "
+ "⚠ 此介面為
legacy(即將退場),請改用統一介面 "
'
/app。'
"
"
)
-def _inject_legacy_banner(html: str, *, studio: bool) -> str:
+def _inject_legacy_banner(html: str) -> str:
"""把 legacy banner 注入 index.html `` 起始處(找不到 body 則前置)。"""
- banner = _legacy_banner_html(studio=studio)
+ banner = _legacy_banner_html()
lowered = html.lower()
body_idx = lowered.find(" str:
return html[: close + 1] + banner + html[close + 1 :]
-def _serve_legacy_spa(root: Path, full_path: str, *, studio: bool) -> FileResponse | HTMLResponse:
+def _serve_legacy_spa(root: Path, full_path: str) -> FileResponse | HTMLResponse:
"""legacy SPA 服務:實檔直接回,index.html / deep-link 回注入 banner 的 HTML。"""
target = (root / full_path).resolve()
try:
@@ -117,7 +111,7 @@ def _serve_legacy_spa(root: Path, full_path: str, *, studio: bool) -> FileRespon
if target.is_file() and target.name != "index.html":
return FileResponse(target)
html = (root / "index.html").read_text(encoding="utf-8")
- return HTMLResponse(_inject_legacy_banner(html, studio=studio))
+ return HTMLResponse(_inject_legacy_banner(html))
def create_app() -> FastAPI:
@@ -179,23 +173,17 @@ def create_app() -> FastAPI:
async def spa_fallback(full_path: str) -> Response:
# 實檔 (例如 /ui/vite.svg) 直接回;index.html / deep-link 回注入退場
# banner (U-3) 的 HTML。防 path traversal 在 helper 內。
- return _serve_legacy_spa(WEB_DIST, full_path, studio=False)
-
- # eduStudio 合併 C-4: infoCard 前端 (vite build --base=/studio/) 服務 /studio/*。
- # 同 /ui 模式:mount assets + SPA fallback。前端目前仍 client-side 呼叫 Gemini
- # (key 由 UI 設定);「前端改打本 server /api」為後續步驟。
- if WEB_STUDIO.exists():
- studio_assets = WEB_STUDIO / "assets"
- if studio_assets.exists():
- app.mount(
- "/studio/assets",
- StaticFiles(directory=str(studio_assets)),
- name="studio-assets",
- )
-
- @app.get("/studio/{full_path:path}", include_in_schema=False)
- async def studio_spa(full_path: str) -> Response:
- return _serve_legacy_spa(WEB_STUDIO, full_path, studio=True)
+ return _serve_legacy_spa(WEB_DIST, full_path)
+
+ # U-1: `/studio` 退場。原 infoCard 前端 client-side 直連 Gemini(繞過後端計費 +
+ # review gate),其源碼不在本 repo,且視覺功能已於 U-2 在 `/app` 補齊對等(逐區
+ # refine / 區域選擇)。故不再 serve 該 SPA,改一律 307 轉址到 `/app/`:關掉繞過
+ # 漏洞、舊書籤/連結不 404。無條件註冊(即使殘留 web/studio build 也不會被服務)。
+ # 307(暫時)而非 301/308:不被瀏覽器永久快取,保留反悔餘地。
+ @app.get("/studio", include_in_schema=False)
+ @app.get("/studio/{full_path:path}", include_in_schema=False)
+ async def studio_sunset(full_path: str = "") -> RedirectResponse:
+ return RedirectResponse(url="/app/", status_code=307)
# eduStudio 合併 C-4: 統一 app(Claude Design 設計)serve 在 /app/*(同 /ui 模式)。
if WEB_EDUAPP.exists():
diff --git a/server/static/landing.html b/server/static/landing.html
index f38f734..0b60390 100644
--- a/server/static/landing.html
+++ b/server/static/landing.html
@@ -67,7 +67,6 @@
.badge-soon { font-size: 11px; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; margin-left: 8px; }
.card.legacy { opacity: 0.82; }
.badge-legacy { font-size: 11px; color: #f0b86e; border: 1px solid #b45309; background: rgba(180,83,9,0.14); border-radius: 999px; padding: 2px 8px; margin-left: 8px; }
- .card .warn { color: #f0b86e; font-size: 12px; margin-top: 8px; }
.hero-cta {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
text-decoration: none; color: inherit; margin-bottom: 28px;
@@ -106,14 +105,6 @@
/ui
-
- 🎨
- 圖卡 · 簡報 · 漫畫legacy
- 把內容轉成資訊圖卡、教學簡報、漫畫分鏡(Gemini 生成)。
- ⚠ 直連 Gemini,未走後端計費與 review 審查,請改用 /app。
- /studio
-
-
🌐
在地化 · 翻譯
diff --git a/tests/test_legacy_banner.py b/tests/test_legacy_banner.py
index b4182b8..648765b 100644
--- a/tests/test_legacy_banner.py
+++ b/tests/test_legacy_banner.py
@@ -1,31 +1,30 @@
-"""U-3: legacy UI 退場 banner 注入測試。
+"""U-3 / U-1: legacy UI 退場測試。
-`/ui` `/studio` build 產物在測試環境通常不存在(route 不註冊),故直接單元測注入
-helper。banner 是收斂到 `/app` 前的非破壞性過渡提示。
+U-3:`/ui` 頂部注入「即將退場、改用 /app」banner(非破壞性過渡提示)。
+U-1:`/studio`(原 client-side 直連 Gemini、繞過後端計費 + review gate)退場 →
+一律 307 轉址到 `/app/`。
+
+`/ui` build 產物在測試環境通常不存在(route 不註冊),故 banner 直接單元測注入
+helper;`/studio` 轉址不依賴 build 產物(無條件註冊),用 TestClient 驗。
"""
+from fastapi.testclient import TestClient
+
from server.main import (
_inject_legacy_banner,
_legacy_banner_html,
+ create_app,
)
def test_banner_links_to_app():
- html = _legacy_banner_html(studio=False)
+ html = _legacy_banner_html()
assert 'href="/app/"' in html
assert "legacy" in html
-def test_studio_banner_warns_about_bypass():
- """/studio banner 額外標「繞過後端計費/審查」(U-1 漏洞警示)。"""
- studio = _legacy_banner_html(studio=True)
- ui = _legacy_banner_html(studio=False)
- assert "計費" in studio and "審查" in studio
- assert "計費" not in ui
-
-
def test_inject_after_body_tag():
html = ""
- out = _inject_legacy_banner(html, studio=False)
+ out = _inject_legacy_banner(html)
# banner 在 之後、#root 之前
body_close = out.find(">", out.find(" body_close
@@ -36,13 +35,30 @@ def test_inject_after_body_tag():
def test_inject_without_body_prepends():
html = ""
- out = _inject_legacy_banner(html, studio=True)
+ out = _inject_legacy_banner(html)
assert out.startswith("" in out
+
+
+def test_studio_root_redirects_to_app():
+ """U-1: `/studio` 退場 → 307 轉址到 `/app/`,不再 serve 直連 Gemini 的 SPA。"""
+ client = TestClient(create_app(), follow_redirects=False)
+ resp = client.get("/studio")
+ assert resp.status_code == 307
+ assert resp.headers["location"] == "/app/"
+
+
+def test_studio_deeplink_redirects_to_app():
+ """任何 `/studio/*` 子路徑(含舊書籤/asset)一律導回 `/app/`,不 404。"""
+ client = TestClient(create_app(), follow_redirects=False)
+ for path in ("/studio/", "/studio/poster", "/studio/assets/index.js"):
+ resp = client.get(path)
+ assert resp.status_code == 307, path
+ assert resp.headers["location"] == "/app/", path