feat(render): booth render history gallery (G-09)
History endpoint over render_job (DONE only) with covering index V54; design studio shows a per-booth history gallery with watermark intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
23ff7d1799
commit
216f3720a1
@ -55,4 +55,13 @@ public class RenderJobController {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.listByBooth(boothId, shot));
|
||||
}
|
||||
|
||||
/** GET /booths/{boothId}/render-history — 완료(DONE) 렌더 이력 갤러리(SCR-06, G-09). 최신순 영속 이력. */
|
||||
@GetMapping("/booths/{boothId}/render-history")
|
||||
public ApiResponse<List<RenderJobDto>> history(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String boothId) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.listHistoryByBooth(boothId));
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,9 @@ public interface RenderJobService {
|
||||
/** 부스 단위 잡 목록(SCR-12 갤러리) — 내구 저장(DB) 기반. */
|
||||
List<RenderJobDto> listByBooth(String boothId, String shotPreset);
|
||||
|
||||
/** 부스 완료(DONE) 렌더 이력(SCR-06 이력 갤러리) — 내구 저장(DB) 기반, 최신순. */
|
||||
List<RenderJobDto> listHistoryByBooth(String boothId);
|
||||
|
||||
/** 워커 완료/실패 콜백 처리 — 상태 갱신 + WebSocket 푸시(+ 성공 시 쿼터 차감). */
|
||||
RenderJobDto handleWorkerCallback(WorkerCallbackRequest callback);
|
||||
}
|
||||
|
||||
@ -155,6 +155,24 @@ public class RenderJobServiceImpl implements RenderJobService {
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RenderJobDto> listHistoryByBooth(String boothId) {
|
||||
List<Map<String, Object>> rows = renderJobMapper.findHistoryByBooth(boothId);
|
||||
List<RenderJobDto> out = new java.util.ArrayList<>();
|
||||
if (rows == null) {
|
||||
return out;
|
||||
}
|
||||
for (Map<String, Object> r : rows) {
|
||||
out.add(new RenderJobDto(
|
||||
str(r.get("jobId")), str(r.get("boothId")), str(r.get("shotPreset")),
|
||||
str(r.get("status")), str(r.get("imageUrl")),
|
||||
str(r.get("schemaHash")), str(r.get("modelVersion")),
|
||||
true, RenderJobDto.WATERMARK_TEXT, RenderJobDto.NOTICE,
|
||||
null, str(r.get("createdAt"))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderJobDto handleWorkerCallback(WorkerCallbackRequest callback) {
|
||||
RenderJobDto prev = readState(callback.jobId());
|
||||
|
||||
@ -27,6 +27,9 @@ public interface RenderJobMapper {
|
||||
List<Map<String, Object>> findByBooth(@Param("boothId") String boothId,
|
||||
@Param("shotPreset") String shotPreset);
|
||||
|
||||
/** 부스 단위 완료(DONE) 렌더 이력(SCR-06 이력 갤러리) — 최신순. 이미지 URL 있는 성공 결과만. */
|
||||
List<Map<String, Object>> findHistoryByBooth(@Param("boothId") String boothId);
|
||||
|
||||
/** 행사별 성공 생성 건수(쿼터 산정). */
|
||||
int countSucceededByEvent(@Param("eventId") String eventId);
|
||||
}
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
-- V54 — 부스 렌더 이력(SCR-06 이력 갤러리, G-09) 조회 최적화.
|
||||
-- findHistoryByBooth: WHERE booth_id = ? AND status = 'DONE' ORDER BY created_at DESC.
|
||||
-- 기존 idx_render_job_booth(booth_id, shot_preset)는 status/정렬을 커버하지 못함 → 전용 커버링 인덱스 추가.
|
||||
-- 멱등(IF NOT EXISTS) — 재적용/기존 데이터 무영향. 신규 테이블·컬럼 없음(render_job 재사용).
|
||||
CREATE INDEX IF NOT EXISTS idx_render_job_history
|
||||
ON render_job (booth_id, status, created_at DESC);
|
||||
@ -48,6 +48,23 @@
|
||||
ORDER BY created_at DESC
|
||||
</select>
|
||||
|
||||
<!-- 부스 완료(DONE) 렌더 이력(SCR-06 이력 갤러리). 이미지 URL 있는 성공 결과만·최신순. -->
|
||||
<select id="findHistoryByBooth" resultType="map">
|
||||
SELECT id AS "jobId",
|
||||
booth_id AS "boothId",
|
||||
shot_preset AS "shotPreset",
|
||||
status,
|
||||
image_url AS "imageUrl",
|
||||
schema_hash AS "schemaHash",
|
||||
model_version AS "modelVersion",
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
|
||||
FROM render_job
|
||||
WHERE booth_id = #{boothId}
|
||||
AND status = 'DONE'
|
||||
AND image_url IS NOT NULL
|
||||
ORDER BY created_at DESC
|
||||
</select>
|
||||
|
||||
<!-- 행사별 성공(DONE) 건수(쿼터 정본 산정). -->
|
||||
<select id="countSucceededByEvent" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
REROOM_STYLES,
|
||||
downscaleImage,
|
||||
reroomApi,
|
||||
renderHistoryApi,
|
||||
MAX_UPLOAD_BYTES,
|
||||
} from './reroomApi';
|
||||
import './studio.css';
|
||||
@ -83,6 +84,20 @@ export function BoothDesignStudioPage() {
|
||||
const [reroomError, setReroomError] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
// ── 렌더 이력 갤러리(G-09) — 완료 렌더잡 영속 이력 ──
|
||||
const [history, setHistory] = useState<RenderJobDto[]>([]);
|
||||
const [historyError, setHistoryError] = useState(false);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (!eventId || !boothId) return;
|
||||
try {
|
||||
setHistory(await renderHistoryApi.list(eventId, boothId));
|
||||
setHistoryError(false);
|
||||
} catch {
|
||||
setHistoryError(true); // 미배선/미존재 시 갤러리 숨김(회귀 방지)
|
||||
}
|
||||
}, [eventId, boothId]);
|
||||
|
||||
// 초기 설계안 로드(501이면 degraded 기본 스펙 유지).
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@ -136,6 +151,21 @@ export function BoothDesignStudioPage() {
|
||||
};
|
||||
}, [refPreview]);
|
||||
|
||||
// 렌더 이력 초기 로드.
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
// 잡 완료(DONE) 감지 시 이력 재조회 — 새 결과가 갤러리에 즉시 반영되도록.
|
||||
const doneKey = Object.values(jobs)
|
||||
.filter((j) => j.status === 'DONE')
|
||||
.map((j) => j.jobId)
|
||||
.concat(reroomJob?.status === 'DONE' && reroomJob.jobId ? [reroomJob.jobId] : [])
|
||||
.join(',');
|
||||
useEffect(() => {
|
||||
if (doneKey) void loadHistory();
|
||||
}, [doneKey, loadHistory]);
|
||||
|
||||
// 참조 이미지 선택 → 1024px 다운스케일 → 즉시 업로드(referenceId 확보).
|
||||
const handleRefFile = useCallback(
|
||||
async (file: File | undefined | null) => {
|
||||
@ -235,11 +265,7 @@ export function BoothDesignStudioPage() {
|
||||
setNotice('행사 이미지 생성 쿼터가 소진되었습니다.');
|
||||
break;
|
||||
}
|
||||
if (err instanceof ApiRequestError && err.code === 'NOT_IMPLEMENTED') {
|
||||
setNotice('렌더 이력 저장이 준비 중입니다(큐잉만 동작).');
|
||||
} else {
|
||||
setNotice(err instanceof ApiRequestError ? err.message : '예상 사진 생성 중 오류.');
|
||||
}
|
||||
setNotice(err instanceof ApiRequestError ? err.message : '예상 사진 생성 중 오류.');
|
||||
}
|
||||
}
|
||||
setGenerating(false);
|
||||
@ -679,6 +705,36 @@ export function BoothDesignStudioPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 렌더 이력 갤러리(G-09) — 완료된 예상 사진의 영속 이력 */}
|
||||
{!historyError && history.length > 0 && (
|
||||
<section className="kx-studio__history" aria-label="렌더 이력">
|
||||
<div className="kx-studio__history-head">
|
||||
<AiLabel>렌더 이력</AiLabel>
|
||||
<span className="kx-studio__history-sub">
|
||||
완료된 예상 사진 {history.length}건 · 최신순 (AI 생성 · 시공 기준은 도면)
|
||||
</span>
|
||||
</div>
|
||||
<div className="kx-studio__history-grid">
|
||||
{history.map((job) => (
|
||||
<figure className="kx-studio__history-item" key={job.jobId}>
|
||||
<AiImage
|
||||
imageUrl={job.imageUrl}
|
||||
status={job.status}
|
||||
watermarkText={job.watermarkText}
|
||||
notice={job.notice}
|
||||
shotLabel={job.shotPreset}
|
||||
alt={`${job.shotPreset} 렌더 이력`}
|
||||
/>
|
||||
<figcaption className="kx-studio__history-cap tnum">
|
||||
{job.shotPreset}
|
||||
{job.createdAt ? ` · ${fmtHistoryDate(job.createdAt)}` : ''}
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 하단 고정 바: 규정 사전검증 요약 + 컨펌 CTA */}
|
||||
<footer className="kx-studio__footer">
|
||||
<div className="kx-studio__precheck">
|
||||
@ -755,6 +811,14 @@ const ZONE_LABEL: Record<string, string> = {
|
||||
storage: '창고',
|
||||
};
|
||||
|
||||
/** 이력 시각(ISO-8601 UTC) → 로컬 "MM-DD HH:mm". 파싱 실패 시 원문 앞부분. */
|
||||
function fmtHistoryDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso.slice(0, 16).replace('T', ' ');
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function Accordion({
|
||||
title,
|
||||
open,
|
||||
|
||||
@ -52,6 +52,18 @@ export const reroomApi = {
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* G-09 부스 렌더 이력 — 완료(DONE) 렌더잡의 영속 갤러리(백엔드 render_job 테이블).
|
||||
* endpoints.ts 무수정 원칙에 따라 화면 폴더 클라이언트로 둔다.
|
||||
* GET /api/events/{eventId}/booths/{boothId}/render-history → RenderJobDto[](최신순)
|
||||
*/
|
||||
export const renderHistoryApi = {
|
||||
list: (eventId: string, boothId: string) =>
|
||||
api.get<RenderJobDto[]>(
|
||||
`/api/events/${encodeURIComponent(eventId)}/booths/${encodeURIComponent(boothId)}/render-history`,
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* 클라이언트 다운스케일(ReRoomAI Studio.handleImageFile 등가) — 긴 쪽 1024px, JPEG 0.85.
|
||||
* 전송량·모델 비용·응답시간 동시 절감. 실패 시 원본 Blob 반환(방어).
|
||||
|
||||
@ -506,3 +506,47 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 렌더 이력 갤러리(G-09) ── */
|
||||
.kx-studio__history {
|
||||
margin: var(--space-4) var(--space-5) 0;
|
||||
padding: var(--space-4);
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e5e8ef);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.kx-studio__history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.kx-studio__history-sub {
|
||||
font-size: var(--fs-micro);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-studio__history-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.kx-studio__history-item {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.kx-studio__history-cap {
|
||||
font-size: var(--fs-nano);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.kx-studio__history {
|
||||
margin: var(--space-4) var(--space-4) 0;
|
||||
}
|
||||
.kx-studio__history-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user