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
34 changes: 34 additions & 0 deletions deploy/prod/docker-compose.db.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
version: "3.8"

services:
postgres:
image: postgis/postgis:16-3.4
container_name: souzip-prod-db
restart: always
networks:
- souzip-network
ports:
- "127.0.0.1:5432:5432"
env_file:
- .env.prod
Comment on lines +12 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge DB compose가 기존 환경파일을 사용하도록 맞추세요

프로덕션 배포 스크립트와 기존 blue/green compose는 .env를 기준으로 동작하는데, 새 DB compose만 env_file: .env.prod를 사용해 변수 소스를 다르게 잡고 있습니다. 운영 서버에 .env.prod가 없거나 값이 분리되어 있으면 PROD_POSTGRES_USER/PROD_POSTGRES_PASSWORD 치환이 비어 DB 컨테이너 기동이 실패할 수 있으므로, 기존 배포 체계와 동일한 env 파일로 통일하거나 배포 스크립트에서 동일 파일을 보장해야 합니다.

Useful? React with 👍 / 👎.

environment:
POSTGRES_DB: souzip_prod
POSTGRES_USER: ${PROD_POSTGRES_USER}
POSTGRES_PASSWORD: ${PROD_POSTGRES_PASSWORD}
TZ: Asia/Seoul
volumes:
- postgres-data:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PROD_POSTGRES_USER} -d souzip_prod"]
interval: 10s
timeout: 5s
retries: 10

networks:
souzip-network:
external: true

volumes:
postgres-data:
driver: local
31 changes: 22 additions & 9 deletions deploy/prod/scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ BLUE_COMPOSE="docker-compose.blue.yaml"
GREEN_COMPOSE="docker-compose.green.yaml"
MONITORING_COMPOSE="docker-compose.monitoring.yaml"
LOGGING_COMPOSE="docker-compose.logging.yaml"
DB_COMPOSE="docker-compose.db.yaml"

BLUE_PROJECT="souzip-blue"
GREEN_PROJECT="souzip-green"
Expand All @@ -40,7 +41,7 @@ fi

cd "$WORK_DIR" || exit 1

echo -e "${YELLOW}[1/8] 최신 이미지 다운로드${NC}"
echo -e "${YELLOW}[1/9] 최신 이미지 다운로드${NC}"

docker pull ${REGISTRY}:latest

Expand All @@ -60,7 +61,7 @@ cd "$DEPLOY_DIR" || exit 1
exit 1
}

echo -e "${YELLOW}[2/8] 현재 active 포트 확인${NC}"
echo -e "${YELLOW}[2/9] 현재 active 포트 확인${NC}"

CURRENT_PORT=$(grep -oE '127\.0\.0\.1:[0-9]+' "$NGINX_UPSTREAM_FILE" | cut -d: -f2 | head -n 1 || true)

Expand Down Expand Up @@ -89,7 +90,19 @@ fi

echo -e "${GREEN}[INFO] 현재:$CURRENT_PORT → 배포:$TARGET($TARGET_PORT)${NC}"

echo -e "${YELLOW}[3/8] 모니터링 스택 확인${NC}"
echo -e "${YELLOW}[3/9] DB 컨테이너 확인${NC}"
if ! docker ps --format '{{.Names}}' | grep -q '^souzip-prod-db$'; then
docker compose -f "$DB_COMPOSE" up -d
if [ $? -ne 0 ]; then
echo -e "${RED}[ERROR] DB 컨테이너 시작 실패${NC}"
exit 1
fi
echo -e "${GREEN}[SUCCESS] DB 컨테이너 시작 완료${NC}"
else
echo -e "${GREEN}[SUCCESS] DB 컨테이너 이미 실행 중${NC}"
fi

echo -e "${YELLOW}[4/9] 모니터링 스택 확인${NC}"
PROMETHEUS_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-prometheus-prod$' && echo "true" || echo "false")
GRAFANA_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-grafana-prod$' && echo "true" || echo "false")
NODE_EXPORTER_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-node-exporter$' && echo "true" || echo "false")
Expand All @@ -110,7 +123,7 @@ else
echo -e "${GREEN}[SUCCESS] 모니터링 스택 이미 실행 중${NC}"
fi

echo -e "${YELLOW}[3.5/8] 로그 수집 스택 확인${NC}"
echo -e "${YELLOW}[4.5/9] 로그 수집 스택 확인${NC}"
LOKI_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-loki-prod$' && echo "true" || echo "false")
PROMTAIL_RUNNING=$(docker ps --format '{{.Names}}' | grep -q '^souzip-promtail-prod$' && echo "true" || echo "false")

Expand All @@ -129,12 +142,12 @@ else
echo -e "${GREEN}[SUCCESS] 로그 수집 스택 이미 실행 중${NC}"
fi

echo -e "${YELLOW}[4/8] $TARGET 컨테이너 실행${NC}"
echo -e "${YELLOW}[5/9] $TARGET 컨테이너 실행${NC}"

docker compose -p "$TARGET_PROJECT" -f "$TARGET_COMPOSE" pull
docker compose -p "$TARGET_PROJECT" -f "$TARGET_COMPOSE" up -d

echo -e "${YELLOW}[5/8] 헬스체크 시작${NC}"
echo -e "${YELLOW}[6/9] 헬스체크 시작${NC}"

RETRY_COUNT=0
HEALTH_OK=false
Expand Down Expand Up @@ -164,7 +177,7 @@ if [ "$HEALTH_OK" = false ]; then
exit 1
fi

echo -e "${YELLOW}[6/8] nginx upstream 전환${NC}"
echo -e "${YELLOW}[7/9] nginx upstream 전환${NC}"

sudo tee "$NGINX_UPSTREAM_FILE" > /dev/null <<EOF
upstream souzip {
Expand All @@ -177,11 +190,11 @@ sudo nginx -s reload

echo -e "${GREEN}[SUCCESS] nginx 전환 완료${NC}"

echo -e "${YELLOW}[7/8] 이전 컨테이너 종료${NC}"
echo -e "${YELLOW}[8/9] 이전 컨테이너 종료${NC}"

docker compose -p "$STOP_PROJECT" -f "$STOP_COMPOSE" down || true

echo -e "${YELLOW}[8/8] 이미지 정리${NC}"
echo -e "${YELLOW}[9/9] 이미지 정리${NC}"

docker image prune -f || true

Expand Down
65 changes: 65 additions & 0 deletions deploy/shared/db-backup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/bin/bash
DATE=$(date +%Y-%m-%d_%H%M)
BACKUP_DIR="/home/souzip-prod/backups"
BACKUP_FILE="$BACKUP_DIR/souzip-$DATE.dump"
BUCKET="souzip-db-backup"
RETENTION_DAYS=30

if [ -f "/home/souzip-prod/souzip/deploy/prod/.env" ]; then
export PROD_POSTGRES_USER=$(grep '^PROD_POSTGRES_USER=' /home/souzip-prod/souzip/deploy/prod/.env | cut -d= -f2-)
export PROD_POSTGRES_PASSWORD=$(grep '^PROD_POSTGRES_PASSWORD=' /home/souzip-prod/souzip/deploy/prod/.env | cut -d= -f2-)
export PROD_DB_DISCORD_WEBHOOK_URL=$(grep '^PROD_DB_DISCORD_WEBHOOK_URL=' /home/souzip-prod/souzip/deploy/prod/.env | cut -d= -f2-)
export NCP_ACCESS_KEY=$(grep '^NCP_ACCESS_KEY=' /home/souzip-prod/souzip/deploy/prod/.env | cut -d= -f2-)
export NCP_SECRET_KEY=$(grep '^NCP_SECRET_KEY=' /home/souzip-prod/souzip/deploy/prod/.env | cut -d= -f2-)
fi

S3CMD_OPTS="--access_key=$NCP_ACCESS_KEY --secret_key=$NCP_SECRET_KEY --host=kr.object.ncloudstorage.com --host-bucket=%(bucket)s.kr.object.ncloudstorage.com"

mkdir -p $BACKUP_DIR

docker exec -e PGPASSWORD=$PROD_POSTGRES_PASSWORD souzip-prod-db \
pg_dump -U $PROD_POSTGRES_USER -d souzip_prod -F c > $BACKUP_FILE

if [ $? -ne 0 ]; then
echo "[ERROR] DB 백업 실패 - $DATE"
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"username\": \"Souzip Bot\", \"content\": \"@here DB 백업 실패 - $DATE\"}" \
"$PROD_DB_DISCORD_WEBHOOK_URL" > /dev/null
exit 1
fi
echo "[INFO] DB 백업 완료 - $BACKUP_FILE"

s3cmd $S3CMD_OPTS put $BACKUP_FILE s3://$BUCKET/souzip-$DATE.dump

if [ $? -ne 0 ]; then
echo "[ERROR] Object Storage 업로드 실패 - $DATE"
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"username\": \"Souzip Bot\", \"content\": \"@here DB 백업 업로드 실패 - $DATE\"}" \
"$PROD_DB_DISCORD_WEBHOOK_URL" > /dev/null
exit 1
fi
echo "[INFO] Object Storage 업로드 완료"

# 로컬 오래된 백업 삭제
find $BACKUP_DIR -name "souzip-*.dump" -mtime +$RETENTION_DAYS -delete

# Object Storage 오래된 백업 삭제
s3cmd $S3CMD_OPTS ls s3://$BUCKET/ | awk '{print $4}' | while read file; do
file_date=$(echo $file | grep -oP '\d{4}-\d{2}-\d{2}')
if [ ! -z "$file_date" ]; then
days_old=$(( ( $(date +%s) - $(date -d "$file_date" +%s) ) / 86400 ))
if [ $days_old -gt $RETENTION_DAYS ]; then
s3cmd $S3CMD_OPTS del $file
echo "[INFO] 오래된 백업 삭제 - $file"
fi
fi
done

echo "[INFO] DB 백업 프로세스 완료 - $DATE"

curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"username\": \"Souzip Bot\", \"embeds\": [{\"title\": \"DB 백업 완료\", \"color\": 3066993, \"fields\": [{\"name\": \"날짜\", \"value\": \"$DATE\", \"inline\": true}, {\"name\": \"파일\", \"value\": \"souzip-$DATE.dump\", \"inline\": true}]}]}" \
"$PROD_DB_DISCORD_WEBHOOK_URL" > /dev/null
1 change: 1 addition & 0 deletions src/docs/asciidoc/app/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ include::../shared/overview.adoc[]
include::auth.adoc[]
include::user.adoc[]
include::souvenir.adoc[]
include::wishlist.adoc[]
include::category.adoc[]
include::country.adoc[]
include::discovery.adoc[]
Expand Down
12 changes: 7 additions & 5 deletions src/docs/asciidoc/app/souvenir.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
== Souvenir API

[[souvenir-get]]
=== 기념품 조회
=== 기념품 조회 (new)

기념품 상세 정보를 조회하는 API입니다.

* 인증 불필요 (Public API)
* 삭제된(soft delete) 기념품은 조회할 수 없습니다.
* 인증 불필요하지만, 로그인한 사용자가 조회하면 내 기념품 여부(isOwned) 함께 반환
* 인증 불필요하지만, 로그인한 사용자가 조회하면 내 기념품 여부(isOwned), 찜 여부(isWishlisted) 함께 반환
* 비로그인 사용자의 경우 `isWishlisted` 는 `null` 로 반환

operation::souvenirs/get-souvenir[snippets='http-request,path-parameters,http-response,response-fields']

Expand All @@ -19,7 +20,7 @@ NOTE: v2 응답 구조 - `price` 필드에 원본 가격(original)과 등록 시
WARNING: 응답의 `localPrice`, `currencySymbol`, `krwPrice` 필드는 v1 호환성을 위해 임시로 제공되며, 향후 제거될 예정입니다. `price` 객체(`price.original.amount`, `price.original.symbol`, `price.converted.amount`) 사용을 권장합니다.

[[souvenir-create]]
=== 기념품 생성 (New)
=== 기념품 생성

사용자가 새로운 기념품을 등록하는 v2 API입니다.

Expand Down Expand Up @@ -59,7 +60,7 @@ operation::souvenirs/create-souvenir[snippets='http-request,request-parts,reques
NOTE: 기념품 가격은 현지 통화 또는 원화 기준으로 입력할 수 있으며, 서버에서 환율이 자동 적용됩니다.

[[souvenir-update]]
=== 기념품 수정 (New)
=== 기념품 수정

v2 API를 사용한 기념품 수정입니다.

Expand Down Expand Up @@ -106,13 +107,14 @@ operation::souvenirs/delete-souvenir[snippets='http-request,path-parameters,http
NOTE: 삭제 성공 시 `data`는 반환되지 않으며, 메시지만 전달됩니다.

[[souvenir-nearby]]
=== 근처 기념품 조회
=== 근처 기념품 조회 (new)

위도/경도를 통해 특정 반경 내 근처 기념품을 조회하는 API입니다.

* 엔드포인트: `GET /api/souvenirs/nearby`
* 인증 불필요 (Public API)
* 삭제된(soft delete) 기념품은 조회할 수 없습니다.
* 조회 반경 단위는 미터(m)
* 로그인한 사용자가 조회하면 찜 여부(isWishlisted) 함께 반환, 비로그인 시 `null`

operation::souvenirs/get-nearby-souvenirs[snippets='http-request,query-parameters,http-response,response-fields']
22 changes: 21 additions & 1 deletion src/docs/asciidoc/app/user.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,24 @@ operation::user/my-souvenirs-page-2[snippets='http-request,query-parameters,http
==== 등록한 기념품이 없는 경우
operation::user/my-souvenirs-empty[snippets='http-request,query-parameters,http-response,response-fields']

NOTE: 기념품 목록은 생성일시 기준 내림차순으로 정렬됩니다. 찜 탭 기능은 추후 구현 예정입니다.
NOTE: 기념품 목록은 생성일시 기준 내림차순으로 정렬됩니다.

[[user-my-wishlists]]
==== 내가 찜한 기념품 목록 조회 (new)

찜 탭에 표시할 기념품 목록을 조회합니다.

* 인증 필요
* 찜한 순서(최신순)로 정렬됩니다.

operation::user/my-wishlists[snippets='http-request,query-parameters,http-response,response-fields']

==== 페이징 예시

===== 2페이지 조회
operation::user/my-wishlists-page-2[snippets='http-request,query-parameters,http-response,response-fields']

==== 찜한 기념품이 없는 경우
operation::user/my-wishlists-empty[snippets='http-request,query-parameters,http-response,response-fields']

NOTE: 기념품 목록은 찜한 시간 기준 내림차순으로 정렬됩니다.
24 changes: 24 additions & 0 deletions src/docs/asciidoc/app/wishlist.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[[wishlist-api]]
== Wishlist API

[[wishlist-add]]
=== 찜 등록 (new)

기념품을 찜 목록에 추가하는 API입니다.

* 엔드포인트: `POST /api/wishlists/{souvenirId}`
* 인증 필요
* 이미 찜한 기념품에 다시 찜 요청을 보내면 에러가 반환됩니다.

operation::wishlists/add[snippets='http-request,path-parameters,http-response,response-fields']

[[wishlist-remove]]
=== 찜 취소 (new)

찜 목록에서 기념품을 제거하는 API입니다.

* 엔드포인트: `DELETE /api/wishlists/{souvenirId}`
* 인증 필요
* 찜하지 않은 기념품을 취소하려 하면 에러가 반환됩니다.

operation::wishlists/remove[snippets='http-request,path-parameters,http-response,response-fields']
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import com.souzip.adapter.integration.googlegeocoding.dto.GoogleGeocodingResponse.Result;
import com.souzip.application.location.dto.AddressResult;
import com.souzip.application.location.required.AddressProvider;
import com.souzip.domain.shared.Coordinate;
import com.souzip.shared.domain.Coordinate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
import com.souzip.adapter.integration.googleplaces.dto.GooglePlacesSearchResponse.Result;
import com.souzip.application.location.dto.SearchPlace;
import com.souzip.application.location.required.PlaceSearchProvider;
import com.souzip.domain.shared.Coordinate;
import java.util.Optional;
import com.souzip.shared.domain.Coordinate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
Expand All @@ -14,6 +13,7 @@

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

@Slf4j
@RequiredArgsConstructor
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/souzip/adapter/storage/file/NcpStorage.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.souzip.adapter.config.ObjectStorageProperties;
import com.souzip.application.file.required.FileStorage;
import com.souzip.global.exception.BusinessException;
import com.souzip.global.exception.ErrorCode;
import com.souzip.shared.exception.BusinessException;
import com.souzip.shared.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import com.souzip.application.email.EmailBroadcastService;
import com.souzip.application.email.dto.EmailBroadcastResult;
import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess;
import com.souzip.global.common.dto.SuccessResponse;
import com.souzip.shared.common.dto.SuccessResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId;
import com.souzip.domain.admin.infrastructure.security.annotation.ViewerAccess;
import com.souzip.domain.notice.Notice;
import com.souzip.global.common.dto.SuccessResponse;
import com.souzip.shared.common.dto.SuccessResponse;
import jakarta.validation.Valid;

import java.util.Optional;

import lombok.RequiredArgsConstructor;

import java.util.List;
import java.util.UUID;

import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

Expand Down
Loading
Loading