음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
309
apps/api/app/routes/share.py
Normal file
309
apps/api/app/routes/share.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"""Public share/unfurl routes.
|
||||
|
||||
공개 공유 URL은 세션 권한을 우회하지 않는다. 학습자가 명시적으로 생성한
|
||||
토큰으로 app.session_share_link의 sanitized payload만 읽고, 원문 축어록은 조회하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import session_persistence
|
||||
|
||||
router = APIRouter(tags=["share"])
|
||||
|
||||
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{32,160}$")
|
||||
_SHARE_HEADERS = {
|
||||
"cache-control": "public, max-age=300",
|
||||
"x-robots-tag": "noindex, noarchive, max-snippet:160",
|
||||
}
|
||||
|
||||
|
||||
class PublicSessionShareResponse(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
summary: str
|
||||
imageUrl: str
|
||||
appUrl: str
|
||||
clientName: str
|
||||
persona: str
|
||||
date: str
|
||||
durationLabel: str
|
||||
reachedPhase: str
|
||||
sessionSignal: str
|
||||
reviewReady: bool = False
|
||||
goodMoments: list[str] = Field(default_factory=list)
|
||||
growthPoints: list[str] = Field(default_factory=list)
|
||||
worksheetHighlights: list[dict[str, str]] = Field(default_factory=list)
|
||||
privacy: str = ""
|
||||
|
||||
|
||||
def _safe_payload(payload: dict[str, Any]) -> PublicSessionShareResponse:
|
||||
return PublicSessionShareResponse(
|
||||
title=str(payload.get("title") or "Vignette 회기 리뷰"),
|
||||
description=str(payload.get("description") or "AI 심리상담 시뮬레이션 회기 리뷰 요약"),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
imageUrl=str(payload.get("imageUrl") or ""),
|
||||
appUrl=str(payload.get("appUrl") or ""),
|
||||
clientName=str(payload.get("clientName") or "내담자"),
|
||||
persona=str(payload.get("persona") or ""),
|
||||
date=str(payload.get("date") or ""),
|
||||
durationLabel=str(payload.get("durationLabel") or ""),
|
||||
reachedPhase=str(payload.get("reachedPhase") or ""),
|
||||
sessionSignal=str(payload.get("sessionSignal") or ""),
|
||||
reviewReady=bool(payload.get("reviewReady")),
|
||||
goodMoments=[str(item) for item in payload.get("goodMoments") or []][:3],
|
||||
growthPoints=[str(item) for item in payload.get("growthPoints") or []][:3],
|
||||
worksheetHighlights=[
|
||||
{
|
||||
"section": str(item.get("section") or ""),
|
||||
"label": str(item.get("label") or ""),
|
||||
"value": str(item.get("value") or ""),
|
||||
}
|
||||
for item in (payload.get("worksheetHighlights") or [])
|
||||
if isinstance(item, dict)
|
||||
][:4],
|
||||
privacy=str(payload.get("privacy") or ""),
|
||||
)
|
||||
|
||||
|
||||
async def _load_share_or_404(token: str) -> PublicSessionShareResponse:
|
||||
if not _TOKEN_RE.match(token):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="share not found")
|
||||
record = await session_persistence.load_public_session_share(
|
||||
session_persistence.share_token_hash(token)
|
||||
)
|
||||
if record is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="share not found")
|
||||
return _safe_payload(dict(record.get("payload") or {}))
|
||||
|
||||
|
||||
def _request_url(request: Request) -> str:
|
||||
return str(request.url)
|
||||
|
||||
|
||||
def _json_ld(share: PublicSessionShareResponse, url: str) -> str:
|
||||
payload = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CreativeWork",
|
||||
"name": share.title,
|
||||
"description": share.description,
|
||||
"url": url,
|
||||
"image": share.imageUrl,
|
||||
"inLanguage": "ko-KR",
|
||||
"educationalUse": "AI counseling simulation review",
|
||||
"isAccessibleForFree": True,
|
||||
"provider": {
|
||||
"@type": "Organization",
|
||||
"name": "Vignette",
|
||||
},
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def _meta(name: str, content: str, *, prop: bool = False) -> str:
|
||||
attr = "property" if prop else "name"
|
||||
return f'<meta {attr}="{html.escape(name)}" content="{html.escape(content, quote=True)}">'
|
||||
|
||||
|
||||
def _list_items(values: list[str]) -> str:
|
||||
if not values:
|
||||
return "<li>아직 공유 가능한 항목이 없습니다.</li>"
|
||||
return "".join(f"<li>{html.escape(value)}</li>" for value in values)
|
||||
|
||||
|
||||
def _worksheet_items(values: list[dict[str, str]]) -> str:
|
||||
if not values:
|
||||
return "<li>사례개념화 워크시트 핵심값은 아직 비어 있습니다.</li>"
|
||||
return "".join(
|
||||
"<li>"
|
||||
f"<b>{html.escape(item['label'])}</b>"
|
||||
f"<span>{html.escape(item['value'])}</span>"
|
||||
"</li>"
|
||||
for item in values
|
||||
)
|
||||
|
||||
|
||||
def _share_html(share: PublicSessionShareResponse, url: str) -> str:
|
||||
title = html.escape(share.title)
|
||||
description = html.escape(share.description)
|
||||
image = html.escape(share.imageUrl, quote=True)
|
||||
app_url = html.escape(share.appUrl or "https://vignette.chanpaca.net", quote=True)
|
||||
return f"""<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
{_meta("robots", "noindex, noarchive, max-snippet:160")}
|
||||
{_meta("description", share.description)}
|
||||
{_meta("og:type", "article", prop=True)}
|
||||
{_meta("og:site_name", "Vignette", prop=True)}
|
||||
{_meta("og:title", share.title, prop=True)}
|
||||
{_meta("og:description", share.description, prop=True)}
|
||||
{_meta("og:url", url, prop=True)}
|
||||
{_meta("og:image", share.imageUrl, prop=True)}
|
||||
{_meta("og:image:width", "1672", prop=True)}
|
||||
{_meta("og:image:height", "941", prop=True)}
|
||||
{_meta("twitter:card", "summary_large_image")}
|
||||
{_meta("twitter:title", share.title)}
|
||||
{_meta("twitter:description", share.description)}
|
||||
{_meta("twitter:image", share.imageUrl)}
|
||||
<script type="application/ld+json">{_json_ld(share, url)}</script>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light dark;
|
||||
--bg: #f8f5ef;
|
||||
--surface: #ffffff;
|
||||
--ink: #172424;
|
||||
--muted: #65706d;
|
||||
--accent: #2f6f63;
|
||||
--line: #e4ddd2;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", sans-serif;
|
||||
line-height: 1.55;
|
||||
}}
|
||||
main {{
|
||||
width: min(920px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 44px 0;
|
||||
}}
|
||||
.hero {{
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 40px rgba(23, 36, 36, .08);
|
||||
}}
|
||||
.hero img {{
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1672 / 941;
|
||||
object-fit: cover;
|
||||
}}
|
||||
.body {{ padding: 28px; }}
|
||||
.eyebrow {{
|
||||
margin: 0 0 8px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
h1 {{ margin: 0; font-size: clamp(26px, 4vw, 42px); line-height: 1.2; letter-spacing: 0; }}
|
||||
.desc {{ margin: 14px 0 0; color: var(--muted); font-size: 17px; }}
|
||||
.facts {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 24px 0;
|
||||
}}
|
||||
.fact {{ border: 1px solid var(--line); border-radius: 10px; padding: 12px; background: color-mix(in srgb, var(--surface) 82%, var(--bg)); }}
|
||||
.fact b {{ display: block; font-size: 12px; color: var(--muted); }}
|
||||
.fact span {{ display: block; margin-top: 4px; font-weight: 750; }}
|
||||
.grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
|
||||
section {{ border-top: 1px solid var(--line); padding-top: 18px; }}
|
||||
h2 {{ margin: 0 0 10px; font-size: 17px; }}
|
||||
ul {{ margin: 0; padding-left: 20px; color: var(--ink); }}
|
||||
li + li {{ margin-top: 8px; }}
|
||||
li span {{ display: block; color: var(--muted); }}
|
||||
.privacy {{ margin-top: 22px; color: var(--muted); font-size: 13px; }}
|
||||
.cta {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
margin-top: 22px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 800;
|
||||
}}
|
||||
@media (max-width: 720px) {{
|
||||
main {{ width: min(100% - 20px, 920px); padding: 20px 0; }}
|
||||
.body {{ padding: 20px; }}
|
||||
.facts, .grid {{ grid-template-columns: 1fr; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article class="hero">
|
||||
<img src="{image}" alt="">
|
||||
<div class="body">
|
||||
<p class="eyebrow">Vignette session review</p>
|
||||
<h1>{title}</h1>
|
||||
<p class="desc">{description}</p>
|
||||
<div class="facts" aria-label="회기 요약">
|
||||
<div class="fact"><b>날짜</b><span>{html.escape(share.date or "-")}</span></div>
|
||||
<div class="fact"><b>시간</b><span>{html.escape(share.durationLabel or "-")}</span></div>
|
||||
<div class="fact"><b>도달 단계</b><span>{html.escape(share.reachedPhase or "-")}</span></div>
|
||||
<div class="fact"><b>상태</b><span>{html.escape(share.sessionSignal or "-")}</span></div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<section>
|
||||
<h2>강점 요약</h2>
|
||||
<ul>{_list_items(share.goodMoments)}</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>개선 요약</h2>
|
||||
<ul>{_list_items(share.growthPoints)}</ul>
|
||||
</section>
|
||||
</div>
|
||||
<section style="margin-top:18px">
|
||||
<h2>사례개념화 핵심값</h2>
|
||||
<ul>{_worksheet_items(share.worksheetHighlights)}</ul>
|
||||
</section>
|
||||
<p class="privacy">{html.escape(share.privacy)}</p>
|
||||
<a class="cta" href="{app_url}">Vignette 열기</a>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/share/session/{token}", response_class=HTMLResponse, name="get_public_session_share")
|
||||
async def get_public_session_share(token: str, request: Request) -> HTMLResponse:
|
||||
share = await _load_share_or_404(token)
|
||||
return HTMLResponse(_share_html(share, _request_url(request)), headers=_SHARE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/share/session/{token}/summary", response_model=PublicSessionShareResponse)
|
||||
async def get_public_session_share_summary(token: str, response: Response) -> PublicSessionShareResponse:
|
||||
for key, value in _SHARE_HEADERS.items():
|
||||
response.headers[key] = value
|
||||
return await _load_share_or_404(token)
|
||||
|
||||
|
||||
@router.get("/robots.txt", include_in_schema=False)
|
||||
async def robots_txt() -> PlainTextResponse:
|
||||
body = "\n".join(
|
||||
[
|
||||
"User-agent: *",
|
||||
"Disallow: /auth/",
|
||||
"Disallow: /admin/",
|
||||
"Disallow: /sessions/",
|
||||
"Disallow: /teacher/",
|
||||
"Disallow: /users/",
|
||||
"Disallow: /eval/",
|
||||
"Disallow: /voice/",
|
||||
"Disallow: /kb/",
|
||||
"Disallow: /share/",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return PlainTextResponse(body, headers={"cache-control": "public, max-age=3600"})
|
||||
Loading…
Add table
Add a link
Reference in a new issue