- Firestore Timestamp가 res.json으로 그대로 나가 {_seconds,_nanoseconds}로 직렬화되던 문제 수정
- 앱의 포인트 내역(/reward/ledger)과 예측 기록(/stats/history) 크래시 원인 제거
- 클라 소비 7개 핸들러(reward/attendance/prediction/user/stats/kbo/chat)의 모든 응답에 명시적 DTO 타입 도입
- 날짜 와이어 형식을 순수 UTC ISO 8601(...Z)로 통일 — 채팅의 기존 KST(+09:00) 출력도 UTC로 전환
- DTO는 필드를 명시적으로 나열해 조립 (스프레드 덤프 제거) — 문서에 새 Timestamp 필드가 생겨도 다시 새지 않는다
- 재사용되는 주문 형태만 toOrderDto로 분리, 나머지는 응답 경계에서 직접 조립
- Firestore 저장 형식은 변경하지 않음. 출석 idempotent 재요청 경로는 저장된 Timestamp/문자열을 모두 처리
- DTO 직렬화 회귀 테스트 추가 (_seconds 부재, UTC ISO 형식, optional 키 생략, 미지 필드 차단)
92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
import { onRequest } from "firebase-functions/https";
|
|
import type { Request } from "firebase-functions/https";
|
|
import { requireAuth } from "../middleware/auth";
|
|
import { HttpError, sendError } from "../middleware/errors";
|
|
import {
|
|
getMessages,
|
|
getQuota,
|
|
getSuggestions,
|
|
reportMessage,
|
|
sendMessage,
|
|
} from "../services/chatService";
|
|
import type {
|
|
ChatHistoryPageDto,
|
|
ChatQuotaDto,
|
|
ChatReportDto,
|
|
ChatSendResultDto,
|
|
ChatSuggestionsDto,
|
|
} from "../types/dto/chatDto";
|
|
|
|
/**
|
|
* AI 채팅(짹) 엔드포인트(§3). `/chat/*` 전체가 인증 필요 API다.
|
|
*
|
|
* - POST /chat/messages 메시지 전송(비스트리밍, §3.1)
|
|
* - GET /chat/messages 대화 이력 조회(§3.2)
|
|
* - GET /chat/quota 오늘 잔여 횟수(§3.3)
|
|
* - POST /chat/messages/{messageId}/report 메시지 신고(§3.4)
|
|
* - GET /chat/suggestions 추천 질문(§3.5)
|
|
*
|
|
* 기본 AI 벤더는 Vertex AI(Gemini, ADC 인증 — 별도 키 불필요)다(§8).
|
|
* 벤더 전환은 config/chat.provider.name으로: "mock" | "vertex" | "anthropic".
|
|
* anthropic 전환 시 `firebase functions:secrets:set ANTHROPIC_API_KEY` 등록과 함께
|
|
* 아래 onRequest 옵션에 `secrets: ["ANTHROPIC_API_KEY"]`를 추가한다(§8.2).
|
|
*/
|
|
/** 401 응답에 에러 코드 UNAUTHENTICATED를 포함한다(§3.1 에러 표 계약). */
|
|
async function requireChatAuth(req: Request): Promise<string> {
|
|
try {
|
|
return await requireAuth(req);
|
|
} catch (err) {
|
|
if (err instanceof HttpError && err.status === 401 && !err.code) {
|
|
throw new HttpError(401, err.message, "UNAUTHENTICATED");
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export const chat = onRequest({ timeoutSeconds: 60 }, async (req, res) => {
|
|
const segs = req.path.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
|
|
try {
|
|
if (segs[0] === "messages" && segs.length === 1) {
|
|
const uid = await requireChatAuth(req);
|
|
if (req.method === "POST") {
|
|
const result: ChatSendResultDto = await sendMessage(uid, req.body ?? {});
|
|
res.status(200).json(result);
|
|
return;
|
|
}
|
|
if (req.method === "GET") {
|
|
const cursor = req.query.cursor != null ? String(req.query.cursor) : undefined;
|
|
const limit = req.query.limit != null ? String(req.query.limit) : undefined;
|
|
const result: ChatHistoryPageDto = await getMessages(uid, cursor, limit);
|
|
res.status(200).json(result);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (segs[0] === "messages" && segs.length === 3 && segs[2] === "report" && req.method === "POST") {
|
|
const uid = await requireChatAuth(req);
|
|
const result: ChatReportDto = await reportMessage(uid, segs[1], req.body ?? {});
|
|
res.status(200).json(result);
|
|
return;
|
|
}
|
|
|
|
if (segs[0] === "quota" && req.method === "GET") {
|
|
const uid = await requireChatAuth(req);
|
|
const result: ChatQuotaDto = await getQuota(uid);
|
|
res.status(200).json(result);
|
|
return;
|
|
}
|
|
|
|
if (segs[0] === "suggestions" && req.method === "GET") {
|
|
const uid = await requireChatAuth(req);
|
|
const result: ChatSuggestionsDto = await getSuggestions(uid);
|
|
res.status(200).json(result);
|
|
return;
|
|
}
|
|
|
|
res.status(404).json({ error: `Unknown: ${req.method} ${req.path}` });
|
|
} catch (err) {
|
|
sendError(res, err);
|
|
}
|
|
});
|