fix(m5): close render completion loop — worker HTTP callback + /renders static serving

Worker published only Redis pub/sub events; backend status stayed QUEUED
forever (callback endpoint never called — gap masked by earlier Gemini 429).
- worker.py: POST /api/internal/render/callback (X-Worker-Token) after each
  job with imageUrl=/renders/{key}, schemaHash, modelVersion, errorMessage
- backend: serve /renders/** from RENDER_OUTPUT_DIR + security permit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 20:52:54 +09:00
parent 9099f7d0d4
commit b5d29e6061
4 changed files with 62 additions and 6 deletions

View File

@ -56,8 +56,8 @@ public class SecurityConfig {
"/api/auth/otp/enroll/setup", "/api/auth/otp/enroll/verify", "/api/auth/otp/enroll/setup", "/api/auth/otp/enroll/verify",
"/api/auth/register", "/api/auth/password/forgot", "/api/auth/register", "/api/auth/password/forgot",
"/api/auth/password/reset", "/api/auth/password/reset",
// 공개 콘텐츠(로그인 슬라이드 ) + 업로드 정적 파일 // 공개 콘텐츠(로그인 슬라이드 ) + 업로드/렌더 산출 정적 파일
"/api/public/**", "/uploads/**", "/api/public/**", "/uploads/**", "/renders/**",
// F099 인바운드 웹훅 수신(인증 없음 토큰 + HMAC 서명 이중 검증으로 방어) // F099 인바운드 웹훅 수신(인증 없음 토큰 + HMAC 서명 이중 검증으로 방어)
"/api/webhooks/in/**").permitAll() "/api/webhooks/in/**").permitAll()
.anyRequest().authenticated()) .anyRequest().authenticated())

View File

@ -15,9 +15,12 @@ import java.nio.file.Paths;
public class WebMvcConfig implements WebMvcConfigurer { public class WebMvcConfig implements WebMvcConfigurer {
private final String uploadDir; private final String uploadDir;
private final String renderOutputDir;
public WebMvcConfig(@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) { public WebMvcConfig(@Value("${kintex.upload.dir:./data/uploads}") String uploadDir,
@Value("${kintex.render.output-dir:./output/visualizations}") String renderOutputDir) {
this.uploadDir = uploadDir; this.uploadDir = uploadDir;
this.renderOutputDir = renderOutputDir;
} }
@Override @Override
@ -26,5 +29,10 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addResourceHandler("/uploads/**") registry.addResourceHandler("/uploads/**")
.addResourceLocations(location) .addResourceLocations(location)
.setCachePeriod(3600); .setCachePeriod(3600);
// 나노바나나 워커 산출 이미지(NANOBANANA_OUTPUT_DIR와 동일 루트) 결과는 불변 파일(UUID )이라 캐시 허용.
String renders = Paths.get(renderOutputDir).toAbsolutePath().normalize().toUri().toString();
registry.addResourceHandler("/renders/**")
.addResourceLocations(renders)
.setCachePeriod(3600);
} }
} }

View File

@ -108,6 +108,8 @@ kintex:
# RenderJob Redis 큐 이름(나노바나나 워커가 소비) — visualizer와 합의한 계약 # RenderJob Redis 큐 이름(나노바나나 워커가 소비) — visualizer와 합의한 계약
queue-key: ${RENDER_QUEUE_KEY:kintex:renderjob:queue} queue-key: ${RENDER_QUEUE_KEY:kintex:renderjob:queue}
event-quota-default: ${RENDER_EVENT_QUOTA:500} event-quota-default: ${RENDER_EVENT_QUOTA:500}
# 워커 산출 이미지 루트(NANOBANANA_OUTPUT_DIR와 동일 경로 주입) — /renders/** 정적 서빙
output-dir: ${RENDER_OUTPUT_DIR:./output/visualizations}
logging: logging:
level: level:

View File

@ -79,6 +79,11 @@ OUTPUT_DIR = os.environ.get("NANOBANANA_OUTPUT_DIR", "output/visualizations")
#: BLPOP 폴링 간격(초) — None 무한 대기 대신 짧은 폴링으로 graceful stop 가능. #: BLPOP 폴링 간격(초) — None 무한 대기 대신 짧은 폴링으로 graceful stop 가능.
BLPOP_TIMEOUT_S = 5 BLPOP_TIMEOUT_S = 5
#: 백엔드 완료 콜백(계약: RenderWorkerCallbackController). 토큰 미주입 시 콜백 생략(스모크/로컬).
BACKEND_CALLBACK_BASE = os.environ.get("BACKEND_CALLBACK_BASE", "").rstrip("/")
RENDER_WORKER_TOKEN = os.environ.get("RENDER_WORKER_TOKEN", "")
CALLBACK_TIMEOUT_S = 10
def is_live() -> bool: def is_live() -> bool:
"""G1 게이트: 실 Gemini 호출 조건(PLANNING R12). """G1 게이트: 실 Gemini 호출 조건(PLANNING R12).
@ -434,7 +439,7 @@ class RenderWorker:
# 단건 처리 (테스트·스모크에서 직접 호출 가능 — Redis 불필요) # 단건 처리 (테스트·스모크에서 직접 호출 가능 — Redis 불필요)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def process_job(self, job: RenderJob) -> RenderResult: def process_job(self, job: RenderJob) -> RenderResult:
"""RenderJob 1건 처리: 렌더 → 스토리지 적재 → 완료 이벤트 발행.""" """RenderJob 1건 처리: 렌더 → 스토리지 적재 → 완료 이벤트 발행 → 백엔드 콜백."""
try: try:
image = self._render(job) image = self._render(job)
key = ObjectStore.key_for(job) key = ObjectStore.key_for(job)
@ -443,13 +448,54 @@ class RenderWorker:
job_id=job.job_id, status="DONE", image_ref=image_ref, meta=image.metadata job_id=job.job_id, status="DONE", image_ref=image_ref, meta=image.metadata
) )
self.publisher.publish(self._event("renderjob.completed", job, result)) self.publisher.publish(self._event("renderjob.completed", job, result))
return result
except Exception as e: # noqa: BLE001 — 분류 후 친화 에러(비밀 미노출) except Exception as e: # noqa: BLE001 — 분류 후 친화 에러(비밀 미노출)
error = self._classify_error(e) error = self._classify_error(e)
result = RenderResult(job_id=job.job_id, status="FAILED", error=error) result = RenderResult(job_id=job.job_id, status="FAILED", error=error)
self.publisher.publish(self._event("renderjob.failed", job, result)) self.publisher.publish(self._event("renderjob.failed", job, result))
self._callback_backend(result)
return result return result
@staticmethod
def _callback_backend(result: RenderResult) -> None:
"""백엔드 상태 갱신 콜백(POST /api/internal/render/callback, X-Worker-Token).
pub/sub WS 릴레이용 신호일 DB/조회 상태의 정본 갱신은 콜백이 담당한다.
실패해도 렌더 결과를 무효화하지 않는다(로그만, 토큰·스택트레이스 미노출).
"""
if not BACKEND_CALLBACK_BASE or not RENDER_WORKER_TOKEN:
print(f"[callback] skipped (base/token 미주입) job={result.job_id}")
return
image_url = None
if result.image_ref and result.image_ref.get("key"):
image_url = "/renders/" + str(result.image_ref["key"])
payload = {
"jobId": result.job_id,
"status": result.status,
"imageUrl": image_url,
"schemaHash": result.meta.get("schema_hash"),
"modelVersion": result.meta.get("model_version"),
"errorMessage": (result.error or {}).get("message"),
}
url = BACKEND_CALLBACK_BASE + "/api/internal/render/callback"
try:
import urllib.request
req = urllib.request.Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"Content-Type": "application/json",
"X-Worker-Token": RENDER_WORKER_TOKEN,
},
method="POST",
)
with urllib.request.urlopen(req, timeout=CALLBACK_TIMEOUT_S) as resp:
print(f"[callback] job={result.job_id} status={result.status} http={resp.status}")
except Exception as e: # noqa: BLE001 — 콜백 실패는 비치명(재시도는 후속 조회로 보완)
print(
f"[callback] FAILED job={result.job_id}: {type(e).__name__}", file=sys.stderr
)
@staticmethod @staticmethod
def _classify_error(e: Exception) -> dict: def _classify_error(e: Exception) -> dict:
"""예외 → {code,message}. 스택트레이스·키 미포함(PLANNING §6-5).""" """예외 → {code,message}. 스택트레이스·키 미포함(PLANNING §6-5)."""