Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

SoundLog React Native/Expo 앱과 연동되는 Express + TypeScript API 서버입니다.

API 구현 기준은 `openapi/soundlog-api.yaml`이며, 현재 Express와 OpenAPI에 동기화된 61개 HTTP 연산을 제공합니다.
API 구현 기준은 `openapi/soundlog-api.yaml`이며, 현재 Express와 OpenAPI에 동기화된 62개 HTTP 연산을 제공합니다.

리캡, 여행 로그, 여행 세션, GPS 경로를 변경할 때는 [Recap / Log 서버 도메인 계약](docs/recap-log-domain-contract.md)을 먼저 확인합니다.

Expand Down Expand Up @@ -136,7 +136,6 @@ pnpm db:seed # 로컬 seed 데이터 적재
- `POST /v1/auth/logout`
- `GET /v1/me`
- `PATCH /v1/me/profile`
- `POST /v1/me/migrate-local-data`
- Tour / Home / Playlists
- `GET /v1/tour/nearby-places`
- `GET /v1/tour/reverse-geocode`
Expand Down
20 changes: 15 additions & 5 deletions docs/recap-log-domain-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,13 @@ API/DB 호환성 때문에 기술 이름을 즉시 바꾸지 않더라도 제품

### Standalone Recap

1. 클라이언트가 `sessionId` 없이 Product Recap을 저장한다.
2. 서버는 독립 리캡 원본을 저장한다.
3. 공유/지도용 서버 `Recap` row가 필요하면 `sessionId = null`로 생성할 수 있다.
4. 이 결과는 Product Log 목록에서 제외한다.
1. 신규 클라이언트가 `POST /v1/recap-captures`에 `sessionId` 없이 `createStandaloneRecap: true`를 보내 Product Recap을 저장한다.
2. 서버는 독립 리캡 원본과 공유 및 지도용 서버 `Recap` row를 함께 만든다.
3. 응답은 독립 리캡의 공유 식별자를 `recapId`로 반환한다.
4. 같은 idempotency key로 재요청하면 기존 원본과 공유 데이터를 반환한다.
5. 이 결과는 Product Log 목록에서 제외한다.

기존 클라이언트는 이 옵션을 보내지 않으므로 서버가 원본만 만들고 기존의 후속 `POST /v1/recaps` 요청을 처리한다. 신규 클라이언트가 이전 서버를 호출해 응답에 `recapId`가 없으면 같은 멱등성 키로 후속 요청을 한 번 수행한다. 이 호환 계약으로 앱과 서버의 배포 순서에 따른 중복 리캡을 막는다.

### Travel Log

Expand All @@ -54,7 +57,14 @@ API/DB 호환성 때문에 기술 이름을 즉시 바꾸지 않더라도 제품
3. 세션 중 `routePoints`를 동기화한다.
4. 여행 종료 시 같은 `sessionId`의 Product Recap ID만 사용해 서버 `Recap` row를 생성한다.
5. 서버는 일반 여행 로그의 `Recap.travelSessionId`를 여행 세션과 1:1로 저장하고 외부 DTO에는 `sessionId`로 응답한다.
6. 오프라인에서 만든 로컬 세션은 소유한 Product Recap이 확인될 때 종료 세션으로 복구한 뒤 Log를 만든다.
6. 서버 저장에 실패하면 클라이언트는 여행 종료를 완료하지 않고 사용자가 다시 시도할 수 있게 한다.

## Server-first policy

- 서버 저장이 끝난 Product Recap과 Product Log만 사용자 기록으로 취급한다.
- 클라이언트의 로컬 기록 이관 API와 저장 대기 큐를 서버 계약에 포함하지 않는다.
- 서버에 저장된 `MomentLog`는 별도의 동기화 상태를 갖지 않는다. 이전 앱 배포 호환을 위한 응답의 `syncStatus: synced` 상수는 한시적으로 유지한다.
- 활성 여행의 GPS 경로 버퍼는 센서 데이터 복구 목적으로 기기에 남을 수 있지만 서버 기록을 대신하지 않는다.

## Read flow

Expand Down
102 changes: 10 additions & 92 deletions openapi/soundlog-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ tags:
- name: Auth
description: 간편 로그인 및 토큰 관리
- name: Me
description: 사용자 프로필과 로컬 데이터 마이그레이션
description: 사용자 프로필과 계정 데이터 관리
- name: Tour
description: 관광공사 데이터 기반 장소 컨텍스트
- name: Home
Expand Down Expand Up @@ -294,35 +294,6 @@ paths:
"401":
$ref: "#/components/responses/Unauthorized"

/v1/me/migrate-local-data:
post:
tags:
- Me
summary: 로그인 전 로컬 데이터 계정 이관
description: |
로그인 전 이 기기에 저장된 순간 로그, 좋아요/저장 음악, Recap 초안을 로그인 계정으로 이관합니다.
동일한 `Idempotency-Key`로 재시도해도 중복 저장되지 않아야 합니다.
operationId: migrateLocalData
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/LocalDataMigrationRequest"
responses:
"200":
description: 이관 처리 결과
content:
application/json:
schema:
$ref: "#/components/schemas/LocalDataMigrationResponse"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"

/v1/tour/places:
get:
tags:
Expand Down Expand Up @@ -756,7 +727,7 @@ paths:
summary: 리캡 캡처 생성
description: |
카메라 버튼으로 촬영한 사진, 현재 위치, 현재 장소, 재생 중인 음악, 무드 태그를 하나의 리캡으로 저장합니다.
여행모드 중 `sessionId`가 포함되면 해당 여행 로그에 묶이고, `sessionId`가 없으면 낱개 리캡으로 남습니다.
여행모드 중 `sessionId`가 포함되면 해당 여행 로그에 묶입니다. `sessionId`가 없고 `createStandaloneRecap`이 true이면 독립 리캡 공유 데이터를 함께 만들고 응답의 `recapId`로 반환합니다.
MVP 저장 정책상 사진, 위치, 장소, 곡은 모두 선택값입니다. 음악이나 위치가 없어도 리캡 캡처는 생성할 수 있습니다.
사진 업로드는 `multipart/form-data`를 기본으로 정의합니다.
operationId: createRecapCapture
Expand Down Expand Up @@ -2769,6 +2740,9 @@ components:
type: string
format: binary
description: 카메라 촬영 이미지 파일. 사진 없이 음악/장소 리캡만 저장할 때는 생략할 수 있습니다.
createStandaloneRecap:
type: boolean
description: 독립 캡처의 리캡 공유 데이터까지 한 요청에서 생성할지 여부입니다. 새 앱이 true로 전송하며 생략하면 기존 클라이언트 방식으로 캡처만 생성합니다.
createdAt:
type: string
format: date-time
Expand Down Expand Up @@ -2877,12 +2851,14 @@ components:
- createdAt
- moodTags
- recapVisibility
- syncStatus
- templateId
properties:
id:
type: string
example: moment_001
recapId:
type: string
description: 여행모드 밖에서 만든 독립 리캡의 공유 식별자입니다. 여행 세션에 속한 리캡은 여행 종료 전까지 이 값이 없습니다.
photoUrl:
type: string
format: uri
Expand Down Expand Up @@ -2920,11 +2896,10 @@ components:
- camera
syncStatus:
type: string
deprecated: true
description: 이전 앱 버전과의 배포 호환을 위해 한시적으로 반환하는 상수입니다. 신규 클라이언트는 사용하지 않습니다.
enum:
- pending
- synced
- failed
example: synced

RecommendationEventType:
type: string
Expand All @@ -2937,7 +2912,6 @@ components:
- track_save
- track_unsave
- moment_log_saved
- moment_log_sync_failed
- playlist_open
- mood_adjusted
- mood_filter_change
Expand Down Expand Up @@ -3968,54 +3942,6 @@ components:
type: string
description: 무효화할 refresh token. 없으면 클라이언트 로컬 세션만 정리합니다.

LocalDataMigrationRequest:
type: object
required:
- idempotencyKey
properties:
idempotencyKey:
type: string
maxLength: 128
description: 중복 이관 방지용 클라이언트 생성 키
momentLogCount:
type: integer
minimum: 0
default: 0
libraryTrackCount:
type: integer
minimum: 0
default: 0
recapDraftCount:
type: integer
minimum: 0
default: 0

LocalDataMigrationResult:
type: object
required:
- accepted
- migrated
properties:
accepted:
type: boolean
example: true
migrated:
type: object
required:
- momentLogCount
- libraryTrackCount
- recapDraftCount
properties:
momentLogCount:
type: integer
example: 3
libraryTrackCount:
type: integer
example: 6
recapDraftCount:
type: integer
example: 1

AuthToken:
type: object
required:
Expand Down Expand Up @@ -4122,14 +4048,6 @@ components:
profile:
$ref: "#/components/schemas/UserProfile"

LocalDataMigrationResponse:
type: object
required:
- data
properties:
data:
$ref: "#/components/schemas/LocalDataMigrationResult"

LibraryTrackListResponse:
type: object
required:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "MomentLog" DROP COLUMN "syncStatus";
1 change: 0 additions & 1 deletion prisma/models/travel.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ model MomentLog {
travelMode String?
moodTags String[]
source String
syncStatus String
visibility String @default("private")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
Expand Down
3 changes: 0 additions & 3 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,6 @@ export async function seedDemoCommunity() {
placeName: capture.placeName,
sessionId: log.sessionId,
source: 'camera',
syncStatus: 'synced',
templateId: capture.templateId,
trackSnapshot: createDemoTrackSnapshot(track),
travelMode: log.travelMode,
Expand Down Expand Up @@ -844,7 +843,6 @@ export async function seedDemoCommunity() {
photoUrl: capture.imageUrl,
placeName: capture.placeName,
source: 'camera',
syncStatus: 'synced',
templateId: capture.templateId,
trackSnapshot: createDemoTrackSnapshot(track),
userId: user.id,
Expand Down Expand Up @@ -988,7 +986,6 @@ export async function seedDatabase() {
note: log.note,
moodTags: [...log.moodTags],
source: 'camera',
syncStatus: 'synced',
templateId: log.templateId,
travelMode: log.travelMode,
visibility: log.visibility,
Expand Down
5 changes: 0 additions & 5 deletions src/controllers/me.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,4 @@ export const meController = {
const user = requireUser(req);
res.json(dataResponse(await apiService.upsertMyProfile(user.id, req.body)));
},

async migrateLocalData(req: Request, res: Response) {
const user = requireUser(req);
res.json(dataResponse(await apiService.migrateLocalData(user.id, req.body)));
},
};
42 changes: 42 additions & 0 deletions src/controllers/moment-log.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,48 @@ export const momentLogController = {
);
},

async createRecapCapture(req: Request, res: Response) {
const user = requireUser(req);
const photoPath = req.file
? createUploadedFilePublicPath(req.file.filename)
: undefined;
const idempotencyKey = req.header('Idempotency-Key');
const capture = await apiService.createMomentLog(
user.id,
{
...req.body,
photoPath,
},
idempotencyKey,
);

if (req.body.sessionId || !req.body.createStandaloneRecap) {
res.status(201).json(dataResponse(capture));
return;
}

if (!capture.id) {
throw new Error('Created recap capture did not return an id.');
}

const recap = await apiService.createRecap(
user.id,
{
momentLogIds: [capture.id],
templateId: req.body.templateId ?? 'film',
visibility: req.body.visibility ?? 'private',
},
`standalone-recap:${idempotencyKey ?? capture.id}`,
);

res.status(201).json(
dataResponse({
...capture,
recapId: recap.id,
}),
);
},

async updateMomentLog(req: Request, res: Response) {
const user = requireUser(req);

Expand Down
2 changes: 0 additions & 2 deletions src/mock/mock-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ type MockMomentLog = {
note?: string;
sessionId?: string;
source: 'camera';
syncStatus: 'failed' | 'pending' | 'synced';
templateId: string;
trackSnapshot?: MockTrack;
travelMode?: string;
Expand Down Expand Up @@ -254,7 +253,6 @@ function createMockDb() {
note: log.note,
moodTags: [...log.moodTags],
source: 'camera' as const,
syncStatus: 'synced' as const,
templateId: log.templateId,
trackSnapshot: trackById.get(log.trackId),
travelMode: log.travelMode,
Expand Down
9 changes: 1 addition & 8 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,6 @@ export function createApiRouter() {
validate({ body: meValidators.profileBody }),
asyncHandler(meController.upsertProfile),
);
router.post(
'/v1/me/migrate-local-data',
authMiddleware,
validate({ body: meValidators.migrationBody }),
asyncHandler(meController.migrateLocalData),
);

router.get(
'/v1/tour/places',
authMiddleware,
Expand Down Expand Up @@ -188,7 +181,7 @@ export function createApiRouter() {
authMiddleware,
momentPhotoUpload.single('photo'),
validate({ body: momentLogValidators.createBody }),
asyncHandler(momentLogController.createMomentLog),
asyncHandler(momentLogController.createRecapCapture),
);
router.patch(
'/v1/recap-captures/:momentLogId',
Expand Down
20 changes: 1 addition & 19 deletions src/services/mock-soundlog.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ function momentLogToDto(log: (typeof mockDb.momentLogs)[number]) {
travelMode: log.travelMode,
moodTags: log.moodTags,
source: log.source,
syncStatus: log.syncStatus,
syncStatus: 'synced' as const,
});
}

Expand Down Expand Up @@ -945,23 +945,6 @@ export const mockSoundlogService = {
return { deleted: true };
},

async migrateLocalData(_userId: string, input: {
idempotencyKey: string;
libraryTrackCount: number;
momentLogCount: number;
recapDraftCount: number;
}) {
return {
accepted: true,
idempotencyKey: input.idempotencyKey,
migrated: {
libraryTrackCount: input.libraryTrackCount,
momentLogCount: input.momentLogCount,
recapDraftCount: input.recapDraftCount,
},
};
},

async getNearbyPlaces(params: {
lat: number;
limit?: number;
Expand Down Expand Up @@ -1373,7 +1356,6 @@ export const mockSoundlogService = {
travelMode: input.travelMode,
moodTags: input.moodTags,
source: 'camera' as const,
syncStatus: 'synced' as const,
visibility: input.visibility ?? 'private',
};

Expand Down
Loading
Loading