Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2485feb0e4 | |||
| 19ee38a635 | |||
| 4f8e3f0b68 | |||
| e3ed0fa800 | |||
| a8861a0313 | |||
| 163cb16c6a | |||
| cffc5a5d06 | |||
| f60654bed6 | |||
| 0b978a9610 | |||
| be58689dff | |||
| 65c2016ab5 |
@@ -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 실행 (보안 규칙)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
# 빌드 타임 분리가 필요하면 --dart-define 또는 .env.{flavor} 도입 고려.
|
||||
# ==========================================
|
||||
|
||||
# 백엔드 API 베이스 URL (백엔드 글로벌 prefix /api 포함)
|
||||
# 안드로이드 에뮬레이터에서 호스트 접근 시: http://10.0.2.2:3000/api
|
||||
# 백엔드 API 베이스 URL (백엔드 글로벌 prefix /api 포함) — 개발용
|
||||
# 안드로이드 에뮬레이터에서 호스트 접근 시: http://10.0.2.2:3000/api (또는 adb reverse tcp:3000 tcp:3000 후 localhost)
|
||||
# 출시 빌드는 이 값 대신 --dart-define=API_URL=https://<도메인>/api 로 넘긴다 (https·공개 주소가 아니면 앱이 시작하지 않는다)
|
||||
API_URL=http://localhost:3000/api
|
||||
|
||||
# 기록을 어디에 둘지 — api: 서버(기본, 로그인한 회원의 기록) / local: 이 기기 (서버 없이 화면만 볼 때)
|
||||
|
||||
@@ -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,3 +1,6 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,8 +8,16 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// 출시 서명 키 — android/key.properties (커밋하지 않는다, 예시는 key.properties.example).
|
||||
// 파일이 없으면 디버그 키로 서명한다 — 스토어에는 올릴 수 없고, 기기에서 릴리스 빌드를 확인할 때만 쓴다.
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
val keystoreProperties = Properties().apply {
|
||||
if (keystorePropertiesFile.exists()) FileInputStream(keystorePropertiesFile).use { load(it) }
|
||||
}
|
||||
val hasReleaseKey = keystorePropertiesFile.exists()
|
||||
|
||||
android {
|
||||
namespace = "com.example.tma_app"
|
||||
namespace = "com.ttipo.rup"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@@ -20,21 +31,34 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.tma_app"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
// 스토어에 올린 뒤에는 바꿀 수 없다 (iOS 번들 ID 도 같은 값)
|
||||
applicationId = "com.ttipo.rup"
|
||||
// 버전은 pubspec.yaml 의 version(이름+빌드 번호)을 따른다
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseKey) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = if (hasReleaseKey) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
logger.warn("android/key.properties 가 없어 릴리스 빌드를 디버그 키로 서명합니다 — 스토어에 올릴 수 없습니다.")
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<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
|
||||
android:label="tma_app"
|
||||
android:label="rup"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.tma_app
|
||||
package com.ttipo.rup
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
@@ -1,12 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<!-- 앱이 뜨는 동안의 화면 — 첫 화면(스플래시)과 같은 노란 바탕만 둔다 -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item android:drawable="@color/launch_background" />
|
||||
</layer-list>
|
||||
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -1,12 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<!-- 앱이 뜨는 동안의 화면 — 첫 화면(스플래시)과 같은 노란 바탕만 둔다 -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item android:drawable="@color/launch_background" />
|
||||
</layer-list>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Android 12+ 시스템 스플래시 — 바탕을 첫 화면(스플래시)과 같은 노란색으로, 가운데는 앱 아이콘 -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/launch_background</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Android 12+ 시스템 스플래시 — 바탕을 첫 화면(스플래시)과 같은 노란색으로, 가운데는 앱 아이콘 -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/launch_background</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 적응형 아이콘 바탕 — 브랜드 주황 (flutter_launcher_icons 가 만든 값) -->
|
||||
<color name="ic_launcher_background">#FF7500</color>
|
||||
<!-- 앱이 뜨는 동안의 바탕 — 첫 화면(스플래시, AppColors.splashYellow)과 같은 색이라 깜빡임이 없다 -->
|
||||
<color name="launch_background">#FFDE84</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
# 출시 서명 키 설정 — 이 파일을 key.properties 로 복사해 값을 채운다 (key.properties 와 키 파일은 커밋하지 않는다).
|
||||
#
|
||||
# 키 만들기 (한 번만 · 잃어버리면 같은 앱으로 업데이트를 올릴 수 없으니 안전한 곳에 백업):
|
||||
# keytool -genkey -v -keystore ~/rup-upload.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
|
||||
#
|
||||
# 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
|
||||
keyPassword=change-me-key-password
|
||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -372,7 +372,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ttipo.rup;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -388,7 +388,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ttipo.rup.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -405,7 +405,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ttipo.rup.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -420,7 +420,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ttipo.rup.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -431,7 +431,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -488,7 +488,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -551,7 +551,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ttipo.rup;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -573,7 +573,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.tmaApp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ttipo.rup;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -1,122 +1 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 379 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 563 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -19,7 +19,7 @@
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color key="backgroundColor" red="1" green="0.87058823529411766" blue="0.51764705882352946" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Tma App</string>
|
||||
<string>rup</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -34,7 +34,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>tma_app</string>
|
||||
<string>rup</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -9,14 +9,40 @@ 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;
|
||||
}
|
||||
|
||||
/// `.env` 의 API_URL — 안드로이드 에뮬레이터에서 호스트에 붙을 때는 http://10.0.2.2:3000/api
|
||||
String readApiBaseUrl() => (dotenv.isInitialized ? dotenv.maybeGet('API_URL') : null) ?? kDefaultApiUrl;
|
||||
/// 빌드할 때 넘긴 API 주소 — `flutter build … --dart-define=API_URL=https://…/api` (출시 빌드는 이것을 쓴다)
|
||||
const String _kDefinedApiUrl = String.fromEnvironment('API_URL');
|
||||
|
||||
/// 서버 주소 — 빌드 때 넘긴 값 → `.env` 의 API_URL → 개발 서버 순.
|
||||
/// 안드로이드 에뮬레이터에서 호스트에 붙을 때는 http://10.0.2.2:3000/api (또는 adb reverse 로 localhost)
|
||||
String readApiBaseUrl() {
|
||||
if (_kDefinedApiUrl.isNotEmpty) return _kDefinedApiUrl;
|
||||
return (dotenv.isInitialized ? dotenv.maybeGet('API_URL') : null) ?? kDefaultApiUrl;
|
||||
}
|
||||
|
||||
/// 내 기기에서만 닿는 주소 — 출시 빌드가 여기를 가리키면 사용자 기기에서 서버에 닿지 않는다
|
||||
const Set<String> _kLocalHosts = {'localhost', '127.0.0.1', '10.0.2.2', '0.0.0.0'};
|
||||
|
||||
/// 출시 빌드의 서버 주소에 문제가 있으면 그 이유, 괜찮으면 null.
|
||||
/// 출시 빌드는 https 만 쓸 수 있고(Android·iOS 모두 평문 http 를 막는다) 개발용 주소를 가리키면 안 된다
|
||||
String? releaseApiUrlProblem(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || uri.host.isEmpty) return '서버 주소(API_URL)를 읽을 수 없습니다: $url';
|
||||
if (uri.scheme != 'https') return '출시 빌드의 서버 주소는 https 여야 합니다: $url';
|
||||
if (_kLocalHosts.contains(uri.host)) return '출시 빌드가 개발용 서버 주소를 가리킵니다: $url';
|
||||
return null;
|
||||
}
|
||||
|
||||
final dataModeProvider = Provider<DataMode>((ref) => readDataMode());
|
||||
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
@@ -20,6 +21,12 @@ Future<void> main() async {
|
||||
// .env 파일이 없는 경우 무시 (기본값 사용)
|
||||
}
|
||||
|
||||
// 출시 빌드가 개발 서버·평문 http 를 가리키면 바로 멈춘다 — 사용자 기기에서 조용히 모든 요청이 실패하는 것보다 낫다
|
||||
if (kReleaseMode && readDataMode() == DataMode.api) {
|
||||
final problem = releaseApiUrlProblem(readApiBaseUrl());
|
||||
if (problem != null) throw StateError(problem);
|
||||
}
|
||||
|
||||
// 시안은 세로 화면 전용이다
|
||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.1"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: "58c8d17d8d589c4259f773522815115de873c397b977a29993588487d3f9acb4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.0"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,6 +57,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -57,6 +73,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -222,6 +246,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.2.1"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -376,6 +408,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -464,6 +504,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -672,6 +720,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.2"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -58,6 +58,7 @@ dev_dependencies:
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
@@ -117,3 +118,15 @@ flutter:
|
||||
- family: UhBeeKeongKeong
|
||||
fonts:
|
||||
- asset: assets/fonts/UhBeeKeongKeong.ttf
|
||||
|
||||
# 앱 아이콘 — 원본은 branding/ (브랜드 주황 + 흰 워드마크, 1024px).
|
||||
# 원본을 바꾸면 `dart run flutter_launcher_icons` 로 Android·iOS 크기별 아이콘을 다시 만든다.
|
||||
flutter_launcher_icons:
|
||||
android: launcher_icon
|
||||
ios: true
|
||||
image_path: branding/app_icon.png
|
||||
remove_alpha_ios: true
|
||||
min_sdk_android: 23
|
||||
# Android 8+ 적응형 아이콘 — 바탕은 브랜드 주황, 앞은 안전 영역 안의 워드마크
|
||||
adaptive_icon_background: "#FF7500"
|
||||
adaptive_icon_foreground: branding/app_icon_foreground.png
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/config/app_config.dart';
|
||||
|
||||
void main() {
|
||||
group('releaseApiUrlProblem', () {
|
||||
test('공개 https 주소는 출시 빌드에 쓸 수 있다', () {
|
||||
expect(releaseApiUrlProblem('https://api.rup.app/api'), isNull);
|
||||
});
|
||||
|
||||
test('평문 http 는 출시 빌드에 쓸 수 없다 (기기가 막는다)', () {
|
||||
expect(releaseApiUrlProblem('http://api.rup.app/api'), contains('https'));
|
||||
});
|
||||
|
||||
test('개발용 주소는 출시 빌드에 쓸 수 없다', () {
|
||||
expect(releaseApiUrlProblem('https://localhost:3000/api'), contains('개발용'));
|
||||
expect(releaseApiUrlProblem('https://10.0.2.2/api'), contains('개발용'));
|
||||
});
|
||||
|
||||
test('읽을 수 없는 주소', () {
|
||||
expect(releaseApiUrlProblem('api'), isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('빌드 때 넘긴 값이 없고 .env 도 읽지 않았으면 개발 서버 주소', () {
|
||||
expect(readApiBaseUrl(), kDefaultApiUrl);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="tma_app">
|
||||
<meta name="apple-mobile-web-app-title" content="rup">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>tma_app</title>
|
||||
<title>rup</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tma_app",
|
||||
"short_name": "tma_app",
|
||||
"name": "rup",
|
||||
"short_name": "rup",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 빌드 컨텍스트에서 제외한다 — 이미지 안에서 npm ci · vite build 로 새로 만든다
|
||||
# (호스트에서 설치한 node_modules 의 네이티브 바이너리가 Linux 빌드를 깨뜨리지 않게)
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||