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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ JWT_EXPIRES_IN_SECONDS=3600
# Optional. Production accepts HTTPS endpoints only; otherwise seed recommendations are used.
ML_RECOMMENDATION_API_URL=
ML_RECOMMENDATION_TIMEOUT_MS=5000
# 앱 심사 대응용 신고 운영 API 비밀키입니다. 운영에서는 32자 이상으로 설정합니다.
MODERATION_ADMIN_KEY=
# cloud_logging은 GCP 구조화 로그를 사용합니다. webhook은 아래 HTTPS 주소가 필요합니다.
MODERATION_ALERT_MODE=cloud_logging
MODERATION_ALERT_WEBHOOK_URL=
MODERATION_SWEEP_INTERVAL_MS=900000
# 심사 시연 데이터를 준비할 때만 사용하는 계정입니다. 저장소에 실제 비밀번호를 넣지 않습니다.
APP_REVIEW_EMAIL=
APP_REVIEW_PASSWORD=
REQUEST_BODY_LIMIT=1mb
MOMENT_PHOTO_MAX_FILE_SIZE_MB=10
REVERSE_GEOCODING_BASE_URL=https://nominatim.openstreetmap.org
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ COPY --chown=node:node --from=build /app/src/data ./src/data
COPY --chown=node:node openapi ./openapi
COPY --chown=node:node public ./public
COPY --chown=node:node scripts/check-public-api-contract.mjs ./scripts/check-public-api-contract.mjs
COPY --chown=node:node scripts/check-production-env.mjs ./scripts/check-production-env.mjs
COPY --chown=node:node scripts/start-container.sh ./scripts/start-container.sh

RUN mkdir -p uploads && chown -R node:node /app
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ services:
JWT_EXPIRES_IN_SECONDS: ${JWT_EXPIRES_IN_SECONDS:-3600}
ML_RECOMMENDATION_API_URL: ${ML_RECOMMENDATION_API_URL:-}
ML_RECOMMENDATION_TIMEOUT_MS: ${ML_RECOMMENDATION_TIMEOUT_MS:-5000}
MODERATION_ADMIN_KEY: ${MODERATION_ADMIN_KEY}
MODERATION_ALERT_MODE: ${MODERATION_ALERT_MODE}
MODERATION_ALERT_WEBHOOK_URL: ${MODERATION_ALERT_WEBHOOK_URL}
MODERATION_SWEEP_INTERVAL_MS: ${MODERATION_SWEEP_INTERVAL_MS:-900000}
APP_REVIEW_EMAIL: ${APP_REVIEW_EMAIL}
APP_REVIEW_PASSWORD: ${APP_REVIEW_PASSWORD}
REQUEST_BODY_LIMIT: ${REQUEST_BODY_LIMIT:-1mb}
MOMENT_PHOTO_MAX_FILE_SIZE_MB: ${MOMENT_PHOTO_MAX_FILE_SIZE_MB:-10}
REVERSE_GEOCODING_BASE_URL: ${REVERSE_GEOCODING_BASE_URL:-https://nominatim.openstreetmap.org}
Expand Down
72 changes: 72 additions & 0 deletions docs/moderation-operations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Soundlog 신고 운영 안내

이 문서는 신고가 들어온 뒤 24시간 안에 확인하고 조치하기 위한 운영 절차를 설명합니다. 앱 심사 전에 운영 서버에 `MODERATION_ADMIN_KEY`와 `MODERATION_ALERT_MODE`를 반드시 설정해야 합니다.

## 운영 환경 설정

`MODERATION_ADMIN_KEY`는 관리자 API 요청을 인증하는 32자 이상의 비밀 문자열입니다. 앱 코드나 공개 문서에 넣지 않습니다.

`MODERATION_ALERT_MODE=cloud_logging`은 새 신고와 처리 기한 알림을 `source=soundlog_moderation` 구조화 로그로 남깁니다. GCP에서는 이 로그에 Cloud Monitoring 알림 정책을 연결합니다. 외부 알림 도구를 사용할 때는 `MODERATION_ALERT_MODE=webhook`을 설정하고 `MODERATION_ALERT_WEBHOOK_URL`에 HTTPS 웹훅 주소를 넣습니다.

`APP_REVIEW_EMAIL`과 `APP_REVIEW_PASSWORD`는 심사 계정과 시연 데이터를 준비할 때 사용합니다. 아래 명령은 공개 추천 카탈로그와 데모 사용자를 갱신한 뒤 심사 계정이 신고하고 차단할 수 있는 사운드맵 핀과 공유 여행방을 만듭니다.

```bash
pnpm db:seed:review
```

## 신고 확인

아래 예시에서 주소와 비밀키는 운영 환경에 맞게 바꿉니다.

```bash
curl -fsS 'https://api.soundlog.shop/v1/admin/moderation/reports?status=pending&limit=50' \
-H 'x-soundlog-admin-key: YOUR_ADMIN_KEY'
```

응답의 `dueAt`이 처리 기한입니다. 신고 내용과 대상 스냅샷을 확인한 뒤 다음 중 하나로 처리합니다.

- `dismiss`는 위반이 아니라고 판단한 신고를 종료합니다.
- `hide_content`는 신고된 콘텐츠를 숨기고 신고를 종료합니다.
- `hide_and_suspend`는 콘텐츠를 숨기고 작성자의 로그인을 중단합니다.

```bash
curl -fsS -X PATCH 'https://api.soundlog.shop/v1/admin/moderation/reports/REPORT_ID' \
-H 'content-type: application/json' \
-H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' \
--data '{"action":"hide_content","note":"운영 정책 위반 콘텐츠를 숨겼습니다."}'
```

## 공개 사진 검토

사진이 포함된 공개 리캡과 음악 기록은 승인 전까지 다른 사용자에게 보이지 않습니다.

```bash
curl -fsS 'https://api.soundlog.shop/v1/admin/moderation/content?limit=50' \
-H 'x-soundlog-admin-key: YOUR_ADMIN_KEY'
```

응답의 `photoUrl` 또는 `backgroundImageUrl` 마지막 경로에 있는 32자리 `fileId`를 관리자 이미지 경로에 넣으면 사용자 로그인 토큰 없이 검토 원본을 확인할 수 있습니다.

```bash
curl -fsS 'https://api.soundlog.shop/v1/admin/moderation/content-images/FILE_ID' \
-H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' \
--output moderation-image
```

```bash
curl -fsS -X PATCH 'https://api.soundlog.shop/v1/admin/moderation/content/CONTENT_ID' \
-H 'content-type: application/json' \
-H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' \
--data '{"type":"recap","decision":"approved"}'
```

## 알림 점검

서버는 15분마다 처리 기한을 확인합니다. 20시간이 지난 미처리 신고와 24시간을 넘긴 신고를 웹훅으로 다시 알립니다. 배포 직후에는 아래 명령으로 점검 작업을 직접 실행하고 웹훅 수신 여부를 확인합니다.

```bash
curl -fsS -X POST 'https://api.soundlog.shop/v1/admin/moderation/sweep' \
-H 'x-soundlog-admin-key: YOUR_ADMIN_KEY'
```

웹훅이 오지 않더라도 신고 데이터는 저장됩니다. 다만 24시간 대응을 보장할 수 없으므로 심사 제출 전에는 실제 신고 한 건을 접수하고 운영 담당자가 알림과 관리자 API를 모두 확인해야 합니다.
203 changes: 200 additions & 3 deletions openapi/soundlog-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ info:
servers:
- url: http://localhost:4000
description: Local Docker/API server
- url: https://soundlog.shop/api/soundlog
description: Production API through the frontend Vercel proxy
- url: https://api.soundlog.shop
description: Production API
tags:
- name: System
description: 서버 상태 확인
Expand Down Expand Up @@ -48,6 +48,8 @@ tags:
description: 지역 사운드 트렌드
- name: Community
description: 공동 여행방, Live Sound Map, 취향 매칭, 신고/차단
- name: ModerationAdmin
description: 관리자 키로 보호되는 신고 및 공개 콘텐츠 운영 API
security:
- bearerAuth: []
paths:
Expand Down Expand Up @@ -1830,6 +1832,11 @@ paths:
schema:
type: object
properties:
targetType:
type: string
enum: [user, sound_pin, recap, travel_room_moment, travel_room_comment, mate_request]
targetContentId:
type: string
targetUserId:
type: string
description: 대상 사용자 ID를 알고 있을 때 사용합니다.
Expand Down Expand Up @@ -1864,10 +1871,163 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/AcceptedResponse"
$ref: "#/components/schemas/CommunityReportAcceptedResponse"
"401":
$ref: "#/components/responses/Unauthorized"

/v1/admin/moderation/reports:
get:
security:
- moderationAdminKey: []
tags: [ModerationAdmin]
summary: 신고 운영 큐 조회
operationId: listModerationReports
parameters:
- name: x-soundlog-admin-key
in: header
required: true
schema: { type: string }
- name: status
in: query
schema:
type: string
enum: [pending, resolved, dismissed]
- name: limit
in: query
schema: { type: integer, default: 50 }
responses:
"200": { description: 처리 기한이 빠른 순서의 신고 목록 }
"401": { $ref: "#/components/responses/Unauthorized" }

/v1/admin/moderation/reports/{reportId}:
patch:
security:
- moderationAdminKey: []
tags: [ModerationAdmin]
summary: 신고 해결 및 콘텐츠 삭제와 사용자 제재
operationId: resolveModerationReport
parameters:
- name: reportId
in: path
required: true
schema: { type: string }
- name: x-soundlog-admin-key
in: header
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action, note]
properties:
action:
type: string
enum: [dismiss, hide_content, hide_and_suspend]
note: { type: string, maxLength: 500 }
responses:
"200": { description: 처리된 신고 }
"401": { $ref: "#/components/responses/Unauthorized" }

/v1/admin/moderation/content:
get:
security:
- moderationAdminKey: []
tags: [ModerationAdmin]
summary: 공개 전 이미지 검토 대기 목록 조회
operationId: listPendingModerationContent
parameters:
- name: x-soundlog-admin-key
in: header
required: true
schema: { type: string }
- name: limit
in: query
schema: { type: integer, default: 50 }
responses:
"200": { description: 검토 대기 콘텐츠 목록 }
"401": { $ref: "#/components/responses/Unauthorized" }

/v1/admin/moderation/content-images/{fileId}:
get:
security:
- moderationAdminKey: []
tags: [ModerationAdmin]
summary: 관리자용 검토 대기 이미지 조회
operationId: getPendingModerationContentImage
parameters:
- name: fileId
in: path
required: true
schema:
type: string
pattern: "^[a-fA-F0-9]{32}$"
- name: x-soundlog-admin-key
in: header
required: true
schema: { type: string }
responses:
"200":
description: 검토할 이미지 원본
content:
image/jpeg: { schema: { type: string, format: binary } }
image/png: { schema: { type: string, format: binary } }
image/webp: { schema: { type: string, format: binary } }
"401": { $ref: "#/components/responses/Unauthorized" }
"404": { $ref: "#/components/responses/NotFound" }

/v1/admin/moderation/content/{contentId}:
patch:
security:
- moderationAdminKey: []
tags: [ModerationAdmin]
summary: 공개 이미지 승인 또는 거절
operationId: reviewModerationContent
parameters:
- name: contentId
in: path
required: true
schema: { type: string }
- name: x-soundlog-admin-key
in: header
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [type, decision]
properties:
type:
type: string
enum: [moment_log, recap]
decision:
type: string
enum: [approved, rejected]
responses:
"200": { description: 검토 결과 }
"401": { $ref: "#/components/responses/Unauthorized" }

/v1/admin/moderation/sweep:
post:
security:
- moderationAdminKey: []
tags: [ModerationAdmin]
summary: 20시간 알림과 24시간 초과 신고 점검
operationId: sweepModerationDeadlines
parameters:
- name: x-soundlog-admin-key
in: header
required: true
schema: { type: string }
responses:
"200": { description: 점검 결과 }
"401": { $ref: "#/components/responses/Unauthorized" }

/v1/travel-sessions:
post:
tags:
Expand Down Expand Up @@ -1965,6 +2125,10 @@ components:
type: http
scheme: bearer
bearerFormat: JWT
moderationAdminKey:
type: apiKey
in: header
name: x-soundlog-admin-key

parameters:
Lat:
Expand Down Expand Up @@ -3084,6 +3248,7 @@ components:
- title
- placeName
- ownerAlias
- isMine
- location
- trackTitle
- artistName
Expand All @@ -3106,6 +3271,8 @@ components:
ownerAlias:
type: string
example: Soundlog 여행자
isMine:
type: boolean
location:
$ref: "#/components/schemas/GeoPoint"
trackTitle:
Expand Down Expand Up @@ -3704,6 +3871,11 @@ components:
- other
targetUserId:
type: string
targetType:
type: string
enum: [user, sound_pin, recap, travel_room_moment, travel_room_comment, mate_request]
targetContentId:
type: string
targetPinId:
type: string
requestId:
Expand All @@ -3712,6 +3884,22 @@ components:
type: string
maxLength: 500

CommunityReportAcceptedResponse:
type: object
required: [data]
properties:
data:
type: object
required: [id, dueAt, notified]
properties:
id:
type: string
dueAt:
type: string
format: date-time
notified:
type: boolean

TravelRoomResponse:
type: object
required:
Expand Down Expand Up @@ -3911,11 +4099,20 @@ components:
allOf:
- $ref: "#/components/schemas/LoginRequest"
- type: object
required:
- termsAccepted
- termsVersion
properties:
displayName:
type: string
maxLength: 120
example: Soundlog User
termsAccepted:
type: boolean
const: true
termsVersion:
type: string
const: "2026-08-15"

AuthUser:
type: object
Expand Down
Loading
Loading