generated from ttipo/project-base
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2485feb0e4 | |||
| 19ee38a635 | |||
| 4f8e3f0b68 | |||
| e3ed0fa800 | |||
| a8861a0313 | |||
| 163cb16c6a | |||
| cffc5a5d06 | |||
| f60654bed6 | |||
| 0b978a9610 | |||
| be58689dff |
@@ -41,6 +41,21 @@ sudo docker compose --env-file .env.production up -d --build
|
||||
sudo docker compose --env-file .env.production logs -f backend
|
||||
```
|
||||
|
||||
첫 배포 전에 서버에서 한 번 준비한다.
|
||||
|
||||
- env 두 파일 — 루트 `.env.production`, `backend/.env.production` (`JWT_SECRET` 32자 이상, 소셜 로그인 값). placeholder 가 남으면 기동 실패
|
||||
- 외부 네트워크 `proxy` — 리버스 프록시(TLS 종료)와 backend·frontend 가 이 네트워크로 만난다. 없으면 `docker network create proxy`
|
||||
- 업로드 디렉터리 — backend 는 uid 1000 으로 돈다. `sudo install -d -o 1000 -g 1000 ./data/uploads` (`UPLOAD_DATA_DIR`).
|
||||
root 소유로 생기면 업로드가 전부 실패하고 backend 가 unhealthy 가 된다
|
||||
- 마이그레이션은 backend 가 기동할 때 자동 적용된다 (`migrationsRun`). 별도 명령이 없다
|
||||
- 백업 — `DB_DATA_DIR` 와 `UPLOAD_DATA_DIR` 를 **함께** 백업한다 (DB 가 파일을 id 로 가리킨다)
|
||||
- 셸에 `APP_ENV` 같은 변수를 export 하지 않는다 — compose 는 셸 변수를 `--env-file` 보다 우선한다.
|
||||
`APP_ENV=development` 가 남아 있으면 운영 명령이 development 설정(개발용 로그인 열림)으로 뜬다.
|
||||
`sudo docker compose --env-file .env.production config | grep NODE_ENV` 로 `production` 인지 확인한다
|
||||
|
||||
배포 후 `sudo docker compose --env-file .env.production ps` 에서 backend 가 `healthy` 인지 확인한다
|
||||
(헬스체크는 `/api/health` — DB 연결과 업로드 디렉터리 쓰기 권한을 함께 본다).
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드는 호스트에서 (개발 환경)
|
||||
|
||||
@@ -17,7 +17,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| 제공 항목 | 위치 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 데이터 모드 | `lib/core/config/app_config.dart` | `.env` 의 `DATA_MODE` — `api`(기본, 서버) / `local`(기기 저장 · 서버 없이 화면 확인 · 테스트) |
|
||||
| Dio 클라이언트 | `lib/core/api/dio_client.dart` + `dio_provider.dart` | Provider 로 주입(`ref.watch(dioProvider)`), baseUrl=dotenv `API_URL`, 타임아웃 |
|
||||
| Dio 클라이언트 | `lib/core/api/dio_client.dart` + `dio_provider.dart` | Provider 로 주입(`ref.watch(dioProvider)`), baseUrl=`readApiBaseUrl()`(빌드 때 넘긴 `--dart-define=API_URL` → `.env` 의 `API_URL` 순), 타임아웃 |
|
||||
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(**401 → 토큰 갱신 후 한 번 재시도**·코드 매핑) — 인라인 |
|
||||
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}` 정규화 예외. 인터셉터가 `DioException.error` 에 실어준다 |
|
||||
| 실패 알림 | `apiErrorReporterProvider` → `rup_toast.dart` 의 `apiErrorToastReporter` | 사전 문구 토스트, 같은 코드는 잠깐 한 번만. 호출부가 처리하는 코드는 `kSilentCodesExtra` 로 끈다 |
|
||||
@@ -30,6 +30,9 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
| QR 스캐너 | `shared/widgets/qr_scanner_page.dart` | `QrScannerPage<T>` — 문구·코드 꺼내기·불러오기만 넘긴다(명함 받기·다이어리 참여·일정 연동). 결과는 `ScanFound` / `ScanTypeCode`. 테스트는 `scannerCameraProvider` 로 카메라를 바꿔 끼운다 |
|
||||
| 함께 쓰는 기록 | `features/diary` · `features/schedule` | 서버가 준 작성자·멤버의 회원 id 가 나면 `kMeAuthorId`/`isMe` 로 바꿔 담는다. 남의 아이는 내 목록에 없으므로 서버가 준 이름·사진 요약(`targets`)으로 그린다. 기기 저장 모드는 `canShare`/`available` 이 false 라 안내만 띄운다 |
|
||||
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | 세션(토큰)을 첫 프레임 전에 읽어 override 한다 |
|
||||
| 앱 링크 | `core/config/app_links.dart` + `features/links/` | `rup://app/<경로>` — Android 는 호스트를 버리므로 **경로로** 무엇을 열지 정한다. `LinkLandingPage` 가 코드를 확인해 받기·참여·연동으로 잇는다. 로그인 전에 받은 링크는 라우터가 기억했다가 진입을 마치면 연다 |
|
||||
| 함께 쓰는 기록 새로 읽기 | `features/shell/.../shared_data_refresher.dart` | 앱으로 돌아올 때 + 보는 동안 3분마다. 뒤에서 읽으므로 `kSilentExtra` 로 실패 알림을 끈다 |
|
||||
| 출시 설정 | `android/app/build.gradle.kts` · `ios/Runner` · `branding/` | 앱 ID `com.ttipo.rup`·이름 `rup`·아이콘·시작 화면·서명 — 아래 "출시 빌드" |
|
||||
| 도메인 feature(패턴 참고) | `lib/features/pet/` | 저장소 **계약 + `Local*` + `Api*`**, 프로바이더가 `DATA_MODE` 로 고른다. 알림기 build 는 `sessionEpochProvider` 를 watch |
|
||||
|
||||
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
|
||||
@@ -226,6 +229,51 @@ await storage.deleteAll();
|
||||
|
||||
---
|
||||
|
||||
## 출시 빌드
|
||||
|
||||
```bash
|
||||
# Android — 스토어 업로드용 (서버 주소는 빌드 때 넘긴다)
|
||||
flutter build appbundle --release --dart-define=API_URL=https://<도메인>/api --dart-define=DATA_MODE=api
|
||||
|
||||
# 기기에서 릴리스 동작만 확인할 때
|
||||
flutter build apk --release --dart-define=API_URL=https://<도메인>/api --dart-define=DATA_MODE=api
|
||||
|
||||
# iOS — macOS 에서, Xcode 로 팀·서명을 고른 뒤
|
||||
flutter build ipa --release --dart-define=API_URL=https://<도메인>/api --dart-define=DATA_MODE=api
|
||||
```
|
||||
|
||||
- **서버 주소**: 출시 빌드는 `.env` 대신 `--dart-define=API_URL` 로 넘긴다. https 가 아니거나 개발용 주소(`localhost`·`10.0.2.2` 등)면
|
||||
**앱이 시작하지 않는다**(`releaseApiUrlProblem()`) — 사용자 기기에서 모든 요청이 조용히 실패하는 것보다 낫다.
|
||||
- **데이터 모드**: 빌드에는 개발자 PC 의 `flutter/.env` 가 asset 으로 묶인다. `--dart-define=DATA_MODE=api` 로 정해 두어 그 `.env` 가 `local` 이어도 출시 빌드가 기기 저장으로 바뀌지 않게 한다.
|
||||
- **Android 서명**: `android/key.properties.example` → `android/key.properties` 복사 후 업로드 키 경로·비밀번호를 채운다.
|
||||
파일이 없으면 디버그 키로 서명되어(빌드 로그에 경고) **스토어에 올릴 수 없다.** `key.properties`·키 파일은 커밋하지 않는다(`.gitignore`).
|
||||
업로드 키를 잃어버리면 같은 앱으로 업데이트를 올릴 수 없으니 별도로 백업한다.
|
||||
- **버전**: `pubspec.yaml` 의 `version: 이름+빌드번호` 하나만 올린다 — Android `versionCode`·iOS `CFBundleVersion` 이 이를 따른다.
|
||||
스토어는 같은 빌드 번호를 두 번 받지 않는다.
|
||||
- **앱 ID**: `com.ttipo.rup`(Android `applicationId` · iOS 번들 ID). **스토어에 올린 뒤에는 바꿀 수 없다.**
|
||||
Dart 패키지 이름(`tma_app`, import 경로)은 앱 ID 와 무관해 그대로 둔다. 데스크톱 폴더(windows·linux·macos)는 출시 대상이 아니라 손대지 않았다.
|
||||
- **아이콘**: 원본은 `branding/app_icon.png`(전체)·`app_icon_foreground.png`(적응형 앞면, 안전 영역 안). 바꾸면 `dart run flutter_launcher_icons` 로
|
||||
크기별 아이콘을 다시 만든다. 바탕색은 `pubspec.yaml` 의 `adaptive_icon_background`.
|
||||
- **시작 화면 색**: `#FFDE84` — Android `res/values/colors.xml` 의 `launch_background`(12+ 는 `values-v31` 스타일), iOS `LaunchScreen.storyboard`. 바꿀 때 함께 고친다.
|
||||
- **앱 링크 scheme** `rup` 은 AndroidManifest 의 intent-filter 와 iOS `CFBundleURLTypes` 에 함께 등록되어 있다 — 하나만 바꾸면 한쪽 기기에서 QR 이 앱을 열지 못한다.
|
||||
|
||||
### 운영 서버 전 휴대폰 테스트 (Android)
|
||||
|
||||
출시 빌드는 https 만 받으므로, PC 개발 서버(http)에 붙이는 시험 빌드는 **profile** 로 만든다 (속도는 출시 빌드와 같고 디버그 키로 서명된다).
|
||||
|
||||
```bash
|
||||
# 휴대폰과 PC 가 같은 공유기에 있어야 한다. <PC IP> 는 PowerShell `Get-NetIPConfiguration` 의 IPv4
|
||||
flutter build apk --profile --target-platform android-arm64 --dart-define=API_URL=http://<PC IP>:3000/api --dart-define=DATA_MODE=api
|
||||
adb install -r build/app/outputs/flutter-apk/app-profile.apk # USB 디버깅을 켜고 연결했을 때
|
||||
```
|
||||
|
||||
- 개발 서버는 개발용 로그인이 열려 있어 기기마다 새 회원이 된다. 공유 기능은 에뮬레이터(`adb reverse tcp:3000 tcp:3000`)를 두 번째 사람으로 쓴다.
|
||||
- PC 의 IP 가 바뀌면(공유기 재시작 등) 다시 빌드한다. 공유기의 "무선 기기 격리(AP isolation)" 가 켜져 있으면 휴대폰이 PC 에 닿지 않는다.
|
||||
- 시험 빌드(디버그 키)와 출시 빌드(업로드 키)는 서명이 달라 덮어 설치되지 않는다 — 앱을 지우고 설치한다(기기에 남은 로그인도 지워진다).
|
||||
- iPhone 은 Mac(Xcode)이 있어야 한다 — Mac 에서 `flutter run --profile` 에 같은 `--dart-define` 을 넘긴다. 운영 서버가 생기면 TestFlight 로 나눠 준다.
|
||||
|
||||
---
|
||||
|
||||
## 성능 & 최신 트렌드 추가 규칙
|
||||
|
||||
- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델)
|
||||
@@ -262,3 +310,4 @@ await storage.deleteAll();
|
||||
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL`·`DATA_MODE` 설정 (Android 에뮬레이터는 `10.0.2.2`).
|
||||
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
|
||||
- [ ] **서버 계약 점검**: 서버 쪽 응답 모양을 바꿨다면 개발 서버를 띄우고 `flutter test test_live` (CI 에서는 돌지 않는다).
|
||||
- [ ] **출시 빌드**: `--dart-define=API_URL=https://…` + `android/key.properties` + `pubspec.yaml` 빌드 번호 증가 (위 "출시 빌드").
|
||||
|
||||
@@ -23,8 +23,8 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
|
||||
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
|
||||
| 전역 ValidationPipe | `main.ts` | `whitelist/forbidNonWhitelisted/transform` |
|
||||
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터 |
|
||||
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트 |
|
||||
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. **개발·테스트에서만 열린다**(`isDevNodeEnv`) — 운영에서 API 구조를 공개하지 않는다 |
|
||||
| 보안 미들웨어 | `main.ts` | helmet · CORS 화이트리스트(루트 `CORS_ORIGIN`, 여러 개면 콤마 구분. 개발·테스트는 localhost 를 더 허용) |
|
||||
| Rate Limiting | `common/guards/throttler.guards.ts` (APP_GUARD) | IP 단위 기본 한도(`THROTTLE_*`) → 인증 → 코드 조회 한도(`@InviteCodeLookup()`, 회원 단위 한 바구니). 초과 시 429 → `REQ_001` |
|
||||
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
|
||||
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
|
||||
|
||||
@@ -76,6 +76,11 @@ COMPOSE_PROFILES=
|
||||
|
||||
# ==========================================
|
||||
# 파일 업로드 (로컬 디스크)
|
||||
#
|
||||
# UPLOAD_DATA_DIR : 업로드 사진이 저장되는 호스트 경로 (컨테이너의 /app/uploads 에 연결)
|
||||
# Linux 호스트라면 백엔드(비-root, uid 1000)가 쓸 수 있게 미리 만든다:
|
||||
# install -d -o 1000 -g 1000 ./data/uploads
|
||||
# (Docker Desktop 의 Windows·macOS 경로는 소유권과 무관하게 쓸 수 있다)
|
||||
# ==========================================
|
||||
UPLOAD_DATA_DIR=./data/uploads
|
||||
UPLOAD_MAX_SIZE_MB=5
|
||||
|
||||
@@ -77,6 +77,12 @@ COMPOSE_PROFILES=
|
||||
|
||||
# ==========================================
|
||||
# 파일 업로드 (로컬 디스크)
|
||||
#
|
||||
# UPLOAD_DATA_DIR : 업로드 사진이 저장되는 호스트 경로 (컨테이너의 /app/uploads 에 연결)
|
||||
# - 백엔드는 비-root(node, uid 1000)로 돈다. 디렉터리가 없으면 docker 가 root 소유로 만들어
|
||||
# 업로드가 전부 실패하므로(헬스체크 unhealthy) **첫 배포 전에 한 번** 만들어 둔다.
|
||||
# sudo install -d -o 1000 -g 1000 ./data/uploads
|
||||
# - DB 는 파일을 id 로 가리킨다. 백업은 DB_DATA_DIR 와 이 디렉터리를 **함께** 한다.
|
||||
# ==========================================
|
||||
UPLOAD_DATA_DIR=./data/uploads
|
||||
UPLOAD_MAX_SIZE_MB=5
|
||||
|
||||
@@ -57,8 +57,11 @@ jobs:
|
||||
run: npm run test
|
||||
|
||||
# 표준 응답 래퍼·에러 코드·traceId 계약 검증 (.claude/rules/response-format.md)
|
||||
# --runInBand: 스위트들이 같은 DB 를 쓰고, NODE_ENV=test 라 스위트마다 기동할 때 마이그레이션을 돌린다.
|
||||
# 병렬로 돌면 빈 DB 에서 여러 스위트가 같은 테이블을 동시에 만들다 충돌한다(duplicate key ... pg_class).
|
||||
# TypeORM 재연결(3초 뒤)로 대개 통과하지만 타이밍에 기대는 것이라 순서대로 돌린다.
|
||||
- name: E2E test
|
||||
run: npm run test:e2e
|
||||
run: npm run test:e2e -- --runInBand
|
||||
env:
|
||||
NODE_ENV: test
|
||||
# 서비스 컨테이너의 네트워크 별칭 (localhost 는 잡 컨테이너 자신을 가리킨다)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# 빌드 컨텍스트에서 제외한다 — 이미지 안에서 새로 만들거나, 이미지에 들어가면 안 되는 것
|
||||
#
|
||||
# node_modules : 호스트(Windows)에서 설치한 네이티브 바이너리(@swc/core-win32 등)가
|
||||
# 이미지의 npm ci 결과를 덮어써 Linux 빌드가 깨진다
|
||||
# .env* : 시크릿 — 값은 compose 의 env_file/environment 로만 주입한다 (.claude/rules/security.md)
|
||||
# uploads : 사용자 파일 — 운영에서는 compose 볼륨(UPLOAD_DATA_DIR)에 있다
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
uploads
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
@@ -29,3 +29,5 @@ NAVER_CLIENT_ID=
|
||||
GOOGLE_CLIENT_IDS=
|
||||
# Apple 번들 ID·서비스 ID (콤마 구분) — id_token 의 aud
|
||||
APPLE_CLIENT_IDS=
|
||||
|
||||
# 개발용 로그인(AUTH_DEV_LOGIN)은 운영에서 값과 무관하게 코드로 닫힌다 — 여기에 두지 않는다.
|
||||
|
||||
@@ -31,6 +31,11 @@ RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# 업로드 저장 위치(compose 의 UPLOAD_DIR) — node 가 쓸 수 있게 소유권을 넘겨 둔다.
|
||||
# named volume 은 이 소유권을 이어받는다. 호스트 경로(bind mount)는 호스트 디렉터리 소유권을 따르므로
|
||||
# 운영 서버에서 미리 uid 1000 소유로 만들어 둔다 (.env.production.example 의 UPLOAD_DATA_DIR 참조)
|
||||
RUN mkdir -p /app/uploads && chown node:node /app/uploads
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# 비-root 실행 (보안 규칙)
|
||||
|
||||
+32
-21
@@ -13,9 +13,14 @@ import type {
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { isDevNodeEnv } from '@/shared/config/node-env';
|
||||
import { parseCommaList } from '@/shared/utils/env';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { winstonConfig } from '@/shared/logger/winston.config';
|
||||
|
||||
/** 개발·테스트에서만 더 허용하는 오리진 — 호스트에서 띄운 vite·Swagger. 운영에는 들어가지 않는다 */
|
||||
const DEV_CORS_ORIGINS = ['http://localhost:5173', 'http://localhost:3000'];
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: WinstonModule.createLogger(winstonConfig),
|
||||
@@ -39,20 +44,20 @@ async function bootstrap() {
|
||||
// 1. 보안 헤더 (Helmet) - XSS 방지 및 기본 웹 보안
|
||||
app.use(helmet());
|
||||
|
||||
// 2. 화이트리스트 기반 CORS 정책
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
const corsOriginEnv = process.env.CORS_ORIGIN;
|
||||
// 2. 화이트리스트 기반 CORS 정책 — 루트 env 의 CORS_ORIGIN(여러 개면 콤마 구분)
|
||||
const isDevEnv = isDevNodeEnv(process.env.NODE_ENV);
|
||||
const whitelist = new Set([
|
||||
...parseCommaList(process.env.CORS_ORIGIN),
|
||||
...(isDevEnv ? DEV_CORS_ORIGINS : []),
|
||||
]);
|
||||
|
||||
app.enableCors({
|
||||
origin: (
|
||||
origin: string | undefined,
|
||||
callback: (err: Error | null, allow?: boolean) => void,
|
||||
) => {
|
||||
// 서버간의 통신(origin이 undefined)이거나 명시된 리스트일 때만 허용
|
||||
const whitelist = [frontendUrl, 'http://localhost:3000'];
|
||||
if (corsOriginEnv) whitelist.push(corsOriginEnv);
|
||||
|
||||
if (!origin || whitelist.indexOf(origin) !== -1) {
|
||||
// 서버간의 통신·앱(origin이 undefined)이거나 명시된 리스트일 때만 허용
|
||||
if (!origin || whitelist.has(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
// Error 를 넘기면 500(SYS_001)이 되어 원인 파악이 어려우므로,
|
||||
@@ -80,15 +85,19 @@ async function bootstrap() {
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
|
||||
|
||||
// 6. Swagger API 문서 설정
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Backend API Documentation')
|
||||
.setDescription('The API documentation conforming to the global guidelines')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
// 6. Swagger API 문서 — 개발·테스트에서만 연다 (운영에서 API 전체 구조를 공개하지 않는다)
|
||||
if (isDevEnv) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Backend API Documentation')
|
||||
.setDescription(
|
||||
'The API documentation conforming to the global guidelines',
|
||||
)
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
}
|
||||
|
||||
// 7. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
|
||||
// 처리 중인 요청을 마친 뒤 DB/Redis 커넥션을 정리하고 종료한다.
|
||||
@@ -100,10 +109,12 @@ async function bootstrap() {
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
|
||||
Logger.log(
|
||||
`📘 Swagger docs at http://localhost:${port}/api-docs`,
|
||||
'Bootstrap',
|
||||
);
|
||||
if (isDevEnv) {
|
||||
Logger.log(
|
||||
`📘 Swagger docs at http://localhost:${port}/api-docs`,
|
||||
'Bootstrap',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isDevNodeEnv } from '@/shared/config/node-env';
|
||||
import { parseDurationMs } from '@/shared/utils/duration';
|
||||
import { parseCommaList } from '@/shared/utils/env';
|
||||
|
||||
/**
|
||||
* 인증 설정 — backend/.env.{APP_ENV} 에서 읽는다 (루트에서 내려주지 않는 백엔드 고유 값).
|
||||
@@ -57,10 +58,7 @@ export function isDevLoginEnabled(env: Env = process.env): boolean {
|
||||
|
||||
/** 콤마로 나눈 목록 (빈 칸 제거) */
|
||||
function readList(env: Env, key: string): string[] {
|
||||
return (readEnv(env, key) ?? '')
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v !== '');
|
||||
return parseCommaList(readEnv(env, key));
|
||||
}
|
||||
|
||||
/** 소셜 제공자별 앱 정보 — 비어 있으면 그 제공자 로그인은 거절한다 */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { MulterModule } from '@nestjs/platform-express';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { diskStorage } from 'multer';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { accessSync, constants as fsConstants, mkdirSync } from 'fs';
|
||||
import { extname } from 'path';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
@@ -17,8 +17,16 @@ import { FileSweeper } from './file-sweeper';
|
||||
import { UploadController } from './upload.controller';
|
||||
import { UploadService } from './upload.service';
|
||||
|
||||
// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다)
|
||||
// 저장 디렉터리는 기동 시 한 번 보장한다 (없으면 multer 가 매 요청 실패한다).
|
||||
// 쓸 수 없으면(운영 서버에서 root 소유로 만들어진 호스트 경로 등) 첫 업로드에서야 500 이 나므로 기동을 막는다 — fail-fast
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
try {
|
||||
accessSync(UPLOAD_DIR, fsConstants.W_OK);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`업로드 디렉터리에 쓸 수 없습니다: ${UPLOAD_DIR} — 백엔드(uid 1000)가 쓸 수 있게 소유권을 맞춰 주세요 (.claude/skills/docker/SKILL.md "첫 배포 전")`,
|
||||
);
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
||||
@@ -80,7 +80,8 @@ export function buildDatabaseOptions(): DataSourceOptions {
|
||||
database: process.env.DB_NAME ?? DEV_DB_DEFAULTS.database,
|
||||
synchronize: shouldSynchronize(),
|
||||
migrationsRun: shouldRunMigrations(),
|
||||
logging: !isProduction,
|
||||
// 운영은 쿼리를 남기지 않는다(양·민감정보). 기동 때 적용한 마이그레이션만 남겨 배포 기록으로 쓴다
|
||||
logging: isProduction ? ['migration'] : true,
|
||||
// 컬럼은 snake_case, 엔티티 속성은 camelCase
|
||||
namingStrategy: new SnakeNamingStrategy(),
|
||||
extra: { ...POOL_OPTIONS },
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { parsePositiveInt } from './env';
|
||||
import { parseCommaList, parsePositiveInt } from './env';
|
||||
|
||||
describe('parseCommaList', () => {
|
||||
it('콤마로 나누고 앞뒤 공백을 지운다', () => {
|
||||
expect(
|
||||
parseCommaList('https://a.com, https://b.com ,https://c.com'),
|
||||
).toEqual(['https://a.com', 'https://b.com', 'https://c.com']);
|
||||
});
|
||||
|
||||
it('값이 없거나 빈 칸뿐이면 빈 목록이다 (compose 미정의 변수 대응)', () => {
|
||||
expect(parseCommaList(undefined)).toEqual([]);
|
||||
expect(parseCommaList('')).toEqual([]);
|
||||
expect(parseCommaList(' , ,')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePositiveInt', () => {
|
||||
it('정상적인 양의 정수 문자열을 숫자로 변환한다', () => {
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
* `Number('')` 는 0 이므로 `??` 만으로는 기본값이 적용되지 않는다 — 이 함수로 방어한다.
|
||||
*/
|
||||
|
||||
/** 콤마로 나눈 목록 — 앞뒤 공백과 빈 칸은 버린다 (값이 없으면 빈 목록) */
|
||||
export function parseCommaList(value: string | undefined): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경변수를 양의 정수로 파싱한다.
|
||||
* 값이 없거나(undefined·빈 문자열) 숫자가 아니거나 0 이하면 기본값을 반환한다.
|
||||
|
||||
@@ -49,6 +49,10 @@ services:
|
||||
ports:
|
||||
- "${BACKEND_PORT}:${BACKEND_PORT}"
|
||||
- "${WEBSOCKET_PORT}:${WEBSOCKET_PORT}"
|
||||
# dev 는 TypeORM 쿼리 로그가 켜져 있어 /api/health(DB ping)를 부르면 30초마다 SELECT 1 이 로그에 쌓인다
|
||||
# → 프로세스가 떠 있는지만 본다 (운영은 base 의 /api/health)
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test -w /app/uploads && wget -qO- http://127.0.0.1:${BACKEND_PORT}/ || exit 1"]
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- WATCHPACK_POLLING=true
|
||||
|
||||
+3
-1
@@ -88,8 +88,10 @@ services:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
healthcheck:
|
||||
# /api/health 는 DB 까지 확인한다 (루트 '/' 는 프로세스가 떠 있는지만 본다)
|
||||
# 업로드 디렉터리에 쓸 수 없으면(호스트 경로 소유권 문제) 사진 업로드가 전부 500 이 되므로 unhealthy 로 드러낸다
|
||||
# localhost 는 컨테이너 안에서 ::1 로 먼저 해석되는데 Node 는 0.0.0.0(IPv4) 바인딩이므로 127.0.0.1 을 명시한다
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${BACKEND_PORT}/ || exit 1"]
|
||||
test: ["CMD-SHELL", "test -w /app/uploads && wget -qO- http://127.0.0.1:${BACKEND_PORT}/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
+11
-14
@@ -1,17 +1,14 @@
|
||||
# tma_app
|
||||
# rup — 모바일 앱 (Flutter)
|
||||
|
||||
A new Flutter project.
|
||||
개발 규칙·구조·출시 빌드 절차는 [`.claude/skills/flutter/SKILL.md`](../.claude/skills/flutter/SKILL.md) 에 있다.
|
||||
|
||||
## Getting Started
|
||||
```bash
|
||||
cp .env.example .env # API_URL · DATA_MODE 확인
|
||||
flutter pub get
|
||||
flutter run # 개발 — 백엔드는 루트에서 docker compose 로 띄운다
|
||||
flutter analyze && flutter test
|
||||
```
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
출시 빌드는 서버 주소를 빌드 때 넘기고(`--dart-define=API_URL=https://<도메인>/api`),
|
||||
Android 는 `android/key.properties`(예시: `key.properties.example`)로 서명한다.
|
||||
운영 서버 전에 휴대폰에서 시험하는 방법도 스킬의 "출시 빌드"에 있다.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 서버와 통신 — 출시 빌드는 이 파일만 쓴다(debug·profile 쪽 선언은 개발용). 지금은 스캐너 라이브러리가 대신 넣어 주지만 기대지 않는다 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- 명함 이미지를 앨범에 저장 (Android 10 이하만 필요 — gal) -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
|
||||
<application
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
# 키 만들기 (한 번만 · 잃어버리면 같은 앱으로 업데이트를 올릴 수 없으니 안전한 곳에 백업):
|
||||
# keytool -genkey -v -keystore ~/rup-upload.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
|
||||
#
|
||||
# storeFile 은 android/app 기준 경로 또는 절대 경로
|
||||
# Windows PowerShell — keytool 이 PATH 에 없으면 Android Studio 에 들어 있는 것을 쓴다 (~ 는 펼쳐지지 않으니 USERPROFILE 로).
|
||||
# 한글 콘솔에서는 확인 질문에 "예"를 넣어도 인식되지 않아 처음부터 다시 묻는다 → -dname 으로 이름·확인 질문을 건너뛴다
|
||||
# (비밀번호만 묻는다. PowerShell 5.1 은 점이 든 -J-D… 옵션을 둘로 쪼개므로 붙이지 않는다):
|
||||
# & "$env:ProgramFiles\Android\Android Studio\jbr\bin\keytool.exe" -genkey -v -keystore "$env:USERPROFILE\rup-upload.jks" -keyalg RSA -keysize 2048 -validity 10000 -alias upload -dname "CN=rup"
|
||||
#
|
||||
# 키 비밀번호를 따로 묻지 않으면(PKCS12 기본) keyPassword 는 storePassword 와 같다.
|
||||
#
|
||||
# storeFile 은 android/app 기준 경로 또는 절대 경로. Windows 도 슬래시(/)로 쓴다 — 이 파일 형식에서 역슬래시는 이스케이프 문자다
|
||||
# 예) storeFile=C:/Users/<사용자>/rup-upload.jks
|
||||
storeFile=/path/to/rup-upload.jks
|
||||
storePassword=change-me-store-password
|
||||
keyAlias=upload
|
||||
|
||||
@@ -9,8 +9,14 @@ enum DataMode { api, local }
|
||||
/// `.env` 에 API_URL 이 없을 때 — 호스트에서 띄운 개발 서버 (백엔드 글로벌 prefix /api 포함)
|
||||
const String kDefaultApiUrl = 'http://localhost:3000/api';
|
||||
|
||||
/// `.env` 의 DATA_MODE (api | local). `.env` 를 읽지 않았으면(테스트) 기기 저장으로 둔다.
|
||||
/// 빌드할 때 넘긴 데이터 모드 — `--dart-define=DATA_MODE=api|local`.
|
||||
/// 빌드에는 개발자 PC 의 `.env` 가 그대로 묶이므로, 기기에 설치할 빌드는 이것으로 정해 둔다
|
||||
const String _kDefinedDataMode = String.fromEnvironment('DATA_MODE');
|
||||
|
||||
/// 데이터 모드 — 빌드 때 넘긴 값 → `.env` 의 DATA_MODE (api | local) 순.
|
||||
/// 둘 다 없고 `.env` 도 읽지 않았으면(테스트) 기기 저장으로 둔다.
|
||||
DataMode readDataMode() {
|
||||
if (_kDefinedDataMode.isNotEmpty) return _kDefinedDataMode == DataMode.local.name ? DataMode.local : DataMode.api;
|
||||
if (!dotenv.isInitialized) return DataMode.local;
|
||||
return dotenv.maybeGet('DATA_MODE') == DataMode.local.name ? DataMode.local : DataMode.api;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:tma_app/features/diary/presentation/pages/diary_compose_page.dar
|
||||
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
|
||||
import 'package:tma_app/features/diary/presentation/pages/diary_settings_page.dart';
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_join.dart';
|
||||
import 'package:tma_app/features/entry/domain/entry_state.dart';
|
||||
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
|
||||
import 'package:tma_app/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:tma_app/features/intro/presentation/pages/intro_page.dart';
|
||||
@@ -63,14 +64,26 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
// 탭바 위에 덮는 화면(등록 등)은 최상위 내비게이터에 올린다
|
||||
final rootKey = GlobalKey<NavigatorState>();
|
||||
|
||||
// 로그인 전에 받은 앱 링크 — 진입(인트로·로그인·온보딩)을 마치고 홈으로 갈 때 대신 연다.
|
||||
// 길 찾기 중에 바꾸므로 프로바이더 상태가 아니라 이 라우터만 쓰는 값으로 둔다
|
||||
String? pendingLink;
|
||||
|
||||
final router = GoRouter(
|
||||
navigatorKey: rootKey,
|
||||
initialLocation: AppRoutes.splash,
|
||||
refreshListenable: refresh,
|
||||
redirect: (context, state) => AppRoutes.redirectFor(
|
||||
ref.read(entryProvider).stage,
|
||||
state.matchedLocation,
|
||||
),
|
||||
redirect: (context, state) {
|
||||
final stage = ref.read(entryProvider).stage;
|
||||
final location = state.matchedLocation;
|
||||
final target = AppRoutes.redirectFor(stage, location);
|
||||
if (target != null && stage != EntryStage.app && isAppLinkPath(location)) pendingLink = location;
|
||||
if (stage == EntryStage.app && pendingLink != null && (target ?? location) == AppRoutes.home) {
|
||||
final link = pendingLink;
|
||||
pendingLink = null;
|
||||
return link;
|
||||
}
|
||||
return target;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.splash,
|
||||
|
||||
@@ -153,6 +153,9 @@ class _LinkLandingPageState extends ConsumerState<LinkLandingPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 앱 링크의 경로인지 — 로그인 전에 받은 링크를 기억할 때 (라우터가 쓴다)
|
||||
bool isAppLinkPath(String path) => AppLinkKind.values.any((k) => path.startsWith(appLinkPattern(k).replaceAll(':code', '')));
|
||||
|
||||
/// 링크의 경로 → 무엇을 열지 (라우터가 쓴다)
|
||||
String appLinkPattern(AppLinkKind kind) => switch (kind) {
|
||||
AppLinkKind.petCard => '/$kCardLinkSegment/:code',
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/app.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
import 'package:tma_app/core/config/app_links.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/features/auth/presentation/pages/login_page.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary_invitation.dart';
|
||||
import 'package:tma_app/features/intro/presentation/pages/intro_page.dart';
|
||||
import 'package:tma_app/features/pet_card/data/received_card_repository.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_style.dart';
|
||||
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
|
||||
@@ -168,4 +176,41 @@ void main() {
|
||||
expect(find.byType(ShareCalendarPage), findsOneWidget);
|
||||
expect(find.text('2/4'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('로그인 전에 받은 링크는 기억했다가 로그인을 마치면 연다', (tester) async {
|
||||
tester.view.physicalSize = const Size(402, 874);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
// 온보딩은 봤지만 로그인하지 않은 사람 — 인트로부터 시작한다
|
||||
SharedPreferences.setMockInitialValues({'entry.onboardingSeen': true});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cards = _CardRepo();
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
dataModeProvider.overrideWithValue(DataMode.local),
|
||||
receivedCardRepositoryProvider.overrideWithValue(cards),
|
||||
],
|
||||
child: const RupApp(),
|
||||
));
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 300)));
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await settle(tester);
|
||||
expect(find.byType(IntroPage), findsOneWidget);
|
||||
|
||||
// 기기 카메라로 찍은 명함 링크 — 로그인 전이라 인트로에 머문다
|
||||
await _openLink(tester, GoRouter.of(tester.element(find.byType(IntroPage))), petCardDeepLink('0428-117'));
|
||||
expect(find.byType(IntroPage), findsOneWidget);
|
||||
expect(cards.previewed, isEmpty);
|
||||
|
||||
await tester.tap(find.text('시작하기'));
|
||||
await settle(tester);
|
||||
expect(find.byType(LoginPage), findsOneWidget);
|
||||
await tester.tap(find.text('카카오로 3초만에 시작하기'));
|
||||
await settle(tester);
|
||||
await settle(tester);
|
||||
|
||||
expect(cards.previewed, ['0428-117']);
|
||||
expect(find.byType(ReceiveCardPage), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 빌드 컨텍스트에서 제외한다 — 이미지 안에서 npm ci · vite build 로 새로 만든다
|
||||
# (호스트에서 설치한 node_modules 의 네이티브 바이너리가 Linux 빌드를 깨뜨리지 않게)
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
Reference in New Issue
Block a user