From b5d29e6061816085587139883fdb85d0af0400cf Mon Sep 17 00:00:00 2001 From: zio Date: Tue, 14 Jul 2026 20:52:54 +0900 Subject: [PATCH] =?UTF-8?q?fix(m5):=20close=20render=20completion=20loop?= =?UTF-8?q?=20=E2=80=94=20worker=20HTTP=20callback=20+=20/renders=20static?= =?UTF-8?q?=20serving?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../zioinfo/kintex/config/SecurityConfig.java | 4 +- .../zioinfo/kintex/config/WebMvcConfig.java | 10 +++- .../src/main/resources/application.yml | 2 + tools/nanobanana/worker.py | 52 +++++++++++++++++-- 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java b/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java index 0579eae..1cb8b3d 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/config/SecurityConfig.java @@ -56,8 +56,8 @@ public class SecurityConfig { "/api/auth/otp/enroll/setup", "/api/auth/otp/enroll/verify", "/api/auth/register", "/api/auth/password/forgot", "/api/auth/password/reset", - // 공개 콘텐츠(로그인 슬라이드 등) + 업로드 정적 파일 - "/api/public/**", "/uploads/**", + // 공개 콘텐츠(로그인 슬라이드 등) + 업로드/렌더 산출 정적 파일 + "/api/public/**", "/uploads/**", "/renders/**", // F099 인바운드 웹훅 수신(인증 없음 — 토큰 + HMAC 서명 이중 검증으로 방어) "/api/webhooks/in/**").permitAll() .anyRequest().authenticated()) diff --git a/src/backend/src/main/java/com/zioinfo/kintex/config/WebMvcConfig.java b/src/backend/src/main/java/com/zioinfo/kintex/config/WebMvcConfig.java index 34ca0e5..5fbdfb4 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/config/WebMvcConfig.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/config/WebMvcConfig.java @@ -15,9 +15,12 @@ import java.nio.file.Paths; public class WebMvcConfig implements WebMvcConfigurer { 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.renderOutputDir = renderOutputDir; } @Override @@ -26,5 +29,10 @@ public class WebMvcConfig implements WebMvcConfigurer { registry.addResourceHandler("/uploads/**") .addResourceLocations(location) .setCachePeriod(3600); + // 나노바나나 워커 산출 이미지(NANOBANANA_OUTPUT_DIR와 동일 루트) — 결과는 불변 파일(UUID 키)이라 캐시 허용. + String renders = Paths.get(renderOutputDir).toAbsolutePath().normalize().toUri().toString(); + registry.addResourceHandler("/renders/**") + .addResourceLocations(renders) + .setCachePeriod(3600); } } diff --git a/src/backend/src/main/resources/application.yml b/src/backend/src/main/resources/application.yml index 008b7e6..c07e38e 100644 --- a/src/backend/src/main/resources/application.yml +++ b/src/backend/src/main/resources/application.yml @@ -108,6 +108,8 @@ kintex: # RenderJob Redis 큐 이름(나노바나나 워커가 소비) — visualizer와 합의한 계약 queue-key: ${RENDER_QUEUE_KEY:kintex:renderjob:queue} event-quota-default: ${RENDER_EVENT_QUOTA:500} + # 워커 산출 이미지 루트(NANOBANANA_OUTPUT_DIR와 동일 경로 주입) — /renders/** 정적 서빙 + output-dir: ${RENDER_OUTPUT_DIR:./output/visualizations} logging: level: diff --git a/tools/nanobanana/worker.py b/tools/nanobanana/worker.py index 51c8258..4642ea3 100644 --- a/tools/nanobanana/worker.py +++ b/tools/nanobanana/worker.py @@ -79,6 +79,11 @@ OUTPUT_DIR = os.environ.get("NANOBANANA_OUTPUT_DIR", "output/visualizations") #: BLPOP 폴링 간격(초) — None 무한 대기 대신 짧은 폴링으로 graceful stop 가능. 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: """G1 게이트: 실 Gemini 호출 조건(PLANNING R12). @@ -434,7 +439,7 @@ class RenderWorker: # 단건 처리 (테스트·스모크에서 직접 호출 가능 — Redis 불필요) # ------------------------------------------------------------------ def process_job(self, job: RenderJob) -> RenderResult: - """RenderJob 1건 처리: 렌더 → 스토리지 적재 → 완료 이벤트 발행.""" + """RenderJob 1건 처리: 렌더 → 스토리지 적재 → 완료 이벤트 발행 → 백엔드 콜백.""" try: image = self._render(job) key = ObjectStore.key_for(job) @@ -443,12 +448,53 @@ class RenderWorker: job_id=job.job_id, status="DONE", image_ref=image_ref, meta=image.metadata ) self.publisher.publish(self._event("renderjob.completed", job, result)) - return result except Exception as e: # noqa: BLE001 — 분류 후 친화 에러(비밀 미노출) error = self._classify_error(e) result = RenderResult(job_id=job.job_id, status="FAILED", error=error) self.publisher.publish(self._event("renderjob.failed", job, result)) - return result + self._callback_backend(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 def _classify_error(e: Exception) -> dict: