Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions docs/PRODUCT_READINESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**(不是首發
Expand All @@ -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)— 現況多語版本選擇只是視覺。要驅動真多語上傳碰
Expand Down
54 changes: 21 additions & 33 deletions server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
'<div role="alert" style="position:fixed;top:0;left:0;right:0;z-index:2147483647;'
"background:#b45309;color:#fff;padding:10px 16px;text-align:center;"
'font:14px/1.4 system-ui,-apple-system,"Noto Sans TC",sans-serif;'
'box-shadow:0 1px 4px rgba(0,0,0,.35)">'
"⚠ 此介面為 <b>legacy(即將退場)</b>" + extra + ",請改用統一介面 "
"⚠ 此介面為 <b>legacy(即將退場)</b>,請改用統一介面 "
'<a href="/app/" style="color:#fff;font-weight:700;text-decoration:underline">/app</a>。'
"</div>"
)


def _inject_legacy_banner(html: str, *, studio: bool) -> str:
def _inject_legacy_banner(html: str) -> str:
"""把 legacy banner 注入 index.html `<body>` 起始處(找不到 body 則前置)。"""
banner = _legacy_banner_html(studio=studio)
banner = _legacy_banner_html()
lowered = html.lower()
body_idx = lowered.find("<body")
if body_idx == -1:
Expand All @@ -107,7 +101,7 @@ def _inject_legacy_banner(html: str, *, studio: bool) -> 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:
Expand All @@ -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:
Expand Down Expand Up @@ -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():
Expand Down
9 changes: 0 additions & 9 deletions server/static/landing.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -106,14 +105,6 @@
<span class="path">/ui</span>
</a>

<a class="card legacy" href="/studio/">
<div class="icon">🎨</div>
<div class="title">圖卡 · 簡報 · 漫畫<span class="badge-legacy">legacy</span></div>
<div class="desc">把內容轉成資訊圖卡、教學簡報、漫畫分鏡(Gemini 生成)。</div>
<div class="warn">⚠ 直連 Gemini,未走後端計費與 review 審查,請改用 /app。</div>
<span class="path">/studio</span>
</a>

<a class="card" href="/docs#/localization">
<div class="icon">🌐</div>
<div class="title">在地化 · 翻譯</div>
Expand Down
46 changes: 31 additions & 15 deletions tests/test_legacy_banner.py
Original file line number Diff line number Diff line change
@@ -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 = "<html><body class=\"x\"><div id=\"root\"></div></body></html>"
out = _inject_legacy_banner(html, studio=False)
out = _inject_legacy_banner(html)
# banner 在 <body ...> 之後、#root 之前
body_close = out.find(">", out.find("<body"))
assert out.index("/app/") > body_close
Expand All @@ -36,13 +35,30 @@ def test_inject_after_body_tag():

def test_inject_without_body_prepends():
html = "<div id=\"root\"></div>"
out = _inject_legacy_banner(html, studio=True)
out = _inject_legacy_banner(html)
assert out.startswith("<div role=\"alert\"")
assert html in out


def test_inject_handles_uppercase_body():
html = "<HTML><BODY><main></main></BODY></HTML>"
out = _inject_legacy_banner(html, studio=False)
out = _inject_legacy_banner(html)
assert "/app/" in out
assert "<main></main>" 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
Loading