[feat] solution/backend: 이메일 승인 확인 화면에서 발행 사이트로 5초 뒤 자동 이동

사장님 지시: "이메일 승인후에 블로그가 올라가는 페이지에 5초 정도 이후에 연결".
승인만 하고 실제로 어디 올라갔는지 못 찾는 걸 줄인다. 재발행(BUILD 잡)은 몇 분
걸리므로 5초 뒤 반영을 보장하진 않지만, 어디로 가면 되는지는 바로 알려준다.
자동 이동을 못 믿어도 되게 같은 주소를 안내 문구의 링크로도 남긴다.

- post_service._blog_url: 발행된 사이트가 있으면 그 미니블로그 자리(#blog) 주소,
  없으면 None — 호출부가 자동 이동 없이 확인 문구만 보여준다
- router/v1/site/post.py: <meta http-equiv="refresh"> + 안내 링크 추가.
  redirect_url은 서버가 site_payload.publish_url()로 만드는 고정 오리진+slugify
  통과 값이라 사용자 입력은 아니지만, HTML 속성에 꽂는 자리라 html.escape 적용
  (자동 보안 리뷰 지적 반영)
- 신규 테스트 2건(발행 사이트 있을 때/없을 때), 관련 스위트 전체 49 passed

로컬 solution-backend·solution-worker 재빌드해 반영 확인함
This commit is contained in:
김성경 2026-09-22 14:10:52 +09:00
parent df1556f2b1
commit 27426dd51c
4 changed files with 143 additions and 12 deletions

View File

@ -106,7 +106,12 @@
- 본문: 문구 전문 + 승인 링크 + 수정 링크(`blog_jobs._mail_body`) - 본문: 문구 전문 + 승인 링크 + 수정 링크(`blog_jobs._mail_body`)
- **승인 링크**: `GET /v1/site/post/approve?t=<토큰>` — 로그인 없음, 토큰이 신원. **누르는 - **승인 링크**: `GET /v1/site/post/approve?t=<토큰>` — 로그인 없음, 토큰이 신원. **누르는
즉시 승인된다**(확인 화면 없음, 2026-09-17 사장님 지시). 토큰은 32바이트 랜덤 → DB 엔 즉시 승인된다**(확인 화면 없음, 2026-09-17 사장님 지시). 토큰은 32바이트 랜덤 → DB 엔
sha256 만, **단회용 · 그날 자정(KST) 만료**(`blog_service.issue_token`) sha256 만, **단회용 · 그날 자정(KST) 만료**(`blog_service.issue_token`). 승인 확인
화면은 그 업장의 발행된 사이트(미니 블로그 자리, `#blog`)로 5초 뒤 자동 이동한다
(2026-09-22 사장님 지시 — `router/v1/site/post.py _page`, `PostService._blog_url`).
재발행(BUILD 잡)은 몇 분 걸리므로 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다 —
그래도 "어디로 가면 보이는지"는 바로 알려준다. 발행된 사이트가 없으면 자동 이동 없이
확인 문구만 보여준다
- **수정 링크**: `{origin}/blog?placeId=&postId=&auto=<그날짜리 JWT>` — 로그인 흐름이다. - **수정 링크**: `{origin}/blog?placeId=&postId=&auto=<그날짜리 JWT>` — 로그인 흐름이다.
`CreateDayPassToken`(`router/v1/validator/dependencies.py`)이 자정까지만 사는 접근 `CreateDayPassToken`(`router/v1/validator/dependencies.py`)이 자정까지만 사는 접근
토큰을 찍고, 빌더 앱이 그 토큰으로 로그인해 그 글의 편집 모달을 바로 연다 토큰을 찍고, 빌더 앱이 그 토큰으로 로그인해 그 글의 편집 모달을 바로 연다

View File

@ -14,6 +14,7 @@
"바로 발행" — 수정 없이 그대로 승인). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다 — "바로 발행" — 수정 없이 그대로 승인). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다 —
승인은 이 두 경로 중 하나를 명시적으로 눌러야 한다. 승인은 이 두 경로 중 하나를 명시적으로 눌러야 한다.
""" """
import html
from datetime import date from datetime import date
from uuid import UUID from uuid import UUID
@ -30,18 +31,27 @@ from services.post_service import PostService
router = APIRouter(prefix="/v1/site/post", tags=["Site"]) router = APIRouter(prefix="/v1/site/post", tags=["Site"])
owner_router = APIRouter(prefix="/v1/place/{place_id}/post", tags=["Site"]) owner_router = APIRouter(prefix="/v1/place/{place_id}/post", tags=["Site"])
_REDIRECT_DELAY_SEC = 5
_PAGE = """<!doctype html><html lang="ko"><head><meta charset="utf-8"> _PAGE = """<!doctype html><html lang="ko"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"> <meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>{title}</title><style> {redirect}<title>{title}</title><style>
body{{margin:0;background:#f6f5f1;color:#1b1a15;font:17px/1.7 -apple-system,'Apple SD Gothic Neo','Noto Sans KR',sans-serif}} body{{margin:0;background:#f6f5f1;color:#1b1a15;font:17px/1.7 -apple-system,'Apple SD Gothic Neo','Noto Sans KR',sans-serif}}
.wrap{{max-width:34rem;margin:0 auto;padding:40px 20px}} .wrap{{max-width:34rem;margin:0 auto;padding:40px 20px}}
h1{{font-size:21px;margin:0 0 6px}} p{{margin:0 0 14px}} h1{{font-size:21px;margin:0 0 6px}} p{{margin:0 0 14px}}
.meta{{color:#6b7269;font-size:14px}} .meta{{color:#6b7269;font-size:14px}} a{{color:#1b1a15}}
</style></head><body><div class="wrap">{content}</div></body></html>""" </style></head><body><div class="wrap">{content}</div></body></html>"""
def _page(title: str, content: str) -> HTMLResponse: def _page(title: str, content: str, *, redirect_url: str | None = None) -> HTMLResponse:
return HTMLResponse(_PAGE.format(title=title, content=content)) # ★ redirect_url 은 항상 서버가 site_payload.publish_url() 로 만든 값(고정 오리진 +
# slugify 통과 슬러그)이라 사용자 입력이 아니지만, HTML 속성에 그대로 꽂는 자리라
# escape 를 걸어 둔다 — 이 함수가 나중에 다른 값을 받게 되더라도 안전하게.
redirect = (
f'<meta http-equiv="refresh" content="{_REDIRECT_DELAY_SEC};url={html.escape(redirect_url, quote=True)}">'
if redirect_url else ""
)
return HTMLResponse(_PAGE.format(title=title, content=content, redirect=redirect))
def _expired_page() -> HTMLResponse: def _expired_page() -> HTMLResponse:
@ -56,7 +66,20 @@ async def approve_page(t: str = Query(min_length=8, max_length=200), service: Po
result = await service.decide(t, skip=False) result = await service.decide(t, skip=False)
if not result["success"]: if not result["success"]:
return _expired_page() return _expired_page()
return _page("올렸습니다", f"<h1>{result['message']}</h1><p class='meta'>사이트에 반영되기까지 몇 분 걸립니다.</p>") redirect_url = result.get("redirect_url")
# ★ 재발행은 몇 분 걸린다(BUILD 잡) — 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다.
# 그래도 "어디로 가면 보이는지" 를 알려주는 게 사장님 입장에서 "눌렀는데 어디 갔지" 보다
# 낫다(2026-09-22, 사장님 지시). 링크 자체는 안내 문구에도 남겨 자동 이동을 못 믿어도 되게 한다.
extra = (
f"<p class='meta'>{_REDIRECT_DELAY_SEC}초 뒤 자동으로 이동합니다. "
f"바로 가려면 <a href='{html.escape(redirect_url, quote=True)}'>여기</a>를 눌러주세요.</p>"
if redirect_url else ""
)
return _page(
"올렸습니다",
f"<h1>{result['message']}</h1><p class='meta'>사이트에 반영되기까지 몇 분 걸립니다.</p>{extra}",
redirect_url=redirect_url,
)
@owner_router.get(path="", response_model=Res_MyPosts, summary="이번 달(또는 고른 달) 생성된 글 목록") @owner_router.get(path="", response_model=Res_MyPosts, summary="이번 달(또는 고른 달) 생성된 글 목록")

View File

@ -15,15 +15,15 @@ from crud.job_crud import JobQueue
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud.post_crud import PostCRUD from crud.post_crud import PostCRUD
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import jobs as jobs_table, place_posts, places from common.database.model.models import jobs as jobs_table, place_posts, places, sites
from common.enums import DBWRType, ErrorType, JobStatus, JobType, PostStatus from common.enums import DBWRType, ErrorType, JobStatus, JobType, PostStatus, SiteStatus
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import Res_WebPacketProtocol, UserInfo from common.models.gmodel import Res_WebPacketProtocol, UserInfo
from common.utils.gtime import GTime from common.utils.gtime import GTime
from router.v1.site.protocol import ( from router.v1.site.protocol import (
GenerationBatch, PostData, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts, GenerationBatch, PostData, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts,
) )
from services import blog_jobs, blog_service, social_service from services import blog_jobs, blog_service, site_payload, social_service
from services.job_service import enqueue_job from services.job_service import enqueue_job
EXPIRED = "처리할 수 없는 링크입니다." EXPIRED = "처리할 수 없는 링크입니다."
@ -76,7 +76,36 @@ class PostService:
return {"success": True, "message": SKIPPED} return {"success": True, "message": SKIPPED}
await self._approve_and_publish(post_id, place_id) await self._approve_and_publish(post_id, place_id)
return {"success": True, "message": APPROVED} return {
"success": True,
"message": APPROVED,
"redirect_url": await self._blog_url(place_id),
}
async def _blog_url(self, place_id) -> str | None:
"""이 업장의 발행된 사이트에서 미니 블로그가 보이는 자리. 승인 확인 화면이 몇 초
뒤 여기로 자동 연결한다(2026-09-22, 사장님 지시) — 사장님이 승인만 하고 실제로
어디에 올라갔는지 못 찾는 걸 줄인다. 사이트가 없거나 아직 미발행이면 None —
호출부가 자동 연결 없이 확인 문구만 보여준다."""
def query(session):
return session.execute(
select(places, sites)
.join(sites, sites.place_id == places.place_id)
.where(
places.place_id == place_id,
places.deleted == False, # noqa: E712
sites.deleted == False, # noqa: E712
sites.status == SiteStatus.PUBLISHED.value,
)
.limit(1)
)
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
row = result.first() if result is not None else None
if not row:
return None
place, site = row
return f"{site_payload.publish_url(place, site)}#blog"
async def _load_place(self, user_info: UserInfo, place_id: str): async def _load_place(self, user_info: UserInfo, place_id: str):
err_type, place = await DB_SESSION_MNG.execute_lambda( err_type, place = await DB_SESSION_MNG.execute_lambda(

View File

@ -14,8 +14,8 @@ from datetime import timezone
from sqlalchemy import text from sqlalchemy import text
from common.enums import JobType, PlaceCategory, PostStatus, PostTopicKind from common.enums import JobType, PlaceCategory, PostStatus, PostTopicKind, SiteStatus
from services import blog_service from services import blog_service, site_payload
BODY = ( BODY = (
"비가 한 차례 지나간 뒤 마당 돌이 검게 젖었습니다. 이런 날에는 대청마루에 앉아 빗소리만 들어도 " "비가 한 차례 지나간 뒤 마당 돌이 검게 젖었습니다. 이런 날에는 대청마루에 앉아 빗소리만 들어도 "
@ -51,6 +51,53 @@ def test_filter_drops_same_topic_twice_in_one_batch():
assert "주제 중복" in dropped[0][1] assert "주제 중복" in dropped[0][1]
def test_build_prompt_falls_back_to_stay_wording_for_unmapped_category():
"""업종별로 분기할 수 있는 구조만 갖춘다 — 숙소 외 업종은 아직 실구현 전이라 숙소 문구로 대체한다."""
stay = blog_service.build_prompt(
place_name="테스트펜션", region="군산", topic_kind=PostTopicKind.WEATHER.value,
material="맑음인 날", used_topics=[], place_category=PlaceCategory.LODGING.value,
)
restaurant = blog_service.build_prompt(
place_name="테스트식당", region="군산", topic_kind=PostTopicKind.WEATHER.value,
material="맑음인 날", used_topics=[], place_category=PlaceCategory.RESTAURANT.value,
)
assert "숙소" in stay
assert "숙소" in restaurant # 음식점 전용 문구가 아직 없어 숙소 문구로 폴백한다
async def test_generate_one_forwards_place_category_into_the_prompt(monkeypatch):
"""generate_one 은 place_category 를 build_prompt 로 그대로 넘긴다 — 링크는 여기서
붙이지 않는다(호출부의 길이 게이트가 이 반환값에 그대로 걸리기 때문에, 붙이면
게이트가 링크까지 세어 정상 문구도 버려진다). 공급자는 provider.active() 를 통해
고르므로(Gemini 로 고정하지 않는다) 가짜 공급자로 검증한다."""
from services.llm import provider
from services.llm.types import LlmResult, Usage
captured = {}
class FakeLlm:
DEFAULT_MODEL = "fake-model"
@staticmethod
def is_configured():
return True
@staticmethod
async def generate(client, model, *, prompt, temperature=0.2, **kwargs):
captured["prompt"] = prompt
return LlmResult(json=None, text=BODY, usage=Usage())
monkeypatch.setattr(provider, "active", lambda: FakeLlm)
result = await blog_service.generate_one(
place_name="테스트식당", region="군산", topic_kind=PostTopicKind.WEATHER.value,
material="맑음인 날", used_topics=[], place_category=PlaceCategory.RESTAURANT.value,
)
assert result == (BODY, "fake-model")
assert "숙소" in captured["prompt"] # 음식점 전용 문구가 아직 없어 숙소 문구로 폴백한다
async def _seed_post(db_engine, owner_id, *, status=PostStatus.SENT, expires_days=14): async def _seed_post(db_engine, owner_id, *, status=PostStatus.SENT, expires_days=14):
place_id, post_id = uuid.uuid4(), uuid.uuid4() place_id, post_id = uuid.uuid4(), uuid.uuid4()
token, token_hash, expires = blog_service.issue_token() token, token_hash, expires = blog_service.issue_token()
@ -94,6 +141,33 @@ async def test_get_approves_immediately(client, db_engine, owner_id):
assert res.status_code == 200 assert res.status_code == 200
assert "올렸습니다" in res.text assert "올렸습니다" in res.text
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value assert await _status(db_engine, post_id) == PostStatus.APPROVED.value
# 발행된 사이트가 없으면(이 테스트) 자동 이동 없이 확인 문구만 보여준다.
assert "meta http-equiv=\"refresh\"" not in res.text
async def test_approve_page_redirects_to_the_published_blog_section(client, db_engine, owner_id):
"""승인 확인 화면은 5초 뒤 그 업장의 발행된 사이트(미니 블로그 자리)로 자동 이동한다
(2026-09-22, 사장님 지시: "이메일 승인후에 블로그가 올라가는 페이지에 5초 정도
이후에 연결되도록"). 자동 이동을 못 믿어도 되게 같은 주소를 링크로도 남긴다."""
token, post_id = await _seed_post(db_engine, owner_id)
async with db_engine.begin() as conn:
place_id = (
await conn.execute(text("SELECT place_id FROM place_posts WHERE post_id=:p"), {"p": post_id})
).scalar_one()
await conn.execute(
text(
"INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"
),
{"sid": uuid.uuid4(), "pid": place_id, "dom": "blog-redirect-test", "st": SiteStatus.PUBLISHED.value},
)
res = await client.get(f"/v1/site/post/approve?t={token}")
assert res.status_code == 200
assert "올렸습니다" in res.text
expected_url = site_payload.publish_origin() + "/s/blog-redirect-test#blog"
assert f'<meta http-equiv="refresh" content="5;url={expected_url}">' in res.text
assert f"href='{expected_url}'" in res.text
async def test_approve_link_is_single_use(client, db_engine, owner_id): async def test_approve_link_is_single_use(client, db_engine, owner_id):