from fastapi import Depends from common.enums import ErrorType, JobType, UserRole from common.models.gmodel import UserInfo from common.logger import LOG from crud.job_crud import JobQueue from router.v1.job.protocol import JobData, Res_Job, Res_JobOps class JobService: """작업 큐 조회·관리. 적재는 각 도메인 서비스가 JobQueue 로 직접 한다. 수집·비전분석·빌드는 몇 분 걸린다 — API 는 잡만 넣고 즉시 응답하고, 클라이언트는 GET /v1/job/{id} 를 폴링한다.""" def __init__(self, queue: JobQueue = Depends(JobQueue)): self.queue = queue async def get_job(self, job_id: str, user_info: UserInfo | None = None) -> Res_Job: """잡 단건. ★ 주인이 아니면 **없는 것으로** 답한다(JOB_NOT_FOUND). ★ 잡 id 하나만 알면 남의 작업 결과가 열렸다(실측 2026-09-15). BUILD 결과에는 site_id · 게이트 상세(비어 있는 필수 항목 목록) · payload 경로가 들어 있다. 예전에는 COPY 만 주인을 봤는데, 가려야 할 것은 잡 종류가 아니라 **남의 사업장**이다. ★ 주인을 알 수 없는 잡(내부 동기화·노래 등 payload 에 owner_user_id 가 없는 것)은 사장님에게 열지 않는다 — '주인이 없으니 아무나' 가 아니라 '확인할 수 없으니 닫는다' 다. ★ user_info 가 None 인 호출은 내부 경로다(운영자 전용 requeue) — 그쪽은 이미 RequireDeveloper 가 막는다. """ res = Res_Job() row = await self.queue.get(job_id) if row is None: res.result.SetResult(ErrorType.JOB_NOT_FOUND) return res payload = row.get("payload") or {} if user_info is not None and (user_info.role or 0) < UserRole.DEVELOPER.value: owner = payload.get("owner_user_id") if owner is None or str(owner) != str(user_info.user_id): res.result.SetResult(ErrorType.JOB_NOT_FOUND) return res fields = {k: v for k, v in row.items() if k in JobData.model_fields} # 어느 사업장의 작업인지는 종류를 가리지 않고 싣는다 — 화면이 "이 화면의 작업이 맞나" 를 # 이 값으로 판단한다(useGenerationJob 의 wrongJob). if payload.get("place_id") is not None: fields["place_id"] = payload.get("place_id") res.job = JobData(**fields) return res async def ops(self) -> Res_JobOps: snap = await self.queue.ops() return Res_JobOps(**{k: v for k, v in snap.items() if k in Res_JobOps.model_fields}) async def requeue(self, job_id: str) -> Res_Job: """DEAD 잡 재큐(운영자 액션).""" try: requeued = await self.queue.requeue(job_id) except Exception as ex: # 같은 dedupe_key 의 활성 잡이 이미 있으면 부분 유니크 위반. LOG.w(f"[job] requeue 실패 {job_id}: {type(ex).__name__}") res = Res_Job() res.result.SetResult(ErrorType.JOB_ALREADY_QUEUED) return res if requeued is None: res = Res_Job() res.result.SetResult(ErrorType.JOB_NOT_DEAD) return res return await self.get_job(job_id) async def enqueue_job( queue: JobQueue, job_type: JobType, payload: dict, dedupe_key: str | None = None, priority: int = 100, ) -> tuple[str | None, bool]: """도메인 서비스가 잡을 넣을 때 쓰는 공용 진입점. 반환: (job_id, 새로 만들었는가). 활성 중복이면 기존 잡의 id 와 False 를 돌려준다 — 같은 사업장 수집을 두 번 눌러도 잡이 두 번 돌지 않는다.""" job_id = await queue.enqueue(job_type.value, payload, priority=priority, dedupe_key=dedupe_key) if job_id is not None: return job_id, True if dedupe_key: existing = await queue.find_active(dedupe_key) if existing: return existing["job_id"], False return None, False